baxian 1.2.22 → 1.2.24

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.
@@ -13,7 +13,7 @@ import { WorktreeManager } from './worktree.js';
13
13
  import { RepoStore, createRepoStoreCache } from './repo-store.js';
14
14
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
15
15
  import { ReviewTransport } from './review-transport.js';
16
- import { buildPromptInline, buildGreetingPrompt, buildPostMergeCleanupPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
16
+ import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
17
17
  import { ApiError } from '../errors.js';
18
18
  import { prepareConfig } from '../config/loader.js';
19
19
  export class EnsureSessionError extends Error {
@@ -53,7 +53,7 @@ export function buildLaunchCommand(agent) {
53
53
  const segments = [];
54
54
  switch (agent.runtime) {
55
55
  case 'claude-code':
56
- segments.push('env CLAUDE_CODE_NO_FLICKER=1 claude --permission-mode bypassPermissions');
56
+ segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude --permission-mode bypassPermissions');
57
57
  break;
58
58
  case 'codex':
59
59
  segments.push('codex --dangerously-bypass-approvals-and-sandbox');
@@ -137,16 +137,6 @@ export function shouldReleaseHeldBinding(state, boundTask) {
137
137
  const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
138
138
  return !boundTask || taskIsTerminal || turnCompleted;
139
139
  }
140
- // null 表示 context 已无效(换 task / 换 pane),调用方应回退到完整注入。
141
- // 返回数组(含空数组)则代表当前 REPL session 已有的 skill 名集合,可作 excludeSkills 入参。
142
- function reuseSkillsIfContextValid(state, taskId, paneId) {
143
- const rec = state?.injectedSkills;
144
- if (!rec)
145
- return null;
146
- if (rec.taskId !== taskId || rec.paneId !== paneId)
147
- return null;
148
- return rec.skills;
149
- }
150
140
  export class AgentManager {
151
141
  config;
152
142
  agentStore;
@@ -1297,7 +1287,7 @@ export class AgentManager {
1297
1287
  await tmux.killSession(agentId).catch(() => { });
1298
1288
  return this.buildFreshSession(tmux, agent, agentId, workdir);
1299
1289
  }
1300
- // 复用既有 REPL,上下文未中断——dedup 仍可沿用。
1290
+ // 复用既有 REPL,上下文未中断——freshRuntime=false,post-approve 可走增量 nudge。
1301
1291
  return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1302
1292
  }
1303
1293
  case 'startup-dialog':
@@ -1598,16 +1588,17 @@ export class AgentManager {
1598
1588
  const result = await this.withTaskLock(async () => {
1599
1589
  const state = await this.agentStore.get(agentId);
1600
1590
  if (!state)
1601
- return { resumed: false, releasedBinding: false };
1591
+ return { resumed: false, releasedBinding: false, reason: 'Agent state not found.' };
1602
1592
  if (state.status !== 'awaiting_human') {
1603
- return { resumed: false, releasedBinding: false };
1593
+ return { resumed: false, releasedBinding: false, reason: 'Agent is not awaiting human; nothing to resume.' };
1604
1594
  }
1605
1595
  // creationToken 仍 set = bootstrap dialog 仍未解决。Resume 不能让它"继续"——
1606
1596
  // dialog 在 pane 里需要 operator 通过 web terminal 处理,slowPoll 解决后自动清状态。
1607
1597
  // 如果 operator 想放弃这个 agent,应该走 DELETE 路径。
1608
1598
  if (state.creationToken) {
1609
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} still has creationToken (bootstrap dialog unresolved); refusing Resume — operator should resolve dialog via web terminal or DELETE the agent.`);
1610
- return { resumed: false, releasedBinding: false };
1599
+ const reason = 'Bootstrap dialog still unresolved; resolve it via the web terminal or DELETE the agent.';
1600
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} still has creationToken ${reason}`);
1601
+ return { resumed: false, releasedBinding: false, reason };
1611
1602
  }
1612
1603
  const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
1613
1604
  // "prompt 可能仍在 pane 中跑"类 phase + bound task 仍 active 时 refuse:Resume 让
@@ -1623,8 +1614,9 @@ export class AgentManager {
1623
1614
  if (state.awaitingPhase != null
1624
1615
  && PROMPT_MAYBE_RUNNING_PHASES.has(state.awaitingPhase)
1625
1616
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
1626
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} with active task ${state.taskId} prompt may still be running; refusing Resume until outcome arrives or operator cancels the task / deletes the agent.`);
1627
- return { resumed: false, releasedBinding: false };
1617
+ const reason = `Prompt may still be running (${state.awaitingPhase}); Resume is blocked until the task outcome arrives. Cancel task ${state.taskId} or DELETE the agent to recover.`;
1618
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${reason}`);
1619
+ return { resumed: false, releasedBinding: false, reason };
1628
1620
  }
1629
1621
  // agent_dialog_pending: pane 仍卡 startup dialog,REPL 未 ready。Resume 让
1630
1622
  // shouldReleaseHeldBinding 看 task terminal/missing 放行后会清 binding/lock,下一次
@@ -1633,8 +1625,9 @@ export class AgentManager {
1633
1625
  // agent_dialog_resolved_runtime(Resume 放行)或 bootstrap path 直接清 Held → status='ok',
1634
1626
  // 或 DELETE agent。
1635
1627
  if (state.awaitingPhase === 'agent_dialog_pending') {
1636
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} dialog still pending; refusing Resume operator should dismiss dialog via web terminal (slowPoll will mark agent_dialog_resolved_runtime, then Resume) or DELETE the agent.`);
1637
- return { resumed: false, releasedBinding: false };
1628
+ const reason = 'Startup dialog still pending; Resume cannot dismiss it. Dismiss the dialog via the web terminal (baxian will auto-resume) or DELETE the agent.';
1629
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${reason}`);
1630
+ return { resumed: false, releasedBinding: false, reason };
1638
1631
  }
1639
1632
  // agent_dialog_resolved_runtime + active task:正常路径下 handleDialogPendingFromRuntime
1640
1633
  // 已 fail task → boundTask 应 terminal。bound task 仍 active 表示 crash window
@@ -1643,8 +1636,9 @@ export class AgentManager {
1643
1636
  // refuse Resume,提示 operator 显式 cancel task 或 DELETE agent。
1644
1637
  if (state.awaitingPhase === 'agent_dialog_resolved_runtime'
1645
1638
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
1646
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} dialog resolved but bound task ${state.taskId} still active (crash window) prompt was never injected; refusing Resume. Operator should cancel the task or DELETE the agent.`);
1647
- return { resumed: false, releasedBinding: false };
1639
+ const reason = `Dialog resolved but task ${state.taskId} is still active and its prompt was never injected; Resume would strand it. Cancel the task or DELETE the agent.`;
1640
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${reason}`);
1641
+ return { resumed: false, releasedBinding: false, reason };
1648
1642
  }
1649
1643
  // code-dispatch-failed: the code-phase prompt never reached the pane (spec
1650
1644
  // approval already transitioned the task). Resume = clear the hold AND
@@ -1674,23 +1668,25 @@ export class AgentManager {
1674
1668
  // Refuse while the task is active; operator must cancel the task or DELETE the agent to retry.
1675
1669
  if (state.awaitingPhase?.startsWith('signal-arm-failed')
1676
1670
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
1677
- 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.`);
1678
- return { resumed: false, releasedBinding: false };
1671
+ const reason = `The dispatched prompt's pane signal has no consumer and Resume cannot rebuild the watcher; cancel task ${state.taskId} or DELETE the agent to retry.`;
1672
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
1673
+ return { resumed: false, releasedBinding: false, reason };
1679
1674
  }
1680
1675
  // Un-cleared pane (cancel mid-clear or /clear unconfirmed): Resume would free + reuse it (terminal
1681
1676
  // task → shouldReleaseHeldBinding) and leak the cancelled task's context. Refuse; only DELETE (which
1682
1677
  // destroys the pane) is a safe recovery.
1683
1678
  if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase)) {
1684
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — pane holds un-cleared context; refusing Resume. DELETE the agent to discard it.`);
1685
- return { resumed: false, releasedBinding: false };
1679
+ const reason = 'The pane holds un-cleared context from a cancelled task; Resume would leak it into the next task. DELETE the agent to discard it.';
1680
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
1681
+ return { resumed: false, releasedBinding: false, reason };
1686
1682
  }
1687
1683
  // A greeting capability failure must be RE-PROVEN, not Resumed away: the default path below
1688
1684
  // flips status→'ok' regardless of shouldReleaseHeldBinding, which would put an unverified
1689
1685
  // agent back in the dispatch pool. The recovery path is restart-repl / retry (re-greets).
1690
1686
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase)) {
1691
- console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase}capability must be re-proven; ` +
1692
- `refusing Resume. Use restart-repl / retry to re-run the greeting check.`);
1693
- return { resumed: false, releasedBinding: false };
1687
+ const reason = 'Greeting capability check failed; the runtime must re-prove it. Resume cannot clear this hold use Restart REPL to re-run the greeting check.';
1688
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} ${reason}`);
1689
+ return { resumed: false, releasedBinding: false, reason };
1694
1690
  }
1695
1691
  const now = new Date().toISOString();
1696
1692
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
@@ -1761,7 +1757,11 @@ export class AgentManager {
1761
1757
  await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume failed; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1762
1758
  }
1763
1759
  }
1764
- return { resumed: result.resumed, releasedBinding: result.releasedBinding };
1760
+ return {
1761
+ resumed: result.resumed,
1762
+ releasedBinding: result.releasedBinding,
1763
+ ...(result.reason ? { reason: result.reason } : {}),
1764
+ };
1765
1765
  }
1766
1766
  async resolvePaneId(state, cfg) {
1767
1767
  if (state.paneId)
@@ -3108,13 +3108,6 @@ export class AgentManager {
3108
3108
  await assertSessionUnchanged();
3109
3109
  await tmux.sendKeysLiteral(paneId, command);
3110
3110
  await tmux.sendEnter(paneId);
3111
- if (command === '/clear') {
3112
- await this.agentStore.update(agentId, (s) => {
3113
- if (!s)
3114
- return AGENT_STORE_NOOP;
3115
- return { ...s, injectedSkills: undefined };
3116
- });
3117
- }
3118
3111
  guardHandedOff = true;
3119
3112
  void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
3120
3113
  .catch(err => {
@@ -3396,14 +3389,6 @@ export class AgentManager {
3396
3389
  // Caller-transmitted token/round take precedence — task fields are stale during dispatch.
3397
3390
  const promptSignalToken = opts.signalToken ?? task.signalToken;
3398
3391
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3399
- const beforeInjectAgent = await this.agentStore.get(agentId);
3400
- // freshRuntime=true 覆盖两种场景:(a) buildFreshSession 全新 tmux session;
3401
- // (b) adoptOrRestartSession 的 shell 重启 / trust-dialog 答完——pane 仍在但 REPL
3402
- // 是新进程。两种情况下旧上下文都没了,必须重置 dedup baseline,决不能因为
3403
- // paneId 字符串恰好相同就沿用旧 skill 集。
3404
- const reuseInjectedSkills = ensure.freshRuntime
3405
- ? null
3406
- : reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
3407
3392
  // develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
3408
3393
  const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
3409
3394
  let prompt;
@@ -3418,7 +3403,6 @@ export class AgentManager {
3418
3403
  hasQaPartner,
3419
3404
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
3420
3405
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
3421
- ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
3422
3406
  ...(imagePaths.length ? { imagePaths } : {}),
3423
3407
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
3424
3408
  ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
@@ -3491,6 +3475,15 @@ export class AgentManager {
3491
3475
  catch { }
3492
3476
  return false;
3493
3477
  }
3478
+ // Pane exists now but the prompt is not out — arm here so a request it triggers is a live chunk,
3479
+ // not snapshot-suppressed scrollback. Abort cleanly (no binding written yet) if it cannot arm.
3480
+ if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3481
+ try {
3482
+ await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3483
+ }
3484
+ catch { }
3485
+ return false;
3486
+ }
3494
3487
  const now = new Date().toISOString();
3495
3488
  let agentMarkedRunning = false;
3496
3489
  try {
@@ -3514,9 +3507,6 @@ export class AgentManager {
3514
3507
  bootstrappingTaskId: taskId,
3515
3508
  updatedAt: now,
3516
3509
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
3517
- ...(reuseInjectedSkills
3518
- ? { injectedSkills: { taskId, paneId, skills: reuseInjectedSkills } }
3519
- : {}),
3520
3510
  };
3521
3511
  });
3522
3512
  if (cancelHoldWon) {
@@ -3529,7 +3519,7 @@ export class AgentManager {
3529
3519
  return false;
3530
3520
  }
3531
3521
  agentMarkedRunning = true;
3532
- const ack = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3522
+ await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3533
3523
  // Prompt delivered → clear the mid-bootstrap marker IMMEDIATELY, before the slower persist/emit/watch
3534
3524
  // steps: a crash between ack and the clear would otherwise leave recover() seeing a stale marker on
3535
3525
  // an already-running prompt and re-dispatching it. The clear is best-effort and NON-destructive — its
@@ -3545,14 +3535,6 @@ export class AgentManager {
3545
3535
  console.warn(`[AgentManager] startSession: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
3546
3536
  });
3547
3537
  }
3548
- // dedup baseline 记录的是「已 paste 进 idle composer 的 skill 文本」:paste 落入 composer 即进
3549
- // REPL 上下文,与 submit-ack 无关。ack 超时(首个 Enter 被吞)下 skill 仍在 composer,跳过落盘会让
3550
- // 下一轮整组重注入——即 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
3551
- // 已 busy,文本进了运行中输入流而非 composer)则不能落盘,否则恢复提示会缺必需 skill。freshRuntime
3552
- // 负责 REPL 真正重启时作废 baseline。
3553
- if (ack.composerDelivered) {
3554
- await this.persistInjectedSkills(agentId, taskId, paneId, agent.role, phase, reuseInjectedSkills);
3555
- }
3556
3538
  await this.eventBus.emit({
3557
3539
  id: '',
3558
3540
  type: 'session.started',
@@ -3728,29 +3710,6 @@ export class AgentManager {
3728
3710
  return false;
3729
3711
  }
3730
3712
  }
3731
- // Snapshot which skills are now resident in the REPL's context, union of
3732
- // the pre-dispatch baseline (when context was still valid) and the phase's
3733
- // declared skills. Guarded by (taskId, paneId) so a concurrent rebind never
3734
- // overwrites a freshly-bound agent.
3735
- async persistInjectedSkills(agentId, taskId, paneId, role, phase, reuseInjectedSkills) {
3736
- const phaseSkills = this.skillRegistry.skillsForPhase(role, phase);
3737
- const baseList = reuseInjectedSkills ?? [];
3738
- // 已有有效 context 记录,且本 phase 没有引入新 skill → 写盘无信息增益,short-circuit。
3739
- // reuseInjectedSkills === null 时缺基线记录,仍需建一份初始档。
3740
- if (reuseInjectedSkills !== null && phaseSkills.every(s => baseList.includes(s)))
3741
- return;
3742
- const merged = Array.from(new Set([...baseList, ...phaseSkills]));
3743
- const now = new Date().toISOString();
3744
- await this.agentStore.update(agentId, (latest) => {
3745
- if (!latest || latest.taskId !== taskId || latest.paneId !== paneId)
3746
- return AGENT_STORE_NOOP;
3747
- return {
3748
- ...latest,
3749
- injectedSkills: { taskId, paneId, skills: merged },
3750
- updatedAt: now,
3751
- };
3752
- });
3753
- }
3754
3713
  // Ready gate prevents mid-paste webhook from flipping to 'waiting' on a busy REPL.
3755
3714
  async markAgentWaiting(agentId, expectedTaskId, opts = {}) {
3756
3715
  return this.releaseAgentForTask(agentId, expectedTaskId, 'waiting', opts);
@@ -3810,17 +3769,10 @@ export class AgentManager {
3810
3769
  const runner = this.createRunnerFor(agent);
3811
3770
  const tmux = new TmuxManager(runner);
3812
3771
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3813
- // 与 startSession 同步:任何 REPL 启动 / 重启路径(freshRuntime=true)都视为新上下文,
3814
- // 强制重新注入完整 skill 集——既覆盖 fresh tmux session,也覆盖同 pane 里的 shell 重启
3815
- // 与 trust-dialog 完成两种 adopt 场景。
3816
- const reuseInjectedSkills = ensure.freshRuntime
3817
- ? null
3818
- : reuseSkillsIfContextValid(agentState, taskId, paneId);
3819
3772
  let prompt;
3820
3773
  try {
3821
- // freshRuntime=true 表示 tmux/REPL 刚新建或重启,旧 post-approve prompt 上下文已丢失
3822
- // (同步:reuseInjectedSkills freshRuntime 时也强制 null 重发 skill 集)。此时即使
3823
- // redispatchCount>0 也必须发完整长段,否则 dev 拿不到 T_self / idempotency / final
3774
+ // freshRuntime=true 表示 tmux/REPL 刚新建或重启,旧 post-approve prompt 上下文已丢失。
3775
+ // 此时即使 redispatchCount>0 也必须发完整长段,否则 dev 拿不到 T_self / idempotency / final
3824
3776
  // re-fetch / 禁止 merge 等首轮规则。
3825
3777
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3826
3778
  && opts.postApproveRedispatchCount > 0
@@ -3839,7 +3791,6 @@ export class AgentManager {
3839
3791
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
3840
3792
  : {}),
3841
3793
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
3842
- ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
3843
3794
  ...(imagePaths.length ? { imagePaths } : {}),
3844
3795
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
3845
3796
  ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
@@ -3891,6 +3842,10 @@ export class AgentManager {
3891
3842
  return false;
3892
3843
  }
3893
3844
  }
3845
+ // Arm before paste (same reasoning as startSession): pane exists, prompt not out yet.
3846
+ if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3847
+ return false;
3848
+ }
3894
3849
  const now = new Date().toISOString();
3895
3850
  await this.agentStore.update(agentId, (latest) => {
3896
3851
  if (!latest)
@@ -3900,16 +3855,9 @@ export class AgentManager {
3900
3855
  paneId,
3901
3856
  worktreePath,
3902
3857
  updatedAt: now,
3903
- ...(reuseInjectedSkills
3904
- ? { injectedSkills: { taskId, paneId, skills: reuseInjectedSkills } }
3905
- : { injectedSkills: undefined }),
3906
3858
  };
3907
3859
  });
3908
- const ack = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3909
- // 仅 composer 投递成功才落 dedup baseline(理由见 startSession);freshRuntime 负责 REPL 重启时作废。
3910
- if (ack.composerDelivered) {
3911
- await this.persistInjectedSkills(agentId, taskId, paneId, agent.role, phase, reuseInjectedSkills);
3912
- }
3860
+ await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3913
3861
  return true;
3914
3862
  }
3915
3863
  // A bootstrappingTaskId marker means a dispatch began (binding written) but its prompt was never
@@ -4053,7 +4001,6 @@ export class AgentManager {
4053
4001
  continue;
4054
4002
  }
4055
4003
  await this.dispatchPostMergeCleanup(state.id, {
4056
- prNumber: boundTask.prNumber,
4057
4004
  taskId: boundTask.id,
4058
4005
  branch: boundTask.branch,
4059
4006
  });
@@ -5214,10 +5161,9 @@ export class AgentManager {
5214
5161
  this.phaseSignalWatcher?.stop(taskId);
5215
5162
  // Keep the agent BOUND (non-dispatchable) until branch cleanup + context reset finish, then
5216
5163
  // release. dispatchPostMergeCleanup owns the whole lifecycle: worktree removal → branch
5217
- // delete → /clear (or /compact if cleanup failed) → release.
5164
+ // delete → /clear release (no agent notification).
5218
5165
  if (task.prNumber && task.branch) {
5219
5166
  const ctx = {
5220
- prNumber: task.prNumber,
5221
5167
  taskId: task.id,
5222
5168
  branch: task.branch,
5223
5169
  };
@@ -5265,14 +5211,22 @@ export class AgentManager {
5265
5211
  }
5266
5212
  await this.removeMergedWorktree(agent, agentId, ctx.taskId);
5267
5213
  const runner = this.createRunnerFor(agent);
5268
- const cleanupResult = state.repoPath
5269
- ? await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId)
5270
- : { outcome: 'skipped', detail: 'agent has no repoPath in binding' };
5214
+ if (state.repoPath) {
5215
+ await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
5216
+ }
5217
+ else {
5218
+ // No repoPath on the binding → can't run `git branch -D`. Keep the "can't clean → warn
5219
+ // server-side" contract: surface the skip (the merged branch may linger locally) rather than
5220
+ // dropping it silently now that there is no agent notification carrying `branch-cleanup: skipped`.
5221
+ console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
5222
+ `branch delete for ${ctx.branch} (it may linger locally)`);
5223
+ }
5224
+ // No agent dialogue: the merged task is already done and the agent is idle, so baxian just resets
5225
+ // its context with /clear and releases it. Worktree + local branch were cleaned above; a failed
5226
+ // branch delete is logged server-side (deleteLocalBranchInRepo), not surfaced into the pane.
5271
5227
  const tmux = new TmuxManager(runner);
5272
- const prompt = buildPostMergeCleanupPrompt(ctx, cleanupResult);
5273
5228
  const runtime = agentRuntimeKindFor(agent);
5274
- const cleanSlate = cleanupResult.outcome === 'deleted' || cleanupResult.outcome === 'absent';
5275
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime, prompt, cleanSlate).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
5229
+ void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
5276
5230
  }
5277
5231
  // Release the post-merge binding (clears taskId + frees the lock we held → agent dispatchable
5278
5232
  // again). Shared success tail. Skips when the binding has already moved to another task, so it
@@ -5316,11 +5270,9 @@ export class AgentManager {
5316
5270
  });
5317
5271
  });
5318
5272
  }
5319
- // shellQuote prevents injection. Three outcomes the caller must distinguish so the
5320
- // notification prompt to the agent doesn't lie about a deletion that actually failed:
5321
- // - deleted: branch ref was removed (or no longer exists at the end of the call).
5322
- // - absent: branch wasn't there at all (auto-delete-head-branches, never landed locally).
5323
- // - failed: worktree still occupies the ref, permissions, etc. — agent must NOT be told "cleaned".
5273
+ // Best-effort local cleanup after merge: prune the remote-tracking ref + worktree admin entry, then
5274
+ // delete the local branch. shellQuote prevents injection. Failures are logged server-side only — the
5275
+ // wrap-up resets the pane with /clear regardless, and there is no agent notification to keep honest.
5324
5276
  async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
5325
5277
  // --expire=now: bare `git worktree prune` honors gc.worktreePruneExpire (default 3 months),
5326
5278
  // so a worktree that the release just removed could still be tracked as occupying the ref.
@@ -5338,28 +5290,22 @@ export class AgentManager {
5338
5290
  const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
5339
5291
  try {
5340
5292
  const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
5341
- if (delResult.exitCode === 0) {
5342
- return { outcome: 'deleted', detail: delResult.stdout.trim() };
5293
+ // exit 0 → deleted; "not found"/"no such branch" → already absent (auto-delete-head-branches). Both fine.
5294
+ if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
5295
+ console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
5296
+ `stderr=${delResult.stderr.trim()}`);
5343
5297
  }
5344
- if (/not found|not a valid|no such branch/i.test(delResult.stderr)) {
5345
- return { outcome: 'absent', detail: delResult.stderr.trim() };
5346
- }
5347
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
5348
- `stderr=${delResult.stderr.trim()}`);
5349
- return { outcome: 'failed', detail: delResult.stderr.trim() || `exit ${delResult.exitCode}` };
5350
5298
  }
5351
5299
  catch (err) {
5352
- const detail = err instanceof Error ? err.message : String(err);
5353
5300
  console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
5354
- return { outcome: 'failed', detail };
5355
5301
  }
5356
5302
  }
5357
- async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate) {
5358
- // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
5303
+ async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
5304
+ // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发,
5359
5305
  // 且 finally 会误删对方的 guard 放穿后续请求。
5360
5306
  await this.acquireCompactGuard(agentId);
5361
5307
  try {
5362
- await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate);
5308
+ await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime);
5363
5309
  }
5364
5310
  finally {
5365
5311
  this.compactInFlight.delete(agentId);
@@ -5385,64 +5331,36 @@ export class AgentManager {
5385
5331
  this.compactInFlight.add(agentId);
5386
5332
  return true;
5387
5333
  }
5388
- async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate) {
5334
+ async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
5389
5335
  const bindingStillOurs = async () => {
5390
5336
  const s = await this.agentStore.get(agentId);
5391
5337
  return !!s && s.taskId === originalTaskId && s.paneId === paneId;
5392
5338
  };
5393
- let attempts = 0;
5339
+ if (!await bindingStillOurs())
5340
+ return;
5341
+ // No notification, no dialogue: the merged task is done and the agent is idle, so just reset its
5342
+ // context. sendPostMergeSlashCommand interrupts any lingering turn (Esc), waits for idle, sends
5343
+ // /clear, verifies it wasn't rejected, and retries. Returns false if the binding moved mid-way.
5394
5344
  let cleared = false;
5395
- while (attempts < 2) {
5396
- attempts++;
5397
- try {
5398
- if (attempts > 1) {
5399
- if (!await bindingStillOurs())
5400
- return;
5401
- await tmux.sendKeysToPane(paneId, 'C-c');
5402
- await new Promise(r => setTimeout(r, 1000));
5403
- }
5404
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5405
- if (!await bindingStillOurs())
5406
- return;
5407
- await tmux.injectPrompt(paneId, prompt, agentId);
5408
- // Resend a swallowed first Enter (bracketed-paste TUI quirk); settle so baseline holds the paste.
5409
- const baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
5410
- await tmux.sendEnter(paneId);
5411
- try {
5412
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
5413
- timeoutMs: this.dispatchAckTimeoutMs,
5414
- resend: () => tmux.sendEnter(paneId),
5415
- resendIntervalMs: this.dispatchAckResendIntervalMs,
5416
- });
5417
- }
5418
- catch (ackErr) {
5419
- // Only a genuine ack timeout is best-effort; other (infra) errors must reach the outer retry.
5420
- if (!(ackErr instanceof Error && /runtime ack timeout/.test(ackErr.message)))
5421
- throw ackErr;
5422
- console.warn(`[AgentManager] post-merge notification ack timeout (${agentId}, pane ${paneId}):`, ackErr);
5423
- }
5424
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5425
- if (!await bindingStillOurs())
5426
- return;
5427
- const command = cleanSlate ? '/clear' : '/compact';
5428
- if (!await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs)) {
5429
- return;
5430
- }
5345
+ try {
5346
+ cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
5347
+ }
5348
+ catch (err) {
5349
+ console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear failed:`, err);
5350
+ }
5351
+ if (!cleared) {
5352
+ // A runtime that exited to a shell can't take /clear; restarting it yields the same clean slate.
5353
+ if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
5431
5354
  cleared = true;
5432
- break;
5433
5355
  }
5434
- catch (err) {
5435
- const label = attempts < 2 ? 'retrying' : 'giving up';
5436
- console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) attempt ${attempts} failed (${label}):`, err);
5356
+ else if (!await bindingStillOurs()) {
5357
+ return; // binding moved to another flow mid-cleanup leave its pane alone (no C-c, no release)
5437
5358
  }
5359
+ else if (!await this.clearComposerForReuse(tmux, paneId, agentId)) {
5360
+ return; // can't even reach a clean composer → hold (don't release) for a human
5361
+ }
5362
+ // else: composer cleaned → a stuck pane must not strand the agent bound to a merged task; release.
5438
5363
  }
5439
- if (!cleared && await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
5440
- await this.releasePostMergeAgent(agentId, originalTaskId);
5441
- return;
5442
- }
5443
- // Give-up: clear the injected-but-unsubmitted notification; hold (don't release) if it can't be cleared.
5444
- if (!cleared && !await this.clearComposerForReuse(tmux, paneId, agentId))
5445
- return;
5446
5364
  await this.releasePostMergeAgent(agentId, originalTaskId);
5447
5365
  }
5448
5366
  async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
@@ -5470,16 +5388,13 @@ export class AgentManager {
5470
5388
  await this.agentStore.update(agentId, (existing) => {
5471
5389
  if (!existing || existing.taskId !== taskId)
5472
5390
  return AGENT_STORE_NOOP;
5473
- if (existing.paneId === ensure.paneId
5474
- && existing.repoPath === ensure.workdir
5475
- && existing.injectedSkills === undefined) {
5391
+ if (existing.paneId === ensure.paneId && existing.repoPath === ensure.workdir) {
5476
5392
  return AGENT_STORE_NOOP;
5477
5393
  }
5478
5394
  return {
5479
5395
  ...existing,
5480
5396
  paneId: ensure.paneId,
5481
5397
  repoPath: ensure.workdir,
5482
- injectedSkills: undefined,
5483
5398
  updatedAt: new Date().toISOString(),
5484
5399
  };
5485
5400
  });
@@ -5491,33 +5406,33 @@ export class AgentManager {
5491
5406
  return false;
5492
5407
  }
5493
5408
  }
5494
- async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs) {
5409
+ async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs) {
5495
5410
  let rejection;
5496
5411
  for (let attempt = 1; attempt <= 2; attempt++) {
5497
- // The runtime rejects /clear|/compact while a turn is in progress, and the post-merge
5498
- // notification turn can still be running when our idle scrape passes. Esc interrupts it (the
5499
- // same stop the cancel flow runs before /clear); a genuinely idle pane absorbs it harmlessly.
5412
+ // The runtime rejects /clear while a turn is in progress, and the agent's last task turn can
5413
+ // still be settling when our idle scrape passes. Esc interrupts it (the same stop the cancel
5414
+ // flow runs before /clear); a genuinely idle pane absorbs it harmlessly.
5500
5415
  await tmux.sendKeysToPane(paneId, 'Escape');
5501
5416
  await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5502
5417
  if (!await bindingStillOurs())
5503
5418
  return false;
5504
- await tmux.sendKeysLiteral(paneId, command);
5419
+ await tmux.sendKeysLiteral(paneId, '/clear');
5505
5420
  await tmux.sendEnter(paneId);
5506
5421
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
5507
5422
  await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5508
5423
  if (!await bindingStillOurs())
5509
5424
  return false;
5510
- if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, command))
5425
+ if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear'))
5511
5426
  return true;
5512
- rejection = new Error(`runtime rejected ${command} because a task is still in progress`);
5427
+ rejection = new Error('runtime rejected /clear because a task is still in progress');
5513
5428
  if (attempt < 2) {
5514
- console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) ${command} rejected; retrying`);
5429
+ console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear rejected; retrying`);
5515
5430
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
5516
5431
  if (!await bindingStillOurs())
5517
5432
  return false;
5518
5433
  }
5519
5434
  }
5520
- throw rejection ?? new Error(`runtime rejected ${command}`);
5435
+ throw rejection ?? new Error('runtime rejected /clear');
5521
5436
  }
5522
5437
  async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
5523
5438
  const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
@@ -5903,6 +5818,9 @@ export class AgentManager {
5903
5818
  ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
5904
5819
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5905
5820
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5821
+ // Arm the verdict + read-file watcher in the pane-exists / pre-paste window so a QA
5822
+ // [bx:read-file:...] emitted during the dispatch is a live chunk, not snapshot-suppressed.
5823
+ armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); }),
5906
5824
  };
5907
5825
  // A continuation consumed the QA's reviewed signal (not the dev's entry
5908
5826
  // signal): rollback restores the prior slice's review/token, so re-arm the
@@ -5923,6 +5841,9 @@ export class AgentManager {
5923
5841
  : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
5924
5842
  }
5925
5843
  catch (err) {
5844
+ // armBeforeInject may have armed the watcher before the failing paste — drop it so a stale
5845
+ // entry can't fire on a rolled-back / failed task (no-op if it never armed).
5846
+ this.stopPhaseSignalWatcher(taskId);
5926
5847
  if (err instanceof DispatchTerminalError) {
5927
5848
  await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
5928
5849
  }
@@ -5939,6 +5860,9 @@ export class AgentManager {
5939
5860
  throw err;
5940
5861
  }
5941
5862
  if (!started) {
5863
+ // Covers armBeforeInject returning false (watcher couldn't arm) as well as any other
5864
+ // pre-paste abort; stop is a no-op when nothing armed.
5865
+ this.stopPhaseSignalWatcher(taskId);
5942
5866
  await rollback();
5943
5867
  if (!opts.continuation) {
5944
5868
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
@@ -5955,8 +5879,6 @@ export class AgentManager {
5955
5879
  });
5956
5880
  return null;
5957
5881
  }
5958
- this.stopPhaseSignalWatcher(taskId);
5959
- await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
5960
5882
  return await this.taskStore.get(taskId);
5961
5883
  }
5962
5884
  async dispatchServerFixToDev(taskId, findingsJson) {