agentquad 0.4.4 → 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-DuZ_lMdf.js → index-By--XlP3.js} +240 -236
- package/dist-web/index.html +2 -2
- package/package.json +1 -1
- package/src/config.js +26 -20
- package/src/pty.js +80 -5
- package/src/routes/ai-terminal.js +58 -0
- 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/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/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
|
|
@@ -1231,6 +1231,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1231
1231
|
nativeSessionMap.clear()
|
|
1232
1232
|
}
|
|
1233
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
|
+
|
|
1234
1255
|
function recoverPendingTodosOnStartup() {
|
|
1235
1256
|
// 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
|
|
1236
1257
|
// 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
|
|
@@ -1329,6 +1350,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1329
1350
|
todoSessionMap.delete(todo.id)
|
|
1330
1351
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1331
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)
|
|
1332
1357
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1333
1358
|
})
|
|
1334
1359
|
} catch (e) {
|
|
@@ -1337,6 +1362,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1337
1362
|
todoSessionMap.delete(todo.id)
|
|
1338
1363
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1339
1364
|
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1365
|
+
markRecoveryFailed(todo.id, sessionId)
|
|
1340
1366
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1341
1367
|
}
|
|
1342
1368
|
}
|
|
@@ -1372,8 +1398,40 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1372
1398
|
}
|
|
1373
1399
|
}
|
|
1374
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
|
+
|
|
1375
1432
|
sweepStuckPendingConfirm()
|
|
1376
1433
|
recoverPendingTodosOnStartup()
|
|
1434
|
+
markOrphanedSessionsAsFailed()
|
|
1377
1435
|
|
|
1378
1436
|
return {
|
|
1379
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) {
|