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.
@@ -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-nkG0O5n8.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CEiuiF0m.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentquad",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "AgentQuad — local four-quadrant AI task scheduler with embedded Claude Code / Codex terminals",
5
5
  "license": "MIT",
6
6
  "author": "LIUZHENHUA521",
@@ -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
- const staleLegacyBin = isStaleLegacyBin(
236
- name,
237
- configuredCommand,
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
- const meta = getToolMetadata(name, { ...tools, [name]: normalized });
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: meta.effectiveCommand,
273
- bin: meta.effectiveBin,
274
- args: meta.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
- ...getToolMetadata(name, tools),
280
+ ...meta,
286
281
  command: resolved[name].command,
287
- bin: resolved[name].bin,
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,
@@ -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(toolCfg.bin)
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(toolCfg.bin, args, {
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} (bin=${toolCfg.bin}, cwd=${effectiveCwd}, args=${JSON.stringify(args)}): ${error.message}`
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
- const extractSource = promptText || session.recentOutput || ''
203
- // 主源 recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
204
- // 兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
205
- let historicalRaw = null
206
- if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
207
- const joined = session.outputHistory.join('')
208
- historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
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 viewer 进入时:清掉历史 scrollback 并跳过 replay。
868
- // scrollback 多半是窄 cols(如默认 80、Dock 卡片宽度)状态下 PTY 写下的硬换行,
869
- // replay 到全屏视图就是"行间短文字 + 右侧大片空白"的乱码观感。
870
- // 真正的对话历史在 Conversation tab 由 jsonl 还原,不依赖这里的 PTY scrollback。
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
- const merged = {
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 (!cwd || !nativeSessionId) return null
95
- const projDir = join(CLAUDE_PROJECTS_DIR, claudeProjectHash(cwd))
96
- const file = join(projDir, `${nativeSessionId}.jsonl`)
97
- return existsSync(file) ? file : null
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) {