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.
@@ -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-DuZ_lMdf.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.4",
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",
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,
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
@@ -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
- 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) {