baxian 1.1.0 → 1.2.0

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 (52) hide show
  1. package/dist/agent/manager.d.ts +10 -9
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +181 -413
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/phase-signal-watcher.d.ts +1 -4
  6. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  7. package/dist/agent/phase-signal-watcher.js +3 -22
  8. package/dist/agent/phase-signal-watcher.js.map +1 -1
  9. package/dist/agent/phase-signal.d.ts +1 -10
  10. package/dist/agent/phase-signal.d.ts.map +1 -1
  11. package/dist/agent/phase-signal.js +5 -8
  12. package/dist/agent/phase-signal.js.map +1 -1
  13. package/dist/agent/prompt.d.ts +1 -2
  14. package/dist/agent/prompt.d.ts.map +1 -1
  15. package/dist/agent/prompt.js +32 -63
  16. package/dist/agent/prompt.js.map +1 -1
  17. package/dist/agent/repo-store.d.ts +0 -1
  18. package/dist/agent/repo-store.d.ts.map +1 -1
  19. package/dist/agent/repo-store.js +0 -25
  20. package/dist/agent/repo-store.js.map +1 -1
  21. package/dist/agent/worktree.d.ts +1 -0
  22. package/dist/agent/worktree.d.ts.map +1 -1
  23. package/dist/agent/worktree.js +14 -0
  24. package/dist/agent/worktree.js.map +1 -1
  25. package/dist/api/agents.d.ts.map +1 -1
  26. package/dist/api/agents.js +4 -0
  27. package/dist/api/agents.js.map +1 -1
  28. package/dist/api/tasks.js +1 -1
  29. package/dist/api/tasks.js.map +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +8 -1
  32. package/dist/cli.js.map +1 -1
  33. package/dist/event/handlers.d.ts.map +1 -1
  34. package/dist/event/handlers.js +10 -430
  35. package/dist/event/handlers.js.map +1 -1
  36. package/dist/event/server-handlers.d.ts.map +1 -1
  37. package/dist/event/server-handlers.js +21 -14
  38. package/dist/event/server-handlers.js.map +1 -1
  39. package/dist/shared/constants.d.ts +1 -1
  40. package/dist/shared/constants.d.ts.map +1 -1
  41. package/dist/shared/constants.js +0 -6
  42. package/dist/shared/constants.js.map +1 -1
  43. package/dist/shared/types.d.ts +1 -1
  44. package/dist/shared/types.d.ts.map +1 -1
  45. package/dist/skills/server-feedback/SKILL.md +4 -2
  46. package/dist/terminal/attach.d.ts.map +1 -1
  47. package/dist/terminal/attach.js +8 -2
  48. package/dist/terminal/attach.js.map +1 -1
  49. package/dist/web/assets/index-OtgjyQI1.js +4 -0
  50. package/dist/web/index.html +1 -1
  51. package/package.json +1 -1
  52. package/dist/web/assets/index-DE_xpPQe.js +0 -4
@@ -74,7 +74,7 @@ function agentRuntimeKindFor(agent) {
74
74
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
75
75
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
76
76
  // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
77
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'spec-fix']);
77
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
78
78
  export function canDispatchWithBinding(binding) {
79
79
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
80
80
  }
@@ -132,6 +132,7 @@ export class AgentManager {
132
132
  runtimeMenuPollIntervalMs = 10_000;
133
133
  compactIdleWaitMs = 5 * 60_000;
134
134
  compactIdlePollMs = 2_000;
135
+ manualCompactWaitMs = 5_000;
135
136
  postMergeFetchTimeoutMs = 60_000;
136
137
  postMergeBranchTimeoutMs = 10_000;
137
138
  // taskIds with in-flight manual review — second concurrent POST gets 409.
@@ -143,6 +144,7 @@ export class AgentManager {
143
144
  // agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
144
145
  // 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
145
146
  deletionInFlight = new Set();
147
+ compactInFlight = new Set();
146
148
  constructor(deps) {
147
149
  this.config = deps.config;
148
150
  this.agentStore = deps.agentStore;
@@ -975,7 +977,7 @@ export class AgentManager {
975
977
  const state = await this.agentStore.get(agentId);
976
978
  const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
977
979
  const reentryPhases = new Set([
978
- 'fix', 'post-approve', 'spec-fix', 'code',
980
+ 'fix', 'post-approve', 'code',
979
981
  'server-feedback', 'server-after-done',
980
982
  ]);
981
983
  const sameTaskReentry = state?.taskId === taskId &&
@@ -1744,7 +1746,6 @@ export class AgentManager {
1744
1746
  expectedKinds: 'pr-merge-ready',
1745
1747
  token: completion.token,
1746
1748
  skipSnapshot,
1747
- reviewMode: task.reviewMode ?? 'github',
1748
1749
  recovered: true,
1749
1750
  });
1750
1751
  }
@@ -1753,8 +1754,8 @@ export class AgentManager {
1753
1754
  }
1754
1755
  }
1755
1756
  }
1756
- // skipSnapshot=true: scrollback 里的 signal 重启后不应再触发。
1757
- // 只对 spec verdict / spec-fixed emit intervention — spec-created 在 develop
1757
+ // snapshot 扫描按协议族决定:server 协议(含全模式 spec 阶段)恢复时必扫,github code 阶段仅 review/fixing 扫。
1758
+ // 只对 spec verdict / spec-fixed emit intervention — spec-done 在 develop
1758
1759
  // prompt 里是 optional, 报警会让所有 in_progress task 噪音化。
1759
1760
  // expectedKinds 必须覆盖 dispatch 时实际 set up 的 kind 集,否则真信号无法匹配。
1760
1761
  async setupRecoveredSpecSignals() {
@@ -1769,23 +1770,25 @@ export class AgentManager {
1769
1770
  continue;
1770
1771
  const { expectedKinds, agentId } = mapped;
1771
1772
  // Only spec verdict / spec-fixed / PR verdict warrant an intervention —
1772
- // optional kinds (spec-created, pr-created in develop) would spam every
1773
+ // optional kinds (spec-done, pr-created in develop) would spam every
1773
1774
  // in_progress task on restart.
1774
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-approved|spec-changes-requested'
1775
+ const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
1775
1776
  : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
1776
1777
  : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
1777
1778
  : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
1778
1779
  : undefined;
1779
- // Scan the pane snapshot for one-shot completion signals the agent may have
1780
- // echoed before the server persisted/consumed it (lost on restart otherwise,
1781
- // since the agent does not re-emit): PR verdict (review) and pr-fixed (code
1782
- // fixing). Both handlers are idempotent under replay (token + status gates),
1783
- // and token rotation still rejects stale ones. Other phases keep
1784
- // skipSnapshot=true their handlers aren't as cleanly replay-safe.
1785
- // Server mode scans on EVERY recovered state: the pane signal is the only
1786
- // verdict channel (no poller backstop) and all server handlers are
1787
- // replay-safe (token/status gates + stored-data resumption).
1788
- const scanSnapshotOnRecover = task.reviewMode === 'server'
1780
+ // spec 阶段恒为 server 协议(无 poller 兜底);code 阶段才按 reviewMode 区分。
1781
+ // Scan pane snapshot on recover for signals the agent emitted before the
1782
+ // server consumed them (lost on restart; agent won't re-emit).
1783
+ // github code states (review/fixing): replay-safe handlers token + status
1784
+ // gates reject duplicates; PR verdict & pr-fixed covered.
1785
+ // github pre-spec (phase undefined, in_progress): spec-done has only the pane
1786
+ // channel (pr-created has a poller backstop, scanning it is idempotent).
1787
+ // server protocol incl. all-mode spec phase: no poller backstop, pane is the
1788
+ // only signal channel; handlers equally replay-safe via same gates.
1789
+ const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
1790
+ const scanSnapshotOnRecover = isServerProtocol
1791
+ || (task.phase === undefined && task.status === 'in_progress')
1789
1792
  || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
1790
1793
  try {
1791
1794
  await this.phaseSignalWatcher.start({
@@ -1795,9 +1798,8 @@ export class AgentManager {
1795
1798
  expectedKinds,
1796
1799
  token: task.signalToken,
1797
1800
  skipSnapshot: !scanSnapshotOnRecover,
1798
- reviewMode: task.reviewMode ?? 'github',
1799
1801
  recovered: true,
1800
- ...(task.reviewMode === 'server' && task.status === 'review'
1802
+ ...(isServerProtocol && task.status === 'review'
1801
1803
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
1802
1804
  : {}),
1803
1805
  });
@@ -2253,7 +2255,7 @@ export class AgentManager {
2253
2255
  return null;
2254
2256
  }
2255
2257
  // 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
2256
- // spec-created/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2258
+ // spec-done/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2257
2259
  // Kinds derive from the task's frozen reviewMode — a hot mode flip during the
2258
2260
  // startSession window must not desync the armed kinds from the sent prompt.
2259
2261
  const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
@@ -2301,12 +2303,83 @@ export class AgentManager {
2301
2303
  const paneId = state?.paneId;
2302
2304
  if (!paneId)
2303
2305
  throw new ApiError(409, `Agent ${agentId} has no live session`);
2304
- const runner = this.createRunnerFor(cfg);
2305
- const path = agentHostPath(agentId, imageFilename(ext));
2306
- await writeImageToHost(runner, path, bytes);
2307
- const tmux = new TmuxManager(runner);
2308
- await tmux.injectPrompt(paneId, `${path} `, agentId);
2309
- return { path };
2306
+ // 写文件→粘贴全程持有 pane 互斥:写文件可能卡住,恢复后的粘贴若落进
2307
+ // compact C-c→/compact 窗口会把路径拼进指令提交。
2308
+ if (!this.tryAcquireCompactGuard(agentId)) {
2309
+ throw new ApiError(409, `Agent ${agentId} compact or upload in progress; retry shortly`);
2310
+ }
2311
+ try {
2312
+ const runner = this.createRunnerFor(cfg);
2313
+ const path = agentHostPath(agentId, imageFilename(ext));
2314
+ await writeImageToHost(runner, path, bytes);
2315
+ const tmux = new TmuxManager(runner);
2316
+ await tmux.injectPrompt(paneId, `${path} `, agentId);
2317
+ return { path };
2318
+ }
2319
+ finally {
2320
+ this.compactInFlight.delete(agentId);
2321
+ }
2322
+ }
2323
+ // busy 时注入会把指令拼进正在运行的回合,宁可 409。
2324
+ async compactAgent(agentId) {
2325
+ const cfg = this.getAgentConfig(agentId);
2326
+ if (!cfg)
2327
+ throw new ApiError(404, `Unknown agent: ${agentId}`);
2328
+ if (!this.tryAcquireCompactGuard(agentId)) {
2329
+ throw new ApiError(409, `Agent ${agentId} compact or upload already in progress`);
2330
+ }
2331
+ let guardHandedOff = false;
2332
+ try {
2333
+ const state = await this.agentStore.get(agentId);
2334
+ const paneId = state?.paneId;
2335
+ if (!paneId)
2336
+ throw new ApiError(409, `Agent ${agentId} has no live session`);
2337
+ const taskIdAtStart = state.taskId;
2338
+ const updatedAtAtStart = state.updatedAt;
2339
+ // updatedAt 拦同任务 phase 派发(paneId/taskId 均不变,派发 paste 前必写 state);
2340
+ // 快照变了决不注入——C-c 会打断刚注入的 prompt。
2341
+ const assertSessionUnchanged = async () => {
2342
+ const now = await this.agentStore.get(agentId);
2343
+ if (!now
2344
+ || now.paneId !== paneId
2345
+ || now.taskId !== taskIdAtStart
2346
+ || now.updatedAt !== updatedAtAtStart) {
2347
+ throw new ApiError(409, `Agent ${agentId} session changed while waiting; compact aborted`);
2348
+ }
2349
+ };
2350
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
2351
+ const waitReady = async () => {
2352
+ try {
2353
+ await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
2354
+ }
2355
+ catch (err) {
2356
+ const detail = err instanceof Error ? err.message : String(err);
2357
+ throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
2358
+ }
2359
+ };
2360
+ await waitReady();
2361
+ await assertSessionUnchanged();
2362
+ // 残留草稿会被「草稿/compact」连带提交;C-c 清线后再发。
2363
+ await tmux.sendKeysToPane(paneId, 'C-c');
2364
+ await waitReady();
2365
+ await assertSessionUnchanged();
2366
+ await tmux.sendKeysLiteral(paneId, '/compact');
2367
+ await tmux.sendEnter(paneId);
2368
+ // 压缩仍在运行:guard 交给后台尾随等待,runtime 回到 idle 才释放,
2369
+ // 否则紧随的上传/派发会粘进压缩中的 pane。
2370
+ guardHandedOff = true;
2371
+ void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
2372
+ .catch(err => {
2373
+ console.warn(`[AgentManager] compactAgent(${agentId}) post-/compact idle wait failed:`, err);
2374
+ })
2375
+ .finally(() => {
2376
+ this.compactInFlight.delete(agentId);
2377
+ });
2378
+ }
2379
+ finally {
2380
+ if (!guardHandedOff)
2381
+ this.compactInFlight.delete(agentId);
2382
+ }
2310
2383
  }
2311
2384
  async persistTaskImages(taskId, images) {
2312
2385
  const dir = join(this.imageStagingRoot, taskId);
@@ -2358,12 +2431,10 @@ export class AgentManager {
2358
2431
  }
2359
2432
  return hostPaths;
2360
2433
  }
2361
- // Dev-facing deliverable phases (initial + rework) all carry the task's uploaded images, since
2362
- // the image is a persistent task input the dev needs while producing or revising the spec/code —
2363
- // and a fresh runtime (restart/recovery) loses the original context. `develop` flows through
2364
- // startSession; `code`/`fix`/`spec-fix` through continueSession both call this. Excluded:
2365
- // QA phases (review/recheck/spec-review) and post-approve (feedback-processing; if it needs
2366
- // changes baxian routes to `fix`, which carries the image) (task-055).
2434
+ // Dev-facing deliverable phases all carry the task's uploaded images, since the image is a
2435
+ // persistent task input the dev needs while producing or revising the spec/code — and a fresh
2436
+ // runtime (restart/recovery) loses the original context. IMAGE_DISPATCH_PHASES 中的后续阶段经
2437
+ // continueSession 触发此方法;QA 阶段和 post-approve 不传图(task-055)。
2367
2438
  async imagePathsForDispatch(runner, task, phase) {
2368
2439
  if (!IMAGE_DISPATCH_PHASES.has(phase))
2369
2440
  return [];
@@ -2552,7 +2623,7 @@ export class AgentManager {
2552
2623
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2553
2624
  const worktreePath = isServerQaPhase
2554
2625
  ? await worktree.createDetachedAtBase(workdir, taskId)
2555
- : phase === 'review' || phase === 'recheck' || phase === 'spec-review'
2626
+ : phase === 'review' || phase === 'recheck'
2556
2627
  ? await worktree.createDetached(workdir, taskId, task.branch)
2557
2628
  : await worktree.create(workdir, taskId, baseRef);
2558
2629
  // Persist worktreePath now so a crash before set-running leaves a recoverable trail.
@@ -2578,6 +2649,8 @@ export class AgentManager {
2578
2649
  const reuseInjectedSkills = ensure.freshRuntime
2579
2650
  ? null
2580
2651
  : reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
2652
+ // develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
2653
+ const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2581
2654
  let prompt;
2582
2655
  try {
2583
2656
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
@@ -2587,9 +2660,9 @@ export class AgentManager {
2587
2660
  agent,
2588
2661
  worktreePath,
2589
2662
  skillRegistry: this.skillRegistry,
2663
+ hasQaPartner,
2590
2664
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2591
2665
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2592
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2593
2666
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2594
2667
  ...(imagePaths.length ? { imagePaths } : {}),
2595
2668
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
@@ -2762,7 +2835,27 @@ export class AgentManager {
2762
2835
  throw err;
2763
2836
  }
2764
2837
  }
2765
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, _runtime) {
2838
+ // 注入方必须持有 pane 互斥:compact 侧的快照校验关不死「校验→按键」
2839
+ // 之间的 async 边界,竞态只能在这里关死。
2840
+ async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
2841
+ const before = await this.agentStore.get(agentId);
2842
+ await this.acquireCompactGuard(agentId);
2843
+ try {
2844
+ // guard 等待期间任务可能被 Cancel(释放绑定)或会话重建;过期派发
2845
+ // 决不落 pane。无快照(direct 调用)时跳过——真实派发必有绑定。
2846
+ if (before) {
2847
+ const now = await this.agentStore.get(agentId);
2848
+ if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
2849
+ throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
2850
+ }
2851
+ }
2852
+ return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime);
2853
+ }
2854
+ finally {
2855
+ this.compactInFlight.delete(agentId);
2856
+ }
2857
+ }
2858
+ async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, _runtime) {
2766
2859
  await tmux.injectPrompt(paneId, prompt, agentId);
2767
2860
  let baseline;
2768
2861
  try {
@@ -2948,7 +3041,6 @@ export class AgentManager {
2948
3041
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
2949
3042
  : {}),
2950
3043
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2951
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2952
3044
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2953
3045
  ...(imagePaths.length ? { imagePaths } : {}),
2954
3046
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
@@ -3937,7 +4029,7 @@ export class AgentManager {
3937
4029
  }
3938
4030
  // General case: rolled-back task may still want a watcher matching its
3939
4031
  // current (restored) state — develop dispatch still waiting on
3940
- // spec-created/pr-created, recheck still waiting on verdict, etc.
4032
+ // spec-done/pr-created, recheck still waiting on verdict, etc.
3941
4033
  const restored = await this.taskStore.get(taskId);
3942
4034
  if (!restored || !restored.signalToken)
3943
4035
  return;
@@ -3966,13 +4058,13 @@ export class AgentManager {
3966
4058
  const mode = reviewMode ?? this.config.review.mode ?? 'github';
3967
4059
  return mode === 'server'
3968
4060
  ? ['spec-done', 'code-done']
3969
- : ['spec-created', 'pr-created'];
4061
+ : ['spec-done', 'pr-created'];
3970
4062
  }
3971
4063
  mapTaskStateToExpectedWatcher(task) {
3972
4064
  if (task.reviewMode === 'server')
3973
4065
  return this.mapServerTaskToExpectedWatcher(task);
3974
4066
  if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
3975
- return { expectedKinds: ['spec-approved', 'spec-changes-requested'], agentId: task.qaAgentId };
4067
+ return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
3976
4068
  }
3977
4069
  if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
3978
4070
  return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
@@ -3982,7 +4074,7 @@ export class AgentManager {
3982
4074
  return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
3983
4075
  }
3984
4076
  if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
3985
- return { expectedKinds: ['spec-created', 'pr-created'], agentId: task.agentId };
4077
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
3986
4078
  }
3987
4079
  if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
3988
4080
  return { expectedKinds: ['pr-created'], agentId: task.agentId };
@@ -4416,6 +4508,28 @@ export class AgentManager {
4416
4508
  }
4417
4509
  }
4418
4510
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4511
+ // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
4512
+ // 且 finally 会误删对方的 guard 放穿后续请求。
4513
+ await this.acquireCompactGuard(agentId);
4514
+ try {
4515
+ await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt);
4516
+ }
4517
+ finally {
4518
+ this.compactInFlight.delete(agentId);
4519
+ }
4520
+ }
4521
+ async acquireCompactGuard(agentId) {
4522
+ while (!this.tryAcquireCompactGuard(agentId)) {
4523
+ await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4524
+ }
4525
+ }
4526
+ tryAcquireCompactGuard(agentId) {
4527
+ if (this.compactInFlight.has(agentId))
4528
+ return false;
4529
+ this.compactInFlight.add(agentId);
4530
+ return true;
4531
+ }
4532
+ async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4419
4533
  const bindingStillOurs = async () => {
4420
4534
  const s = await this.agentStore.get(agentId);
4421
4535
  return !!s && s.taskId === originalTaskId && s.paneId === paneId;
@@ -4488,12 +4602,6 @@ export class AgentManager {
4488
4602
  stopPhaseSignalWatcher(taskId) {
4489
4603
  this.phaseSignalWatcher?.stop(taskId);
4490
4604
  }
4491
- // Backwards-compat alias for spec-only call sites (recovery, transitions
4492
- // that already named the kind). New callers should use setupPhaseSignalWatcher
4493
- // directly with the right expectedKinds.
4494
- stopSpecSignalWatcher(taskId) {
4495
- this.stopPhaseSignalWatcher(taskId);
4496
- }
4497
4605
  // Prompt build (via task.signalToken) and watcher must share the same token.
4498
4606
  // Returns whether dispatch may safely proceed. False ONLY when a configured watcher failed
4499
4607
  // to arm — the dangerous case where a same-identity verdict would have no consumer. When no
@@ -4513,9 +4621,6 @@ export class AgentManager {
4513
4621
  expectedKinds,
4514
4622
  token,
4515
4623
  skipSnapshot,
4516
- // Mode routing rides every arm automatically: the snapshot lives on the
4517
- // task, so github-mode tasks keep their legacy event types untouched.
4518
- reviewMode: task.reviewMode ?? 'github',
4519
4624
  ...(onReadFile ? { onReadFile } : {}),
4520
4625
  });
4521
4626
  }
@@ -4580,358 +4685,6 @@ export class AgentManager {
4580
4685
  if (mapped)
4581
4686
  await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
4582
4687
  }
4583
- async readSpecReviewFile(taskId, fileName) {
4584
- const task = await this.taskStore.get(taskId);
4585
- if (!task)
4586
- return null;
4587
- if (!task.branch) {
4588
- throw new Error(`readSpecReviewFile: task ${taskId} has no branch`);
4589
- }
4590
- const project = this.getProjectConfig(task.projectId);
4591
- if (!project) {
4592
- throw new Error(`readSpecReviewFile: unknown project ${task.projectId}`);
4593
- }
4594
- const dev = this.getAgentConfig(task.agentId);
4595
- if (!dev) {
4596
- throw new Error(`readSpecReviewFile: task ${taskId} has no dev agent bound`);
4597
- }
4598
- const runner = this.createRunnerFor(dev);
4599
- const store = this.createRepoStore(dev, project, runner);
4600
- const workdir = await this.resolveWorkdir(dev, await this.agentStore.get(dev.id))
4601
- ?? await store.ensure();
4602
- const filePath = `.baxian/spec-review/${fileName}`;
4603
- return store.readFileFromBranch(workdir, task.branch, filePath);
4604
- }
4605
- async dispatchSpecReviewToQa(taskId) {
4606
- // Phase 1 (lock): validate + decide qa + compute newToken/newRound (无 mutation, 无 park)。
4607
- // 关键约束:task 不能在 startSession 之前被改 — startSession 内部调用
4608
- // buildPromptInline,prompt 必须看到的是新 token 和新 round;这里只 *计算*,
4609
- // 真正写回 task 放到 Phase 3。
4610
- const claim = await this.withTaskLock(async () => {
4611
- const task = await this.taskStore.get(taskId);
4612
- if (!task)
4613
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} not found`);
4614
- if (!task.branch)
4615
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} has no branch`);
4616
- // Stale spec-created guard: 一旦 task 离开 pre-spec 阶段 (phase='code' 或其他
4617
- // 非 'spec'/undefined 值),迟到的 spec-created signal 不应再 dispatch review。
4618
- // 允许 phase==='spec' 是预留 dev 在 fix-complete 后再 emit spec-created 的扩展点。
4619
- if (task.phase !== undefined && task.phase !== 'spec') {
4620
- await this.safeEmit({
4621
- id: '',
4622
- type: 'human.intervention',
4623
- timestamp: new Date().toISOString(),
4624
- projectId: task.projectId,
4625
- agentId: task.agentId,
4626
- taskId,
4627
- data: { phase: 'spec-created-stale-after-code', taskPhase: task.phase },
4628
- });
4629
- return null;
4630
- }
4631
- const qa = this.findQaPartner(task.agentId);
4632
- if (!qa) {
4633
- await this.safeEmit({
4634
- id: '',
4635
- type: 'human.intervention',
4636
- timestamp: new Date().toISOString(),
4637
- projectId: task.projectId,
4638
- agentId: task.agentId,
4639
- taskId,
4640
- data: { phase: 'spec-review-no-qa-partner', devAgentId: task.agentId },
4641
- });
4642
- return null;
4643
- }
4644
- // 记录入口 status — fix-then-review 重派 (fromStatus 含 'fixing') 时,
4645
- // spawn 失败 rollback 不能无差别回 in_progress;必须回到原 status 以保留 spec phase。
4646
- // transitionTaskStatus 的 fromStatus 守门已限定为这三种之一; 其他 status 不会走到这里。
4647
- const isReviewEntry = task.status === 'in_progress'
4648
- || task.status === 'fixing'
4649
- || task.status === 'pending';
4650
- if (!isReviewEntry)
4651
- return null;
4652
- return {
4653
- qaId: qa.id,
4654
- devAgentId: task.agentId,
4655
- projectId: task.projectId,
4656
- newToken: createSignalToken(),
4657
- newRound: (task.specReviewRound ?? 0) + 1,
4658
- originalStatus: task.status,
4659
- // 记录原 spec-created token — pre-spec entry rollback 时 restore,
4660
- // 让 dev 后续 spec-created signal (with 原 token) 经 handler freshness gate 通过 → auto retry。
4661
- originalToken: task.signalToken,
4662
- // 回滚时 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4663
- originalRound: task.specReviewRound,
4664
- };
4665
- });
4666
- if (!claim)
4667
- return null;
4668
- const { qaId, devAgentId, projectId, newToken, newRound, originalStatus, originalToken, originalRound } = claim;
4669
- // Phase 2a: 先 acquire QA — 失败时 dev 还未 park,直接 return 即可。
4670
- const acquired = await this.acquireAgentForTask(qaId, taskId, 'spec-review');
4671
- if (!acquired) {
4672
- await this.safeEmit({
4673
- id: '',
4674
- type: 'human.intervention',
4675
- timestamp: new Date().toISOString(),
4676
- projectId,
4677
- agentId: qaId,
4678
- taskId,
4679
- data: { phase: 'spec-review-qa-acquire-failed', qaAgentId: qaId },
4680
- });
4681
- return null;
4682
- }
4683
- // Phase 2b: dev gate — park dev so it stops editing the spec while QA reviews。
4684
- // 顺序在 acquireQA 之后:避免 QA 失败时 dev 已 parked 但 task 仍 in_progress,
4685
- // 无任何后续 dispatch 把 dev 拉出 waiting (即 dev 永久挂起)。
4686
- if (devAgentId) {
4687
- const devOk = await this.markAgentWaiting(devAgentId, taskId);
4688
- if (!devOk) {
4689
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4690
- .catch(() => undefined);
4691
- await this.safeEmit({
4692
- id: '',
4693
- type: 'human.intervention',
4694
- timestamp: new Date().toISOString(),
4695
- projectId,
4696
- agentId: devAgentId,
4697
- taskId,
4698
- data: { phase: 'spec-review-dev-park-failed', devAgentId },
4699
- });
4700
- return null;
4701
- }
4702
- }
4703
- // Phase 2c (lock): atomic transition + persist newToken/newRound/phase/qaAgentId.
4704
- // 必须在 startSession 之前;若顺序反过来,startSession 之后崩溃但 transition 没做时,
4705
- // setupRecoveredSpecSignals 会读旧 status/token 推断错 kind/token,新 signal 无法匹配 → 链路死。
4706
- const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'pending'] }, {
4707
- specReviewRound: newRound,
4708
- signalToken: newToken,
4709
- phase: 'spec',
4710
- qaAgentId: qaId,
4711
- });
4712
- if (!transition) {
4713
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4714
- .catch(() => undefined);
4715
- // 不 re-acquire dev: markAgentWaiting (mode='waiting') 仅 bump updatedAt,
4716
- // dev 仍 bound 到 task; develop phase 不在 reentry 集合, 重 acquire 必返回 false (dead code)。
4717
- await this.safeEmit({
4718
- id: '',
4719
- type: 'human.intervention',
4720
- timestamp: new Date().toISOString(),
4721
- projectId,
4722
- agentId: qaId,
4723
- taskId,
4724
- data: { phase: 'spec-review-transition-failed', qaAgentId: qaId },
4725
- });
4726
- return null;
4727
- }
4728
- // Phase 2d: startSession 用显式 newToken/newRound 透传到 prompt。
4729
- // 失败时回滚 transition + 清新 persist 字段,避免 task 留在 review 但 qa 无 session 的 stuck。
4730
- // 不调 acquireAgentForTask(dev, 'develop'):markAgentWaiting 走 mode='waiting' 仅 bump updatedAt
4731
- // (不清 binding 也不真正 park REPL),dev 仍 bound 到 task;且 develop phase 不在
4732
- // canDispatchWithBinding 的 reentry 集合,重 acquire 必返回 false — 是 dead code。
4733
- let started = false;
4734
- try {
4735
- started = await this.startSession(taskId, qaId, 'spec-review', {
4736
- bypassTaskStatusGate: true,
4737
- signalToken: newToken,
4738
- currentSpecRound: newRound,
4739
- });
4740
- }
4741
- catch (err) {
4742
- // DispatchTerminalError 都委托给 failTaskForDispatchError:ack_unknown 会保留绑定走
4743
- // markAwaitingHuman,其他 reason(prompt_too_large 等非 transient)让 task 进 failed
4744
- // 而不是 rollback 让 cron 反复 retry。其他异常(瞬时 / 不明)才走 rollback + release。
4745
- if (err instanceof DispatchTerminalError) {
4746
- await this.failTaskForDispatchError(taskId, 'spec-review', qaId, err);
4747
- }
4748
- else if (err instanceof EnsureSessionError && err.partial.handled) {
4749
- // handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release,
4750
- // 否则 boundTask terminal 让 release gate 放行清掉仍卡 dialog 的 pane lock。
4751
- }
4752
- else {
4753
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4754
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4755
- .catch(() => undefined);
4756
- }
4757
- throw err;
4758
- }
4759
- if (!started) {
4760
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4761
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4762
- .catch(() => undefined);
4763
- await this.safeEmit({
4764
- id: '',
4765
- type: 'human.intervention',
4766
- timestamp: new Date().toISOString(),
4767
- projectId,
4768
- agentId: qaId,
4769
- taskId,
4770
- data: { phase: 'spec-review-start-failed', qaAgentId: qaId },
4771
- });
4772
- return null;
4773
- }
4774
- // Phase 3: set up watcher。spec-created 已被消费,先 tear down 防止 dev 之后无关 signal 误触发。
4775
- // QA echoes exactly one verdict signal — set up both kinds, the first match wins.
4776
- this.stopSpecSignalWatcher(taskId);
4777
- await this.armPostDispatchSignalOrHold(taskId, qaId, ['spec-approved', 'spec-changes-requested'], newToken);
4778
- return await this.taskStore.get(taskId);
4779
- }
4780
- // startSession 失败回滚:
4781
- // - pre-spec entry: restore originalToken 让 dev 后续 spec-created signal 经 freshness gate 通过 → auto retry。
4782
- // - fixing entry: 保留 phase='spec' + qaAgentId(否则 spec.* freshness gate 全 fail),清 token 防 stale。
4783
- // round 必须 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4784
- async rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound) {
4785
- if (originalStatus === 'fixing') {
4786
- await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: undefined, specReviewRound: originalRound });
4787
- return;
4788
- }
4789
- await this.transitionTaskStatus(taskId, originalStatus, { fromStatus: ['review'] }, {
4790
- signalToken: originalToken,
4791
- phase: undefined,
4792
- qaAgentId: undefined,
4793
- specReviewRound: originalRound,
4794
- });
4795
- }
4796
- async dispatchSpecFixToDev(taskId, findings) {
4797
- // Phase 1 (lock): validate + phase guard + decide newToken。
4798
- // fix 是同 round 的 dev 处理 QA findings,round 不递增;只刷新 token 让 prompt + watcher 唯一识别本轮 fix。
4799
- const claim = await this.withTaskLock(async () => {
4800
- const task = await this.taskStore.get(taskId);
4801
- if (!task)
4802
- throw new Error(`dispatchSpecFixToDev: task ${taskId} not found`);
4803
- const devAgentId = task.agentId;
4804
- if (!devAgentId) {
4805
- throw new Error(`dispatchSpecFixToDev: task ${taskId} has no dev agent`);
4806
- }
4807
- // 离开 spec 阶段的 task 不应再被 spec-fix dispatch 击中 (defense in depth — handler 也 gate)。
4808
- if (task.phase !== 'spec') {
4809
- await this.safeEmit({
4810
- id: '',
4811
- type: 'human.intervention',
4812
- timestamp: new Date().toISOString(),
4813
- projectId: task.projectId,
4814
- agentId: devAgentId,
4815
- taskId,
4816
- data: { phase: 'spec-fix-stale-phase', taskPhase: task.phase },
4817
- });
4818
- return null;
4819
- }
4820
- return {
4821
- devAgentId,
4822
- qaAgentId: task.qaAgentId,
4823
- projectId: task.projectId,
4824
- newToken: createSignalToken(),
4825
- currentRound: task.specReviewRound ?? 1,
4826
- };
4827
- });
4828
- if (!claim)
4829
- return null;
4830
- const { devAgentId, qaAgentId, projectId, newToken, currentRound } = claim;
4831
- if (qaAgentId) {
4832
- // release 失败留 stale qa binding,下一轮 acquireAgentForTask(qa) 必拒;abort + emit intervention。
4833
- const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
4834
- .catch(err => {
4835
- console.warn(`[AgentManager] dispatchSpecFixToDev release qa=${qaAgentId} failed:`, err);
4836
- return false;
4837
- });
4838
- if (!released) {
4839
- await this.safeEmit({
4840
- id: '',
4841
- type: 'human.intervention',
4842
- timestamp: new Date().toISOString(),
4843
- projectId,
4844
- agentId: qaAgentId,
4845
- taskId,
4846
- data: { phase: 'spec-fix-qa-release-failed', qaAgentId },
4847
- });
4848
- return null;
4849
- }
4850
- }
4851
- // Phase 2a: acquire dev。
4852
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'spec-fix');
4853
- if (!acquired) {
4854
- await this.safeEmit({
4855
- id: '',
4856
- type: 'human.intervention',
4857
- timestamp: new Date().toISOString(),
4858
- projectId,
4859
- agentId: devAgentId,
4860
- taskId,
4861
- data: { phase: 'spec-fix-dev-acquire-failed', devAgentId },
4862
- });
4863
- return null;
4864
- }
4865
- // Phase 2b (lock): atomic transition + persist newToken/phase。
4866
- // 必须在 continueSession 之前;否则崩溃后 setupRecoveredSpecSignals 读旧 token,
4867
- // 与 dev 输出的 newToken signal 不匹配 → 链路死。
4868
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: newToken, phase: 'spec' });
4869
- if (!transition) {
4870
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4871
- .catch(() => undefined);
4872
- await this.safeEmit({
4873
- id: '',
4874
- type: 'human.intervention',
4875
- timestamp: new Date().toISOString(),
4876
- projectId,
4877
- agentId: devAgentId,
4878
- taskId,
4879
- data: { phase: 'spec-fix-transition-failed', devAgentId },
4880
- });
4881
- return null;
4882
- }
4883
- // Phase 2c: continueSession 透传 newToken + currentRound 给 prompt。
4884
- // 失败时回滚 transition + 清新 token,避免 task 留在 fixing 但 dev 无 spec-fix prompt 的 stuck。
4885
- let resumed = false;
4886
- try {
4887
- resumed = await this.continueSession(taskId, devAgentId, 'spec-fix', {
4888
- specFindings: findings,
4889
- signalToken: newToken,
4890
- currentSpecRound: currentRound,
4891
- bypassTaskStatusGate: true,
4892
- });
4893
- }
4894
- catch (err) {
4895
- // 同 spec-review:DispatchTerminalError 走 failTaskForDispatchError 统一处理
4896
- // (ack_unknown → markAwaitingHuman,其他 reason → release + task failed)。
4897
- if (err instanceof DispatchTerminalError) {
4898
- await this.failTaskForDispatchError(taskId, 'spec-fix', devAgentId, err);
4899
- }
4900
- else if (err instanceof EnsureSessionError && err.partial.handled) {
4901
- // handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release。
4902
- }
4903
- else {
4904
- await this.rollbackSpecFixTransition(taskId);
4905
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4906
- .catch(() => undefined);
4907
- }
4908
- console.error(`[AgentManager] dispatchSpecFixToDev continueSession(dev=${devAgentId}) failed:`, err);
4909
- throw err;
4910
- }
4911
- if (!resumed) {
4912
- await this.rollbackSpecFixTransition(taskId);
4913
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4914
- .catch(() => undefined);
4915
- await this.safeEmit({
4916
- id: '',
4917
- type: 'human.intervention',
4918
- timestamp: new Date().toISOString(),
4919
- projectId,
4920
- agentId: devAgentId,
4921
- taskId,
4922
- data: { phase: 'spec-fix-resume-failed', devAgentId },
4923
- });
4924
- return null;
4925
- }
4926
- // Phase 3: set up watcher。
4927
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'spec-fixed', newToken);
4928
- return await this.taskStore.get(taskId);
4929
- }
4930
- // continueSession 失败回滚:fixing → review + 清新 token。
4931
- // 保留 phase='spec' 与 qaAgentId — 失败后 review 状态需要人工 retry 或重新 dispatch。
4932
- async rollbackSpecFixTransition(taskId) {
4933
- await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['fixing'] }, { signalToken: undefined });
4934
- }
4935
4688
  async transitionToCodePhase(taskId) {
4936
4689
  const task = await this.taskStore.get(taskId);
4937
4690
  if (!task)
@@ -5000,7 +4753,7 @@ export class AgentManager {
5000
4753
  resumed = await this.continueSession(taskId, devAgentId, 'code');
5001
4754
  }
5002
4755
  catch (err) {
5003
- // 同 spec-review/spec-fix:DispatchTerminalError 委托给 failTaskForDispatchError
4756
+ // 同 dispatchServerReviewToQa/dispatchServerFixToDev:DispatchTerminalError 委托给 failTaskForDispatchError
5004
4757
  // (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
5005
4758
  if (err instanceof DispatchTerminalError) {
5006
4759
  await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
@@ -5038,7 +4791,8 @@ export class AgentManager {
5038
4791
  const task = await this.taskStore.get(taskId);
5039
4792
  if (!task)
5040
4793
  throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
5041
- if (task.reviewMode !== 'server') {
4794
+ // spec 阶段恒为 server 中转;code 阶段仍 server-only。
4795
+ if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5042
4796
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5043
4797
  }
5044
4798
  const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
@@ -5073,6 +4827,7 @@ export class AgentManager {
5073
4827
  originalRound: roundField,
5074
4828
  originalBatchIndex: task.batchIndex,
5075
4829
  originalBatchTotal: task.batchTotal,
4830
+ originalPhase: task.phase,
5076
4831
  };
5077
4832
  });
5078
4833
  if (!claim)
@@ -5085,6 +4840,9 @@ export class AgentManager {
5085
4840
  signalToken: claim.originalToken,
5086
4841
  batchIndex: claim.originalBatchIndex,
5087
4842
  batchTotal: claim.originalBatchTotal,
4843
+ // spec transition 写入 phase:'spec';github 首轮失败若不还原,dev 直发
4844
+ // pr-created 会被 legacy freshness gate 拒(设计 §2)。
4845
+ phase: claim.originalPhase,
5088
4846
  ...(opts.phase === 'spec'
5089
4847
  ? { specReviewRound: claim.originalRound }
5090
4848
  : { reviewRound: claim.originalRound }),
@@ -5234,7 +4992,7 @@ export class AgentManager {
5234
4992
  const task = await this.taskStore.get(taskId);
5235
4993
  if (!task)
5236
4994
  throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5237
- if (task.reviewMode !== 'server') {
4995
+ if (task.reviewMode !== 'server' && task.phase !== 'spec') {
5238
4996
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5239
4997
  }
5240
4998
  if (!task.agentId)
@@ -5470,24 +5228,34 @@ export class AgentManager {
5470
5228
  return;
5471
5229
  }
5472
5230
  try {
5473
- await this.injectTextToAgent(qaAgentId, body);
5231
+ await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
5474
5232
  }
5475
5233
  catch (err) {
5476
5234
  console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
5477
5235
  }
5478
5236
  }
5479
5237
  // Plain text paste + submit into a live agent pane (no skills, no ack protocol).
5480
- async injectTextToAgent(agentId, text) {
5238
+ async injectTextToAgent(agentId, text, opts = {}) {
5481
5239
  const cfg = this.getAgentConfig(agentId);
5482
5240
  if (!cfg)
5483
5241
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
5484
- const state = await this.agentStore.get(agentId);
5485
- const paneId = state?.paneId;
5486
- if (!paneId)
5487
- throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5488
- const tmux = new TmuxManager(this.createRunnerFor(cfg));
5489
- await tmux.injectPrompt(paneId, text, agentId);
5490
- await tmux.sendEnter(paneId);
5242
+ await this.acquireCompactGuard(agentId);
5243
+ try {
5244
+ // 锁内重读:guard 等待期间绑定可能已易主,过期文本决不落 pane。
5245
+ const state = await this.agentStore.get(agentId);
5246
+ if (opts.expectedTaskId !== undefined && state?.taskId !== opts.expectedTaskId) {
5247
+ throw new Error(`injectTextToAgent: agent ${agentId} no longer bound to ${opts.expectedTaskId}`);
5248
+ }
5249
+ const paneId = state?.paneId;
5250
+ if (!paneId)
5251
+ throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5252
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5253
+ await tmux.injectPrompt(paneId, text, agentId);
5254
+ await tmux.sendEnter(paneId);
5255
+ }
5256
+ finally {
5257
+ this.compactInFlight.delete(agentId);
5258
+ }
5491
5259
  }
5492
5260
  // Human gate confirm (spec §10): executes the configured completion for
5493
5261
  // ready (server mode) / merge-ready (github mode) tasks.