agentquad 0.4.5 → 0.4.7

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.
@@ -24,34 +24,62 @@ const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
24
24
  const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
25
25
  const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
26
26
  const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
27
+ // Unicode Block Elements (U+2580-259F):▀▁▂▃▄▅▆▇█ ▉▊▋▌▍▎▏ ▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟
28
+ // Cursor TUI 用这些字符画状态栏 / 进度条 / 边框,连一串看起来就是大片黑条。
29
+ const BOX_BLOCK = /[▀-▟]/g
27
30
 
28
31
  // Claude TUI 噪声 —— 与 openclaw-hook.js 保持同步
29
32
  const SPINNER_CHARS_STR = '✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈'
30
33
  // "Brewing for 3m" / "Skedaddled for 5s" / "Cooked." 这类 spinner 状态行
31
34
  const STATUS_KEYWORDS = /\b[A-Z][a-z]{2,19}(?:ing|ed)\s+for\s+/
32
35
  const STATUS_VERB_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]*\s*[A-Z][a-z]{2,19}(?:ing|ed)\s*(…|\.\.\.|\.\.|\.)\s*$/
36
+ // 真实形态:"✽ Embellishing… 7 303 thinking more"——spinner + 动词 + 后面一堆杂物
37
+ // 老的 STATUS_VERB_LINE 要求整行只有 spinner+verb,匹配不上。这条更宽松:spinner 起头 + 动词,
38
+ // 后面爱写啥写啥都丢掉。
39
+ const SPINNER_PROGRESS_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]\s+[A-Z][a-z]{2,19}(?:ing|ed)\b/
33
40
  // 行首单独的指示符行(不带任何内容)
34
41
  const TUI_PROMPT_LINE = /^\s*[❯⏵►→]\s*$/
35
42
  const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
36
43
  const BORDER_ONLY = /^[\s\-=_|+~]+$/
37
44
 
45
+ // Claude 真权限框/选择器底部固定 footer(cleanPtyTail 不会过滤掉这行)。
46
+ // 跟 claude-prompt-detector 里的 CLAUDE_PERMISSION_FOOTER 是同一份语义。
47
+ const CLAUDE_FOOTER_RE = /Esc\s+to\s+cancel|Tab\s+to\s+amend|Tab\s+to\s+select/i
48
+
38
49
  // 已知的"该停下来等用户"锚点。命中后我们围绕它取窗口,避免把锚点前的 prompt
39
- // 文本(Bash 命令、文件路径、warning 等)切掉。多语言都列上,省得后续再扩。
50
+ // 文本(Bash 命令、文件路径、warning 等)切掉。
51
+ //
52
+ // 通用化(用户回归:edit pty.js 那条没命中,因为老 whitelist 漏了一些措辞):
53
+ // Claude 的标准提问全部是 "Do you want to <verb> ...?" 句型——proceed / make this
54
+ // edit / make this change / create / write / install / run / ... whitelist 永远追不
55
+ // 上 Claude 的新词。直接放宽成 `/Do you want to/i` 这一条通用 pattern,配合
56
+ // footer-at-bottom + ≥2 数字选项的强守卫已经够区分 AI 自由回复(AI 回复不会
57
+ // 末尾带字面 "Esc to cancel · Tab to amend")。
58
+ //
59
+ // 老的 Codex 单行 `[y/N]` / `apply patch?` 也留着;不影响 Claude 路径。
40
60
  const PERMISSION_ANCHORS = [
41
- /Do you want to proceed/i,
42
- /Do you want to make this edit/i,
43
- /Do you want to make this change/i,
44
- /Do you want to create/i,
61
+ /Do you want to\b/i, // 通用 Claude 提问
45
62
  /Allow this/i,
46
- /apply patch\?/i,
47
- /run this command\?/i,
63
+ /apply patch\?/i, // legacy codex
64
+ /run this command\?/i, // legacy codex
48
65
  /Approve\??/i,
49
- /\?\s*\[[yYnN]\/[yYnN]\]/,
66
+ /\?\s*\[[yYnN]\/[yYnN]\]/, // legacy codex y/N
50
67
  /(允许|批准|授权).*\?/,
51
68
  ]
52
69
 
53
- function stripAnsi(s) {
70
+ // Claude Code (ink/yoga) TUI 用 CUF(cursor forward, `\x1b[NC`)和 CUD(cursor down,
71
+ // `\x1b[NB`)做空白对齐,而不是直接打空格/换行。如果先无脑 strip 掉 CSI,对齐空白
72
+ // 就跟着没了——"Do you want to proceed" 会变成 "Doyouwanttoproceed",PERMISSION_ANCHORS
73
+ // 这种带字面量空格的 regex 全部失配,detector 永远 emit 不出来。
74
+ // 修复:strip CSI 之前先把 CUF/CUD 还原成对应数量的空格/换行;缺省参数 N 视作 1。
75
+ function expandCursorMoves(s) {
54
76
  return String(s || '')
77
+ .replace(/\x1b\[(\d*)C/g, (_m, n) => ' '.repeat(Math.min(parseInt(n, 10) || 1, 200)))
78
+ .replace(/\x1b\[(\d*)B/g, (_m, n) => '\n'.repeat(Math.min(parseInt(n, 10) || 1, 50)))
79
+ }
80
+
81
+ function stripAnsi(s) {
82
+ return expandCursorMoves(s)
55
83
  .replace(ANSI_OSC, '')
56
84
  .replace(ANSI_CSI, '')
57
85
  .replace(ANSI_OTHER, '')
@@ -64,6 +92,7 @@ function stripBoxDrawing(s) {
64
92
  .replace(BOX_VERTICAL, '')
65
93
  .replace(BOX_CORNERS, '')
66
94
  .replace(BOX_TEES, '')
95
+ .replace(BOX_BLOCK, '')
67
96
  }
68
97
 
69
98
  function compactBlankLines(s) {
@@ -81,6 +110,7 @@ function isSpinnerOnly(line) {
81
110
 
82
111
  function isNoiseLine(line) {
83
112
  if (STATUS_VERB_LINE.test(line)) return true
113
+ if (SPINNER_PROGRESS_LINE.test(line)) return true
84
114
  if (STATUS_KEYWORDS.test(line)) return true
85
115
  if (TUI_PROMPT_LINE.test(line)) return true
86
116
  if (AUTO_MODE_LINE.test(line)) return true
@@ -137,19 +167,71 @@ function findAnchorIndex(lines) {
137
167
  }
138
168
 
139
169
  /**
140
- * 从清洗后的 lines 取一个"覆盖授权 prompt 的窗口"
141
- * - 找到锚点:起点 = anchor - maxLines*0.7,终点 = anchor + maxLines*0.3 + 1
142
- * (要把选项行也带进来)
143
- * - 没找到锚点:直接取尾部 maxLines
170
+ * 严格的"真权限框"窗口定位:
171
+ *
172
+ * 1. footer (Esc to cancel · Tab to amend) 必须在最后 5 行 —— 屏幕**当前**正在显示
173
+ * 权限框,不是缓冲深处某次老 prompt 的残骸;
174
+ * 2. footer 上面 maxBack 行内必须找到 anchor (Do you want to ...);
175
+ * 3. anchor 和 footer 之间必须有 ≥2 个数字选项 (1. Yes / 2. No)。
176
+ *
177
+ * 三个信号全在一个紧凑、顺序正确的窗口里才认。这条规则把
178
+ * "AI 自由回复里恰好出现 anchor、缓冲老地方有 footer、又有 markdown 数字列表"
179
+ * 这种零散信号拼出来的假阳性挡掉。
180
+ *
181
+ * 命中:返回 { startIdx, footerIdx, options };不命中:null。
144
182
  */
145
- function takeWindow(lines, maxLines) {
146
- const idx = findAnchorIndex(lines)
147
- if (idx >= 0) {
148
- const back = Math.floor(maxLines * 0.7)
149
- const fwd = Math.ceil(maxLines * 0.3)
150
- return lines.slice(Math.max(0, idx - back), Math.min(lines.length, idx + fwd + 1))
183
+ function findStrictPermissionWindow(lines, { maxBack = 15, contextLinesBeforeAnchor = 8 } = {}) {
184
+ // 1) 找缓冲里"最后"一次出现的 footer(不再硬限末 5 行 —— Claude TUI 把 TodoWrite
185
+ // 状态面板、active 任务列表等渲染在 prompt 之后,footer 会被它们顶到中间)。
186
+ // "最后一次"语义:如果缓冲里有多个老 prompt,用最新的那个的 footer。
187
+ // 紧凑性 (anchor footer 上方 15 行内) + 数字选项的约束已经够区分 AI 自由
188
+ // 回复(自由回复不会刚好"anchor + ≥2 数字选项 + Esc to cancel"全部出现在
189
+ // 15 行紧凑窗口里)。
190
+ let footerIdx = -1
191
+ for (let i = lines.length - 1; i >= 0; i--) {
192
+ if (CLAUDE_FOOTER_RE.test(lines[i])) { footerIdx = i; break }
193
+ }
194
+ if (footerIdx < 0) return null
195
+
196
+ // 2) footer 上方 maxBack 行内找 anchor
197
+ const searchFloor = Math.max(0, footerIdx - maxBack)
198
+ let anchorIdx = -1
199
+ for (let i = footerIdx - 1; i >= searchFloor; i--) {
200
+ if (PERMISSION_ANCHORS.some((re) => re.test(lines[i]))) { anchorIdx = i; break }
151
201
  }
152
- return lines.slice(-maxLines)
202
+ if (anchorIdx < 0) return null
203
+
204
+ // 3) anchor → footer 之间 ≥2 数字选项
205
+ const options = []
206
+ const seen = new Map()
207
+ for (let i = anchorIdx + 1; i < footerIdx; i++) {
208
+ const m = lines[i].match(/^\s*([1-9])\.\s+(\S.{0,79}?)\s*$/)
209
+ if (!m) continue
210
+ const idx = parseInt(m[1], 10)
211
+ const label = m[2].trim()
212
+ if (!label || seen.has(idx)) continue
213
+ seen.set(idx, label)
214
+ options.push({ index: idx, label })
215
+ }
216
+ if (options.length < 2) return null
217
+
218
+ // 4) 起点往 anchor 上方再退一段(典型形态:Bash command / Edit file path / description
219
+ // 等几行在 anchor 上方)。stop 在空行或第二个连续空行,避免把上一帧 chat 内容卷进来。
220
+ let startIdx = anchorIdx
221
+ let blanksSeen = 0
222
+ for (let i = anchorIdx - 1; i >= Math.max(0, anchorIdx - contextLinesBeforeAnchor); i--) {
223
+ const isBlank = !lines[i].trim()
224
+ if (isBlank) {
225
+ blanksSeen++
226
+ if (blanksSeen >= 2) break // 两个空行 = 工具盒上面,截断
227
+ startIdx = i
228
+ continue
229
+ }
230
+ blanksSeen = 0
231
+ startIdx = i
232
+ }
233
+
234
+ return { startIdx, footerIdx, options }
153
235
  }
154
236
 
155
237
  /**
@@ -210,23 +292,23 @@ export function extractPermissionPrompt(
210
292
  ) {
211
293
  function extract(source) {
212
294
  const cleaned = cleanPtyTail(source)
213
- if (!cleaned) return ''
295
+ if (!cleaned) return { text: '', options: [] }
214
296
  const lines = cleaned.split('\n')
215
- const window = takeWindow(lines, maxLines)
216
- let text = window.join('\n').trim()
297
+ const m = findStrictPermissionWindow(lines, { maxBack: maxLines, footerTailRange: 5 })
298
+ if (!m) return { text: '', options: [] }
299
+ let text = lines.slice(m.startIdx, m.footerIdx + 1).join('\n').trim()
217
300
  if (text.length > maxChars) text = text.slice(-maxChars)
218
- return text
301
+ return { text, options: m.options }
219
302
  }
220
303
 
221
- let text = extract(raw)
222
- // 主源里没有锚点或太瘦回退完整历史尾部
223
- const hasAnchor = (s) => PERMISSION_ANCHORS.some((re) => re.test(s))
224
- if ((!text || text.length < 40 || !hasAnchor(text)) && historicalRaw) {
304
+ let { text, options } = extract(raw)
305
+ // 主源里 strict window 没命中 回退完整历史尾部再试一次
306
+ if ((!text || options.length < 2) && historicalRaw) {
225
307
  const fallback = extract(historicalRaw)
226
- if (fallback && (hasAnchor(fallback) || fallback.length > text.length)) {
227
- text = fallback
308
+ if (fallback.text && fallback.options.length >= 2) {
309
+ text = fallback.text
310
+ options = fallback.options
228
311
  }
229
312
  }
230
- const options = parsePermissionOptions(text)
231
313
  return { text, options }
232
314
  }
package/src/pty.js CHANGED
@@ -6,6 +6,7 @@ import { readdirSync, statSync, existsSync, unlinkSync, watch as fsWatch, mkdirS
6
6
  import { delimiter, dirname, isAbsolute, join } from 'node:path'
7
7
  import { homedir } from 'node:os'
8
8
  import { createCodexPromptDetector } from './codex-prompt-detector.js'
9
+ import { createClaudePromptDetector } from './claude-prompt-detector.js'
9
10
 
10
11
  const require = createRequire(import.meta.url)
11
12
 
@@ -325,7 +326,7 @@ function defaultClaudeSessionLocator(nativeSessionId) {
325
326
  }
326
327
 
327
328
  export class PtyManager extends EventEmitter {
328
- constructor({ tools, ptyFactory, promptDelayMs = 2000, codexWatcherFactory, claudeSessionLocator, codexSessionLocator, sidecar = null, eventEmitterFactory = null, codexPromptDetectorFactory = null } = {}) {
329
+ constructor({ tools, ptyFactory, promptDelayMs = 2000, codexWatcherFactory, claudeSessionLocator, codexSessionLocator, sidecar = null, eventEmitterFactory = null, codexPromptDetectorFactory = null, claudePromptDetectorFactory = null } = {}) {
329
330
  super()
330
331
  if (!tools) throw new Error('PtyManager: tools required')
331
332
  this.tools = tools
@@ -337,6 +338,7 @@ export class PtyManager extends EventEmitter {
337
338
  this.sidecar = sidecar
338
339
  this.eventEmitterFactory = eventEmitterFactory
339
340
  this.codexPromptDetectorFactory = codexPromptDetectorFactory || createCodexPromptDetector
341
+ this.claudePromptDetectorFactory = claudePromptDetectorFactory || createClaudePromptDetector
340
342
  this.sessions = new Map()
341
343
  }
342
344
 
@@ -357,57 +359,62 @@ export class PtyManager extends EventEmitter {
357
359
  if (session.detectTimer) { clearInterval(session.detectTimer); session.detectTimer = null }
358
360
  if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
359
361
  this.emit('native-session', { sessionId: session.sessionId, nativeId })
360
- // codex 专属:拿到 native id 后落 sidecar + 启动 jsonl 增量 emitter,给 IM 推送链路用。
361
- if (session.tool === 'codex') {
362
- console.log(`[codex-detect] _setNativeId session=${session.sessionId} nativeId=${nativeId}`)
363
- if (this.sidecar) {
364
- try {
365
- const p = this.sidecar.write({
366
- nativeId,
367
- quadtodoSessionId: session.sessionId,
368
- todoId: session.todoId || null,
369
- cwd: session.cwd || null,
370
- })
371
- if (p && typeof p.catch === 'function') p.catch(() => {})
372
- console.log(`[codex-detect] sidecar.write OK nativeId=${nativeId}`)
373
- } catch (e) {
374
- console.warn(`[codex-detect] sidecar.write FAILED:`, e?.message || e)
375
- }
376
- } else {
377
- console.warn(`[codex-detect] this.sidecar is null — server.js didn't wire it`)
362
+ this._ensureCodexSidecarAndEmitter(session, nativeId)
363
+ return true
364
+ }
365
+
366
+ // 把 codex sidecar.write + emitter.start 抽出来,方便 codex resume 路径直接调
367
+ // (resume session.nativeId 在 spawn 阶段就预置好,_setNativeId 里那个
368
+ // "已经一样了 → return false" 早早短路,emitter 永远起不来 → 状态条卡"运行中")。
369
+ _ensureCodexSidecarAndEmitter(session, nativeId) {
370
+ if (session.tool !== 'codex' || !nativeId) return
371
+ console.log(`[codex-detect] ensure sidecar+emitter session=${session.sessionId} nativeId=${nativeId}`)
372
+ if (this.sidecar) {
373
+ try {
374
+ const p = this.sidecar.write({
375
+ nativeId,
376
+ quadtodoSessionId: session.sessionId,
377
+ todoId: session.todoId || null,
378
+ cwd: session.cwd || null,
379
+ })
380
+ if (p && typeof p.catch === 'function') p.catch(() => {})
381
+ console.log(`[codex-detect] sidecar.write OK nativeId=${nativeId}`)
382
+ } catch (e) {
383
+ console.warn(`[codex-detect] sidecar.write FAILED:`, e?.message || e)
378
384
  }
379
- if (this.eventEmitterFactory && !session.eventEmitter) {
380
- try {
381
- const loc = this.codexSessionLocator(nativeId)
382
- if (loc?.filePath) {
383
- session.eventEmitter = this.eventEmitterFactory({ filePath: loc.filePath, nativeId })
384
- session.eventEmitter.start?.()
385
- console.log(`[codex-detect] emitter started filePath=${loc.filePath}`)
386
- } else {
387
- console.warn(`[codex-detect] codexSessionLocator returned null for nativeId=${nativeId} — emitter NOT started (will retry below)`)
388
- // jsonl 文件这一刻可能还没 flush 到 fs;500ms / 1500ms 各重试一次。
389
- const retry = (delay) => setTimeout(() => {
390
- if (session.eventEmitter || session.stopped) return
391
- const loc2 = this.codexSessionLocator(nativeId)
392
- if (loc2?.filePath && this.eventEmitterFactory) {
393
- session.eventEmitter = this.eventEmitterFactory({ filePath: loc2.filePath, nativeId })
394
- session.eventEmitter.start?.()
395
- console.log(`[codex-detect] emitter started on retry+${delay}ms filePath=${loc2.filePath}`)
396
- } else if (delay < 1500) {
397
- console.warn(`[codex-detect] retry+${delay}ms still no jsonl file for ${nativeId}`)
398
- }
399
- }, delay)
400
- retry(500).unref?.()
401
- retry(1500).unref?.()
402
- }
403
- } catch (e) {
404
- console.warn(`[codex-detect] emitter start FAILED:`, e?.message || e)
385
+ } else {
386
+ console.warn(`[codex-detect] this.sidecar is null — server.js didn't wire it`)
387
+ }
388
+ if (this.eventEmitterFactory && !session.eventEmitter) {
389
+ try {
390
+ const loc = this.codexSessionLocator(nativeId)
391
+ if (loc?.filePath) {
392
+ session.eventEmitter = this.eventEmitterFactory({ filePath: loc.filePath, nativeId })
393
+ session.eventEmitter.start?.()
394
+ console.log(`[codex-detect] emitter started filePath=${loc.filePath}`)
395
+ } else {
396
+ console.warn(`[codex-detect] codexSessionLocator returned null for nativeId=${nativeId} — emitter NOT started (will retry below)`)
397
+ // jsonl 文件这一刻可能还没 flush 到 fs;500ms / 1500ms 各重试一次。
398
+ const retry = (delay) => setTimeout(() => {
399
+ if (session.eventEmitter || session.stopped) return
400
+ const loc2 = this.codexSessionLocator(nativeId)
401
+ if (loc2?.filePath && this.eventEmitterFactory) {
402
+ session.eventEmitter = this.eventEmitterFactory({ filePath: loc2.filePath, nativeId })
403
+ session.eventEmitter.start?.()
404
+ console.log(`[codex-detect] emitter started on retry+${delay}ms filePath=${loc2.filePath}`)
405
+ } else if (delay < 1500) {
406
+ console.warn(`[codex-detect] retry+${delay}ms still no jsonl file for ${nativeId}`)
407
+ }
408
+ }, delay)
409
+ retry(500).unref?.()
410
+ retry(1500).unref?.()
405
411
  }
406
- } else if (!this.eventEmitterFactory) {
407
- console.warn(`[codex-detect] this.eventEmitterFactory is null server.js didn't wire it`)
412
+ } catch (e) {
413
+ console.warn(`[codex-detect] emitter start FAILED:`, e?.message || e)
408
414
  }
415
+ } else if (!this.eventEmitterFactory) {
416
+ console.warn(`[codex-detect] this.eventEmitterFactory is null — server.js didn't wire it`)
409
417
  }
410
- return true
411
418
  }
412
419
 
413
420
  has(sessionId) {
@@ -424,6 +431,13 @@ export class PtyManager extends EventEmitter {
424
431
  }
425
432
 
426
433
  /** 返回当前所有活跃 PTY 的 { sessionId, pid, tool },供 pidusage 采样用 */
434
+ /** Watcher 写在 PtyManager 自己的 session 上的 usage 副本。route 的 sessions Map
435
+ * 和这里不是同一份对象,必须显式 cross-read。返回 null 表示还没解析到。 */
436
+ getUsage(sessionId) {
437
+ const s = this.sessions.get(sessionId)
438
+ return s?.usage || null
439
+ }
440
+
427
441
  getPids() {
428
442
  const out = []
429
443
  for (const [sessionId, s] of this.sessions) {
@@ -458,7 +472,7 @@ export class PtyManager extends EventEmitter {
458
472
  * 会话建立时调 create()、收到前端真实 cols/rows 后再调 startWithSize(),
459
473
  * 这样 PTY 永远不会在默认 80×24 上 spawn 一次再 resize。
460
474
  */
461
- create({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv, mcpConfigPath = null, codexMcpUrl = null }) {
475
+ create({ sessionId, tool, prompt, cwd, resumeNativeId, permissionMode, extraEnv, mcpConfigPath = null, codexMcpUrl = null, suppressStaleTurnDetect = false }) {
462
476
  const toolCfg = this.tools[tool]
463
477
  if (!toolCfg) throw new Error(`unknown tool: ${tool}`)
464
478
  const baseArgs = toolCfg.args || []
@@ -568,6 +582,13 @@ export class PtyManager extends EventEmitter {
568
582
  lastTuiAlertAt: 0,
569
583
  cursorChatPromise,
570
584
  mcpConfigPath: mcpConfigPath || null,
585
+ // 当 ai-terminal 在"运行中"触发托管模式切换(半托管 ↔ 全托管)时置 true:
586
+ // 老 PTY 被 kill 时 jsonl 处于 mid-turn 状态(最后一行往往是 user/tool_result
587
+ // 或 assistant.tool_use),新的 claude --resume 只是接管同一个 jsonl,不会再
588
+ // 真跑一轮。watcher 默认会把这条残留的"turn-started"当成新输入 emit,把刚被
589
+ // ai-terminal 翻成 idle 的状态又翻回 running。这里告诉 watcher:吃掉第一帧
590
+ // stale 状态、只用它来 seed 内部 mtime/kind,下次 jsonl 真正再变才 emit。
591
+ suppressStaleTurnDetect: !!suppressStaleTurnDetect,
571
592
  spawnSpec: {
572
593
  args,
573
594
  env,
@@ -666,6 +687,30 @@ export class PtyManager extends EventEmitter {
666
687
  }
667
688
  }
668
689
 
690
+ // Claude 专属:stdout 提示词检测器,兜底 Notification hook 不 fire 的场景
691
+ // (settings.json permissions.defaultMode='auto' 时 model classifier 决定弹权限框
692
+ // 但 Notification hook 实测不 fire;Notification 是 markPendingConfirm 的唯一上游,
693
+ // 没它就既不翻 pending_confirm 也不推 IM)。
694
+ if (tool === 'claude') {
695
+ try {
696
+ session.detector = this.claudePromptDetectorFactory({
697
+ pty: proc,
698
+ onMatch: ({ promptText, options }) => {
699
+ this.emit('claude-prompt', {
700
+ sessionId: session.sessionId,
701
+ nativeId: session.nativeId,
702
+ promptText,
703
+ options,
704
+ })
705
+ },
706
+ })
707
+ session.detector.start?.()
708
+ } catch (e) {
709
+ console.warn('[pty] claude prompt detector start failed:', e?.message || e)
710
+ session.detector = null
711
+ }
712
+ }
713
+
669
714
  // 已知 nativeId 立即同步通知 —— 覆盖三种情况:
670
715
  // 1) Claude 新会话:presetClaudeId(randomUUID)
671
716
  // 2) Claude --resume:resumeNativeId(沿用 native id)
@@ -673,6 +718,11 @@ export class PtyManager extends EventEmitter {
673
718
  // Codex 新会话(无 resume 也无 preset)走下面的 fs.watch / 轮询 / regex 三路探测
674
719
  if (session.nativeId) {
675
720
  this.emit('native-session', { sessionId, nativeId: session.nativeId })
721
+ // Codex resume 专用:nativeId 早就在 create() 阶段被预置,_setNativeId 里
722
+ // "已经一样了 → return false" 会短路掉 sidecar.write + emitter.start,导致 codex
723
+ // 重启 resume 后 jsonl 不被读、task_complete 事件不上报、UI 状态条永远卡"运行中"。
724
+ // 走 _ensureCodexSidecarAndEmitter 显式补齐(idempotent,emitter 已存在就 no-op)。
725
+ this._ensureCodexSidecarAndEmitter(session, session.nativeId)
676
726
  }
677
727
 
678
728
  // Codex 新会话:codex CLI 无 --session-id / --rollout-path 预置能力。
@@ -802,6 +852,26 @@ export class PtyManager extends EventEmitter {
802
852
  // 反向扫,跳过 system / attachment / last-prompt 等元数据行,
803
853
  // 找最近一条 type ∈ {user, assistant} 的有效行。
804
854
  const lines = content.split('\n')
855
+ // 每次 mtime 推进都刷新 usage(不能等下面的 kind-变化早 return —— 同一轮
856
+ // 内追加 assistant 消息时 kind 不变,会 return 跳过 usage 解析)。
857
+ for (let i = lines.length - 1; i >= 0; i--) {
858
+ const ln = (lines[i] || '').trim()
859
+ if (!ln.startsWith('{')) continue
860
+ let obj
861
+ try { obj = JSON.parse(ln) } catch { continue }
862
+ if (obj.type !== 'assistant') continue
863
+ const u = obj.message?.usage
864
+ if (!u) continue
865
+ session.usage = {
866
+ input: Number(u.input_tokens) || 0,
867
+ output: Number(u.output_tokens) || 0,
868
+ cacheRead: Number(u.cache_read_input_tokens) || 0,
869
+ cacheCreation: Number(u.cache_creation_input_tokens) || 0,
870
+ model: obj.message?.model || null,
871
+ ts: obj.timestamp ? Date.parse(obj.timestamp) : Date.now(),
872
+ }
873
+ break
874
+ }
805
875
  let kind = null // 'turn-started' | 'turn-done' | null
806
876
  for (let i = lines.length - 1; i >= 0; i--) {
807
877
  const line = lines[i].trim()
@@ -853,7 +923,16 @@ export class PtyManager extends EventEmitter {
853
923
  }
854
924
  session.claudeLastJsonlMtimeMs = st.mtimeMs
855
925
  session.claudeLastEmittedKind = kind
926
+ // 托管模式切换重启场景:第一次扫到的 kind 是 kill 前的残留状态,吞掉这次
927
+ // emit 但保留 mtime/kind tracking —— 下次 jsonl 真正变动(用户新输入 / 新一轮
928
+ // 结束)会因为 kind 切换正常 emit。
929
+ if (session.suppressStaleTurnDetect) {
930
+ session.suppressStaleTurnDetect = false
931
+ return
932
+ }
856
933
  if (kind === 'turn-started') {
934
+ // 新一轮开始 → 让 PTY detector 的 lastEmittedText 失效,下一次权限提示能再次 emit
935
+ try { session.detector?.reset?.() } catch { /* ignore */ }
857
936
  this.emit('claude-turn-started', { sessionId, nativeId })
858
937
  } else {
859
938
  this.emit('claude-turn-done', { sessionId, nativeId })
@@ -863,6 +942,58 @@ export class PtyManager extends EventEmitter {
863
942
  session.claudeWatchTimer.unref?.()
864
943
  }
865
944
 
945
+ // codex 专属:mtime-gated 周期扫 rollout-*.jsonl,抽 latest token_count 事件的
946
+ // total_token_usage(cumulative)给 /sessions API 用。跟 claudeWatchTimer 同步频率。
947
+ if (tool === 'codex') {
948
+ session.codexUsageLastMtimeMs = 0
949
+ session.codexUsageWatchTimer = setInterval(() => {
950
+ try {
951
+ const nativeId = session.nativeId
952
+ if (!nativeId) return
953
+ if (!session.codexUsageJsonlPath) {
954
+ const loc = this.codexSessionLocator(nativeId)
955
+ if (!loc?.filePath) return
956
+ session.codexUsageJsonlPath = loc.filePath
957
+ }
958
+ const jsonlPath = session.codexUsageJsonlPath
959
+ if (!existsSync(jsonlPath)) return
960
+ const st = statSync(jsonlPath)
961
+ if (st.mtimeMs <= session.codexUsageLastMtimeMs) return
962
+ session.codexUsageLastMtimeMs = st.mtimeMs
963
+ const lines = readFileSync(jsonlPath, 'utf8').split('\n')
964
+ let last = null
965
+ let model = null
966
+ for (let i = lines.length - 1; i >= 0; i--) {
967
+ const ln = (lines[i] || '').trim()
968
+ if (!ln.startsWith('{')) continue
969
+ let obj
970
+ try { obj = JSON.parse(ln) } catch { continue }
971
+ if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
972
+ const info = obj.payload.info
973
+ if (info?.total_token_usage && !last) last = info.total_token_usage
974
+ }
975
+ if (!model && obj.type === 'turn_context') {
976
+ model = obj.payload?.model || obj.payload?.collaboration_mode?.settings?.model || null
977
+ }
978
+ if (!model && obj.type === 'session_meta') {
979
+ model = obj.payload?.model || obj.payload?.model_provider?.model || null
980
+ }
981
+ if (last && model) break
982
+ }
983
+ if (!last) return
984
+ session.usage = {
985
+ input: Number(last.input_tokens) || 0,
986
+ output: Number(last.output_tokens) || 0,
987
+ cacheRead: Number(last.cached_input_tokens || last.cache_read_input_tokens) || 0,
988
+ cacheCreation: Number(last.cache_creation_input_tokens) || 0,
989
+ model: model || null,
990
+ ts: Date.now(),
991
+ }
992
+ } catch { /* ignore */ }
993
+ }, 2000)
994
+ session.codexUsageWatchTimer.unref?.()
995
+ }
996
+
866
997
  // cursor 专属:监听 chatId 的 jsonl,末行 role===assistant 且 mtime 推进
867
998
  // → 一轮回复结束。这里走轮询而不是依赖 cursor 自家 stop hook,是因为
868
999
  // 实测 cursor 的 stop hook 偶发不 fire(同一 cursor 安装,部分 session 完全
@@ -922,6 +1053,7 @@ export class PtyManager extends EventEmitter {
922
1053
  if (session.promptTimer) clearTimeout(session.promptTimer)
923
1054
  if (session.cursorWatchTimer) { clearInterval(session.cursorWatchTimer); session.cursorWatchTimer = null }
924
1055
  if (session.claudeWatchTimer) { clearInterval(session.claudeWatchTimer); session.claudeWatchTimer = null }
1056
+ if (session.codexUsageWatchTimer) { clearInterval(session.codexUsageWatchTimer); session.codexUsageWatchTimer = null }
925
1057
  if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
926
1058
  if (session.detector) { try { session.detector.stop?.() } catch { /* ignore */ } session.detector = null }
927
1059
  if (session.eventEmitter) {
@@ -1078,6 +1210,8 @@ export class PtyManager extends EventEmitter {
1078
1210
  if (s.promptTimer) { try { clearTimeout(s.promptTimer) } catch { /* ignore */ } s.promptTimer = null }
1079
1211
  if (s.detectTimer) { try { clearInterval(s.detectTimer) } catch { /* ignore */ } s.detectTimer = null }
1080
1212
  if (s.cursorWatchTimer) { try { clearInterval(s.cursorWatchTimer) } catch { /* ignore */ } s.cursorWatchTimer = null }
1213
+ if (s.claudeWatchTimer) { try { clearInterval(s.claudeWatchTimer) } catch { /* ignore */ } s.claudeWatchTimer = null }
1214
+ if (s.codexUsageWatchTimer) { try { clearInterval(s.codexUsageWatchTimer) } catch { /* ignore */ } s.codexUsageWatchTimer = null }
1081
1215
  if (s.fsWatcher) { try { s.fsWatcher.close() } catch { /* ignore */ } s.fsWatcher = null }
1082
1216
  // Cleanup runtime MCP config file (Task 10)
1083
1217
  if (s.mcpConfigPath) {