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