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
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { Router } from 'express'
|
|
4
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import pidusage from 'pidusage'
|
|
7
|
+
import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
|
|
8
|
+
|
|
9
|
+
const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
|
|
10
|
+
const CLEANUP_MS = 30 * 60_000
|
|
11
|
+
const MIN_RESIZE_COLS = 30
|
|
12
|
+
// PTY 实际使用的 cols 下限。低于这个值的 viewer(例如默认 480px Dock、手机竖屏)
|
|
13
|
+
// 不会真的把 PTY 拉窄,而是让 PTY 留在 80 cols 输出,xterm 端做软折行。
|
|
14
|
+
// 为什么:Claude 的 TUI/diff 输出包含按 cols 计算好坐标的字符画,一旦 PTY 写下窄行
|
|
15
|
+
// 就以 \r\n 形式硬刻进 outputHistory,replay 到更宽的 viewer 仍然窄。
|
|
16
|
+
const MIN_PTY_COLS = 80
|
|
17
|
+
const LIVE_AI_STATUSES = new Set(['running', 'idle', 'pending_confirm'])
|
|
18
|
+
const TERMINAL_RESIZE_STATUSES = new Set(['done', 'failed', 'stopped'])
|
|
19
|
+
// 防御:claude end_turn 之后那几帧 TUI redraw 不计为"新一轮的活动"
|
|
20
|
+
const EFFECTIVE_STATUS_OUTPUT_GRACE_MS = 500
|
|
21
|
+
// 用户按打断键(Ctrl+C / Esc)后到再次轮询 lastOutputAt 的等待时间。
|
|
22
|
+
// 取 1500ms:长于 EFFECTIVE_STATUS_OUTPUT_GRACE_MS(500ms),盖住 Claude/Codex 收尾打印
|
|
23
|
+
// 那条 "Interrupted by user" 之类的 echo;又短于 stop hook 的常见到达延迟,保证 UI 翻状态
|
|
24
|
+
// 比 hook 早。
|
|
25
|
+
const INTERRUPT_GRACE_MS = 1500
|
|
26
|
+
// "停顿够久才视为真 idle":等 INTERRUPT_GRACE_MS 之后再看,如果距上次 PTY 输出 ≥ 这个值,
|
|
27
|
+
// 才相信 agent 已经停了。低于此值 → 还在喷收尾文本 → 再等一轮。
|
|
28
|
+
const INTERRUPT_QUIET_MS = 800
|
|
29
|
+
// 兜底的最大重试次数:避免遇到一直喷输出的奇怪 agent 时无限挂钩;超过后让 stop hook /
|
|
30
|
+
// jsonl watcher 继续接力,本地静默放弃。
|
|
31
|
+
const INTERRUPT_MAX_RETRIES = 3
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 计算前端展示用的 effectiveStatus —— 用 PTY 输出活性兜底"hook/watcher 判定结束错了"的边界。
|
|
35
|
+
*
|
|
36
|
+
* - status === 'pending_confirm' 原样返回:这是后端用 PTY 输出 (confirm pattern)
|
|
37
|
+
* 主动设上的等待授权态,PTY 最近一定有输出(就是那条提示本身),不能再当成
|
|
38
|
+
* "stale pending" 升级成 running,否则前端展示会把"待确认"误报成"running"。
|
|
39
|
+
* - 其它 LIVE session(running / idle)且 lastOutputAt 晚于 lastTurnDoneAt + 500ms
|
|
40
|
+
* → PTY 还在喷新内容、所谓的"turn done"是假的 → 强制 running。
|
|
41
|
+
* - 其余情况返回 session.status 自身。
|
|
42
|
+
*
|
|
43
|
+
* 与 src/openclaw-hook.js 里的 stop_reason 校验门并存:双层防御,互不依赖。
|
|
44
|
+
*/
|
|
45
|
+
export function computeEffectiveStatus(session, now = Date.now()) {
|
|
46
|
+
if (!session || typeof session !== 'object') return null
|
|
47
|
+
const status = session.status
|
|
48
|
+
if (!LIVE_AI_STATUSES.has(status)) return status
|
|
49
|
+
if (status === 'pending_confirm') return status
|
|
50
|
+
const lastOutputAt = Number(session.lastOutputAt || 0)
|
|
51
|
+
const lastTurnDoneAt = Number(session.lastTurnDoneAt || 0)
|
|
52
|
+
if (!lastOutputAt) return status
|
|
53
|
+
if (lastOutputAt > lastTurnDoneAt + EFFECTIVE_STATUS_OUTPUT_GRACE_MS) return 'running'
|
|
54
|
+
return status
|
|
55
|
+
}
|
|
56
|
+
function isValidResizeSize(cols, rows) {
|
|
57
|
+
return Number.isFinite(cols) && Number.isFinite(rows) && cols >= MIN_RESIZE_COLS && rows > 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clampPtyCols(cols) {
|
|
61
|
+
return cols < MIN_PTY_COLS ? MIN_PTY_COLS : cols
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canResizeSession(session) {
|
|
65
|
+
return session && !TERMINAL_RESIZE_STATUSES.has(session.status)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 在 spawn PTY 前先确认工具确实在 PATH(或显式 bin 路径)里。
|
|
69
|
+
// 比起让 node-pty 抛 ENOENT,这里返回结构化的 tool_missing → 路由层映射成 HTTP 424,
|
|
70
|
+
// CLI/前端可以直接展示「跑 agentquad install-tools --claude」修复指引。
|
|
71
|
+
function checkToolAvailable(tool, cfg) {
|
|
72
|
+
const tools = resolveToolsConfig(cfg?.tools || {})
|
|
73
|
+
const bin = tools?.[tool]?.bin || tools?.[tool]?.command || tool
|
|
74
|
+
const r = spawnSync('command', ['-v', bin], { encoding: 'utf8', shell: '/bin/sh' })
|
|
75
|
+
return {
|
|
76
|
+
ok: r.status === 0 && r.stdout.trim().length > 0,
|
|
77
|
+
bin,
|
|
78
|
+
resolvedPath: r.stdout.trim() || null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, onSessionSpawned = null, onSessionEnded = null, rootDir = DEFAULT_ROOT_DIR }) {
|
|
83
|
+
/** @type {Map<string, any>} */
|
|
84
|
+
const sessions = new Map()
|
|
85
|
+
/** @type {Map<string, string>} */
|
|
86
|
+
const todoSessionMap = new Map()
|
|
87
|
+
/** @type {Map<string, string>} */
|
|
88
|
+
const nativeSessionMap = new Map()
|
|
89
|
+
|
|
90
|
+
function resolveSessionCwd(requestedCwd) {
|
|
91
|
+
const fallback = getDefaultCwd?.() || defaultCwd || process.env.HOME || process.cwd()
|
|
92
|
+
if (requestedCwd && existsSync(requestedCwd)) return requestedCwd
|
|
93
|
+
if (fallback && existsSync(fallback)) return fallback
|
|
94
|
+
return process.env.HOME || process.cwd()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mergeTodoAiSessions(todo, nextSession) {
|
|
98
|
+
const history = Array.isArray(todo?.aiSessions) ? todo.aiSessions : (todo?.aiSession ? [todo.aiSession] : [])
|
|
99
|
+
const filtered = history.filter((item) => {
|
|
100
|
+
if (!item) return false
|
|
101
|
+
if (item.sessionId === nextSession.sessionId) return false
|
|
102
|
+
if (nextSession.nativeSessionId && item.tool === nextSession.tool && item.nativeSessionId === nextSession.nativeSessionId) {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
return true
|
|
106
|
+
})
|
|
107
|
+
return [nextSession, ...filtered]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function replaceTodoAiSessionInPlace(todo, nextSession) {
|
|
111
|
+
const history = Array.isArray(todo?.aiSessions) ? todo.aiSessions : (todo?.aiSession ? [todo.aiSession] : [])
|
|
112
|
+
const index = history.findIndex(item => item?.sessionId === nextSession.sessionId)
|
|
113
|
+
if (index === -1) return [...history, nextSession]
|
|
114
|
+
return history.map((item, i) => i === index ? nextSession : item)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function broadcastToSession(session, msg) {
|
|
118
|
+
const data = JSON.stringify(msg)
|
|
119
|
+
for (const ws of session.browsers) {
|
|
120
|
+
if (ws.readyState === ws.OPEN) ws.send(data)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function persistLiveSessionState(session, status, todoStatus, extra = {}) {
|
|
125
|
+
const todo = db.getTodo(session.todoId)
|
|
126
|
+
if (!todo) return
|
|
127
|
+
const current = (todo.aiSessions || []).find(item => item.sessionId === session.sessionId) || todo.aiSession || {}
|
|
128
|
+
db.updateTodo(session.todoId, {
|
|
129
|
+
status: todoStatus,
|
|
130
|
+
aiSessions: mergeTodoAiSessions(todo, {
|
|
131
|
+
...current,
|
|
132
|
+
...extra,
|
|
133
|
+
sessionId: session.sessionId,
|
|
134
|
+
tool: session.tool,
|
|
135
|
+
nativeSessionId: session.nativeSessionId || current.nativeSessionId || null,
|
|
136
|
+
cwd: session.cwd || current.cwd || null,
|
|
137
|
+
status,
|
|
138
|
+
startedAt: session.startedAt,
|
|
139
|
+
completedAt: null,
|
|
140
|
+
prompt: session.prompt,
|
|
141
|
+
}),
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function markSessionIdleAfterTurn(session, ts) {
|
|
146
|
+
if (!session || session.status !== 'running') return false
|
|
147
|
+
session.status = 'idle'
|
|
148
|
+
session.awaitingReply = true
|
|
149
|
+
session.recentOutput = ''
|
|
150
|
+
// 一旦走到 idle,不管来源是 Stop hook / jsonl watcher / 还是用户打断键调度器,
|
|
151
|
+
// 都要清掉打断 timer:否则 timer 之后还可能再次触发 turn_done 广播。
|
|
152
|
+
clearInterruptTimer(session)
|
|
153
|
+
persistLiveSessionState(session, 'idle', 'ai_done', { lastTurnDoneAt: ts })
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function markSessionRunningAfterInput(session) {
|
|
158
|
+
if (!session || !LIVE_AI_STATUSES.has(session.status)) return false
|
|
159
|
+
const wasPending = session.status === 'pending_confirm'
|
|
160
|
+
if (session.status === 'running' && session.awaitingReply === false) return false
|
|
161
|
+
session.status = 'running'
|
|
162
|
+
session.awaitingReply = false
|
|
163
|
+
if (wasPending) session.recentOutput = ''
|
|
164
|
+
persistLiveSessionState(session, 'running', 'ai_running')
|
|
165
|
+
if (wasPending) broadcastToSession(session, { type: 'pending_cleared' })
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 由 hook 路径调用:Claude Code 的 Notification + permissionish,或 codex-prompt-detector
|
|
170
|
+
// 命中真实的工具授权弹窗时,这里把 session.status 翻成 'pending_confirm',让
|
|
171
|
+
// /api/ai-terminal/sessions 立刻反映"等授权"状态。
|
|
172
|
+
//
|
|
173
|
+
// 跟旧的 PTY 正则路径相比,区别是:信号源是 agent 本身(Claude Code hook / Codex sidecar),
|
|
174
|
+
// 不会因为 AI 回复文本里出现"Do you want to..."等关键词被误触发。
|
|
175
|
+
//
|
|
176
|
+
// 幂等:已经处于 pending_confirm 直接返回 true;非 LIVE_AI_STATUSES(已 done/failed/stopped)
|
|
177
|
+
// 返回 false 不动状态。
|
|
178
|
+
function markPendingConfirm(sessionId, { source = null } = {}) {
|
|
179
|
+
const session = sessions.get(sessionId)
|
|
180
|
+
if (!session) return false
|
|
181
|
+
if (!LIVE_AI_STATUSES.has(session.status)) return false
|
|
182
|
+
if (session.status === 'pending_confirm') return true
|
|
183
|
+
session.status = 'pending_confirm'
|
|
184
|
+
const todo = db.getTodo(session.todoId)
|
|
185
|
+
if (todo) {
|
|
186
|
+
const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession || {}
|
|
187
|
+
db.updateTodo(session.todoId, {
|
|
188
|
+
status: 'ai_pending',
|
|
189
|
+
aiSessions: mergeTodoAiSessions(todo, {
|
|
190
|
+
...current,
|
|
191
|
+
sessionId: session.sessionId,
|
|
192
|
+
tool: session.tool,
|
|
193
|
+
nativeSessionId: session.nativeSessionId || current.nativeSessionId || null,
|
|
194
|
+
status: 'pending_confirm',
|
|
195
|
+
startedAt: session.startedAt,
|
|
196
|
+
completedAt: null,
|
|
197
|
+
prompt: session.prompt,
|
|
198
|
+
}),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
broadcastToSession(session, {
|
|
202
|
+
type: 'pending_confirm',
|
|
203
|
+
snippet: session.recentOutput ? session.recentOutput.slice(-500) : '',
|
|
204
|
+
source: source || 'hook',
|
|
205
|
+
})
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function notifyTurnDone(sessionId, payload = {}) {
|
|
210
|
+
const session = sessions.get(sessionId)
|
|
211
|
+
if (!session) return false
|
|
212
|
+
const ts = payload.timestamp || Date.now()
|
|
213
|
+
session.lastTurnDoneAt = ts
|
|
214
|
+
const markedIdle = markSessionIdleAfterTurn(session, ts)
|
|
215
|
+
// 持久化到 todo.aiSessions[i].lastTurnDoneAt:即使 server 重启或浏览器关掉,
|
|
216
|
+
// 客户端再开仍能根据 lastTurnDoneAt > 本地 lastSeenAt 判断未读。
|
|
217
|
+
try {
|
|
218
|
+
const todo = db.getTodo(session.todoId)
|
|
219
|
+
if (todo) {
|
|
220
|
+
const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession
|
|
221
|
+
if (current && !markedIdle) {
|
|
222
|
+
db.updateTodo(session.todoId, {
|
|
223
|
+
aiSessions: mergeTodoAiSessions(todo, { ...current, lastTurnDoneAt: ts }),
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.warn('[ai-terminal] persist lastTurnDoneAt failed:', e.message)
|
|
229
|
+
}
|
|
230
|
+
broadcastToSession(session, {
|
|
231
|
+
...payload,
|
|
232
|
+
type: 'turn_done',
|
|
233
|
+
event: payload.event || 'stop',
|
|
234
|
+
status: payload.status || session.status || 'idle',
|
|
235
|
+
timestamp: ts,
|
|
236
|
+
})
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sendToBrowser(ws, msg) {
|
|
241
|
+
if (!ws || ws.readyState !== ws.OPEN) return
|
|
242
|
+
ws.send(JSON.stringify(msg))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function restoreSessionAsCurrent(session, todoSnapshot) {
|
|
246
|
+
todoSessionMap.set(session.todoId, session.sessionId)
|
|
247
|
+
if (session.nativeSessionId) nativeSessionMap.set(`${session.tool}:${session.nativeSessionId}`, session.sessionId)
|
|
248
|
+
if (todoSnapshot) {
|
|
249
|
+
db.updateTodo(session.todoId, {
|
|
250
|
+
status: todoSnapshot.status,
|
|
251
|
+
aiSessions: todoSnapshot.aiSessions,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function appendOutput(session, data) {
|
|
257
|
+
session.outputHistory.push(data)
|
|
258
|
+
session.outputSize += data.length
|
|
259
|
+
while (session.outputSize > MAX_OUTPUT_BUFFER && session.outputHistory.length > 1) {
|
|
260
|
+
const removed = session.outputHistory.shift()
|
|
261
|
+
session.outputSize -= removed.length
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function writeFullLog(sessionId, fullLog) {
|
|
266
|
+
if (!logDir || !fullLog) return
|
|
267
|
+
try {
|
|
268
|
+
await mkdir(logDir, { recursive: true })
|
|
269
|
+
const tail = fullLog.length > MAX_OUTPUT_BUFFER ? fullLog.slice(-MAX_OUTPUT_BUFFER) : fullLog
|
|
270
|
+
await writeFile(join(logDir, `${sessionId}.log`), tail, 'utf8')
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.warn('[ai-terminal] write log failed:', e.message)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── PTY event wiring ───
|
|
277
|
+
|
|
278
|
+
pty.on('output', ({ sessionId, data }) => {
|
|
279
|
+
const session = sessions.get(sessionId)
|
|
280
|
+
if (!session) return
|
|
281
|
+
appendOutput(session, data)
|
|
282
|
+
session.recentOutput = `${session.recentOutput || ''}${data}`.slice(-4000)
|
|
283
|
+
session.lastOutputAt = Date.now()
|
|
284
|
+
session.outputBytesTotal = (session.outputBytesTotal || 0) + data.length
|
|
285
|
+
broadcastToSession(session, { type: 'output', data })
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
pty.on('native-session', ({ sessionId, nativeId }) => {
|
|
289
|
+
const session = sessions.get(sessionId)
|
|
290
|
+
if (!session) return
|
|
291
|
+
if (session.nativeSessionId && session.nativeSessionId !== nativeId) {
|
|
292
|
+
const oldNativeKey = `${session.tool}:${session.nativeSessionId}`
|
|
293
|
+
if (nativeSessionMap.get(oldNativeKey) === sessionId) nativeSessionMap.delete(oldNativeKey)
|
|
294
|
+
}
|
|
295
|
+
session.nativeSessionId = nativeId
|
|
296
|
+
nativeSessionMap.set(`${session.tool}:${nativeId}`, sessionId)
|
|
297
|
+
const todo = db.getTodo(session.todoId)
|
|
298
|
+
if (todo) {
|
|
299
|
+
const current = (todo.aiSessions || []).find(item => item.sessionId === sessionId) || todo.aiSession
|
|
300
|
+
if (!current) return
|
|
301
|
+
const nextAi = { ...current, nativeSessionId: nativeId, cwd: session.cwd || current.cwd || null }
|
|
302
|
+
db.updateTodo(session.todoId, {
|
|
303
|
+
aiSessions: mergeTodoAiSessions(todo, nextAi),
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// cursor 专属:jsonl tail watcher 检测到末行 role===assistant → 一轮已完成。
|
|
309
|
+
// cursor 自家 stop hook 偶发不 fire(log 实测),所以走 jsonl 兜底。
|
|
310
|
+
// 走的字段跟 Claude Stop hook 一样:notifyTurnDone 设 lastTurnDoneAt,
|
|
311
|
+
// markSessionAwaitingReply(true) 让前端 deriveAiState 知道"PTY 活着但本轮已结束"。
|
|
312
|
+
pty.on('cursor-turn-done', ({ sessionId }) => {
|
|
313
|
+
if (!sessions.has(sessionId)) return
|
|
314
|
+
notifyTurnDone(sessionId, { event: 'stop', status: 'idle' })
|
|
315
|
+
markSessionAwaitingReply(sessionId, true)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// claude jsonl tail watcher(pty.js 内部 2s 轮询):
|
|
319
|
+
// - turn-started:末行是 user/tool_result → Claude 在跑 → awaitingReply=false
|
|
320
|
+
// - turn-done :末行 assistant.stop_reason==='end_turn' → 真完成 → awaitingReply=true
|
|
321
|
+
// 与 stop hook 并存,谁先到谁先生效。markSessionAwaitingReply 幂等。
|
|
322
|
+
pty.on('claude-turn-started', ({ sessionId }) => {
|
|
323
|
+
if (!sessions.has(sessionId)) return
|
|
324
|
+
markSessionAwaitingReply(sessionId, false)
|
|
325
|
+
})
|
|
326
|
+
pty.on('claude-turn-done', ({ sessionId }) => {
|
|
327
|
+
if (!sessions.has(sessionId)) return
|
|
328
|
+
notifyTurnDone(sessionId, { event: 'stop', status: 'idle' })
|
|
329
|
+
markSessionAwaitingReply(sessionId, true)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
pty.on('done', ({ sessionId, exitCode, fullLog, nativeId, stopped }) => {
|
|
333
|
+
const session = sessions.get(sessionId)
|
|
334
|
+
if (!session) return
|
|
335
|
+
if (session.nativeSessionId) {
|
|
336
|
+
const nativeKey = `${session.tool}:${session.nativeSessionId}`
|
|
337
|
+
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let aiStatus, todoStatus
|
|
341
|
+
if (stopped) {
|
|
342
|
+
aiStatus = 'stopped'
|
|
343
|
+
todoStatus = 'todo'
|
|
344
|
+
} else if (exitCode === 0) {
|
|
345
|
+
aiStatus = 'done'
|
|
346
|
+
todoStatus = 'ai_done'
|
|
347
|
+
} else {
|
|
348
|
+
aiStatus = 'failed'
|
|
349
|
+
todoStatus = 'todo'
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
session.status = aiStatus
|
|
353
|
+
session.completedAt = Date.now()
|
|
354
|
+
session.awaitingReply = false
|
|
355
|
+
clearInterruptTimer(session)
|
|
356
|
+
|
|
357
|
+
const superseded = Boolean(session.replacedBySessionId) || todoSessionMap.get(session.todoId) !== sessionId
|
|
358
|
+
const todo = db.getTodo(session.todoId)
|
|
359
|
+
if (todo) {
|
|
360
|
+
const existingEntry = (todo.aiSessions || []).find(item => item.sessionId === session.sessionId)
|
|
361
|
+
// bypass 热重启的老 session:B 的 spawnSession 已通过 mergeTodoAiSessions
|
|
362
|
+
// 按 tool+nativeSessionId 把老 A 从 aiSessions 里剔除。这里别再 append 回去 ——
|
|
363
|
+
// 否则一次切"完全托管"会留下 2 条历史卡片(B running + A stopped)。
|
|
364
|
+
// session_log 仍由下方 insertSessionLog 写入,Dashboard 统计不会丢这次运行。
|
|
365
|
+
const skipHistoryWrite = superseded && !existingEntry
|
|
366
|
+
if (!skipHistoryWrite) {
|
|
367
|
+
const newAi = {
|
|
368
|
+
...(existingEntry || todo.aiSession || {}),
|
|
369
|
+
sessionId: session.sessionId,
|
|
370
|
+
tool: session.tool,
|
|
371
|
+
nativeSessionId: nativeId || session.nativeSessionId || null,
|
|
372
|
+
cwd: session.cwd || null,
|
|
373
|
+
status: aiStatus,
|
|
374
|
+
startedAt: session.startedAt,
|
|
375
|
+
completedAt: session.completedAt,
|
|
376
|
+
prompt: session.prompt,
|
|
377
|
+
}
|
|
378
|
+
// 用户主动通过删/关 topic 触发的 stop:handleTopicEvent 已把 todo 标 done,
|
|
379
|
+
// 这里别用 stopped→'todo' 的默认逻辑覆写它。只更 aiSessions(记录会话退出状态)。
|
|
380
|
+
const updates = {
|
|
381
|
+
aiSessions: superseded
|
|
382
|
+
? replaceTodoAiSessionInPlace(todo, newAi)
|
|
383
|
+
: mergeTodoAiSessions(todo, newAi),
|
|
384
|
+
}
|
|
385
|
+
if (!session.userClosedReason && !superseded) {
|
|
386
|
+
updates.status = todoStatus
|
|
387
|
+
}
|
|
388
|
+
db.updateTodo(session.todoId, updates)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
writeFullLog(sessionId, fullLog)
|
|
393
|
+
const replacedByLive = superseded
|
|
394
|
+
&& typeof session.replacedBySessionId === 'string'
|
|
395
|
+
&& session.replacedBySessionId !== '__pending__'
|
|
396
|
+
if (!replacedByLive) {
|
|
397
|
+
broadcastToSession(session, { type: 'done', exitCode, status: aiStatus })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 落库一条历史记录,供仪表盘 Tab C 统计使用
|
|
401
|
+
try {
|
|
402
|
+
const todoQuadrant = todo?.quadrant ?? 4
|
|
403
|
+
db.insertSessionLog({
|
|
404
|
+
id: session.sessionId,
|
|
405
|
+
todoId: session.todoId,
|
|
406
|
+
tool: session.tool,
|
|
407
|
+
quadrant: todoQuadrant,
|
|
408
|
+
status: aiStatus,
|
|
409
|
+
exitCode: exitCode ?? null,
|
|
410
|
+
startedAt: session.startedAt,
|
|
411
|
+
completedAt: session.completedAt,
|
|
412
|
+
})
|
|
413
|
+
} catch (e) {
|
|
414
|
+
console.warn('[ai-terminal] insertSessionLog failed:', e.message)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Telegram 自动关 topic 钩子:PTY 自然退出 / crash / exit 命令都走这里
|
|
418
|
+
if (!superseded && typeof onSessionEnded === 'function') {
|
|
419
|
+
try {
|
|
420
|
+
const r = onSessionEnded({
|
|
421
|
+
sessionId,
|
|
422
|
+
todoId: session.todoId,
|
|
423
|
+
exitCode,
|
|
424
|
+
status: aiStatus,
|
|
425
|
+
startedAt: session.startedAt,
|
|
426
|
+
completedAt: session.completedAt,
|
|
427
|
+
})
|
|
428
|
+
if (r && typeof r.catch === 'function') r.catch((e) => console.warn(`[ai-terminal] onSessionEnded failed: ${e.message}`))
|
|
429
|
+
} catch (e) { console.warn(`[ai-terminal] onSessionEnded threw: ${e.message}`) }
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
|
|
434
|
+
function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false }) {
|
|
435
|
+
if (!todoId || typeof prompt !== 'string' || !tool) {
|
|
436
|
+
const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
|
|
437
|
+
throw err
|
|
438
|
+
}
|
|
439
|
+
if (!SUPPORTED_TOOLS.includes(tool)) {
|
|
440
|
+
const err = new Error('invalid tool'); err.code = 'bad_request'
|
|
441
|
+
throw err
|
|
442
|
+
}
|
|
443
|
+
// 工具不在 PATH(或显式 bin 路径不存在)时立刻报错,不要把 ENOENT 留给 node-pty。
|
|
444
|
+
// 路由层会把 tool_missing 映射成 HTTP 424 + 修复指引。
|
|
445
|
+
const cfg = loadConfig({ rootDir })
|
|
446
|
+
const avail = checkToolAvailable(tool, cfg)
|
|
447
|
+
if (!avail.ok) {
|
|
448
|
+
const err = new Error(`tool_missing: ${tool} (looked for "${avail.bin}" in PATH)`)
|
|
449
|
+
err.code = 'tool_missing'
|
|
450
|
+
err.tool = tool
|
|
451
|
+
err.bin = avail.bin
|
|
452
|
+
err.fix = `agentquad install-tools --${tool}`
|
|
453
|
+
throw err
|
|
454
|
+
}
|
|
455
|
+
const todo = db.getTodo(todoId)
|
|
456
|
+
if (!todo) {
|
|
457
|
+
const err = new Error('todo_not_found'); err.code = 'not_found'
|
|
458
|
+
throw err
|
|
459
|
+
}
|
|
460
|
+
if (resumeNativeId && !ignoreExistingNativeSessionId) {
|
|
461
|
+
const existing = nativeSessionMap.get(`${tool}:${resumeNativeId}`)
|
|
462
|
+
if (existing) return { sessionId: existing, reused: true }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const sessionId = externalSessionId || `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
466
|
+
let sessionCwd = resolveSessionCwd(cwd)
|
|
467
|
+
// 跟 recoverPendingTodosOnStartup 同样的 claude resume cwd 漂移修正:
|
|
468
|
+
// 前端把 session.cwd 原样回传,但那个 cwd 跟实际 jsonl 落盘的目录可能不一致;
|
|
469
|
+
// 在记到 DB 之前先用文件位置反查真实 cwd,下次再 resume 就不用再纠正了。
|
|
470
|
+
if (resumeNativeId && tool === 'claude' && pty.findClaudeSession) {
|
|
471
|
+
const located = pty.findClaudeSession(resumeNativeId)
|
|
472
|
+
if (located?.cwd && existsSync(located.cwd) && located.cwd !== sessionCwd) {
|
|
473
|
+
console.log(`[ai-terminal] resume cwd corrected for ${resumeNativeId.slice(0, 8)}: ${sessionCwd} → ${located.cwd}`)
|
|
474
|
+
sessionCwd = located.cwd
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const effectivePermissionMode = permissionMode || 'default'
|
|
478
|
+
const session = {
|
|
479
|
+
sessionId,
|
|
480
|
+
todoId,
|
|
481
|
+
tool,
|
|
482
|
+
prompt,
|
|
483
|
+
status: 'running',
|
|
484
|
+
startedAt: Date.now(),
|
|
485
|
+
completedAt: null,
|
|
486
|
+
browsers: new Set(),
|
|
487
|
+
outputHistory: [],
|
|
488
|
+
outputSize: 0,
|
|
489
|
+
nativeSessionId: resumeNativeId || null,
|
|
490
|
+
recentOutput: '',
|
|
491
|
+
cwd: sessionCwd,
|
|
492
|
+
currentCwd: sessionCwd,
|
|
493
|
+
permissionMode: effectivePermissionMode,
|
|
494
|
+
autoMode: effectivePermissionMode !== 'default' ? effectivePermissionMode : null,
|
|
495
|
+
lastOutputAt: null,
|
|
496
|
+
lastTurnDoneAt: null,
|
|
497
|
+
outputBytesTotal: 0,
|
|
498
|
+
awaitingReply: false,
|
|
499
|
+
spawned: false,
|
|
500
|
+
spawnFallbackTimer: null,
|
|
501
|
+
}
|
|
502
|
+
sessions.set(sessionId, session)
|
|
503
|
+
todoSessionMap.set(todoId, sessionId)
|
|
504
|
+
if (resumeNativeId) nativeSessionMap.set(`${tool}:${resumeNativeId}`, sessionId)
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
// 自动注入 QUADTODO_* env,让 ~/.agentquad/claude-hooks/notify.js 能识别这是
|
|
508
|
+
// AgentQuad 启的 Claude Code → Stop / SessionEnd 事件回推到 AgentQuad /api/openclaw/hook。
|
|
509
|
+
// 之前只有 wizard.finalize 会显式传 extraEnv,web/CLI 直接 spawn 的 session 由于缺这些
|
|
510
|
+
// env,hook 脚本 exit 0 → 完成时不推 telegram。caller-supplied 排前面,自动 env 后置覆盖
|
|
511
|
+
// 防止 caller 传错的 sessionId。
|
|
512
|
+
const autoEnv = {
|
|
513
|
+
QUADTODO_SESSION_ID: sessionId,
|
|
514
|
+
QUADTODO_TODO_ID: String(todoId),
|
|
515
|
+
QUADTODO_TODO_TITLE: String(todo.title || ''),
|
|
516
|
+
}
|
|
517
|
+
// 1. 先 pty.create 让 PtyManager 把 presetClaudeId / resumeNativeId 落进 session 记录。
|
|
518
|
+
pty.create({
|
|
519
|
+
sessionId,
|
|
520
|
+
todoId,
|
|
521
|
+
tool,
|
|
522
|
+
prompt: resumeNativeId ? null : prompt,
|
|
523
|
+
cwd: sessionCwd,
|
|
524
|
+
resumeNativeId: resumeNativeId || undefined,
|
|
525
|
+
permissionMode: permissionMode || null,
|
|
526
|
+
extraEnv: { ...(extraEnv || {}), ...autoEnv },
|
|
527
|
+
})
|
|
528
|
+
// 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
|
|
529
|
+
// 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
|
|
530
|
+
const presetNativeId = pty.getNativeId(sessionId)
|
|
531
|
+
session.nativeSessionId = presetNativeId
|
|
532
|
+
if (presetNativeId && !resumeNativeId) {
|
|
533
|
+
// resume 路径上面已经 set 过;新会话首次得到 nativeId 时补一次。
|
|
534
|
+
nativeSessionMap.set(`${tool}:${presetNativeId}`, sessionId)
|
|
535
|
+
}
|
|
536
|
+
// 3. 一次性把 nativeSessionId 写进 DB(搬进 try 内:失败时不留脏 DB)。
|
|
537
|
+
db.updateTodo(todoId, {
|
|
538
|
+
status: 'ai_running',
|
|
539
|
+
aiSessions: mergeTodoAiSessions(todo, {
|
|
540
|
+
sessionId,
|
|
541
|
+
tool,
|
|
542
|
+
nativeSessionId: presetNativeId,
|
|
543
|
+
cwd: sessionCwd,
|
|
544
|
+
status: 'running',
|
|
545
|
+
startedAt: session.startedAt,
|
|
546
|
+
completedAt: null,
|
|
547
|
+
prompt,
|
|
548
|
+
permissionMode: effectivePermissionMode,
|
|
549
|
+
...(label ? { label } : {}),
|
|
550
|
+
}),
|
|
551
|
+
})
|
|
552
|
+
// 4. 5s 兜底:前端如果一直没发合法 init(极少见 — /exec 返回后 WS 还没连上),
|
|
553
|
+
// 用老的 80×24 兜底 spawn,避免 session 永远卡在 create 状态。
|
|
554
|
+
session.spawnFallbackTimer = setTimeout(() => {
|
|
555
|
+
session.spawnFallbackTimer = null
|
|
556
|
+
if (session.spawned) return
|
|
557
|
+
console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 5s)`)
|
|
558
|
+
session.spawned = true
|
|
559
|
+
pty.startWithSize(sessionId, 80, 24).catch((e) => {
|
|
560
|
+
console.warn(`[ai-terminal] spawn fallback failed: ${e.message}`)
|
|
561
|
+
session.spawned = false
|
|
562
|
+
})
|
|
563
|
+
}, 5000)
|
|
564
|
+
session.spawnFallbackTimer.unref?.()
|
|
565
|
+
} catch (error) {
|
|
566
|
+
sessions.delete(sessionId)
|
|
567
|
+
if (todoSessionMap.get(todoId) === sessionId) todoSessionMap.delete(todoId)
|
|
568
|
+
if (resumeNativeId) {
|
|
569
|
+
const nativeKey = `${tool}:${resumeNativeId}`
|
|
570
|
+
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
571
|
+
}
|
|
572
|
+
// 新会话的 preset nativeSessionMap 也要清掉(resume 路径在上一个 if 已处理)。
|
|
573
|
+
if (session.nativeSessionId && session.nativeSessionId !== resumeNativeId) {
|
|
574
|
+
const nativeKey = `${tool}:${session.nativeSessionId}`
|
|
575
|
+
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
576
|
+
}
|
|
577
|
+
// 顺手补:如果 pty.create 已经把 session 占位写进 pty.sessions、但后续步骤抛错,要清掉。
|
|
578
|
+
try { pty.stop(sessionId) } catch { /* ignore */ }
|
|
579
|
+
throw error
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Telegram 自动建 topic 钩子(B 方案:默认开,wizard 等已自管 topic 的传 skipTelegram=true)
|
|
583
|
+
if (!skipTelegram && typeof onSessionSpawned === 'function') {
|
|
584
|
+
try {
|
|
585
|
+
const r = onSessionSpawned({ sessionId, todoId, tool })
|
|
586
|
+
if (r && typeof r.catch === 'function') r.catch((e) => console.warn(`[ai-terminal] onSessionSpawned failed: ${e.message}`))
|
|
587
|
+
} catch (e) { console.warn(`[ai-terminal] onSessionSpawned threw: ${e.message}`) }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { sessionId, reused: false }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ─── REST ───
|
|
594
|
+
|
|
595
|
+
const router = Router()
|
|
596
|
+
|
|
597
|
+
router.post('/exec', (req, res) => {
|
|
598
|
+
try {
|
|
599
|
+
const body = req.body || {}
|
|
600
|
+
const result = spawnSession({
|
|
601
|
+
todoId: body.todoId,
|
|
602
|
+
prompt: body.prompt,
|
|
603
|
+
tool: body.tool,
|
|
604
|
+
cwd: body.cwd,
|
|
605
|
+
resumeNativeId: body.resumeNativeId,
|
|
606
|
+
permissionMode: body.permissionMode,
|
|
607
|
+
})
|
|
608
|
+
res.json({ ok: true, ...result })
|
|
609
|
+
} catch (e) {
|
|
610
|
+
if (e.code === 'tool_missing') {
|
|
611
|
+
return res.status(424).json({
|
|
612
|
+
ok: false,
|
|
613
|
+
code: 'tool_missing',
|
|
614
|
+
tool: e.tool,
|
|
615
|
+
bin: e.bin,
|
|
616
|
+
fix: e.fix,
|
|
617
|
+
message: e.message,
|
|
618
|
+
error: e.message,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
const status = e.code === 'bad_request' ? 400 : e.code === 'not_found' ? 404 : 500
|
|
622
|
+
if (status >= 500) console.error('[ai-terminal/exec]', e)
|
|
623
|
+
res.status(status).json({ ok: false, error: e.message })
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
// 返回当前内存中的所有会话(包含已完成的"雕像期"),供仪表盘和宠物视图使用
|
|
629
|
+
router.get('/sessions', (req, res) => {
|
|
630
|
+
try {
|
|
631
|
+
const out = []
|
|
632
|
+
const now = Date.now()
|
|
633
|
+
for (const [sessionId, s] of sessions) {
|
|
634
|
+
const todo = db.getTodo(s.todoId)
|
|
635
|
+
out.push({
|
|
636
|
+
sessionId,
|
|
637
|
+
todoId: s.todoId,
|
|
638
|
+
todoTitle: todo?.title || '',
|
|
639
|
+
quadrant: todo?.quadrant || 4,
|
|
640
|
+
tool: s.tool,
|
|
641
|
+
status: s.status,
|
|
642
|
+
effectiveStatus: computeEffectiveStatus(s, now),
|
|
643
|
+
autoMode: s.autoMode || null,
|
|
644
|
+
nativeSessionId: s.nativeSessionId || null,
|
|
645
|
+
cwd: s.cwd || null,
|
|
646
|
+
startedAt: s.startedAt,
|
|
647
|
+
completedAt: s.completedAt || null,
|
|
648
|
+
lastOutputAt: s.lastOutputAt || null,
|
|
649
|
+
lastTurnDoneAt: s.lastTurnDoneAt || null,
|
|
650
|
+
outputBytesTotal: s.outputBytesTotal || 0,
|
|
651
|
+
awaitingReply: !!s.awaitingReply,
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
res.json({ ok: true, sessions: out })
|
|
655
|
+
} catch (e) {
|
|
656
|
+
console.error('[ai-terminal/sessions]', e)
|
|
657
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
658
|
+
}
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
// 聚合历史统计:range=today|week|month
|
|
662
|
+
router.get('/stats', (req, res) => {
|
|
663
|
+
try {
|
|
664
|
+
const range = req.query.range || 'today'
|
|
665
|
+
const now = Date.now()
|
|
666
|
+
let since = now - 86400_000
|
|
667
|
+
if (range === 'week') since = now - 7 * 86400_000
|
|
668
|
+
else if (range === 'month') since = now - 30 * 86400_000
|
|
669
|
+
const stats = db.querySessionStats({ since, until: now })
|
|
670
|
+
res.json({ ok: true, range, since, until: now, stats })
|
|
671
|
+
} catch (e) {
|
|
672
|
+
console.error('[ai-terminal/stats]', e)
|
|
673
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
674
|
+
}
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// 当前所有 PTY 进程的 CPU/内存快照
|
|
678
|
+
router.get('/resource', async (req, res) => {
|
|
679
|
+
try {
|
|
680
|
+
const pids = pty.getPids ? pty.getPids() : []
|
|
681
|
+
if (!pids.length) {
|
|
682
|
+
res.json({ ok: true, resources: [] })
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
const pidList = pids.map(p => p.pid)
|
|
686
|
+
let usage = {}
|
|
687
|
+
try {
|
|
688
|
+
usage = await pidusage(pidList)
|
|
689
|
+
} catch (err) {
|
|
690
|
+
// 部分 pid 可能已退出,单独采样避免一错全错
|
|
691
|
+
for (const pid of pidList) {
|
|
692
|
+
try { usage[pid] = await pidusage(pid) } catch { /* skip dead pid */ }
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const now = Date.now()
|
|
696
|
+
const resources = pids.map(({ sessionId, pid, tool }) => {
|
|
697
|
+
const u = usage[pid]
|
|
698
|
+
const session = sessions.get(sessionId)
|
|
699
|
+
const todo = session ? db.getTodo(session.todoId) : null
|
|
700
|
+
return {
|
|
701
|
+
sessionId,
|
|
702
|
+
todoId: session?.todoId || null,
|
|
703
|
+
todoTitle: todo?.title || '',
|
|
704
|
+
tool,
|
|
705
|
+
pid,
|
|
706
|
+
cpu: u?.cpu ?? 0,
|
|
707
|
+
memory: u?.memory ?? 0,
|
|
708
|
+
elapsedMs: session?.startedAt ? (now - session.startedAt) : 0,
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
res.json({ ok: true, resources })
|
|
712
|
+
} catch (e) {
|
|
713
|
+
console.error('[ai-terminal/resource]', e)
|
|
714
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
715
|
+
}
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
router.post('/stop', (req, res) => {
|
|
719
|
+
try {
|
|
720
|
+
const { sessionId } = req.body || {}
|
|
721
|
+
const session = sessions.get(sessionId)
|
|
722
|
+
if (!session) {
|
|
723
|
+
res.status(404).json({ ok: false, error: 'session_not_found' })
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
pty.stop(sessionId)
|
|
727
|
+
// pty 'done' event will fire and broadcast 'done' to browsers;
|
|
728
|
+
// that handler sets todo.status to 'todo' because stopped=true.
|
|
729
|
+
broadcastToSession(session, { type: 'stopped' })
|
|
730
|
+
res.json({ ok: true })
|
|
731
|
+
} catch (e) {
|
|
732
|
+
console.error('[ai-terminal/stop]', e)
|
|
733
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
router.post('/input', (req, res) => {
|
|
738
|
+
try {
|
|
739
|
+
const { sessionId, data } = req.body || {}
|
|
740
|
+
if (!sessionId || typeof data !== 'string') {
|
|
741
|
+
res.status(400).json({ ok: false, error: 'missing sessionId or data' })
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
const session = sessions.get(sessionId)
|
|
745
|
+
if (!session) {
|
|
746
|
+
res.status(404).json({ ok: false, error: 'session_not_found' })
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
// 只在真正"提交"按键(Enter / Ctrl+C / Ctrl+D)时翻 awaitingReply=false。
|
|
750
|
+
// 普通字符 / 焦点 ANSI 序列 / 粘贴中间态都不算 Claude 正式 busy ——
|
|
751
|
+
// 如果在这里无条件翻 false,dispatcher 会把同一 chat 后续的 IM 消息全部 queue,
|
|
752
|
+
// 而队列只能等下一次 Stop hook 才会 flush,导致飞书消息延迟数分钟才送达。
|
|
753
|
+
if (isPendingClearingInput(data)) markSessionRunningAfterInput(session)
|
|
754
|
+
// running 中遇到打断键 → 排一个 idle 兜底检查(Stop hook 不 fire 时也能翻状态)
|
|
755
|
+
if (session.status === 'running' && isInterruptInput(data)) scheduleInterruptIdleCheck(session)
|
|
756
|
+
writeRestInputToPty(sessionId, data)
|
|
757
|
+
res.json({ ok: true })
|
|
758
|
+
} catch (e) {
|
|
759
|
+
console.error('[ai-terminal/input]', e)
|
|
760
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
// ─── WebSocket hooks (called from server.js on upgrade) ───
|
|
765
|
+
|
|
766
|
+
function addBrowser(sessionId, ws) {
|
|
767
|
+
const session = sessions.get(sessionId)
|
|
768
|
+
if (!session) {
|
|
769
|
+
try {
|
|
770
|
+
ws.send(JSON.stringify({ type: 'error', error: 'session_not_found' }))
|
|
771
|
+
ws.close?.(4004, 'session_not_found')
|
|
772
|
+
} catch { /* ignore */ }
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
session.browsers.add(ws)
|
|
776
|
+
if (session.outputHistory.length > 0) {
|
|
777
|
+
ws.send(JSON.stringify({ type: 'replay', chunks: session.outputHistory }))
|
|
778
|
+
}
|
|
779
|
+
ws.send(JSON.stringify({ type: 'auto_mode', autoMode: session.autoMode || null }))
|
|
780
|
+
if (session.status === 'done' || session.status === 'failed' || session.status === 'stopped') {
|
|
781
|
+
ws.send(JSON.stringify({ type: 'done', status: session.status }))
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function removeBrowser(sessionId, ws) {
|
|
786
|
+
const session = sessions.get(sessionId)
|
|
787
|
+
if (!session) return
|
|
788
|
+
session.browsers.delete(ws)
|
|
789
|
+
// 这条浏览器走了之后,剩下的浏览器里最小尺寸可能变大,重算一次让 PTY
|
|
790
|
+
// 恢复到能充分利用剩余窗口的尺寸;没有浏览器时什么都不用发,等下一个进来再说。
|
|
791
|
+
if (session.browsers.size > 0) applyAggregatedResize(session)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// 同一个 session 被多个网页同时打开时(比如你在另一个 tab/window 又开了一遍
|
|
795
|
+
// 同一个 todo),每个 tab 的 xterm fit 出的 cols/rows 都不一样,谁最后发谁
|
|
796
|
+
// 赢就会在两个尺寸之间来回抖,Claude 的 TUI 不停重排、scrollback 全是残影。
|
|
797
|
+
// 取所有在线浏览器上报尺寸的 **最小值** 发给 PTY:最窄的窗口看得下,更宽的
|
|
798
|
+
// tab 只是右边留空白,整体输出保持稳定。
|
|
799
|
+
function applyAggregatedResize(session) {
|
|
800
|
+
if (!canResizeSession(session)) return
|
|
801
|
+
|
|
802
|
+
let cols = Infinity
|
|
803
|
+
let rows = Infinity
|
|
804
|
+
for (const b of session.browsers) {
|
|
805
|
+
const sz = b.__quadtodoSize
|
|
806
|
+
if (!sz || !isValidResizeSize(sz.cols, sz.rows)) continue
|
|
807
|
+
if (sz.cols < cols) cols = sz.cols
|
|
808
|
+
if (sz.rows < rows) rows = sz.rows
|
|
809
|
+
}
|
|
810
|
+
if (!isValidResizeSize(cols, rows)) return
|
|
811
|
+
cols = clampPtyCols(cols)
|
|
812
|
+
if (session.lastAppliedCols === cols && session.lastAppliedRows === rows) return
|
|
813
|
+
session.lastAppliedCols = cols
|
|
814
|
+
session.lastAppliedRows = rows
|
|
815
|
+
pty.resize(session.sessionId, cols, rows)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// \r=Enter \n=LF \x03=Ctrl+C \x04=Ctrl+D —— 这些才会真正让 Claude/Codex 的 confirm 提示推进
|
|
819
|
+
function isPendingClearingInput(data) {
|
|
820
|
+
if (typeof data !== 'string' || !data) return false
|
|
821
|
+
return /[\r\n\x03\x04]/.test(data)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 用户希望"打断当前轮"的按键:Ctrl+C(\x03)或 Esc(裸 \x1b / \x1b\x1b)。
|
|
825
|
+
// 注意:必须排除 ANSI 转义序列 —— 箭头键是 '\x1b[A'、焦点切换是 '\x1b[I',
|
|
826
|
+
// 它们都以 \x1b 起头,但不是用户意图上的"打断"。所以只认严格匹配的两种形态。
|
|
827
|
+
// 这里不区分 status:调用方负责按 session.status === 'running' 决定是否调度。
|
|
828
|
+
function isInterruptInput(data) {
|
|
829
|
+
if (typeof data !== 'string' || !data) return false
|
|
830
|
+
if (data.includes('\x03')) return true
|
|
831
|
+
if (data === '\x1b' || data === '\x1b\x1b') return true
|
|
832
|
+
return false
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// running 中的会话遇到用户按 Ctrl+C/Esc 时被调用。
|
|
836
|
+
// 现状:Claude / Codex 自然 turn done 才发 Stop hook 和 stop_reason='end_turn',
|
|
837
|
+
// 用户打断不发 → session.status 卡在 running、computeEffectiveStatus 又因 lastOutputAt
|
|
838
|
+
// 推进而强转 running,前端徽标永远转圈。
|
|
839
|
+
//
|
|
840
|
+
// 策略:从按下打断键起延后 INTERRUPT_GRACE_MS 检查 lastOutputAt;若 PTY 已经静默
|
|
841
|
+
// ≥ INTERRUPT_QUIET_MS 则视为打断成功,复用 markSessionIdleAfterTurn 走与自然 turn done
|
|
842
|
+
// 同样的持久化路径(todo.status → ai_done、broadcast turn_done),保留会话本身(PTY 不退)。
|
|
843
|
+
// 若 PTY 还在喷收尾输出,重试 INTERRUPT_MAX_RETRIES 次后放弃,留给 stop hook / jsonl
|
|
844
|
+
// watcher 接力(双层防御互不依赖)。
|
|
845
|
+
function scheduleInterruptIdleCheck(session) {
|
|
846
|
+
if (!session) return
|
|
847
|
+
if (session.interruptTimer) {
|
|
848
|
+
clearTimeout(session.interruptTimer)
|
|
849
|
+
session.interruptTimer = null
|
|
850
|
+
}
|
|
851
|
+
session.interruptRetries = 0
|
|
852
|
+
const tryFlip = () => {
|
|
853
|
+
session.interruptTimer = null
|
|
854
|
+
if (!sessions.has(session.sessionId)) return
|
|
855
|
+
if (session.status !== 'running') return
|
|
856
|
+
const lastOutputAt = Number(session.lastOutputAt || 0)
|
|
857
|
+
const now = Date.now()
|
|
858
|
+
if (lastOutputAt > 0 && now - lastOutputAt < INTERRUPT_QUIET_MS) {
|
|
859
|
+
session.interruptRetries = (session.interruptRetries || 0) + 1
|
|
860
|
+
if (session.interruptRetries >= INTERRUPT_MAX_RETRIES) return
|
|
861
|
+
session.interruptTimer = setTimeout(tryFlip, INTERRUPT_GRACE_MS)
|
|
862
|
+
session.interruptTimer.unref?.()
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
const ts = now
|
|
866
|
+
session.lastTurnDoneAt = ts
|
|
867
|
+
markSessionIdleAfterTurn(session, ts)
|
|
868
|
+
broadcastToSession(session, {
|
|
869
|
+
type: 'turn_done',
|
|
870
|
+
event: 'interrupted',
|
|
871
|
+
status: session.status || 'idle',
|
|
872
|
+
timestamp: ts,
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
session.interruptTimer = setTimeout(tryFlip, INTERRUPT_GRACE_MS)
|
|
876
|
+
session.interruptTimer.unref?.()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function clearInterruptTimer(session) {
|
|
880
|
+
if (!session) return
|
|
881
|
+
if (session.interruptTimer) {
|
|
882
|
+
clearTimeout(session.interruptTimer)
|
|
883
|
+
session.interruptTimer = null
|
|
884
|
+
}
|
|
885
|
+
session.interruptRetries = 0
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function writeRestInputToPty(sessionId, data) {
|
|
889
|
+
if (typeof data !== 'string') return
|
|
890
|
+
const submit = data.match(/[\r\n]$/)?.[0]
|
|
891
|
+
if (!submit || data.length === 1) {
|
|
892
|
+
pty.write(sessionId, data)
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
const text = data.slice(0, -1)
|
|
896
|
+
if (text) pty.write(sessionId, text)
|
|
897
|
+
setTimeout(() => {
|
|
898
|
+
try { pty.write(sessionId, submit) } catch (e) {
|
|
899
|
+
console.warn(`[ai-terminal/input] submit write failed for ${sessionId}: ${e.message}`)
|
|
900
|
+
}
|
|
901
|
+
}, 80)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function handleSetAutoMode(sessionId, msg, ws) {
|
|
905
|
+
const session = sessions.get(sessionId)
|
|
906
|
+
if (!session) return
|
|
907
|
+
const nextAutoMode = msg.autoMode || null
|
|
908
|
+
session.autoMode = nextAutoMode
|
|
909
|
+
broadcastToSession(session, { type: 'auto_mode', autoMode: session.autoMode || null })
|
|
910
|
+
|
|
911
|
+
if (nextAutoMode !== 'bypass' || session.tool !== 'claude') return
|
|
912
|
+
|
|
913
|
+
if (!session.nativeSessionId) {
|
|
914
|
+
sendToBrowser(ws, {
|
|
915
|
+
type: 'auto_mode_notice',
|
|
916
|
+
autoMode: 'bypass',
|
|
917
|
+
immediate: false,
|
|
918
|
+
reason: 'native_session_missing',
|
|
919
|
+
message: '当前 Claude 会话尚未拿到原生 session id,全托管将仅对后续启动/恢复的会话生效。',
|
|
920
|
+
})
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
broadcastToSession(session, { type: 'auto_mode_switching', target: 'bypass' })
|
|
925
|
+
const todoSnapshot = db.getTodo(session.todoId)
|
|
926
|
+
session.replacedBySessionId = '__pending__'
|
|
927
|
+
let restarted
|
|
928
|
+
try {
|
|
929
|
+
restarted = spawnSession({
|
|
930
|
+
todoId: session.todoId,
|
|
931
|
+
prompt: session.prompt || '',
|
|
932
|
+
tool: session.tool,
|
|
933
|
+
cwd: session.cwd || undefined,
|
|
934
|
+
resumeNativeId: session.nativeSessionId,
|
|
935
|
+
permissionMode: 'bypass',
|
|
936
|
+
label: 'runtime:bypass',
|
|
937
|
+
skipTelegram: true,
|
|
938
|
+
ignoreExistingNativeSessionId: true,
|
|
939
|
+
})
|
|
940
|
+
} catch (e) {
|
|
941
|
+
delete session.replacedBySessionId
|
|
942
|
+
restoreSessionAsCurrent(session, todoSnapshot)
|
|
943
|
+
sendToBrowser(ws, {
|
|
944
|
+
type: 'auto_mode_notice',
|
|
945
|
+
autoMode: 'bypass',
|
|
946
|
+
immediate: false,
|
|
947
|
+
reason: 'restart_failed',
|
|
948
|
+
message: `切换全托管失败:${e.message}`,
|
|
949
|
+
})
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
broadcastToSession(session, {
|
|
954
|
+
type: 'session_restarted',
|
|
955
|
+
oldSessionId: sessionId,
|
|
956
|
+
newSessionId: restarted.sessionId,
|
|
957
|
+
autoMode: 'bypass',
|
|
958
|
+
})
|
|
959
|
+
if (restarted.sessionId !== sessionId) {
|
|
960
|
+
session.replacedBySessionId = restarted.sessionId
|
|
961
|
+
pty.stop(sessionId)
|
|
962
|
+
} else {
|
|
963
|
+
delete session.replacedBySessionId
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function handleBrowserMessage(sessionId, msg, ws) {
|
|
968
|
+
if (msg.type === 'input') {
|
|
969
|
+
const session = sessions.get(sessionId)
|
|
970
|
+
// 只有"决定性"按键才视为对 confirm 提示的真实回应:Enter / Ctrl+C / Ctrl+D。
|
|
971
|
+
// 普通可见字符('a'、'y' 等)不会让 Claude TUI 推进,提示原样保留 —— 若此时清掉
|
|
972
|
+
// pending 状态,紧跟的回显输出会再次匹配 confirm 关键词,把状态翻回 pending_confirm。
|
|
973
|
+
// 浏览器侧每次按键都收到 pending_cleared → pending_confirm 一对消息,导致前端 border
|
|
974
|
+
// 在 1px ↔ 2px 之间反复,肉眼上就是"打字时终端布局抖动"。
|
|
975
|
+
if (isPendingClearingInput(msg.data)) markSessionRunningAfterInput(session)
|
|
976
|
+
// 同 REST /input:running 中遇到打断键 → 排 idle 兜底检查,给前端 1.5s 后翻状态
|
|
977
|
+
if (session?.status === 'running' && isInterruptInput(msg.data)) scheduleInterruptIdleCheck(session)
|
|
978
|
+
// 同 REST /input:只在真正的"提交"键才翻 false,避免普通字符 / 焦点序列 / 粘贴
|
|
979
|
+
// 中间态把 awaitingReply 推回 false,导致 dispatcher 把 IM 消息死锁在队列里
|
|
980
|
+
// 直到下一次 Stop。
|
|
981
|
+
pty.write(sessionId, msg.data)
|
|
982
|
+
} else if (msg.type === 'init') {
|
|
983
|
+
const cols = Number(msg.cols)
|
|
984
|
+
const rows = Number(msg.rows)
|
|
985
|
+
const session = sessions.get(sessionId)
|
|
986
|
+
if (!session) return
|
|
987
|
+
if (!isValidResizeSize(cols, rows)) return
|
|
988
|
+
if (!session.spawned) {
|
|
989
|
+
if (session.spawnFallbackTimer) {
|
|
990
|
+
clearTimeout(session.spawnFallbackTimer)
|
|
991
|
+
session.spawnFallbackTimer = null
|
|
992
|
+
}
|
|
993
|
+
session.spawned = true
|
|
994
|
+
pty.startWithSize(sessionId, clampPtyCols(cols), rows).then(() => {
|
|
995
|
+
session.lastAppliedCols = clampPtyCols(cols)
|
|
996
|
+
session.lastAppliedRows = rows
|
|
997
|
+
if (ws && session.browsers.has(ws)) {
|
|
998
|
+
ws.__quadtodoSize = { cols, rows }
|
|
999
|
+
applyAggregatedResize(session)
|
|
1000
|
+
}
|
|
1001
|
+
}).catch((e) => {
|
|
1002
|
+
console.warn(`[ai-terminal] startWithSize failed for ${sessionId}: ${e.message}`)
|
|
1003
|
+
session.spawned = false
|
|
1004
|
+
})
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
// Register this WS's size into the aggregation map either way (covers
|
|
1008
|
+
// both the spawned-by-this-init case and the spawned-earlier reconnect case).
|
|
1009
|
+
if (ws && session.browsers.has(ws)) {
|
|
1010
|
+
ws.__quadtodoSize = { cols, rows }
|
|
1011
|
+
applyAggregatedResize(session)
|
|
1012
|
+
}
|
|
1013
|
+
} else if (msg.type === 'resize') {
|
|
1014
|
+
const cols = Number(msg.cols)
|
|
1015
|
+
const rows = Number(msg.rows)
|
|
1016
|
+
const session = sessions.get(sessionId)
|
|
1017
|
+
if (!canResizeSession(session)) return
|
|
1018
|
+
if (ws && session.browsers.has(ws)) {
|
|
1019
|
+
if (!isValidResizeSize(cols, rows)) {
|
|
1020
|
+
delete ws.__quadtodoSize
|
|
1021
|
+
applyAggregatedResize(session)
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
ws.__quadtodoSize = { cols, rows }
|
|
1025
|
+
applyAggregatedResize(session)
|
|
1026
|
+
} else {
|
|
1027
|
+
if (!isValidResizeSize(cols, rows)) return
|
|
1028
|
+
// 没拿到 ws 兜底走老路径,保留对非 WS 调用方的兼容
|
|
1029
|
+
const clampedCols = clampPtyCols(cols)
|
|
1030
|
+
if (session.lastAppliedCols === clampedCols && session.lastAppliedRows === rows) return
|
|
1031
|
+
session.lastAppliedCols = clampedCols
|
|
1032
|
+
session.lastAppliedRows = rows
|
|
1033
|
+
pty.resize(sessionId, clampedCols, rows)
|
|
1034
|
+
}
|
|
1035
|
+
} else if (msg.type === 'set_auto_mode') {
|
|
1036
|
+
handleSetAutoMode(sessionId, msg, ws)
|
|
1037
|
+
} else if (msg.type === 'clear_history') {
|
|
1038
|
+
// 用户主动清空旧 scrollback:用于摆脱"老 session 在窄 cols 时写下的硬折行"
|
|
1039
|
+
// 污染新窗口显示的场景。只清缓冲,不动 PTY 状态——Claude 会在下次输出时自动重绘。
|
|
1040
|
+
const session = sessions.get(sessionId)
|
|
1041
|
+
if (!session) return
|
|
1042
|
+
session.outputHistory = []
|
|
1043
|
+
session.outputSize = 0
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// ─── Cleanup of stale finished sessions (30 min) ───
|
|
1048
|
+
|
|
1049
|
+
const cleanupTimer = setInterval(() => {
|
|
1050
|
+
const cutoff = Date.now() - CLEANUP_MS
|
|
1051
|
+
for (const [id, s] of sessions) {
|
|
1052
|
+
if (!LIVE_AI_STATUSES.has(s.status)
|
|
1053
|
+
&& s.completedAt && s.completedAt < cutoff
|
|
1054
|
+
&& s.browsers.size === 0) {
|
|
1055
|
+
sessions.delete(id)
|
|
1056
|
+
if (todoSessionMap.get(s.todoId) === id) todoSessionMap.delete(s.todoId)
|
|
1057
|
+
if (s.nativeSessionId) {
|
|
1058
|
+
const nativeKey = `${s.tool}:${s.nativeSessionId}`
|
|
1059
|
+
if (nativeSessionMap.get(nativeKey) === id) nativeSessionMap.delete(nativeKey)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}, 5 * 60_000)
|
|
1064
|
+
cleanupTimer.unref?.()
|
|
1065
|
+
|
|
1066
|
+
function close() {
|
|
1067
|
+
clearInterval(cleanupTimer)
|
|
1068
|
+
for (const id of sessions.keys()) pty.stop(id)
|
|
1069
|
+
sessions.clear()
|
|
1070
|
+
todoSessionMap.clear()
|
|
1071
|
+
nativeSessionMap.clear()
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function recoverPendingTodosOnStartup() {
|
|
1075
|
+
// 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
|
|
1076
|
+
// 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
|
|
1077
|
+
// 会用交互式默认(= UI 上显示"手动")。
|
|
1078
|
+
let startupDefaultPermissionMode = null
|
|
1079
|
+
try {
|
|
1080
|
+
startupDefaultPermissionMode = loadConfig({ rootDir }).defaultPermissionMode || null
|
|
1081
|
+
} catch (e) {
|
|
1082
|
+
console.warn('[ai-terminal] recover: loadConfig failed:', e.message)
|
|
1083
|
+
}
|
|
1084
|
+
const todos = db.listTodos()
|
|
1085
|
+
.filter(todo => ['ai_running', 'ai_pending'].includes(todo.status))
|
|
1086
|
+
for (const todo of todos) {
|
|
1087
|
+
let recoverable = (todo.aiSessions || []).find(item => item?.nativeSessionId && (item.status === 'running' || item.status === 'idle' || item.status === 'pending_confirm'))
|
|
1088
|
+
|| (todo.aiSessions || []).find(item => item?.nativeSessionId)
|
|
1089
|
+
if (!recoverable) {
|
|
1090
|
+
db.updateTodo(todo.id, { status: 'todo' })
|
|
1091
|
+
continue
|
|
1092
|
+
}
|
|
1093
|
+
if (nativeSessionMap.has(`${recoverable.tool}:${recoverable.nativeSessionId}`)) continue
|
|
1094
|
+
// claude --resume 在错误的 cwd 下会立刻 "No conversation found" 退出。启动恢复前
|
|
1095
|
+
// 按 uuid 在 ~/.claude/projects/*/ 实际定位 jsonl:文件没了就放弃恢复(避免起一个
|
|
1096
|
+
// 注定失败的 PTY),文件还在就用 jsonl 内嵌的 cwd 字段修正可能漂移的 recoverable.cwd。
|
|
1097
|
+
if (recoverable.tool === 'claude' && pty.findClaudeSession) {
|
|
1098
|
+
const located = pty.findClaudeSession(recoverable.nativeSessionId)
|
|
1099
|
+
if (!located) {
|
|
1100
|
+
console.warn(`[ai-terminal] recovery skip: claude session ${recoverable.nativeSessionId.slice(0, 8)} no longer on disk`)
|
|
1101
|
+
db.updateTodo(todo.id, { status: 'todo' })
|
|
1102
|
+
continue
|
|
1103
|
+
}
|
|
1104
|
+
if (located.cwd && existsSync(located.cwd) && located.cwd !== recoverable.cwd) {
|
|
1105
|
+
console.log(`[ai-terminal] recovery cwd corrected for ${recoverable.nativeSessionId.slice(0, 8)}: ${recoverable.cwd} → ${located.cwd}`)
|
|
1106
|
+
recoverable = { ...recoverable, cwd: located.cwd }
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const sessionId = `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
1110
|
+
const cwd = resolveSessionCwd(recoverable.cwd || todo.workDir)
|
|
1111
|
+
// DB 里记的优先(尊重用户在该 session 上的显式选择,包括运行中切到 bypass 的那条);
|
|
1112
|
+
// 没记 → 回退到 config 全局默认;都没有 → null(即 'default',原始行为)。
|
|
1113
|
+
const recoveredPermissionMode = recoverable.permissionMode || startupDefaultPermissionMode || null
|
|
1114
|
+
const session = {
|
|
1115
|
+
sessionId,
|
|
1116
|
+
todoId: todo.id,
|
|
1117
|
+
tool: recoverable.tool,
|
|
1118
|
+
prompt: recoverable.prompt,
|
|
1119
|
+
status: 'running',
|
|
1120
|
+
startedAt: Date.now(),
|
|
1121
|
+
completedAt: null,
|
|
1122
|
+
browsers: new Set(),
|
|
1123
|
+
outputHistory: [],
|
|
1124
|
+
outputSize: 0,
|
|
1125
|
+
nativeSessionId: recoverable.nativeSessionId,
|
|
1126
|
+
recentOutput: '',
|
|
1127
|
+
cwd,
|
|
1128
|
+
currentCwd: cwd,
|
|
1129
|
+
permissionMode: recoveredPermissionMode || 'default',
|
|
1130
|
+
autoMode: recoveredPermissionMode && recoveredPermissionMode !== 'default' ? recoveredPermissionMode : null,
|
|
1131
|
+
lastOutputAt: null,
|
|
1132
|
+
lastTurnDoneAt: null,
|
|
1133
|
+
outputBytesTotal: 0,
|
|
1134
|
+
completedAt: null,
|
|
1135
|
+
awaitingReply: false,
|
|
1136
|
+
}
|
|
1137
|
+
sessions.set(sessionId, session)
|
|
1138
|
+
todoSessionMap.set(todo.id, sessionId)
|
|
1139
|
+
nativeSessionMap.set(`${recoverable.tool}:${recoverable.nativeSessionId}`, sessionId)
|
|
1140
|
+
db.updateTodo(todo.id, {
|
|
1141
|
+
status: 'ai_running',
|
|
1142
|
+
aiSessions: mergeTodoAiSessions(todo, {
|
|
1143
|
+
...recoverable,
|
|
1144
|
+
sessionId,
|
|
1145
|
+
cwd,
|
|
1146
|
+
status: 'running',
|
|
1147
|
+
startedAt: Date.now(),
|
|
1148
|
+
completedAt: null,
|
|
1149
|
+
permissionMode: recoveredPermissionMode || recoverable.permissionMode || null,
|
|
1150
|
+
}),
|
|
1151
|
+
})
|
|
1152
|
+
try {
|
|
1153
|
+
pty.start({
|
|
1154
|
+
sessionId,
|
|
1155
|
+
tool: recoverable.tool,
|
|
1156
|
+
prompt: null,
|
|
1157
|
+
cwd,
|
|
1158
|
+
resumeNativeId: recoverable.nativeSessionId,
|
|
1159
|
+
permissionMode: recoveredPermissionMode || undefined,
|
|
1160
|
+
extraEnv: {
|
|
1161
|
+
QUADTODO_SESSION_ID: sessionId,
|
|
1162
|
+
QUADTODO_TODO_ID: String(todo.id),
|
|
1163
|
+
QUADTODO_TODO_TITLE: String(todo.title || ''),
|
|
1164
|
+
QUADTODO_URL: 'http://127.0.0.1:5677',
|
|
1165
|
+
},
|
|
1166
|
+
}).catch((e) => {
|
|
1167
|
+
console.warn('[ai-terminal] auto-recover start failed:', e.message)
|
|
1168
|
+
sessions.delete(sessionId)
|
|
1169
|
+
todoSessionMap.delete(todo.id)
|
|
1170
|
+
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1171
|
+
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1172
|
+
db.updateTodo(todo.id, { status: 'todo' })
|
|
1173
|
+
})
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
console.warn('[ai-terminal] auto-recover failed:', e.message)
|
|
1176
|
+
sessions.delete(sessionId)
|
|
1177
|
+
todoSessionMap.delete(todo.id)
|
|
1178
|
+
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1179
|
+
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1180
|
+
db.updateTodo(todo.id, { status: 'todo' })
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
recoverPendingTodosOnStartup()
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
router,
|
|
1189
|
+
sessions,
|
|
1190
|
+
todoSessionMap,
|
|
1191
|
+
nativeSessionMap,
|
|
1192
|
+
addBrowser,
|
|
1193
|
+
removeBrowser,
|
|
1194
|
+
handleBrowserMessage,
|
|
1195
|
+
broadcastToSession,
|
|
1196
|
+
notifyTurnDone,
|
|
1197
|
+
spawnSession,
|
|
1198
|
+
markSessionAwaitingReply,
|
|
1199
|
+
markPendingConfirm,
|
|
1200
|
+
isSessionAwaitingReply,
|
|
1201
|
+
close,
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function markSessionAwaitingReply(sessionId, value) {
|
|
1205
|
+
const session = sessions.get(sessionId)
|
|
1206
|
+
if (!session) return false
|
|
1207
|
+
if (!LIVE_AI_STATUSES.has(session.status)) return false
|
|
1208
|
+
const next = !!value
|
|
1209
|
+
if (!next) {
|
|
1210
|
+
markSessionRunningAfterInput(session)
|
|
1211
|
+
return true
|
|
1212
|
+
}
|
|
1213
|
+
if (session.status === 'running') {
|
|
1214
|
+
markSessionIdleAfterTurn(session, Date.now())
|
|
1215
|
+
return true
|
|
1216
|
+
}
|
|
1217
|
+
if (session.awaitingReply === next) return true
|
|
1218
|
+
session.awaitingReply = next
|
|
1219
|
+
return true
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function isSessionAwaitingReply(sessionId) {
|
|
1223
|
+
const session = sessions.get(sessionId)
|
|
1224
|
+
if (!session) return false // 不存在 → 视为 busy(保守,避免抢跑)
|
|
1225
|
+
if (!LIVE_AI_STATUSES.has(session.status)) return false
|
|
1226
|
+
return !!session.awaitingReply
|
|
1227
|
+
}
|
|
1228
|
+
}
|