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/dist-web/assets/{index-DuZ_lMdf.js → index-BEiPvgk7.js} +243 -237
- package/dist-web/assets/index-qY2UiOW2.css +32 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- package/src/codex-hook-installer.js +1 -1
- package/src/codex-prompt-detector.js +104 -13
- package/src/config.js +59 -25
- package/src/db.js +53 -31
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/openclaw/index.js +1 -1
- package/src/openclaw-bridge.js +176 -28
- package/src/openclaw-hook-installer.js +2 -1
- package/src/openclaw-hook.js +127 -9
- package/src/openclaw-wizard.js +119 -24
- package/src/permission-prompt.js +113 -31
- package/src/pty.js +263 -54
- package/src/routes/ai-terminal.js +133 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +92 -23
- package/src/session-input-dispatcher.js +48 -4
- package/src/telegram-bot.js +82 -15
- package/src/telegram-loading-status.js +1 -1
- package/src/templates/claude-hooks/notify.js +1 -1
- package/src/templates/codex-hooks/notify.js +1 -1
- package/src/transcript.js +17 -4
- package/dist-web/assets/index-CEiuiF0m.css +0 -32
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
}
|
|
368
|
-
console.warn(`[codex-detect]
|
|
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(
|
|
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(
|
|
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} (
|
|
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) {
|