- v{update.version} available
+ {t('update.available', { version: update.version })}
{update.downloading ? (
@@ -91,7 +93,7 @@ export function UpdateChecker() {
style={{ width: `${Math.min(update.progress, 100)}%` }}
/>
-
Downloading...
+
{t('update.downloading')}
) : (
@@ -99,13 +101,13 @@ export function UpdateChecker() {
onClick={handleUpdate}
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
>
- Update now
+ {t('update.now')}
)}
diff --git a/desktop/src/components/tasks/DayOfWeekPicker.tsx b/desktop/src/components/tasks/DayOfWeekPicker.tsx
index 6a9ef7ea..577b8484 100644
--- a/desktop/src/components/tasks/DayOfWeekPicker.tsx
+++ b/desktop/src/components/tasks/DayOfWeekPicker.tsx
@@ -48,7 +48,7 @@ export function DayOfWeekPicker({ selected, onChange }: Props) {
}
`}
>
- {t(DAY_KEYS[day] as any)}
+ {t(DAY_KEYS[day]!)}
)
})}
diff --git a/desktop/src/components/tasks/TaskRunsPanel.tsx b/desktop/src/components/tasks/TaskRunsPanel.tsx
index 93685cf5..582e8c9d 100644
--- a/desktop/src/components/tasks/TaskRunsPanel.tsx
+++ b/desktop/src/components/tasks/TaskRunsPanel.tsx
@@ -141,7 +141,7 @@ export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
{/* Status text */}
- {t(`tasks.runStatus.${run.status}` as any)}
+ {t(`tasks.runStatus.${run.status}` as any)} {/* dynamic key */}
{/* Time */}
diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts
index 21f2afdd..2fc633b9 100644
--- a/desktop/src/hooks/useKeyboardShortcuts.ts
+++ b/desktop/src/hooks/useKeyboardShortcuts.ts
@@ -1,4 +1,4 @@
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
@@ -13,6 +13,13 @@ export function useKeyboardShortcuts() {
const activeTabId = useTabStore((s) => s.activeTabId)
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
+ const activeModalRef = useRef(activeModal)
+ activeModalRef.current = activeModal
+ const chatStateRef = useRef(chatState)
+ chatStateRef.current = chatState
+ const activeTabIdRef = useRef(activeTabId)
+ activeTabIdRef.current = activeTabId
+
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey
@@ -33,21 +40,21 @@ export function useKeyboardShortcuts() {
// Escape — Close modal or clear state
if (e.key === 'Escape') {
- if (activeModal) {
+ if (activeModalRef.current) {
closeModal()
}
}
// Cmd+. — Stop generation
if (meta && e.key === '.') {
- if (chatState !== 'idle' && activeTabId) {
+ if (chatStateRef.current !== 'idle' && activeTabIdRef.current) {
e.preventDefault()
- stopGeneration(activeTabId)
+ stopGeneration(activeTabIdRef.current)
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
- }, [activeModal, activeTabId, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
+ }, [closeModal, setActiveSession, setActiveView, stopGeneration])
}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index b639e840..1b222e73 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -516,6 +516,29 @@ export const en = {
'scheduledPage.connectedLocal': 'Connected to local node',
'scheduledPage.thisMonth': '{count} this month',
+ // ─── Update Checker ──────────────────────────────────────
+ 'update.available': 'v{version} available',
+ 'update.newVersion': 'New version v{version} available',
+ 'update.downloading': 'Downloading...',
+ 'update.now': 'Update now',
+ 'update.later': 'Later',
+ 'update.failed': 'Update failed: {error}',
+
+ // ─── Active Session ──────────────────────────────────────
+ 'session.untitled': 'Untitled Session',
+ 'session.active': 'session active',
+ 'session.lastUpdated': 'last updated {time}',
+ 'session.messages': '{count} messages',
+ 'session.workspaceUnavailable': 'Workspace unavailable: {dir}',
+ 'session.timeJustNow': 'just now',
+ 'session.timeMinutes': '{n}m ago',
+ 'session.timeHours': '{n}h ago',
+ 'session.timeDays': '{n}d ago',
+
+ // ─── App Shell ──────────────────────────────────────
+ 'app.serverFailed': 'Local server failed to start',
+ 'app.launching': 'Launching local workspace...',
+
// ─── Error Codes ──────────────────────────────────────
'error.CLI_NOT_RUNNING': 'CLI process is not running. The session may have ended or the process crashed.',
'error.CLI_START_FAILED': 'Failed to start CLI process.',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index e1b0e921..6c6f58a4 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -518,6 +518,29 @@ export const zh: Record
= {
'scheduledPage.connectedLocal': '已连接到本地节点',
'scheduledPage.thisMonth': '本月 {count}',
+ // ─── 更新检查 ──────────────────────────────────────
+ 'update.available': 'v{version} 可用',
+ 'update.newVersion': '新版本 v{version} 可用',
+ 'update.downloading': '下载中...',
+ 'update.now': '立即更新',
+ 'update.later': '稍后',
+ 'update.failed': '更新失败: {error}',
+
+ // ─── 活跃会话 ──────────────────────────────────────
+ 'session.untitled': '未命名会话',
+ 'session.active': '会话活跃中',
+ 'session.lastUpdated': '最后更新 {time}',
+ 'session.messages': '{count} 条消息',
+ 'session.workspaceUnavailable': '工作目录不可用: {dir}',
+ 'session.timeJustNow': '刚刚',
+ 'session.timeMinutes': '{n}分钟前',
+ 'session.timeHours': '{n}小时前',
+ 'session.timeDays': '{n}天前',
+
+ // ─── 应用外壳 ──────────────────────────────────────
+ 'app.serverFailed': '本地服务启动失败',
+ 'app.launching': '正在启动本地工作区...',
+
// ─── Error Codes ──────────────────────────────────────
'error.CLI_NOT_RUNNING': 'CLI 进程未运行。会话可能已结束或进程已崩溃。',
'error.CLI_START_FAILED': 'CLI 进程启动失败。',
diff --git a/desktop/src/lib/cronDescribe.ts b/desktop/src/lib/cronDescribe.ts
index 1365f6db..7383cf68 100644
--- a/desktop/src/lib/cronDescribe.ts
+++ b/desktop/src/lib/cronDescribe.ts
@@ -3,8 +3,9 @@
* Works with standard 5-field cron: minute hour day-of-month month day-of-week
*/
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type TFunc = (key: any, params?: Record) => string
+import type { TranslationKey } from '../i18n'
+
+type TFunc = (key: TranslationKey, params?: Record) => string
function pad(n: number): string {
return n.toString().padStart(2, '0')
@@ -27,7 +28,7 @@ function describeDow(field: string, t: TFunc): string {
days.push(parseInt(part))
}
}
- return days.map((d) => t(`cron.dow.${d % 7}`)).join(', ')
+ return days.map((d) => t(`cron.dow.${d % 7}` as any)).join(', ') // dynamic key
}
export function describeCron(cron: string, t: TFunc): string {
diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx
index 3aeb1eeb..6b2907d9 100644
--- a/desktop/src/pages/ActiveSession.tsx
+++ b/desktop/src/pages/ActiveSession.tsx
@@ -65,11 +65,11 @@ export function ActiveSession() {
const lastUpdated = useMemo(() => {
if (!session?.modifiedAt) return ''
const diff = Date.now() - new Date(session.modifiedAt).getTime()
- if (diff < 60000) return 'just now'
- if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
- if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
- return `${Math.floor(diff / 86400000)}d ago`
- }, [session?.modifiedAt])
+ if (diff < 60000) return t('session.timeJustNow')
+ if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
+ if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
+ return t('session.timeDays', { n: Math.floor(diff / 86400000) })
+ }, [session?.modifiedAt, t])
if (!activeTabId) return null
@@ -94,13 +94,13 @@ export function ActiveSession() {
- {session?.title || 'Untitled Session'}
+ {session?.title || t('session.untitled')}
{isActive && (
- session active
+ {t('session.active')}
)}
{totalTokens > 0 && (
@@ -112,13 +112,13 @@ export function ActiveSession() {
{lastUpdated && (
<>
·
- last updated {lastUpdated}
+ {t('session.lastUpdated', { time: lastUpdated })}
>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
·
- {session.messageCount} messages
+ {t('session.messages', { count: session.messageCount })}
>
)}
@@ -126,7 +126,7 @@ export function ActiveSession() {
warning
- Workspace unavailable: {session.workDir || 'directory no longer exists'}
+ {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
)}
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 1c2094f8..5dbc627f 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -86,6 +86,10 @@ const pendingTaskToolUseIds = new Set
()
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
+// Streaming throttle for content_delta
+let pendingDelta = ''
+let flushTimer: ReturnType | null = null
+
/** Helper: immutably update a specific session within the sessions record */
function updateSessionIn(
sessions: Record,
@@ -144,6 +148,12 @@ export const useChatStore = create((set, get) => ({
disconnectSession: (sessionId) => {
const session = get().sessions[sessionId]
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
+ if (pendingDelta) {
+ const text = pendingDelta
+ pendingDelta = ''
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
+ }
wsManager.disconnect(sessionId)
set((s) => {
const { [sessionId]: _, ...rest } = s.sessions
@@ -225,6 +235,12 @@ export const useChatStore = create((set, get) => ({
stopGeneration: (sessionId) => {
wsManager.send(sessionId, { type: 'stop_generation' })
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
+ if (pendingDelta) {
+ const text = pendingDelta
+ pendingDelta = ''
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
+ }
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
@@ -319,7 +335,17 @@ export const useChatStore = create((set, get) => ({
}
case 'content_delta':
- if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
+ if (msg.text !== undefined) {
+ pendingDelta += msg.text
+ if (!flushTimer) {
+ flushTimer = setTimeout(() => {
+ const text = pendingDelta
+ pendingDelta = ''
+ flushTimer = null
+ update((s) => ({ streamingText: s.streamingText + text }))
+ }, 50)
+ }
+ }
if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
break
diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts
index 27b3ee93..fb02d73a 100644
--- a/desktop/src/stores/providerStore.ts
+++ b/desktop/src/stores/providerStore.ts
@@ -14,6 +14,7 @@ type ProviderStore = {
providers: SavedProvider[]
activeId: string | null
isLoading: boolean
+ error: string | null
fetchProviders: () => Promise
createProvider: (input: CreateProviderInput) => Promise
@@ -29,14 +30,15 @@ export const useProviderStore = create((set, get) => ({
providers: [],
activeId: null,
isLoading: false,
+ error: null,
fetchProviders: async () => {
- set({ isLoading: true })
+ set({ isLoading: true, error: null })
try {
const { providers, activeId } = await providersApi.list()
set({ providers, activeId, isLoading: false })
- } catch {
- set({ isLoading: false })
+ } catch (err) {
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
}
},
diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts
index 950cc894..b8831a96 100644
--- a/desktop/src/stores/settingsStore.ts
+++ b/desktop/src/stores/settingsStore.ts
@@ -31,7 +31,7 @@ type SettingsStore = {
setLocale: (locale: Locale) => void
}
-export const useSettingsStore = create((set) => ({
+export const useSettingsStore = create((set, get) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'high',
@@ -68,8 +68,13 @@ export const useSettingsStore = create((set) => ({
},
setPermissionMode: async (mode) => {
+ const prev = get().permissionMode
set({ permissionMode: mode })
- await settingsApi.setPermissionMode(mode)
+ try {
+ await settingsApi.setPermissionMode(mode)
+ } catch {
+ set({ permissionMode: prev })
+ }
},
setModel: async (modelId) => {
@@ -79,8 +84,13 @@ export const useSettingsStore = create((set) => ({
},
setEffort: async (level) => {
+ const prev = get().effortLevel
set({ effortLevel: level })
- await modelsApi.setEffort(level)
+ try {
+ await modelsApi.setEffort(level)
+ } catch {
+ set({ effortLevel: prev })
+ }
},
setLocale: (locale) => {
diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts
index 437e1d45..3ad9a04f 100644
--- a/desktop/src/stores/teamStore.ts
+++ b/desktop/src/stores/teamStore.ts
@@ -10,6 +10,7 @@ type TeamStore = {
viewingAgentId: string | null
agentTranscript: TranscriptMessage[]
memberColors: Map
+ error: string | null
fetchTeams: () => Promise
fetchTeamDetail: (name: string) => Promise
@@ -29,17 +30,20 @@ export const useTeamStore = create((set, get) => ({
viewingAgentId: null,
agentTranscript: [],
memberColors: new Map(),
+ error: null,
fetchTeams: async () => {
+ set({ error: null })
try {
const { teams } = await teamsApi.list()
set({ teams })
- } catch {
- // ignore
+ } catch (err) {
+ set({ error: err instanceof Error ? err.message : String(err) })
}
},
fetchTeamDetail: async (name: string) => {
+ set({ error: null })
try {
const detail = await teamsApi.get(name)
// Assign colors to members
@@ -48,17 +52,18 @@ export const useTeamStore = create((set, get) => ({
colors.set(m.agentId, AGENT_COLORS[i % AGENT_COLORS.length]!)
})
set({ activeTeam: detail, memberColors: colors })
- } catch {
- // ignore
+ } catch (err) {
+ set({ error: err instanceof Error ? err.message : String(err) })
}
},
fetchMemberTranscript: async (teamName: string, agentId: string) => {
+ set({ error: null })
try {
const { messages } = await teamsApi.getMemberTranscript(teamName, agentId)
set({ agentTranscript: messages, viewingAgentId: agentId })
- } catch {
- // ignore
+ } catch (err) {
+ set({ error: err instanceof Error ? err.message : String(err) })
}
},
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index ca2558aa..19abb51d 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -78,6 +78,17 @@ const zhSidebar = [
{ text: '架构解析', link: '/features/computer-use-architecture' },
],
},
+ {
+ text: '桌面端',
+ collapsed: false,
+ items: [
+ { text: '概览', link: '/desktop/' },
+ { text: '快速上手', link: '/desktop/01-quick-start' },
+ { text: '架构设计', link: '/desktop/02-architecture' },
+ { text: '功能详解', link: '/desktop/03-features' },
+ { text: '安装与构建', link: '/desktop/04-installation' },
+ ],
+ },
{
text: '参考',
collapsed: true,
@@ -143,6 +154,17 @@ const enSidebar = [
{ text: 'Architecture', link: '/en/features/computer-use-architecture' },
],
},
+ {
+ text: 'Desktop',
+ collapsed: false,
+ items: [
+ { text: 'Overview', link: '/en/desktop/' },
+ { text: 'Quick Start', link: '/en/desktop/01-quick-start' },
+ { text: 'Architecture', link: '/en/desktop/02-architecture' },
+ { text: 'Features', link: '/en/desktop/03-features' },
+ { text: 'Installation & Build', link: '/en/desktop/04-installation' },
+ ],
+ },
{
text: 'Reference',
collapsed: true,
diff --git a/docs/desktop/02-architecture.md b/docs/desktop/02-architecture.md
index 84a03a5c..7cbdd096 100644
--- a/docs/desktop/02-architecture.md
+++ b/docs/desktop/02-architecture.md
@@ -770,8 +770,7 @@ desktop/
│ ├── Cargo.toml # Rust 依赖
│ └── tauri.conf.json # Tauri 配置
├── sidecars/ # Sidecar 启动器
-│ ├── server-launcher.ts # Server 进程启动
-│ └── cli-launcher.ts # CLI 进程启动
+│ └── claude-sidecar.ts # server/cli/adapters 三种模式
├── scripts/
│ └── build-sidecars.ts # 跨平台编译脚本
├── vite.config.ts # Vite 构建配置
@@ -819,17 +818,17 @@ adapters/ # 外部 IM 适配器
```bash
# 前端开发
-cd desktop && pnpm dev
+cd desktop && bun run dev
# 编译 Sidecar
cd desktop && bun run build:sidecars
# Tauri 开发模式(前端 + 桌面框架)
-cd desktop && pnpm tauri dev
+cd desktop && bunx tauri dev
# 构建发布包
-cd desktop && pnpm tauri build
+cd desktop && bunx tauri build
# 运行测试
-cd desktop && pnpm test
+cd desktop && bun run test
```
diff --git a/docs/desktop/03-features.md b/docs/desktop/03-features.md
index c88ade5f..4e333119 100644
--- a/docs/desktop/03-features.md
+++ b/docs/desktop/03-features.md
@@ -253,6 +253,10 @@
- 支持表格、列表、引用、链接
- 内联代码和粗体/斜体
+### Mermaid 图表渲染
+
+支持 Mermaid 图表实时渲染(`MermaidRenderer` 组件),使用 `securityLevel: 'strict'` 安全模式。支持流程图、时序图、甘特图、类图等所有 Mermaid 图表类型。渲染失败时自动回退显示源代码。
+
---
## 四、Agent Teams 团队协作
@@ -405,6 +409,10 @@
| projectSettings | 项目级自定义 |
| localSettings | 本地自定义 |
+### 关于页面
+
+设置页新增「关于」标签页(About),展示应用名称、版本号、GitHub 仓库链接、作者信息及社交链接。macOS 菜单栏的「关于 Claude Code Haha」菜单项会直接导航到此页面,而非弹出系统默认的关于弹窗。
+
---
## 七、定时任务系统
diff --git a/src/constants/keys.ts b/src/constants/keys.ts
index 773fdd14..5971afa1 100644
--- a/src/constants/keys.ts
+++ b/src/constants/keys.ts
@@ -5,7 +5,7 @@ import { isEnvTruthy } from '../utils/envUtils.js'
export function getGrowthBookClientKey(): string {
return process.env.USER_TYPE === 'ant'
? isEnvTruthy(process.env.ENABLE_GROWTHBOOK_DEV)
- ? 'sdk-yZQvlplybuXjYh6L'
- : 'sdk-xRVcrliHIlrg4og4'
- : 'sdk-zAZezfDKGoZuXXKe'
+ ? ''
+ : ''
+ : ''
}
diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts
index dea45f60..31655950 100644
--- a/src/server/__tests__/providers-real.test.ts
+++ b/src/server/__tests__/providers-real.test.ts
@@ -28,7 +28,7 @@ describe('Real Provider Configs', () => {
const minimax = await service.addProvider({
name: 'MiniMax',
baseUrl: 'https://api.minimaxi.com/anthropic',
- apiKey: 'sk-api-aijgzQKtr1vLPS1KaoySY7_TRGTXCiQTCvgmoLOG31eyMfnrTBOLdtsEd_fFGAghPY_9Tlxt_jPc6bs5bziApZoEIuGq6bAERlKd-XHebifv44HMxLHDvjQ',
+ apiKey: 'sk-fake-test-key-for-testing-only',
models: [
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', description: 'MiniMax 高速模型' },
],
@@ -66,7 +66,7 @@ describe('Real Provider Configs', () => {
const jiekou = await service.addProvider({
name: '接口AI中转站',
baseUrl: 'https://api.jiekou.ai/anthropic',
- apiKey: 'sk_WGYwseQ5YZ1Kb6tXbqnd-AXIAhlqFnYLfUt2gSF-vjQ',
+ apiKey: 'sk-fake-test-key-for-testing-only',
models: [
{ id: 'claude-opus-4-6', name: 'Opus 4.6', description: 'Most capable', context: '200k' },
{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: 'Most efficient', context: '200k' },
diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts
index 2e387d62..94b51b05 100644
--- a/src/server/api/filesystem.ts
+++ b/src/server/api/filesystem.ts
@@ -5,6 +5,7 @@
import * as path from 'path'
import * as fs from 'fs'
+import * as os from 'os'
const IMAGE_MIME_TYPES: Record = {
'.png': 'image/png',
@@ -37,6 +38,13 @@ async function handleServeFile(url: URL): Promise {
}
const resolvedPath = path.resolve(filePath)
+
+ // Path whitelist: only allow access under home directory or /tmp
+ const homeDir = os.homedir()
+ if (!resolvedPath.startsWith(homeDir) && !resolvedPath.startsWith('/tmp')) {
+ return json({ error: 'Access denied: path outside allowed directory' }, 403)
+ }
+
const ext = path.extname(resolvedPath).toLowerCase()
const mimeType = IMAGE_MIME_TYPES[ext]
@@ -71,6 +79,13 @@ async function handleServeFile(url: URL): Promise {
async function handleBrowse(url: URL): Promise {
const targetPath = url.searchParams.get('path') || process.env.HOME || '/'
const resolvedPath = path.resolve(targetPath)
+
+ // Path whitelist: only allow browsing under home directory or /tmp
+ const homeDir = os.homedir()
+ if (!resolvedPath.startsWith(homeDir) && !resolvedPath.startsWith('/tmp')) {
+ return json({ error: 'Access denied: path outside allowed directory' }, 403)
+ }
+
const searchQuery = url.searchParams.get('search') || ''
const includeFiles = url.searchParams.get('includeFiles') === 'true'
const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200)
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts
index 00bf9c23..fdabcf7f 100644
--- a/src/server/services/conversationService.ts
+++ b/src/server/services/conversationService.ts
@@ -335,7 +335,7 @@ export class ConversationService {
const msg = JSON.parse(line)
session.sdkMessages.push(msg)
if (session.sdkMessages.length > 40) {
- session.sdkMessages.shift()
+ session.sdkMessages.splice(0, 20)
}
if (
msg?.type === 'control_request' &&
@@ -404,7 +404,7 @@ export class ConversationService {
.filter(Boolean)) {
session.stderrLines.push(line)
if (session.stderrLines.length > 20) {
- session.stderrLines.shift()
+ session.stderrLines.splice(0, 10)
}
}
}
diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts
index aa29324f..332d0c04 100644
--- a/src/server/services/cronScheduler.ts
+++ b/src/server/services/cronScheduler.ts
@@ -8,6 +8,7 @@
*/
import * as fs from 'fs/promises'
+import { existsSync, statSync } from 'node:fs'
import * as path from 'path'
import * as os from 'os'
import * as crypto from 'crypto'
@@ -357,7 +358,11 @@ export class CronScheduler {
const runId = crypto.randomBytes(6).toString('hex')
const startedAt = new Date().toISOString()
- const workDir = task.folderPath || os.homedir()
+ let workDir = task.folderPath || os.homedir()
+ if (task.folderPath && (!existsSync(task.folderPath) || !statSync(task.folderPath).isDirectory())) {
+ console.warn(`[cron] task ${task.id}: folderPath "${task.folderPath}" is not a valid directory, falling back to homedir`)
+ workDir = os.homedir()
+ }
// Only create a session when explicitly requested (manual "Run Now"),
// not for automatic cron runs — avoids flooding the sidebar.
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 1076955d..6975e215 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -26,6 +26,12 @@ const providerService = new ProviderService()
*/
const sessionSlashCommands = new Map>()
+/**
+ * Timers for delayed session cleanup after client disconnect.
+ * If a client reconnects within 5 minutes, the timer is cancelled.
+ */
+const sessionCleanupTimers = new Map>()
+
/**
* Track user message count and title state per session for auto-title generation.
*/
@@ -70,6 +76,13 @@ export const handleWebSocket = {
console.log(`[WS] Client connected for session: ${sessionId}`)
+ // Cancel pending cleanup timer if client reconnects
+ const pendingTimer = sessionCleanupTimers.get(sessionId)
+ if (pendingTimer) {
+ clearTimeout(pendingTimer)
+ sessionCleanupTimers.delete(sessionId)
+ }
+
activeSessions.set(sessionId, ws)
const msg: ServerMessage = { type: 'connected', sessionId }
@@ -134,9 +147,16 @@ export const handleWebSocket = {
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
- // NOTE: Do NOT stop CLI subprocess on WS disconnect.
- // The CLI process should stay alive so reconnecting reuses it.
- // This prevents "Session ID already in use" errors from stale locks.
+ // Schedule delayed cleanup: if the client doesn't reconnect within 5 minutes,
+ // stop the CLI subprocess to avoid leaking resources.
+ const cleanupTimer = setTimeout(() => {
+ sessionCleanupTimers.delete(sessionId)
+ if (!activeSessions.has(sessionId)) {
+ console.log(`[WS] Session ${sessionId} not reconnected after 5 min, stopping CLI subprocess`)
+ conversationService.stopSession(sessionId)
+ }
+ }, 5 * 60 * 1000)
+ sessionCleanupTimers.set(sessionId, cleanupTimer)
},
drain(ws: ServerWebSocket) {
diff --git a/src/services/analytics/datadog.ts b/src/services/analytics/datadog.ts
index 2f8bdf31..1d0e457e 100644
--- a/src/services/analytics/datadog.ts
+++ b/src/services/analytics/datadog.ts
@@ -11,7 +11,7 @@ import { getEventMetadata } from './metadata.js'
const DATADOG_LOGS_ENDPOINT =
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
-const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf'
+const DATADOG_CLIENT_TOKEN = ''
const DEFAULT_FLUSH_INTERVAL_MS = 15000
const MAX_BATCH_SIZE = 100
const NETWORK_TIMEOUT_MS = 5000
@@ -128,7 +128,7 @@ function scheduleFlush(): void {
}
export const initializeDatadog = memoize(async (): Promise => {
- if (isAnalyticsDisabled()) {
+ if (!DATADOG_CLIENT_TOKEN || isAnalyticsDisabled()) {
datadogInitialized = false
return false
}