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.
- package/dist-web/assets/{index-By--XlP3.js → index-BEiPvgk7.js} +237 -235
- 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 +33 -5
- 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 +183 -49
- package/src/routes/ai-terminal.js +75 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +90 -12
- 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/dist-web/assets/index-8A0oLLcX.css +0 -32
package/src/permission-prompt.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
}
|
|
407
|
-
console.warn(`[codex-detect]
|
|
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) {
|