@songsid/agend 2.1.4-beta.2 → 2.1.4-beta.20

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 (128) hide show
  1. package/dist/agent-endpoint.js +1 -1
  2. package/dist/agent-endpoint.js.map +1 -1
  3. package/dist/backend/antigravity.d.ts +1 -0
  4. package/dist/backend/antigravity.js +4 -0
  5. package/dist/backend/antigravity.js.map +1 -1
  6. package/dist/backend/claude-code.js +23 -2
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/codex.d.ts +17 -0
  9. package/dist/backend/codex.js +122 -7
  10. package/dist/backend/codex.js.map +1 -1
  11. package/dist/backend/kiro.d.ts +1 -0
  12. package/dist/backend/kiro.js +14 -0
  13. package/dist/backend/kiro.js.map +1 -1
  14. package/dist/backend/opencode.d.ts +27 -11
  15. package/dist/backend/opencode.js +48 -37
  16. package/dist/backend/opencode.js.map +1 -1
  17. package/dist/backend/types.d.ts +14 -0
  18. package/dist/backend/types.js +7 -0
  19. package/dist/backend/types.js.map +1 -1
  20. package/dist/channel/adapters/discord.js +72 -25
  21. package/dist/channel/adapters/discord.js.map +1 -1
  22. package/dist/channel/adapters/telegram.js +5 -3
  23. package/dist/channel/adapters/telegram.js.map +1 -1
  24. package/dist/channel/mcp-server.js +6 -20
  25. package/dist/channel/mcp-server.js.map +1 -1
  26. package/dist/channel/mcp-tools.js +4 -2
  27. package/dist/channel/mcp-tools.js.map +1 -1
  28. package/dist/channel/types.d.ts +1 -1
  29. package/dist/classic-channel-manager.d.ts +40 -3
  30. package/dist/classic-channel-manager.js +218 -31
  31. package/dist/classic-channel-manager.js.map +1 -1
  32. package/dist/cli.js +159 -36
  33. package/dist/cli.js.map +1 -1
  34. package/dist/completion-install.d.ts +70 -0
  35. package/dist/completion-install.js +152 -0
  36. package/dist/completion-install.js.map +1 -0
  37. package/dist/completion.d.ts +7 -1
  38. package/dist/completion.js +14 -4
  39. package/dist/completion.js.map +1 -1
  40. package/dist/config-validator.js +38 -7
  41. package/dist/config-validator.js.map +1 -1
  42. package/dist/config.d.ts +2 -0
  43. package/dist/config.js +10 -1
  44. package/dist/config.js.map +1 -1
  45. package/dist/daemon.d.ts +171 -5
  46. package/dist/daemon.js +696 -82
  47. package/dist/daemon.js.map +1 -1
  48. package/dist/doctor.d.ts +26 -0
  49. package/dist/doctor.js +216 -0
  50. package/dist/doctor.js.map +1 -0
  51. package/dist/fleet-context.d.ts +14 -0
  52. package/dist/fleet-manager.d.ts +231 -10
  53. package/dist/fleet-manager.js +1798 -493
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/fleet-yaml-slim.d.ts +10 -0
  56. package/dist/fleet-yaml-slim.js +59 -0
  57. package/dist/fleet-yaml-slim.js.map +1 -0
  58. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  59. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  60. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  61. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  62. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  63. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  64. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  65. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  66. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  67. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  68. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  69. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  70. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  71. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  72. package/dist/instance-lifecycle.d.ts +8 -0
  73. package/dist/instance-lifecycle.js +46 -25
  74. package/dist/instance-lifecycle.js.map +1 -1
  75. package/dist/instructions.d.ts +12 -0
  76. package/dist/instructions.js +33 -0
  77. package/dist/instructions.js.map +1 -1
  78. package/dist/locale.js +656 -220
  79. package/dist/locale.js.map +1 -1
  80. package/dist/outbound-handlers.d.ts +8 -0
  81. package/dist/outbound-handlers.js +44 -5
  82. package/dist/outbound-handlers.js.map +1 -1
  83. package/dist/outbound-schemas.d.ts +5 -0
  84. package/dist/outbound-schemas.js +7 -0
  85. package/dist/outbound-schemas.js.map +1 -1
  86. package/dist/quickstart.js +25 -0
  87. package/dist/quickstart.js.map +1 -1
  88. package/dist/restart-progress.d.ts +9 -1
  89. package/dist/restart-progress.js +30 -6
  90. package/dist/restart-progress.js.map +1 -1
  91. package/dist/scheduler/db.d.ts +5 -0
  92. package/dist/scheduler/db.js +35 -0
  93. package/dist/scheduler/db.js.map +1 -1
  94. package/dist/service-installer.d.ts +11 -0
  95. package/dist/service-installer.js +84 -18
  96. package/dist/service-installer.js.map +1 -1
  97. package/dist/settings-api.js +1 -1
  98. package/dist/settings-api.js.map +1 -1
  99. package/dist/tips.d.ts +39 -0
  100. package/dist/tips.js +351 -0
  101. package/dist/tips.js.map +1 -0
  102. package/dist/tool-progress.d.ts +40 -0
  103. package/dist/tool-progress.js +289 -0
  104. package/dist/tool-progress.js.map +1 -0
  105. package/dist/topic-commands.d.ts +28 -4
  106. package/dist/topic-commands.js +217 -65
  107. package/dist/topic-commands.js.map +1 -1
  108. package/dist/transcript-monitor.d.ts +15 -2
  109. package/dist/transcript-monitor.js +63 -17
  110. package/dist/transcript-monitor.js.map +1 -1
  111. package/dist/transcript-sources.d.ts +106 -0
  112. package/dist/transcript-sources.js +428 -0
  113. package/dist/transcript-sources.js.map +1 -0
  114. package/dist/types.d.ts +11 -0
  115. package/dist/ui/dashboard.html +55 -32
  116. package/dist/ui/settings.html +120 -51
  117. package/dist/ui/view.html +23 -15
  118. package/dist/update-marker.d.ts +30 -0
  119. package/dist/update-marker.js +71 -20
  120. package/dist/update-marker.js.map +1 -1
  121. package/dist/update-progress.d.ts +6 -0
  122. package/dist/update-progress.js +29 -0
  123. package/dist/update-progress.js.map +1 -0
  124. package/dist/usage/usage-api.d.ts +8 -2
  125. package/dist/usage/usage-api.js +24 -3
  126. package/dist/usage/usage-api.js.map +1 -1
  127. package/dist/workflow-templates/default.md +2 -1
  128. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -8,6 +8,8 @@ import { mcpServerState } from "./mcp-liveness.js";
8
8
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
9
9
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
10
10
  import { TranscriptMonitor } from "./transcript-monitor.js";
11
+ import { createTranscriptSource } from "./transcript-sources.js";
12
+ import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
11
13
  import { ContextGuardian } from "./context-guardian.js";
12
14
  import { IpcServer } from "./channel/ipc-bridge.js";
13
15
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
@@ -45,6 +47,10 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
45
47
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
46
48
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
47
49
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
50
+ /** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
51
+ export const PERIODIC_REDRAW_PROBE_MS = 25;
52
+ /** A foreground server/port-forward should hand control back or be acknowledged. */
53
+ export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
48
54
  const LAST_INBOUND_FILE = "last-inbound-at";
49
55
  /** Minimum gap between "health check is failing" notifications for one instance. */
50
56
  const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
@@ -157,6 +163,11 @@ export class AutoPauseController {
157
163
  recordActivity(now = Date.now()) {
158
164
  this.lastActivityAt = now;
159
165
  }
166
+ /** Apply a new idle threshold and start a fresh countdown from this update. */
167
+ reconfigure(thresholdMs, now = Date.now()) {
168
+ this.thresholdMs = Math.max(0, thresholdMs);
169
+ this.lastActivityAt = now;
170
+ }
160
171
  observe(state, now = Date.now()) {
161
172
  if (this.pausedAt !== null || this.thresholdMs <= 0)
162
173
  return false;
@@ -279,9 +290,11 @@ export class PaneStateMachine {
279
290
  * working" and a finished turn would not be seen as idle until the next 60s
280
291
  * safety sweep. Output timestamps are the honest liveness signal here, and
281
292
  * the caller has them.
293
+ * @param opts.forceBusy the backend has positively identified a still-running
294
+ * foreground tool even though its TUI keeps an old ready footer visible.
282
295
  */
283
296
  observe(pane, now = Date.now(), opts = {}) {
284
- const { settled = false, changeAt = now } = opts;
297
+ const { settled = false, changeAt = now, forceBusy = false } = opts;
285
298
  const paneHash = createHash("sha256").update(pane).digest("hex");
286
299
  const firstObservation = this.lastPaneHash === null;
287
300
  const paneChanged = this.lastPaneHash !== paneHash;
@@ -290,7 +303,10 @@ export class PaneStateMachine {
290
303
  this.lastPaneChangeAt = changeAt;
291
304
  }
292
305
  this.lastObservedAt = now;
293
- const ready = this.isReady(pane);
306
+ // Some TUIs keep their ready footer visible while a foreground tool owns
307
+ // stdin. Backends can positively identify that tool from the pane; in that
308
+ // case the old prompt must not clear pending work or retire Cancel.
309
+ const ready = !forceBusy && this.isReady(pane);
294
310
  const nextState = firstObservation
295
311
  ? ready ? "idle" : "working"
296
312
  : paneChanged && !settled
@@ -591,6 +607,69 @@ export class InteractivePromptDetector {
591
607
  this.notifiedSignature = null;
592
608
  }
593
609
  }
610
+ const BLOCKING_PROCESS_PATTERNS = [
611
+ // kubectl port-forward (including a backgrounded child whose inherited
612
+ // stdout keeps the parent shell tool open).
613
+ /^\s*(?:Forwarding from\s+(?:127\.0\.0\.1|localhost|\[::1\]):\d+\s+->\s+\d+|Handling connection for\s+\d+)\s*$/im,
614
+ // Common development servers. Keep these line-shaped and require an address
615
+ // or port so prose such as "the server is running" does not arm the detector.
616
+ /^\s*(?:(?:INFO:\s*)?Uvicorn running on|Serving HTTP on|Listening on|Server (?:is )?(?:listening|running) (?:at|on)|Application startup complete[^\n]*(?:port|https?:\/\/))[^\n]*(?:https?:\/\/|\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]|port)\b)[^\n]*$/im,
617
+ ];
618
+ /**
619
+ * Detect a foreground shell tool which has become a long-lived server.
620
+ *
621
+ * Unlike the generic pane-stuck clock, this clock deliberately survives new
622
+ * output: access logs and `Handling connection` lines are exactly why a
623
+ * foreground server can remain blocked forever without ever looking silent.
624
+ * Once a server marker is seen, the still-running tool identity is the stable
625
+ * signal; the marker may scroll out of the 32-row pane.
626
+ */
627
+ export class BlockingProcessDetector {
628
+ graceMs;
629
+ activity = null;
630
+ evidence = null;
631
+ detectedAt = 0;
632
+ notified = false;
633
+ constructor(graceMs = DEFAULT_BLOCKING_PROCESS_GRACE_MS) {
634
+ this.graceMs = graceMs;
635
+ }
636
+ observe(pane, activity, now = Date.now()) {
637
+ const shellActivity = activity && /^(?:shell|bash|exec_command|terminal|command)(?::|$)/i.test(activity);
638
+ if (!shellActivity) {
639
+ this.reset();
640
+ return null;
641
+ }
642
+ if (activity !== this.activity) {
643
+ this.reset();
644
+ this.activity = activity;
645
+ }
646
+ if (!this.evidence) {
647
+ const tail = sanitizePaneTail(pane, 40).join("\n");
648
+ for (const pattern of BLOCKING_PROCESS_PATTERNS) {
649
+ const match = tail.match(pattern);
650
+ if (!match)
651
+ continue;
652
+ this.evidence = match[0].trim().slice(0, 200);
653
+ this.detectedAt = now;
654
+ break;
655
+ }
656
+ }
657
+ if (!this.evidence || this.notified || now - this.detectedAt < this.graceMs)
658
+ return null;
659
+ this.notified = true;
660
+ return {
661
+ activity,
662
+ evidence: this.evidence,
663
+ blockedForMs: now - this.detectedAt,
664
+ };
665
+ }
666
+ reset() {
667
+ this.activity = null;
668
+ this.evidence = null;
669
+ this.detectedAt = 0;
670
+ this.notified = false;
671
+ }
672
+ }
594
673
  export class Daemon extends EventEmitter {
595
674
  name;
596
675
  config;
@@ -655,6 +734,7 @@ export class Daemon extends EventEmitter {
655
734
  /** Fallback safety sweep used only when no shared tmux control client exists. */
656
735
  instanceStateMonitorTimer = null;
657
736
  instanceStateIdleTimer = null;
737
+ instanceStateOutputProbeTimer = null;
658
738
  instanceStateStuckTimer = null;
659
739
  instanceStateOutputListener = null;
660
740
  instanceStateOutputEventName = null;
@@ -665,6 +745,7 @@ export class Daemon extends EventEmitter {
665
745
  instanceStateReadyPattern = null;
666
746
  instanceStateBusyPattern = null;
667
747
  instanceStateMonitorActive = false;
748
+ sessionCheckpointWarningEmitted = false;
668
749
  statePollInFlight = false;
669
750
  // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
670
751
  // Set when an inbound message lands in the CLI; cleared on the idle edge that
@@ -684,6 +765,20 @@ export class Daemon extends EventEmitter {
684
765
  * would silently no-op exactly when we most need to stop feeding the CLI.
685
766
  */
686
767
  pausePending = false;
768
+ /**
769
+ * An auth failure was detected and nothing has resolved it yet.
770
+ *
771
+ * Separate from `pausePending` because it must outlive it: the deferred pause
772
+ * clears `pausePending` as soon as it fires, but if that pause is refused the
773
+ * instance is still auth-broken and must not resume raising alarms.
774
+ */
775
+ authFailureUnresolved = false;
776
+ /**
777
+ * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
778
+ * pause — see the pausePending consumption site for why waiting for idle there
779
+ * is waiting forever.
780
+ */
781
+ pauseAllowStuck = false;
687
782
  pauseWakeState = "active";
688
783
  pauseWakeTransition = null;
689
784
  // Model failover: override model on next spawn when rate-limited
@@ -697,6 +792,12 @@ export class Daemon extends EventEmitter {
697
792
  * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
698
793
  * reach the pane — {@link paneWriteLock} does. */
699
794
  pasteLock = Promise.resolve();
795
+ /**
796
+ * Fleet cancellation epoch. Queue entries retain the epoch they arrived with;
797
+ * advancing it invalidates only work received before the cancel, never a new
798
+ * user message which arrives immediately afterwards.
799
+ */
800
+ deliveryEpoch = 0;
700
801
  /** Mutual exclusion for *every* write into the pane, whichever subsystem it
701
802
  * comes from. See PaneWriteLock for why interleaving is destructive. */
702
803
  paneWriteLock = new PaneWriteLock();
@@ -710,9 +811,21 @@ export class Daemon extends EventEmitter {
710
811
  lastBuiltInstructions = "";
711
812
  pasteQueueDepth = 0;
712
813
  firstDeliveryDelay = new FirstDeliveryDelay();
814
+ /** Orders /steer pastes against each other. Deliberately NOT pasteLock:
815
+ * a steer must not queue behind the normal deliveries it exists to
816
+ * overtake. Pane-level exclusion still comes from paneWriteLock inside
817
+ * deliverMessage, so a steer and a normal delivery can never interleave
818
+ * their PTY writes — the steer just doesn't wait for idle. */
819
+ steerLock = Promise.resolve();
820
+ /** Tool lines shown in the channel processing bubble for the current turn. */
821
+ turnProgress = new ProgressAccumulator();
822
+ lastProgressBroadcast = "";
823
+ progressBroadcastTimer = null;
824
+ lastProgressBroadcastAt = 0;
713
825
  // PTY error pattern monitoring
714
826
  errorMonitorTimer = null;
715
827
  interactivePromptDetector = new InteractivePromptDetector();
828
+ blockingProcessDetector = new BlockingProcessDetector();
716
829
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
717
830
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
718
831
  lastMcpDeathNotifiedAt = 0;
@@ -910,43 +1023,38 @@ export class Daemon extends EventEmitter {
910
1023
  // Fleet manager routed a message to us (topic mode)
911
1024
  const meta = msg.meta;
912
1025
  const targetSession = msg.targetSession;
1026
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
913
1027
  void this.wake().then(() => {
914
- this.pushChannelMessage(msg.content, meta, targetSession);
1028
+ this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
915
1029
  }).catch(err => {
916
1030
  this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
917
1031
  });
918
1032
  }
919
1033
  else if (msg.type === "raw_paste") {
920
1034
  // Paste raw text directly to CLI without [user:] wrapping.
921
- if (this.tmux) {
922
- const rawText = msg.content;
923
- this.pasteLock = this.pasteLock.then(async () => {
924
- await this.wake();
925
- await this.deliverMessage(rawText);
926
- this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
927
- }).catch(err => {
928
- this.logger.warn({ err: err.message }, "raw_paste delivery error");
929
- });
930
- }
1035
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch));
1036
+ }
1037
+ else if (msg.type === "config_update") {
1038
+ this.applyConfigUpdate(msg.config);
1039
+ }
1040
+ else if (msg.type === "steer") {
1041
+ const meta = (msg.meta ?? {});
1042
+ this.steerMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
1043
+ }
1044
+ else if (msg.type === "btw") {
1045
+ const meta = (msg.meta ?? {});
1046
+ this.btwMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
931
1047
  }
932
1048
  else if (msg.type === "fleet_schedule_trigger") {
933
1049
  const payload = msg.payload;
934
1050
  const meta = msg.meta;
935
- void this.wake().then(() => this.pushChannelMessage(payload.message, meta)).catch(err => {
1051
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
1052
+ void this.wake().then(() => this.pushChannelMessage(payload.message, meta, undefined, deliveryEpoch)).catch(err => {
936
1053
  this.logger.error({ err: err.message }, "Wake failed for scheduled delivery");
937
1054
  });
938
1055
  }
939
1056
  else if (msg.type === "query_instance_state") {
940
- const snapshot = this.getInstanceStateSnapshot();
941
- this.ipcServer?.send(socket, {
942
- type: "instance_state_response",
943
- requestId: msg.requestId,
944
- instanceName: this.name,
945
- ...snapshot,
946
- state: this.isPaused ? "paused" : snapshot.state,
947
- processStatus: this.processStatus,
948
- pausedAt: this.lastPausedAt,
949
- });
1057
+ void this.respondToInstanceStateQuery(msg, socket);
950
1058
  }
951
1059
  });
952
1060
  // 2. Tmux — ensure session, create window if not alive
@@ -1032,8 +1140,11 @@ export class Daemon extends EventEmitter {
1032
1140
  const outputLog = join(this.instanceDir, "output.log");
1033
1141
  rotateLogIfNeeded(outputLog);
1034
1142
  await this.tmux.pipeOutput(outputLog).catch(() => { });
1035
- // 4. Transcript monitor
1036
- this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
1143
+ // 4. Transcript monitor. claude-code is handled inside the monitor
1144
+ // (statusline transcript); codex/kiro/opencode read their CLI's own
1145
+ // conversation store via a pluggable source. Backends with no known
1146
+ // source stay inert exactly as before.
1147
+ this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
1037
1148
  // 5. Wire transcript events
1038
1149
  const ackIfPending = () => {
1039
1150
  if (!this.pendingAckMessage || !this.adapter)
@@ -1049,6 +1160,7 @@ export class Daemon extends EventEmitter {
1049
1160
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
1050
1161
  this.recordRecentToolActivity(this.summarizeTool(name, input));
1051
1162
  this.publishActivity(this.summarizeTool(name, input));
1163
+ this.recordToolProgress(name, input);
1052
1164
  });
1053
1165
  this.transcriptMonitor.on("tool_result", (name, _output) => {
1054
1166
  this.recordRecentEvent({ type: "tool_result", name });
@@ -1260,7 +1372,7 @@ export class Daemon extends EventEmitter {
1260
1372
  this.logger.info("CLI exited normally (code 0) — pausing health check");
1261
1373
  await this.tmux.killWindow();
1262
1374
  this.healthCheckPaused = true;
1263
- this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.");
1375
+ this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.", 0);
1264
1376
  return;
1265
1377
  }
1266
1378
  // Distinguish tmux server crash from single window crash.
@@ -1357,6 +1469,17 @@ export class Daemon extends EventEmitter {
1357
1469
  catch (err) {
1358
1470
  this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
1359
1471
  }
1472
+ // Keep the loop alive: every health-check exit must either
1473
+ // scheduleNext() or deliberately set healthCheckPaused. This one
1474
+ // did neither, so a recovered instance ran UNMONITORED (while
1475
+ // isHealthCheckEffectivelyPaused still said monitoring was on)
1476
+ // until the next pause→wake cycle or fleet restart. The next tick
1477
+ // does the right thing in both outcomes: a healthy new window
1478
+ // passes the alive check, and a failed recovery hits the dead
1479
+ // window again with backgroundSessionRecoveryAttempted set — so
1480
+ // it falls through to the normal crash handling (crash counting,
1481
+ // backoff, supervision_ended when retries are exhausted).
1482
+ scheduleNext();
1360
1483
  return; // Don't count as crash
1361
1484
  }
1362
1485
  // Already attempted recovery — fall through to normal crash handling
@@ -1506,8 +1629,13 @@ export class Daemon extends EventEmitter {
1506
1629
  * `crash_loop` already had this treatment; this is the same bridge for the other
1507
1630
  * four, carrying a human-readable cause and the operator's next step.
1508
1631
  */
1509
- emitSupervisionEnded(reason, remedy) {
1510
- this.emit("supervision_ended", { name: this.name, reason, remedy });
1632
+ emitSupervisionEnded(reason, remedy, exitCode) {
1633
+ this.emit("supervision_ended", {
1634
+ name: this.name,
1635
+ reason,
1636
+ remedy,
1637
+ ...(exitCode !== undefined ? { exitCode } : {}),
1638
+ });
1511
1639
  }
1512
1640
  setProcessStatus(status) {
1513
1641
  if (this.processStatus === status)
@@ -1568,6 +1696,25 @@ export class Daemon extends EventEmitter {
1568
1696
  // A prompt is not an error and must not enter the PTY recovery gate.
1569
1697
  // Continue scanning real errors in this same snapshot.
1570
1698
  }
1699
+ // If a backend can derive activity from this exact pane, trust its
1700
+ // explicit null (tool completed) instead of falling back to a possibly
1701
+ // stale transcript activity from the previous scan.
1702
+ const paneActivity = this.backend?.getPaneActivity
1703
+ ? this.backend.getPaneActivity(pane)
1704
+ : this.currentActivity;
1705
+ const hasPendingWork = this.pendingWork.hasPendingWork();
1706
+ if (!hasPendingWork)
1707
+ this.blockingProcessDetector.reset();
1708
+ const blockingProcess = hasPendingWork
1709
+ ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
1710
+ : null;
1711
+ if (blockingProcess) {
1712
+ // Reuse the hang-notification bridge: it offers explicit Restart/Wait
1713
+ // choices and, unlike pty_error, does not retire a still-useful Cancel
1714
+ // button merely because a foreground process owns stdin.
1715
+ this.logger.warn(blockingProcess, "Foreground process is blocking the agent input loop");
1716
+ this.hangDetector?.emit("hang", { unchangedForMs: blockingProcess.blockedForMs });
1717
+ }
1571
1718
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
1572
1719
  for (const dialog of dialogs) {
1573
1720
  if (!dialog.pattern.test(pane))
@@ -1640,6 +1787,7 @@ export class Daemon extends EventEmitter {
1640
1787
  if (seen > 0)
1641
1788
  this.lastErrorCount.set(Daemon.errorPatternKey(ep), seen);
1642
1789
  }
1790
+ this.authFailureUnresolved = false;
1643
1791
  this.clearErrorRecoveryGate();
1644
1792
  this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
1645
1793
  this.emit("pty_recovered", { name: this.name, downtime_s: downtime });
@@ -1701,6 +1849,11 @@ export class Daemon extends EventEmitter {
1701
1849
  if (ep.action === "failover")
1702
1850
  this.lastFailoverAt = now;
1703
1851
  const message = this.resolveErrorMessage(pane, ep);
1852
+ // Remember an auth failure for as long as it is unresolved. An auth-broken
1853
+ // CLI cannot finish the turn it is holding, so every other detector in this
1854
+ // scan would otherwise keep reporting the same dead instance forever.
1855
+ if (ep.type === "auth_error")
1856
+ this.authFailureUnresolved = true;
1704
1857
  this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
1705
1858
  this.emit("pty_error", { name: this.name, ...ep, message });
1706
1859
  break; // Only handle first unsuppressed new error per scan
@@ -1744,6 +1897,46 @@ export class Daemon extends EventEmitter {
1744
1897
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
1745
1898
  await this.tmux?.sendSpecialKey(cancelKey);
1746
1899
  }
1900
+ /**
1901
+ * Drop every not-yet-started pane delivery which predates a user cancel.
1902
+ * Promise chains cannot be removed, so entries become generation-checked
1903
+ * no-ops. An entry already past its check is allowed to finish its current PTY
1904
+ * transaction; the interrupt key still cancels the CLI generation.
1905
+ */
1906
+ clearPendingDeliveries(fleetEpoch) {
1907
+ this.deliveryEpoch = fleetEpoch === undefined
1908
+ ? this.deliveryEpoch + 1
1909
+ : Math.max(this.deliveryEpoch, fleetEpoch);
1910
+ this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
1911
+ }
1912
+ captureDeliveryEpoch(value) {
1913
+ const epoch = typeof value === "number" && Number.isSafeInteger(value) && value >= 0
1914
+ ? value
1915
+ : this.deliveryEpoch;
1916
+ if (epoch > this.deliveryEpoch)
1917
+ this.deliveryEpoch = epoch;
1918
+ return epoch;
1919
+ }
1920
+ isDeliveryEpochCurrent(epoch) {
1921
+ return epoch === this.deliveryEpoch;
1922
+ }
1923
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch) {
1924
+ if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
1925
+ return;
1926
+ this.pasteLock = this.pasteLock.then(async () => {
1927
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
1928
+ this.logger.info("Pending raw delivery dropped by user cancel");
1929
+ return;
1930
+ }
1931
+ await this.wake();
1932
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
1933
+ return;
1934
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
1935
+ this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
1936
+ }).catch(err => {
1937
+ this.logger.warn({ err: err.message }, "raw_paste delivery error");
1938
+ });
1939
+ }
1747
1940
  /** Send the backend-specific graceful quit command/key sequence. */
1748
1941
  async sendQuitSequence() {
1749
1942
  if (!this.tmux || !this.backend)
@@ -1845,6 +2038,33 @@ export class Daemon extends EventEmitter {
1845
2038
  stateChangedAt: Date.now(),
1846
2039
  };
1847
2040
  }
2041
+ /**
2042
+ * Answer a FleetManager state query. Most callers only need the cached state,
2043
+ * but lifecycle decisions such as post-reply Cancel retirement need an
2044
+ * authoritative pane observation: after startup the last transition can stay
2045
+ * "working" even though the CLI has since settled without emitting another
2046
+ * control-mode output record.
2047
+ */
2048
+ async respondToInstanceStateQuery(msg, socket) {
2049
+ try {
2050
+ if (msg.refresh === true) {
2051
+ await this.captureAndEvaluateInstanceState("state_query");
2052
+ }
2053
+ const snapshot = this.getInstanceStateSnapshot();
2054
+ this.ipcServer?.send(socket, {
2055
+ type: "instance_state_response",
2056
+ requestId: msg.requestId,
2057
+ instanceName: this.name,
2058
+ ...snapshot,
2059
+ state: this.isPaused ? "paused" : snapshot.state,
2060
+ processStatus: this.processStatus,
2061
+ pausedAt: this.lastPausedAt,
2062
+ });
2063
+ }
2064
+ catch (err) {
2065
+ this.logger.debug({ err: err.message }, "Instance state query failed");
2066
+ }
2067
+ }
1848
2068
  /** Gracefully stop the CLI while keeping its remain-on-exit tmux window. */
1849
2069
  /**
1850
2070
  * Mark the instance to pause as soon as its pane is idle. Used for auth
@@ -1864,7 +2084,14 @@ export class Daemon extends EventEmitter {
1864
2084
  if (this.getPauseWakeState() !== "active")
1865
2085
  return;
1866
2086
  }
1867
- if (this.instanceState !== "idle" || this.pasteQueueDepth > 0) {
2087
+ // `pauseAllowStuck` is consumed here, not merely read: it authorises exactly
2088
+ // one pause from a stuck pane (the auth-deferred one) and must never leak
2089
+ // into an ordinary idle-timeout pause of a busy instance.
2090
+ const allowStuck = this.pauseAllowStuck;
2091
+ this.pauseAllowStuck = false;
2092
+ const pausableState = this.instanceState === "idle"
2093
+ || (allowStuck && this.instanceState === "stuck");
2094
+ if (!pausableState || this.pasteQueueDepth > 0) {
1868
2095
  this.pauseRequested = false;
1869
2096
  return;
1870
2097
  }
@@ -1928,6 +2155,7 @@ export class Daemon extends EventEmitter {
1928
2155
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
1929
2156
  // auth pause — otherwise the instance would pause again the moment it idles.
1930
2157
  this.pausePending = false;
2158
+ this.authFailureUnresolved = false;
1931
2159
  if (this.pauseWakeState === "active")
1932
2160
  return;
1933
2161
  if (this.pauseWakeState === "pausing")
@@ -1988,6 +2216,14 @@ export class Daemon extends EventEmitter {
1988
2216
  applyInstanceStateSnapshot(snapshot, pane) {
1989
2217
  const previous = this.instanceState;
1990
2218
  this.instanceState = snapshot.state;
2219
+ // OpenCode creates its session lazily on the first submitted message.
2220
+ // Waiting until stop/pause to persist that id loses resume state when the
2221
+ // fleet is SIGKILLed or the host reboots.
2222
+ // Idle observations are safe checkpoints; the 60s safety sweep also gives
2223
+ // us a bounded retry if session creation produced no visible state edge.
2224
+ if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
2225
+ this.saveSessionId();
2226
+ }
1991
2227
  // Only a transition back to idle completes pending work. Repeated idle
1992
2228
  // observations between enqueue and paste must not clear a newer inbound.
1993
2229
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -1996,6 +2232,7 @@ export class Daemon extends EventEmitter {
1996
2232
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
1997
2233
  // the last tool pinned to the progress line for the rest of the session.
1998
2234
  this.publishActivity(null);
2235
+ this.resetToolProgress();
1999
2236
  // Must run before the mcpRestartPending branch below: the pane text is the
2000
2237
  // only copy of the answer, and the revival restart is about to clear it.
2001
2238
  this.maybeProxyReplyOnTurnEnd(pane);
@@ -2015,8 +2252,13 @@ export class Daemon extends EventEmitter {
2015
2252
  if (snapshot.state !== "idle")
2016
2253
  this.pauseRequested = false;
2017
2254
  // A pause deferred by an auth failure: retry the moment the pane settles.
2018
- if (this.pausePending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
2255
+ // "stuck" counts as settled here, and that is the whole point — an expired
2256
+ // session cannot complete the turn it is holding, so the pane never returns
2257
+ // to idle and waiting for idle meant waiting forever. The instance stayed
2258
+ // live, kept its warm slot, and re-raised a hang alert on every scan.
2259
+ if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
2019
2260
  this.pausePending = false;
2261
+ this.pauseAllowStuck = snapshot.state === "stuck";
2020
2262
  this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
2021
2263
  return;
2022
2264
  }
@@ -2143,6 +2385,25 @@ export class Daemon extends EventEmitter {
2143
2385
  clearTimeout(this.instanceStateIdleTimer);
2144
2386
  this.instanceStateIdleTimer = null;
2145
2387
  }
2388
+ clearInstanceStateOutputProbeTimer() {
2389
+ if (this.instanceStateOutputProbeTimer)
2390
+ clearTimeout(this.instanceStateOutputProbeTimer);
2391
+ this.instanceStateOutputProbeTimer = null;
2392
+ }
2393
+ /**
2394
+ * Periodic status-line hooks can generate `%output` without changing a single
2395
+ * visible cell. Probe the pane once per burst: a changed capture is real work;
2396
+ * an identical ready capture remains idle and emits no state edge.
2397
+ */
2398
+ scheduleInstanceStateOutputProbe() {
2399
+ if (this.instanceStateOutputProbeTimer)
2400
+ return;
2401
+ this.instanceStateOutputProbeTimer = setTimeout(() => {
2402
+ this.instanceStateOutputProbeTimer = null;
2403
+ void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
2404
+ }, PERIODIC_REDRAW_PROBE_MS);
2405
+ this.instanceStateOutputProbeTimer.unref?.();
2406
+ }
2146
2407
  clearInstanceStateStuckTimer() {
2147
2408
  if (this.instanceStateStuckTimer)
2148
2409
  clearTimeout(this.instanceStateStuckTimer);
@@ -2187,6 +2448,8 @@ export class Daemon extends EventEmitter {
2187
2448
  if (this.statePollInFlight) {
2188
2449
  if (reason === "idle_debounce")
2189
2450
  this.scheduleInstanceStateIdleCapture();
2451
+ if (reason === "output_probe")
2452
+ this.scheduleInstanceStateOutputProbe();
2190
2453
  return;
2191
2454
  }
2192
2455
  this.statePollInFlight = true;
@@ -2195,9 +2458,19 @@ export class Daemon extends EventEmitter {
2195
2458
  const pane = await this.tmux.capturePane();
2196
2459
  // Output received while capture-pane was in flight makes this snapshot
2197
2460
  // stale. Its output handler has already armed a new debounce.
2198
- if ((expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2199
- || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt))
2461
+ const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2462
+ || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
2463
+ if (outputMovedDuringCapture) {
2464
+ if (reason === "output_probe") {
2465
+ // A second burst overtook the probe. Do not bless its stale capture as
2466
+ // idle: continuous output is the safer working signal, while the next
2467
+ // quiet probe/debounce can still prove that it was only repainting.
2468
+ this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(this.instanceStateLastOutputAt));
2469
+ this.scheduleInstanceStateIdleCapture();
2470
+ this.scheduleInstanceStateStuckDeadline(this.instanceStateLastOutputAt);
2471
+ }
2200
2472
  return;
2473
+ }
2201
2474
  const observedChangeAt = expectedOutputAt || captureStartedAt;
2202
2475
  // One observation per capture. This used to call observe() twice with the
2203
2476
  // same pane: the second call always saw an unchanged pane, so the
@@ -2206,6 +2479,7 @@ export class Daemon extends EventEmitter {
2206
2479
  // mismatched spinner frame was enough to report idle mid-turn.
2207
2480
  const settled = this.instanceStateLastOutputAt === 0
2208
2481
  || captureStartedAt - this.instanceStateLastOutputAt >= this.instanceStateIdleDebounceMs;
2482
+ const paneActivity = this.backend?.getPaneActivity?.(pane) ?? null;
2209
2483
  // The two times are genuinely different and both matter: the content
2210
2484
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
2211
2485
  // decisions about *now*. The old double-observe expressed that by calling
@@ -2213,6 +2487,7 @@ export class Daemon extends EventEmitter {
2213
2487
  const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
2214
2488
  settled,
2215
2489
  changeAt: observedChangeAt,
2490
+ forceBusy: paneActivity !== null,
2216
2491
  });
2217
2492
  this.applyInstanceStateSnapshot(snapshot, pane);
2218
2493
  // Backends without a transcript feed can still say what they are doing, if
@@ -2220,7 +2495,7 @@ export class Daemon extends EventEmitter {
2220
2495
  // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
2221
2496
  // safety sweep), which matches the progress ticker's own 60s interval.
2222
2497
  if (this.backend?.getPaneActivity) {
2223
- this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
2498
+ this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
2224
2499
  }
2225
2500
  if (snapshot.state === "idle") {
2226
2501
  this.clearInstanceStateStuckTimer();
@@ -2247,6 +2522,14 @@ export class Daemon extends EventEmitter {
2247
2522
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
2248
2523
  return;
2249
2524
  this.instanceStateLastOutputAt = event.at;
2525
+ if (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle") {
2526
+ // Do not emit idle→working solely because agy repainted an identical
2527
+ // footer. The short capture probe below still observes genuine pane
2528
+ // changes promptly and moves the state to working.
2529
+ this.scheduleInstanceStateOutputProbe();
2530
+ this.scheduleInstanceStateIdleCapture();
2531
+ return;
2532
+ }
2250
2533
  const snapshot = this.instanceStateMachine.recordOutput(event.at);
2251
2534
  this.applyInstanceStateSnapshot(snapshot);
2252
2535
  this.scheduleInstanceStateIdleCapture();
@@ -2297,6 +2580,7 @@ export class Daemon extends EventEmitter {
2297
2580
  stopInstanceStateMonitor() {
2298
2581
  this.instanceStateMonitorActive = false;
2299
2582
  this.clearInstanceStateIdleTimer();
2583
+ this.clearInstanceStateOutputProbeTimer();
2300
2584
  this.clearInstanceStateStuckTimer();
2301
2585
  if (this.instanceStateMonitorTimer)
2302
2586
  clearInterval(this.instanceStateMonitorTimer);
@@ -2331,6 +2615,15 @@ export class Daemon extends EventEmitter {
2331
2615
  this.logger.debug(diagnostic, "Suppressing stuck notification without pending work");
2332
2616
  return;
2333
2617
  }
2618
+ // An auth-broken instance IS stuck, and stays stuck: it cannot finish the
2619
+ // turn it is holding, so pendingWork never clears and this fired on every
2620
+ // scan forever. The notification is also useless — it offers Restart/Wait
2621
+ // and asks General for help, but only the user re-logging in fixes this, and
2622
+ // they have already been told exactly that by the auth alert.
2623
+ if (this.authFailureUnresolved) {
2624
+ this.logger.debug(diagnostic, "Suppressing stuck notification — unresolved auth failure (re-login required)");
2625
+ return;
2626
+ }
2334
2627
  this.logger.warn(diagnostic, "Instance pane stuck with pending work");
2335
2628
  this.hangDetector?.emit("hang", { unchangedForMs: snapshot.unchangedForMs });
2336
2629
  }
@@ -2350,6 +2643,7 @@ export class Daemon extends EventEmitter {
2350
2643
  // fire into a stopped daemon).
2351
2644
  this.clearMcpRestartRequest();
2352
2645
  this.interactivePromptDetector.reset();
2646
+ this.blockingProcessDetector.reset();
2353
2647
  this.stopInstanceStateMonitor();
2354
2648
  this.transcriptMonitor?.stop();
2355
2649
  this.guardian?.stop();
@@ -2375,9 +2669,9 @@ export class Daemon extends EventEmitter {
2375
2669
  * Tell the fleet manager what this instance is doing right now, for the live
2376
2670
  * progress line on the cancel button.
2377
2671
  *
2378
- * Purely cosmetic — nothing decides anything from it, so it is fine that only
2379
- * backends with a transcript feed report at all (claude-code today). For the
2380
- * rest the progress line keeps showing just elapsed time.
2672
+ * Primarily cosmetic. The foreground-process detector also uses a current
2673
+ * shell activity as one of several positive signals, but never makes a state
2674
+ * decision from an arbitrary activity label alone.
2381
2675
  *
2382
2676
  * Repeats are dropped: the ticker only edits the channel message when the text
2383
2677
  * changes, and a stream of identical broadcasts would defeat that.
@@ -2393,6 +2687,141 @@ export class Daemon extends EventEmitter {
2393
2687
  activity: next,
2394
2688
  });
2395
2689
  }
2690
+ /** Effective tool_progress level, hardened against junk config values.
2691
+ * Off unless explicitly enabled: an update must never make bubbles start
2692
+ * listing tool activity the operator did not ask to broadcast. */
2693
+ toolProgressLevel() {
2694
+ const raw = this.config.tool_progress;
2695
+ return raw === "standard" || raw === "verbose" ? raw : "off";
2696
+ }
2697
+ /**
2698
+ * Apply a tool-progress setting without restarting the CLI session.
2699
+ *
2700
+ * Drop the current accumulator at the boundary: verbose entries may contain
2701
+ * command previews which must not survive a downgrade to standard/off, and a
2702
+ * newly enabled level should start with a clean, internally consistent list.
2703
+ */
2704
+ updateToolProgress(level) {
2705
+ const previous = this.toolProgressLevel();
2706
+ this.config.tool_progress = level;
2707
+ if (this.toolProgressLevel() !== previous)
2708
+ this.resetToolProgress();
2709
+ }
2710
+ /** Snapshot the configuration actually owned by this live daemon. */
2711
+ getConfigSnapshot() {
2712
+ return structuredClone(this.config);
2713
+ }
2714
+ /**
2715
+ * Apply the whitelisted hot configuration carried by FleetManager IPC.
2716
+ * null removes an optional value; invalid/unlisted fields are ignored rather
2717
+ * than allowing another client on the per-instance socket to mutate cold
2718
+ * process/session settings.
2719
+ */
2720
+ applyConfigUpdate(value) {
2721
+ if (!value || typeof value !== "object" || Array.isArray(value))
2722
+ return;
2723
+ const update = value;
2724
+ if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
2725
+ this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
2726
+ }
2727
+ if (typeof update.mcp_proxy_reply === "boolean")
2728
+ this.config.mcp_proxy_reply = update.mcp_proxy_reply;
2729
+ else if (update.mcp_proxy_reply === null)
2730
+ delete this.config.mcp_proxy_reply;
2731
+ if (typeof update.warm_cap === "number" && Number.isInteger(update.warm_cap) && update.warm_cap >= 0)
2732
+ this.config.warm_cap = update.warm_cap;
2733
+ else if (update.warm_cap === null)
2734
+ delete this.config.warm_cap;
2735
+ const nextAutoPause = typeof update.auto_pause_after === "number"
2736
+ && Number.isFinite(update.auto_pause_after)
2737
+ && update.auto_pause_after >= 0
2738
+ ? update.auto_pause_after
2739
+ : update.auto_pause_after === null ? 0 : undefined;
2740
+ if (nextAutoPause !== undefined) {
2741
+ const previousAutoPause = typeof this.config.auto_pause_after === "number" ? this.config.auto_pause_after : 0;
2742
+ if (update.auto_pause_after === null)
2743
+ delete this.config.auto_pause_after;
2744
+ else
2745
+ this.config.auto_pause_after = nextAutoPause;
2746
+ if (nextAutoPause !== previousAutoPause) {
2747
+ const isGeneral = this.config.general_topic === true || this.name === "general";
2748
+ this.autoPauseController.reconfigure((isGeneral ? 0 : nextAutoPause) * 60_000);
2749
+ }
2750
+ }
2751
+ for (const key of ["display_name", "description"]) {
2752
+ if (typeof update[key] === "string")
2753
+ this.config[key] = update[key];
2754
+ else if (update[key] === null)
2755
+ delete this.config[key];
2756
+ }
2757
+ if (Array.isArray(update.tags) && update.tags.every(tag => typeof tag === "string"))
2758
+ this.config.tags = [...update.tags];
2759
+ else if (update.tags === null)
2760
+ delete this.config.tags;
2761
+ if (["trace", "debug", "info", "warn", "error"].includes(String(update.log_level))) {
2762
+ const level = update.log_level;
2763
+ if (this.config.log_level !== level) {
2764
+ this.config.log_level = level;
2765
+ this.logger.level = level;
2766
+ }
2767
+ }
2768
+ }
2769
+ /**
2770
+ * Accumulate one semantic progress line for the channel bubble and schedule
2771
+ * a coalesced broadcast. Separate from publishActivity on purpose: that one
2772
+ * is the single-line statusline detail (terse, operator-facing), this is the
2773
+ * multi-line channel list (semantic, argument-free at `standard`) — see
2774
+ * tool-progress.ts for why the two labellers must not merge.
2775
+ */
2776
+ recordToolProgress(name, input) {
2777
+ const level = this.toolProgressLevel();
2778
+ if (level === "off")
2779
+ return;
2780
+ if (this.turnProgress.add(summarizeProgress(name, input, level))) {
2781
+ this.scheduleProgressBroadcast();
2782
+ }
2783
+ }
2784
+ /** Coalesce progress broadcasts: at most one per 3s, trailing edge kept. */
2785
+ scheduleProgressBroadcast() {
2786
+ const MIN_INTERVAL_MS = 3_000;
2787
+ const since = Date.now() - this.lastProgressBroadcastAt;
2788
+ if (since >= MIN_INTERVAL_MS) {
2789
+ this.broadcastToolProgress();
2790
+ return;
2791
+ }
2792
+ if (this.progressBroadcastTimer)
2793
+ return;
2794
+ this.progressBroadcastTimer = setTimeout(() => {
2795
+ this.progressBroadcastTimer = null;
2796
+ this.broadcastToolProgress();
2797
+ }, MIN_INTERVAL_MS - since);
2798
+ this.progressBroadcastTimer.unref?.();
2799
+ }
2800
+ broadcastToolProgress() {
2801
+ const rendered = this.turnProgress.render();
2802
+ if (rendered === this.lastProgressBroadcast)
2803
+ return;
2804
+ this.lastProgressBroadcast = rendered;
2805
+ this.lastProgressBroadcastAt = Date.now();
2806
+ this.ipcServer?.broadcast({
2807
+ type: "instance_progress",
2808
+ instanceName: this.name,
2809
+ progress: rendered,
2810
+ });
2811
+ }
2812
+ /** New turn (or turn over): drop the list and tell the fleet to clear it. */
2813
+ resetToolProgress() {
2814
+ if (this.progressBroadcastTimer) {
2815
+ clearTimeout(this.progressBroadcastTimer);
2816
+ this.progressBroadcastTimer = null;
2817
+ }
2818
+ this.turnProgress.reset();
2819
+ if (this.lastProgressBroadcast !== "") {
2820
+ this.lastProgressBroadcast = "";
2821
+ this.lastProgressBroadcastAt = Date.now();
2822
+ this.ipcServer?.broadcast({ type: "instance_progress", instanceName: this.name, progress: "" });
2823
+ }
2824
+ }
2396
2825
  /**
2397
2826
  * Options for every system-initiated paste (startup notice, session snapshot,
2398
2827
  * runtime-dialog keys).
@@ -2428,12 +2857,130 @@ export class Daemon extends EventEmitter {
2428
2857
  return ""; // skip channel tools
2429
2858
  return name;
2430
2859
  }
2860
+ /**
2861
+ * The one place an inbound message grows its metadata wrapper ([user:]/
2862
+ * [from:] prefix, pending reactions, handoff metadata, reply instructions).
2863
+ * Both the normal queued path (pushChannelMessage) and /steer go through
2864
+ * here — a steered message must read EXACTLY like a queued one to the
2865
+ * agent, or the two drift apart in what the agent is told about replying.
2866
+ */
2867
+ formatInboundMessage(content, meta) {
2868
+ const user = meta.user || "unknown";
2869
+ const fromInstance = meta.from_instance;
2870
+ let formatted;
2871
+ if (fromInstance) {
2872
+ // #77: show the sender's display name for readability, keeping the machine
2873
+ // instance name in parens so the recipient's send_to_instance target is valid.
2874
+ const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
2875
+ formatted = `[from:${fromLabel}] ${content}`;
2876
+ formatted += renderHandoffMetadata(meta);
2877
+ // A delegated task that requires a reply must not read like a chatty FYI —
2878
+ // the "you may stay silent" line is for the latter only.
2879
+ formatted += meta.requires_reply === "true"
2880
+ ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2881
+ : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
2882
+ }
2883
+ else {
2884
+ const via = meta.source ? ` via ${meta.source}` : "";
2885
+ const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
2886
+ formatted = `[user:${user}${via}${idTag}] ${content}`;
2887
+ // Reactions queued since the last real message (#432). One leading line of
2888
+ // context, present only when something is pending — a reaction no longer
2889
+ // costs a turn of its own.
2890
+ if (meta.pending_reactions) {
2891
+ formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2892
+ }
2893
+ formatted += renderHandoffMetadata(meta);
2894
+ formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
2895
+ }
2896
+ if (meta.reply_to_text) {
2897
+ formatted += `\n(reply_to: "${meta.reply_to_text}")`;
2898
+ }
2899
+ return formatted;
2900
+ }
2901
+ /**
2902
+ * /steer: interject into the CURRENT turn instead of queueing for idle.
2903
+ *
2904
+ * Differences from pushChannelMessage, and nothing else:
2905
+ * - serialized on steerLock, not pasteLock — it must overtake, not queue
2906
+ * - deliverMessage runs with { steer: true }, which takes the busy branch
2907
+ * that pastes immediately (the codex native-queue transaction) instead of
2908
+ * waiting for idle. Verification and silent-loss fallback are the ones
2909
+ * that path already has: pane-capture visibility check, then one
2910
+ * idle-gated redelivery — so on a TUI that swallows busy input the steer
2911
+ * degrades to "next message after this turn", never silently vanishes.
2912
+ *
2913
+ * The steered text goes through formatInboundMessage so the agent sees a
2914
+ * normal inbound message, with a steering notice prepended for context.
2915
+ */
2916
+ steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2917
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2918
+ return;
2919
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2920
+ this.pendingWork.recordInbound();
2921
+ this.recordRecentUserMessage(content, meta);
2922
+ const formatted = "[STEERING — mid-task course correction from the user. Fold this into the CURRENT work.]\n"
2923
+ + this.formatInboundMessage(content, meta);
2924
+ const chatId = meta.chat_id;
2925
+ const messageId = meta.message_id;
2926
+ const status = (chatId && messageId)
2927
+ ? { chatId: meta.thread_id || chatId, messageId }
2928
+ : undefined;
2929
+ this.steerLock = this.steerLock.then(async () => {
2930
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2931
+ return;
2932
+ await this.wake();
2933
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2934
+ return;
2935
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2936
+ this.markTurnStarted(meta, formatted);
2937
+ }
2938
+ }).catch(err => {
2939
+ this.logger.warn({ err: err.message }, "steer delivery error");
2940
+ });
2941
+ }
2942
+ /**
2943
+ * /btw: submit a separately-labelled side question immediately while asking
2944
+ * the agent not to change the active task. Claude's native `/btw` opens a
2945
+ * branchAndResume panel whose answer never reaches the channel or transcript,
2946
+ * so channel delivery intentionally uses a normal wrapped inbound instead.
2947
+ */
2948
+ btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2949
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2950
+ return;
2951
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2952
+ this.pendingWork.recordInbound();
2953
+ this.recordRecentUserMessage(content, meta);
2954
+ const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
2955
+ + this.formatInboundMessage(content, meta);
2956
+ const chatId = meta.chat_id;
2957
+ const messageId = meta.message_id;
2958
+ const status = (chatId && messageId)
2959
+ ? { chatId: meta.thread_id || chatId, messageId }
2960
+ : undefined;
2961
+ this.steerLock = this.steerLock.then(async () => {
2962
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2963
+ return;
2964
+ await this.wake();
2965
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2966
+ return;
2967
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2968
+ this.markTurnStarted(meta, formatted);
2969
+ }
2970
+ }).catch(err => {
2971
+ this.logger.warn({ err: err.message }, "btw delivery error");
2972
+ });
2973
+ }
2431
2974
  /**
2432
2975
  * Push an inbound channel message to a specific MCP session.
2433
2976
  * If targetSession is provided, only send to the matching socket.
2434
2977
  * Otherwise send to the instance's own session (this.name).
2435
2978
  */
2436
- pushChannelMessage(content, meta, _targetSession) {
2979
+ pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
2980
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2981
+ this.logger.info("Pending channel delivery dropped by user cancel");
2982
+ return;
2983
+ }
2437
2984
  if (!this.tmux) {
2438
2985
  this.logger.warn("Cannot push channel message: tmux not running");
2439
2986
  return;
@@ -2467,46 +3014,19 @@ export class Daemon extends EventEmitter {
2467
3014
  const rawText = content.slice(5);
2468
3015
  this.logger.info({ user }, "Raw paste from topic mode user");
2469
3016
  this.pasteLock = this.pasteLock.then(async () => {
2470
- if (await this.deliverMessage(rawText))
3017
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3018
+ return;
3019
+ if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
2471
3020
  this.markTurnStarted(meta, rawText);
2472
3021
  }).catch(err => {
2473
3022
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
2474
3023
  });
2475
3024
  return;
2476
3025
  }
2477
- let formatted;
2478
- if (fromInstance) {
2479
- // #77: show the sender's display name for readability, keeping the machine
2480
- // instance name in parens so the recipient's send_to_instance target is valid.
2481
- const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
2482
- formatted = `[from:${fromLabel}] ${content}`;
2483
- formatted += renderHandoffMetadata(meta);
2484
- // A delegated task that requires a reply must not read like a chatty FYI —
2485
- // the "you may stay silent" line is for the latter only.
2486
- formatted += meta.requires_reply === "true"
2487
- ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2488
- : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
2489
- }
2490
- else {
2491
- const via = meta.source ? ` via ${meta.source}` : "";
2492
- const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
2493
- formatted = `[user:${user}${via}${idTag}] ${content}`;
2494
- // Reactions queued since the last real message (#432). One leading line of
2495
- // context, present only when something is pending — a reaction no longer
2496
- // costs a turn of its own.
2497
- if (meta.pending_reactions) {
2498
- formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2499
- }
2500
- formatted += renderHandoffMetadata(meta);
2501
- formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
2502
- }
2503
- if (meta.reply_to_text) {
2504
- formatted += `\n(reply_to: "${meta.reply_to_text}")`;
2505
- }
3026
+ const formatted = this.formatInboundMessage(content, meta);
2506
3027
  // Serialize deliveries: each message waits for the previous to complete,
2507
3028
  // and each waits for the CLI to be idle before pasting. Messages are never
2508
- // dropped for age — a long-busy CLI just queues them until it frees up
2509
- // (the user can press Cancel to interrupt and let the queue drain sooner).
3029
+ // dropped for age — only an explicit user Cancel invalidates queued work.
2510
3030
  const chatId = meta.chat_id;
2511
3031
  const messageId = meta.message_id;
2512
3032
  const wasQueued = this.pasteQueueDepth > 0;
@@ -2519,17 +3039,28 @@ export class Daemon extends EventEmitter {
2519
3039
  }
2520
3040
  this.pasteLock = this.pasteLock.then(async () => {
2521
3041
  try {
3042
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3043
+ this.logger.info("Pending channel delivery dropped by user cancel");
3044
+ return;
3045
+ }
2522
3046
  if (this.config.pre_task_command) {
2523
- await this.deliverMessage(this.config.pre_task_command);
3047
+ await this.deliverMessage(this.config.pre_task_command, undefined, { deliveryEpoch });
3048
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3049
+ return;
2524
3050
  }
2525
3051
  if (this.pendingInstructionsNotice) {
2526
3052
  this.pendingInstructionsNotice = false;
2527
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
3053
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
3054
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3055
+ return;
2528
3056
  }
2529
3057
  const status = (chatId && messageId)
2530
3058
  ? { chatId: meta.thread_id || chatId, messageId }
2531
3059
  : undefined;
2532
- if (await this.deliverMessage(formatted, status))
3060
+ // A fresh delivery begins a fresh turn — its bubble must not inherit
3061
+ // the previous turn's tool list.
3062
+ this.resetToolProgress();
3063
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch }))
2533
3064
  this.markTurnStarted(meta, formatted);
2534
3065
  }
2535
3066
  finally {
@@ -2556,7 +3087,11 @@ export class Daemon extends EventEmitter {
2556
3087
  * immediately and own the application-level queue themselves. The pasteLock remains
2557
3088
  * serial in both cases so separate PTY writes can never overlap.
2558
3089
  */
2559
- async deliverMessage(formatted, status) {
3090
+ async deliverMessage(formatted, status, opts) {
3091
+ const cancelled = () => opts?.deliveryEpoch !== undefined
3092
+ && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
3093
+ if (cancelled())
3094
+ return false;
2560
3095
  // Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
2561
3096
  const fenceCount = (formatted.match(/```/g) || []).length;
2562
3097
  if (fenceCount % 2 !== 0) {
@@ -2565,22 +3100,31 @@ export class Daemon extends EventEmitter {
2565
3100
  }
2566
3101
  // Before anything reads the window id: a spawn in progress is about to change it.
2567
3102
  await this.waitForSpawnToSettle();
3103
+ if (cancelled())
3104
+ return false;
2568
3105
  let windowId = this.getWindowId();
2569
3106
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
2570
3107
  let handingOffToNativeQueue = false;
2571
3108
  // If the CLI is busy, either hand the complete submission to its native input
2572
3109
  // queue or wait for idle. Native queue support is an explicit backend capability:
2573
3110
  // normal Enter input has steering/interrupt semantics in several other CLIs.
2574
- if (windowId && this.controlClient && !this.controlClient.isIdle(windowId)) {
3111
+ if (windowId && this.controlClient && !this.isPaneIdleForDelivery(windowId)) {
2575
3112
  if (status)
2576
3113
  this.emit("message_queued", status);
2577
- if (supportsQueuedInput) {
3114
+ if (supportsQueuedInput || opts?.steer) {
3115
+ // Native queue (codex), or an explicit /steer: hand the complete
3116
+ // paste+Enter transaction to the busy CLI now. For steer this is the
3117
+ // point — the user asked to interject, and the transaction's
3118
+ // visibility check + idle-gated fallback catch TUIs that swallow
3119
+ // busy input (see steerMessage).
2578
3120
  handingOffToNativeQueue = true;
2579
- this.logger.debug("CLI busy — handing message to backend-native input queue");
3121
+ this.logger.debug(opts?.steer ? "CLI busy — steering into the running turn" : "CLI busy — handing message to backend-native input queue");
2580
3122
  }
2581
3123
  else {
2582
3124
  this.logger.debug("CLI busy — queuing message until idle");
2583
- const becameIdle = await this.controlClient.waitUntilIdle(windowId);
3125
+ const becameIdle = await this.waitForPaneIdleForDelivery(windowId);
3126
+ if (cancelled())
3127
+ return false;
2584
3128
  if (!becameIdle) {
2585
3129
  // The pane never freed up. Report the failure instead of pasting into a
2586
3130
  // wedged CLI (where the text would sit unsubmitted and the next message
@@ -2596,7 +3140,54 @@ export class Daemon extends EventEmitter {
2596
3140
  // held under the pane lock — holding it across the idle wait (up to 30 min)
2597
3141
  // would starve the runtime-dialog dismisser, which is often the very thing
2598
3142
  // that would let the pane go idle again.
2599
- return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
3143
+ return this.paneWriteLock.run(async () => {
3144
+ if (cancelled())
3145
+ return false;
3146
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3147
+ });
3148
+ }
3149
+ /**
3150
+ * A raw `%output` silence check is authoritative for ordinary TUIs. It is not
3151
+ * for a backend such as agy whose status-line hook periodically redraws an
3152
+ * identical screen. In that case trust an idle pane-state snapshot only when
3153
+ * it was captured after both the latest output and the latest control-client
3154
+ * observation reset.
3155
+ */
3156
+ isPaneIdleForDelivery(windowId) {
3157
+ if (!this.controlClient)
3158
+ return true;
3159
+ if (this.controlClient.isIdle(windowId))
3160
+ return true;
3161
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true || this.instanceState !== "idle")
3162
+ return false;
3163
+ const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
3164
+ if (lastOutputAt === undefined || !this.instanceStateMachine)
3165
+ return false;
3166
+ const observedAt = this.instanceStateMachine.snapshot().observedAt;
3167
+ return observedAt >= lastOutputAt
3168
+ && observedAt >= this.controlClient.getObservationResetAt();
3169
+ }
3170
+ waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
3171
+ if (!this.controlClient)
3172
+ return Promise.resolve(true);
3173
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true) {
3174
+ return this.controlClient.waitUntilIdle(windowId, timeoutMs);
3175
+ }
3176
+ if (this.isPaneIdleForDelivery(windowId))
3177
+ return Promise.resolve(true);
3178
+ return new Promise(resolve => {
3179
+ const check = setInterval(() => {
3180
+ if (!this.isPaneIdleForDelivery(windowId))
3181
+ return;
3182
+ clearInterval(check);
3183
+ clearTimeout(timer);
3184
+ resolve(true);
3185
+ }, 50);
3186
+ const timer = setTimeout(() => {
3187
+ clearInterval(check);
3188
+ resolve(false);
3189
+ }, timeoutMs);
3190
+ });
2600
3191
  }
2601
3192
  /**
2602
3193
  * The write half of a delivery: paste → Enter → confirm, with retries.
@@ -3229,6 +3820,8 @@ export class Daemon extends EventEmitter {
3229
3820
  });
3230
3821
  }
3231
3822
  const agentPort = parseInt(process.env.AGEND_PORT ?? "19280", 10);
3823
+ const backendName = this.config.backend ?? "claude-code";
3824
+ const backendOptions = this.config.backend_options?.[backendName];
3232
3825
  return {
3233
3826
  workingDirectory: this.config.working_directory,
3234
3827
  instanceDir: this.instanceDir,
@@ -3248,6 +3841,7 @@ export class Daemon extends EventEmitter {
3248
3841
  instructions,
3249
3842
  agentMode: isCliMode ? "cli" : "mcp",
3250
3843
  agentPort: isCliMode ? agentPort : undefined,
3844
+ backendOptions,
3251
3845
  };
3252
3846
  }
3253
3847
  /**
@@ -3611,9 +4205,29 @@ export class Daemon extends EventEmitter {
3611
4205
  // back from statusline.json — that would re-arm --continue and re-loop.
3612
4206
  if (this.skipResume)
3613
4207
  return;
3614
- const sid = this.backend?.getSessionId();
3615
- if (sid) {
3616
- writeFileSync(join(this.instanceDir, "session-id"), sid);
4208
+ try {
4209
+ const sid = this.backend?.getSessionId();
4210
+ if (!sid)
4211
+ return;
4212
+ const path = join(this.instanceDir, "session-id");
4213
+ // Idle observations happen after turns. Avoid needless writes (and mtime
4214
+ // churn) while still updating the marker when /new changes the session.
4215
+ try {
4216
+ if (readFileSync(path, "utf-8").trim() === sid)
4217
+ return;
4218
+ }
4219
+ catch { /* first checkpoint */ }
4220
+ writeFileSync(path, sid);
4221
+ this.sessionCheckpointWarningEmitted = false;
4222
+ this.logger.debug("Session id checkpointed");
4223
+ }
4224
+ catch (err) {
4225
+ // Session discovery is best-effort and must never break the pane state
4226
+ // monitor. Existing stop/pause checkpoints get another chance later.
4227
+ if (!this.sessionCheckpointWarningEmitted) {
4228
+ this.sessionCheckpointWarningEmitted = true;
4229
+ this.logger.warn({ err: err.message }, "Session id checkpoint failed");
4230
+ }
3617
4231
  }
3618
4232
  }
3619
4233
  readContextPercentage() {