agentquad 0.4.4 → 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.
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
 
@@ -64,6 +65,7 @@ const TUI_ALERT_COOLDOWN_MS = 30_000
64
65
  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
66
  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
67
  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$/
68
+ const CLAUDE_JSONL_FILE_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/
67
69
  const MAX_LOG_BYTES = 512 * 1024
68
70
  const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
69
71
 
@@ -143,6 +145,44 @@ function detectCodexSessionFromFs(afterMs) {
143
145
  return newest
144
146
  }
145
147
 
148
+ // Claude 把 JSONL 写到 ~/.claude/projects/<cwd-hash>/<uuid>.jsonl。我们在 spawn
149
+ // 时通过 --session-id <presetClaudeId> 把 UUID 推下去,理想情况下 Claude 会用这个
150
+ // UUID 写文件,session.nativeId 直接对得上。
151
+ //
152
+ // 但部分代理 / wrapper(mira / trae 之类)会再 spawn 一次 claude、丢掉 --session-id,
153
+ // 或自家 fork 不识别这个 flag → Claude 用自己生成的 UUID 写 JSONL → session.nativeId
154
+ // 与磁盘上不一致 → loadTranscript 找不到文件 → 兜底成 PTY raw → Conversation
155
+ // 整段 banner 塌掉。
156
+ //
157
+ // 形态对齐 detectCodexSessionFromFs:扫所有 project 目录里 mtime > spawnTime 的
158
+ // <uuid>.jsonl,挑最新一个的 UUID。命中后由 _setNativeId 去重 + 覆盖。
159
+ function detectClaudeSessionFromFs(afterMs) {
160
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) return null
161
+ let dirs
162
+ try { dirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) } catch { return null }
163
+ let newest = null
164
+ let newestTime = 0
165
+ for (const dirent of dirs) {
166
+ if (!dirent.isDirectory()) continue
167
+ const projDir = join(CLAUDE_PROJECTS_DIR, dirent.name)
168
+ let files
169
+ try { files = readdirSync(projDir) } catch { continue }
170
+ for (const f of files) {
171
+ const m = f.match(CLAUDE_JSONL_FILE_RE)
172
+ if (!m) continue
173
+ try {
174
+ const st = statSync(join(projDir, f))
175
+ const t = st.birthtimeMs || st.ctimeMs
176
+ if (t > afterMs && t > newestTime) {
177
+ newest = m[1]
178
+ newestTime = t
179
+ }
180
+ } catch { /* ignore */ }
181
+ }
182
+ }
183
+ return newest
184
+ }
185
+
146
186
  function tryReadCwdFromSessionMeta(filePath) {
147
187
  try {
148
188
  const head = readFileSync(filePath, 'utf8').split('\n').slice(0, 2)
@@ -286,7 +326,7 @@ function defaultClaudeSessionLocator(nativeSessionId) {
286
326
  }
287
327
 
288
328
  export class PtyManager extends EventEmitter {
289
- 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 } = {}) {
290
330
  super()
291
331
  if (!tools) throw new Error('PtyManager: tools required')
292
332
  this.tools = tools
@@ -298,6 +338,7 @@ export class PtyManager extends EventEmitter {
298
338
  this.sidecar = sidecar
299
339
  this.eventEmitterFactory = eventEmitterFactory
300
340
  this.codexPromptDetectorFactory = codexPromptDetectorFactory || createCodexPromptDetector
341
+ this.claudePromptDetectorFactory = claudePromptDetectorFactory || createClaudePromptDetector
301
342
  this.sessions = new Map()
302
343
  }
303
344
 
@@ -318,57 +359,62 @@ export class PtyManager extends EventEmitter {
318
359
  if (session.detectTimer) { clearInterval(session.detectTimer); session.detectTimer = null }
319
360
  if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
320
361
  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`)
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)
339
384
  }
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)
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?.()
366
411
  }
367
- } else if (!this.eventEmitterFactory) {
368
- 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)
369
414
  }
415
+ } else if (!this.eventEmitterFactory) {
416
+ console.warn(`[codex-detect] this.eventEmitterFactory is null — server.js didn't wire it`)
370
417
  }
371
- return true
372
418
  }
373
419
 
374
420
  has(sessionId) {
@@ -385,6 +431,13 @@ export class PtyManager extends EventEmitter {
385
431
  }
386
432
 
387
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
+
388
441
  getPids() {
389
442
  const out = []
390
443
  for (const [sessionId, s] of this.sessions) {
@@ -419,7 +472,7 @@ export class PtyManager extends EventEmitter {
419
472
  * 会话建立时调 create()、收到前端真实 cols/rows 后再调 startWithSize(),
420
473
  * 这样 PTY 永远不会在默认 80×24 上 spawn 一次再 resize。
421
474
  */
422
- 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 }) {
423
476
  const toolCfg = this.tools[tool]
424
477
  if (!toolCfg) throw new Error(`unknown tool: ${tool}`)
425
478
  const baseArgs = toolCfg.args || []
@@ -449,9 +502,11 @@ export class PtyManager extends EventEmitter {
449
502
  // cursor-agent 没有 --session-id 预置,但有 `cursor-agent create-chat` 异步建会话拿 chatId。
450
503
  // 新会话先异步跑 create-chat,拿到 chatId 后在 startWithSize() 里用 --resume 进交互模式。
451
504
  // create-chat 失败就降级(无 nativeId,直接传 prompt)。
505
+ // bin 为空时 fallback 到 command 名,让 execFile / spawn 走 PATH 解析。
506
+ const spawnFile = (toolCfg.bin && String(toolCfg.bin).trim()) || toolCfg.command
452
507
  let cursorChatPromise = null
453
508
  if (tool === 'cursor' && !resumeNativeId) {
454
- cursorChatPromise = createCursorChatAsync(toolCfg.bin)
509
+ cursorChatPromise = createCursorChatAsync(spawnFile)
455
510
  }
456
511
  const cursorResumeId = tool === 'cursor' ? resumeNativeId : null
457
512
 
@@ -527,12 +582,20 @@ export class PtyManager extends EventEmitter {
527
582
  lastTuiAlertAt: 0,
528
583
  cursorChatPromise,
529
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,
530
592
  spawnSpec: {
531
593
  args,
532
594
  env,
533
595
  effectiveCwd,
534
596
  toolCfg,
535
597
  tool,
598
+ spawnFile,
536
599
  resumeNativeId: resumeNativeId || null,
537
600
  _baseArgs: [...baseArgs],
538
601
  _permissionArgs: [...permissionArgs],
@@ -572,14 +635,14 @@ export class PtyManager extends EventEmitter {
572
635
 
573
636
  const spec = session.spawnSpec
574
637
  if (!spec) throw new Error(`session ${sessionId} has no spawnSpec (was it created?)`)
575
- const { args, env, effectiveCwd, toolCfg, tool } = spec
638
+ const { args, env, effectiveCwd, toolCfg, tool, spawnFile } = spec
576
639
  const { resumeNativeId } = spec
577
640
 
578
- console.log(`[pty] starting ${tool} bin=${toolCfg.bin} cwd=${effectiveCwd} args=${JSON.stringify(args)} cols=${cols} rows=${rows}`)
641
+ console.log(`[pty] starting ${tool} spawnFile=${spawnFile} (configured bin=${toolCfg.bin || '<empty>'}) cwd=${effectiveCwd} args=${JSON.stringify(args)} cols=${cols} rows=${rows}`)
579
642
 
580
643
  let proc
581
644
  try {
582
- proc = this.ptyFactory(toolCfg.bin, args, {
645
+ proc = this.ptyFactory(spawnFile, args, {
583
646
  name: 'xterm-256color',
584
647
  cols,
585
648
  rows,
@@ -594,7 +657,7 @@ export class PtyManager extends EventEmitter {
594
657
  try { if (existsSync(session.mcpConfigPath)) unlinkSync(session.mcpConfigPath) } catch { /* ignore */ }
595
658
  }
596
659
  this.sessions.delete(sessionId)
597
- error.message = `PTY spawn failed for ${tool} (bin=${toolCfg.bin}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
660
+ error.message = `PTY spawn failed for ${tool} (spawnFile=${spawnFile}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
598
661
  throw error
599
662
  }
600
663
  session.proc = proc
@@ -624,6 +687,30 @@ export class PtyManager extends EventEmitter {
624
687
  }
625
688
  }
626
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
+
627
714
  // 已知 nativeId 立即同步通知 —— 覆盖三种情况:
628
715
  // 1) Claude 新会话:presetClaudeId(randomUUID)
629
716
  // 2) Claude --resume:resumeNativeId(沿用 native id)
@@ -631,6 +718,11 @@ export class PtyManager extends EventEmitter {
631
718
  // Codex 新会话(无 resume 也无 preset)走下面的 fs.watch / 轮询 / regex 三路探测
632
719
  if (session.nativeId) {
633
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)
634
726
  }
635
727
 
636
728
  // Codex 新会话:codex CLI 无 --session-id / --rollout-path 预置能力。
@@ -669,6 +761,39 @@ export class PtyManager extends EventEmitter {
669
761
  session.detectTimer.unref?.()
670
762
  }
671
763
 
764
+ // Claude 新会话:虽然 spawn 时已经传了 --session-id <presetClaudeId> 把 UUID
765
+ // 推下去(session.nativeId 也立刻设上),但代理/wrapper(mira / trae 等)会
766
+ // 在转发链路里丢掉 --session-id 或自家 fork claude → Claude 写 JSONL 时用自己
767
+ // 的 UUID → session.nativeId 对不上磁盘 → loadTranscript 兜底成 PTY raw。
768
+ //
769
+ // 这里加一道 FS 轮询治本:扫到 mtime > spawnTime 的真实 UUID,跟 session.nativeId
770
+ // 比一比;如果一致说明 preset 被 honor,停掉轮询即可;不一致则 _setNativeId 覆盖。
771
+ if (!resumeNativeId && tool === 'claude') {
772
+ const spawnTime = Date.now() - 1000
773
+ let detectAttempts = 0
774
+ const presetIdShort = session.nativeId?.slice(0, 8)
775
+ console.log(`[claude-detect] poll started session=${sessionId} preset=${presetIdShort} spawnTime=${spawnTime}`)
776
+ session.detectTimer = setInterval(() => {
777
+ detectAttempts++
778
+ const id = detectClaudeSessionFromFs(spawnTime)
779
+ if (id) {
780
+ if (id !== session.nativeId) {
781
+ console.log(`[claude-detect] poll attempt=${detectAttempts} OVERRIDE ${session.nativeId?.slice(0, 8)} → ${id.slice(0, 8)} (--session-id likely ignored by wrapper)`)
782
+ this._setNativeId(session, id)
783
+ } else {
784
+ console.log(`[claude-detect] poll attempt=${detectAttempts} preset honored, stop`)
785
+ clearInterval(session.detectTimer)
786
+ session.detectTimer = null
787
+ }
788
+ } else if (detectAttempts >= 30) {
789
+ console.warn(`[claude-detect] poll GAVE UP after 30 attempts (12s) for session=${sessionId} — no jsonl matching afterMs=${spawnTime} under ${CLAUDE_PROJECTS_DIR}`)
790
+ clearInterval(session.detectTimer)
791
+ session.detectTimer = null
792
+ }
793
+ }, 400)
794
+ session.detectTimer.unref?.()
795
+ }
796
+
672
797
  proc.onData((data) => {
673
798
  session.fullLog.push(data)
674
799
  session.logBytes += data.length
@@ -727,6 +852,26 @@ export class PtyManager extends EventEmitter {
727
852
  // 反向扫,跳过 system / attachment / last-prompt 等元数据行,
728
853
  // 找最近一条 type ∈ {user, assistant} 的有效行。
729
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
+ }
730
875
  let kind = null // 'turn-started' | 'turn-done' | null
731
876
  for (let i = lines.length - 1; i >= 0; i--) {
732
877
  const line = lines[i].trim()
@@ -778,7 +923,16 @@ export class PtyManager extends EventEmitter {
778
923
  }
779
924
  session.claudeLastJsonlMtimeMs = st.mtimeMs
780
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
+ }
781
933
  if (kind === 'turn-started') {
934
+ // 新一轮开始 → 让 PTY detector 的 lastEmittedText 失效,下一次权限提示能再次 emit
935
+ try { session.detector?.reset?.() } catch { /* ignore */ }
782
936
  this.emit('claude-turn-started', { sessionId, nativeId })
783
937
  } else {
784
938
  this.emit('claude-turn-done', { sessionId, nativeId })
@@ -788,6 +942,58 @@ export class PtyManager extends EventEmitter {
788
942
  session.claudeWatchTimer.unref?.()
789
943
  }
790
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
+
791
997
  // cursor 专属:监听 chatId 的 jsonl,末行 role===assistant 且 mtime 推进
792
998
  // → 一轮回复结束。这里走轮询而不是依赖 cursor 自家 stop hook,是因为
793
999
  // 实测 cursor 的 stop hook 偶发不 fire(同一 cursor 安装,部分 session 完全
@@ -847,6 +1053,7 @@ export class PtyManager extends EventEmitter {
847
1053
  if (session.promptTimer) clearTimeout(session.promptTimer)
848
1054
  if (session.cursorWatchTimer) { clearInterval(session.cursorWatchTimer); session.cursorWatchTimer = null }
849
1055
  if (session.claudeWatchTimer) { clearInterval(session.claudeWatchTimer); session.claudeWatchTimer = null }
1056
+ if (session.codexUsageWatchTimer) { clearInterval(session.codexUsageWatchTimer); session.codexUsageWatchTimer = null }
850
1057
  if (session.fsWatcher) { try { session.fsWatcher.close() } catch { /* ignore */ } session.fsWatcher = null }
851
1058
  if (session.detector) { try { session.detector.stop?.() } catch { /* ignore */ } session.detector = null }
852
1059
  if (session.eventEmitter) {
@@ -1003,6 +1210,8 @@ export class PtyManager extends EventEmitter {
1003
1210
  if (s.promptTimer) { try { clearTimeout(s.promptTimer) } catch { /* ignore */ } s.promptTimer = null }
1004
1211
  if (s.detectTimer) { try { clearInterval(s.detectTimer) } catch { /* ignore */ } s.detectTimer = null }
1005
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 }
1006
1215
  if (s.fsWatcher) { try { s.fsWatcher.close() } catch { /* ignore */ } s.fsWatcher = null }
1007
1216
  // Cleanup runtime MCP config file (Task 10)
1008
1217
  if (s.mcpConfigPath) {