baxian 1.2.8 → 1.2.10

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.
Files changed (41) hide show
  1. package/dist/agent/manager.d.ts +18 -0
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +374 -42
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/prompt.d.ts +1 -2
  6. package/dist/agent/prompt.d.ts.map +1 -1
  7. package/dist/agent/prompt.js +68 -58
  8. package/dist/agent/prompt.js.map +1 -1
  9. package/dist/agent/runner.d.ts.map +1 -1
  10. package/dist/agent/runner.js +4 -2
  11. package/dist/agent/runner.js.map +1 -1
  12. package/dist/agent/tmux.d.ts +1 -0
  13. package/dist/agent/tmux.d.ts.map +1 -1
  14. package/dist/agent/tmux.js +4 -0
  15. package/dist/agent/tmux.js.map +1 -1
  16. package/dist/shared/constants.js +13 -13
  17. package/dist/shared/constants.js.map +1 -1
  18. package/dist/skill/registry.d.ts +3 -0
  19. package/dist/skill/registry.d.ts.map +1 -1
  20. package/dist/skill/registry.js +49 -5
  21. package/dist/skill/registry.js.map +1 -1
  22. package/dist/skills/{pr-feedback → baxian-pr-feedback}/SKILL.md +2 -1
  23. package/dist/skills/baxian-pr-feedback/agents/openai.yaml +2 -0
  24. package/dist/skills/{pr-recheck → baxian-pr-recheck}/SKILL.md +2 -1
  25. package/dist/skills/baxian-pr-recheck/agents/openai.yaml +2 -0
  26. package/dist/skills/{pr-review → baxian-pr-review}/SKILL.md +2 -1
  27. package/dist/skills/baxian-pr-review/agents/openai.yaml +2 -0
  28. package/dist/skills/{task-check → baxian-task-check}/SKILL.md +4 -3
  29. package/dist/skills/baxian-task-check/agents/openai.yaml +2 -0
  30. package/dist/web/assets/index-D29eOcxi.js +4 -0
  31. package/dist/web/assets/{react-BG4Iuztk.js → react-BFCkCmbU.js} +7 -7
  32. package/dist/web/assets/{router-eEZdpwQZ.js → router-D24GsdXZ.js} +1 -1
  33. package/dist/web/index.html +3 -3
  34. package/package.json +1 -1
  35. package/dist/skills/baxian-rules/SKILL.md +0 -18
  36. package/dist/skills/server-feedback/SKILL.md +0 -34
  37. package/dist/skills/server-recheck/SKILL.md +0 -30
  38. package/dist/skills/server-review/SKILL.md +0 -43
  39. package/dist/skills/server-spec-review/SKILL.md +0 -31
  40. package/dist/skills/spells/SKILL.md +0 -8
  41. package/dist/web/assets/index-6OPCjoD6.js +0 -4
@@ -6,7 +6,7 @@ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES
6
6
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
7
7
  import { PostApproveStore } from '../state/post-approve-store.js';
8
8
  import { SkillRegistry } from '../skill/registry.js';
9
- import { createRunner, LocalRunner, shellQuote, resolveAgentHost } from './runner.js';
9
+ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
10
10
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
11
11
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, } from './tmux.js';
12
12
  import { WorktreeManager } from './worktree.js';
@@ -87,8 +87,24 @@ export function canDispatchWithBinding(binding) {
87
87
  // recover 路径)都可能让第二个 prompt 派进同 pane 与旧 turn 混在一起。outcome handler 通过显式
88
88
  // allowAwaitingHuman:true release,gate 单点放行。
89
89
  const TURN_COMPLETED_AWAITING_PHASES = new Set();
90
+ // Pane is stopped (interrupt landed) but its session was not cleared: cancel is mid /clear
91
+ // (`cancel-clearing`) or /clear was unconfirmed (`cancel-clear-failed`). Resume can't fix it (it doesn't
92
+ // /clear), so these are DELETE-only: shouldReleaseHeldBinding returns false → recover()/escape/Resume all
93
+ // refuse. Persisted, so the protection survives a restart mid-cleanup.
94
+ const UNCLEARED_PANE_PHASES = new Set(['cancel-clearing', 'cancel-clear-failed']);
95
+ // All cancel-cleanup holds (the un-cleared ones plus `cancel-interrupt-failed`, where the interrupt failed
96
+ // so the pane may still be running the cancelled task). None may be AUTO-released — not by recover(), not by
97
+ // a terminal-task escape, not even by an allowAwaitingHuman caller — because that would reuse the cancelled
98
+ // session. Only cancel's own confirmed-/clear release (fromCancelCleanup) frees one automatically; the
99
+ // operator recovers via Resume (cancel-interrupt-failed only, after verifying) or DELETE (any).
100
+ const CANCEL_CLEANUP_HOLD_PHASES = new Set([...UNCLEARED_PANE_PHASES, 'cancel-interrupt-failed']);
101
+ // A prompt line still holding the typed `/clear` (e.g. `❯ /clear`, `› /clear`) = the Enter was swallowed,
102
+ // so /clear was never submitted. After a real submission /clear wipes the screen and the composer is empty.
103
+ const CLEAR_PENDING_IN_COMPOSER_RE = /(?:^|\n)[ \t]*[❯>›→][ \t]*\/clear\b/;
90
104
  // Resume / recover 共用:决定 Held agent 的 binding 是否随状态恢复一起清掉。
91
105
  export function shouldReleaseHeldBinding(state, boundTask) {
106
+ if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase))
107
+ return false;
92
108
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
93
109
  const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
94
110
  return !boundTask || taskIsTerminal || turnCompleted;
@@ -134,6 +150,7 @@ export class AgentManager {
134
150
  compactIdleWaitMs = 5 * 60_000;
135
151
  compactIdlePollMs = 2_000;
136
152
  manualCompactWaitMs = 5_000;
153
+ clearContextWaitMs = 30_000;
137
154
  postMergeFetchTimeoutMs = 60_000;
138
155
  postMergeBranchTimeoutMs = 10_000;
139
156
  // taskIds with in-flight manual review — second concurrent POST gets 409.
@@ -311,6 +328,16 @@ export class AgentManager {
311
328
  catch (err) {
312
329
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
313
330
  }
331
+ // Skills must be on disk at the repo root before the REPL launches OR is reused,
332
+ // so native discovery sees them and the dispatch's /skill / $skill resolves. Runs
333
+ // on every path — fresh launch, adopt of a live runtime, and shell/REPL restart —
334
+ // not just buildFreshSession; the version marker keeps the steady state a single cat.
335
+ try {
336
+ await this.provisionRepoSkills(runner, agent, workdir);
337
+ }
338
+ catch (err) {
339
+ throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
340
+ }
314
341
  let alive;
315
342
  try {
316
343
  alive = await tmux.hasSession(agentId);
@@ -327,6 +354,130 @@ export class AgentManager {
327
354
  }
328
355
  return this.buildFreshSession(tmux, agent, agentId, workdir);
329
356
  }
357
+ // Materialize baxian skills into the repo root the REPL launches in (cwd at
358
+ // launch), so the agent's `claude`/`codex` discovers them as native skills and
359
+ // the dispatch can force-load one with `/skill` / `$skill`. Each file is written
360
+ // atomically (stage + rename) and a current skill's dir is never removed, so a
361
+ // concurrent agent's lazy SKILL.md read never observes an absent/partial file.
362
+ async provisionRepoSkills(runner, agent, workdir) {
363
+ if (this.skillRegistry.names().length === 0)
364
+ return;
365
+ const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
366
+ const destRoot = `${workdir}/${subdir}`;
367
+ // Re-run on EVERY dispatch — do NOT cache the result. Config hot-reload
368
+ // (replaceConfig, no restart) can repoint this agent's workdir/runtime to another
369
+ // repo / skills dir, and repo code or a prior agent turn can tamper the on-disk
370
+ // skill tree between dispatches; a skip cache (in-memory or on-disk) would then
371
+ // serve a missing or repo-controlled tree. The files are tiny, so materialize
372
+ // unconditionally. The cleanup + git-exclude are idempotent + best-effort.
373
+ await this.excludeInjectedSkills(runner, workdir, subdir);
374
+ // Serialize the cleanup+materialize per target skills dir (shared with the launch-time
375
+ // scan in buildFreshSession): two same-runtime agents on one repo would otherwise let
376
+ // one's `rm` blank the tree while the other materializes or its REPL scans for skills.
377
+ await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
378
+ await this.ensureSkillDirSafe(runner, workdir, subdir);
379
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
380
+ });
381
+ }
382
+ // Per (host, workdir, runtime-subdir) in-process lock. Both the cleanup+materialize
383
+ // and the fresh REPL launch (which scans the skills dir at startup) run under it, so a
384
+ // concurrent same-dir agent never observes a transiently-empty skills tree.
385
+ skillDirChain = new Map();
386
+ skillDirLockKey(agent, workdir) {
387
+ // Canonicalize the host (a registry id and an equivalent inline host, or a blank vs default
388
+ // port, must collapse to one key) and the workdir (a trailing slash must not fork the lock),
389
+ // so two agents truly pointing at the same physical dir serialize instead of racing.
390
+ const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
391
+ const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
392
+ const dir = workdir.replace(/\/+$/, '');
393
+ return `${host}:${dir}:${subdir}`;
394
+ }
395
+ runUnderSkillDirLock(key, fn) {
396
+ const prev = this.skillDirChain.get(key) ?? Promise.resolve();
397
+ const run = prev.then(fn, fn);
398
+ this.skillDirChain.set(key, run.then(() => undefined, () => undefined));
399
+ return run;
400
+ }
401
+ // Atomic per-file replace. materialize() hands us each skill file's FINAL path; we stage it as a
402
+ // sibling `.baxian-tmp` and `mv -f` it into place. POSIX rename is atomic, so a claude/codex lazy
403
+ // SKILL.md body read — which happens at `/baxian-*` / `$baxian-*` INVOKE time, after this dir's
404
+ // provisioning lock has already been released — sees either the complete old file or the complete
405
+ // new one, never the truncate-in-place window of a bare writeFile or the blank window of a
406
+ // delete-then-rewrite. The tmp lives inside the `baxian-*` leaf, so the git-exclude rule covers it.
407
+ async atomicWriteFile(runner, finalPath, content) {
408
+ const tmp = `${finalPath}.baxian-tmp`;
409
+ await runner.writeFile(tmp, content);
410
+ const mv = `mv -f ${shellQuote(tmp)} ${shellQuote(finalPath)}`;
411
+ const res = await runner.exec(`sh -c ${shellQuote(mv)}`);
412
+ if (res.exitCode !== 0) {
413
+ throw new Error(`atomic skill write failed (${finalPath}): ${res.stderr || 'unknown error'}`);
414
+ }
415
+ }
416
+ // Make the skills subtree symlink-safe before writing into it, WITHOUT blanking a live skill. A
417
+ // current skill's dir is left in place (its files are swapped atomically by atomicWriteFile); we
418
+ // prune `baxian-*` dirs no longer in the registry, strip EVERY symlink anywhere under a `baxian-*`
419
+ // tree (leaf OR a nested component like `baxian-pr-review/agents`) so the atomic write can't be
420
+ // redirected out of the workdir, and drop stale helper files left by a past skill version (a
421
+ // removed/renamed file) — SKILL.md is kept so a concurrent lazy read is never blanked, and
422
+ // materialize re-writes every current file atomically. The PARENT components (`.claude`/`.agents`
423
+ // + their `skills` subdir) fail fast when they are symlinks: following one could write OUTSIDE the
424
+ // workdir, and silently rm-ing it would destroy a user's legitimate symlinked skills dir (codex
425
+ // documents symlinked skill folders as supported). `find -name`/`-path` (not a bare glob) avoids
426
+ // zsh NOMATCH; the whole thing runs under POSIX `sh -c` since wrapRemoteCommand otherwise uses the
427
+ // login shell (maybe fish). `top`/`subdir` are fixed constants; names are baxian-owned slugs.
428
+ async ensureSkillDirSafe(runner, workdir, subdir) {
429
+ const top = subdir.split('/')[0];
430
+ const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
431
+ const inner = `cd ${shellQuote(workdir)} && ` +
432
+ `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
433
+ `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
434
+ `exit 3; fi; done && ` +
435
+ `mkdir -p ${subdir} && ` +
436
+ `find ${subdir} -maxdepth 1 -name 'baxian-*' ${keep} -exec rm -rf {} + && ` +
437
+ `find ${subdir} -path '${subdir}/baxian-*' -type l -exec rm -f {} + && ` +
438
+ `find ${subdir} -path '${subdir}/baxian-*/*' -type f ! -name 'SKILL.md' -exec rm -f {} +`;
439
+ const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
440
+ if (res.exitCode !== 0) {
441
+ throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
442
+ }
443
+ }
444
+ // Tag a freshly-launched session with the skills version it discovered at launch, so
445
+ // adoptOrRestartSession can tell when a live REPL predates the current skills.
446
+ async tagSessionSkillsVersion(tmux, agentId) {
447
+ if (this.skillRegistry.names().length === 0)
448
+ return;
449
+ await tmux.setOption(agentId, '@baxian-skills-version', this.skillRegistry.contentHash());
450
+ }
451
+ // True when a live REPL's launch-time skills version differs from the current one
452
+ // (or is absent — a pre-skills session): it cannot resolve a dispatched /baxian-*.
453
+ async replSkillsStale(tmux, agentId) {
454
+ if (this.skillRegistry.names().length === 0)
455
+ return false;
456
+ // getOption already maps a MISSING tag to null (→ stale). Do NOT swallow other
457
+ // errors: a thrown tmux probe failure must propagate so the caller surfaces it as an
458
+ // EnsureSessionError, instead of being read as stale and needlessly killing the REPL.
459
+ const tagged = await tmux.getOption(agentId, '@baxian-skills-version');
460
+ return tagged !== this.skillRegistry.contentHash();
461
+ }
462
+ // Hide ONLY what baxian writes — the `baxian-*` skill dirs — from the agent's
463
+ // `git status` / PRs. Excluding the whole skills dir would also hide a user repo's own
464
+ // untracked native skills there, defeating the `baxian-` prefix's coexistence intent.
465
+ // The `if git rev-parse` guard skips a non-git workdir, and failure only warns: skills
466
+ // are already on disk, so a git hiccup must not block the session.
467
+ async excludeInjectedSkills(runner, workdir, subdir) {
468
+ // info/exclude patterns anchor at the REPO ROOT, but the skills dir lives at the
469
+ // workdir; when workdir is a SUBDIR of the repo, prefix the rule with the workdir's
470
+ // path relative to the repo root (git rev-parse --show-prefix) so the pattern matches.
471
+ const inner = `cd ${shellQuote(workdir)} && if p="$(git rev-parse --git-path info/exclude 2>/dev/null)"; then ` +
472
+ `pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
473
+ `mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; fi`;
474
+ // Run under POSIX sh (if/then/fi + $() are not fish syntax; wrapRemoteCommand uses $SHELL).
475
+ const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
476
+ if (res.exitCode !== 0) {
477
+ console.warn(`[AgentManager] skill info/exclude best-effort failed in ${workdir} ` +
478
+ `(skills still materialized): ${res.stderr || 'unknown error'}`);
479
+ }
480
+ }
330
481
  async pinRuntimeSessionOptions(tmux, agentId) {
331
482
  await tmux.setOption(agentId, 'prefix', 'C-b');
332
483
  await tmux.setOption(agentId, 'prefix2', 'None');
@@ -338,6 +489,11 @@ export class AgentManager {
338
489
  await this.pinRuntimeSessionOptions(tmux, agentId);
339
490
  }
340
491
  async buildFreshSession(tmux, agent, agentId, workdir) {
492
+ // Hold the per-skills-dir lock across the launch so the REPL's startup skill scan
493
+ // can't overlap a concurrent same-dir agent's provisioning rm (see provisionRepoSkills).
494
+ return this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir));
495
+ }
496
+ async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
341
497
  let createdSession = false;
342
498
  const runtime = agentRuntimeKindFor(agent);
343
499
  try {
@@ -361,6 +517,7 @@ export class AgentManager {
361
517
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
362
518
  scrollback: 0,
363
519
  });
520
+ await this.tagSessionSkillsVersion(tmux, agentId);
364
521
  return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
365
522
  }
366
523
  catch (err) {
@@ -923,9 +1080,25 @@ export class AgentManager {
923
1080
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
924
1081
  }
925
1082
  switch (state.kind) {
926
- case 'live-runtime':
1083
+ case 'live-runtime': {
1084
+ let stale;
1085
+ try {
1086
+ stale = await this.replSkillsStale(tmux, agentId);
1087
+ }
1088
+ catch (err) {
1089
+ // A tmux probe failure here is transient — surface it, do NOT kill the REPL.
1090
+ throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
1091
+ }
1092
+ if (stale) {
1093
+ // This REPL launched before the current skills were on disk; claude/codex only
1094
+ // discover a freshly-created top-level skills dir at launch, so a dispatched
1095
+ // /baxian-* / $baxian-* would not resolve. Rebuild so the command works.
1096
+ await tmux.killSession(agentId).catch(() => { });
1097
+ return this.buildFreshSession(tmux, agent, agentId, workdir);
1098
+ }
927
1099
  // 复用既有 REPL,上下文未中断——dedup 仍可沿用。
928
1100
  return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1101
+ }
929
1102
  case 'startup-dialog':
930
1103
  throw new EnsureSessionError({
931
1104
  createdSession: false,
@@ -949,6 +1122,7 @@ export class AgentManager {
949
1122
  scrollback: 0,
950
1123
  });
951
1124
  // 信任弹窗刚被答完,REPL 从启动态进入可用——上下文是新的。
1125
+ await this.tagSessionSkillsVersion(tmux, agentId);
952
1126
  return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
953
1127
  }
954
1128
  catch (trustErr) {
@@ -976,6 +1150,7 @@ export class AgentManager {
976
1150
  scrollback: 0,
977
1151
  });
978
1152
  // shell 路径:在原 pane 里重新启动了 REPL,新进程没有旧 prompt 上下文。
1153
+ await this.tagSessionSkillsVersion(tmux, agentId);
979
1154
  return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
980
1155
  }
981
1156
  catch (relErr) {
@@ -1038,6 +1213,17 @@ export class AgentManager {
1038
1213
  `(expected ${expectedTaskId}, got ${state.taskId}); skipping`);
1039
1214
  return false;
1040
1215
  }
1216
+ // Cancel-cleanup hold: only cancel's own release may free it. Checked BEFORE the allowAwaitingHuman
1217
+ // gate below, because that gate skips shouldReleaseHeldBinding entirely — so without this an
1218
+ // allowAwaitingHuman caller (startup false-start, review/max-rounds handlers, a terminal-task escape)
1219
+ // could reassign the un-cleared/maybe-running pane before cancel confirms /clear. (Operator recovery
1220
+ // via resumeAgent / DELETE doesn't go through this path.)
1221
+ if (state.awaitingPhase != null
1222
+ && CANCEL_CLEANUP_HOLD_PHASES.has(state.awaitingPhase)
1223
+ && !opts.fromCancelCleanup) {
1224
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} ${state.awaitingPhase} (cancel-cleanup hold); refusing auto-release`);
1225
+ return false;
1226
+ }
1041
1227
  const boundTask = await this.taskStore.get(expectedTaskId);
1042
1228
  if (state.status === 'awaiting_human' && !opts.allowAwaitingHuman) {
1043
1229
  // gate 例外:bound task 已 terminal / turn-completed phase 都属于正常 cleanup 路径,
@@ -1268,6 +1454,13 @@ export class AgentManager {
1268
1454
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} with active task ${state.taskId} — the dispatched prompt's pane signal has no consumer and Resume cannot rebuild the watcher; refusing Resume. Operator should cancel the task or DELETE the agent.`);
1269
1455
  return { resumed: false, releasedBinding: false };
1270
1456
  }
1457
+ // Un-cleared pane (cancel mid-clear or /clear unconfirmed): Resume would free + reuse it (terminal
1458
+ // task → shouldReleaseHeldBinding) and leak the cancelled task's context. Refuse; only DELETE (which
1459
+ // destroys the pane) is a safe recovery.
1460
+ if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase)) {
1461
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — pane holds un-cleared context; refusing Resume. DELETE the agent to discard it.`);
1462
+ return { resumed: false, releasedBinding: false };
1463
+ }
1271
1464
  const now = new Date().toISOString();
1272
1465
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
1273
1466
  const cfg = this.getAgentConfig(agentId);
@@ -1339,25 +1532,29 @@ export class AgentManager {
1339
1532
  }
1340
1533
  return { resumed: result.resumed, releasedBinding: result.releasedBinding };
1341
1534
  }
1342
- async interruptPaneAndWaitReady(state, cfg) {
1343
- const runner = this.createRunnerFor(cfg);
1344
- const tmux = new TmuxManager(runner);
1345
- let paneId = state.paneId;
1346
- if (!paneId) {
1347
- try {
1348
- paneId = await tmux.getSinglePaneId(cfg.id);
1349
- }
1350
- catch (err) {
1351
- console.warn(`[AgentManager] interruptPaneAndWaitReady: getSinglePaneId failed for ${cfg.id}:`, err);
1352
- return false;
1353
- }
1535
+ async resolvePaneId(state, cfg) {
1536
+ if (state.paneId)
1537
+ return state.paneId;
1538
+ try {
1539
+ return await new TmuxManager(this.createRunnerFor(cfg)).getSinglePaneId(cfg.id);
1540
+ }
1541
+ catch (err) {
1542
+ console.warn(`[AgentManager] resolvePaneId: getSinglePaneId failed for ${cfg.id}:`, err);
1543
+ return null;
1354
1544
  }
1545
+ }
1546
+ // ESC (not Ctrl-C) is the interrupt key both runtimes advertise; returns whether the pane reached idle.
1547
+ async interruptPaneAndWaitReady(state, cfg) {
1548
+ const paneId = await this.resolvePaneId(state, cfg);
1549
+ if (!paneId)
1550
+ return false;
1551
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
1355
1552
  try {
1356
- await tmux.sendKeysToPane(paneId, 'C-c');
1553
+ await tmux.sendKeysToPane(paneId, 'Escape');
1357
1554
  await new Promise(r => setTimeout(r, 200));
1358
1555
  }
1359
1556
  catch (err) {
1360
- console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c failed for pane ${paneId}:`, err);
1557
+ console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
1361
1558
  return false;
1362
1559
  }
1363
1560
  try {
@@ -1372,6 +1569,72 @@ export class AgentManager {
1372
1569
  return false;
1373
1570
  }
1374
1571
  }
1572
+ // Persist a "cancel is interrupting + /clearing this pane" hold BEFORE the ESC→/clear window so the
1573
+ // protection survives a restart (recover() holds UNCLEARED_PANE_PHASES) and the escape can't reassign the
1574
+ // un-cleared pane. Direct update (no intervention event): a normal cancel clears it on release; only a
1575
+ // crash/failure leaves it. Conditional on the binding so a stale cancel can't mark an agent rebound away.
1576
+ async markPaneCancelClearing(agentId, taskId) {
1577
+ const now = new Date().toISOString();
1578
+ await this.agentStore.update(agentId, (latest) => {
1579
+ if (!latest || latest.taskId !== taskId)
1580
+ return AGENT_STORE_NOOP;
1581
+ return {
1582
+ ...latest,
1583
+ status: 'awaiting_human',
1584
+ awaitingPhase: 'cancel-clearing',
1585
+ awaitingReason: 'Cancelling: interrupting and clearing the runtime session; not reusable until /clear is confirmed or the agent is deleted.',
1586
+ awaitingSince: now,
1587
+ updatedAt: now,
1588
+ };
1589
+ });
1590
+ }
1591
+ // Returns whether /clear was confirmed (a real busy→idle, not the stale pre-/clear idle frame); the
1592
+ // caller must hold the agent, not release it, on false or the un-cleared context leaks to the next dispatch.
1593
+ async clearPaneContext(state, cfg) {
1594
+ const paneId = await this.resolvePaneId(state, cfg);
1595
+ if (!paneId)
1596
+ return false;
1597
+ if (!this.tryAcquireCompactGuard(cfg.id)) {
1598
+ console.warn(`[AgentManager] clearPaneContext: ${cfg.id} compact/upload in progress; cannot /clear`);
1599
+ return false;
1600
+ }
1601
+ try {
1602
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
1603
+ const runtime = agentRuntimeKindFor(cfg);
1604
+ // C-c clears any prompt an ack-timeout left in the composer, so /clear isn't appended to it and submitted.
1605
+ await tmux.sendKeysToPane(paneId, 'C-c');
1606
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.clearContextWaitMs);
1607
+ await tmux.sendKeysLiteral(paneId, '/clear');
1608
+ // Snapshot the composer holding the typed /clear; require submission proof so a swallowed Enter
1609
+ // (which would leave /clear idle in the composer and let waitForReplPromptReady pass on the stale
1610
+ // frame) is resent rather than treated as cleared.
1611
+ const beforeSubmit = await tmux.capturePaneSnapshot(paneId);
1612
+ await tmux.sendEnter(paneId);
1613
+ await tmux.waitSubmitAck(paneId, beforeSubmit, runtime, {
1614
+ timeoutMs: this.clearContextWaitMs,
1615
+ acceptComposerChange: true,
1616
+ resend: () => tmux.sendEnter(paneId),
1617
+ resendIntervalMs: this.compactIdlePollMs,
1618
+ });
1619
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.clearContextWaitMs);
1620
+ // Positively confirm the composer is empty: a ready anchor alone can sit above a composer that still
1621
+ // holds the typed /clear (e.g. acceptComposerChange returned on an unrelated redraw). If /clear is
1622
+ // still parked there, it was never submitted — treat as unconfirmed so the caller holds the pane.
1623
+ const afterClear = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
1624
+ if (CLEAR_PENDING_IN_COMPOSER_RE.test(afterClear)) {
1625
+ console.warn(`[AgentManager] clearPaneContext: /clear still in composer for ${cfg.id}; unconfirmed`);
1626
+ return false;
1627
+ }
1628
+ return true;
1629
+ }
1630
+ catch (err) {
1631
+ console.warn(`[AgentManager] clearPaneContext: /clear failed for ${cfg.id}:`, err);
1632
+ return false;
1633
+ }
1634
+ finally {
1635
+ this.compactInFlight.delete(cfg.id);
1636
+ }
1637
+ }
1375
1638
  async failTaskForDispatchError(taskId, phase, agentId, err) {
1376
1639
  const expected = PHASE_EXPECTED_STATUS[phase] ?? [];
1377
1640
  const transitioned = await this.transitionTaskStatus(taskId, 'failed', { fromStatus: expected.length > 0 ? expected : ['in_progress', 'review', 'fixing', 'approved', 'merge-ready'] });
@@ -1747,7 +2010,7 @@ export class AgentManager {
1747
2010
  return cleared;
1748
2011
  });
1749
2012
  }
1750
- // manual-merge skips snapshot a stale scrollback signal would re-fire every restart.
2013
+ // Recovery snapshot replay is safe because the completion token is cleared after merge-ready.
1751
2014
  async setupRecoveredPostApproveSignals() {
1752
2015
  if (!this.phaseSignalWatcher)
1753
2016
  return;
@@ -1756,8 +2019,6 @@ export class AgentManager {
1756
2019
  const completion = await this.postApproveStore.get(task.id);
1757
2020
  if (!completion)
1758
2021
  continue;
1759
- const project = this.getProjectConfig(task.projectId);
1760
- const skipSnapshot = project?.merge !== 'auto';
1761
2022
  try {
1762
2023
  await this.phaseSignalWatcher.start({
1763
2024
  taskId: task.id,
@@ -1765,7 +2026,7 @@ export class AgentManager {
1765
2026
  agentId: task.agentId,
1766
2027
  expectedKinds: 'pr-merge-ready',
1767
2028
  token: completion.token,
1768
- skipSnapshot,
2029
+ skipSnapshot: false,
1769
2030
  recovered: true,
1770
2031
  });
1771
2032
  }
@@ -2316,11 +2577,26 @@ export class AgentManager {
2316
2577
  if (!fresh || TERMINAL_STATUSES.includes(fresh.status)) {
2317
2578
  const cfg = this.getAgentConfig(agentId);
2318
2579
  const state = await this.agentStore.get(agentId);
2319
- if (cfg && state && state.taskId === taskId && !(await this.interruptPaneAndWaitReady(state, cfg))) {
2320
- await this.markAwaitingHuman(agentId, 'cancel-interrupt-failed', 'Task was cancelled during startup but C-c / REPL ready check failed; the agent may still be ' +
2321
- 'running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
2580
+ if (cfg && state && state.taskId === taskId) {
2581
+ // Persist the un-cleared hold before the ESC→/clear window so a restart mid-cleanup recovers it
2582
+ // held instead of releasing the still-dirty pane (mirrors cancelTask).
2583
+ await this.markPaneCancelClearing(agentId, taskId);
2584
+ if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
2585
+ await this.markAwaitingHuman(agentId, 'cancel-interrupt-failed', 'Task was cancelled during startup but ESC / REPL ready check failed; the agent may still be ' +
2586
+ 'running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
2587
+ return null;
2588
+ }
2589
+ if (!(await this.clearPaneContext(state, cfg))) {
2590
+ await this.markAwaitingHuman(agentId, 'cancel-clear-failed', 'Task was cancelled during startup and the session interrupted, but /clear was not confirmed; ' +
2591
+ 'the pane holds un-cleared context. DELETE the agent to discard it (Resume will not reuse an un-cleared pane).', { expectedTaskId: taskId });
2592
+ return null;
2593
+ }
2594
+ // /clear confirmed → cancel cleanup is done; free the cancel-clearing hold (only path allowed to).
2595
+ await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true, fromCancelCleanup: true });
2322
2596
  return null;
2323
2597
  }
2598
+ // No live pane to clean (agent gone / rebound): release without the cancel-cleanup bypass — if it
2599
+ // somehow holds an un-cleared phase, refusing here keeps the dirty pane for the owning cancel.
2324
2600
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
2325
2601
  return null;
2326
2602
  }
@@ -3320,7 +3596,11 @@ export class AgentManager {
3320
3596
  console.warn(`[recover] dispatchPostMergeCleanup(${state.id}, ${boundTask.id}) failed:`, cleanupErr);
3321
3597
  }
3322
3598
  }
3323
- const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
3599
+ // A cancel-cleanup hold must NOT be auto-released on restart (it would reuse the cancelled,
3600
+ // un-cleared/maybe-running pane). cancel-interrupt-failed has shouldReleaseHeldBinding=true (it's
3601
+ // operator-Resume recoverable), so exclude the whole cancel-cleanup set here explicitly.
3602
+ const cancelHold = state.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(state.awaitingPhase);
3603
+ const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
3324
3604
  // 释放 binding 时同步清 worktree(与 resumeAgent 一致)——否则跨重启恢复后
3325
3605
  // worktreePath 在下面 update 中被丢弃,磁盘上的 worktree 永远无人回收。
3326
3606
  if (shouldReleaseBinding && state.worktreePath) {
@@ -3378,7 +3658,9 @@ export class AgentManager {
3378
3658
  if (shouldReleaseBinding) {
3379
3659
  await this.lockManager.release(state.id);
3380
3660
  }
3381
- if (state.taskId && !shouldReleaseBinding) {
3661
+ // Skip the menu-watch for cancel-cleanup holds: they await operator Resume/DELETE, and their taskId
3662
+ // won't clear on its own, so the watcher would poll forever.
3663
+ if (state.taskId && !shouldReleaseBinding && !cancelHold) {
3382
3664
  this.startRuntimeMenuWatch(state.id);
3383
3665
  }
3384
3666
  }
@@ -3573,6 +3855,13 @@ export class AgentManager {
3573
3855
  mayBeInFlight: false,
3574
3856
  };
3575
3857
  }
3858
+ // Mark the panes cancel-clearing BEFORE flipping the task terminal (still under the lock), so any
3859
+ // window — a concurrent escape, or a restart — sees a persisted hold instead of a plain binding to a
3860
+ // terminal task that recover()/the escape would release with the session still un-cleared.
3861
+ for (const id of [devToRelease, qaToRelease]) {
3862
+ if (id)
3863
+ await this.markPaneCancelClearing(id, taskId);
3864
+ }
3576
3865
  const now = new Date().toISOString();
3577
3866
  task.status = 'cancelled';
3578
3867
  task.updatedAt = now;
@@ -3594,31 +3883,46 @@ export class AgentManager {
3594
3883
  // (config hot-removed: the pane outlives the config; state gone; rebound)
3595
3884
  // leave an in-flight publish possible.
3596
3885
  let devStopConfirmed = false;
3886
+ // Phase 1 — interrupt every still-bound pane first, so a slow /clear on one agent can't keep another
3887
+ // running the cancelled task. The persisted cancel-clearing hold (set under the lock) blocks any
3888
+ // concurrent escape from releasing these panes until they are /cleared.
3889
+ const stopped = [];
3597
3890
  for (const id of [devToRelease, qaToRelease]) {
3598
3891
  if (!id)
3599
3892
  continue;
3600
3893
  const cfg = this.getAgentConfig(id);
3601
3894
  const state = await this.agentStore.get(id);
3602
- if (!cfg || !state)
3603
- continue;
3604
- // 重校验绑定:lock 释放后另一路 release+acquire 可能已把 agent 绑给新任务,
3605
- // 此时不能 C-c 打到新会话上、也不能继续 idle release。
3606
- if (state.taskId !== taskId) {
3607
- console.warn(`[AgentManager] cancelTask: ${id} no longer bound to ${taskId} (got ${state.taskId}); skipping`);
3895
+ if (!cfg || !state || state.taskId !== taskId) {
3896
+ console.warn(`[AgentManager] cancelTask: ${id} no longer bound to ${taskId} (got ${state?.taskId}); skipping`);
3608
3897
  continue;
3609
3898
  }
3610
- const ok = await this.interruptPaneAndWaitReady(state, cfg);
3611
- if (!ok) {
3612
- await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but C-c / REPL ready check failed; agent may still be running. Attach via web terminal to verify, then Resume or Delete.');
3899
+ if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
3900
+ await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but ESC / REPL ready check failed; agent may still be running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
3613
3901
  continue;
3614
3902
  }
3903
+ // devStopConfirmed reflects only "the pane stopped" — set before /clear so published-artifact
3904
+ // retirement still proceeds even when /clear can't be confirmed.
3615
3905
  if (id === publishedCleanup?.devAgentId)
3616
3906
  devStopConfirmed = true;
3907
+ stopped.push(id);
3908
+ }
3909
+ // Phase 2 — every pane is stopped; /clear + release each.
3910
+ for (const id of stopped) {
3911
+ const cfg = this.getAgentConfig(id);
3912
+ const state = await this.agentStore.get(id);
3913
+ if (!cfg || !state || state.taskId !== taskId) {
3914
+ console.warn(`[AgentManager] cancelTask: ${id} rebound before /clear (got ${state?.taskId}); skipping`);
3915
+ continue;
3916
+ }
3917
+ if (!(await this.clearPaneContext(state, cfg))) {
3918
+ // /clear unconfirmed → hold; the un-cleared pane stays bound (UNCLEARED_PANE_PHASES) until DELETE.
3919
+ await this.markAwaitingHuman(id, 'cancel-clear-failed', 'Task marked cancelled and the session interrupted, but /clear was not confirmed; the pane holds un-cleared context. DELETE the agent to discard it (Resume will not reuse an un-cleared pane).', { expectedTaskId: taskId });
3920
+ continue;
3921
+ }
3617
3922
  try {
3618
- // allowAwaitingHuman: cancelTask 是显式回收入口,agent 之前可能因 ack_unknown 等被标 Held,
3619
- // 用户主动 Cancel 应允许跨过 awaiting_human gate 清理 binding;release 默认 gate 是为了
3620
- // 拦住非显式路径(如 generic catch)的意外清理,cancelTask 不属于那类。
3621
- await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true });
3923
+ // fromCancelCleanup: this IS the owning cancel, having confirmed /clear — the only release allowed
3924
+ // to free the cancel-clearing hold. allowAwaitingHuman: cross the awaiting_human gate too.
3925
+ await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true, fromCancelCleanup: true });
3622
3926
  }
3623
3927
  catch (err) {
3624
3928
  console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
@@ -4623,10 +4927,10 @@ export class AgentManager {
4623
4927
  await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
4624
4928
  if (!await bindingStillOurs())
4625
4929
  return;
4626
- await tmux.sendKeysLiteral(paneId, cleanSlate ? '/clear' : '/compact');
4627
- await tmux.sendEnter(paneId);
4628
- await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4629
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
4930
+ const command = cleanSlate ? '/clear' : '/compact';
4931
+ if (!await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs)) {
4932
+ return;
4933
+ }
4630
4934
  break;
4631
4935
  }
4632
4936
  catch (err) {
@@ -4636,6 +4940,34 @@ export class AgentManager {
4636
4940
  }
4637
4941
  await this.releasePostMergeAgent(agentId, originalTaskId);
4638
4942
  }
4943
+ async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs) {
4944
+ let rejection;
4945
+ for (let attempt = 1; attempt <= 2; attempt++) {
4946
+ await tmux.sendKeysLiteral(paneId, command);
4947
+ await tmux.sendEnter(paneId);
4948
+ await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4949
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
4950
+ if (!await bindingStillOurs())
4951
+ return false;
4952
+ if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, command))
4953
+ return true;
4954
+ rejection = new Error(`runtime rejected ${command} because a task is still in progress`);
4955
+ if (attempt < 2) {
4956
+ console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) ${command} rejected; retrying`);
4957
+ await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4958
+ if (!await bindingStillOurs())
4959
+ return false;
4960
+ }
4961
+ }
4962
+ throw rejection ?? new Error(`runtime rejected ${command}`);
4963
+ }
4964
+ async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
4965
+ const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
4966
+ return this.runtimeSlashCommandRejectedPattern(command).test(cap);
4967
+ }
4968
+ runtimeSlashCommandRejectedPattern(command) {
4969
+ return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
4970
+ }
4639
4971
  // pane_current_command 是 runtime 是否仍活的权威信号(不被 viewport stale frame 骗)。
4640
4972
  // anchor 在 codex busy 屏(`Working on it…\n esc to interrupt`)不存在,所以 busy 状态只看
4641
4973
  // procTitle;只有准备返回 idle 时才用 anchor 作双重证据,挡 stale-frame + shell 误报。