agentquad 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. package/src/wiki/sources.js +122 -0
@@ -0,0 +1,1228 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { Router } from 'express'
4
+ import { writeFile, mkdir } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import pidusage from 'pidusage'
7
+ import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
8
+
9
+ const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
10
+ const CLEANUP_MS = 30 * 60_000
11
+ const MIN_RESIZE_COLS = 30
12
+ // PTY 实际使用的 cols 下限。低于这个值的 viewer(例如默认 480px Dock、手机竖屏)
13
+ // 不会真的把 PTY 拉窄,而是让 PTY 留在 80 cols 输出,xterm 端做软折行。
14
+ // 为什么:Claude 的 TUI/diff 输出包含按 cols 计算好坐标的字符画,一旦 PTY 写下窄行
15
+ // 就以 \r\n 形式硬刻进 outputHistory,replay 到更宽的 viewer 仍然窄。
16
+ const MIN_PTY_COLS = 80
17
+ const LIVE_AI_STATUSES = new Set(['running', 'idle', 'pending_confirm'])
18
+ const TERMINAL_RESIZE_STATUSES = new Set(['done', 'failed', 'stopped'])
19
+ // 防御:claude end_turn 之后那几帧 TUI redraw 不计为"新一轮的活动"
20
+ const EFFECTIVE_STATUS_OUTPUT_GRACE_MS = 500
21
+ // 用户按打断键(Ctrl+C / Esc)后到再次轮询 lastOutputAt 的等待时间。
22
+ // 取 1500ms:长于 EFFECTIVE_STATUS_OUTPUT_GRACE_MS(500ms),盖住 Claude/Codex 收尾打印
23
+ // 那条 "Interrupted by user" 之类的 echo;又短于 stop hook 的常见到达延迟,保证 UI 翻状态
24
+ // 比 hook 早。
25
+ const INTERRUPT_GRACE_MS = 1500
26
+ // "停顿够久才视为真 idle":等 INTERRUPT_GRACE_MS 之后再看,如果距上次 PTY 输出 ≥ 这个值,
27
+ // 才相信 agent 已经停了。低于此值 → 还在喷收尾文本 → 再等一轮。
28
+ const INTERRUPT_QUIET_MS = 800
29
+ // 兜底的最大重试次数:避免遇到一直喷输出的奇怪 agent 时无限挂钩;超过后让 stop hook /
30
+ // jsonl watcher 继续接力,本地静默放弃。
31
+ const INTERRUPT_MAX_RETRIES = 3
32
+
33
+ /**
34
+ * 计算前端展示用的 effectiveStatus —— 用 PTY 输出活性兜底"hook/watcher 判定结束错了"的边界。
35
+ *
36
+ * - status === 'pending_confirm' 原样返回:这是后端用 PTY 输出 (confirm pattern)
37
+ * 主动设上的等待授权态,PTY 最近一定有输出(就是那条提示本身),不能再当成
38
+ * "stale pending" 升级成 running,否则前端展示会把"待确认"误报成"running"。
39
+ * - 其它 LIVE session(running / idle)且 lastOutputAt 晚于 lastTurnDoneAt + 500ms
40
+ * → PTY 还在喷新内容、所谓的"turn done"是假的 → 强制 running。
41
+ * - 其余情况返回 session.status 自身。
42
+ *
43
+ * 与 src/openclaw-hook.js 里的 stop_reason 校验门并存:双层防御,互不依赖。
44
+ */
45
+ export function computeEffectiveStatus(session, now = Date.now()) {
46
+ if (!session || typeof session !== 'object') return null
47
+ const status = session.status
48
+ if (!LIVE_AI_STATUSES.has(status)) return status
49
+ if (status === 'pending_confirm') return status
50
+ const lastOutputAt = Number(session.lastOutputAt || 0)
51
+ const lastTurnDoneAt = Number(session.lastTurnDoneAt || 0)
52
+ if (!lastOutputAt) return status
53
+ if (lastOutputAt > lastTurnDoneAt + EFFECTIVE_STATUS_OUTPUT_GRACE_MS) return 'running'
54
+ return status
55
+ }
56
+ function isValidResizeSize(cols, rows) {
57
+ return Number.isFinite(cols) && Number.isFinite(rows) && cols >= MIN_RESIZE_COLS && rows > 0
58
+ }
59
+
60
+ function clampPtyCols(cols) {
61
+ return cols < MIN_PTY_COLS ? MIN_PTY_COLS : cols
62
+ }
63
+
64
+ function canResizeSession(session) {
65
+ return session && !TERMINAL_RESIZE_STATUSES.has(session.status)
66
+ }
67
+
68
+ // 在 spawn PTY 前先确认工具确实在 PATH(或显式 bin 路径)里。
69
+ // 比起让 node-pty 抛 ENOENT,这里返回结构化的 tool_missing → 路由层映射成 HTTP 424,
70
+ // CLI/前端可以直接展示「跑 agentquad install-tools --claude」修复指引。
71
+ function checkToolAvailable(tool, cfg) {
72
+ const tools = resolveToolsConfig(cfg?.tools || {})
73
+ const bin = tools?.[tool]?.bin || tools?.[tool]?.command || tool
74
+ const r = spawnSync('command', ['-v', bin], { encoding: 'utf8', shell: '/bin/sh' })
75
+ return {
76
+ ok: r.status === 0 && r.stdout.trim().length > 0,
77
+ bin,
78
+ resolvedPath: r.stdout.trim() || null,
79
+ }
80
+ }
81
+
82
+ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, onSessionSpawned = null, onSessionEnded = null, rootDir = DEFAULT_ROOT_DIR }) {
83
+ /** @type {Map<string, any>} */
84
+ const sessions = new Map()
85
+ /** @type {Map<string, string>} */
86
+ const todoSessionMap = new Map()
87
+ /** @type {Map<string, string>} */
88
+ const nativeSessionMap = new Map()
89
+
90
+ function resolveSessionCwd(requestedCwd) {
91
+ const fallback = getDefaultCwd?.() || defaultCwd || process.env.HOME || process.cwd()
92
+ if (requestedCwd && existsSync(requestedCwd)) return requestedCwd
93
+ if (fallback && existsSync(fallback)) return fallback
94
+ return process.env.HOME || process.cwd()
95
+ }
96
+
97
+ function mergeTodoAiSessions(todo, nextSession) {
98
+ const history = Array.isArray(todo?.aiSessions) ? todo.aiSessions : (todo?.aiSession ? [todo.aiSession] : [])
99
+ const filtered = history.filter((item) => {
100
+ if (!item) return false
101
+ if (item.sessionId === nextSession.sessionId) return false
102
+ if (nextSession.nativeSessionId && item.tool === nextSession.tool && item.nativeSessionId === nextSession.nativeSessionId) {
103
+ return false
104
+ }
105
+ return true
106
+ })
107
+ return [nextSession, ...filtered]
108
+ }
109
+
110
+ function replaceTodoAiSessionInPlace(todo, nextSession) {
111
+ const history = Array.isArray(todo?.aiSessions) ? todo.aiSessions : (todo?.aiSession ? [todo.aiSession] : [])
112
+ const index = history.findIndex(item => item?.sessionId === nextSession.sessionId)
113
+ if (index === -1) return [...history, nextSession]
114
+ return history.map((item, i) => i === index ? nextSession : item)
115
+ }
116
+
117
+ function broadcastToSession(session, msg) {
118
+ const data = JSON.stringify(msg)
119
+ for (const ws of session.browsers) {
120
+ if (ws.readyState === ws.OPEN) ws.send(data)
121
+ }
122
+ }
123
+
124
+ function persistLiveSessionState(session, status, todoStatus, extra = {}) {
125
+ const todo = db.getTodo(session.todoId)
126
+ if (!todo) return
127
+ const current = (todo.aiSessions || []).find(item => item.sessionId === session.sessionId) || todo.aiSession || {}
128
+ db.updateTodo(session.todoId, {
129
+ status: todoStatus,
130
+ aiSessions: mergeTodoAiSessions(todo, {
131
+ ...current,
132
+ ...extra,
133
+ sessionId: session.sessionId,
134
+ tool: session.tool,
135
+ nativeSessionId: session.nativeSessionId || current.nativeSessionId || null,
136
+ cwd: session.cwd || current.cwd || null,
137
+ status,
138
+ startedAt: session.startedAt,
139
+ completedAt: null,
140
+ prompt: session.prompt,
141
+ }),
142
+ })
143
+ }
144
+
145
+ function markSessionIdleAfterTurn(session, ts) {
146
+ if (!session || session.status !== 'running') return false
147
+ session.status = 'idle'
148
+ session.awaitingReply = true
149
+ session.recentOutput = ''
150
+ // 一旦走到 idle,不管来源是 Stop hook / jsonl watcher / 还是用户打断键调度器,
151
+ // 都要清掉打断 timer:否则 timer 之后还可能再次触发 turn_done 广播。
152
+ clearInterruptTimer(session)
153
+ persistLiveSessionState(session, 'idle', 'ai_done', { lastTurnDoneAt: ts })
154
+ return true
155
+ }
156
+
157
+ function markSessionRunningAfterInput(session) {
158
+ if (!session || !LIVE_AI_STATUSES.has(session.status)) return false
159
+ const wasPending = session.status === 'pending_confirm'
160
+ if (session.status === 'running' && session.awaitingReply === false) return false
161
+ session.status = 'running'
162
+ session.awaitingReply = false
163
+ if (wasPending) session.recentOutput = ''
164
+ persistLiveSessionState(session, 'running', 'ai_running')
165
+ if (wasPending) broadcastToSession(session, { type: 'pending_cleared' })
166
+ return true
167
+ }
168
+
169
+ // 由 hook 路径调用:Claude Code 的 Notification + permissionish,或 codex-prompt-detector
170
+ // 命中真实的工具授权弹窗时,这里把 session.status 翻成 'pending_confirm',让
171
+ // /api/ai-terminal/sessions 立刻反映"等授权"状态。
172
+ //
173
+ // 跟旧的 PTY 正则路径相比,区别是:信号源是 agent 本身(Claude Code hook / Codex sidecar),
174
+ // 不会因为 AI 回复文本里出现"Do you want to..."等关键词被误触发。
175
+ //
176
+ // 幂等:已经处于 pending_confirm 直接返回 true;非 LIVE_AI_STATUSES(已 done/failed/stopped)
177
+ // 返回 false 不动状态。
178
+ function markPendingConfirm(sessionId, { source = null } = {}) {
179
+ const session = sessions.get(sessionId)
180
+ if (!session) return false
181
+ if (!LIVE_AI_STATUSES.has(session.status)) return false
182
+ if (session.status === 'pending_confirm') return true
183
+ session.status = 'pending_confirm'
184
+ const todo = db.getTodo(session.todoId)
185
+ if (todo) {
186
+ const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession || {}
187
+ db.updateTodo(session.todoId, {
188
+ status: 'ai_pending',
189
+ aiSessions: mergeTodoAiSessions(todo, {
190
+ ...current,
191
+ sessionId: session.sessionId,
192
+ tool: session.tool,
193
+ nativeSessionId: session.nativeSessionId || current.nativeSessionId || null,
194
+ status: 'pending_confirm',
195
+ startedAt: session.startedAt,
196
+ completedAt: null,
197
+ prompt: session.prompt,
198
+ }),
199
+ })
200
+ }
201
+ broadcastToSession(session, {
202
+ type: 'pending_confirm',
203
+ snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
204
+ source: source || 'hook',
205
+ })
206
+ return true
207
+ }
208
+
209
+ function notifyTurnDone(sessionId, payload = {}) {
210
+ const session = sessions.get(sessionId)
211
+ if (!session) return false
212
+ const ts = payload.timestamp || Date.now()
213
+ session.lastTurnDoneAt = ts
214
+ const markedIdle = markSessionIdleAfterTurn(session, ts)
215
+ // 持久化到 todo.aiSessions[i].lastTurnDoneAt:即使 server 重启或浏览器关掉,
216
+ // 客户端再开仍能根据 lastTurnDoneAt > 本地 lastSeenAt 判断未读。
217
+ try {
218
+ const todo = db.getTodo(session.todoId)
219
+ if (todo) {
220
+ const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession
221
+ if (current && !markedIdle) {
222
+ db.updateTodo(session.todoId, {
223
+ aiSessions: mergeTodoAiSessions(todo, { ...current, lastTurnDoneAt: ts }),
224
+ })
225
+ }
226
+ }
227
+ } catch (e) {
228
+ console.warn('[ai-terminal] persist lastTurnDoneAt failed:', e.message)
229
+ }
230
+ broadcastToSession(session, {
231
+ ...payload,
232
+ type: 'turn_done',
233
+ event: payload.event || 'stop',
234
+ status: payload.status || session.status || 'idle',
235
+ timestamp: ts,
236
+ })
237
+ return true
238
+ }
239
+
240
+ function sendToBrowser(ws, msg) {
241
+ if (!ws || ws.readyState !== ws.OPEN) return
242
+ ws.send(JSON.stringify(msg))
243
+ }
244
+
245
+ function restoreSessionAsCurrent(session, todoSnapshot) {
246
+ todoSessionMap.set(session.todoId, session.sessionId)
247
+ if (session.nativeSessionId) nativeSessionMap.set(`${session.tool}:${session.nativeSessionId}`, session.sessionId)
248
+ if (todoSnapshot) {
249
+ db.updateTodo(session.todoId, {
250
+ status: todoSnapshot.status,
251
+ aiSessions: todoSnapshot.aiSessions,
252
+ })
253
+ }
254
+ }
255
+
256
+ function appendOutput(session, data) {
257
+ session.outputHistory.push(data)
258
+ session.outputSize += data.length
259
+ while (session.outputSize > MAX_OUTPUT_BUFFER && session.outputHistory.length > 1) {
260
+ const removed = session.outputHistory.shift()
261
+ session.outputSize -= removed.length
262
+ }
263
+ }
264
+
265
+ async function writeFullLog(sessionId, fullLog) {
266
+ if (!logDir || !fullLog) return
267
+ try {
268
+ await mkdir(logDir, { recursive: true })
269
+ const tail = fullLog.length > MAX_OUTPUT_BUFFER ? fullLog.slice(-MAX_OUTPUT_BUFFER) : fullLog
270
+ await writeFile(join(logDir, `${sessionId}.log`), tail, 'utf8')
271
+ } catch (e) {
272
+ console.warn('[ai-terminal] write log failed:', e.message)
273
+ }
274
+ }
275
+
276
+ // ─── PTY event wiring ───
277
+
278
+ pty.on('output', ({ sessionId, data }) => {
279
+ const session = sessions.get(sessionId)
280
+ if (!session) return
281
+ appendOutput(session, data)
282
+ session.recentOutput = `${session.recentOutput || ''}${data}`.slice(-4000)
283
+ session.lastOutputAt = Date.now()
284
+ session.outputBytesTotal = (session.outputBytesTotal || 0) + data.length
285
+ broadcastToSession(session, { type: 'output', data })
286
+ })
287
+
288
+ pty.on('native-session', ({ sessionId, nativeId }) => {
289
+ const session = sessions.get(sessionId)
290
+ if (!session) return
291
+ if (session.nativeSessionId && session.nativeSessionId !== nativeId) {
292
+ const oldNativeKey = `${session.tool}:${session.nativeSessionId}`
293
+ if (nativeSessionMap.get(oldNativeKey) === sessionId) nativeSessionMap.delete(oldNativeKey)
294
+ }
295
+ session.nativeSessionId = nativeId
296
+ nativeSessionMap.set(`${session.tool}:${nativeId}`, sessionId)
297
+ const todo = db.getTodo(session.todoId)
298
+ if (todo) {
299
+ const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession
300
+ if (!current) return
301
+ const nextAi = { ...current, nativeSessionId: nativeId, cwd: session.cwd || current.cwd || null }
302
+ db.updateTodo(session.todoId, {
303
+ aiSessions: mergeTodoAiSessions(todo, nextAi),
304
+ })
305
+ }
306
+ })
307
+
308
+ // cursor 专属:jsonl tail watcher 检测到末行 role===assistant → 一轮已完成。
309
+ // cursor 自家 stop hook 偶发不 fire(log 实测),所以走 jsonl 兜底。
310
+ // 走的字段跟 Claude Stop hook 一样:notifyTurnDone 设 lastTurnDoneAt,
311
+ // markSessionAwaitingReply(true) 让前端 deriveAiState 知道"PTY 活着但本轮已结束"。
312
+ pty.on('cursor-turn-done', ({ sessionId }) => {
313
+ if (!sessions.has(sessionId)) return
314
+ notifyTurnDone(sessionId, { event: 'stop', status: 'idle' })
315
+ markSessionAwaitingReply(sessionId, true)
316
+ })
317
+
318
+ // claude jsonl tail watcher(pty.js 内部 2s 轮询):
319
+ // - turn-started:末行是 user/tool_result → Claude 在跑 → awaitingReply=false
320
+ // - turn-done :末行 assistant.stop_reason==='end_turn' → 真完成 → awaitingReply=true
321
+ // 与 stop hook 并存,谁先到谁先生效。markSessionAwaitingReply 幂等。
322
+ pty.on('claude-turn-started', ({ sessionId }) => {
323
+ if (!sessions.has(sessionId)) return
324
+ markSessionAwaitingReply(sessionId, false)
325
+ })
326
+ pty.on('claude-turn-done', ({ sessionId }) => {
327
+ if (!sessions.has(sessionId)) return
328
+ notifyTurnDone(sessionId, { event: 'stop', status: 'idle' })
329
+ markSessionAwaitingReply(sessionId, true)
330
+ })
331
+
332
+ pty.on('done', ({ sessionId, exitCode, fullLog, nativeId, stopped }) => {
333
+ const session = sessions.get(sessionId)
334
+ if (!session) return
335
+ if (session.nativeSessionId) {
336
+ const nativeKey = `${session.tool}:${session.nativeSessionId}`
337
+ if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
338
+ }
339
+
340
+ let aiStatus, todoStatus
341
+ if (stopped) {
342
+ aiStatus = 'stopped'
343
+ todoStatus = 'todo'
344
+ } else if (exitCode === 0) {
345
+ aiStatus = 'done'
346
+ todoStatus = 'ai_done'
347
+ } else {
348
+ aiStatus = 'failed'
349
+ todoStatus = 'todo'
350
+ }
351
+
352
+ session.status = aiStatus
353
+ session.completedAt = Date.now()
354
+ session.awaitingReply = false
355
+ clearInterruptTimer(session)
356
+
357
+ const superseded = Boolean(session.replacedBySessionId) || todoSessionMap.get(session.todoId) !== sessionId
358
+ const todo = db.getTodo(session.todoId)
359
+ if (todo) {
360
+ const existingEntry = (todo.aiSessions || []).find(item => item.sessionId === session.sessionId)
361
+ // bypass 热重启的老 session:B 的 spawnSession 已通过 mergeTodoAiSessions
362
+ // 按 tool+nativeSessionId 把老 A 从 aiSessions 里剔除。这里别再 append 回去 ——
363
+ // 否则一次切"完全托管"会留下 2 条历史卡片(B running + A stopped)。
364
+ // session_log 仍由下方 insertSessionLog 写入,Dashboard 统计不会丢这次运行。
365
+ const skipHistoryWrite = superseded && !existingEntry
366
+ if (!skipHistoryWrite) {
367
+ const newAi = {
368
+ ...(existingEntry || todo.aiSession || {}),
369
+ sessionId: session.sessionId,
370
+ tool: session.tool,
371
+ nativeSessionId: nativeId || session.nativeSessionId || null,
372
+ cwd: session.cwd || null,
373
+ status: aiStatus,
374
+ startedAt: session.startedAt,
375
+ completedAt: session.completedAt,
376
+ prompt: session.prompt,
377
+ }
378
+ // 用户主动通过删/关 topic 触发的 stop:handleTopicEvent 已把 todo 标 done,
379
+ // 这里别用 stopped→'todo' 的默认逻辑覆写它。只更 aiSessions(记录会话退出状态)。
380
+ const updates = {
381
+ aiSessions: superseded
382
+ ? replaceTodoAiSessionInPlace(todo, newAi)
383
+ : mergeTodoAiSessions(todo, newAi),
384
+ }
385
+ if (!session.userClosedReason && !superseded) {
386
+ updates.status = todoStatus
387
+ }
388
+ db.updateTodo(session.todoId, updates)
389
+ }
390
+ }
391
+
392
+ writeFullLog(sessionId, fullLog)
393
+ const replacedByLive = superseded
394
+ && typeof session.replacedBySessionId === 'string'
395
+ && session.replacedBySessionId !== '__pending__'
396
+ if (!replacedByLive) {
397
+ broadcastToSession(session, { type: 'done', exitCode, status: aiStatus })
398
+ }
399
+
400
+ // 落库一条历史记录,供仪表盘 Tab C 统计使用
401
+ try {
402
+ const todoQuadrant = todo?.quadrant ?? 4
403
+ db.insertSessionLog({
404
+ id: session.sessionId,
405
+ todoId: session.todoId,
406
+ tool: session.tool,
407
+ quadrant: todoQuadrant,
408
+ status: aiStatus,
409
+ exitCode: exitCode ?? null,
410
+ startedAt: session.startedAt,
411
+ completedAt: session.completedAt,
412
+ })
413
+ } catch (e) {
414
+ console.warn('[ai-terminal] insertSessionLog failed:', e.message)
415
+ }
416
+
417
+ // Telegram 自动关 topic 钩子:PTY 自然退出 / crash / exit 命令都走这里
418
+ if (!superseded && typeof onSessionEnded === 'function') {
419
+ try {
420
+ const r = onSessionEnded({
421
+ sessionId,
422
+ todoId: session.todoId,
423
+ exitCode,
424
+ status: aiStatus,
425
+ startedAt: session.startedAt,
426
+ completedAt: session.completedAt,
427
+ })
428
+ if (r && typeof r.catch === 'function') r.catch((e) => console.warn(`[ai-terminal] onSessionEnded failed: ${e.message}`))
429
+ } catch (e) { console.warn(`[ai-terminal] onSessionEnded threw: ${e.message}`) }
430
+ }
431
+ })
432
+
433
+ // ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
434
+ function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false }) {
435
+ if (!todoId || typeof prompt !== 'string' || !tool) {
436
+ const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
437
+ throw err
438
+ }
439
+ if (!SUPPORTED_TOOLS.includes(tool)) {
440
+ const err = new Error('invalid tool'); err.code = 'bad_request'
441
+ throw err
442
+ }
443
+ // 工具不在 PATH(或显式 bin 路径不存在)时立刻报错,不要把 ENOENT 留给 node-pty。
444
+ // 路由层会把 tool_missing 映射成 HTTP 424 + 修复指引。
445
+ const cfg = loadConfig({ rootDir })
446
+ const avail = checkToolAvailable(tool, cfg)
447
+ if (!avail.ok) {
448
+ const err = new Error(`tool_missing: ${tool} (looked for "${avail.bin}" in PATH)`)
449
+ err.code = 'tool_missing'
450
+ err.tool = tool
451
+ err.bin = avail.bin
452
+ err.fix = `agentquad install-tools --${tool}`
453
+ throw err
454
+ }
455
+ const todo = db.getTodo(todoId)
456
+ if (!todo) {
457
+ const err = new Error('todo_not_found'); err.code = 'not_found'
458
+ throw err
459
+ }
460
+ if (resumeNativeId && !ignoreExistingNativeSessionId) {
461
+ const existing = nativeSessionMap.get(`${tool}:${resumeNativeId}`)
462
+ if (existing) return { sessionId: existing, reused: true }
463
+ }
464
+
465
+ const sessionId = externalSessionId || `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
466
+ let sessionCwd = resolveSessionCwd(cwd)
467
+ // 跟 recoverPendingTodosOnStartup 同样的 claude resume cwd 漂移修正:
468
+ // 前端把 session.cwd 原样回传,但那个 cwd 跟实际 jsonl 落盘的目录可能不一致;
469
+ // 在记到 DB 之前先用文件位置反查真实 cwd,下次再 resume 就不用再纠正了。
470
+ if (resumeNativeId && tool === 'claude' && pty.findClaudeSession) {
471
+ const located = pty.findClaudeSession(resumeNativeId)
472
+ if (located?.cwd && existsSync(located.cwd) && located.cwd !== sessionCwd) {
473
+ console.log(`[ai-terminal] resume cwd corrected for ${resumeNativeId.slice(0, 8)}: ${sessionCwd} → ${located.cwd}`)
474
+ sessionCwd = located.cwd
475
+ }
476
+ }
477
+ const effectivePermissionMode = permissionMode || 'default'
478
+ const session = {
479
+ sessionId,
480
+ todoId,
481
+ tool,
482
+ prompt,
483
+ status: 'running',
484
+ startedAt: Date.now(),
485
+ completedAt: null,
486
+ browsers: new Set(),
487
+ outputHistory: [],
488
+ outputSize: 0,
489
+ nativeSessionId: resumeNativeId || null,
490
+ recentOutput: '',
491
+ cwd: sessionCwd,
492
+ currentCwd: sessionCwd,
493
+ permissionMode: effectivePermissionMode,
494
+ autoMode: effectivePermissionMode !== 'default' ? effectivePermissionMode : null,
495
+ lastOutputAt: null,
496
+ lastTurnDoneAt: null,
497
+ outputBytesTotal: 0,
498
+ awaitingReply: false,
499
+ spawned: false,
500
+ spawnFallbackTimer: null,
501
+ }
502
+ sessions.set(sessionId, session)
503
+ todoSessionMap.set(todoId, sessionId)
504
+ if (resumeNativeId) nativeSessionMap.set(`${tool}:${resumeNativeId}`, sessionId)
505
+
506
+ try {
507
+ // 自动注入 QUADTODO_* env,让 ~/.agentquad/claude-hooks/notify.js 能识别这是
508
+ // AgentQuad 启的 Claude Code → Stop / SessionEnd 事件回推到 AgentQuad /api/openclaw/hook。
509
+ // 之前只有 wizard.finalize 会显式传 extraEnv,web/CLI 直接 spawn 的 session 由于缺这些
510
+ // env,hook 脚本 exit 0 → 完成时不推 telegram。caller-supplied 排前面,自动 env 后置覆盖
511
+ // 防止 caller 传错的 sessionId。
512
+ const autoEnv = {
513
+ QUADTODO_SESSION_ID: sessionId,
514
+ QUADTODO_TODO_ID: String(todoId),
515
+ QUADTODO_TODO_TITLE: String(todo.title || ''),
516
+ }
517
+ // 1. 先 pty.create 让 PtyManager 把 presetClaudeId / resumeNativeId 落进 session 记录。
518
+ pty.create({
519
+ sessionId,
520
+ todoId,
521
+ tool,
522
+ prompt: resumeNativeId ? null : prompt,
523
+ cwd: sessionCwd,
524
+ resumeNativeId: resumeNativeId || undefined,
525
+ permissionMode: permissionMode || null,
526
+ extraEnv: { ...(extraEnv || {}), ...autoEnv },
527
+ })
528
+ // 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
529
+ // 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
530
+ const presetNativeId = pty.getNativeId(sessionId)
531
+ session.nativeSessionId = presetNativeId
532
+ if (presetNativeId && !resumeNativeId) {
533
+ // resume 路径上面已经 set 过;新会话首次得到 nativeId 时补一次。
534
+ nativeSessionMap.set(`${tool}:${presetNativeId}`, sessionId)
535
+ }
536
+ // 3. 一次性把 nativeSessionId 写进 DB(搬进 try 内:失败时不留脏 DB)。
537
+ db.updateTodo(todoId, {
538
+ status: 'ai_running',
539
+ aiSessions: mergeTodoAiSessions(todo, {
540
+ sessionId,
541
+ tool,
542
+ nativeSessionId: presetNativeId,
543
+ cwd: sessionCwd,
544
+ status: 'running',
545
+ startedAt: session.startedAt,
546
+ completedAt: null,
547
+ prompt,
548
+ permissionMode: effectivePermissionMode,
549
+ ...(label ? { label } : {}),
550
+ }),
551
+ })
552
+ // 4. 5s 兜底:前端如果一直没发合法 init(极少见 — /exec 返回后 WS 还没连上),
553
+ // 用老的 80×24 兜底 spawn,避免 session 永远卡在 create 状态。
554
+ session.spawnFallbackTimer = setTimeout(() => {
555
+ session.spawnFallbackTimer = null
556
+ if (session.spawned) return
557
+ console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 5s)`)
558
+ session.spawned = true
559
+ pty.startWithSize(sessionId, 80, 24).catch((e) => {
560
+ console.warn(`[ai-terminal] spawn fallback failed: ${e.message}`)
561
+ session.spawned = false
562
+ })
563
+ }, 5000)
564
+ session.spawnFallbackTimer.unref?.()
565
+ } catch (error) {
566
+ sessions.delete(sessionId)
567
+ if (todoSessionMap.get(todoId) === sessionId) todoSessionMap.delete(todoId)
568
+ if (resumeNativeId) {
569
+ const nativeKey = `${tool}:${resumeNativeId}`
570
+ if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
571
+ }
572
+ // 新会话的 preset nativeSessionMap 也要清掉(resume 路径在上一个 if 已处理)。
573
+ if (session.nativeSessionId && session.nativeSessionId !== resumeNativeId) {
574
+ const nativeKey = `${tool}:${session.nativeSessionId}`
575
+ if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
576
+ }
577
+ // 顺手补:如果 pty.create 已经把 session 占位写进 pty.sessions、但后续步骤抛错,要清掉。
578
+ try { pty.stop(sessionId) } catch { /* ignore */ }
579
+ throw error
580
+ }
581
+
582
+ // Telegram 自动建 topic 钩子(B 方案:默认开,wizard 等已自管 topic 的传 skipTelegram=true)
583
+ if (!skipTelegram && typeof onSessionSpawned === 'function') {
584
+ try {
585
+ const r = onSessionSpawned({ sessionId, todoId, tool })
586
+ if (r && typeof r.catch === 'function') r.catch((e) => console.warn(`[ai-terminal] onSessionSpawned failed: ${e.message}`))
587
+ } catch (e) { console.warn(`[ai-terminal] onSessionSpawned threw: ${e.message}`) }
588
+ }
589
+
590
+ return { sessionId, reused: false }
591
+ }
592
+
593
+ // ─── REST ───
594
+
595
+ const router = Router()
596
+
597
+ router.post('/exec', (req, res) => {
598
+ try {
599
+ const body = req.body || {}
600
+ const result = spawnSession({
601
+ todoId: body.todoId,
602
+ prompt: body.prompt,
603
+ tool: body.tool,
604
+ cwd: body.cwd,
605
+ resumeNativeId: body.resumeNativeId,
606
+ permissionMode: body.permissionMode,
607
+ })
608
+ res.json({ ok: true, ...result })
609
+ } catch (e) {
610
+ if (e.code === 'tool_missing') {
611
+ return res.status(424).json({
612
+ ok: false,
613
+ code: 'tool_missing',
614
+ tool: e.tool,
615
+ bin: e.bin,
616
+ fix: e.fix,
617
+ message: e.message,
618
+ error: e.message,
619
+ })
620
+ }
621
+ const status = e.code === 'bad_request' ? 400 : e.code === 'not_found' ? 404 : 500
622
+ if (status >= 500) console.error('[ai-terminal/exec]', e)
623
+ res.status(status).json({ ok: false, error: e.message })
624
+ }
625
+ })
626
+
627
+
628
+ // 返回当前内存中的所有会话(包含已完成的"雕像期"),供仪表盘和宠物视图使用
629
+ router.get('/sessions', (req, res) => {
630
+ try {
631
+ const out = []
632
+ const now = Date.now()
633
+ for (const [sessionId, s] of sessions) {
634
+ const todo = db.getTodo(s.todoId)
635
+ out.push({
636
+ sessionId,
637
+ todoId: s.todoId,
638
+ todoTitle: todo?.title || '',
639
+ quadrant: todo?.quadrant || 4,
640
+ tool: s.tool,
641
+ status: s.status,
642
+ effectiveStatus: computeEffectiveStatus(s, now),
643
+ autoMode: s.autoMode || null,
644
+ nativeSessionId: s.nativeSessionId || null,
645
+ cwd: s.cwd || null,
646
+ startedAt: s.startedAt,
647
+ completedAt: s.completedAt || null,
648
+ lastOutputAt: s.lastOutputAt || null,
649
+ lastTurnDoneAt: s.lastTurnDoneAt || null,
650
+ outputBytesTotal: s.outputBytesTotal || 0,
651
+ awaitingReply: !!s.awaitingReply,
652
+ })
653
+ }
654
+ res.json({ ok: true, sessions: out })
655
+ } catch (e) {
656
+ console.error('[ai-terminal/sessions]', e)
657
+ res.status(500).json({ ok: false, error: e.message })
658
+ }
659
+ })
660
+
661
+ // 聚合历史统计:range=today|week|month
662
+ router.get('/stats', (req, res) => {
663
+ try {
664
+ const range = req.query.range || 'today'
665
+ const now = Date.now()
666
+ let since = now - 86400_000
667
+ if (range === 'week') since = now - 7 * 86400_000
668
+ else if (range === 'month') since = now - 30 * 86400_000
669
+ const stats = db.querySessionStats({ since, until: now })
670
+ res.json({ ok: true, range, since, until: now, stats })
671
+ } catch (e) {
672
+ console.error('[ai-terminal/stats]', e)
673
+ res.status(500).json({ ok: false, error: e.message })
674
+ }
675
+ })
676
+
677
+ // 当前所有 PTY 进程的 CPU/内存快照
678
+ router.get('/resource', async (req, res) => {
679
+ try {
680
+ const pids = pty.getPids ? pty.getPids() : []
681
+ if (!pids.length) {
682
+ res.json({ ok: true, resources: [] })
683
+ return
684
+ }
685
+ const pidList = pids.map(p => p.pid)
686
+ let usage = {}
687
+ try {
688
+ usage = await pidusage(pidList)
689
+ } catch (err) {
690
+ // 部分 pid 可能已退出,单独采样避免一错全错
691
+ for (const pid of pidList) {
692
+ try { usage[pid] = await pidusage(pid) } catch { /* skip dead pid */ }
693
+ }
694
+ }
695
+ const now = Date.now()
696
+ const resources = pids.map(({ sessionId, pid, tool }) => {
697
+ const u = usage[pid]
698
+ const session = sessions.get(sessionId)
699
+ const todo = session ? db.getTodo(session.todoId) : null
700
+ return {
701
+ sessionId,
702
+ todoId: session?.todoId || null,
703
+ todoTitle: todo?.title || '',
704
+ tool,
705
+ pid,
706
+ cpu: u?.cpu ?? 0,
707
+ memory: u?.memory ?? 0,
708
+ elapsedMs: session?.startedAt ? (now - session.startedAt) : 0,
709
+ }
710
+ })
711
+ res.json({ ok: true, resources })
712
+ } catch (e) {
713
+ console.error('[ai-terminal/resource]', e)
714
+ res.status(500).json({ ok: false, error: e.message })
715
+ }
716
+ })
717
+
718
+ router.post('/stop', (req, res) => {
719
+ try {
720
+ const { sessionId } = req.body || {}
721
+ const session = sessions.get(sessionId)
722
+ if (!session) {
723
+ res.status(404).json({ ok: false, error: 'session_not_found' })
724
+ return
725
+ }
726
+ pty.stop(sessionId)
727
+ // pty 'done' event will fire and broadcast 'done' to browsers;
728
+ // that handler sets todo.status to 'todo' because stopped=true.
729
+ broadcastToSession(session, { type: 'stopped' })
730
+ res.json({ ok: true })
731
+ } catch (e) {
732
+ console.error('[ai-terminal/stop]', e)
733
+ res.status(500).json({ ok: false, error: e.message })
734
+ }
735
+ })
736
+
737
+ router.post('/input', (req, res) => {
738
+ try {
739
+ const { sessionId, data } = req.body || {}
740
+ if (!sessionId || typeof data !== 'string') {
741
+ res.status(400).json({ ok: false, error: 'missing sessionId or data' })
742
+ return
743
+ }
744
+ const session = sessions.get(sessionId)
745
+ if (!session) {
746
+ res.status(404).json({ ok: false, error: 'session_not_found' })
747
+ return
748
+ }
749
+ // 只在真正"提交"按键(Enter / Ctrl+C / Ctrl+D)时翻 awaitingReply=false。
750
+ // 普通字符 / 焦点 ANSI 序列 / 粘贴中间态都不算 Claude 正式 busy ——
751
+ // 如果在这里无条件翻 false,dispatcher 会把同一 chat 后续的 IM 消息全部 queue,
752
+ // 而队列只能等下一次 Stop hook 才会 flush,导致飞书消息延迟数分钟才送达。
753
+ if (isPendingClearingInput(data)) markSessionRunningAfterInput(session)
754
+ // running 中遇到打断键 → 排一个 idle 兜底检查(Stop hook 不 fire 时也能翻状态)
755
+ if (session.status === 'running' && isInterruptInput(data)) scheduleInterruptIdleCheck(session)
756
+ writeRestInputToPty(sessionId, data)
757
+ res.json({ ok: true })
758
+ } catch (e) {
759
+ console.error('[ai-terminal/input]', e)
760
+ res.status(500).json({ ok: false, error: e.message })
761
+ }
762
+ })
763
+
764
+ // ─── WebSocket hooks (called from server.js on upgrade) ───
765
+
766
+ function addBrowser(sessionId, ws) {
767
+ const session = sessions.get(sessionId)
768
+ if (!session) {
769
+ try {
770
+ ws.send(JSON.stringify({ type: 'error', error: 'session_not_found' }))
771
+ ws.close?.(4004, 'session_not_found')
772
+ } catch { /* ignore */ }
773
+ return
774
+ }
775
+ session.browsers.add(ws)
776
+ if (session.outputHistory.length > 0) {
777
+ ws.send(JSON.stringify({ type: 'replay', chunks: session.outputHistory }))
778
+ }
779
+ ws.send(JSON.stringify({ type: 'auto_mode', autoMode: session.autoMode || null }))
780
+ if (session.status === 'done' || session.status === 'failed' || session.status === 'stopped') {
781
+ ws.send(JSON.stringify({ type: 'done', status: session.status }))
782
+ }
783
+ }
784
+
785
+ function removeBrowser(sessionId, ws) {
786
+ const session = sessions.get(sessionId)
787
+ if (!session) return
788
+ session.browsers.delete(ws)
789
+ // 这条浏览器走了之后,剩下的浏览器里最小尺寸可能变大,重算一次让 PTY
790
+ // 恢复到能充分利用剩余窗口的尺寸;没有浏览器时什么都不用发,等下一个进来再说。
791
+ if (session.browsers.size > 0) applyAggregatedResize(session)
792
+ }
793
+
794
+ // 同一个 session 被多个网页同时打开时(比如你在另一个 tab/window 又开了一遍
795
+ // 同一个 todo),每个 tab 的 xterm fit 出的 cols/rows 都不一样,谁最后发谁
796
+ // 赢就会在两个尺寸之间来回抖,Claude 的 TUI 不停重排、scrollback 全是残影。
797
+ // 取所有在线浏览器上报尺寸的 **最小值** 发给 PTY:最窄的窗口看得下,更宽的
798
+ // tab 只是右边留空白,整体输出保持稳定。
799
+ function applyAggregatedResize(session) {
800
+ if (!canResizeSession(session)) return
801
+
802
+ let cols = Infinity
803
+ let rows = Infinity
804
+ for (const b of session.browsers) {
805
+ const sz = b.__quadtodoSize
806
+ if (!sz || !isValidResizeSize(sz.cols, sz.rows)) continue
807
+ if (sz.cols < cols) cols = sz.cols
808
+ if (sz.rows < rows) rows = sz.rows
809
+ }
810
+ if (!isValidResizeSize(cols, rows)) return
811
+ cols = clampPtyCols(cols)
812
+ if (session.lastAppliedCols === cols && session.lastAppliedRows === rows) return
813
+ session.lastAppliedCols = cols
814
+ session.lastAppliedRows = rows
815
+ pty.resize(session.sessionId, cols, rows)
816
+ }
817
+
818
+ // \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D —— 这些才会真正让 Claude/Codex 的 confirm 提示推进
819
+ function isPendingClearingInput(data) {
820
+ if (typeof data !== 'string' || !data) return false
821
+ return /[\r\n\x03\x04]/.test(data)
822
+ }
823
+
824
+ // 用户希望"打断当前轮"的按键:Ctrl+C(\x03)或 Esc(裸 \x1b / \x1b\x1b)。
825
+ // 注意:必须排除 ANSI 转义序列 —— 箭头键是 '\x1b[A'、焦点切换是 '\x1b[I',
826
+ // 它们都以 \x1b 起头,但不是用户意图上的"打断"。所以只认严格匹配的两种形态。
827
+ // 这里不区分 status:调用方负责按 session.status === 'running' 决定是否调度。
828
+ function isInterruptInput(data) {
829
+ if (typeof data !== 'string' || !data) return false
830
+ if (data.includes('\x03')) return true
831
+ if (data === '\x1b' || data === '\x1b\x1b') return true
832
+ return false
833
+ }
834
+
835
+ // running 中的会话遇到用户按 Ctrl+C/Esc 时被调用。
836
+ // 现状:Claude / Codex 自然 turn done 才发 Stop hook 和 stop_reason='end_turn',
837
+ // 用户打断不发 → session.status 卡在 running、computeEffectiveStatus 又因 lastOutputAt
838
+ // 推进而强转 running,前端徽标永远转圈。
839
+ //
840
+ // 策略:从按下打断键起延后 INTERRUPT_GRACE_MS 检查 lastOutputAt;若 PTY 已经静默
841
+ // ≥ INTERRUPT_QUIET_MS 则视为打断成功,复用 markSessionIdleAfterTurn 走与自然 turn done
842
+ // 同样的持久化路径(todo.status → ai_done、broadcast turn_done),保留会话本身(PTY 不退)。
843
+ // 若 PTY 还在喷收尾输出,重试 INTERRUPT_MAX_RETRIES 次后放弃,留给 stop hook / jsonl
844
+ // watcher 接力(双层防御互不依赖)。
845
+ function scheduleInterruptIdleCheck(session) {
846
+ if (!session) return
847
+ if (session.interruptTimer) {
848
+ clearTimeout(session.interruptTimer)
849
+ session.interruptTimer = null
850
+ }
851
+ session.interruptRetries = 0
852
+ const tryFlip = () => {
853
+ session.interruptTimer = null
854
+ if (!sessions.has(session.sessionId)) return
855
+ if (session.status !== 'running') return
856
+ const lastOutputAt = Number(session.lastOutputAt || 0)
857
+ const now = Date.now()
858
+ if (lastOutputAt > 0 && now - lastOutputAt < INTERRUPT_QUIET_MS) {
859
+ session.interruptRetries = (session.interruptRetries || 0) + 1
860
+ if (session.interruptRetries >= INTERRUPT_MAX_RETRIES) return
861
+ session.interruptTimer = setTimeout(tryFlip, INTERRUPT_GRACE_MS)
862
+ session.interruptTimer.unref?.()
863
+ return
864
+ }
865
+ const ts = now
866
+ session.lastTurnDoneAt = ts
867
+ markSessionIdleAfterTurn(session, ts)
868
+ broadcastToSession(session, {
869
+ type: 'turn_done',
870
+ event: 'interrupted',
871
+ status: session.status || 'idle',
872
+ timestamp: ts,
873
+ })
874
+ }
875
+ session.interruptTimer = setTimeout(tryFlip, INTERRUPT_GRACE_MS)
876
+ session.interruptTimer.unref?.()
877
+ }
878
+
879
+ function clearInterruptTimer(session) {
880
+ if (!session) return
881
+ if (session.interruptTimer) {
882
+ clearTimeout(session.interruptTimer)
883
+ session.interruptTimer = null
884
+ }
885
+ session.interruptRetries = 0
886
+ }
887
+
888
+ function writeRestInputToPty(sessionId, data) {
889
+ if (typeof data !== 'string') return
890
+ const submit = data.match(/[\r\n]$/)?.[0]
891
+ if (!submit || data.length === 1) {
892
+ pty.write(sessionId, data)
893
+ return
894
+ }
895
+ const text = data.slice(0, -1)
896
+ if (text) pty.write(sessionId, text)
897
+ setTimeout(() => {
898
+ try { pty.write(sessionId, submit) } catch (e) {
899
+ console.warn(`[ai-terminal/input] submit write failed for ${sessionId}: ${e.message}`)
900
+ }
901
+ }, 80)
902
+ }
903
+
904
+ function handleSetAutoMode(sessionId, msg, ws) {
905
+ const session = sessions.get(sessionId)
906
+ if (!session) return
907
+ const nextAutoMode = msg.autoMode || null
908
+ session.autoMode = nextAutoMode
909
+ broadcastToSession(session, { type: 'auto_mode', autoMode: session.autoMode || null })
910
+
911
+ if (nextAutoMode !== 'bypass' || session.tool !== 'claude') return
912
+
913
+ if (!session.nativeSessionId) {
914
+ sendToBrowser(ws, {
915
+ type: 'auto_mode_notice',
916
+ autoMode: 'bypass',
917
+ immediate: false,
918
+ reason: 'native_session_missing',
919
+ message: '当前 Claude 会话尚未拿到原生 session id,全托管将仅对后续启动/恢复的会话生效。',
920
+ })
921
+ return
922
+ }
923
+
924
+ broadcastToSession(session, { type: 'auto_mode_switching', target: 'bypass' })
925
+ const todoSnapshot = db.getTodo(session.todoId)
926
+ session.replacedBySessionId = '__pending__'
927
+ let restarted
928
+ try {
929
+ restarted = spawnSession({
930
+ todoId: session.todoId,
931
+ prompt: session.prompt || '',
932
+ tool: session.tool,
933
+ cwd: session.cwd || undefined,
934
+ resumeNativeId: session.nativeSessionId,
935
+ permissionMode: 'bypass',
936
+ label: 'runtime:bypass',
937
+ skipTelegram: true,
938
+ ignoreExistingNativeSessionId: true,
939
+ })
940
+ } catch (e) {
941
+ delete session.replacedBySessionId
942
+ restoreSessionAsCurrent(session, todoSnapshot)
943
+ sendToBrowser(ws, {
944
+ type: 'auto_mode_notice',
945
+ autoMode: 'bypass',
946
+ immediate: false,
947
+ reason: 'restart_failed',
948
+ message: `切换全托管失败:${e.message}`,
949
+ })
950
+ return
951
+ }
952
+
953
+ broadcastToSession(session, {
954
+ type: 'session_restarted',
955
+ oldSessionId: sessionId,
956
+ newSessionId: restarted.sessionId,
957
+ autoMode: 'bypass',
958
+ })
959
+ if (restarted.sessionId !== sessionId) {
960
+ session.replacedBySessionId = restarted.sessionId
961
+ pty.stop(sessionId)
962
+ } else {
963
+ delete session.replacedBySessionId
964
+ }
965
+ }
966
+
967
+ function handleBrowserMessage(sessionId, msg, ws) {
968
+ if (msg.type === 'input') {
969
+ const session = sessions.get(sessionId)
970
+ // 只有"决定性"按键才视为对 confirm 提示的真实回应:Enter / Ctrl+C / Ctrl+D。
971
+ // 普通可见字符('a'、'y' 等)不会让 Claude TUI 推进,提示原样保留 —— 若此时清掉
972
+ // pending 状态,紧跟的回显输出会再次匹配 confirm 关键词,把状态翻回 pending_confirm。
973
+ // 浏览器侧每次按键都收到 pending_cleared → pending_confirm 一对消息,导致前端 border
974
+ // 在 1px ↔ 2px 之间反复,肉眼上就是"打字时终端布局抖动"。
975
+ if (isPendingClearingInput(msg.data)) markSessionRunningAfterInput(session)
976
+ // 同 REST /input:running 中遇到打断键 → 排 idle 兜底检查,给前端 1.5s 后翻状态
977
+ if (session?.status === 'running' && isInterruptInput(msg.data)) scheduleInterruptIdleCheck(session)
978
+ // 同 REST /input:只在真正的"提交"键才翻 false,避免普通字符 / 焦点序列 / 粘贴
979
+ // 中间态把 awaitingReply 推回 false,导致 dispatcher 把 IM 消息死锁在队列里
980
+ // 直到下一次 Stop。
981
+ pty.write(sessionId, msg.data)
982
+ } else if (msg.type === 'init') {
983
+ const cols = Number(msg.cols)
984
+ const rows = Number(msg.rows)
985
+ const session = sessions.get(sessionId)
986
+ if (!session) return
987
+ if (!isValidResizeSize(cols, rows)) return
988
+ if (!session.spawned) {
989
+ if (session.spawnFallbackTimer) {
990
+ clearTimeout(session.spawnFallbackTimer)
991
+ session.spawnFallbackTimer = null
992
+ }
993
+ session.spawned = true
994
+ pty.startWithSize(sessionId, clampPtyCols(cols), rows).then(() => {
995
+ session.lastAppliedCols = clampPtyCols(cols)
996
+ session.lastAppliedRows = rows
997
+ if (ws && session.browsers.has(ws)) {
998
+ ws.__quadtodoSize = { cols, rows }
999
+ applyAggregatedResize(session)
1000
+ }
1001
+ }).catch((e) => {
1002
+ console.warn(`[ai-terminal] startWithSize failed for ${sessionId}: ${e.message}`)
1003
+ session.spawned = false
1004
+ })
1005
+ return
1006
+ }
1007
+ // Register this WS's size into the aggregation map either way (covers
1008
+ // both the spawned-by-this-init case and the spawned-earlier reconnect case).
1009
+ if (ws && session.browsers.has(ws)) {
1010
+ ws.__quadtodoSize = { cols, rows }
1011
+ applyAggregatedResize(session)
1012
+ }
1013
+ } else if (msg.type === 'resize') {
1014
+ const cols = Number(msg.cols)
1015
+ const rows = Number(msg.rows)
1016
+ const session = sessions.get(sessionId)
1017
+ if (!canResizeSession(session)) return
1018
+ if (ws && session.browsers.has(ws)) {
1019
+ if (!isValidResizeSize(cols, rows)) {
1020
+ delete ws.__quadtodoSize
1021
+ applyAggregatedResize(session)
1022
+ return
1023
+ }
1024
+ ws.__quadtodoSize = { cols, rows }
1025
+ applyAggregatedResize(session)
1026
+ } else {
1027
+ if (!isValidResizeSize(cols, rows)) return
1028
+ // 没拿到 ws 兜底走老路径,保留对非 WS 调用方的兼容
1029
+ const clampedCols = clampPtyCols(cols)
1030
+ if (session.lastAppliedCols === clampedCols && session.lastAppliedRows === rows) return
1031
+ session.lastAppliedCols = clampedCols
1032
+ session.lastAppliedRows = rows
1033
+ pty.resize(sessionId, clampedCols, rows)
1034
+ }
1035
+ } else if (msg.type === 'set_auto_mode') {
1036
+ handleSetAutoMode(sessionId, msg, ws)
1037
+ } else if (msg.type === 'clear_history') {
1038
+ // 用户主动清空旧 scrollback:用于摆脱"老 session 在窄 cols 时写下的硬折行"
1039
+ // 污染新窗口显示的场景。只清缓冲,不动 PTY 状态——Claude 会在下次输出时自动重绘。
1040
+ const session = sessions.get(sessionId)
1041
+ if (!session) return
1042
+ session.outputHistory = []
1043
+ session.outputSize = 0
1044
+ }
1045
+ }
1046
+
1047
+ // ─── Cleanup of stale finished sessions (30 min) ───
1048
+
1049
+ const cleanupTimer = setInterval(() => {
1050
+ const cutoff = Date.now() - CLEANUP_MS
1051
+ for (const [id, s] of sessions) {
1052
+ if (!LIVE_AI_STATUSES.has(s.status)
1053
+ && s.completedAt && s.completedAt < cutoff
1054
+ && s.browsers.size === 0) {
1055
+ sessions.delete(id)
1056
+ if (todoSessionMap.get(s.todoId) === id) todoSessionMap.delete(s.todoId)
1057
+ if (s.nativeSessionId) {
1058
+ const nativeKey = `${s.tool}:${s.nativeSessionId}`
1059
+ if (nativeSessionMap.get(nativeKey) === id) nativeSessionMap.delete(nativeKey)
1060
+ }
1061
+ }
1062
+ }
1063
+ }, 5 * 60_000)
1064
+ cleanupTimer.unref?.()
1065
+
1066
+ function close() {
1067
+ clearInterval(cleanupTimer)
1068
+ for (const id of sessions.keys()) pty.stop(id)
1069
+ sessions.clear()
1070
+ todoSessionMap.clear()
1071
+ nativeSessionMap.clear()
1072
+ }
1073
+
1074
+ function recoverPendingTodosOnStartup() {
1075
+ // 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
1076
+ // 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
1077
+ // 会用交互式默认(= UI 上显示"手动")。
1078
+ let startupDefaultPermissionMode = null
1079
+ try {
1080
+ startupDefaultPermissionMode = loadConfig({ rootDir }).defaultPermissionMode || null
1081
+ } catch (e) {
1082
+ console.warn('[ai-terminal] recover: loadConfig failed:', e.message)
1083
+ }
1084
+ const todos = db.listTodos()
1085
+ .filter(todo => ['ai_running', 'ai_pending'].includes(todo.status))
1086
+ for (const todo of todos) {
1087
+ let recoverable = (todo.aiSessions || []).find(item => item?.nativeSessionId && (item.status === 'running' || item.status === 'idle' || item.status === 'pending_confirm'))
1088
+ || (todo.aiSessions || []).find(item => item?.nativeSessionId)
1089
+ if (!recoverable) {
1090
+ db.updateTodo(todo.id, { status: 'todo' })
1091
+ continue
1092
+ }
1093
+ if (nativeSessionMap.has(`${recoverable.tool}:${recoverable.nativeSessionId}`)) continue
1094
+ // claude --resume 在错误的 cwd 下会立刻 "No conversation found" 退出。启动恢复前
1095
+ // 按 uuid 在 ~/.claude/projects/*/ 实际定位 jsonl:文件没了就放弃恢复(避免起一个
1096
+ // 注定失败的 PTY),文件还在就用 jsonl 内嵌的 cwd 字段修正可能漂移的 recoverable.cwd。
1097
+ if (recoverable.tool === 'claude' && pty.findClaudeSession) {
1098
+ const located = pty.findClaudeSession(recoverable.nativeSessionId)
1099
+ if (!located) {
1100
+ console.warn(`[ai-terminal] recovery skip: claude session ${recoverable.nativeSessionId.slice(0, 8)} no longer on disk`)
1101
+ db.updateTodo(todo.id, { status: 'todo' })
1102
+ continue
1103
+ }
1104
+ if (located.cwd && existsSync(located.cwd) && located.cwd !== recoverable.cwd) {
1105
+ console.log(`[ai-terminal] recovery cwd corrected for ${recoverable.nativeSessionId.slice(0, 8)}: ${recoverable.cwd} → ${located.cwd}`)
1106
+ recoverable = { ...recoverable, cwd: located.cwd }
1107
+ }
1108
+ }
1109
+ const sessionId = `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
1110
+ const cwd = resolveSessionCwd(recoverable.cwd || todo.workDir)
1111
+ // DB 里记的优先(尊重用户在该 session 上的显式选择,包括运行中切到 bypass 的那条);
1112
+ // 没记 → 回退到 config 全局默认;都没有 → null(即 'default',原始行为)。
1113
+ const recoveredPermissionMode = recoverable.permissionMode || startupDefaultPermissionMode || null
1114
+ const session = {
1115
+ sessionId,
1116
+ todoId: todo.id,
1117
+ tool: recoverable.tool,
1118
+ prompt: recoverable.prompt,
1119
+ status: 'running',
1120
+ startedAt: Date.now(),
1121
+ completedAt: null,
1122
+ browsers: new Set(),
1123
+ outputHistory: [],
1124
+ outputSize: 0,
1125
+ nativeSessionId: recoverable.nativeSessionId,
1126
+ recentOutput: '',
1127
+ cwd,
1128
+ currentCwd: cwd,
1129
+ permissionMode: recoveredPermissionMode || 'default',
1130
+ autoMode: recoveredPermissionMode && recoveredPermissionMode !== 'default' ? recoveredPermissionMode : null,
1131
+ lastOutputAt: null,
1132
+ lastTurnDoneAt: null,
1133
+ outputBytesTotal: 0,
1134
+ completedAt: null,
1135
+ awaitingReply: false,
1136
+ }
1137
+ sessions.set(sessionId, session)
1138
+ todoSessionMap.set(todo.id, sessionId)
1139
+ nativeSessionMap.set(`${recoverable.tool}:${recoverable.nativeSessionId}`, sessionId)
1140
+ db.updateTodo(todo.id, {
1141
+ status: 'ai_running',
1142
+ aiSessions: mergeTodoAiSessions(todo, {
1143
+ ...recoverable,
1144
+ sessionId,
1145
+ cwd,
1146
+ status: 'running',
1147
+ startedAt: Date.now(),
1148
+ completedAt: null,
1149
+ permissionMode: recoveredPermissionMode || recoverable.permissionMode || null,
1150
+ }),
1151
+ })
1152
+ try {
1153
+ pty.start({
1154
+ sessionId,
1155
+ tool: recoverable.tool,
1156
+ prompt: null,
1157
+ cwd,
1158
+ resumeNativeId: recoverable.nativeSessionId,
1159
+ permissionMode: recoveredPermissionMode || undefined,
1160
+ extraEnv: {
1161
+ QUADTODO_SESSION_ID: sessionId,
1162
+ QUADTODO_TODO_ID: String(todo.id),
1163
+ QUADTODO_TODO_TITLE: String(todo.title || ''),
1164
+ QUADTODO_URL: 'http://127.0.0.1:5677',
1165
+ },
1166
+ }).catch((e) => {
1167
+ console.warn('[ai-terminal] auto-recover start failed:', e.message)
1168
+ sessions.delete(sessionId)
1169
+ todoSessionMap.delete(todo.id)
1170
+ const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
1171
+ if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
1172
+ db.updateTodo(todo.id, { status: 'todo' })
1173
+ })
1174
+ } catch (e) {
1175
+ console.warn('[ai-terminal] auto-recover failed:', e.message)
1176
+ sessions.delete(sessionId)
1177
+ todoSessionMap.delete(todo.id)
1178
+ const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
1179
+ if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
1180
+ db.updateTodo(todo.id, { status: 'todo' })
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ recoverPendingTodosOnStartup()
1186
+
1187
+ return {
1188
+ router,
1189
+ sessions,
1190
+ todoSessionMap,
1191
+ nativeSessionMap,
1192
+ addBrowser,
1193
+ removeBrowser,
1194
+ handleBrowserMessage,
1195
+ broadcastToSession,
1196
+ notifyTurnDone,
1197
+ spawnSession,
1198
+ markSessionAwaitingReply,
1199
+ markPendingConfirm,
1200
+ isSessionAwaitingReply,
1201
+ close,
1202
+ }
1203
+
1204
+ function markSessionAwaitingReply(sessionId, value) {
1205
+ const session = sessions.get(sessionId)
1206
+ if (!session) return false
1207
+ if (!LIVE_AI_STATUSES.has(session.status)) return false
1208
+ const next = !!value
1209
+ if (!next) {
1210
+ markSessionRunningAfterInput(session)
1211
+ return true
1212
+ }
1213
+ if (session.status === 'running') {
1214
+ markSessionIdleAfterTurn(session, Date.now())
1215
+ return true
1216
+ }
1217
+ if (session.awaitingReply === next) return true
1218
+ session.awaitingReply = next
1219
+ return true
1220
+ }
1221
+
1222
+ function isSessionAwaitingReply(sessionId) {
1223
+ const session = sessions.get(sessionId)
1224
+ if (!session) return false // 不存在 → 视为 busy(保守,避免抢跑)
1225
+ if (!LIVE_AI_STATUSES.has(session.status)) return false
1226
+ return !!session.awaitingReply
1227
+ }
1228
+ }