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
package/src/pty.js ADDED
@@ -0,0 +1,992 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createRequire } from 'node:module'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { spawnSync, execFile as execFileCb } from 'node:child_process'
5
+ import { readdirSync, statSync, existsSync, watch as fsWatch, mkdirSync, openSync, readSync, closeSync, readFileSync } from 'node:fs'
6
+ import { delimiter, dirname, isAbsolute, join } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { createCodexPromptDetector } from './codex-prompt-detector.js'
9
+
10
+ const require = createRequire(import.meta.url)
11
+
12
+ /**
13
+ * 将托管模式映射为原生 CLI 参数:
14
+ * - default / null:无额外参数(交互式确认)
15
+ * - acceptEdits (半托管):自动放行编辑类操作
16
+ * - bypass (完全托管):跳过全部权限询问
17
+ * claude/codex 两个 CLI 的标志不同,这里分开处理。
18
+ */
19
+ function buildPermissionArgs(tool, mode) {
20
+ if (!mode || mode === 'default') return []
21
+ if (tool === 'claude') {
22
+ if (mode === 'acceptEdits') return ['--permission-mode', 'acceptEdits']
23
+ if (mode === 'bypass') return ['--permission-mode', 'bypassPermissions']
24
+ return []
25
+ }
26
+ if (tool === 'codex') {
27
+ if (mode === 'acceptEdits') return ['--ask-for-approval', 'on-request', '--sandbox', 'workspace-write']
28
+ if (mode === 'bypass') return ['--dangerously-bypass-approvals-and-sandbox']
29
+ return []
30
+ }
31
+ if (tool === 'cursor') {
32
+ // cursor-agent 交互模式只接受 --force / --yolo;--trust 仅 --print/headless 可用,
33
+ // 在 PTY 里跑会被 cursor-agent 直接拒掉(Error: --trust can only be used with --print/headless mode)。
34
+ // acceptEdits → --force(除非 deny 否则放行命令)
35
+ // bypass → --yolo(= --force 别名,cursor 没提供更细颗粒)
36
+ if (mode === 'acceptEdits') return ['--force']
37
+ if (mode === 'bypass') return ['--yolo']
38
+ return []
39
+ }
40
+ return []
41
+ }
42
+
43
+ // Claude Code 的 AskUserQuestion 是 TUI(ANSI 重绘 + Tab/Arrow 导航),在 PTY 里
44
+ // 推到 Telegram 既看不全也没法回复。这里源头禁掉,AI 调用会失败 → 退路到文本或
45
+ // 自家 ask_user MCP(后者在 Telegram 渲染成 inline 按钮)。
46
+ // 仅作用于 AgentQuad 启动的 claude,不写到全局 settings.json。
47
+ function buildClaudeDisallowedToolsArgs(tool) {
48
+ if (tool !== 'claude') return []
49
+ return ['--disallowedTools', 'AskUserQuestion']
50
+ }
51
+
52
+ function buildChildPath(toolBin, basePath = process.env.PATH || '') {
53
+ if (!toolBin || !isAbsolute(toolBin)) return basePath
54
+ const binDir = dirname(toolBin)
55
+ const parts = basePath ? basePath.split(delimiter) : []
56
+ return [binDir, ...parts.filter((part) => part !== binDir)].join(delimiter)
57
+ }
58
+
59
+ // 检测 Claude Code AskUserQuestion / 类似选择器 TUI 的 footer 特征。
60
+ // 真要兜底:禁用参数是主力,这一道用来万一参数失效(升级/改名)时仍能给 Telegram 一个提示。
61
+ const TUI_FOOTER_RE = /Tab\/Arrow keys to navigate.*Esc to cancel|Enter to select.*Tab\/Arrow/
62
+ const TUI_ALERT_COOLDOWN_MS = 30_000
63
+
64
+ const CLAUDE_SESSION_RE = /claude\s+--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
65
+ const CODEX_SESSION_RE = /codex\s+resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
66
+ const CODEX_ROLLOUT_FILE_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/
67
+ const MAX_LOG_BYTES = 512 * 1024
68
+ const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
69
+
70
+ function codexDayDir(date) {
71
+ return join(
72
+ CODEX_SESSIONS_DIR,
73
+ String(date.getFullYear()),
74
+ String(date.getMonth() + 1).padStart(2, '0'),
75
+ String(date.getDate()).padStart(2, '0'),
76
+ )
77
+ }
78
+
79
+ function codexTodayDir() {
80
+ return codexDayDir(new Date())
81
+ }
82
+
83
+ // AgentQuad 进程时区可能跟 codex CLI 进程时区不一致(典型场景:AgentQuad 没设 TZ + LANG=zh_CN
84
+ // 让 Node 默认 CST,但 codex 用 macOS 系统 TZ 是 PDT,差 15h 直接跨日);同时盯 today/
85
+ // yesterday/tomorrow 三个目录,把 ±24h 时区漂移吃掉。
86
+ function codexNearbyDayDirs() {
87
+ const now = Date.now()
88
+ return [
89
+ codexDayDir(new Date(now - 86400_000)),
90
+ codexDayDir(new Date(now)),
91
+ codexDayDir(new Date(now + 86400_000)),
92
+ ]
93
+ }
94
+
95
+ // codex 0.124.0 无 --session-id / --rollout-path 预置能力;首个可靠的 session id 来源是
96
+ // ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*-<uuid>.jsonl 文件出现的那一刻。
97
+ // fs.watch 的事件延迟通常 <50ms,远优于 400ms 轮询;fs.watch 在部分 FS 不可靠,所以
98
+ // 三路并行(fs.watch / 400ms 轮询 / stdout 正则),setNativeId 里处理去重与相互清理。
99
+ function defaultCodexWatcherFactory(_spawnTime, onHit) {
100
+ const dirs = codexNearbyDayDirs()
101
+ const watchers = []
102
+ for (const dir of dirs) {
103
+ try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
104
+ try {
105
+ const w = fsWatch(dir, { persistent: false }, (eventType, filename) => {
106
+ if (!filename) return
107
+ const m = filename.match(CODEX_ROLLOUT_FILE_RE)
108
+ console.log(`[codex-detect] fs.watch event=${eventType} dir=${dir} file=${filename} match=${!!m}`)
109
+ if (m) onHit(m[1])
110
+ })
111
+ watchers.push(w)
112
+ console.log(`[codex-detect] fs.watch armed on ${dir}`)
113
+ } catch (e) {
114
+ console.warn(`[codex-detect] fs.watch FAILED on ${dir}:`, e?.message || e)
115
+ }
116
+ }
117
+ if (!watchers.length) return null
118
+ // 返回个聚合 close 函数让上层照旧 .close()
119
+ return { close() { for (const w of watchers) { try { w.close() } catch { /* ignore */ } } } }
120
+ }
121
+
122
+ function detectCodexSessionFromFs(afterMs) {
123
+ // 同时扫 today / yesterday / tomorrow,对抗 AgentQuad / codex 进程间的 TZ 漂移
124
+ let newest = null
125
+ let newestTime = 0
126
+ for (const dayDir of codexNearbyDayDirs()) {
127
+ if (!existsSync(dayDir)) continue
128
+ try {
129
+ for (const file of readdirSync(dayDir)) {
130
+ if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
131
+ const st = statSync(join(dayDir, file))
132
+ const t = st.birthtimeMs || st.ctimeMs
133
+ if (t > afterMs && t > newestTime) {
134
+ const uuidMatch = file.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/)
135
+ if (uuidMatch) {
136
+ newest = uuidMatch[1]
137
+ newestTime = t
138
+ }
139
+ }
140
+ }
141
+ } catch { /* ignore one bad dir, keep scanning others */ }
142
+ }
143
+ return newest
144
+ }
145
+
146
+ function tryReadCwdFromSessionMeta(filePath) {
147
+ try {
148
+ const head = readFileSync(filePath, 'utf8').split('\n').slice(0, 2)
149
+ for (const line of head) {
150
+ if (!line.trim()) continue
151
+ const j = JSON.parse(line)
152
+ if (j?.type === 'session_meta' && j?.payload?.cwd) return j.payload.cwd
153
+ }
154
+ } catch {}
155
+ return null
156
+ }
157
+
158
+ /**
159
+ * 反向定位某个 codex nativeSessionId 对应的 rollout-*.jsonl 文件 + 起始 cwd。
160
+ * 用途:拿到 native id 后由上层需要订阅 jsonl 增量,或恢复时校验文件是否还在。
161
+ * 读 head 两行扫 session_meta,找不到时 cwd:null(仍返回 filePath)。
162
+ */
163
+ export function findCodexSession(nativeSessionId, { sessionsRoot = CODEX_SESSIONS_DIR } = {}) {
164
+ if (!nativeSessionId) return null
165
+ if (!existsSync(sessionsRoot)) return null
166
+ let years
167
+ try { years = readdirSync(sessionsRoot).filter(y => /^\d{4}$/.test(y)) } catch { return null }
168
+ for (const y of years) {
169
+ const yDir = join(sessionsRoot, y)
170
+ let months
171
+ try { months = readdirSync(yDir) } catch { continue }
172
+ for (const m of months) {
173
+ const mDir = join(yDir, m)
174
+ let days
175
+ try { days = readdirSync(mDir) } catch { continue }
176
+ for (const d of days) {
177
+ const dDir = join(mDir, d)
178
+ let files
179
+ try { files = readdirSync(dDir) } catch { continue }
180
+ for (const f of files) {
181
+ const match = f.match(CODEX_ROLLOUT_FILE_RE)
182
+ if (!match || match[1] !== nativeSessionId) continue
183
+ const filePath = join(dDir, f)
184
+ const cwd = tryReadCwdFromSessionMeta(filePath)
185
+ return { filePath, cwd, nativeId: nativeSessionId }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return null
191
+ }
192
+
193
+ function defaultPtyFactory() {
194
+ const pty = require('node-pty')
195
+ return (bin, args, opts) => pty.spawn(bin, args, opts)
196
+ }
197
+
198
+ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
199
+ const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
200
+
201
+ /**
202
+ * cursor-agent 的 cwd 编码:把绝对路径里的 `/` 全部换成 `-`,前导 `-` 保留。
203
+ * /Users/foo/bar → Users-foo-bar
204
+ * /private/tmp/x → private-tmp-x
205
+ * / → empty-window(特例,交给 cursor 自己决定,不在这里处理)
206
+ */
207
+ function encodeCursorCwd(cwd) {
208
+ if (!cwd) return null
209
+ const trimmed = cwd.replace(/^\/+/, '').replace(/\/+$/, '')
210
+ if (!trimmed) return null
211
+ return trimmed.replace(/\//g, '-')
212
+ }
213
+
214
+ /**
215
+ * 公开:返回某个 cwd 下某 chatId 对应的 jsonl 绝对路径(不保证存在)。
216
+ * 失败返回 null。
217
+ */
218
+ export function cursorTranscriptPath(cwd, chatId) {
219
+ const encoded = encodeCursorCwd(cwd)
220
+ if (!encoded || !chatId) return null
221
+ return join(CURSOR_PROJECTS_DIR, encoded, 'agent-transcripts', chatId, `${chatId}.jsonl`)
222
+ }
223
+
224
+ /**
225
+ * 异步预生成 cursor chatId:跑 `cursor-agent create-chat`,stdout 第一行就是 UUID。
226
+ * 非阻塞,默认 6s 超时。失败/超时 resolve null(让上层走"无 nativeId"降级路径)。
227
+ */
228
+ function createCursorChatAsync(bin, timeoutMs = 6000) {
229
+ return new Promise((resolve) => {
230
+ try {
231
+ execFileCb(bin, ['create-chat'], { timeout: timeoutMs, encoding: 'utf8' }, (err, stdout) => {
232
+ if (err) { resolve(null); return }
233
+ const m = String(stdout || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)
234
+ resolve(m ? m[0] : null)
235
+ })
236
+ } catch {
237
+ resolve(null)
238
+ }
239
+ })
240
+ }
241
+
242
+ /**
243
+ * Claude Code 把每段对话按 cwd 编码存到 ~/.claude/projects/<encoded>/<uuid>.jsonl,
244
+ * --resume 在当前 cwd 对应的目录里查 uuid,找不到就 "No conversation found"。
245
+ * 我们 DB 里的 session.cwd 可能跟 claude 当时实际的 cwd 不一致(例如默认 cwd 改过、
246
+ * 或者起会话时传错了),导致 resume 100% 失败。
247
+ *
248
+ * 这里直接按 uuid 在所有 project 目录里搜一遍,找到 jsonl 后从前面几条记录里读出
249
+ * claude 自己写下的 cwd 字段;那才是 resume 应该用的 cwd。
250
+ *
251
+ * 返回 { filePath, cwd } | null。读不到 cwd 字段时 cwd=null(仍返回 filePath,调用方
252
+ * 可以决定是否兜底)。
253
+ */
254
+ function defaultClaudeSessionLocator(nativeSessionId) {
255
+ if (!nativeSessionId || typeof nativeSessionId !== 'string') return null
256
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) return null
257
+ let entries
258
+ try { entries = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) } catch { return null }
259
+ for (const dirent of entries) {
260
+ if (!dirent.isDirectory()) continue
261
+ const filePath = join(CLAUDE_PROJECTS_DIR, dirent.name, `${nativeSessionId}.jsonl`)
262
+ if (!existsSync(filePath)) continue
263
+ let cwd = null
264
+ try {
265
+ // jsonl 可能很大,只读前 64KB;cwd 字段一般在最早几条 message 里就出现
266
+ const fd = openSync(filePath, 'r')
267
+ try {
268
+ const buf = Buffer.alloc(65536)
269
+ const n = readSync(fd, buf, 0, buf.length, 0)
270
+ const chunk = buf.slice(0, n).toString('utf8')
271
+ const lines = chunk.split('\n')
272
+ // 最后一行可能被截断,跳过
273
+ for (let i = 0; i < lines.length - 1; i++) {
274
+ const line = lines[i].trim()
275
+ if (!line) continue
276
+ try {
277
+ const obj = JSON.parse(line)
278
+ if (typeof obj.cwd === 'string' && obj.cwd) { cwd = obj.cwd; break }
279
+ } catch { /* 不是 JSON 或解析失败,跳过 */ }
280
+ }
281
+ } finally { closeSync(fd) }
282
+ } catch { /* 读文件失败,cwd 留 null */ }
283
+ return { filePath, cwd }
284
+ }
285
+ return null
286
+ }
287
+
288
+ export class PtyManager extends EventEmitter {
289
+ constructor({ tools, ptyFactory, promptDelayMs = 2000, codexWatcherFactory, claudeSessionLocator, codexSessionLocator, sidecar = null, eventEmitterFactory = null, codexPromptDetectorFactory = null } = {}) {
290
+ super()
291
+ if (!tools) throw new Error('PtyManager: tools required')
292
+ this.tools = tools
293
+ this.ptyFactory = ptyFactory || defaultPtyFactory()
294
+ this.codexWatcherFactory = codexWatcherFactory || defaultCodexWatcherFactory
295
+ this.claudeSessionLocator = claudeSessionLocator || defaultClaudeSessionLocator
296
+ this.codexSessionLocator = codexSessionLocator || ((id) => findCodexSession(id))
297
+ this.promptDelayMs = promptDelayMs
298
+ this.sidecar = sidecar
299
+ this.eventEmitterFactory = eventEmitterFactory
300
+ this.codexPromptDetectorFactory = codexPromptDetectorFactory || createCodexPromptDetector
301
+ this.sessions = new Map()
302
+ }
303
+
304
+ /**
305
+ * 公开的 claude resume 文件定位接口。返回 { filePath, cwd } | null。
306
+ * 上层(ai-terminal 启动恢复)用它来:
307
+ * - 判断 nativeSessionId 是否还在硬盘上(不在就别 spawn 一个注定失败的 --resume)
308
+ * - 拿到真实 cwd 修正 DB 里漂移的记录
309
+ */
310
+ findClaudeSession(nativeSessionId) {
311
+ try { return this.claudeSessionLocator(nativeSessionId) } catch { return null }
312
+ }
313
+
314
+ // 任何一条探测路径命中 id,统一走这里:去重 + 清理其余两路 + emit。
315
+ _setNativeId(session, nativeId) {
316
+ if (!nativeId || session.nativeId === nativeId) return false
317
+ session.nativeId = nativeId
318
+ if (session.detectTimer) { clearInterval(session.detectTimer); session.detectTimer = null }
319
+ if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
320
+ this.emit('native-session', { sessionId: session.sessionId, nativeId })
321
+ // codex 专属:拿到 native id 后落 sidecar + 启动 jsonl 增量 emitter,给 IM 推送链路用。
322
+ if (session.tool === 'codex') {
323
+ console.log(`[codex-detect] _setNativeId session=${session.sessionId} nativeId=${nativeId}`)
324
+ if (this.sidecar) {
325
+ try {
326
+ const p = this.sidecar.write({
327
+ nativeId,
328
+ quadtodoSessionId: session.sessionId,
329
+ todoId: session.todoId || null,
330
+ cwd: session.cwd || null,
331
+ })
332
+ if (p && typeof p.catch === 'function') p.catch(() => {})
333
+ console.log(`[codex-detect] sidecar.write OK nativeId=${nativeId}`)
334
+ } catch (e) {
335
+ console.warn(`[codex-detect] sidecar.write FAILED:`, e?.message || e)
336
+ }
337
+ } else {
338
+ console.warn(`[codex-detect] this.sidecar is null — server.js didn't wire it`)
339
+ }
340
+ if (this.eventEmitterFactory && !session.eventEmitter) {
341
+ try {
342
+ const loc = this.codexSessionLocator(nativeId)
343
+ if (loc?.filePath) {
344
+ session.eventEmitter = this.eventEmitterFactory({ filePath: loc.filePath, nativeId })
345
+ session.eventEmitter.start?.()
346
+ console.log(`[codex-detect] emitter started filePath=${loc.filePath}`)
347
+ } else {
348
+ console.warn(`[codex-detect] codexSessionLocator returned null for nativeId=${nativeId} — emitter NOT started (will retry below)`)
349
+ // jsonl 文件这一刻可能还没 flush 到 fs;500ms / 1500ms 各重试一次。
350
+ const retry = (delay) => setTimeout(() => {
351
+ if (session.eventEmitter || session.stopped) return
352
+ const loc2 = this.codexSessionLocator(nativeId)
353
+ if (loc2?.filePath && this.eventEmitterFactory) {
354
+ session.eventEmitter = this.eventEmitterFactory({ filePath: loc2.filePath, nativeId })
355
+ session.eventEmitter.start?.()
356
+ console.log(`[codex-detect] emitter started on retry+${delay}ms filePath=${loc2.filePath}`)
357
+ } else if (delay < 1500) {
358
+ console.warn(`[codex-detect] retry+${delay}ms still no jsonl file for ${nativeId}`)
359
+ }
360
+ }, delay)
361
+ retry(500).unref?.()
362
+ retry(1500).unref?.()
363
+ }
364
+ } catch (e) {
365
+ console.warn(`[codex-detect] emitter start FAILED:`, e?.message || e)
366
+ }
367
+ } else if (!this.eventEmitterFactory) {
368
+ console.warn(`[codex-detect] this.eventEmitterFactory is null — server.js didn't wire it`)
369
+ }
370
+ }
371
+ return true
372
+ }
373
+
374
+ has(sessionId) {
375
+ return this.sessions.has(sessionId)
376
+ }
377
+
378
+ list() {
379
+ return [...this.sessions.keys()]
380
+ }
381
+
382
+ /** 返回 session 已知的 native id(claude 预置 / resume 沿用);codex 新会话探测前为 null。 */
383
+ getNativeId(sessionId) {
384
+ return this.sessions.get(sessionId)?.nativeId || null
385
+ }
386
+
387
+ /** 返回当前所有活跃 PTY 的 { sessionId, pid, tool },供 pidusage 采样用 */
388
+ getPids() {
389
+ const out = []
390
+ for (const [sessionId, s] of this.sessions) {
391
+ const pid = s.proc?.pid
392
+ if (pid) out.push({ sessionId, pid, tool: s.tool })
393
+ }
394
+ return out
395
+ }
396
+
397
+ /**
398
+ * 测试 / Phase A 友好入口:传 { tool, sessionId, cwd, todoId, prompt?, ... },返回一个
399
+ * 可 .kill() 的 handle。内部仍走 start() 的全部生命周期,方便 sidecar / emitter 接线
400
+ * 测试不必走 onExit 链路。
401
+ */
402
+ spawn({ tool, sessionId, cwd, todoId, prompt = null, resumeNativeId = null, permissionMode = null, extraEnv = null } = {}) {
403
+ this.start({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv })
404
+ const session = this.sessions.get(sessionId)
405
+ if (session) {
406
+ session.todoId = todoId || null
407
+ session.cwd = cwd || null
408
+ }
409
+ return {
410
+ sessionId,
411
+ get nativeId() { return session?.nativeId || null },
412
+ kill: () => this.stop(sessionId),
413
+ }
414
+ }
415
+
416
+ /**
417
+ * 两段式 spawn:create() 只构造 session 记录(不开子进程),
418
+ * startWithSize() 才真正调 ptyFactory 把 PTY 拉起来。WS init 握手在
419
+ * 会话建立时调 create()、收到前端真实 cols/rows 后再调 startWithSize(),
420
+ * 这样 PTY 永远不会在默认 80×24 上 spawn 一次再 resize。
421
+ */
422
+ create({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv }) {
423
+ const toolCfg = this.tools[tool]
424
+ if (!toolCfg) throw new Error(`unknown tool: ${tool}`)
425
+ const baseArgs = toolCfg.args || []
426
+ // 是否通过 CLI 参数传递 prompt(仅新会话、非 resume 时可用)
427
+ const useCliPrompt = prompt && !resumeNativeId
428
+ // permissionMode → 原生 CLI 标志:把托管模式直接交给 claude/codex 处理,
429
+ // 比在 PTY 输出里做正则匹配 + 自动回车 更可靠。
430
+ const permissionArgs = buildPermissionArgs(tool, permissionMode)
431
+ // Claude 内置 AskUserQuestion TUI 在 Telegram 不可用,源头禁掉
432
+ const disallowedToolsArgs = buildClaudeDisallowedToolsArgs(tool)
433
+ // Claude 支持 --session-id <uuid>:新会话时由我们预生成,避免事后靠 FS/输出扫描。
434
+ const presetClaudeId = tool === 'claude' && !resumeNativeId ? randomUUID() : null
435
+ const claudeSessionArgs = presetClaudeId ? ['--session-id', presetClaudeId] : []
436
+
437
+ // cursor-agent 没有 --session-id 预置,但有 `cursor-agent create-chat` 异步建会话拿 chatId。
438
+ // 新会话先异步跑 create-chat,拿到 chatId 后在 startWithSize() 里用 --resume 进交互模式。
439
+ // create-chat 失败就降级(无 nativeId,直接传 prompt)。
440
+ let cursorChatPromise = null
441
+ if (tool === 'cursor' && !resumeNativeId) {
442
+ cursorChatPromise = createCursorChatAsync(toolCfg.bin)
443
+ }
444
+ const cursorResumeId = tool === 'cursor' ? resumeNativeId : null
445
+
446
+ let args
447
+ if (resumeNativeId) {
448
+ if (tool === 'codex') args = [...baseArgs, ...permissionArgs, 'resume', resumeNativeId]
449
+ else if (tool === 'cursor') args = [...baseArgs, ...permissionArgs, '--resume', resumeNativeId]
450
+ else args = [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, '--resume', resumeNativeId]
451
+ } else if (tool === 'cursor' && cursorResumeId) {
452
+ args = useCliPrompt
453
+ ? [...baseArgs, ...permissionArgs, '--resume', cursorResumeId, prompt]
454
+ : [...baseArgs, ...permissionArgs, '--resume', cursorResumeId]
455
+ } else {
456
+ args = useCliPrompt
457
+ ? [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...claudeSessionArgs, prompt]
458
+ : [...baseArgs, ...permissionArgs, ...disallowedToolsArgs, ...claudeSessionArgs]
459
+ }
460
+ let effectiveCwd = cwd || process.env.HOME || process.cwd()
461
+
462
+ // claude --resume 的 cwd 必须跟原会话的 cwd 一致,否则 claude 在错误的 projects/<encoded>
463
+ // 目录里找不到 jsonl 就抛 "No conversation found"。这里按 uuid 反查文件位置 + 内嵌 cwd
464
+ // 字段做一次纠正;找不到文件就只 warn,让 claude 自己抛原始错误。
465
+ if (resumeNativeId && tool === 'claude') {
466
+ try {
467
+ const located = this.claudeSessionLocator(resumeNativeId)
468
+ if (located?.cwd && located.cwd !== effectiveCwd && existsSync(located.cwd)) {
469
+ console.log(`[pty] claude --resume ${resumeNativeId.slice(0, 8)}: cwd corrected ${effectiveCwd} → ${located.cwd}`)
470
+ effectiveCwd = located.cwd
471
+ } else if (!located) {
472
+ console.warn(`[pty] claude --resume ${resumeNativeId.slice(0, 8)}: no jsonl in ~/.claude/projects/*/ — resume will likely fail`)
473
+ }
474
+ } catch (e) {
475
+ console.warn(`[pty] claudeSessionLocator failed: ${e.message}`)
476
+ }
477
+ }
478
+
479
+ const env = {
480
+ ...process.env,
481
+ TERM: 'xterm-256color',
482
+ TZ: process.env.TZ || 'America/Los_Angeles',
483
+ FORCE_COLOR: '1',
484
+ // Force narrow East-Asian-Ambiguous wcwidth so PTY children agree with xterm.js's
485
+ // Unicode rendering. Set AGENTQUAD_KEEP_CJK_LOCALE=1 to disable this override.
486
+ ...(process.env.AGENTQUAD_KEEP_CJK_LOCALE === '1' ? {} : {
487
+ LANG: 'en_US.UTF-8',
488
+ LC_CTYPE: 'en_US.UTF-8',
489
+ }),
490
+ ...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
491
+ }
492
+ env.PATH = buildChildPath(toolCfg.bin, env.PATH || '')
493
+
494
+ const session = {
495
+ proc: null,
496
+ tool,
497
+ sessionId,
498
+ cwd: effectiveCwd,
499
+ todoId: null,
500
+ fullLog: [],
501
+ logBytes: 0,
502
+ pendingPrompt: useCliPrompt ? null : (prompt && !resumeNativeId ? prompt : null),
503
+ resized: false,
504
+ promptTimer: null,
505
+ nativeId: resumeNativeId || presetClaudeId || null,
506
+ stopped: false,
507
+ detectTimer: null,
508
+ fsWatcher: null,
509
+ eventEmitter: null,
510
+ detector: null,
511
+ lastTuiAlertAt: 0,
512
+ cursorChatPromise,
513
+ spawnSpec: {
514
+ args,
515
+ env,
516
+ effectiveCwd,
517
+ toolCfg,
518
+ tool,
519
+ resumeNativeId: resumeNativeId || null,
520
+ _baseArgs: [...baseArgs],
521
+ _permissionArgs: [...permissionArgs],
522
+ _promptArg: useCliPrompt ? prompt : null,
523
+ },
524
+ }
525
+ this.sessions.set(sessionId, session)
526
+ }
527
+
528
+ /**
529
+ * 用真实 cols/rows 把 PTY 拉起来;第二次及之后调用会降级为 resize()。
530
+ * 必须先经过 create()。对 cursor 新会话会先 await create-chat 异步结果。
531
+ */
532
+ async startWithSize(sessionId, cols, rows) {
533
+ const session = this.sessions.get(sessionId)
534
+ if (!session) throw new Error(`no session ${sessionId}`)
535
+ if (session.proc) {
536
+ try { session.proc.resize(cols, rows) } catch { /* ignore */ }
537
+ return
538
+ }
539
+
540
+ // cursor 新会话:等待 create-chat 异步结果,拿到 chatId 后重建 args
541
+ if (session.cursorChatPromise) {
542
+ const chatId = await session.cursorChatPromise
543
+ session.cursorChatPromise = null
544
+ if (chatId) {
545
+ session.nativeId = chatId
546
+ const spec = session.spawnSpec
547
+ const parts = [...spec._baseArgs, ...spec._permissionArgs, '--resume', chatId]
548
+ if (spec._promptArg) parts.push(spec._promptArg)
549
+ spec.args = parts
550
+ this.emit('native-session', { sessionId, nativeId: chatId })
551
+ } else {
552
+ console.warn(`[pty] cursor-agent create-chat failed; session will run without nativeId tracking`)
553
+ }
554
+ }
555
+
556
+ const spec = session.spawnSpec
557
+ if (!spec) throw new Error(`session ${sessionId} has no spawnSpec (was it created?)`)
558
+ const { args, env, effectiveCwd, toolCfg, tool } = spec
559
+ const { resumeNativeId } = spec
560
+
561
+ console.log(`[pty] starting ${tool} bin=${toolCfg.bin} cwd=${effectiveCwd} args=${JSON.stringify(args)} cols=${cols} rows=${rows}`)
562
+
563
+ let proc
564
+ try {
565
+ proc = this.ptyFactory(toolCfg.bin, args, {
566
+ name: 'xterm-256color',
567
+ cols,
568
+ rows,
569
+ cwd: effectiveCwd,
570
+ env,
571
+ })
572
+ } catch (error) {
573
+ // ptyFactory failed → session record is stranded (no proc → no onExit to clean it up).
574
+ // Remove it explicitly so callers can retry / start a new session with the same id.
575
+ this.sessions.delete(sessionId)
576
+ error.message = `PTY spawn failed for ${tool} (bin=${toolCfg.bin}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
577
+ throw error
578
+ }
579
+ session.proc = proc
580
+
581
+ // Codex 专属:stdout 提示词检测器(接 [Y/n] / apply patch? 之类的兜底权限弹窗)。
582
+ // emitter 用迟绑定 getter 包装:detector 创建在 _setNativeId 之前,eventEmitter 还是 null。
583
+ if (tool === 'codex') {
584
+ try {
585
+ session.detector = this.codexPromptDetectorFactory({
586
+ pty: proc,
587
+ emitter: {
588
+ getLatestAssistantContent: () => session.eventEmitter?.getLatestAssistantContent?.() || '',
589
+ },
590
+ onMatch: ({ promptText, matchedPattern }) => {
591
+ this.emit('codex-prompt', {
592
+ sessionId: session.sessionId,
593
+ nativeId: session.nativeId,
594
+ promptText,
595
+ matchedPattern,
596
+ })
597
+ },
598
+ })
599
+ session.detector.start?.()
600
+ } catch (e) {
601
+ console.warn('[pty] codex prompt detector start failed:', e?.message || e)
602
+ session.detector = null
603
+ }
604
+ }
605
+
606
+ // 已知 nativeId 立即同步通知 —— 覆盖三种情况:
607
+ // 1) Claude 新会话:presetClaudeId(randomUUID)
608
+ // 2) Claude --resume:resumeNativeId(沿用 native id)
609
+ // 3) Codex --resume:resumeNativeId
610
+ // Codex 新会话(无 resume 也无 preset)走下面的 fs.watch / 轮询 / regex 三路探测
611
+ if (session.nativeId) {
612
+ this.emit('native-session', { sessionId, nativeId: session.nativeId })
613
+ }
614
+
615
+ // Codex 新会话:codex CLI 无 --session-id / --rollout-path 预置能力。
616
+ // 三路并行探测 native id,首个命中即停(_setNativeId 内部去重 + 清理其余):
617
+ // 1) fs.watch 当日 rollout 目录 —— 首选,通常 <50ms
618
+ // 2) 400ms 轮询 —— 兜底 fs.watch 不可靠的 FS(Docker volume / SMB 等)
619
+ // 3) PTY stdout 正则 —— 再兜底,见下方 proc.onData
620
+ if (!resumeNativeId && tool === 'codex') {
621
+ const spawnTime = Date.now() - 1000
622
+
623
+ session.fsWatcher = this.codexWatcherFactory(spawnTime, (id) => {
624
+ this._setNativeId(session, id)
625
+ })
626
+
627
+ let detectAttempts = 0
628
+ console.log(`[codex-detect] poll started session=${sessionId} spawnTime=${spawnTime}`)
629
+ session.detectTimer = setInterval(() => {
630
+ detectAttempts++
631
+ if (session.nativeId) {
632
+ clearInterval(session.detectTimer)
633
+ session.detectTimer = null
634
+ return
635
+ }
636
+ const id = detectCodexSessionFromFs(spawnTime)
637
+ if (id) {
638
+ console.log(`[codex-detect] poll attempt=${detectAttempts} found nativeId=${id}`)
639
+ this._setNativeId(session, id)
640
+ } else if (detectAttempts >= 30) {
641
+ console.warn(`[codex-detect] poll GAVE UP after 30 attempts (12s) for session=${sessionId} — codex never wrote a rollout matching afterMs=${spawnTime}; check ~/.codex/sessions/$(date +%Y/%m/%d)/`)
642
+ clearInterval(session.detectTimer)
643
+ session.detectTimer = null
644
+ } else if (detectAttempts === 1 || detectAttempts === 5 || detectAttempts === 15) {
645
+ console.log(`[codex-detect] poll attempt=${detectAttempts} no match yet`)
646
+ }
647
+ }, 400)
648
+ session.detectTimer.unref?.()
649
+ }
650
+
651
+ proc.onData((data) => {
652
+ session.fullLog.push(data)
653
+ session.logBytes += data.length
654
+ while (session.logBytes > MAX_LOG_BYTES && session.fullLog.length > 1) {
655
+ const removed = session.fullLog.shift()
656
+ session.logBytes -= removed.length
657
+ }
658
+ const stripped = data
659
+ .replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '')
660
+ .replace(/\x1b\][^\x07]*\x07/g, '')
661
+ .replace(/\x1b[()#][A-Za-z0-9]/g, '')
662
+ .replace(/\x1b[>=<cDEHMNOPZ78]/g, '')
663
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
664
+ const sessionRe = tool === 'codex' ? CODEX_SESSION_RE : CLAUDE_SESSION_RE
665
+ const m = stripped.match(sessionRe)
666
+ if (m) this._setNativeId(session, m[1])
667
+ // TUI 兜底检测:只在 claude 上看,30s 内同一 session 只推一次
668
+ if (tool === 'claude' && TUI_FOOTER_RE.test(stripped)) {
669
+ const now = Date.now()
670
+ if (now - session.lastTuiAlertAt > TUI_ALERT_COOLDOWN_MS) {
671
+ session.lastTuiAlertAt = now
672
+ this.emit('tui-detected', { sessionId, tool })
673
+ }
674
+ }
675
+ this.emit('output', { sessionId, data })
676
+ })
677
+
678
+ // claude 专属:监听 ~/.claude/projects/<encoded>/<uuid>.jsonl 末行类型,
679
+ // 作为 awaitingReply 的"真相源"兜底 stop hook:
680
+ // - 末行 type=='user' 且非 tool_result → 真用户输入 → claude-turn-started
681
+ // - 末行 type=='assistant' 且 stop_reason=='end_turn' → 真轮次完成 → claude-turn-done
682
+ // - 中间态(tool_use / tool_result)跳过,等下一拍
683
+ // 解决两个 bug:
684
+ // 1) 第一轮 prompt 是后端 proc.write 直写,绕过 awaitingReply 复位路径,
685
+ // stop hook fire 完后 awaitingReply=true 就再也回不到 false → 假 idle
686
+ // 2) stop hook 偶发不 fire 时,没有任何信号让前端从 running 切到 idle
687
+ // 与 stop hook 并存,markSessionAwaitingReply 自身幂等。
688
+ if (tool === 'claude') {
689
+ session.claudeLastJsonlMtimeMs = 0
690
+ session.claudeJsonlPath = null
691
+ session.claudeLastEmittedKind = null
692
+ session.claudeWatchTimer = setInterval(() => {
693
+ try {
694
+ const nativeId = session.nativeId
695
+ if (!nativeId) return
696
+ if (!session.claudeJsonlPath) {
697
+ const located = this.claudeSessionLocator(nativeId)
698
+ if (!located?.filePath) return
699
+ session.claudeJsonlPath = located.filePath
700
+ }
701
+ const jsonlPath = session.claudeJsonlPath
702
+ if (!existsSync(jsonlPath)) return
703
+ const st = statSync(jsonlPath)
704
+ if (st.mtimeMs <= session.claudeLastJsonlMtimeMs) return
705
+ const content = readFileSync(jsonlPath, 'utf8')
706
+ // 反向扫,跳过 system / attachment / last-prompt 等元数据行,
707
+ // 找最近一条 type ∈ {user, assistant} 的有效行。
708
+ const lines = content.split('\n')
709
+ let kind = null // 'turn-started' | 'turn-done' | null
710
+ for (let i = lines.length - 1; i >= 0; i--) {
711
+ const line = lines[i].trim()
712
+ if (!line || !line.startsWith('{')) continue
713
+ let obj
714
+ try { obj = JSON.parse(line) } catch { continue }
715
+ const t = obj.type
716
+ if (t !== 'user' && t !== 'assistant') continue
717
+ const content = obj.message?.content
718
+ const blocks = Array.isArray(content) ? content : []
719
+ if (t === 'user') {
720
+ // 不区分 tool_result vs 真用户输入:两者都意味着 "Claude 正在/即将处理"
721
+ // → awaitingReply=false。tool_result 是 Claude 自家 tool_use → tool_result 闭环,
722
+ // 看到 tool_result 说明上一拍的 assistant 还会继续追加内容,没结束。
723
+ //
724
+ // 例外:Claude Code 在用户 Esc/Ctrl+C 打断时会写入一条 type=user 的消息,
725
+ // 内容里带 "[Request interrupted by user" 文本(可能在 string content 里,也
726
+ // 可能落在某个 tool_result.content 里)。这种情况 Stop hook 不会 fire,
727
+ // stop_reason 也不会是 end_turn → 不识别就 stuck-running。这里检出 marker
728
+ // 后把它当作"轮次已结束"看,让上层走 markSessionAwaitingReply(true) 路径。
729
+ const contentText = typeof content === 'string'
730
+ ? content
731
+ : blocks.map(b => {
732
+ if (!b) return ''
733
+ if (typeof b.text === 'string') return b.text
734
+ if (typeof b.content === 'string') return b.content
735
+ return ''
736
+ }).join('\n')
737
+ if (contentText.includes('[Request interrupted by user')) {
738
+ kind = 'turn-done'
739
+ } else {
740
+ kind = 'turn-started'
741
+ }
742
+ } else {
743
+ // assistant:仅 stop_reason==='end_turn' 才算真完成;tool_use / max_tokens / null 都是中间态
744
+ const sr = obj.message?.stop_reason
745
+ if (sr === 'end_turn') kind = 'turn-done'
746
+ else continue // 中间 assistant,继续往前找上一条 user
747
+ }
748
+ break
749
+ }
750
+ if (!kind) {
751
+ session.claudeLastJsonlMtimeMs = st.mtimeMs
752
+ return
753
+ }
754
+ if (kind === session.claudeLastEmittedKind) {
755
+ session.claudeLastJsonlMtimeMs = st.mtimeMs
756
+ return
757
+ }
758
+ session.claudeLastJsonlMtimeMs = st.mtimeMs
759
+ session.claudeLastEmittedKind = kind
760
+ if (kind === 'turn-started') {
761
+ this.emit('claude-turn-started', { sessionId, nativeId })
762
+ } else {
763
+ this.emit('claude-turn-done', { sessionId, nativeId })
764
+ }
765
+ } catch { /* ignore — watcher 不能影响 PTY 主链路 */ }
766
+ }, 2000)
767
+ session.claudeWatchTimer.unref?.()
768
+ }
769
+
770
+ // cursor 专属:监听 chatId 的 jsonl,末行 role===assistant 且 mtime 推进
771
+ // → 一轮回复结束。这里走轮询而不是依赖 cursor 自家 stop hook,是因为
772
+ // 实测 cursor 的 stop hook 偶发不 fire(同一 cursor 安装,部分 session 完全
773
+ // 收不到 stop 事件),靠 jsonl 才能做到稳定 100%。
774
+ if (tool === 'cursor') {
775
+ session.cursorLastSeenMtimeMs = 0
776
+ session.cursorPendingDone = false
777
+ session.cursorWatchTimer = setInterval(() => {
778
+ try {
779
+ const nativeId = session.nativeId
780
+ if (!nativeId) return
781
+ const jsonlPath = cursorTranscriptPath(session.cwd, nativeId)
782
+ if (!jsonlPath || !existsSync(jsonlPath)) return
783
+ const st = statSync(jsonlPath)
784
+
785
+ if (st.mtimeMs === session.cursorLastSeenMtimeMs) {
786
+ // mtime 没变 → 文件已稳定;如果之前看到了 assistant 末行,现在可以安全 emit
787
+ if (session.cursorPendingDone) {
788
+ session.cursorPendingDone = false
789
+ this.emit('cursor-turn-done', { sessionId, nativeId })
790
+ }
791
+ return
792
+ }
793
+
794
+ // mtime 变了 → cursor 还在写,读文件判断当前状态但不急着 emit
795
+ session.cursorLastSeenMtimeMs = st.mtimeMs
796
+ const content = readFileSync(jsonlPath, 'utf8')
797
+ const idx = content.lastIndexOf('\n', content.length - 2)
798
+ const lastLine = (idx >= 0 ? content.slice(idx + 1) : content).trim()
799
+ if (!lastLine) return
800
+ let role = null
801
+ try { role = JSON.parse(lastLine)?.role || null } catch { return }
802
+ if (role === 'assistant') {
803
+ session.cursorPendingDone = true
804
+ } else {
805
+ session.cursorPendingDone = false
806
+ }
807
+ } catch { /* ignore — watcher 不能影响 PTY 主链路 */ }
808
+ }, 2000)
809
+ session.cursorWatchTimer.unref?.()
810
+ }
811
+
812
+ // size-first 路径:spawn 时已经是真实尺寸,prompt 不再依赖 resize 触发,
813
+ // 直接按 promptDelayMs(构造器默认 2000ms,可被测试 / 调用方覆盖)发送即可。
814
+ if (session.pendingPrompt) {
815
+ session.promptTimer = setTimeout(() => {
816
+ if (session.pendingPrompt) {
817
+ proc.write(session.pendingPrompt + '\r')
818
+ session.pendingPrompt = null
819
+ }
820
+ }, this.promptDelayMs)
821
+ session.resized = true // 标记 prompt 路径已被 startWithSize 接管,不再由 resize() 驱动
822
+ }
823
+
824
+ proc.onExit(({ exitCode }) => {
825
+ if (session.detectTimer) clearInterval(session.detectTimer)
826
+ if (session.promptTimer) clearTimeout(session.promptTimer)
827
+ if (session.cursorWatchTimer) { clearInterval(session.cursorWatchTimer); session.cursorWatchTimer = null }
828
+ if (session.claudeWatchTimer) { clearInterval(session.claudeWatchTimer); session.claudeWatchTimer = null }
829
+ if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
830
+ if (session.detector) { try { session.detector.stop?.() } catch { /* ignore */ } session.detector = null }
831
+ if (session.eventEmitter) {
832
+ // codex 在 jsonl 里没有"会话整体结束"的事件,只有 task_complete(一轮)和
833
+ // 进程实际退出。这里合成 SessionEnd 抛给上层,对应 IM 里的 ✅ + 全量 transcript 附件。
834
+ if (session.tool === 'codex' && session.nativeId) {
835
+ try {
836
+ session.eventEmitter.emitSynthetic?.({
837
+ event: 'SessionEnd',
838
+ nativeId: session.nativeId,
839
+ rawEventPayload: { exitCode: exitCode ?? 1 },
840
+ })
841
+ } catch { /* ignore */ }
842
+ }
843
+ try { session.eventEmitter.stop?.() } catch { /* ignore */ }
844
+ session.eventEmitter = null
845
+ }
846
+ if (this.sidecar && session.tool === 'codex' && session.nativeId) {
847
+ try { this.sidecar.clear(session.nativeId) } catch { /* ignore */ }
848
+ }
849
+ const fullLog = session.fullLog.join('')
850
+ this.sessions.delete(sessionId)
851
+ this.emit('done', {
852
+ sessionId,
853
+ exitCode: exitCode ?? 1,
854
+ fullLog,
855
+ nativeId: session.nativeId,
856
+ stopped: session.stopped,
857
+ })
858
+ })
859
+
860
+ // 释放对 args/env 等较大对象的引用(已经被 ptyFactory 闭包持有了)。
861
+ session.spawnSpec = null
862
+ }
863
+
864
+ /**
865
+ * 向后兼容入口:把老 start() 的语义维持成 create() + startWithSize(80, 24)。
866
+ * 现有的 route / 测试 / CLI 调用不需要改,只是 PTY 会先在 80×24 上开。
867
+ * size-first 握手路径请改用 create() + startWithSize(realCols, realRows)。
868
+ */
869
+ async start(opts) {
870
+ this.create(opts)
871
+ await this.startWithSize(opts.sessionId, 80, 24)
872
+ }
873
+
874
+ startShell({ sessionId, shell, cwd }) {
875
+ const effectiveCwd = cwd || process.env.HOME || process.cwd()
876
+ let proc
877
+ try {
878
+ proc = this.ptyFactory(shell, [], {
879
+ name: 'xterm-256color',
880
+ cols: 80,
881
+ rows: 24,
882
+ cwd: effectiveCwd,
883
+ env: {
884
+ ...process.env,
885
+ TERM: 'xterm-256color',
886
+ TZ: process.env.TZ || 'America/Los_Angeles',
887
+ FORCE_COLOR: '1',
888
+ ...(process.env.AGENTQUAD_KEEP_CJK_LOCALE === '1' ? {} : {
889
+ LANG: 'en_US.UTF-8',
890
+ LC_CTYPE: 'en_US.UTF-8',
891
+ }),
892
+ },
893
+ })
894
+ } catch (error) {
895
+ error.message = `PTY spawn failed for shell (bin=${shell}, cwd=${effectiveCwd}): ${error.message}`
896
+ throw error
897
+ }
898
+
899
+ const session = {
900
+ proc,
901
+ tool: 'shell',
902
+ sessionId,
903
+ fullLog: [],
904
+ logBytes: 0,
905
+ pendingPrompt: null,
906
+ resized: false,
907
+ promptTimer: null,
908
+ nativeId: null,
909
+ stopped: false,
910
+ detectTimer: null,
911
+ }
912
+ this.sessions.set(sessionId, session)
913
+
914
+ proc.onData((data) => {
915
+ session.fullLog.push(data)
916
+ session.logBytes += data.length
917
+ while (session.logBytes > MAX_LOG_BYTES && session.fullLog.length > 1) {
918
+ const removed = session.fullLog.shift()
919
+ session.logBytes -= removed.length
920
+ }
921
+ this.emit('output', { sessionId, data })
922
+ })
923
+
924
+ proc.onExit(({ exitCode }) => {
925
+ const fullLog = session.fullLog.join('')
926
+ this.sessions.delete(sessionId)
927
+ this.emit('done', {
928
+ sessionId,
929
+ exitCode: exitCode ?? 0,
930
+ fullLog,
931
+ nativeId: null,
932
+ stopped: session.stopped,
933
+ })
934
+ })
935
+ }
936
+
937
+ write(sessionId, data) {
938
+ const s = this.sessions.get(sessionId)
939
+ if (s) s.proc.write(data)
940
+ }
941
+
942
+ resize(sessionId, cols, rows) {
943
+ const s = this.sessions.get(sessionId)
944
+ if (!s) return
945
+ try { s.proc.resize(cols, rows) } catch { /* ignore */ }
946
+ if (!s.resized && s.pendingPrompt) {
947
+ s.resized = true
948
+ if (s.promptTimer) { clearTimeout(s.promptTimer); s.promptTimer = null }
949
+ setTimeout(() => {
950
+ if (s.pendingPrompt) {
951
+ s.proc.write(s.pendingPrompt + '\r')
952
+ s.pendingPrompt = null
953
+ }
954
+ }, this.promptDelayMs)
955
+ }
956
+ }
957
+
958
+ stop(sessionId) {
959
+ const s = this.sessions.get(sessionId)
960
+ if (!s) return
961
+ s.stopped = true
962
+ // Codex 侧的 sidecar/emitter 在这里同步清理 —— onExit 也会再做一次(幂等)。
963
+ // 之所以提前清,是因为某些 PTY 实现的 kill() 不一定会准时触发 onExit(测试环境
964
+ // 用 mock proc 完全不触发),sidecar 残留会让下次 boot 误以为会话还在。
965
+ if (s.detector) { try { s.detector.stop?.() } catch { /* ignore */ } s.detector = null }
966
+ if (s.eventEmitter) { try { s.eventEmitter.stop?.() } catch { /* ignore */ } s.eventEmitter = null }
967
+ if (this.sidecar && s.tool === 'codex' && s.nativeId) {
968
+ try { this.sidecar.clear(s.nativeId) } catch { /* ignore */ }
969
+ }
970
+ if (s.proc) {
971
+ try { s.proc.kill() } catch { /* ignore */ }
972
+ // cleanup of this.sessions entry happens in onExit (which also emits 'done')
973
+ } else {
974
+ // Not-yet-spawned session (create() called but startWithSize() not yet, or it failed):
975
+ // no proc to kill, no onExit will fire — clean up timers/watchers, delete the record,
976
+ // and emit a synthetic 'done' so route-level cleanup runs (same lifecycle event
977
+ // a spawned-then-killed session would produce).
978
+ if (s.promptTimer) { try { clearTimeout(s.promptTimer) } catch { /* ignore */ } s.promptTimer = null }
979
+ if (s.detectTimer) { try { clearInterval(s.detectTimer) } catch { /* ignore */ } s.detectTimer = null }
980
+ if (s.cursorWatchTimer) { try { clearInterval(s.cursorWatchTimer) } catch { /* ignore */ } s.cursorWatchTimer = null }
981
+ if (s.fsWatcher) { try { s.fsWatcher.close() } catch { /* ignore */ } s.fsWatcher = null }
982
+ this.sessions.delete(sessionId)
983
+ this.emit('done', {
984
+ sessionId,
985
+ exitCode: 0,
986
+ fullLog: '',
987
+ nativeId: s.nativeId || null,
988
+ stopped: true,
989
+ })
990
+ }
991
+ }
992
+ }