@songsid/agend 2.1.4-beta.3 → 2.1.4-beta.30

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 (139) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-endpoint.js +1 -1
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/backend/antigravity.d.ts +9 -5
  6. package/dist/backend/antigravity.js +155 -30
  7. package/dist/backend/antigravity.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +23 -21
  9. package/dist/backend/claude-code.js +47 -24
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/codex.d.ts +17 -0
  12. package/dist/backend/codex.js +122 -7
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +1 -0
  15. package/dist/backend/kiro.js +14 -0
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/opencode.d.ts +27 -11
  18. package/dist/backend/opencode.js +48 -37
  19. package/dist/backend/opencode.js.map +1 -1
  20. package/dist/backend/types.d.ts +14 -0
  21. package/dist/backend/types.js +7 -0
  22. package/dist/backend/types.js.map +1 -1
  23. package/dist/channel/adapters/discord.js +106 -19
  24. package/dist/channel/adapters/discord.js.map +1 -1
  25. package/dist/channel/adapters/telegram.js +5 -3
  26. package/dist/channel/adapters/telegram.js.map +1 -1
  27. package/dist/channel/agy-mcp-launcher.d.ts +2 -0
  28. package/dist/channel/agy-mcp-launcher.js +28 -0
  29. package/dist/channel/agy-mcp-launcher.js.map +1 -0
  30. package/dist/channel/mcp-server.js +6 -20
  31. package/dist/channel/mcp-server.js.map +1 -1
  32. package/dist/channel/mcp-tools.js +4 -2
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/channel/types.d.ts +1 -1
  35. package/dist/classic-channel-manager.d.ts +40 -3
  36. package/dist/classic-channel-manager.js +218 -31
  37. package/dist/classic-channel-manager.js.map +1 -1
  38. package/dist/cli.js +148 -33
  39. package/dist/cli.js.map +1 -1
  40. package/dist/completion-install.d.ts +70 -0
  41. package/dist/completion-install.js +152 -0
  42. package/dist/completion-install.js.map +1 -0
  43. package/dist/completion.d.ts +7 -1
  44. package/dist/completion.js +14 -4
  45. package/dist/completion.js.map +1 -1
  46. package/dist/config-validator.js +38 -7
  47. package/dist/config-validator.js.map +1 -1
  48. package/dist/config.d.ts +2 -0
  49. package/dist/config.js +10 -1
  50. package/dist/config.js.map +1 -1
  51. package/dist/daemon.d.ts +173 -5
  52. package/dist/daemon.js +721 -83
  53. package/dist/daemon.js.map +1 -1
  54. package/dist/doctor.d.ts +26 -0
  55. package/dist/doctor.js +216 -0
  56. package/dist/doctor.js.map +1 -0
  57. package/dist/fleet-context.d.ts +47 -0
  58. package/dist/fleet-manager.d.ts +247 -10
  59. package/dist/fleet-manager.js +1978 -384
  60. package/dist/fleet-manager.js.map +1 -1
  61. package/dist/fleet-yaml-slim.d.ts +10 -0
  62. package/dist/fleet-yaml-slim.js +59 -0
  63. package/dist/fleet-yaml-slim.js.map +1 -0
  64. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  65. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  66. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  67. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  68. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  69. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  70. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  71. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  72. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  73. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  74. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  75. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  76. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  77. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  78. package/dist/instance-lifecycle.d.ts +28 -0
  79. package/dist/instance-lifecycle.js +79 -5
  80. package/dist/instance-lifecycle.js.map +1 -1
  81. package/dist/instructions.d.ts +12 -0
  82. package/dist/instructions.js +35 -1
  83. package/dist/instructions.js.map +1 -1
  84. package/dist/locale.js +746 -288
  85. package/dist/locale.js.map +1 -1
  86. package/dist/login-flows.d.ts +85 -0
  87. package/dist/login-flows.js +159 -0
  88. package/dist/login-flows.js.map +1 -0
  89. package/dist/login-manager.d.ts +63 -0
  90. package/dist/login-manager.js +134 -0
  91. package/dist/login-manager.js.map +1 -0
  92. package/dist/outbound-handlers.d.ts +8 -0
  93. package/dist/outbound-handlers.js +44 -5
  94. package/dist/outbound-handlers.js.map +1 -1
  95. package/dist/outbound-schemas.d.ts +5 -0
  96. package/dist/outbound-schemas.js +7 -0
  97. package/dist/outbound-schemas.js.map +1 -1
  98. package/dist/quickstart.js +25 -0
  99. package/dist/quickstart.js.map +1 -1
  100. package/dist/restart-progress.d.ts +2 -0
  101. package/dist/restart-progress.js +3 -0
  102. package/dist/restart-progress.js.map +1 -1
  103. package/dist/scheduler/db.d.ts +12 -0
  104. package/dist/scheduler/db.js +59 -0
  105. package/dist/scheduler/db.js.map +1 -1
  106. package/dist/service-installer.d.ts +11 -0
  107. package/dist/service-installer.js +84 -18
  108. package/dist/service-installer.js.map +1 -1
  109. package/dist/settings-api.js +1 -1
  110. package/dist/settings-api.js.map +1 -1
  111. package/dist/setup-wizard.js +2 -2
  112. package/dist/setup-wizard.js.map +1 -1
  113. package/dist/tips.d.ts +44 -0
  114. package/dist/tips.js +355 -0
  115. package/dist/tips.js.map +1 -0
  116. package/dist/tmux-manager.d.ts +6 -0
  117. package/dist/tmux-manager.js +12 -0
  118. package/dist/tmux-manager.js.map +1 -1
  119. package/dist/tool-progress.d.ts +40 -0
  120. package/dist/tool-progress.js +289 -0
  121. package/dist/tool-progress.js.map +1 -0
  122. package/dist/topic-commands.d.ts +36 -4
  123. package/dist/topic-commands.js +322 -64
  124. package/dist/topic-commands.js.map +1 -1
  125. package/dist/transcript-monitor.d.ts +15 -2
  126. package/dist/transcript-monitor.js +63 -17
  127. package/dist/transcript-monitor.js.map +1 -1
  128. package/dist/transcript-sources.d.ts +131 -0
  129. package/dist/transcript-sources.js +580 -0
  130. package/dist/transcript-sources.js.map +1 -0
  131. package/dist/types.d.ts +11 -0
  132. package/dist/ui/dashboard.html +55 -32
  133. package/dist/ui/settings.html +120 -51
  134. package/dist/ui/view.html +23 -15
  135. package/dist/usage/usage-api.d.ts +8 -2
  136. package/dist/usage/usage-api.js +24 -3
  137. package/dist/usage/usage-api.js.map +1 -1
  138. package/dist/workflow-templates/default.md +2 -1
  139. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -8,6 +8,9 @@ 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 { LOGIN_FLOWS } from "./login-flows.js";
13
+ import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
11
14
  import { ContextGuardian } from "./context-guardian.js";
12
15
  import { IpcServer } from "./channel/ipc-bridge.js";
13
16
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
@@ -45,6 +48,10 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
45
48
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
46
49
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
47
50
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
51
+ /** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
52
+ export const PERIODIC_REDRAW_PROBE_MS = 25;
53
+ /** A foreground server/port-forward should hand control back or be acknowledged. */
54
+ export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
48
55
  const LAST_INBOUND_FILE = "last-inbound-at";
49
56
  /** Minimum gap between "health check is failing" notifications for one instance. */
50
57
  const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
@@ -157,6 +164,11 @@ export class AutoPauseController {
157
164
  recordActivity(now = Date.now()) {
158
165
  this.lastActivityAt = now;
159
166
  }
167
+ /** Apply a new idle threshold and start a fresh countdown from this update. */
168
+ reconfigure(thresholdMs, now = Date.now()) {
169
+ this.thresholdMs = Math.max(0, thresholdMs);
170
+ this.lastActivityAt = now;
171
+ }
160
172
  observe(state, now = Date.now()) {
161
173
  if (this.pausedAt !== null || this.thresholdMs <= 0)
162
174
  return false;
@@ -279,9 +291,11 @@ export class PaneStateMachine {
279
291
  * working" and a finished turn would not be seen as idle until the next 60s
280
292
  * safety sweep. Output timestamps are the honest liveness signal here, and
281
293
  * the caller has them.
294
+ * @param opts.forceBusy the backend has positively identified a still-running
295
+ * foreground tool even though its TUI keeps an old ready footer visible.
282
296
  */
283
297
  observe(pane, now = Date.now(), opts = {}) {
284
- const { settled = false, changeAt = now } = opts;
298
+ const { settled = false, changeAt = now, forceBusy = false } = opts;
285
299
  const paneHash = createHash("sha256").update(pane).digest("hex");
286
300
  const firstObservation = this.lastPaneHash === null;
287
301
  const paneChanged = this.lastPaneHash !== paneHash;
@@ -290,7 +304,10 @@ export class PaneStateMachine {
290
304
  this.lastPaneChangeAt = changeAt;
291
305
  }
292
306
  this.lastObservedAt = now;
293
- const ready = this.isReady(pane);
307
+ // Some TUIs keep their ready footer visible while a foreground tool owns
308
+ // stdin. Backends can positively identify that tool from the pane; in that
309
+ // case the old prompt must not clear pending work or retire Cancel.
310
+ const ready = !forceBusy && this.isReady(pane);
294
311
  const nextState = firstObservation
295
312
  ? ready ? "idle" : "working"
296
313
  : paneChanged && !settled
@@ -591,6 +608,69 @@ export class InteractivePromptDetector {
591
608
  this.notifiedSignature = null;
592
609
  }
593
610
  }
611
+ const BLOCKING_PROCESS_PATTERNS = [
612
+ // kubectl port-forward (including a backgrounded child whose inherited
613
+ // stdout keeps the parent shell tool open).
614
+ /^\s*(?:Forwarding from\s+(?:127\.0\.0\.1|localhost|\[::1\]):\d+\s+->\s+\d+|Handling connection for\s+\d+)\s*$/im,
615
+ // Common development servers. Keep these line-shaped and require an address
616
+ // or port so prose such as "the server is running" does not arm the detector.
617
+ /^\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,
618
+ ];
619
+ /**
620
+ * Detect a foreground shell tool which has become a long-lived server.
621
+ *
622
+ * Unlike the generic pane-stuck clock, this clock deliberately survives new
623
+ * output: access logs and `Handling connection` lines are exactly why a
624
+ * foreground server can remain blocked forever without ever looking silent.
625
+ * Once a server marker is seen, the still-running tool identity is the stable
626
+ * signal; the marker may scroll out of the 32-row pane.
627
+ */
628
+ export class BlockingProcessDetector {
629
+ graceMs;
630
+ activity = null;
631
+ evidence = null;
632
+ detectedAt = 0;
633
+ notified = false;
634
+ constructor(graceMs = DEFAULT_BLOCKING_PROCESS_GRACE_MS) {
635
+ this.graceMs = graceMs;
636
+ }
637
+ observe(pane, activity, now = Date.now()) {
638
+ const shellActivity = activity && /^(?:shell|bash|exec_command|terminal|command)(?::|$)/i.test(activity);
639
+ if (!shellActivity) {
640
+ this.reset();
641
+ return null;
642
+ }
643
+ if (activity !== this.activity) {
644
+ this.reset();
645
+ this.activity = activity;
646
+ }
647
+ if (!this.evidence) {
648
+ const tail = sanitizePaneTail(pane, 40).join("\n");
649
+ for (const pattern of BLOCKING_PROCESS_PATTERNS) {
650
+ const match = tail.match(pattern);
651
+ if (!match)
652
+ continue;
653
+ this.evidence = match[0].trim().slice(0, 200);
654
+ this.detectedAt = now;
655
+ break;
656
+ }
657
+ }
658
+ if (!this.evidence || this.notified || now - this.detectedAt < this.graceMs)
659
+ return null;
660
+ this.notified = true;
661
+ return {
662
+ activity,
663
+ evidence: this.evidence,
664
+ blockedForMs: now - this.detectedAt,
665
+ };
666
+ }
667
+ reset() {
668
+ this.activity = null;
669
+ this.evidence = null;
670
+ this.detectedAt = 0;
671
+ this.notified = false;
672
+ }
673
+ }
594
674
  export class Daemon extends EventEmitter {
595
675
  name;
596
676
  config;
@@ -655,6 +735,7 @@ export class Daemon extends EventEmitter {
655
735
  /** Fallback safety sweep used only when no shared tmux control client exists. */
656
736
  instanceStateMonitorTimer = null;
657
737
  instanceStateIdleTimer = null;
738
+ instanceStateOutputProbeTimer = null;
658
739
  instanceStateStuckTimer = null;
659
740
  instanceStateOutputListener = null;
660
741
  instanceStateOutputEventName = null;
@@ -665,6 +746,7 @@ export class Daemon extends EventEmitter {
665
746
  instanceStateReadyPattern = null;
666
747
  instanceStateBusyPattern = null;
667
748
  instanceStateMonitorActive = false;
749
+ sessionCheckpointWarningEmitted = false;
668
750
  statePollInFlight = false;
669
751
  // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
670
752
  // Set when an inbound message lands in the CLI; cleared on the idle edge that
@@ -684,6 +766,22 @@ export class Daemon extends EventEmitter {
684
766
  * would silently no-op exactly when we most need to stop feeding the CLI.
685
767
  */
686
768
  pausePending = false;
769
+ /**
770
+ * An auth failure was detected and nothing has resolved it yet.
771
+ *
772
+ * Separate from `pausePending` because it must outlive it: the deferred pause
773
+ * clears `pausePending` as soon as it fires, but if that pause is refused the
774
+ * instance is still auth-broken and must not resume raising alarms.
775
+ */
776
+ authFailureUnresolved = false;
777
+ /** One login-screen auth report per spawn — the screen persists across polls. */
778
+ loginScreenReported = false;
779
+ /**
780
+ * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
781
+ * pause — see the pausePending consumption site for why waiting for idle there
782
+ * is waiting forever.
783
+ */
784
+ pauseAllowStuck = false;
687
785
  pauseWakeState = "active";
688
786
  pauseWakeTransition = null;
689
787
  // Model failover: override model on next spawn when rate-limited
@@ -697,6 +795,12 @@ export class Daemon extends EventEmitter {
697
795
  * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
698
796
  * reach the pane — {@link paneWriteLock} does. */
699
797
  pasteLock = Promise.resolve();
798
+ /**
799
+ * Fleet cancellation epoch. Queue entries retain the epoch they arrived with;
800
+ * advancing it invalidates only work received before the cancel, never a new
801
+ * user message which arrives immediately afterwards.
802
+ */
803
+ deliveryEpoch = 0;
700
804
  /** Mutual exclusion for *every* write into the pane, whichever subsystem it
701
805
  * comes from. See PaneWriteLock for why interleaving is destructive. */
702
806
  paneWriteLock = new PaneWriteLock();
@@ -710,9 +814,21 @@ export class Daemon extends EventEmitter {
710
814
  lastBuiltInstructions = "";
711
815
  pasteQueueDepth = 0;
712
816
  firstDeliveryDelay = new FirstDeliveryDelay();
817
+ /** Orders /steer pastes against each other. Deliberately NOT pasteLock:
818
+ * a steer must not queue behind the normal deliveries it exists to
819
+ * overtake. Pane-level exclusion still comes from paneWriteLock inside
820
+ * deliverMessage, so a steer and a normal delivery can never interleave
821
+ * their PTY writes — the steer just doesn't wait for idle. */
822
+ steerLock = Promise.resolve();
823
+ /** Tool lines shown in the channel processing bubble for the current turn. */
824
+ turnProgress = new ProgressAccumulator();
825
+ lastProgressBroadcast = "";
826
+ progressBroadcastTimer = null;
827
+ lastProgressBroadcastAt = 0;
713
828
  // PTY error pattern monitoring
714
829
  errorMonitorTimer = null;
715
830
  interactivePromptDetector = new InteractivePromptDetector();
831
+ blockingProcessDetector = new BlockingProcessDetector();
716
832
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
717
833
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
718
834
  lastMcpDeathNotifiedAt = 0;
@@ -910,43 +1026,38 @@ export class Daemon extends EventEmitter {
910
1026
  // Fleet manager routed a message to us (topic mode)
911
1027
  const meta = msg.meta;
912
1028
  const targetSession = msg.targetSession;
1029
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
913
1030
  void this.wake().then(() => {
914
- this.pushChannelMessage(msg.content, meta, targetSession);
1031
+ this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
915
1032
  }).catch(err => {
916
1033
  this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
917
1034
  });
918
1035
  }
919
1036
  else if (msg.type === "raw_paste") {
920
1037
  // 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
- }
1038
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch));
1039
+ }
1040
+ else if (msg.type === "config_update") {
1041
+ this.applyConfigUpdate(msg.config);
1042
+ }
1043
+ else if (msg.type === "steer") {
1044
+ const meta = (msg.meta ?? {});
1045
+ this.steerMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
1046
+ }
1047
+ else if (msg.type === "btw") {
1048
+ const meta = (msg.meta ?? {});
1049
+ this.btwMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
931
1050
  }
932
1051
  else if (msg.type === "fleet_schedule_trigger") {
933
1052
  const payload = msg.payload;
934
1053
  const meta = msg.meta;
935
- void this.wake().then(() => this.pushChannelMessage(payload.message, meta)).catch(err => {
1054
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
1055
+ void this.wake().then(() => this.pushChannelMessage(payload.message, meta, undefined, deliveryEpoch)).catch(err => {
936
1056
  this.logger.error({ err: err.message }, "Wake failed for scheduled delivery");
937
1057
  });
938
1058
  }
939
1059
  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
- });
1060
+ void this.respondToInstanceStateQuery(msg, socket);
950
1061
  }
951
1062
  });
952
1063
  // 2. Tmux — ensure session, create window if not alive
@@ -1032,8 +1143,11 @@ export class Daemon extends EventEmitter {
1032
1143
  const outputLog = join(this.instanceDir, "output.log");
1033
1144
  rotateLogIfNeeded(outputLog);
1034
1145
  await this.tmux.pipeOutput(outputLog).catch(() => { });
1035
- // 4. Transcript monitor
1036
- this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
1146
+ // 4. Transcript monitor. claude-code is handled inside the monitor
1147
+ // (statusline transcript); codex/kiro/opencode read their CLI's own
1148
+ // conversation store via a pluggable source. Backends with no known
1149
+ // source stay inert exactly as before.
1150
+ this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
1037
1151
  // 5. Wire transcript events
1038
1152
  const ackIfPending = () => {
1039
1153
  if (!this.pendingAckMessage || !this.adapter)
@@ -1049,6 +1163,7 @@ export class Daemon extends EventEmitter {
1049
1163
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
1050
1164
  this.recordRecentToolActivity(this.summarizeTool(name, input));
1051
1165
  this.publishActivity(this.summarizeTool(name, input));
1166
+ this.recordToolProgress(name, input);
1052
1167
  });
1053
1168
  this.transcriptMonitor.on("tool_result", (name, _output) => {
1054
1169
  this.recordRecentEvent({ type: "tool_result", name });
@@ -1129,9 +1244,13 @@ export class Daemon extends EventEmitter {
1129
1244
  return;
1130
1245
  this.mcpDeathNotifiedForPid = status.pid;
1131
1246
  this.lastMcpDeathNotifiedAt = Date.now();
1132
- const autoRestart = this.config.mcp_auto_restart !== false;
1133
- this.logger.error({ pid: status.pid, autoRestart }, "MCP server process is gone — instance has no agend tools");
1134
- this.emit("mcp_died", { name: this.name, pid: status.pid, autoRestart });
1247
+ // An auth-broken CLI cannot keep an MCP server alive, and restarting it
1248
+ // only loops back to the sign-in screen — hold the auto-restart until a
1249
+ // re-login (which restarts the instance and replaces this daemon anyway).
1250
+ const authSuspected = this.authFailureUnresolved;
1251
+ const autoRestart = this.config.mcp_auto_restart !== false && !authSuspected;
1252
+ this.logger.error({ pid: status.pid, autoRestart, authSuspected }, "MCP server process is gone — instance has no agend tools");
1253
+ this.emit("mcp_died", { name: this.name, pid: status.pid, autoRestart, authSuspected });
1135
1254
  if (autoRestart)
1136
1255
  this.armMcpRestartWhenIdle();
1137
1256
  }
@@ -1357,6 +1476,17 @@ export class Daemon extends EventEmitter {
1357
1476
  catch (err) {
1358
1477
  this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
1359
1478
  }
1479
+ // Keep the loop alive: every health-check exit must either
1480
+ // scheduleNext() or deliberately set healthCheckPaused. This one
1481
+ // did neither, so a recovered instance ran UNMONITORED (while
1482
+ // isHealthCheckEffectivelyPaused still said monitoring was on)
1483
+ // until the next pause→wake cycle or fleet restart. The next tick
1484
+ // does the right thing in both outcomes: a healthy new window
1485
+ // passes the alive check, and a failed recovery hits the dead
1486
+ // window again with backgroundSessionRecoveryAttempted set — so
1487
+ // it falls through to the normal crash handling (crash counting,
1488
+ // backoff, supervision_ended when retries are exhausted).
1489
+ scheduleNext();
1360
1490
  return; // Don't count as crash
1361
1491
  }
1362
1492
  // Already attempted recovery — fall through to normal crash handling
@@ -1573,6 +1703,25 @@ export class Daemon extends EventEmitter {
1573
1703
  // A prompt is not an error and must not enter the PTY recovery gate.
1574
1704
  // Continue scanning real errors in this same snapshot.
1575
1705
  }
1706
+ // If a backend can derive activity from this exact pane, trust its
1707
+ // explicit null (tool completed) instead of falling back to a possibly
1708
+ // stale transcript activity from the previous scan.
1709
+ const paneActivity = this.backend?.getPaneActivity
1710
+ ? this.backend.getPaneActivity(pane)
1711
+ : this.currentActivity;
1712
+ const hasPendingWork = this.pendingWork.hasPendingWork();
1713
+ if (!hasPendingWork)
1714
+ this.blockingProcessDetector.reset();
1715
+ const blockingProcess = hasPendingWork
1716
+ ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
1717
+ : null;
1718
+ if (blockingProcess) {
1719
+ // Reuse the hang-notification bridge: it offers explicit Restart/Wait
1720
+ // choices and, unlike pty_error, does not retire a still-useful Cancel
1721
+ // button merely because a foreground process owns stdin.
1722
+ this.logger.warn(blockingProcess, "Foreground process is blocking the agent input loop");
1723
+ this.hangDetector?.emit("hang", { unchangedForMs: blockingProcess.blockedForMs });
1724
+ }
1576
1725
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
1577
1726
  for (const dialog of dialogs) {
1578
1727
  if (!dialog.pattern.test(pane))
@@ -1645,6 +1794,7 @@ export class Daemon extends EventEmitter {
1645
1794
  if (seen > 0)
1646
1795
  this.lastErrorCount.set(Daemon.errorPatternKey(ep), seen);
1647
1796
  }
1797
+ this.authFailureUnresolved = false;
1648
1798
  this.clearErrorRecoveryGate();
1649
1799
  this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
1650
1800
  this.emit("pty_recovered", { name: this.name, downtime_s: downtime });
@@ -1706,6 +1856,11 @@ export class Daemon extends EventEmitter {
1706
1856
  if (ep.action === "failover")
1707
1857
  this.lastFailoverAt = now;
1708
1858
  const message = this.resolveErrorMessage(pane, ep);
1859
+ // Remember an auth failure for as long as it is unresolved. An auth-broken
1860
+ // CLI cannot finish the turn it is holding, so every other detector in this
1861
+ // scan would otherwise keep reporting the same dead instance forever.
1862
+ if (ep.type === "auth_error")
1863
+ this.authFailureUnresolved = true;
1709
1864
  this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
1710
1865
  this.emit("pty_error", { name: this.name, ...ep, message });
1711
1866
  break; // Only handle first unsuppressed new error per scan
@@ -1749,6 +1904,46 @@ export class Daemon extends EventEmitter {
1749
1904
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
1750
1905
  await this.tmux?.sendSpecialKey(cancelKey);
1751
1906
  }
1907
+ /**
1908
+ * Drop every not-yet-started pane delivery which predates a user cancel.
1909
+ * Promise chains cannot be removed, so entries become generation-checked
1910
+ * no-ops. An entry already past its check is allowed to finish its current PTY
1911
+ * transaction; the interrupt key still cancels the CLI generation.
1912
+ */
1913
+ clearPendingDeliveries(fleetEpoch) {
1914
+ this.deliveryEpoch = fleetEpoch === undefined
1915
+ ? this.deliveryEpoch + 1
1916
+ : Math.max(this.deliveryEpoch, fleetEpoch);
1917
+ this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
1918
+ }
1919
+ captureDeliveryEpoch(value) {
1920
+ const epoch = typeof value === "number" && Number.isSafeInteger(value) && value >= 0
1921
+ ? value
1922
+ : this.deliveryEpoch;
1923
+ if (epoch > this.deliveryEpoch)
1924
+ this.deliveryEpoch = epoch;
1925
+ return epoch;
1926
+ }
1927
+ isDeliveryEpochCurrent(epoch) {
1928
+ return epoch === this.deliveryEpoch;
1929
+ }
1930
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch) {
1931
+ if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
1932
+ return;
1933
+ this.pasteLock = this.pasteLock.then(async () => {
1934
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
1935
+ this.logger.info("Pending raw delivery dropped by user cancel");
1936
+ return;
1937
+ }
1938
+ await this.wake();
1939
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
1940
+ return;
1941
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
1942
+ this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
1943
+ }).catch(err => {
1944
+ this.logger.warn({ err: err.message }, "raw_paste delivery error");
1945
+ });
1946
+ }
1752
1947
  /** Send the backend-specific graceful quit command/key sequence. */
1753
1948
  async sendQuitSequence() {
1754
1949
  if (!this.tmux || !this.backend)
@@ -1850,6 +2045,33 @@ export class Daemon extends EventEmitter {
1850
2045
  stateChangedAt: Date.now(),
1851
2046
  };
1852
2047
  }
2048
+ /**
2049
+ * Answer a FleetManager state query. Most callers only need the cached state,
2050
+ * but lifecycle decisions such as post-reply Cancel retirement need an
2051
+ * authoritative pane observation: after startup the last transition can stay
2052
+ * "working" even though the CLI has since settled without emitting another
2053
+ * control-mode output record.
2054
+ */
2055
+ async respondToInstanceStateQuery(msg, socket) {
2056
+ try {
2057
+ if (msg.refresh === true) {
2058
+ await this.captureAndEvaluateInstanceState("state_query");
2059
+ }
2060
+ const snapshot = this.getInstanceStateSnapshot();
2061
+ this.ipcServer?.send(socket, {
2062
+ type: "instance_state_response",
2063
+ requestId: msg.requestId,
2064
+ instanceName: this.name,
2065
+ ...snapshot,
2066
+ state: this.isPaused ? "paused" : snapshot.state,
2067
+ processStatus: this.processStatus,
2068
+ pausedAt: this.lastPausedAt,
2069
+ });
2070
+ }
2071
+ catch (err) {
2072
+ this.logger.debug({ err: err.message }, "Instance state query failed");
2073
+ }
2074
+ }
1853
2075
  /** Gracefully stop the CLI while keeping its remain-on-exit tmux window. */
1854
2076
  /**
1855
2077
  * Mark the instance to pause as soon as its pane is idle. Used for auth
@@ -1869,7 +2091,14 @@ export class Daemon extends EventEmitter {
1869
2091
  if (this.getPauseWakeState() !== "active")
1870
2092
  return;
1871
2093
  }
1872
- if (this.instanceState !== "idle" || this.pasteQueueDepth > 0) {
2094
+ // `pauseAllowStuck` is consumed here, not merely read: it authorises exactly
2095
+ // one pause from a stuck pane (the auth-deferred one) and must never leak
2096
+ // into an ordinary idle-timeout pause of a busy instance.
2097
+ const allowStuck = this.pauseAllowStuck;
2098
+ this.pauseAllowStuck = false;
2099
+ const pausableState = this.instanceState === "idle"
2100
+ || (allowStuck && this.instanceState === "stuck");
2101
+ if (!pausableState || this.pasteQueueDepth > 0) {
1873
2102
  this.pauseRequested = false;
1874
2103
  return;
1875
2104
  }
@@ -1933,6 +2162,8 @@ export class Daemon extends EventEmitter {
1933
2162
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
1934
2163
  // auth pause — otherwise the instance would pause again the moment it idles.
1935
2164
  this.pausePending = false;
2165
+ this.authFailureUnresolved = false;
2166
+ this.loginScreenReported = false;
1936
2167
  if (this.pauseWakeState === "active")
1937
2168
  return;
1938
2169
  if (this.pauseWakeState === "pausing")
@@ -1993,6 +2224,14 @@ export class Daemon extends EventEmitter {
1993
2224
  applyInstanceStateSnapshot(snapshot, pane) {
1994
2225
  const previous = this.instanceState;
1995
2226
  this.instanceState = snapshot.state;
2227
+ // OpenCode creates its session lazily on the first submitted message.
2228
+ // Waiting until stop/pause to persist that id loses resume state when the
2229
+ // fleet is SIGKILLed or the host reboots.
2230
+ // Idle observations are safe checkpoints; the 60s safety sweep also gives
2231
+ // us a bounded retry if session creation produced no visible state edge.
2232
+ if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
2233
+ this.saveSessionId();
2234
+ }
1996
2235
  // Only a transition back to idle completes pending work. Repeated idle
1997
2236
  // observations between enqueue and paste must not clear a newer inbound.
1998
2237
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -2001,6 +2240,7 @@ export class Daemon extends EventEmitter {
2001
2240
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2002
2241
  // the last tool pinned to the progress line for the rest of the session.
2003
2242
  this.publishActivity(null);
2243
+ this.resetToolProgress();
2004
2244
  // Must run before the mcpRestartPending branch below: the pane text is the
2005
2245
  // only copy of the answer, and the revival restart is about to clear it.
2006
2246
  this.maybeProxyReplyOnTurnEnd(pane);
@@ -2020,8 +2260,13 @@ export class Daemon extends EventEmitter {
2020
2260
  if (snapshot.state !== "idle")
2021
2261
  this.pauseRequested = false;
2022
2262
  // A pause deferred by an auth failure: retry the moment the pane settles.
2023
- if (this.pausePending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
2263
+ // "stuck" counts as settled here, and that is the whole point — an expired
2264
+ // session cannot complete the turn it is holding, so the pane never returns
2265
+ // to idle and waiting for idle meant waiting forever. The instance stayed
2266
+ // live, kept its warm slot, and re-raised a hang alert on every scan.
2267
+ if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
2024
2268
  this.pausePending = false;
2269
+ this.pauseAllowStuck = snapshot.state === "stuck";
2025
2270
  this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
2026
2271
  return;
2027
2272
  }
@@ -2148,6 +2393,25 @@ export class Daemon extends EventEmitter {
2148
2393
  clearTimeout(this.instanceStateIdleTimer);
2149
2394
  this.instanceStateIdleTimer = null;
2150
2395
  }
2396
+ clearInstanceStateOutputProbeTimer() {
2397
+ if (this.instanceStateOutputProbeTimer)
2398
+ clearTimeout(this.instanceStateOutputProbeTimer);
2399
+ this.instanceStateOutputProbeTimer = null;
2400
+ }
2401
+ /**
2402
+ * Periodic status-line hooks can generate `%output` without changing a single
2403
+ * visible cell. Probe the pane once per burst: a changed capture is real work;
2404
+ * an identical ready capture remains idle and emits no state edge.
2405
+ */
2406
+ scheduleInstanceStateOutputProbe() {
2407
+ if (this.instanceStateOutputProbeTimer)
2408
+ return;
2409
+ this.instanceStateOutputProbeTimer = setTimeout(() => {
2410
+ this.instanceStateOutputProbeTimer = null;
2411
+ void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
2412
+ }, PERIODIC_REDRAW_PROBE_MS);
2413
+ this.instanceStateOutputProbeTimer.unref?.();
2414
+ }
2151
2415
  clearInstanceStateStuckTimer() {
2152
2416
  if (this.instanceStateStuckTimer)
2153
2417
  clearTimeout(this.instanceStateStuckTimer);
@@ -2192,6 +2456,8 @@ export class Daemon extends EventEmitter {
2192
2456
  if (this.statePollInFlight) {
2193
2457
  if (reason === "idle_debounce")
2194
2458
  this.scheduleInstanceStateIdleCapture();
2459
+ if (reason === "output_probe")
2460
+ this.scheduleInstanceStateOutputProbe();
2195
2461
  return;
2196
2462
  }
2197
2463
  this.statePollInFlight = true;
@@ -2200,9 +2466,19 @@ export class Daemon extends EventEmitter {
2200
2466
  const pane = await this.tmux.capturePane();
2201
2467
  // Output received while capture-pane was in flight makes this snapshot
2202
2468
  // stale. Its output handler has already armed a new debounce.
2203
- if ((expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2204
- || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt))
2469
+ const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2470
+ || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
2471
+ if (outputMovedDuringCapture) {
2472
+ if (reason === "output_probe") {
2473
+ // A second burst overtook the probe. Do not bless its stale capture as
2474
+ // idle: continuous output is the safer working signal, while the next
2475
+ // quiet probe/debounce can still prove that it was only repainting.
2476
+ this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(this.instanceStateLastOutputAt));
2477
+ this.scheduleInstanceStateIdleCapture();
2478
+ this.scheduleInstanceStateStuckDeadline(this.instanceStateLastOutputAt);
2479
+ }
2205
2480
  return;
2481
+ }
2206
2482
  const observedChangeAt = expectedOutputAt || captureStartedAt;
2207
2483
  // One observation per capture. This used to call observe() twice with the
2208
2484
  // same pane: the second call always saw an unchanged pane, so the
@@ -2211,6 +2487,7 @@ export class Daemon extends EventEmitter {
2211
2487
  // mismatched spinner frame was enough to report idle mid-turn.
2212
2488
  const settled = this.instanceStateLastOutputAt === 0
2213
2489
  || captureStartedAt - this.instanceStateLastOutputAt >= this.instanceStateIdleDebounceMs;
2490
+ const paneActivity = this.backend?.getPaneActivity?.(pane) ?? null;
2214
2491
  // The two times are genuinely different and both matter: the content
2215
2492
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
2216
2493
  // decisions about *now*. The old double-observe expressed that by calling
@@ -2218,6 +2495,7 @@ export class Daemon extends EventEmitter {
2218
2495
  const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
2219
2496
  settled,
2220
2497
  changeAt: observedChangeAt,
2498
+ forceBusy: paneActivity !== null,
2221
2499
  });
2222
2500
  this.applyInstanceStateSnapshot(snapshot, pane);
2223
2501
  // Backends without a transcript feed can still say what they are doing, if
@@ -2225,7 +2503,7 @@ export class Daemon extends EventEmitter {
2225
2503
  // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
2226
2504
  // safety sweep), which matches the progress ticker's own 60s interval.
2227
2505
  if (this.backend?.getPaneActivity) {
2228
- this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
2506
+ this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
2229
2507
  }
2230
2508
  if (snapshot.state === "idle") {
2231
2509
  this.clearInstanceStateStuckTimer();
@@ -2252,6 +2530,14 @@ export class Daemon extends EventEmitter {
2252
2530
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
2253
2531
  return;
2254
2532
  this.instanceStateLastOutputAt = event.at;
2533
+ if (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle") {
2534
+ // Do not emit idle→working solely because agy repainted an identical
2535
+ // footer. The short capture probe below still observes genuine pane
2536
+ // changes promptly and moves the state to working.
2537
+ this.scheduleInstanceStateOutputProbe();
2538
+ this.scheduleInstanceStateIdleCapture();
2539
+ return;
2540
+ }
2255
2541
  const snapshot = this.instanceStateMachine.recordOutput(event.at);
2256
2542
  this.applyInstanceStateSnapshot(snapshot);
2257
2543
  this.scheduleInstanceStateIdleCapture();
@@ -2302,6 +2588,7 @@ export class Daemon extends EventEmitter {
2302
2588
  stopInstanceStateMonitor() {
2303
2589
  this.instanceStateMonitorActive = false;
2304
2590
  this.clearInstanceStateIdleTimer();
2591
+ this.clearInstanceStateOutputProbeTimer();
2305
2592
  this.clearInstanceStateStuckTimer();
2306
2593
  if (this.instanceStateMonitorTimer)
2307
2594
  clearInterval(this.instanceStateMonitorTimer);
@@ -2336,6 +2623,15 @@ export class Daemon extends EventEmitter {
2336
2623
  this.logger.debug(diagnostic, "Suppressing stuck notification without pending work");
2337
2624
  return;
2338
2625
  }
2626
+ // An auth-broken instance IS stuck, and stays stuck: it cannot finish the
2627
+ // turn it is holding, so pendingWork never clears and this fired on every
2628
+ // scan forever. The notification is also useless — it offers Restart/Wait
2629
+ // and asks General for help, but only the user re-logging in fixes this, and
2630
+ // they have already been told exactly that by the auth alert.
2631
+ if (this.authFailureUnresolved) {
2632
+ this.logger.debug(diagnostic, "Suppressing stuck notification — unresolved auth failure (re-login required)");
2633
+ return;
2634
+ }
2339
2635
  this.logger.warn(diagnostic, "Instance pane stuck with pending work");
2340
2636
  this.hangDetector?.emit("hang", { unchangedForMs: snapshot.unchangedForMs });
2341
2637
  }
@@ -2355,6 +2651,7 @@ export class Daemon extends EventEmitter {
2355
2651
  // fire into a stopped daemon).
2356
2652
  this.clearMcpRestartRequest();
2357
2653
  this.interactivePromptDetector.reset();
2654
+ this.blockingProcessDetector.reset();
2358
2655
  this.stopInstanceStateMonitor();
2359
2656
  this.transcriptMonitor?.stop();
2360
2657
  this.guardian?.stop();
@@ -2380,9 +2677,9 @@ export class Daemon extends EventEmitter {
2380
2677
  * Tell the fleet manager what this instance is doing right now, for the live
2381
2678
  * progress line on the cancel button.
2382
2679
  *
2383
- * Purely cosmetic — nothing decides anything from it, so it is fine that only
2384
- * backends with a transcript feed report at all (claude-code today). For the
2385
- * rest the progress line keeps showing just elapsed time.
2680
+ * Primarily cosmetic. The foreground-process detector also uses a current
2681
+ * shell activity as one of several positive signals, but never makes a state
2682
+ * decision from an arbitrary activity label alone.
2386
2683
  *
2387
2684
  * Repeats are dropped: the ticker only edits the channel message when the text
2388
2685
  * changes, and a stream of identical broadcasts would defeat that.
@@ -2398,6 +2695,141 @@ export class Daemon extends EventEmitter {
2398
2695
  activity: next,
2399
2696
  });
2400
2697
  }
2698
+ /** Effective tool_progress level, hardened against junk config values.
2699
+ * Off unless explicitly enabled: an update must never make bubbles start
2700
+ * listing tool activity the operator did not ask to broadcast. */
2701
+ toolProgressLevel() {
2702
+ const raw = this.config.tool_progress;
2703
+ return raw === "standard" || raw === "verbose" ? raw : "off";
2704
+ }
2705
+ /**
2706
+ * Apply a tool-progress setting without restarting the CLI session.
2707
+ *
2708
+ * Drop the current accumulator at the boundary: verbose entries may contain
2709
+ * command previews which must not survive a downgrade to standard/off, and a
2710
+ * newly enabled level should start with a clean, internally consistent list.
2711
+ */
2712
+ updateToolProgress(level) {
2713
+ const previous = this.toolProgressLevel();
2714
+ this.config.tool_progress = level;
2715
+ if (this.toolProgressLevel() !== previous)
2716
+ this.resetToolProgress();
2717
+ }
2718
+ /** Snapshot the configuration actually owned by this live daemon. */
2719
+ getConfigSnapshot() {
2720
+ return structuredClone(this.config);
2721
+ }
2722
+ /**
2723
+ * Apply the whitelisted hot configuration carried by FleetManager IPC.
2724
+ * null removes an optional value; invalid/unlisted fields are ignored rather
2725
+ * than allowing another client on the per-instance socket to mutate cold
2726
+ * process/session settings.
2727
+ */
2728
+ applyConfigUpdate(value) {
2729
+ if (!value || typeof value !== "object" || Array.isArray(value))
2730
+ return;
2731
+ const update = value;
2732
+ if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
2733
+ this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
2734
+ }
2735
+ if (typeof update.mcp_proxy_reply === "boolean")
2736
+ this.config.mcp_proxy_reply = update.mcp_proxy_reply;
2737
+ else if (update.mcp_proxy_reply === null)
2738
+ delete this.config.mcp_proxy_reply;
2739
+ if (typeof update.warm_cap === "number" && Number.isInteger(update.warm_cap) && update.warm_cap >= 0)
2740
+ this.config.warm_cap = update.warm_cap;
2741
+ else if (update.warm_cap === null)
2742
+ delete this.config.warm_cap;
2743
+ const nextAutoPause = typeof update.auto_pause_after === "number"
2744
+ && Number.isFinite(update.auto_pause_after)
2745
+ && update.auto_pause_after >= 0
2746
+ ? update.auto_pause_after
2747
+ : update.auto_pause_after === null ? 0 : undefined;
2748
+ if (nextAutoPause !== undefined) {
2749
+ const previousAutoPause = typeof this.config.auto_pause_after === "number" ? this.config.auto_pause_after : 0;
2750
+ if (update.auto_pause_after === null)
2751
+ delete this.config.auto_pause_after;
2752
+ else
2753
+ this.config.auto_pause_after = nextAutoPause;
2754
+ if (nextAutoPause !== previousAutoPause) {
2755
+ const isGeneral = this.config.general_topic === true || this.name === "general";
2756
+ this.autoPauseController.reconfigure((isGeneral ? 0 : nextAutoPause) * 60_000);
2757
+ }
2758
+ }
2759
+ for (const key of ["display_name", "description"]) {
2760
+ if (typeof update[key] === "string")
2761
+ this.config[key] = update[key];
2762
+ else if (update[key] === null)
2763
+ delete this.config[key];
2764
+ }
2765
+ if (Array.isArray(update.tags) && update.tags.every(tag => typeof tag === "string"))
2766
+ this.config.tags = [...update.tags];
2767
+ else if (update.tags === null)
2768
+ delete this.config.tags;
2769
+ if (["trace", "debug", "info", "warn", "error"].includes(String(update.log_level))) {
2770
+ const level = update.log_level;
2771
+ if (this.config.log_level !== level) {
2772
+ this.config.log_level = level;
2773
+ this.logger.level = level;
2774
+ }
2775
+ }
2776
+ }
2777
+ /**
2778
+ * Accumulate one semantic progress line for the channel bubble and schedule
2779
+ * a coalesced broadcast. Separate from publishActivity on purpose: that one
2780
+ * is the single-line statusline detail (terse, operator-facing), this is the
2781
+ * multi-line channel list (semantic, argument-free at `standard`) — see
2782
+ * tool-progress.ts for why the two labellers must not merge.
2783
+ */
2784
+ recordToolProgress(name, input) {
2785
+ const level = this.toolProgressLevel();
2786
+ if (level === "off")
2787
+ return;
2788
+ if (this.turnProgress.add(summarizeProgress(name, input, level))) {
2789
+ this.scheduleProgressBroadcast();
2790
+ }
2791
+ }
2792
+ /** Coalesce progress broadcasts: at most one per 3s, trailing edge kept. */
2793
+ scheduleProgressBroadcast() {
2794
+ const MIN_INTERVAL_MS = 3_000;
2795
+ const since = Date.now() - this.lastProgressBroadcastAt;
2796
+ if (since >= MIN_INTERVAL_MS) {
2797
+ this.broadcastToolProgress();
2798
+ return;
2799
+ }
2800
+ if (this.progressBroadcastTimer)
2801
+ return;
2802
+ this.progressBroadcastTimer = setTimeout(() => {
2803
+ this.progressBroadcastTimer = null;
2804
+ this.broadcastToolProgress();
2805
+ }, MIN_INTERVAL_MS - since);
2806
+ this.progressBroadcastTimer.unref?.();
2807
+ }
2808
+ broadcastToolProgress() {
2809
+ const rendered = this.turnProgress.render();
2810
+ if (rendered === this.lastProgressBroadcast)
2811
+ return;
2812
+ this.lastProgressBroadcast = rendered;
2813
+ this.lastProgressBroadcastAt = Date.now();
2814
+ this.ipcServer?.broadcast({
2815
+ type: "instance_progress",
2816
+ instanceName: this.name,
2817
+ progress: rendered,
2818
+ });
2819
+ }
2820
+ /** New turn (or turn over): drop the list and tell the fleet to clear it. */
2821
+ resetToolProgress() {
2822
+ if (this.progressBroadcastTimer) {
2823
+ clearTimeout(this.progressBroadcastTimer);
2824
+ this.progressBroadcastTimer = null;
2825
+ }
2826
+ this.turnProgress.reset();
2827
+ if (this.lastProgressBroadcast !== "") {
2828
+ this.lastProgressBroadcast = "";
2829
+ this.lastProgressBroadcastAt = Date.now();
2830
+ this.ipcServer?.broadcast({ type: "instance_progress", instanceName: this.name, progress: "" });
2831
+ }
2832
+ }
2401
2833
  /**
2402
2834
  * Options for every system-initiated paste (startup notice, session snapshot,
2403
2835
  * runtime-dialog keys).
@@ -2433,12 +2865,130 @@ export class Daemon extends EventEmitter {
2433
2865
  return ""; // skip channel tools
2434
2866
  return name;
2435
2867
  }
2868
+ /**
2869
+ * The one place an inbound message grows its metadata wrapper ([user:]/
2870
+ * [from:] prefix, pending reactions, handoff metadata, reply instructions).
2871
+ * Both the normal queued path (pushChannelMessage) and /steer go through
2872
+ * here — a steered message must read EXACTLY like a queued one to the
2873
+ * agent, or the two drift apart in what the agent is told about replying.
2874
+ */
2875
+ formatInboundMessage(content, meta) {
2876
+ const user = meta.user || "unknown";
2877
+ const fromInstance = meta.from_instance;
2878
+ let formatted;
2879
+ if (fromInstance) {
2880
+ // #77: show the sender's display name for readability, keeping the machine
2881
+ // instance name in parens so the recipient's send_to_instance target is valid.
2882
+ const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
2883
+ formatted = `[from:${fromLabel}] ${content}`;
2884
+ formatted += renderHandoffMetadata(meta);
2885
+ // A delegated task that requires a reply must not read like a chatty FYI —
2886
+ // the "you may stay silent" line is for the latter only.
2887
+ formatted += meta.requires_reply === "true"
2888
+ ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2889
+ : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
2890
+ }
2891
+ else {
2892
+ const via = meta.source ? ` via ${meta.source}` : "";
2893
+ const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
2894
+ formatted = `[user:${user}${via}${idTag}] ${content}`;
2895
+ // Reactions queued since the last real message (#432). One leading line of
2896
+ // context, present only when something is pending — a reaction no longer
2897
+ // costs a turn of its own.
2898
+ if (meta.pending_reactions) {
2899
+ formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2900
+ }
2901
+ formatted += renderHandoffMetadata(meta);
2902
+ formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
2903
+ }
2904
+ if (meta.reply_to_text) {
2905
+ formatted += `\n(reply_to: "${meta.reply_to_text}")`;
2906
+ }
2907
+ return formatted;
2908
+ }
2909
+ /**
2910
+ * /steer: interject into the CURRENT turn instead of queueing for idle.
2911
+ *
2912
+ * Differences from pushChannelMessage, and nothing else:
2913
+ * - serialized on steerLock, not pasteLock — it must overtake, not queue
2914
+ * - deliverMessage runs with { steer: true }, which takes the busy branch
2915
+ * that pastes immediately (the codex native-queue transaction) instead of
2916
+ * waiting for idle. Verification and silent-loss fallback are the ones
2917
+ * that path already has: pane-capture visibility check, then one
2918
+ * idle-gated redelivery — so on a TUI that swallows busy input the steer
2919
+ * degrades to "next message after this turn", never silently vanishes.
2920
+ *
2921
+ * The steered text goes through formatInboundMessage so the agent sees a
2922
+ * normal inbound message, with a steering notice prepended for context.
2923
+ */
2924
+ steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2925
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2926
+ return;
2927
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2928
+ this.pendingWork.recordInbound();
2929
+ this.recordRecentUserMessage(content, meta);
2930
+ const formatted = "[STEERING — mid-task course correction from the user. Fold this into the CURRENT work.]\n"
2931
+ + this.formatInboundMessage(content, meta);
2932
+ const chatId = meta.chat_id;
2933
+ const messageId = meta.message_id;
2934
+ const status = (chatId && messageId)
2935
+ ? { chatId: meta.thread_id || chatId, messageId }
2936
+ : undefined;
2937
+ this.steerLock = this.steerLock.then(async () => {
2938
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2939
+ return;
2940
+ await this.wake();
2941
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2942
+ return;
2943
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2944
+ this.markTurnStarted(meta, formatted);
2945
+ }
2946
+ }).catch(err => {
2947
+ this.logger.warn({ err: err.message }, "steer delivery error");
2948
+ });
2949
+ }
2950
+ /**
2951
+ * /btw: submit a separately-labelled side question immediately while asking
2952
+ * the agent not to change the active task. Claude's native `/btw` opens a
2953
+ * branchAndResume panel whose answer never reaches the channel or transcript,
2954
+ * so channel delivery intentionally uses a normal wrapped inbound instead.
2955
+ */
2956
+ btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
2957
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2958
+ return;
2959
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
2960
+ this.pendingWork.recordInbound();
2961
+ this.recordRecentUserMessage(content, meta);
2962
+ const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
2963
+ + this.formatInboundMessage(content, meta);
2964
+ const chatId = meta.chat_id;
2965
+ const messageId = meta.message_id;
2966
+ const status = (chatId && messageId)
2967
+ ? { chatId: meta.thread_id || chatId, messageId }
2968
+ : undefined;
2969
+ this.steerLock = this.steerLock.then(async () => {
2970
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2971
+ return;
2972
+ await this.wake();
2973
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2974
+ return;
2975
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
2976
+ this.markTurnStarted(meta, formatted);
2977
+ }
2978
+ }).catch(err => {
2979
+ this.logger.warn({ err: err.message }, "btw delivery error");
2980
+ });
2981
+ }
2436
2982
  /**
2437
2983
  * Push an inbound channel message to a specific MCP session.
2438
2984
  * If targetSession is provided, only send to the matching socket.
2439
2985
  * Otherwise send to the instance's own session (this.name).
2440
2986
  */
2441
- pushChannelMessage(content, meta, _targetSession) {
2987
+ pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
2988
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2989
+ this.logger.info("Pending channel delivery dropped by user cancel");
2990
+ return;
2991
+ }
2442
2992
  if (!this.tmux) {
2443
2993
  this.logger.warn("Cannot push channel message: tmux not running");
2444
2994
  return;
@@ -2472,46 +3022,19 @@ export class Daemon extends EventEmitter {
2472
3022
  const rawText = content.slice(5);
2473
3023
  this.logger.info({ user }, "Raw paste from topic mode user");
2474
3024
  this.pasteLock = this.pasteLock.then(async () => {
2475
- if (await this.deliverMessage(rawText))
3025
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3026
+ return;
3027
+ if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
2476
3028
  this.markTurnStarted(meta, rawText);
2477
3029
  }).catch(err => {
2478
3030
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
2479
3031
  });
2480
3032
  return;
2481
3033
  }
2482
- let formatted;
2483
- if (fromInstance) {
2484
- // #77: show the sender's display name for readability, keeping the machine
2485
- // instance name in parens so the recipient's send_to_instance target is valid.
2486
- const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
2487
- formatted = `[from:${fromLabel}] ${content}`;
2488
- formatted += renderHandoffMetadata(meta);
2489
- // A delegated task that requires a reply must not read like a chatty FYI —
2490
- // the "you may stay silent" line is for the latter only.
2491
- formatted += meta.requires_reply === "true"
2492
- ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2493
- : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
2494
- }
2495
- else {
2496
- const via = meta.source ? ` via ${meta.source}` : "";
2497
- const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
2498
- formatted = `[user:${user}${via}${idTag}] ${content}`;
2499
- // Reactions queued since the last real message (#432). One leading line of
2500
- // context, present only when something is pending — a reaction no longer
2501
- // costs a turn of its own.
2502
- if (meta.pending_reactions) {
2503
- formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2504
- }
2505
- formatted += renderHandoffMetadata(meta);
2506
- formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
2507
- }
2508
- if (meta.reply_to_text) {
2509
- formatted += `\n(reply_to: "${meta.reply_to_text}")`;
2510
- }
3034
+ const formatted = this.formatInboundMessage(content, meta);
2511
3035
  // Serialize deliveries: each message waits for the previous to complete,
2512
3036
  // and each waits for the CLI to be idle before pasting. Messages are never
2513
- // dropped for age — a long-busy CLI just queues them until it frees up
2514
- // (the user can press Cancel to interrupt and let the queue drain sooner).
3037
+ // dropped for age — only an explicit user Cancel invalidates queued work.
2515
3038
  const chatId = meta.chat_id;
2516
3039
  const messageId = meta.message_id;
2517
3040
  const wasQueued = this.pasteQueueDepth > 0;
@@ -2524,17 +3047,28 @@ export class Daemon extends EventEmitter {
2524
3047
  }
2525
3048
  this.pasteLock = this.pasteLock.then(async () => {
2526
3049
  try {
3050
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3051
+ this.logger.info("Pending channel delivery dropped by user cancel");
3052
+ return;
3053
+ }
2527
3054
  if (this.config.pre_task_command) {
2528
- await this.deliverMessage(this.config.pre_task_command);
3055
+ await this.deliverMessage(this.config.pre_task_command, undefined, { deliveryEpoch });
3056
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3057
+ return;
2529
3058
  }
2530
3059
  if (this.pendingInstructionsNotice) {
2531
3060
  this.pendingInstructionsNotice = false;
2532
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
3061
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
3062
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3063
+ return;
2533
3064
  }
2534
3065
  const status = (chatId && messageId)
2535
3066
  ? { chatId: meta.thread_id || chatId, messageId }
2536
3067
  : undefined;
2537
- if (await this.deliverMessage(formatted, status))
3068
+ // A fresh delivery begins a fresh turn — its bubble must not inherit
3069
+ // the previous turn's tool list.
3070
+ this.resetToolProgress();
3071
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch }))
2538
3072
  this.markTurnStarted(meta, formatted);
2539
3073
  }
2540
3074
  finally {
@@ -2561,7 +3095,11 @@ export class Daemon extends EventEmitter {
2561
3095
  * immediately and own the application-level queue themselves. The pasteLock remains
2562
3096
  * serial in both cases so separate PTY writes can never overlap.
2563
3097
  */
2564
- async deliverMessage(formatted, status) {
3098
+ async deliverMessage(formatted, status, opts) {
3099
+ const cancelled = () => opts?.deliveryEpoch !== undefined
3100
+ && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
3101
+ if (cancelled())
3102
+ return false;
2565
3103
  // Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
2566
3104
  const fenceCount = (formatted.match(/```/g) || []).length;
2567
3105
  if (fenceCount % 2 !== 0) {
@@ -2570,22 +3108,31 @@ export class Daemon extends EventEmitter {
2570
3108
  }
2571
3109
  // Before anything reads the window id: a spawn in progress is about to change it.
2572
3110
  await this.waitForSpawnToSettle();
3111
+ if (cancelled())
3112
+ return false;
2573
3113
  let windowId = this.getWindowId();
2574
3114
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
2575
3115
  let handingOffToNativeQueue = false;
2576
3116
  // If the CLI is busy, either hand the complete submission to its native input
2577
3117
  // queue or wait for idle. Native queue support is an explicit backend capability:
2578
3118
  // normal Enter input has steering/interrupt semantics in several other CLIs.
2579
- if (windowId && this.controlClient && !this.controlClient.isIdle(windowId)) {
3119
+ if (windowId && this.controlClient && !this.isPaneIdleForDelivery(windowId)) {
2580
3120
  if (status)
2581
3121
  this.emit("message_queued", status);
2582
- if (supportsQueuedInput) {
3122
+ if (supportsQueuedInput || opts?.steer) {
3123
+ // Native queue (codex), or an explicit /steer: hand the complete
3124
+ // paste+Enter transaction to the busy CLI now. For steer this is the
3125
+ // point — the user asked to interject, and the transaction's
3126
+ // visibility check + idle-gated fallback catch TUIs that swallow
3127
+ // busy input (see steerMessage).
2583
3128
  handingOffToNativeQueue = true;
2584
- this.logger.debug("CLI busy — handing message to backend-native input queue");
3129
+ this.logger.debug(opts?.steer ? "CLI busy — steering into the running turn" : "CLI busy — handing message to backend-native input queue");
2585
3130
  }
2586
3131
  else {
2587
3132
  this.logger.debug("CLI busy — queuing message until idle");
2588
- const becameIdle = await this.controlClient.waitUntilIdle(windowId);
3133
+ const becameIdle = await this.waitForPaneIdleForDelivery(windowId);
3134
+ if (cancelled())
3135
+ return false;
2589
3136
  if (!becameIdle) {
2590
3137
  // The pane never freed up. Report the failure instead of pasting into a
2591
3138
  // wedged CLI (where the text would sit unsubmitted and the next message
@@ -2601,7 +3148,54 @@ export class Daemon extends EventEmitter {
2601
3148
  // held under the pane lock — holding it across the idle wait (up to 30 min)
2602
3149
  // would starve the runtime-dialog dismisser, which is often the very thing
2603
3150
  // that would let the pane go idle again.
2604
- return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
3151
+ return this.paneWriteLock.run(async () => {
3152
+ if (cancelled())
3153
+ return false;
3154
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3155
+ });
3156
+ }
3157
+ /**
3158
+ * A raw `%output` silence check is authoritative for ordinary TUIs. It is not
3159
+ * for a backend such as agy whose status-line hook periodically redraws an
3160
+ * identical screen. In that case trust an idle pane-state snapshot only when
3161
+ * it was captured after both the latest output and the latest control-client
3162
+ * observation reset.
3163
+ */
3164
+ isPaneIdleForDelivery(windowId) {
3165
+ if (!this.controlClient)
3166
+ return true;
3167
+ if (this.controlClient.isIdle(windowId))
3168
+ return true;
3169
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true || this.instanceState !== "idle")
3170
+ return false;
3171
+ const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
3172
+ if (lastOutputAt === undefined || !this.instanceStateMachine)
3173
+ return false;
3174
+ const observedAt = this.instanceStateMachine.snapshot().observedAt;
3175
+ return observedAt >= lastOutputAt
3176
+ && observedAt >= this.controlClient.getObservationResetAt();
3177
+ }
3178
+ waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
3179
+ if (!this.controlClient)
3180
+ return Promise.resolve(true);
3181
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true) {
3182
+ return this.controlClient.waitUntilIdle(windowId, timeoutMs);
3183
+ }
3184
+ if (this.isPaneIdleForDelivery(windowId))
3185
+ return Promise.resolve(true);
3186
+ return new Promise(resolve => {
3187
+ const check = setInterval(() => {
3188
+ if (!this.isPaneIdleForDelivery(windowId))
3189
+ return;
3190
+ clearInterval(check);
3191
+ clearTimeout(timer);
3192
+ resolve(true);
3193
+ }, 50);
3194
+ const timer = setTimeout(() => {
3195
+ clearInterval(check);
3196
+ resolve(false);
3197
+ }, timeoutMs);
3198
+ });
2605
3199
  }
2606
3200
  /**
2607
3201
  * The write half of a delivery: paste → Enter → confirm, with retries.
@@ -3097,7 +3691,10 @@ export class Daemon extends EventEmitter {
3097
3691
  }
3098
3692
  /** Build config object for the CLI backend */
3099
3693
  buildBackendConfig() {
3100
- const isCliMode = this.config.agent_mode === "cli" || (this.config.agent_mode == null && this.config.backend === "antigravity");
3694
+ // Antigravity 1.1.17 has no per-workspace MCP config, but its global MCP
3695
+ // children inherit the CLI process environment. The backend installs one
3696
+ // environment-selecting launcher, so MCP is now the safe default here too.
3697
+ const isCliMode = this.config.agent_mode === "cli";
3101
3698
  const sockPath = join(this.instanceDir, "channel.sock");
3102
3699
  let serverJs = join(__dirname, "channel", "mcp-server.js");
3103
3700
  if (!existsSync(serverJs)) {
@@ -3234,6 +3831,8 @@ export class Daemon extends EventEmitter {
3234
3831
  });
3235
3832
  }
3236
3833
  const agentPort = parseInt(process.env.AGEND_PORT ?? "19280", 10);
3834
+ const backendName = this.config.backend ?? "claude-code";
3835
+ const backendOptions = this.config.backend_options?.[backendName];
3237
3836
  return {
3238
3837
  workingDirectory: this.config.working_directory,
3239
3838
  instanceDir: this.instanceDir,
@@ -3253,6 +3852,7 @@ export class Daemon extends EventEmitter {
3253
3852
  instructions,
3254
3853
  agentMode: isCliMode ? "cli" : "mcp",
3255
3854
  agentPort: isCliMode ? agentPort : undefined,
3855
+ backendOptions,
3256
3856
  };
3257
3857
  }
3258
3858
  /**
@@ -3580,6 +4180,24 @@ export class Daemon extends EventEmitter {
3580
4180
  // CLI is ready (pattern defined by each backend)
3581
4181
  if (this.backend.getReadyPattern().test(pane))
3582
4182
  return true;
4183
+ // A CLI parked at its own sign-in screen is an AUTH incident: no dialog
4184
+ // key can dismiss it and no ready pattern will ever appear. Report it as
4185
+ // an auth error (the lifecycle double-checks with the token-free probe)
4186
+ // instead of letting it decay into crash/hang/MCP-died noise, and stop
4187
+ // the retry loop — only re-login (see /login) ends this state.
4188
+ const loginScreen = LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenPattern;
4189
+ if (loginScreen?.test(pane) && !this.loginScreenReported) {
4190
+ this.loginScreenReported = true;
4191
+ this.authFailureUnresolved = true;
4192
+ this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
4193
+ this.emit("pty_error", {
4194
+ name: this.name,
4195
+ type: "auth_error",
4196
+ action: "pause",
4197
+ message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
4198
+ });
4199
+ return true;
4200
+ }
3583
4201
  // Fatal: command not found (must match full phrase to avoid false positives
3584
4202
  // like Kiro's "agent X not found, using default")
3585
4203
  if (/command not found|: not found$/m.test(pane))
@@ -3616,9 +4234,29 @@ export class Daemon extends EventEmitter {
3616
4234
  // back from statusline.json — that would re-arm --continue and re-loop.
3617
4235
  if (this.skipResume)
3618
4236
  return;
3619
- const sid = this.backend?.getSessionId();
3620
- if (sid) {
3621
- writeFileSync(join(this.instanceDir, "session-id"), sid);
4237
+ try {
4238
+ const sid = this.backend?.getSessionId();
4239
+ if (!sid)
4240
+ return;
4241
+ const path = join(this.instanceDir, "session-id");
4242
+ // Idle observations happen after turns. Avoid needless writes (and mtime
4243
+ // churn) while still updating the marker when /new changes the session.
4244
+ try {
4245
+ if (readFileSync(path, "utf-8").trim() === sid)
4246
+ return;
4247
+ }
4248
+ catch { /* first checkpoint */ }
4249
+ writeFileSync(path, sid);
4250
+ this.sessionCheckpointWarningEmitted = false;
4251
+ this.logger.debug("Session id checkpointed");
4252
+ }
4253
+ catch (err) {
4254
+ // Session discovery is best-effort and must never break the pane state
4255
+ // monitor. Existing stop/pause checkpoints get another chance later.
4256
+ if (!this.sessionCheckpointWarningEmitted) {
4257
+ this.sessionCheckpointWarningEmitted = true;
4258
+ this.logger.warn({ err: err.message }, "Session id checkpoint failed");
4259
+ }
3622
4260
  }
3623
4261
  }
3624
4262
  readContextPercentage() {