agentquad 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-web/assets/{index-CEiuiF0m.css → index-8A0oLLcX.css} +1 -1
- package/dist-web/assets/{index-nkG0O5n8.js → index-By--XlP3.js} +242 -238
- package/dist-web/index.html +2 -2
- package/package.json +1 -1
- package/src/claude-transcript.js +50 -0
- package/src/config.js +26 -20
- package/src/permission-prompt.js +44 -0
- package/src/pty.js +80 -5
- package/src/routes/ai-terminal.js +95 -17
- package/src/server.js +3 -12
- package/src/transcript.js +17 -4
package/dist-web/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>AgentQuad</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-By--XlP3.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-8A0oLLcX.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/claude-transcript.js
CHANGED
|
@@ -200,4 +200,54 @@ export function buildFullTranscript(jsonlPath, opts = {}) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* 找最近一个"还没拿到 tool_result 的 tool_use"。
|
|
205
|
+
*
|
|
206
|
+
* 用途:Claude Code Notification fire 时,jsonl 末尾通常已经写了 assistant 的
|
|
207
|
+
* `tool_use` 块(比如 Bash 命令),但还没拿到 `tool_result`(用户没批准之前
|
|
208
|
+
* Claude Code 不会 invoke 工具)。把这一块直接读出来,比从 PTY redraw 噪声
|
|
209
|
+
* 里抽要干净一万倍——前端 PermissionCard 直接拿来展示要授权的是哪个命令。
|
|
210
|
+
*
|
|
211
|
+
* 返回 { id, name, input, timestamp } 或 null。
|
|
212
|
+
* 算法:从 jsonl 末尾向前扫,先收集所有已经回收的 tool_use_id;遇到第一个
|
|
213
|
+
* "id 不在已回收集合里"的 tool_use 即返回。
|
|
214
|
+
*/
|
|
215
|
+
export function findLatestPendingToolUse(jsonlPath) {
|
|
216
|
+
const lines = readJsonlLines(jsonlPath)
|
|
217
|
+
if (lines.length === 0) return null
|
|
218
|
+
const resolvedIds = new Set()
|
|
219
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
220
|
+
const obj = parseJsonlLine(lines[i])
|
|
221
|
+
if (!obj) continue
|
|
222
|
+
if (obj.isMeta || obj.isSidechain) continue
|
|
223
|
+
const content = normalizeContent(obj.message?.content)
|
|
224
|
+
if (content.length === 0) continue
|
|
225
|
+
|
|
226
|
+
if (obj.type === 'user') {
|
|
227
|
+
for (const block of content) {
|
|
228
|
+
if (block?.type === 'tool_result' && block.tool_use_id) {
|
|
229
|
+
resolvedIds.add(block.tool_use_id)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (obj.type !== 'assistant') continue
|
|
235
|
+
|
|
236
|
+
// assistant 块里可能多个 tool_use(并发工具)—— 取末尾(最新)那一个是
|
|
237
|
+
// pending 的就返回。
|
|
238
|
+
for (let j = content.length - 1; j >= 0; j--) {
|
|
239
|
+
const block = content[j]
|
|
240
|
+
if (block?.type !== 'tool_use' || !block.id) continue
|
|
241
|
+
if (resolvedIds.has(block.id)) continue
|
|
242
|
+
return {
|
|
243
|
+
id: block.id,
|
|
244
|
+
name: block.name || 'tool',
|
|
245
|
+
input: block.input || {},
|
|
246
|
+
timestamp: obj.timestamp || null,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
export const __test__ = { normalizeContent, blockToText, parseJsonlLine }
|
package/src/config.js
CHANGED
|
@@ -217,12 +217,6 @@ function runtimeBinOverride(name) {
|
|
|
217
217
|
return process.env[`${name.toUpperCase()}_BIN`] || null;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
function isStaleLegacyBin(name, configuredCommand, configuredBin, detectedBin) {
|
|
221
|
-
if (!configuredCommand || !configuredBin) return false;
|
|
222
|
-
if (configuredBin === detectedBin) return false;
|
|
223
|
-
return basename(configuredBin) === name;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
220
|
function getToolMetadata(name, tools = {}) {
|
|
227
221
|
const normalizedTool = normalizeToolConfig(name, tools?.[name], {
|
|
228
222
|
applyDefaultCommand: false,
|
|
@@ -232,12 +226,9 @@ function getToolMetadata(name, tools = {}) {
|
|
|
232
226
|
const configuredBin = normalizedTool.bin || null;
|
|
233
227
|
const effectiveCommand = configuredCommand || defaultToolCommand(name);
|
|
234
228
|
const detectedBin = detectBinary(effectiveCommand);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
configuredBin,
|
|
239
|
-
detectedBin,
|
|
240
|
-
);
|
|
229
|
+
// effectiveBin 与 PTY 实际启动顺序保持一致:env override > 用户字面 bin > PATH 探测兜底。
|
|
230
|
+
// 不再用 basename 启发式自动改写用户的字面值(option C:用户输入即真理)。
|
|
231
|
+
const effectiveBin = envBin || configuredBin || detectedBin;
|
|
241
232
|
const source = envBin
|
|
242
233
|
? "env"
|
|
243
234
|
: configuredBin
|
|
@@ -253,8 +244,7 @@ function getToolMetadata(name, tools = {}) {
|
|
|
253
244
|
configuredCommand: configuredCommand || null,
|
|
254
245
|
effectiveCommand,
|
|
255
246
|
configuredBin,
|
|
256
|
-
effectiveBin
|
|
257
|
-
envBin || (staleLegacyBin ? detectedBin : configuredBin) || detectedBin,
|
|
247
|
+
effectiveBin,
|
|
258
248
|
args: normalizedTool.args,
|
|
259
249
|
source,
|
|
260
250
|
installHint: TOOL_INSTALL_HINTS[name] || null,
|
|
@@ -266,12 +256,16 @@ export function resolveToolsConfig(tools = {}) {
|
|
|
266
256
|
const out = {};
|
|
267
257
|
for (const name of SUPPORTED_TOOLS) {
|
|
268
258
|
const normalized = normalizeToolConfig(name, tools[name]);
|
|
269
|
-
|
|
259
|
+
// 不再用 `command -v` 自动填充 bin —— 用户输入即真理。
|
|
260
|
+
// 仍保留 env override(<TOOL>_BIN,runtime 调试用),优先级高于配置文件,
|
|
261
|
+
// 但绝不写回 config.json。
|
|
262
|
+
// PTY 启动时 bin 为空会 fallback 到 command 名走 PATH 解析。
|
|
263
|
+
const envBin = runtimeBinOverride(name);
|
|
270
264
|
out[name] = {
|
|
271
265
|
...normalized,
|
|
272
|
-
command:
|
|
273
|
-
bin:
|
|
274
|
-
args:
|
|
266
|
+
command: normalized.command || defaultToolCommand(name),
|
|
267
|
+
bin: envBin || normalized.bin || "",
|
|
268
|
+
args: normalized.args,
|
|
275
269
|
};
|
|
276
270
|
}
|
|
277
271
|
return out;
|
|
@@ -281,10 +275,13 @@ export function inspectToolsConfig(tools = {}) {
|
|
|
281
275
|
const resolved = resolveToolsConfig(tools);
|
|
282
276
|
const out = {};
|
|
283
277
|
for (const name of SUPPORTED_TOOLS) {
|
|
278
|
+
const meta = getToolMetadata(name, tools);
|
|
284
279
|
out[name] = {
|
|
285
|
-
...
|
|
280
|
+
...meta,
|
|
286
281
|
command: resolved[name].command,
|
|
287
|
-
|
|
282
|
+
// 诊断行"当前有效路径"显示的是 envBin / configuredBin / detectedBin 三路兜底的值,
|
|
283
|
+
// 让用户能看到 PATH 探测会落到哪里;这里跟 resolved.bin(仅用户字面值)刻意分开。
|
|
284
|
+
bin: meta.effectiveBin,
|
|
288
285
|
};
|
|
289
286
|
}
|
|
290
287
|
return out;
|
|
@@ -313,6 +310,11 @@ function defaultConfig() {
|
|
|
313
310
|
host: "127.0.0.1",
|
|
314
311
|
defaultCwd: homedir(),
|
|
315
312
|
defaultPermissionMode: "default",
|
|
313
|
+
// 新建待办时是否默认勾选「创建后自动启动 AI 终端」。
|
|
314
|
+
// Drawer 上的开关仍可单次覆盖;这里只是默认值。
|
|
315
|
+
defaultAutoStartAi: false,
|
|
316
|
+
// 自动启动 / dispatch / 顶栏 ⌘K 等场景下使用的默认 AI 工具。
|
|
317
|
+
defaultAiTool: "claude",
|
|
316
318
|
tools: resolveToolsConfig(),
|
|
317
319
|
webhook: { ...DEFAULT_WEBHOOK_CONFIG },
|
|
318
320
|
openclaw: {
|
|
@@ -372,10 +374,14 @@ export function normalizeConfig(cfg = {}) {
|
|
|
372
374
|
};
|
|
373
375
|
finalTools[name] = normalizeToolConfig(name, mergedTools[name]);
|
|
374
376
|
}
|
|
377
|
+
const rawDefaultAiTool = typeof cfg.defaultAiTool === "string" ? cfg.defaultAiTool.trim() : "";
|
|
378
|
+
const defaultAiTool = SUPPORTED_TOOLS.includes(rawDefaultAiTool) ? rawDefaultAiTool : defaults.defaultAiTool;
|
|
375
379
|
return {
|
|
376
380
|
...defaults,
|
|
377
381
|
...cfgRest,
|
|
378
382
|
defaultPermissionMode: normalizePermissionMode(cfg.defaultPermissionMode, "default"),
|
|
383
|
+
defaultAutoStartAi: !!cfg.defaultAutoStartAi,
|
|
384
|
+
defaultAiTool,
|
|
379
385
|
tools: {
|
|
380
386
|
...mergedTools,
|
|
381
387
|
...finalTools,
|
package/src/permission-prompt.js
CHANGED
|
@@ -160,6 +160,50 @@ function takeWindow(lines, maxLines) {
|
|
|
160
160
|
*
|
|
161
161
|
* 返回 { text, options };text 不超过 maxChars,options 默认 maxLines=30。
|
|
162
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* 把 jsonl 里的 pending tool_use 块渲染成 PermissionCard 要显示的 prompt 文本。
|
|
165
|
+
*
|
|
166
|
+
* Claude Code 的工具有十几种,这里只把"用户最关心的字段"挑出来:
|
|
167
|
+
* Bash → input.command(完整命令,最多 1200 字)
|
|
168
|
+
* Edit/Write → input.file_path
|
|
169
|
+
* Read → input.file_path
|
|
170
|
+
* Glob/Grep → input.pattern / input.glob_pattern
|
|
171
|
+
* WebFetch → input.url
|
|
172
|
+
* 其它 → JSON.stringify(input)
|
|
173
|
+
* + 如果 input.description 存在,补一行说明。
|
|
174
|
+
*/
|
|
175
|
+
export function formatToolUseAsPrompt(toolUse, { maxChars = 1200 } = {}) {
|
|
176
|
+
if (!toolUse || typeof toolUse !== 'object') return ''
|
|
177
|
+
const name = String(toolUse.name || 'tool')
|
|
178
|
+
const input = toolUse.input || {}
|
|
179
|
+
let body = ''
|
|
180
|
+
if (typeof input.command === 'string') body = input.command
|
|
181
|
+
else if (typeof input.cmd === 'string') body = input.cmd
|
|
182
|
+
else if (typeof input.file_path === 'string') body = input.file_path
|
|
183
|
+
else if (typeof input.path === 'string') body = input.path
|
|
184
|
+
else if (typeof input.url === 'string') body = input.url
|
|
185
|
+
else if (typeof input.pattern === 'string') body = input.pattern
|
|
186
|
+
else if (typeof input.glob_pattern === 'string') body = input.glob_pattern
|
|
187
|
+
else if (typeof input.query === 'string') body = input.query
|
|
188
|
+
else {
|
|
189
|
+
try { body = JSON.stringify(input, null, 2) } catch { body = String(input) }
|
|
190
|
+
}
|
|
191
|
+
if (body.length > maxChars) body = body.slice(0, maxChars) + ' …(truncated)'
|
|
192
|
+
const desc = typeof input.description === 'string' && input.description.trim()
|
|
193
|
+
? `\n\n${input.description.trim()}`
|
|
194
|
+
: ''
|
|
195
|
+
return `${name}:\n${body}${desc}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Claude Code 标准 3 选项授权弹窗。当我们从 jsonl 拿到 pending tool_use 时,
|
|
199
|
+
// 选项是固定的——不必再去 PTY 里猜。前端按这三项渲染。
|
|
200
|
+
// 文案保持英文原样,与 TUI 一致,方便用户对照终端确认。
|
|
201
|
+
export const CLAUDE_DEFAULT_PERMISSION_OPTIONS = [
|
|
202
|
+
{ index: 1, label: 'Yes' },
|
|
203
|
+
{ index: 2, label: "Yes, and don't ask again this session" },
|
|
204
|
+
{ index: 3, label: 'No, and tell Claude what to do differently' },
|
|
205
|
+
]
|
|
206
|
+
|
|
163
207
|
export function extractPermissionPrompt(
|
|
164
208
|
raw,
|
|
165
209
|
{ historicalRaw = null, maxLines = 30, maxChars = 1200 } = {},
|
package/src/pty.js
CHANGED
|
@@ -64,6 +64,7 @@ const TUI_ALERT_COOLDOWN_MS = 30_000
|
|
|
64
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
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
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 CLAUDE_JSONL_FILE_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/
|
|
67
68
|
const MAX_LOG_BYTES = 512 * 1024
|
|
68
69
|
const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
|
|
69
70
|
|
|
@@ -143,6 +144,44 @@ function detectCodexSessionFromFs(afterMs) {
|
|
|
143
144
|
return newest
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// Claude 把 JSONL 写到 ~/.claude/projects/<cwd-hash>/<uuid>.jsonl。我们在 spawn
|
|
148
|
+
// 时通过 --session-id <presetClaudeId> 把 UUID 推下去,理想情况下 Claude 会用这个
|
|
149
|
+
// UUID 写文件,session.nativeId 直接对得上。
|
|
150
|
+
//
|
|
151
|
+
// 但部分代理 / wrapper(mira / trae 之类)会再 spawn 一次 claude、丢掉 --session-id,
|
|
152
|
+
// 或自家 fork 不识别这个 flag → Claude 用自己生成的 UUID 写 JSONL → session.nativeId
|
|
153
|
+
// 与磁盘上不一致 → loadTranscript 找不到文件 → 兜底成 PTY raw → Conversation
|
|
154
|
+
// 整段 banner 塌掉。
|
|
155
|
+
//
|
|
156
|
+
// 形态对齐 detectCodexSessionFromFs:扫所有 project 目录里 mtime > spawnTime 的
|
|
157
|
+
// <uuid>.jsonl,挑最新一个的 UUID。命中后由 _setNativeId 去重 + 覆盖。
|
|
158
|
+
function detectClaudeSessionFromFs(afterMs) {
|
|
159
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return null
|
|
160
|
+
let dirs
|
|
161
|
+
try { dirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) } catch { return null }
|
|
162
|
+
let newest = null
|
|
163
|
+
let newestTime = 0
|
|
164
|
+
for (const dirent of dirs) {
|
|
165
|
+
if (!dirent.isDirectory()) continue
|
|
166
|
+
const projDir = join(CLAUDE_PROJECTS_DIR, dirent.name)
|
|
167
|
+
let files
|
|
168
|
+
try { files = readdirSync(projDir) } catch { continue }
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
const m = f.match(CLAUDE_JSONL_FILE_RE)
|
|
171
|
+
if (!m) continue
|
|
172
|
+
try {
|
|
173
|
+
const st = statSync(join(projDir, f))
|
|
174
|
+
const t = st.birthtimeMs || st.ctimeMs
|
|
175
|
+
if (t > afterMs && t > newestTime) {
|
|
176
|
+
newest = m[1]
|
|
177
|
+
newestTime = t
|
|
178
|
+
}
|
|
179
|
+
} catch { /* ignore */ }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return newest
|
|
183
|
+
}
|
|
184
|
+
|
|
146
185
|
function tryReadCwdFromSessionMeta(filePath) {
|
|
147
186
|
try {
|
|
148
187
|
const head = readFileSync(filePath, 'utf8').split('\n').slice(0, 2)
|
|
@@ -449,9 +488,11 @@ export class PtyManager extends EventEmitter {
|
|
|
449
488
|
// cursor-agent 没有 --session-id 预置,但有 `cursor-agent create-chat` 异步建会话拿 chatId。
|
|
450
489
|
// 新会话先异步跑 create-chat,拿到 chatId 后在 startWithSize() 里用 --resume 进交互模式。
|
|
451
490
|
// create-chat 失败就降级(无 nativeId,直接传 prompt)。
|
|
491
|
+
// bin 为空时 fallback 到 command 名,让 execFile / spawn 走 PATH 解析。
|
|
492
|
+
const spawnFile = (toolCfg.bin && String(toolCfg.bin).trim()) || toolCfg.command
|
|
452
493
|
let cursorChatPromise = null
|
|
453
494
|
if (tool === 'cursor' && !resumeNativeId) {
|
|
454
|
-
cursorChatPromise = createCursorChatAsync(
|
|
495
|
+
cursorChatPromise = createCursorChatAsync(spawnFile)
|
|
455
496
|
}
|
|
456
497
|
const cursorResumeId = tool === 'cursor' ? resumeNativeId : null
|
|
457
498
|
|
|
@@ -533,6 +574,7 @@ export class PtyManager extends EventEmitter {
|
|
|
533
574
|
effectiveCwd,
|
|
534
575
|
toolCfg,
|
|
535
576
|
tool,
|
|
577
|
+
spawnFile,
|
|
536
578
|
resumeNativeId: resumeNativeId || null,
|
|
537
579
|
_baseArgs: [...baseArgs],
|
|
538
580
|
_permissionArgs: [...permissionArgs],
|
|
@@ -572,14 +614,14 @@ export class PtyManager extends EventEmitter {
|
|
|
572
614
|
|
|
573
615
|
const spec = session.spawnSpec
|
|
574
616
|
if (!spec) throw new Error(`session ${sessionId} has no spawnSpec (was it created?)`)
|
|
575
|
-
const { args, env, effectiveCwd, toolCfg, tool } = spec
|
|
617
|
+
const { args, env, effectiveCwd, toolCfg, tool, spawnFile } = spec
|
|
576
618
|
const { resumeNativeId } = spec
|
|
577
619
|
|
|
578
|
-
console.log(`[pty] starting ${tool} bin=${toolCfg.bin} cwd=${effectiveCwd} args=${JSON.stringify(args)} cols=${cols} rows=${rows}`)
|
|
620
|
+
console.log(`[pty] starting ${tool} spawnFile=${spawnFile} (configured bin=${toolCfg.bin || '<empty>'}) cwd=${effectiveCwd} args=${JSON.stringify(args)} cols=${cols} rows=${rows}`)
|
|
579
621
|
|
|
580
622
|
let proc
|
|
581
623
|
try {
|
|
582
|
-
proc = this.ptyFactory(
|
|
624
|
+
proc = this.ptyFactory(spawnFile, args, {
|
|
583
625
|
name: 'xterm-256color',
|
|
584
626
|
cols,
|
|
585
627
|
rows,
|
|
@@ -594,7 +636,7 @@ export class PtyManager extends EventEmitter {
|
|
|
594
636
|
try { if (existsSync(session.mcpConfigPath)) unlinkSync(session.mcpConfigPath) } catch { /* ignore */ }
|
|
595
637
|
}
|
|
596
638
|
this.sessions.delete(sessionId)
|
|
597
|
-
error.message = `PTY spawn failed for ${tool} (
|
|
639
|
+
error.message = `PTY spawn failed for ${tool} (spawnFile=${spawnFile}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
|
|
598
640
|
throw error
|
|
599
641
|
}
|
|
600
642
|
session.proc = proc
|
|
@@ -669,6 +711,39 @@ export class PtyManager extends EventEmitter {
|
|
|
669
711
|
session.detectTimer.unref?.()
|
|
670
712
|
}
|
|
671
713
|
|
|
714
|
+
// Claude 新会话:虽然 spawn 时已经传了 --session-id <presetClaudeId> 把 UUID
|
|
715
|
+
// 推下去(session.nativeId 也立刻设上),但代理/wrapper(mira / trae 等)会
|
|
716
|
+
// 在转发链路里丢掉 --session-id 或自家 fork claude → Claude 写 JSONL 时用自己
|
|
717
|
+
// 的 UUID → session.nativeId 对不上磁盘 → loadTranscript 兜底成 PTY raw。
|
|
718
|
+
//
|
|
719
|
+
// 这里加一道 FS 轮询治本:扫到 mtime > spawnTime 的真实 UUID,跟 session.nativeId
|
|
720
|
+
// 比一比;如果一致说明 preset 被 honor,停掉轮询即可;不一致则 _setNativeId 覆盖。
|
|
721
|
+
if (!resumeNativeId && tool === 'claude') {
|
|
722
|
+
const spawnTime = Date.now() - 1000
|
|
723
|
+
let detectAttempts = 0
|
|
724
|
+
const presetIdShort = session.nativeId?.slice(0, 8)
|
|
725
|
+
console.log(`[claude-detect] poll started session=${sessionId} preset=${presetIdShort} spawnTime=${spawnTime}`)
|
|
726
|
+
session.detectTimer = setInterval(() => {
|
|
727
|
+
detectAttempts++
|
|
728
|
+
const id = detectClaudeSessionFromFs(spawnTime)
|
|
729
|
+
if (id) {
|
|
730
|
+
if (id !== session.nativeId) {
|
|
731
|
+
console.log(`[claude-detect] poll attempt=${detectAttempts} OVERRIDE ${session.nativeId?.slice(0, 8)} → ${id.slice(0, 8)} (--session-id likely ignored by wrapper)`)
|
|
732
|
+
this._setNativeId(session, id)
|
|
733
|
+
} else {
|
|
734
|
+
console.log(`[claude-detect] poll attempt=${detectAttempts} preset honored, stop`)
|
|
735
|
+
clearInterval(session.detectTimer)
|
|
736
|
+
session.detectTimer = null
|
|
737
|
+
}
|
|
738
|
+
} else if (detectAttempts >= 30) {
|
|
739
|
+
console.warn(`[claude-detect] poll GAVE UP after 30 attempts (12s) for session=${sessionId} — no jsonl matching afterMs=${spawnTime} under ${CLAUDE_PROJECTS_DIR}`)
|
|
740
|
+
clearInterval(session.detectTimer)
|
|
741
|
+
session.detectTimer = null
|
|
742
|
+
}
|
|
743
|
+
}, 400)
|
|
744
|
+
session.detectTimer.unref?.()
|
|
745
|
+
}
|
|
746
|
+
|
|
672
747
|
proc.onData((data) => {
|
|
673
748
|
session.fullLog.push(data)
|
|
674
749
|
session.logBytes += data.length
|
|
@@ -7,7 +7,8 @@ import { homedir } from 'node:os'
|
|
|
7
7
|
import pidusage from 'pidusage'
|
|
8
8
|
import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
|
|
9
9
|
import { writeRuntimeMcpConfig } from '../agent-installer-shared.js'
|
|
10
|
-
import { extractPermissionPrompt } from '../permission-prompt.js'
|
|
10
|
+
import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt } from '../permission-prompt.js'
|
|
11
|
+
import { findLatestPendingToolUse } from '../claude-transcript.js'
|
|
11
12
|
|
|
12
13
|
const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
|
|
13
14
|
const CLEANUP_MS = 30 * 60_000
|
|
@@ -199,15 +200,38 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
199
200
|
const wasPending = session.status === 'pending_confirm'
|
|
200
201
|
if (!wasPending && session.status !== 'running') return false
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
203
|
+
let text = ''
|
|
204
|
+
let options = []
|
|
205
|
+
|
|
206
|
+
// Claude 优先走 jsonl 路径:Notification fire 时 jsonl 末尾通常已经写好了
|
|
207
|
+
// pending 的 tool_use 块(Bash 命令、Edit 文件 path 等),结构化、无 ANSI 噪声。
|
|
208
|
+
if (!promptText && session.tool === 'claude' && session.nativeSessionId && pty?.findClaudeSession) {
|
|
209
|
+
try {
|
|
210
|
+
const loc = pty.findClaudeSession(session.nativeSessionId)
|
|
211
|
+
if (loc?.filePath) {
|
|
212
|
+
const toolUse = findLatestPendingToolUse(loc.filePath)
|
|
213
|
+
if (toolUse) {
|
|
214
|
+
text = formatToolUseAsPrompt(toolUse)
|
|
215
|
+
options = CLAUDE_DEFAULT_PERMISSION_OPTIONS
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch { /* ignore — 走 PTY 兜底 */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 兜底:从 PTY 提取(Codex 主路径 / Claude jsonl 拿不到时的 backup)。
|
|
222
|
+
// recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
|
|
223
|
+
// 再兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
|
|
224
|
+
if (!text) {
|
|
225
|
+
const extractSource = promptText || session.recentOutput || ''
|
|
226
|
+
let historicalRaw = null
|
|
227
|
+
if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
|
|
228
|
+
const joined = session.outputHistory.join('')
|
|
229
|
+
historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
|
|
230
|
+
}
|
|
231
|
+
const r = extractPermissionPrompt(extractSource, { historicalRaw })
|
|
232
|
+
text = r.text
|
|
233
|
+
options = r.options
|
|
209
234
|
}
|
|
210
|
-
const { text, options } = extractPermissionPrompt(extractSource, { historicalRaw })
|
|
211
235
|
const hasContent = !!(text || options.length)
|
|
212
236
|
const prevPrompt = session.permissionPrompt || null
|
|
213
237
|
|
|
@@ -864,14 +888,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
864
888
|
const effectiveRole = role === 'primary' ? 'primary' : 'secondary'
|
|
865
889
|
if (ws) ws.__quadtodoRole = effectiveRole
|
|
866
890
|
session.browsers.add(ws)
|
|
867
|
-
// primary
|
|
868
|
-
//
|
|
869
|
-
// replay
|
|
870
|
-
|
|
871
|
-
if (effectiveRole === 'primary') {
|
|
872
|
-
session.outputHistory = []
|
|
873
|
-
session.outputSize = 0
|
|
874
|
-
} else if (session.outputHistory.length > 0) {
|
|
891
|
+
// 不分 primary / secondary,都回放——否则 reopen SessionFocus 会看到一片空白。
|
|
892
|
+
// 旧顾虑是"窄 cols 时代 scrollback 在宽 viewer 里重排乱码";现在交给 init/resize 后
|
|
893
|
+
// TUI 的 SIGWINCH 自重绘兜底,replay 内容沉到 scrollback 上面、用户可滚回去看。
|
|
894
|
+
if (session.outputHistory.length > 0) {
|
|
875
895
|
ws.send(JSON.stringify({ type: 'replay', chunks: session.outputHistory }))
|
|
876
896
|
}
|
|
877
897
|
ws.send(JSON.stringify({ type: 'auto_mode', autoMode: session.autoMode || null }))
|
|
@@ -1211,6 +1231,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1211
1231
|
nativeSessionMap.clear()
|
|
1212
1232
|
}
|
|
1213
1233
|
|
|
1234
|
+
// recoverPendingTodosOnStartup 的 spawn 失败 catch 路径用:把 mergeTodoAiSessions
|
|
1235
|
+
// 之前刚写进 DB 的 status='running' 那条改回 'failed',其它 aiSessions 原样保留。
|
|
1236
|
+
function markRecoveryFailed(todoId, sessionId) {
|
|
1237
|
+
try {
|
|
1238
|
+
const todoNow = db.getTodo(todoId)
|
|
1239
|
+
if (!todoNow) return
|
|
1240
|
+
const aiSessions = Array.isArray(todoNow.aiSessions) ? todoNow.aiSessions : []
|
|
1241
|
+
let mutated = false
|
|
1242
|
+
const next = aiSessions.map((s) => {
|
|
1243
|
+
if (s?.sessionId === sessionId) {
|
|
1244
|
+
mutated = true
|
|
1245
|
+
return { ...s, status: 'failed', completedAt: Date.now() }
|
|
1246
|
+
}
|
|
1247
|
+
return s
|
|
1248
|
+
})
|
|
1249
|
+
if (mutated) db.updateTodo(todoId, { aiSessions: next })
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
console.warn('[ai-terminal] markRecoveryFailed failed:', e.message)
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1214
1255
|
function recoverPendingTodosOnStartup() {
|
|
1215
1256
|
// 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
|
|
1216
1257
|
// 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
|
|
@@ -1309,6 +1350,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1309
1350
|
todoSessionMap.delete(todo.id)
|
|
1310
1351
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1311
1352
|
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1353
|
+
// recoverPendingTodosOnStartup 此前已 mergeTodoAiSessions 把该 session 写成
|
|
1354
|
+
// status='running';spawn 失败必须把那条改回 'failed',否则前端读到 running
|
|
1355
|
+
// 会渲染"运行中"且没有对应 PTY。与 markOrphanedSessionsAsFailed 互为冗余。
|
|
1356
|
+
markRecoveryFailed(todo.id, sessionId)
|
|
1312
1357
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1313
1358
|
})
|
|
1314
1359
|
} catch (e) {
|
|
@@ -1317,6 +1362,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1317
1362
|
todoSessionMap.delete(todo.id)
|
|
1318
1363
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1319
1364
|
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1365
|
+
markRecoveryFailed(todo.id, sessionId)
|
|
1320
1366
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1321
1367
|
}
|
|
1322
1368
|
}
|
|
@@ -1352,8 +1398,40 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1352
1398
|
}
|
|
1353
1399
|
}
|
|
1354
1400
|
|
|
1401
|
+
// 服务硬重启 / crash 时 PTY 进程没机会触发 onExit,DB 里 aiSession.status='running'
|
|
1402
|
+
// (或 idle / pending_confirm) 会留作"僵尸",前端读到后渲染成「运行中」却没有对应 PTY。
|
|
1403
|
+
// 启动期一次性把所有"看起来还活着但无对应 live PTY"的 aiSession 改成 'failed'。
|
|
1404
|
+
// 必须在 recoverPendingTodosOnStartup 之后调用:成功 recover 的 session 此时已在
|
|
1405
|
+
// nativeSessionMap 里,扫描会跳过它们;只有真正的孤儿会被改写。
|
|
1406
|
+
function markOrphanedSessionsAsFailed() {
|
|
1407
|
+
const ALIVE_LOOKING = new Set(['running', 'idle', 'pending_confirm'])
|
|
1408
|
+
let swept = 0
|
|
1409
|
+
try {
|
|
1410
|
+
for (const todo of db.listTodos()) {
|
|
1411
|
+
const aiSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
|
|
1412
|
+
let changed = false
|
|
1413
|
+
const nextSessions = aiSessions.map((s) => {
|
|
1414
|
+
if (!s || !ALIVE_LOOKING.has(s.status)) return s
|
|
1415
|
+
const key = s.tool && s.nativeSessionId ? `${s.tool}:${s.nativeSessionId}` : null
|
|
1416
|
+
if (key && nativeSessionMap.has(key)) return s
|
|
1417
|
+
changed = true
|
|
1418
|
+
return { ...s, status: 'failed', completedAt: Date.now() }
|
|
1419
|
+
})
|
|
1420
|
+
if (!changed) continue
|
|
1421
|
+
db.updateTodo(todo.id, { aiSessions: nextSessions })
|
|
1422
|
+
swept += 1
|
|
1423
|
+
}
|
|
1424
|
+
if (swept > 0) {
|
|
1425
|
+
console.log(`[ai-terminal] orphan sweep: marked ${swept} sessions as failed`)
|
|
1426
|
+
}
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
console.warn('[ai-terminal] markOrphanedSessionsAsFailed failed:', e.message)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1355
1432
|
sweepStuckPendingConfirm()
|
|
1356
1433
|
recoverPendingTodosOnStartup()
|
|
1434
|
+
markOrphanedSessionsAsFailed()
|
|
1357
1435
|
|
|
1358
1436
|
return {
|
|
1359
1437
|
router,
|
package/src/server.js
CHANGED
|
@@ -369,21 +369,12 @@ function buildNativeResumeCommand(tool, nativeSessionId, tools = {}) {
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
function mergeToolConfig(currentTool = {}, nextTool = {}) {
|
|
372
|
-
|
|
372
|
+
// 用户字段即真理:直接合并,不再因为 command 变了就悄悄清空 bin。
|
|
373
|
+
// PTY 启动时 bin 为空会 fallback 到 command 名走 PATH 解析。
|
|
374
|
+
return {
|
|
373
375
|
...currentTool,
|
|
374
376
|
...nextTool,
|
|
375
377
|
};
|
|
376
|
-
const commandChanged =
|
|
377
|
-
nextTool.command !== undefined &&
|
|
378
|
-
nextTool.command !== (currentTool.command || "");
|
|
379
|
-
const binUnchanged =
|
|
380
|
-
nextTool.bin !== undefined && nextTool.bin === (currentTool.bin || "");
|
|
381
|
-
|
|
382
|
-
if (commandChanged && binUnchanged) {
|
|
383
|
-
merged.bin = "";
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return merged;
|
|
387
378
|
}
|
|
388
379
|
|
|
389
380
|
function splitEditorPath(rawPath = "") {
|
package/src/transcript.js
CHANGED
|
@@ -91,10 +91,23 @@ function parseClaudeJsonl(filePath) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function findClaudeFile(cwd, nativeSessionId) {
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
if (!nativeSessionId) return null
|
|
95
|
+
// 优先按 cwd 哈希命中预期路径(多数场景的 fast path)
|
|
96
|
+
if (cwd) {
|
|
97
|
+
const file = join(CLAUDE_PROJECTS_DIR, claudeProjectHash(cwd), `${nativeSessionId}.jsonl`)
|
|
98
|
+
if (existsSync(file)) return file
|
|
99
|
+
}
|
|
100
|
+
// 兜底:cwd 哈希漂移(symlink / 特殊字符 / Claude 内部规范化差异)时,按 UUID
|
|
101
|
+
// 全局唯一性遍历所有 project 目录。命中是确定的,不会误伤。
|
|
102
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return null
|
|
103
|
+
let entries
|
|
104
|
+
try { entries = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) } catch { return null }
|
|
105
|
+
for (const dirent of entries) {
|
|
106
|
+
if (!dirent.isDirectory()) continue
|
|
107
|
+
const file = join(CLAUDE_PROJECTS_DIR, dirent.name, `${nativeSessionId}.jsonl`)
|
|
108
|
+
if (existsSync(file)) return file
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
function findCodexFile(nativeSessionId) {
|