@songsid/agend 2.1.4-beta.5 → 2.1.4-beta.50

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 (175) 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 +12 -8
  6. package/dist/backend/antigravity.js +189 -42
  7. package/dist/backend/antigravity.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +62 -23
  9. package/dist/backend/claude-code.js +296 -43
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/codex.d.ts +18 -0
  12. package/dist/backend/codex.js +148 -8
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/gemini-cli.js +2 -2
  15. package/dist/backend/gemini-cli.js.map +1 -1
  16. package/dist/backend/grok.js +2 -2
  17. package/dist/backend/grok.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +52 -2
  19. package/dist/backend/kiro.js +267 -12
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/opencode.d.ts +27 -11
  22. package/dist/backend/opencode.js +65 -41
  23. package/dist/backend/opencode.js.map +1 -1
  24. package/dist/backend/types.d.ts +97 -3
  25. package/dist/backend/types.js +12 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/backend-outage.d.ts +61 -0
  28. package/dist/backend-outage.js +71 -0
  29. package/dist/backend-outage.js.map +1 -0
  30. package/dist/channel/adapters/discord.d.ts +44 -2
  31. package/dist/channel/adapters/discord.js +526 -78
  32. package/dist/channel/adapters/discord.js.map +1 -1
  33. package/dist/channel/adapters/telegram.d.ts +30 -0
  34. package/dist/channel/adapters/telegram.js +221 -19
  35. package/dist/channel/adapters/telegram.js.map +1 -1
  36. package/dist/channel/agy-mcp-launcher.d.ts +2 -0
  37. package/dist/channel/agy-mcp-launcher.js +28 -0
  38. package/dist/channel/agy-mcp-launcher.js.map +1 -0
  39. package/dist/channel/ipc-bridge.d.ts +2 -1
  40. package/dist/channel/ipc-bridge.js +33 -4
  41. package/dist/channel/ipc-bridge.js.map +1 -1
  42. package/dist/channel/mcp-server.js +28 -25
  43. package/dist/channel/mcp-server.js.map +1 -1
  44. package/dist/channel/mcp-tools.js +4 -2
  45. package/dist/channel/mcp-tools.js.map +1 -1
  46. package/dist/channel/message-queue.js +62 -1
  47. package/dist/channel/message-queue.js.map +1 -1
  48. package/dist/channel/types.d.ts +31 -1
  49. package/dist/classic-channel-manager.d.ts +61 -3
  50. package/dist/classic-channel-manager.js +256 -31
  51. package/dist/classic-channel-manager.js.map +1 -1
  52. package/dist/cli.js +109 -37
  53. package/dist/cli.js.map +1 -1
  54. package/dist/config-validator.js +48 -7
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.d.ts +4 -0
  57. package/dist/config.js +12 -1
  58. package/dist/config.js.map +1 -1
  59. package/dist/cross-instance-envelope.d.ts +6 -0
  60. package/dist/cross-instance-envelope.js +30 -0
  61. package/dist/cross-instance-envelope.js.map +1 -0
  62. package/dist/daemon.d.ts +429 -9
  63. package/dist/daemon.js +1843 -289
  64. package/dist/daemon.js.map +1 -1
  65. package/dist/doctor.d.ts +41 -0
  66. package/dist/doctor.js +267 -0
  67. package/dist/doctor.js.map +1 -0
  68. package/dist/fleet-context.d.ts +54 -0
  69. package/dist/fleet-manager.d.ts +398 -11
  70. package/dist/fleet-manager.js +2756 -499
  71. package/dist/fleet-manager.js.map +1 -1
  72. package/dist/fleet-yaml-slim.d.ts +10 -0
  73. package/dist/fleet-yaml-slim.js +59 -0
  74. package/dist/fleet-yaml-slim.js.map +1 -0
  75. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  76. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  77. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  78. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  79. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  80. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  81. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  82. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  83. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  84. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  85. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  86. package/dist/general-knowledge/skills/session-management/SKILL.md +172 -8
  87. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  88. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  89. package/dist/instance-lifecycle.d.ts +99 -0
  90. package/dist/instance-lifecycle.js +339 -11
  91. package/dist/instance-lifecycle.js.map +1 -1
  92. package/dist/instructions.d.ts +12 -0
  93. package/dist/instructions.js +35 -1
  94. package/dist/instructions.js.map +1 -1
  95. package/dist/locale.js +1004 -288
  96. package/dist/locale.js.map +1 -1
  97. package/dist/login-flows.d.ts +91 -0
  98. package/dist/login-flows.js +170 -0
  99. package/dist/login-flows.js.map +1 -0
  100. package/dist/login-manager.d.ts +63 -0
  101. package/dist/login-manager.js +134 -0
  102. package/dist/login-manager.js.map +1 -0
  103. package/dist/network-family.d.ts +18 -0
  104. package/dist/network-family.js +20 -0
  105. package/dist/network-family.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +8 -0
  107. package/dist/outbound-handlers.js +101 -18
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +7 -1
  110. package/dist/outbound-schemas.js +9 -0
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/pane-input-residue.d.ts +52 -0
  113. package/dist/pane-input-residue.js +107 -0
  114. package/dist/pane-input-residue.js.map +1 -0
  115. package/dist/reply-dedup.d.ts +7 -8
  116. package/dist/reply-dedup.js +0 -0
  117. package/dist/reply-dedup.js.map +1 -1
  118. package/dist/restart-progress.d.ts +2 -0
  119. package/dist/restart-progress.js +3 -0
  120. package/dist/restart-progress.js.map +1 -1
  121. package/dist/scheduler/db.d.ts +12 -0
  122. package/dist/scheduler/db.js +59 -0
  123. package/dist/scheduler/db.js.map +1 -1
  124. package/dist/service-installer.d.ts +11 -0
  125. package/dist/service-installer.js +84 -18
  126. package/dist/service-installer.js.map +1 -1
  127. package/dist/settings-api.js +1 -1
  128. package/dist/settings-api.js.map +1 -1
  129. package/dist/setup-wizard.js +2 -2
  130. package/dist/setup-wizard.js.map +1 -1
  131. package/dist/spawn-gate.d.ts +30 -0
  132. package/dist/spawn-gate.js +79 -0
  133. package/dist/spawn-gate.js.map +1 -0
  134. package/dist/storm-window.d.ts +83 -0
  135. package/dist/storm-window.js +251 -0
  136. package/dist/storm-window.js.map +1 -0
  137. package/dist/tips.d.ts +44 -0
  138. package/dist/tips.js +355 -0
  139. package/dist/tips.js.map +1 -0
  140. package/dist/tmux-manager.d.ts +16 -0
  141. package/dist/tmux-manager.js +110 -25
  142. package/dist/tmux-manager.js.map +1 -1
  143. package/dist/tool-progress.d.ts +40 -0
  144. package/dist/tool-progress.js +289 -0
  145. package/dist/tool-progress.js.map +1 -0
  146. package/dist/topic-commands.d.ts +42 -4
  147. package/dist/topic-commands.js +372 -67
  148. package/dist/topic-commands.js.map +1 -1
  149. package/dist/transcript-monitor.d.ts +15 -2
  150. package/dist/transcript-monitor.js +63 -17
  151. package/dist/transcript-monitor.js.map +1 -1
  152. package/dist/transcript-sources.d.ts +131 -0
  153. package/dist/transcript-sources.js +580 -0
  154. package/dist/transcript-sources.js.map +1 -0
  155. package/dist/types.d.ts +13 -0
  156. package/dist/ui/dashboard.html +55 -32
  157. package/dist/ui/settings.html +200 -60
  158. package/dist/ui/view.html +147 -30
  159. package/dist/usage/format-rich.d.ts +1 -1
  160. package/dist/usage/format-rich.js +28 -24
  161. package/dist/usage/format-rich.js.map +1 -1
  162. package/dist/usage/i18n-keys.d.ts +7 -0
  163. package/dist/usage/i18n-keys.js +34 -0
  164. package/dist/usage/i18n-keys.js.map +1 -0
  165. package/dist/usage/i18n.d.ts +5 -0
  166. package/dist/usage/i18n.js +27 -0
  167. package/dist/usage/i18n.js.map +1 -0
  168. package/dist/usage/providers.d.ts +21 -0
  169. package/dist/usage/providers.js +153 -75
  170. package/dist/usage/providers.js.map +1 -1
  171. package/dist/usage/usage-api.d.ts +11 -3
  172. package/dist/usage/usage-api.js +61 -24
  173. package/dist/usage/usage-api.js.map +1 -1
  174. package/dist/workflow-templates/default.md +2 -1
  175. package/package.json +2 -2
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";
@@ -19,6 +22,8 @@ import { HangDetector } from "./hang-detector.js";
19
22
  import { writeSecretFile } from "./secret-file.js";
20
23
  import { PaneWriteLock } from "./pane-write-lock.js";
21
24
  import { buildFleetInstructions } from "./instructions.js";
25
+ import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
26
+ import { bottomRowIsReady, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
22
27
  const __filename = fileURLToPath(import.meta.url);
23
28
  const __dirname = dirname(__filename);
24
29
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -45,6 +50,10 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
45
50
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
46
51
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
47
52
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
53
+ /** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
54
+ export const PERIODIC_REDRAW_PROBE_MS = 25;
55
+ /** A foreground server/port-forward should hand control back or be acknowledged. */
56
+ export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
48
57
  const LAST_INBOUND_FILE = "last-inbound-at";
49
58
  /** Minimum gap between "health check is failing" notifications for one instance. */
50
59
  const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
@@ -131,19 +140,7 @@ export function writeLastInboundAt(instanceDir, timestamp) {
131
140
  * message_id line.
132
141
  */
133
142
  export function renderHandoffMetadata(meta) {
134
- const rows = [];
135
- const add = (label, value) => {
136
- if (value && value.trim())
137
- rows.push(`${label}: ${value.trim()}`);
138
- };
139
- add("message_id", meta.message_id);
140
- add("correlation_id", meta.correlation_id);
141
- add("request_kind", meta.request_kind);
142
- add("task_summary", meta.task_summary);
143
- add("working_directory", meta.working_directory);
144
- add("branch", meta.branch);
145
- add("attachment_file_id", meta.attachment_file_id);
146
- return rows.length ? `\n(${rows.join(" | ")})` : "";
143
+ return renderCrossInstanceHandoffMetadata(meta);
147
144
  }
148
145
  /** Headless inactivity timer used by the daemon and unit tests. */
149
146
  export class AutoPauseController {
@@ -157,6 +154,11 @@ export class AutoPauseController {
157
154
  recordActivity(now = Date.now()) {
158
155
  this.lastActivityAt = now;
159
156
  }
157
+ /** Apply a new idle threshold and start a fresh countdown from this update. */
158
+ reconfigure(thresholdMs, now = Date.now()) {
159
+ this.thresholdMs = Math.max(0, thresholdMs);
160
+ this.lastActivityAt = now;
161
+ }
160
162
  observe(state, now = Date.now()) {
161
163
  if (this.pausedAt !== null || this.thresholdMs <= 0)
162
164
  return false;
@@ -279,9 +281,11 @@ export class PaneStateMachine {
279
281
  * working" and a finished turn would not be seen as idle until the next 60s
280
282
  * safety sweep. Output timestamps are the honest liveness signal here, and
281
283
  * the caller has them.
284
+ * @param opts.forceBusy the backend has positively identified a still-running
285
+ * foreground tool even though its TUI keeps an old ready footer visible.
282
286
  */
283
287
  observe(pane, now = Date.now(), opts = {}) {
284
- const { settled = false, changeAt = now } = opts;
288
+ const { settled = false, changeAt = now, forceBusy = false } = opts;
285
289
  const paneHash = createHash("sha256").update(pane).digest("hex");
286
290
  const firstObservation = this.lastPaneHash === null;
287
291
  const paneChanged = this.lastPaneHash !== paneHash;
@@ -290,7 +294,10 @@ export class PaneStateMachine {
290
294
  this.lastPaneChangeAt = changeAt;
291
295
  }
292
296
  this.lastObservedAt = now;
293
- const ready = this.isReady(pane);
297
+ // Some TUIs keep their ready footer visible while a foreground tool owns
298
+ // stdin. Backends can positively identify that tool from the pane; in that
299
+ // case the old prompt must not clear pending work or retire Cancel.
300
+ const ready = !forceBusy && this.isReady(pane);
294
301
  const nextState = firstObservation
295
302
  ? ready ? "idle" : "working"
296
303
  : paneChanged && !settled
@@ -352,6 +359,24 @@ export class PendingWorkTracker {
352
359
  }
353
360
  }
354
361
  const NORMAL_ENTER_SETTLE_MS = 500;
362
+ /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
363
+ const BOTTOM_READY_POLL_MS = 250;
364
+ /**
365
+ * Startup failed because the CLI's backend is unreachable (see backend-outage.ts).
366
+ * The session-id is deliberately KEPT; the fleet schedules a delayed retry.
367
+ */
368
+ export class BackendUnreachableStartupError extends Error {
369
+ backend;
370
+ constructor(backend) {
371
+ super(`CLI failed to start: the ${backend} backend is unreachable — session kept for a later retry`);
372
+ this.backend = backend;
373
+ this.name = "BackendUnreachableStartupError";
374
+ }
375
+ }
376
+ /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
377
+ const STRANDED_RETRY_READY_WAIT_MS = 30_000;
378
+ /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
379
+ const STRANDED_INPUT_MAX_ROUNDS = 3;
355
380
  const FIRST_ENTER_SETTLE_MS = 1_750;
356
381
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
357
382
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
@@ -529,6 +554,66 @@ export function extractProxyReplyText(pane, opts = {}) {
529
554
  // The end of the turn is the answer — when over budget, keep the tail.
530
555
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
531
556
  }
557
+ /**
558
+ * Find the narrow Claude XML failure signature reported in #648.
559
+ *
560
+ * This intentionally runs only at the daemon's busy→idle edge (the caller
561
+ * enforces that) and only scans output after the current inbound marker. A
562
+ * partial tool call while Claude is still streaming is therefore never acted
563
+ * on. The extracted argument goes through the same credential redaction as the
564
+ * dead-MCP pane proxy before it can leave the host.
565
+ */
566
+ export function detectMalformedClaudeToolCall(pane, opts = {}) {
567
+ let clean = pane
568
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
569
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
570
+ const marker = opts.inboundMarker?.trim();
571
+ if (marker) {
572
+ const markerAt = clean.lastIndexOf(marker);
573
+ if (markerAt >= 0)
574
+ clean = clean.slice(markerAt + marker.length);
575
+ }
576
+ const lines = clean.split(/\r?\n/).slice(-(opts.maxLines ?? 120));
577
+ const tail = lines.join("\n");
578
+ const closingTags = [...tail.matchAll(/<\/(?:parameter|invoke|function_calls)>/gi)];
579
+ if (closingTags.length === 0)
580
+ return null;
581
+ const parameterOpen = /<parameter\b[^>]*\bname\s*=\s*["']text["'][^>]*>/gi;
582
+ const parameterOpens = [...tail.matchAll(parameterOpen)];
583
+ const open = parameterOpens.at(-1);
584
+ if (!open || open.index === undefined) {
585
+ // Base stale suppression on the XML artifact itself, not unrelated pane
586
+ // redraws appended after it. The occurrence counts still distinguish a
587
+ // genuinely new identical malformed call while both remain visible.
588
+ const signature = createHash("sha256")
589
+ .update(`0:${closingTags.length}:${closingTags.map(tag => tag[0].toLowerCase()).join(":")}`)
590
+ .digest("hex");
591
+ return { signature, text: null };
592
+ }
593
+ const contentAt = open.index + open[0].length;
594
+ const closeAt = tail.slice(contentAt).search(/<\/parameter>/i);
595
+ if (closeAt < 0) {
596
+ const signature = createHash("sha256")
597
+ .update(`${parameterOpens.length}:${closingTags.length}:${open[0]}`)
598
+ .digest("hex");
599
+ return { signature, text: null };
600
+ }
601
+ const fragment = tail.slice(contentAt, contentAt + closeAt);
602
+ // Fingerprint only the malformed XML artifact; never put its contents in logs.
603
+ const signature = createHash("sha256")
604
+ .update(`${parameterOpens.length}:${closingTags.length}:${open[0]}${fragment}</parameter>`)
605
+ .digest("hex");
606
+ let text = sanitizePaneTail(fragment, opts.maxLines ?? 120)
607
+ .join("\n")
608
+ .replace(/\n{3,}/g, "\n\n")
609
+ .trim();
610
+ if (text.replace(/[^\p{L}\p{N}]/gu, "").length < 2)
611
+ return { signature, text: null };
612
+ const maxChars = opts.maxChars ?? 12_000;
613
+ if (text.length > maxChars)
614
+ text = text.slice(0, maxChars);
615
+ return { signature, text };
616
+ }
532
617
  const INTERACTIVE_PROMPT_PATTERNS = [
533
618
  { kind: "sudo_password", pattern: /^\s*\[sudo\]\s+password\s+for\s+[^:\n]+:\s*$/im },
534
619
  // macOS sudo and several package installers use this exact no-echo prompt.
@@ -591,6 +676,69 @@ export class InteractivePromptDetector {
591
676
  this.notifiedSignature = null;
592
677
  }
593
678
  }
679
+ const BLOCKING_PROCESS_PATTERNS = [
680
+ // kubectl port-forward (including a backgrounded child whose inherited
681
+ // stdout keeps the parent shell tool open).
682
+ /^\s*(?:Forwarding from\s+(?:127\.0\.0\.1|localhost|\[::1\]):\d+\s+->\s+\d+|Handling connection for\s+\d+)\s*$/im,
683
+ // Common development servers. Keep these line-shaped and require an address
684
+ // or port so prose such as "the server is running" does not arm the detector.
685
+ /^\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,
686
+ ];
687
+ /**
688
+ * Detect a foreground shell tool which has become a long-lived server.
689
+ *
690
+ * Unlike the generic pane-stuck clock, this clock deliberately survives new
691
+ * output: access logs and `Handling connection` lines are exactly why a
692
+ * foreground server can remain blocked forever without ever looking silent.
693
+ * Once a server marker is seen, the still-running tool identity is the stable
694
+ * signal; the marker may scroll out of the 32-row pane.
695
+ */
696
+ export class BlockingProcessDetector {
697
+ graceMs;
698
+ activity = null;
699
+ evidence = null;
700
+ detectedAt = 0;
701
+ notified = false;
702
+ constructor(graceMs = DEFAULT_BLOCKING_PROCESS_GRACE_MS) {
703
+ this.graceMs = graceMs;
704
+ }
705
+ observe(pane, activity, now = Date.now()) {
706
+ const shellActivity = activity && /^(?:shell|bash|exec_command|terminal|command)(?::|$)/i.test(activity);
707
+ if (!shellActivity) {
708
+ this.reset();
709
+ return null;
710
+ }
711
+ if (activity !== this.activity) {
712
+ this.reset();
713
+ this.activity = activity;
714
+ }
715
+ if (!this.evidence) {
716
+ const tail = sanitizePaneTail(pane, 40).join("\n");
717
+ for (const pattern of BLOCKING_PROCESS_PATTERNS) {
718
+ const match = tail.match(pattern);
719
+ if (!match)
720
+ continue;
721
+ this.evidence = match[0].trim().slice(0, 200);
722
+ this.detectedAt = now;
723
+ break;
724
+ }
725
+ }
726
+ if (!this.evidence || this.notified || now - this.detectedAt < this.graceMs)
727
+ return null;
728
+ this.notified = true;
729
+ return {
730
+ activity,
731
+ evidence: this.evidence,
732
+ blockedForMs: now - this.detectedAt,
733
+ };
734
+ }
735
+ reset() {
736
+ this.activity = null;
737
+ this.evidence = null;
738
+ this.detectedAt = 0;
739
+ this.notified = false;
740
+ }
741
+ }
594
742
  export class Daemon extends EventEmitter {
595
743
  name;
596
744
  config;
@@ -599,6 +747,9 @@ export class Daemon extends EventEmitter {
599
747
  backend;
600
748
  controlClient;
601
749
  runtimeIdentity;
750
+ spawnGate;
751
+ stormWindow;
752
+ backendOutage;
602
753
  logger;
603
754
  tmuxSessionName;
604
755
  tmux = null;
@@ -608,6 +759,13 @@ export class Daemon extends EventEmitter {
608
759
  guardian = null;
609
760
  adapter = null;
610
761
  pendingIpcRequests = new Map();
762
+ /**
763
+ * Daemon-global request sequence. MCP request ids are only process-local:
764
+ * Claude can have more than one MCP child connected to the same daemon and
765
+ * each starts at 1, so using requestId alone lets one caller steal another's
766
+ * response.
767
+ */
768
+ fleetRequestSeq = 0;
611
769
  // Track chatId/threadId from inbound messages for automatic outbound routing
612
770
  lastChatId;
613
771
  lastThreadId;
@@ -618,10 +776,8 @@ export class Daemon extends EventEmitter {
618
776
  currentActivity = null;
619
777
  // Session identity: map IPC socket → sessionName (from mcp_ready)
620
778
  socketSessionNames = new Map();
621
- // Crash recovery
622
- static tmuxServerCrashTimestamps = [];
623
- static tmuxServerPaused = false;
624
- static tmuxServerRecoveryTimer = null;
779
+ // Crash recovery is fleet-owned; all daemon objects share the coordinators
780
+ // injected by FleetManager below.
625
781
  healthCheckTimer = null;
626
782
  crashCount = 0;
627
783
  lastCrashAt = 0;
@@ -642,6 +798,7 @@ export class Daemon extends EventEmitter {
642
798
  resolveSpawnSettled = null;
643
799
  spawnDepth = 0;
644
800
  skipResume = false;
801
+ startupAborted = false;
645
802
  backgroundSessionRecoveryAttempted = false;
646
803
  /** Whether the last spawn started a fresh session (not resumed). */
647
804
  isNewSession = false;
@@ -655,6 +812,7 @@ export class Daemon extends EventEmitter {
655
812
  /** Fallback safety sweep used only when no shared tmux control client exists. */
656
813
  instanceStateMonitorTimer = null;
657
814
  instanceStateIdleTimer = null;
815
+ instanceStateOutputProbeTimer = null;
658
816
  instanceStateStuckTimer = null;
659
817
  instanceStateOutputListener = null;
660
818
  instanceStateOutputEventName = null;
@@ -665,6 +823,7 @@ export class Daemon extends EventEmitter {
665
823
  instanceStateReadyPattern = null;
666
824
  instanceStateBusyPattern = null;
667
825
  instanceStateMonitorActive = false;
826
+ sessionCheckpointWarningEmitted = false;
668
827
  statePollInFlight = false;
669
828
  // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
670
829
  // Set when an inbound message lands in the CLI; cleared on the idle edge that
@@ -672,8 +831,12 @@ export class Daemon extends EventEmitter {
672
831
  // between, the agent's answer exists only on screen — the daemon relays it.
673
832
  turnHadInbound = false;
674
833
  turnOutboundDelivered = false;
834
+ /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
835
+ turnReplyDelivered = false;
675
836
  turnCorrelationId;
676
837
  turnInboundMarker;
838
+ /** Prevent a visible stale XML fragment from being recovered on later turns. */
839
+ lastMalformedToolCallSignature;
677
840
  proxyReplySeq = 0;
678
841
  autoPauseController;
679
842
  pauseRequested = false;
@@ -684,6 +847,35 @@ export class Daemon extends EventEmitter {
684
847
  * would silently no-op exactly when we most need to stop feeding the CLI.
685
848
  */
686
849
  pausePending = false;
850
+ /**
851
+ * An auth failure was detected and nothing has resolved it yet.
852
+ *
853
+ * Separate from `pausePending` because it must outlive it: the deferred pause
854
+ * clears `pausePending` as soon as it fires, but if that pause is refused the
855
+ * instance is still auth-broken and must not resume raising alarms.
856
+ */
857
+ authFailureUnresolved = false;
858
+ /** One login-screen auth report per spawn — the screen persists across polls. */
859
+ loginScreenReported = false;
860
+ /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
861
+ fatalStartupReported = false;
862
+ /**
863
+ * The CLI is parked on a fatal startup screen and every pane write must be
864
+ * refused. This is deliberately separate from the pause that the reported
865
+ * pty_error triggers: the pause is applied by an async handler, and a queued
866
+ * delivery would race it into the modal (where paste+Enter can confirm
867
+ * "Exit and fix manually"). Set synchronously at dialog-match time; cleared
868
+ * when a startup scan reaches a real ready prompt, or on wake().
869
+ */
870
+ fatalStartupBlocked = false;
871
+ /** model_error seen mid-turn: notify only if it survives to the idle screen. */
872
+ pendingModelErrorKey = null;
873
+ /**
874
+ * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
875
+ * pause — see the pausePending consumption site for why waiting for idle there
876
+ * is waiting forever.
877
+ */
878
+ pauseAllowStuck = false;
687
879
  pauseWakeState = "active";
688
880
  pauseWakeTransition = null;
689
881
  // Model failover: override model on next spawn when rate-limited
@@ -697,6 +889,12 @@ export class Daemon extends EventEmitter {
697
889
  * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
698
890
  * reach the pane — {@link paneWriteLock} does. */
699
891
  pasteLock = Promise.resolve();
892
+ /**
893
+ * Fleet cancellation epoch. Queue entries retain the epoch they arrived with;
894
+ * advancing it invalidates only work received before the cancel, never a new
895
+ * user message which arrives immediately afterwards.
896
+ */
897
+ deliveryEpoch = 0;
700
898
  /** Mutual exclusion for *every* write into the pane, whichever subsystem it
701
899
  * comes from. See PaneWriteLock for why interleaving is destructive. */
702
900
  paneWriteLock = new PaneWriteLock();
@@ -710,9 +908,21 @@ export class Daemon extends EventEmitter {
710
908
  lastBuiltInstructions = "";
711
909
  pasteQueueDepth = 0;
712
910
  firstDeliveryDelay = new FirstDeliveryDelay();
911
+ /** Orders /steer pastes against each other. Deliberately NOT pasteLock:
912
+ * a steer must not queue behind the normal deliveries it exists to
913
+ * overtake. Pane-level exclusion still comes from paneWriteLock inside
914
+ * deliverMessage, so a steer and a normal delivery can never interleave
915
+ * their PTY writes — the steer just doesn't wait for idle. */
916
+ steerLock = Promise.resolve();
917
+ /** Tool lines shown in the channel processing bubble for the current turn. */
918
+ turnProgress = new ProgressAccumulator();
919
+ lastProgressBroadcast = "";
920
+ progressBroadcastTimer = null;
921
+ lastProgressBroadcastAt = 0;
713
922
  // PTY error pattern monitoring
714
923
  errorMonitorTimer = null;
715
924
  interactivePromptDetector = new InteractivePromptDetector();
925
+ blockingProcessDetector = new BlockingProcessDetector();
716
926
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
717
927
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
718
928
  lastMcpDeathNotifiedAt = 0;
@@ -724,6 +934,16 @@ export class Daemon extends EventEmitter {
724
934
  * past this point interrupting the turn costs less than staying mute.
725
935
  */
726
936
  static MCP_RESTART_MAX_WAIT_MS = 30 * 60_000;
937
+ /**
938
+ * How long the revival restart waits, at the point of firing, for a
939
+ * CLI-respawned MCP server to announce itself.
940
+ *
941
+ * Codex <0.146 tears down and recreates ALL MCP connections on an auth/config
942
+ * change — healthy ones included — so the daemon sees the old pid die while
943
+ * the CLI is fine and a replacement is seconds away. Module load measured
944
+ * ~0.36s, so 2s is roughly 5x headroom without making a genuine loss wait.
945
+ */
946
+ static MCP_REPLACEMENT_GRACE_MS = 2_000;
727
947
  /**
728
948
  * Sticky "restart to revive the MCP server as soon as the pane idles" — same
729
949
  * shape as pausePending, because the death is usually detected mid-turn when
@@ -732,6 +952,25 @@ export class Daemon extends EventEmitter {
732
952
  */
733
953
  mcpRestartPending = false;
734
954
  mcpRestartStaleTimer = null;
955
+ /** In-flight replacement grace; also the guard against three triggers stacking. */
956
+ mcpRestartGraceTimer = null;
957
+ /**
958
+ * Which trigger the in-flight grace will commit under.
959
+ *
960
+ * The single-timer guard would otherwise SWALLOW a stale_timeout that lands
961
+ * during an idle-gated grace — and because the stale timer is one-shot and
962
+ * already cleared itself, the 30-minute backstop would be gone for good on an
963
+ * instance that never idles, which is the exact case it exists for.
964
+ */
965
+ mcpRestartGraceTrigger = null;
966
+ /**
967
+ * A dead-MCP sighting held back once, pending a possible replacement.
968
+ *
969
+ * Deliberately NOT the same field as `mcpDeathNotifiedForPid`: deferring must
970
+ * not consume the once-per-pid budget, or a suspicion that turns out to be a
971
+ * real loss could never be reported at all.
972
+ */
973
+ mcpDeathDeferredForPid = null;
735
974
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
736
975
  runtimeMonitorsFrozen = false;
737
976
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
@@ -740,7 +979,7 @@ export class Daemon extends EventEmitter {
740
979
  activeErrorPatternKey = null;
741
980
  /** Whether this instance is in an abnormal error state (auto-pause is normal). */
742
981
  get isErrorState() {
743
- return this.errorWaitingForRecovery || (this.healthCheckPaused && !this.isPaused) || Daemon.tmuxServerPaused;
982
+ return this.errorWaitingForRecovery || (this.healthCheckPaused && !this.isPaused) || this.stormWindow?.isActive() === true;
744
983
  }
745
984
  get isPaused() { return this.pauseWakeState !== "active"; }
746
985
  /** Current CLI process state for status surfaces and recovery commands. */
@@ -780,7 +1019,9 @@ export class Daemon extends EventEmitter {
780
1019
  this.errorRecoveryDeadlineAt = 0;
781
1020
  this.activeErrorPatternKey = null;
782
1021
  }
783
- constructor(name, config, instanceDir, topicMode = false, backend, controlClient, rootLogger, runtimeIdentity) {
1022
+ constructor(name, config, instanceDir, topicMode = false, backend, controlClient, rootLogger, runtimeIdentity, spawnGate, stormWindow,
1023
+ /** Fleet-level "is this CLI's backend down?" memory — see backend-outage.ts. */
1024
+ backendOutage) {
784
1025
  super();
785
1026
  this.name = name;
786
1027
  this.config = config;
@@ -789,6 +1030,9 @@ export class Daemon extends EventEmitter {
789
1030
  this.backend = backend;
790
1031
  this.controlClient = controlClient;
791
1032
  this.runtimeIdentity = runtimeIdentity;
1033
+ this.spawnGate = spawnGate;
1034
+ this.stormWindow = stormWindow;
1035
+ this.backendOutage = backendOutage;
792
1036
  if (!rootLogger)
793
1037
  throw new Error("Daemon requires a shared root logger");
794
1038
  this.runtimeIdentity ??= {
@@ -877,20 +1121,7 @@ export class Daemon extends EventEmitter {
877
1121
  this.handleToolCall(msg, socket);
878
1122
  }
879
1123
  else if (msg.type === "mcp_ready") {
880
- const sessionName = msg.sessionName;
881
- if (sessionName) {
882
- this.socketSessionNames.set(socket, sessionName);
883
- socket.on("close", () => {
884
- this.socketSessionNames.delete(socket);
885
- // Notify fleet manager so it can clean up sessionRegistry
886
- if (sessionName !== this.name) {
887
- this.ipcServer?.broadcast({ type: "session_disconnected", sessionName });
888
- }
889
- });
890
- }
891
- this.logger.debug({ sessionName }, "MCP channel server connected and ready");
892
- // Notify FleetManager's IPC client that MCP is ready
893
- this.ipcServer?.broadcast({ type: "mcp_ready", sessionName });
1124
+ this.handleMcpReady(msg, socket);
894
1125
  }
895
1126
  else if (msg.type === "query_sessions") {
896
1127
  // Fleet manager asks for all registered session names (catches sessions
@@ -910,43 +1141,38 @@ export class Daemon extends EventEmitter {
910
1141
  // Fleet manager routed a message to us (topic mode)
911
1142
  const meta = msg.meta;
912
1143
  const targetSession = msg.targetSession;
1144
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
913
1145
  void this.wake().then(() => {
914
- this.pushChannelMessage(msg.content, meta, targetSession);
1146
+ this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
915
1147
  }).catch(err => {
916
1148
  this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
917
1149
  });
918
1150
  }
919
1151
  else if (msg.type === "raw_paste") {
920
1152
  // 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
- }
1153
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true);
1154
+ }
1155
+ else if (msg.type === "config_update") {
1156
+ this.applyConfigUpdate(msg.config);
1157
+ }
1158
+ else if (msg.type === "steer") {
1159
+ const meta = (msg.meta ?? {});
1160
+ this.steerMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
1161
+ }
1162
+ else if (msg.type === "btw") {
1163
+ const meta = (msg.meta ?? {});
1164
+ this.btwMessage(msg.content, meta, this.captureDeliveryEpoch(msg.delivery_epoch));
931
1165
  }
932
1166
  else if (msg.type === "fleet_schedule_trigger") {
933
1167
  const payload = msg.payload;
934
1168
  const meta = msg.meta;
935
- void this.wake().then(() => this.pushChannelMessage(payload.message, meta)).catch(err => {
1169
+ const deliveryEpoch = this.captureDeliveryEpoch(msg.delivery_epoch);
1170
+ void this.wake().then(() => this.pushChannelMessage(payload.message, meta, undefined, deliveryEpoch)).catch(err => {
936
1171
  this.logger.error({ err: err.message }, "Wake failed for scheduled delivery");
937
1172
  });
938
1173
  }
939
1174
  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
- });
1175
+ void this.respondToInstanceStateQuery(msg, socket);
950
1176
  }
951
1177
  });
952
1178
  // 2. Tmux — ensure session, create window if not alive
@@ -1032,8 +1258,11 @@ export class Daemon extends EventEmitter {
1032
1258
  const outputLog = join(this.instanceDir, "output.log");
1033
1259
  rotateLogIfNeeded(outputLog);
1034
1260
  await this.tmux.pipeOutput(outputLog).catch(() => { });
1035
- // 4. Transcript monitor
1036
- this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
1261
+ // 4. Transcript monitor. claude-code is handled inside the monitor
1262
+ // (statusline transcript); codex/kiro/opencode read their CLI's own
1263
+ // conversation store via a pluggable source. Backends with no known
1264
+ // source stay inert exactly as before.
1265
+ this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
1037
1266
  // 5. Wire transcript events
1038
1267
  const ackIfPending = () => {
1039
1268
  if (!this.pendingAckMessage || !this.adapter)
@@ -1049,6 +1278,7 @@ export class Daemon extends EventEmitter {
1049
1278
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
1050
1279
  this.recordRecentToolActivity(this.summarizeTool(name, input));
1051
1280
  this.publishActivity(this.summarizeTool(name, input));
1281
+ this.recordToolProgress(name, input);
1052
1282
  });
1053
1283
  this.transcriptMonitor.on("tool_result", (name, _output) => {
1054
1284
  this.recordRecentEvent({ type: "tool_result", name });
@@ -1111,6 +1341,42 @@ export class Daemon extends EventEmitter {
1111
1341
  * process would have no client reading it. Only the CLI can restore its own
1112
1342
  * tools — hence notify, and let the operator decide about restarting.
1113
1343
  */
1344
+ /**
1345
+ * An MCP server has connected and announced itself.
1346
+ *
1347
+ * Beyond the existing session bookkeeping this is the earliest evidence that
1348
+ * tools are back — roughly 30s ahead of the next health tick, which is
1349
+ * exactly the window #663's false restarts fired in.
1350
+ */
1351
+ handleMcpReady(msg, socket) {
1352
+ const sessionName = msg.sessionName;
1353
+ if (sessionName) {
1354
+ this.socketSessionNames.set(socket, sessionName);
1355
+ socket.on("close", () => {
1356
+ this.socketSessionNames.delete(socket);
1357
+ // Notify fleet manager so it can clean up sessionRegistry
1358
+ if (sessionName !== this.name) {
1359
+ this.ipcServer?.broadcast({ type: "session_disconnected", sessionName });
1360
+ }
1361
+ });
1362
+ }
1363
+ this.logger.debug({ sessionName, pid: msg.pid }, "MCP channel server connected and ready");
1364
+ // Gated on ownership: this ipcServer also accepts EXTERNAL sessions (a
1365
+ // `claude` run in another directory registers as `external-<cwd>-<pid>`).
1366
+ // Without the gate an unrelated session could stand down a restart armed
1367
+ // for THIS instance's genuinely dead server. The liveness re-read below is
1368
+ // a second guard, but ownership is the one that is semantically right.
1369
+ if (sessionName === this.name && this.mcpRestartPending) {
1370
+ const live = mcpServerState(this.instanceDir);
1371
+ if (live.state === "alive") {
1372
+ this.logger.info({ pid: live.pid }, "MCP server replaced — standing down the pending revival restart");
1373
+ this.mcpDeathNotifiedForPid = null;
1374
+ this.mcpDeathDeferredForPid = null;
1375
+ this.clearMcpRestartRequest();
1376
+ }
1377
+ }
1378
+ this.ipcServer?.broadcast({ type: "mcp_ready", sessionName });
1379
+ }
1114
1380
  checkMcpServerAlive() {
1115
1381
  if (this.isPaused)
1116
1382
  return;
@@ -1119,22 +1385,53 @@ export class Daemon extends EventEmitter {
1119
1385
  return;
1120
1386
  if (status.state === "alive") {
1121
1387
  this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
1388
+ this.mcpDeathDeferredForPid = null;
1122
1389
  this.clearMcpRestartRequest(); // tools are back — stand down a pending auto-restart
1123
1390
  return;
1124
1391
  }
1125
1392
  // Dead: report once per pid, and at most once per cooldown window.
1126
1393
  if (this.mcpDeathNotifiedForPid === status.pid)
1127
1394
  return;
1395
+ // An auth-broken CLI cannot keep an MCP server alive, and restarting it
1396
+ // only loops back to the sign-in screen — hold the auto-restart until a
1397
+ // re-login (which restarts the instance and replaces this daemon anyway).
1398
+ const authSuspected = this.authFailureUnresolved;
1399
+ const autoRestart = this.config.mcp_auto_restart !== false && !authSuspected;
1400
+ // Codex <0.146 kills and recreates its MCP connections wholesale, so the
1401
+ // FIRST sighting of a dead pid under a live Codex is more likely a swap
1402
+ // than a loss. Hold the alarm for one tick — only the alarm: the restart is
1403
+ // still armed, and the chokepoint's re-read cancels it if the replacement
1404
+ // arrives. Not generalised to other backends, where a dead MCP under a live
1405
+ // CLI is a genuine incident that silence would mask.
1406
+ //
1407
+ // Crucially this does NOT touch mcpDeathNotifiedForPid or the cooldown
1408
+ // clock: if the suspicion is wrong the incident must still be reportable.
1409
+ if (this.suspectsTransientMcpReplacement() && this.mcpDeathDeferredForPid !== status.pid) {
1410
+ this.mcpDeathDeferredForPid = status.pid;
1411
+ this.logger.warn({ pid: status.pid }, "MCP server pid is gone under a live Codex — holding the alarm one tick for a possible replacement");
1412
+ if (autoRestart)
1413
+ this.armMcpRestartWhenIdle();
1414
+ return;
1415
+ }
1128
1416
  if (Date.now() - this.lastMcpDeathNotifiedAt < Daemon.MCP_DEATH_COOLDOWN_MS)
1129
1417
  return;
1130
1418
  this.mcpDeathNotifiedForPid = status.pid;
1419
+ this.mcpDeathDeferredForPid = null;
1131
1420
  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 });
1421
+ this.logger.error({ pid: status.pid, autoRestart, authSuspected }, "MCP server process is gone — instance has no agend tools");
1422
+ this.emit("mcp_died", { name: this.name, pid: status.pid, autoRestart, authSuspected });
1135
1423
  if (autoRestart)
1136
1424
  this.armMcpRestartWhenIdle();
1137
1425
  }
1426
+ /**
1427
+ * Whether a dead MCP pid on this backend is more likely a CLI-driven
1428
+ * replacement than a real loss. Codex only, and deliberately narrow: it is
1429
+ * the backend whose released fix (0.146) confirms it tore down healthy MCP
1430
+ * connections alongside the one it meant to restart.
1431
+ */
1432
+ suspectsTransientMcpReplacement() {
1433
+ return (this.config.backend ?? this.backend?.binaryName) === "codex";
1434
+ }
1138
1435
  /**
1139
1436
  * Arm an automatic instance restart to revive a dead MCP server. Fires on the
1140
1437
  * next busy→idle edge — or immediately when the pane is already idle, which is
@@ -1157,21 +1454,110 @@ export class Daemon extends EventEmitter {
1157
1454
  this.fireMcpRestartRequest("already_idle");
1158
1455
  }
1159
1456
  }
1457
+ /**
1458
+ * Single chokepoint for all three revival triggers (already_idle, idle_edge,
1459
+ * stale_timeout) — and therefore the one place worth re-checking reality.
1460
+ *
1461
+ * The pending flag is armed by a health tick that saw the OLD pid die, and
1462
+ * was previously fired without ever looking again. When a CLI legitimately
1463
+ * respawns its own MCP child, the replacement is already serving by the time
1464
+ * the turn ends — but the idle edge fires inside the ~30s before the next
1465
+ * health tick can stand the request down, restarting a healthy instance. One
1466
+ * external user was restarted 18 times in two days this way (#663).
1467
+ *
1468
+ * Re-reading here is safe for every backend: worst case it clears a pending
1469
+ * restart that should not have existed.
1470
+ */
1160
1471
  fireMcpRestartRequest(trigger) {
1161
- // A recovery is already reshaping the CLI: crash-loop handling
1162
- // (healthCheckPaused), a respawn in flight (spawning), or a pause
1163
- // (isPaused / frozen monitors). Each of those paths ends in a fresh CLI —
1164
- // and with it a fresh MCP server — or in supervision ending, where a
1165
- // restart on top would fight the recovery. Either way the revival restart
1166
- // is moot: cancel it rather than defer it.
1167
- if (this.healthCheckPaused || this.spawning || this.isPaused || this.runtimeMonitorsFrozen) {
1168
- this.logger.warn({ trigger }, "MCP revival restart cancelled — instance is already pausing/spawning/recovering");
1472
+ if (this.mcpRestartCancelledByRecovery(trigger))
1473
+ return;
1474
+ // Authoritative re-read — the CLI may have replaced the server since the
1475
+ // health tick that armed this.
1476
+ if (mcpServerState(this.instanceDir).state === "alive") {
1477
+ this.logger.info({ trigger }, "MCP revival restart stood down — a live MCP server is already serving");
1169
1478
  this.clearMcpRestartRequest();
1170
1479
  return;
1171
1480
  }
1481
+ // Not back yet: give a CLI-driven replacement a bounded moment rather than
1482
+ // restarting the instance out from under it. One timer covers all three
1483
+ // triggers; a second trigger during the wait is a no-op.
1484
+ if (this.mcpRestartGraceTimer) {
1485
+ // stale_timeout outranks the idle-gated triggers: it is the backstop for
1486
+ // a turn that never ends, so it must not be lost to an in-flight grace.
1487
+ if (trigger === "stale_timeout")
1488
+ this.mcpRestartGraceTrigger = "stale_timeout";
1489
+ return;
1490
+ }
1491
+ this.mcpRestartGraceTrigger = trigger;
1492
+ this.mcpRestartGraceTimer = setTimeout(() => {
1493
+ this.mcpRestartGraceTimer = null;
1494
+ const committed = this.mcpRestartGraceTrigger ?? trigger;
1495
+ this.mcpRestartGraceTrigger = null;
1496
+ if (!this.mcpRestartPending || this.runtimeMonitorsFrozen)
1497
+ return;
1498
+ if (this.mcpRestartCancelledByRecovery(committed))
1499
+ return;
1500
+ if (mcpServerState(this.instanceDir).state === "alive") {
1501
+ this.logger.info({ trigger: committed }, "MCP revival restart stood down — replacement came up during the grace window");
1502
+ this.clearMcpRestartRequest();
1503
+ return;
1504
+ }
1505
+ // Re-check the idle gate. The grace turned what used to be a synchronous
1506
+ // decision into a 2s window, and "idle edge, then a queued message lands"
1507
+ // is the NORMAL ordering for queued delivery — so without this the
1508
+ // restart tears down a turn that started during the wait. Worse than the
1509
+ // race it replaced: the #485 proxy reply only runs at turn end, so an
1510
+ // in-flight answer would be swallowed with no fallback. Only the
1511
+ // 30-minute stale timer may interrupt a live turn, by design.
1512
+ //
1513
+ // Deliberately does NOT clear pending: the MCP really is gone, so the
1514
+ // request must survive to the next idle edge (which re-enters here), and
1515
+ // the stale timer keeps running underneath as the backstop.
1516
+ if (committed !== "stale_timeout" && !(this.instanceState === "idle" && this.pasteQueueDepth === 0)) {
1517
+ this.logger.info({ trigger: committed, instanceState: this.instanceState, pasteQueueDepth: this.pasteQueueDepth }, "MCP revival restart deferred — the pane went busy during the grace window");
1518
+ return;
1519
+ }
1520
+ // The suspicion was wrong: a real loss. Report it BEFORE asking for the
1521
+ // restart — instance-lifecycle's suppression path documents that it
1522
+ // assumes the mcp_died notification has already gone out, and this daemon
1523
+ // does not survive the restart to send it afterwards.
1524
+ this.reportDeferredMcpDeath();
1525
+ this.clearMcpRestartRequest();
1526
+ this.logger.warn({ trigger: committed }, "Requesting instance restart to revive its MCP server");
1527
+ this.emit("mcp_restart_requested", { name: this.name, trigger: committed });
1528
+ }, Daemon.MCP_REPLACEMENT_GRACE_MS);
1529
+ this.mcpRestartGraceTimer.unref?.();
1530
+ }
1531
+ /**
1532
+ * A recovery is already reshaping the CLI: crash-loop handling
1533
+ * (healthCheckPaused), a respawn in flight (spawning), or a pause
1534
+ * (isPaused / frozen monitors). Each ends in a fresh CLI — and with it a
1535
+ * fresh MCP server — or in supervision ending, where a restart on top would
1536
+ * fight the recovery. Either way the revival restart is moot.
1537
+ */
1538
+ mcpRestartCancelledByRecovery(trigger) {
1539
+ if (!(this.healthCheckPaused || this.spawning || this.isPaused || this.runtimeMonitorsFrozen || this.stormWindow?.isActive()))
1540
+ return false;
1541
+ this.logger.warn({ trigger }, "MCP revival restart cancelled — instance is already pausing/spawning/recovering");
1172
1542
  this.clearMcpRestartRequest();
1173
- this.logger.warn({ trigger }, "Requesting instance restart to revive its MCP server");
1174
- this.emit("mcp_restart_requested", { name: this.name, trigger });
1543
+ return true;
1544
+ }
1545
+ /**
1546
+ * Emit a dead-MCP report that was held back as a suspected replacement, once
1547
+ * it is clear no replacement is coming. Consumes the once-per-pid budget
1548
+ * here — deferring was to avoid spending it on a false alarm, not to drop
1549
+ * the incident.
1550
+ */
1551
+ reportDeferredMcpDeath() {
1552
+ const pid = this.mcpDeathDeferredForPid;
1553
+ if (pid == null || this.mcpDeathNotifiedForPid === pid)
1554
+ return;
1555
+ const authSuspected = this.authFailureUnresolved;
1556
+ const autoRestart = this.config.mcp_auto_restart !== false && !authSuspected;
1557
+ this.mcpDeathNotifiedForPid = pid;
1558
+ this.lastMcpDeathNotifiedAt = Date.now();
1559
+ this.logger.error({ pid, autoRestart, authSuspected }, "MCP server process is gone — no replacement arrived, instance has no agend tools");
1560
+ this.emit("mcp_died", { name: this.name, pid, autoRestart, authSuspected });
1175
1561
  }
1176
1562
  clearMcpRestartRequest() {
1177
1563
  this.mcpRestartPending = false;
@@ -1179,6 +1565,11 @@ export class Daemon extends EventEmitter {
1179
1565
  clearTimeout(this.mcpRestartStaleTimer);
1180
1566
  this.mcpRestartStaleTimer = null;
1181
1567
  }
1568
+ if (this.mcpRestartGraceTimer) {
1569
+ clearTimeout(this.mcpRestartGraceTimer);
1570
+ this.mcpRestartGraceTimer = null;
1571
+ }
1572
+ this.mcpRestartGraceTrigger = null;
1182
1573
  }
1183
1574
  startHealthCheck() {
1184
1575
  if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
@@ -1212,7 +1603,7 @@ export class Daemon extends EventEmitter {
1212
1603
  this.emitSupervisionEnded("instance directory was removed from disk", "Recreate the instance, or delete it from fleet.yaml.");
1213
1604
  return;
1214
1605
  }
1215
- if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
1606
+ if (!this.tmux || this.spawning || this.healthCheckPaused) {
1216
1607
  scheduleNext();
1217
1608
  return;
1218
1609
  }
@@ -1270,28 +1661,29 @@ export class Daemon extends EventEmitter {
1270
1661
  let nullReason;
1271
1662
  if (!paneStatus) {
1272
1663
  const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
1273
- if (!serverAlive) {
1664
+ // A server may have already restarted before this daemon's health
1665
+ // tick. The new PID is the durable generation boundary: treat it as
1666
+ // a fleet storm even though `has-session` is true again.
1667
+ const generationChanged = serverAlive
1668
+ ? this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName)) === true
1669
+ : false;
1670
+ if (generationChanged)
1671
+ this.emit("tmux_server_crash", this.name);
1672
+ if (!serverAlive || generationChanged || this.stormWindow?.needsRecovery(this.name)) {
1274
1673
  crashType = "server";
1275
- nullReason = "server_gone";
1276
- this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
1277
- // Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
1278
- Daemon.tmuxServerCrashTimestamps.push(Date.now());
1279
- const cutoff = Date.now() - 5 * 60_000;
1280
- Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
1281
- if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
1282
- Daemon.tmuxServerPaused = true;
1283
- this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
1284
- this.emit("tmux_server_crash", this.name);
1285
- if (!Daemon.tmuxServerRecoveryTimer) {
1286
- Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
1287
- Daemon.tmuxServerRecoveryTimer = null;
1288
- Daemon.tmuxServerPaused = false;
1289
- }, 30_000);
1290
- }
1291
- scheduleNext();
1292
- return;
1674
+ nullReason = serverAlive ? "server_storm_window_loss" : "server_gone";
1675
+ if (!serverAlive) {
1676
+ this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
1677
+ const distinct = this.stormWindow?.recordServerDead(this.name, [this.name]) ?? true;
1678
+ if (distinct)
1679
+ this.emit("tmux_server_crash", this.name);
1293
1680
  }
1294
- await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
1681
+ else {
1682
+ this.stormWindow?.addAffected(this.name);
1683
+ }
1684
+ // The fleet breaker owns the delay and extends it on every new
1685
+ // server generation. Do not schedule a competing fixed timer.
1686
+ await this.stormWindow?.waitForSpawnAllowed();
1295
1687
  }
1296
1688
  else {
1297
1689
  // null but server alive: window-level disappearance. Probe whether
@@ -1387,6 +1779,14 @@ export class Daemon extends EventEmitter {
1387
1779
  }
1388
1780
  // Append to crash history
1389
1781
  this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
1782
+ // A fleet-wide server loss is one infrastructure incident, not N
1783
+ // independent CLI crash loops. StormWindow owns its escalation; do
1784
+ // not let the same three server generations permanently trip every
1785
+ // daemon's per-instance 3-in-5m breaker.
1786
+ if (crashType === "server") {
1787
+ this.crashTimestamps = [];
1788
+ this.crashCount = 0;
1789
+ }
1390
1790
  if (max_retries <= 0) {
1391
1791
  this.healthCheckPaused = true;
1392
1792
  this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
@@ -1473,9 +1873,15 @@ export class Daemon extends EventEmitter {
1473
1873
  this.setProcessStatus("running");
1474
1874
  this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
1475
1875
  this.emit("crash_respawn", this.name);
1876
+ // Emit the per-instance audit event while the storm is still active
1877
+ // so its chat notification is suppressed into the fleet summary.
1878
+ if (crashType === "server")
1879
+ this.stormWindow?.markRecovered(this.name);
1476
1880
  }
1477
1881
  catch (err) {
1478
- this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
1882
+ if (!this.handOffBackendUnreachableRespawn(err)) {
1883
+ this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
1884
+ }
1479
1885
  }
1480
1886
  }
1481
1887
  catch (err) {
@@ -1584,6 +1990,25 @@ export class Daemon extends EventEmitter {
1584
1990
  // A prompt is not an error and must not enter the PTY recovery gate.
1585
1991
  // Continue scanning real errors in this same snapshot.
1586
1992
  }
1993
+ // If a backend can derive activity from this exact pane, trust its
1994
+ // explicit null (tool completed) instead of falling back to a possibly
1995
+ // stale transcript activity from the previous scan.
1996
+ const paneActivity = this.backend?.getPaneActivity
1997
+ ? this.backend.getPaneActivity(pane)
1998
+ : this.currentActivity;
1999
+ const hasPendingWork = this.pendingWork.hasPendingWork();
2000
+ if (!hasPendingWork)
2001
+ this.blockingProcessDetector.reset();
2002
+ const blockingProcess = hasPendingWork
2003
+ ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
2004
+ : null;
2005
+ if (blockingProcess) {
2006
+ // Reuse the hang-notification bridge: it offers explicit Restart/Wait
2007
+ // choices and, unlike pty_error, does not retire a still-useful Cancel
2008
+ // button merely because a foreground process owns stdin.
2009
+ this.logger.warn(blockingProcess, "Foreground process is blocking the agent input loop");
2010
+ this.hangDetector?.emit("hang", { unchangedForMs: blockingProcess.blockedForMs });
2011
+ }
1587
2012
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
1588
2013
  for (const dialog of dialogs) {
1589
2014
  if (!dialog.pattern.test(pane))
@@ -1656,6 +2081,7 @@ export class Daemon extends EventEmitter {
1656
2081
  if (seen > 0)
1657
2082
  this.lastErrorCount.set(Daemon.errorPatternKey(ep), seen);
1658
2083
  }
2084
+ this.authFailureUnresolved = false;
1659
2085
  this.clearErrorRecoveryGate();
1660
2086
  this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
1661
2087
  this.emit("pty_recovered", { name: this.name, downtime_s: downtime });
@@ -1676,6 +2102,19 @@ export class Daemon extends EventEmitter {
1676
2102
  this.logger.warn({ errorType: this.lastDetectedErrorType }, "PTY error recovery deadline expired — resuming error scan");
1677
2103
  this.clearErrorRecoveryGate();
1678
2104
  }
2105
+ // A model_error deferred mid-turn: the turn is over (or the pane left
2106
+ // "working"), so decide now — still on screen means the CLI gave up and
2107
+ // the user must act; gone means a retry got past it and nobody needs to know.
2108
+ if (this.pendingModelErrorKey && this.instanceState !== "working") {
2109
+ const key = this.pendingModelErrorKey;
2110
+ this.pendingModelErrorKey = null;
2111
+ const ep = patterns.find(candidate => Daemon.errorPatternKey(candidate) === key);
2112
+ if (ep && ep.pattern.test(pane)) {
2113
+ this.emitErrorPattern(ep, key, pane, now);
2114
+ return;
2115
+ }
2116
+ this.logger.debug({ errorKey: key }, "Deferred model_error cleared before idle — dropped");
2117
+ }
1679
2118
  // State: monitoring — count-based new-error detection over the full pane.
1680
2119
  for (const ep of patterns) {
1681
2120
  const key = Daemon.errorPatternKey(ep);
@@ -1703,25 +2142,49 @@ export class Daemon extends EventEmitter {
1703
2142
  continue;
1704
2143
  }
1705
2144
  this.lastErrorCount.set(key, count);
1706
- // skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
1707
- // back at its prompt immediately). Keep monitoring so the next occurrence
1708
- // can fire without waiting for a possibly startup-only ready pattern.
1709
- if (!ep.skipRecoveryWait) {
1710
- this.errorWaitingForRecovery = true;
1711
- this.errorDetectedAt = now;
1712
- this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
1713
- this.activeErrorPatternKey = key;
1714
- this.lastDetectedErrorType = ep.type;
1715
- }
1716
- this.lastErrorNotifiedAt.set(key, now);
1717
- if (ep.action === "failover")
1718
- this.lastFailoverAt = now;
1719
- const message = this.resolveErrorMessage(pane, ep);
1720
- this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
1721
- this.emit("pty_error", { name: this.name, ...ep, message });
2145
+ // Kiro's `auto` model picks a concrete model per turn and reroutes around
2146
+ // a temporarily unavailable one on its own — telling the user to run
2147
+ // /model would ask them to fix something the CLI is already handling.
2148
+ if (ep.type === "model_error" && ep.action === "notify" && this.config.model === "auto") {
2149
+ this.logger.debug({ errorType: ep.type }, "model_error suppressed — model 'auto' reroutes on its own");
2150
+ continue;
2151
+ }
2152
+ // A mid-turn model error is often a transient the CLI retries past. Hold
2153
+ // the notification until the turn ends and notify only if the error is
2154
+ // still on the idle screen (see the deferred check above).
2155
+ if (ep.type === "model_error" && ep.action === "notify" && this.instanceState === "working") {
2156
+ this.pendingModelErrorKey = key;
2157
+ this.logger.debug({ errorType: ep.type }, "model_error deferred — instance is mid-turn, re-checking at idle");
2158
+ continue;
2159
+ }
2160
+ this.emitErrorPattern(ep, key, pane, now);
1722
2161
  break; // Only handle first unsuppressed new error per scan
1723
2162
  }
1724
2163
  }
2164
+ /** Arm the recovery gates and emit one detected error pattern. */
2165
+ emitErrorPattern(ep, key, pane, now) {
2166
+ // skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
2167
+ // back at its prompt immediately). Keep monitoring so the next occurrence
2168
+ // can fire without waiting for a possibly startup-only ready pattern.
2169
+ if (!ep.skipRecoveryWait) {
2170
+ this.errorWaitingForRecovery = true;
2171
+ this.errorDetectedAt = now;
2172
+ this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
2173
+ this.activeErrorPatternKey = key;
2174
+ this.lastDetectedErrorType = ep.type;
2175
+ }
2176
+ this.lastErrorNotifiedAt.set(key, now);
2177
+ if (ep.action === "failover")
2178
+ this.lastFailoverAt = now;
2179
+ const message = this.resolveErrorMessage(pane, ep);
2180
+ // Remember an auth failure for as long as it is unresolved. An auth-broken
2181
+ // CLI cannot finish the turn it is holding, so every other detector in this
2182
+ // scan would otherwise keep reporting the same dead instance forever.
2183
+ if (ep.type === "auth_error")
2184
+ this.authFailureUnresolved = true;
2185
+ this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
2186
+ this.emit("pty_error", { name: this.name, ...ep, message });
2187
+ }
1725
2188
  /**
1726
2189
  * Notification text for a detected pattern. `formatMessage` patterns build it
1727
2190
  * from the match so the user gets the specifics (e.g. which period and what
@@ -1760,6 +2223,97 @@ export class Daemon extends EventEmitter {
1760
2223
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
1761
2224
  await this.tmux?.sendSpecialKey(cancelKey);
1762
2225
  }
2226
+ /**
2227
+ * Drop every not-yet-started pane delivery which predates a user cancel.
2228
+ * Promise chains cannot be removed, so entries become generation-checked
2229
+ * no-ops. An entry already past its check is allowed to finish its current PTY
2230
+ * transaction; the interrupt key still cancels the CLI generation.
2231
+ */
2232
+ clearPendingDeliveries(fleetEpoch) {
2233
+ this.deliveryEpoch = fleetEpoch === undefined
2234
+ ? this.deliveryEpoch + 1
2235
+ : Math.max(this.deliveryEpoch, fleetEpoch);
2236
+ this.logger.info({ deliveryEpoch: this.deliveryEpoch }, "Pending delivery queue cleared by user cancel");
2237
+ }
2238
+ captureDeliveryEpoch(value) {
2239
+ const epoch = typeof value === "number" && Number.isSafeInteger(value) && value >= 0
2240
+ ? value
2241
+ : this.deliveryEpoch;
2242
+ if (epoch > this.deliveryEpoch)
2243
+ this.deliveryEpoch = epoch;
2244
+ return epoch;
2245
+ }
2246
+ isDeliveryEpochCurrent(epoch) {
2247
+ return epoch === this.deliveryEpoch;
2248
+ }
2249
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false) {
2250
+ if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
2251
+ return;
2252
+ this.pasteLock = this.pasteLock.then(async () => {
2253
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2254
+ this.logger.info("Pending raw delivery dropped by user cancel");
2255
+ return;
2256
+ }
2257
+ await this.wake();
2258
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2259
+ return;
2260
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
2261
+ if (confirmClear)
2262
+ await this.confirmBackendClearDialog();
2263
+ this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
2264
+ }).catch(err => {
2265
+ this.logger.warn({ err: err.message }, "raw_paste delivery error");
2266
+ });
2267
+ }
2268
+ /**
2269
+ * Confirm a backend's terminal-level /clear prompt after the channel-level
2270
+ * admin confirmation has already succeeded. Poll instead of pasting `/clear\ny`
2271
+ * as one buffer: Kiro versions that clear immediately interpret that queued
2272
+ * `y` as a brand-new user message.
2273
+ */
2274
+ async confirmBackendClearDialog() {
2275
+ const dialog = this.backend?.getClearConfirmationDialog?.();
2276
+ if (!dialog || !this.tmux)
2277
+ return;
2278
+ const matches = (pane) => {
2279
+ dialog.pattern.lastIndex = 0;
2280
+ return dialog.pattern.test(pane);
2281
+ };
2282
+ const deadline = Date.now() + 3_000;
2283
+ while (Date.now() <= deadline) {
2284
+ const pane = await this.tmux.capturePane().catch(() => "");
2285
+ if (matches(pane)) {
2286
+ const sent = await this.paneWriteLock.run(async () => {
2287
+ // Re-check under the write lock so a prompt that disappeared between
2288
+ // capture and lock acquisition never receives a stray literal `y`.
2289
+ const currentPane = await this.tmux.capturePane().catch(() => "");
2290
+ if (!matches(currentPane))
2291
+ return false;
2292
+ const specialKeys = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
2293
+ for (const key of dialog.keys) {
2294
+ const ok = specialKeys.has(key)
2295
+ ? await this.tmux.sendSpecialKey(key)
2296
+ : await this.tmux.sendKeys(key);
2297
+ if (!ok)
2298
+ return false;
2299
+ await new Promise(resolve => setTimeout(resolve, 100));
2300
+ }
2301
+ return true;
2302
+ });
2303
+ if (sent) {
2304
+ this.logger.info({ dialog: dialog.description }, "Auto-confirmed backend clear dialog");
2305
+ }
2306
+ else {
2307
+ this.logger.warn({ dialog: dialog.description }, "Backend clear dialog disappeared or confirmation keys failed");
2308
+ }
2309
+ return;
2310
+ }
2311
+ await new Promise(resolve => setTimeout(resolve, 100));
2312
+ }
2313
+ // Recent Kiro releases may clear immediately without a second prompt. That
2314
+ // is a successful no-op here, not an error.
2315
+ this.logger.debug({ dialog: dialog.description }, "Backend clear dialog did not appear");
2316
+ }
1763
2317
  /** Send the backend-specific graceful quit command/key sequence. */
1764
2318
  async sendQuitSequence() {
1765
2319
  if (!this.tmux || !this.backend)
@@ -1767,18 +2321,20 @@ export class Daemon extends EventEmitter {
1767
2321
  const quitCmd = this.backend.getQuitCommand();
1768
2322
  const quitKey = this.backend.getQuitKey?.();
1769
2323
  if (quitCmd) {
1770
- await this.tmux.sendKeys(quitCmd);
2324
+ if (!await this.tmux.sendKeys(quitCmd))
2325
+ return false;
1771
2326
  // Delay before Enter to prevent tmux server races when instances stop in
1772
2327
  // parallel (same pattern as pasteText).
1773
2328
  await new Promise(r => setTimeout(r, 150));
1774
- await this.tmux.sendSpecialKey("Enter");
1775
- return true;
2329
+ return this.tmux.sendSpecialKey("Enter");
1776
2330
  }
1777
2331
  if (!quitKey)
1778
2332
  return false;
1779
2333
  const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
1780
2334
  for (let i = 0; i < presses; i++) {
1781
- await this.tmux.sendSpecialKey(quitKey);
2335
+ const sent = await this.tmux.sendSpecialKey(quitKey);
2336
+ if (!sent)
2337
+ return false;
1782
2338
  if (i + 1 < presses)
1783
2339
  await new Promise(r => setTimeout(r, 250));
1784
2340
  }
@@ -1811,8 +2367,23 @@ export class Daemon extends EventEmitter {
1811
2367
  }
1812
2368
  }
1813
2369
  }
1814
- if (!killed)
1815
- this.logger.warn("CLI did not exit gracefully within 3s, force killing window");
2370
+ if (!killed) {
2371
+ this.logger.warn({ quitSent }, "CLI did not exit gracefully within 3s — falling back to SIGTERM");
2372
+ await this.killProcessTree("SIGTERM");
2373
+ for (let i = 0; i < 5; i++) {
2374
+ await new Promise(r => setTimeout(r, 200));
2375
+ const status = await this.tmux.getPaneStatus();
2376
+ if (!status || !status.alive) {
2377
+ killed = true;
2378
+ break;
2379
+ }
2380
+ }
2381
+ }
2382
+ if (!killed) {
2383
+ this.logger.warn("CLI process tree survived SIGTERM — falling back to SIGKILL");
2384
+ await this.killProcessTree("SIGKILL");
2385
+ await new Promise(r => setTimeout(r, 200));
2386
+ }
1816
2387
  // Always kill window — remain-on-exit keeps dead panes around after CLI exits
1817
2388
  const stoppedWindowId = this.tmux.getWindowId();
1818
2389
  await this.tmux.killWindow();
@@ -1861,6 +2432,33 @@ export class Daemon extends EventEmitter {
1861
2432
  stateChangedAt: Date.now(),
1862
2433
  };
1863
2434
  }
2435
+ /**
2436
+ * Answer a FleetManager state query. Most callers only need the cached state,
2437
+ * but lifecycle decisions such as post-reply Cancel retirement need an
2438
+ * authoritative pane observation: after startup the last transition can stay
2439
+ * "working" even though the CLI has since settled without emitting another
2440
+ * control-mode output record.
2441
+ */
2442
+ async respondToInstanceStateQuery(msg, socket) {
2443
+ try {
2444
+ if (msg.refresh === true) {
2445
+ await this.captureAndEvaluateInstanceState("state_query");
2446
+ }
2447
+ const snapshot = this.getInstanceStateSnapshot();
2448
+ this.ipcServer?.send(socket, {
2449
+ type: "instance_state_response",
2450
+ requestId: msg.requestId,
2451
+ instanceName: this.name,
2452
+ ...snapshot,
2453
+ state: this.isPaused ? "paused" : snapshot.state,
2454
+ processStatus: this.processStatus,
2455
+ pausedAt: this.lastPausedAt,
2456
+ });
2457
+ }
2458
+ catch (err) {
2459
+ this.logger.debug({ err: err.message }, "Instance state query failed");
2460
+ }
2461
+ }
1864
2462
  /** Gracefully stop the CLI while keeping its remain-on-exit tmux window. */
1865
2463
  /**
1866
2464
  * Mark the instance to pause as soon as its pane is idle. Used for auth
@@ -1880,7 +2478,14 @@ export class Daemon extends EventEmitter {
1880
2478
  if (this.getPauseWakeState() !== "active")
1881
2479
  return;
1882
2480
  }
1883
- if (this.instanceState !== "idle" || this.pasteQueueDepth > 0) {
2481
+ // `pauseAllowStuck` is consumed here, not merely read: it authorises exactly
2482
+ // one pause from a stuck pane (the auth-deferred one) and must never leak
2483
+ // into an ordinary idle-timeout pause of a busy instance.
2484
+ const allowStuck = this.pauseAllowStuck;
2485
+ this.pauseAllowStuck = false;
2486
+ const pausableState = this.instanceState === "idle"
2487
+ || (allowStuck && this.instanceState === "stuck");
2488
+ if (!pausableState || this.pasteQueueDepth > 0) {
1884
2489
  this.pauseRequested = false;
1885
2490
  return;
1886
2491
  }
@@ -1944,6 +2549,10 @@ export class Daemon extends EventEmitter {
1944
2549
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
1945
2550
  // auth pause — otherwise the instance would pause again the moment it idles.
1946
2551
  this.pausePending = false;
2552
+ this.authFailureUnresolved = false;
2553
+ this.loginScreenReported = false;
2554
+ this.fatalStartupReported = false;
2555
+ this.fatalStartupBlocked = false;
1947
2556
  if (this.pauseWakeState === "active")
1948
2557
  return;
1949
2558
  if (this.pauseWakeState === "pausing")
@@ -1956,15 +2565,20 @@ export class Daemon extends EventEmitter {
1956
2565
  }
1957
2566
  this.pauseWakeState = "waking";
1958
2567
  this.beginSpawn();
2568
+ // A wake is a real resume launch: it gets at least the backend's resume
2569
+ // budget (kiro: 60s — the conversation must come back from the backend
2570
+ // before anything paints), never less than the caller's or the configured
2571
+ // startup_timeout_ms.
2572
+ const budgetMs = this.wakeBudgetMs(timeoutMs);
1959
2573
  const transition = this.autoPauseController.wakeOnDeliver(async () => {
1960
2574
  let timeout;
1961
2575
  try {
1962
2576
  const ready = await Promise.race([
1963
- this.trySpawn(true, timeoutMs),
1964
- new Promise(resolve => { timeout = setTimeout(() => resolve(false), timeoutMs); }),
2577
+ this.trySpawn(true, budgetMs),
2578
+ new Promise(resolve => { timeout = setTimeout(() => resolve(false), budgetMs); }),
1965
2579
  ]);
1966
2580
  if (!ready)
1967
- throw new Error(`Wake timed out before CLI became ready (${timeoutMs}ms)`);
2581
+ throw new Error(`Wake timed out before CLI became ready (${budgetMs}ms)`);
1968
2582
  }
1969
2583
  finally {
1970
2584
  if (timeout)
@@ -2004,6 +2618,14 @@ export class Daemon extends EventEmitter {
2004
2618
  applyInstanceStateSnapshot(snapshot, pane) {
2005
2619
  const previous = this.instanceState;
2006
2620
  this.instanceState = snapshot.state;
2621
+ // OpenCode creates its session lazily on the first submitted message.
2622
+ // Waiting until stop/pause to persist that id loses resume state when the
2623
+ // fleet is SIGKILLed or the host reboots.
2624
+ // Idle observations are safe checkpoints; the 60s safety sweep also gives
2625
+ // us a bounded retry if session creation produced no visible state edge.
2626
+ if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
2627
+ this.saveSessionId();
2628
+ }
2007
2629
  // Only a transition back to idle completes pending work. Repeated idle
2008
2630
  // observations between enqueue and paste must not clear a newer inbound.
2009
2631
  if (snapshot.state === "idle" && previous !== "idle") {
@@ -2012,6 +2634,7 @@ export class Daemon extends EventEmitter {
2012
2634
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2013
2635
  // the last tool pinned to the progress line for the rest of the session.
2014
2636
  this.publishActivity(null);
2637
+ this.resetToolProgress();
2015
2638
  // Must run before the mcpRestartPending branch below: the pane text is the
2016
2639
  // only copy of the answer, and the revival restart is about to clear it.
2017
2640
  this.maybeProxyReplyOnTurnEnd(pane);
@@ -2031,8 +2654,13 @@ export class Daemon extends EventEmitter {
2031
2654
  if (snapshot.state !== "idle")
2032
2655
  this.pauseRequested = false;
2033
2656
  // A pause deferred by an auth failure: retry the moment the pane settles.
2034
- if (this.pausePending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
2657
+ // "stuck" counts as settled here, and that is the whole point — an expired
2658
+ // session cannot complete the turn it is holding, so the pane never returns
2659
+ // to idle and waiting for idle meant waiting forever. The instance stayed
2660
+ // live, kept its warm slot, and re-raised a hang alert on every scan.
2661
+ if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
2035
2662
  this.pausePending = false;
2663
+ this.pauseAllowStuck = snapshot.state === "stuck";
2036
2664
  this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
2037
2665
  return;
2038
2666
  }
@@ -2062,6 +2690,7 @@ export class Daemon extends EventEmitter {
2062
2690
  return;
2063
2691
  this.turnHadInbound = true;
2064
2692
  this.turnOutboundDelivered = false;
2693
+ this.turnReplyDelivered = false;
2065
2694
  this.turnCorrelationId = meta.correlation_id || undefined;
2066
2695
  // The last non-empty line of what we pasted: everything on screen after it
2067
2696
  // is the agent's own output.
@@ -2077,13 +2706,38 @@ export class Daemon extends EventEmitter {
2077
2706
  maybeProxyReplyOnTurnEnd(pane) {
2078
2707
  const hadInbound = this.turnHadInbound;
2079
2708
  const delivered = this.turnOutboundDelivered;
2709
+ const replyDelivered = this.turnReplyDelivered;
2080
2710
  const correlationId = this.turnCorrelationId;
2081
2711
  const inboundMarker = this.turnInboundMarker;
2082
2712
  this.turnHadInbound = false;
2083
2713
  this.turnOutboundDelivered = false;
2714
+ this.turnReplyDelivered = false;
2084
2715
  this.turnCorrelationId = undefined;
2085
2716
  this.turnInboundMarker = undefined;
2086
- if (!hadInbound || delivered || this.isPaused)
2717
+ if (!hadInbound || this.isPaused)
2718
+ return;
2719
+ // Claude sometimes prints a broken XML tool call as plain text and returns
2720
+ // idle without ever invoking `reply`. This is independent of MCP liveness:
2721
+ // the model malformed the call before the server could receive it.
2722
+ if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2723
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2724
+ if (malformed) {
2725
+ const stale = malformed.signature === this.lastMalformedToolCallSignature;
2726
+ this.lastMalformedToolCallSignature = malformed.signature;
2727
+ if (!stale) {
2728
+ const recovered = malformed.text != null;
2729
+ this.logger.warn({ correlationId, recovered }, recovered
2730
+ ? "Malformed Claude tool call detected — attempting to recover reply text"
2731
+ : "Malformed Claude tool call detected — reply text could not be extracted");
2732
+ this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2733
+ if (malformed.text)
2734
+ this.sendRecoveredMalformedReply(malformed.text, correlationId);
2735
+ }
2736
+ // Never let the broader dead-MCP fallback relay the XML/chrome too.
2737
+ return;
2738
+ }
2739
+ }
2740
+ if (delivered)
2087
2741
  return;
2088
2742
  // Opt-in: raw pane text can carry secrets past the regex redaction.
2089
2743
  if (this.config.mcp_proxy_reply !== true)
@@ -2093,6 +2747,11 @@ export class Daemon extends EventEmitter {
2093
2747
  return;
2094
2748
  void this.sendProxyReply(pane, inboundMarker, correlationId);
2095
2749
  }
2750
+ isClaudeCodeBackend() {
2751
+ return this.runtimeIdentity?.backend === "claude-code"
2752
+ || this.config.backend === "claude-code"
2753
+ || this.backend?.binaryName === "claude";
2754
+ }
2096
2755
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2097
2756
  async sendProxyReply(pane, inboundMarker, correlationId) {
2098
2757
  try {
@@ -2110,55 +2769,92 @@ export class Daemon extends EventEmitter {
2110
2769
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2111
2770
  if (correlationId)
2112
2771
  body += `\n\n(correlation_id: ${correlationId})`;
2113
- const args = { text: body };
2114
- // Same context-bound routing as the reply tool in handleToolCall.
2115
- if (this.lastChatId) {
2116
- args.chat_id = this.lastChatId;
2117
- if (this.lastThreadId)
2118
- args.thread_id = this.lastThreadId;
2119
- }
2120
2772
  this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2121
2773
  this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2122
- const adapters = this.messageBus.getAllAdapters();
2123
- if (adapters.length > 0) {
2124
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2125
- if (error)
2126
- this.logger.error({ error }, "Dead-MCP proxy reply failed");
2127
- });
2128
- return;
2129
- }
2130
- if (!this.ipcServer)
2131
- return;
2132
- const fleetReqId = `proxyreply_${++this.proxyReplySeq}`;
2133
- this.ipcServer.broadcast({
2134
- type: "fleet_outbound",
2135
- tool: "reply",
2136
- args,
2137
- fleetRequestId: fleetReqId,
2138
- adapterId: this.lastAdapterId,
2139
- });
2140
- const timeout = setTimeout(() => {
2141
- this.pendingIpcRequests.delete(fleetReqId);
2142
- this.logger.error("Dead-MCP proxy reply timed out waiting for the fleet manager");
2143
- }, daemonBudgetMs("reply"));
2144
- timeout.unref?.();
2145
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2146
- clearTimeout(timeout);
2147
- if (respMsg.error)
2148
- this.logger.error({ error: respMsg.error }, "Dead-MCP proxy reply failed");
2149
- else
2150
- this.logger.info("Dead-MCP proxy reply delivered");
2151
- });
2774
+ this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply");
2152
2775
  }
2153
2776
  catch (err) {
2154
2777
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2155
2778
  }
2156
2779
  }
2780
+ /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2781
+ sendRecoveredMalformedReply(text, correlationId) {
2782
+ try {
2783
+ this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2784
+ }
2785
+ catch (err) {
2786
+ this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2787
+ }
2788
+ }
2789
+ /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2790
+ deliverDaemonReply(body, requestPrefix, logLabel) {
2791
+ const args = { text: body };
2792
+ if (this.lastChatId) {
2793
+ args.chat_id = this.lastChatId;
2794
+ if (this.lastThreadId)
2795
+ args.thread_id = this.lastThreadId;
2796
+ }
2797
+ const adapters = this.messageBus.getAllAdapters();
2798
+ if (adapters.length > 0) {
2799
+ routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2800
+ if (error)
2801
+ this.logger.error({ error }, `${logLabel} failed`);
2802
+ else
2803
+ this.logger.info(`${logLabel} delivered`);
2804
+ });
2805
+ return;
2806
+ }
2807
+ if (!this.ipcServer) {
2808
+ this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2809
+ return;
2810
+ }
2811
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2812
+ const timeout = setTimeout(() => {
2813
+ this.pendingIpcRequests.delete(fleetReqId);
2814
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2815
+ }, daemonBudgetMs("reply"));
2816
+ timeout.unref?.();
2817
+ // Register before broadcast: an in-process/fast adapter can answer in the
2818
+ // same tick, and a waiter installed afterwards would miss that response.
2819
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2820
+ clearTimeout(timeout);
2821
+ if (respMsg.error)
2822
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
2823
+ else
2824
+ this.logger.info(`${logLabel} delivered`);
2825
+ });
2826
+ this.ipcServer.broadcast({
2827
+ type: "fleet_outbound",
2828
+ tool: "reply",
2829
+ args,
2830
+ fleetRequestId: fleetReqId,
2831
+ adapterId: this.lastAdapterId,
2832
+ });
2833
+ }
2157
2834
  clearInstanceStateIdleTimer() {
2158
2835
  if (this.instanceStateIdleTimer)
2159
2836
  clearTimeout(this.instanceStateIdleTimer);
2160
2837
  this.instanceStateIdleTimer = null;
2161
2838
  }
2839
+ clearInstanceStateOutputProbeTimer() {
2840
+ if (this.instanceStateOutputProbeTimer)
2841
+ clearTimeout(this.instanceStateOutputProbeTimer);
2842
+ this.instanceStateOutputProbeTimer = null;
2843
+ }
2844
+ /**
2845
+ * Periodic status-line hooks can generate `%output` without changing a single
2846
+ * visible cell. Probe the pane once per burst: a changed capture is real work;
2847
+ * an identical ready capture remains idle and emits no state edge.
2848
+ */
2849
+ scheduleInstanceStateOutputProbe() {
2850
+ if (this.instanceStateOutputProbeTimer)
2851
+ return;
2852
+ this.instanceStateOutputProbeTimer = setTimeout(() => {
2853
+ this.instanceStateOutputProbeTimer = null;
2854
+ void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
2855
+ }, PERIODIC_REDRAW_PROBE_MS);
2856
+ this.instanceStateOutputProbeTimer.unref?.();
2857
+ }
2162
2858
  clearInstanceStateStuckTimer() {
2163
2859
  if (this.instanceStateStuckTimer)
2164
2860
  clearTimeout(this.instanceStateStuckTimer);
@@ -2203,6 +2899,8 @@ export class Daemon extends EventEmitter {
2203
2899
  if (this.statePollInFlight) {
2204
2900
  if (reason === "idle_debounce")
2205
2901
  this.scheduleInstanceStateIdleCapture();
2902
+ if (reason === "output_probe")
2903
+ this.scheduleInstanceStateOutputProbe();
2206
2904
  return;
2207
2905
  }
2208
2906
  this.statePollInFlight = true;
@@ -2211,9 +2909,19 @@ export class Daemon extends EventEmitter {
2211
2909
  const pane = await this.tmux.capturePane();
2212
2910
  // Output received while capture-pane was in flight makes this snapshot
2213
2911
  // stale. Its output handler has already armed a new debounce.
2214
- if ((expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2215
- || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt))
2912
+ const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
2913
+ || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
2914
+ if (outputMovedDuringCapture) {
2915
+ if (reason === "output_probe") {
2916
+ // A second burst overtook the probe. Do not bless its stale capture as
2917
+ // idle: continuous output is the safer working signal, while the next
2918
+ // quiet probe/debounce can still prove that it was only repainting.
2919
+ this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(this.instanceStateLastOutputAt));
2920
+ this.scheduleInstanceStateIdleCapture();
2921
+ this.scheduleInstanceStateStuckDeadline(this.instanceStateLastOutputAt);
2922
+ }
2216
2923
  return;
2924
+ }
2217
2925
  const observedChangeAt = expectedOutputAt || captureStartedAt;
2218
2926
  // One observation per capture. This used to call observe() twice with the
2219
2927
  // same pane: the second call always saw an unchanged pane, so the
@@ -2222,6 +2930,7 @@ export class Daemon extends EventEmitter {
2222
2930
  // mismatched spinner frame was enough to report idle mid-turn.
2223
2931
  const settled = this.instanceStateLastOutputAt === 0
2224
2932
  || captureStartedAt - this.instanceStateLastOutputAt >= this.instanceStateIdleDebounceMs;
2933
+ const paneActivity = this.backend?.getPaneActivity?.(pane) ?? null;
2225
2934
  // The two times are genuinely different and both matter: the content
2226
2935
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
2227
2936
  // decisions about *now*. The old double-observe expressed that by calling
@@ -2229,6 +2938,7 @@ export class Daemon extends EventEmitter {
2229
2938
  const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
2230
2939
  settled,
2231
2940
  changeAt: observedChangeAt,
2941
+ forceBusy: paneActivity !== null,
2232
2942
  });
2233
2943
  this.applyInstanceStateSnapshot(snapshot, pane);
2234
2944
  // Backends without a transcript feed can still say what they are doing, if
@@ -2236,7 +2946,7 @@ export class Daemon extends EventEmitter {
2236
2946
  // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
2237
2947
  // safety sweep), which matches the progress ticker's own 60s interval.
2238
2948
  if (this.backend?.getPaneActivity) {
2239
- this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
2949
+ this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
2240
2950
  }
2241
2951
  if (snapshot.state === "idle") {
2242
2952
  this.clearInstanceStateStuckTimer();
@@ -2263,6 +2973,14 @@ export class Daemon extends EventEmitter {
2263
2973
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
2264
2974
  return;
2265
2975
  this.instanceStateLastOutputAt = event.at;
2976
+ if (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle") {
2977
+ // Do not emit idle→working solely because agy repainted an identical
2978
+ // footer. The short capture probe below still observes genuine pane
2979
+ // changes promptly and moves the state to working.
2980
+ this.scheduleInstanceStateOutputProbe();
2981
+ this.scheduleInstanceStateIdleCapture();
2982
+ return;
2983
+ }
2266
2984
  const snapshot = this.instanceStateMachine.recordOutput(event.at);
2267
2985
  this.applyInstanceStateSnapshot(snapshot);
2268
2986
  this.scheduleInstanceStateIdleCapture();
@@ -2313,6 +3031,7 @@ export class Daemon extends EventEmitter {
2313
3031
  stopInstanceStateMonitor() {
2314
3032
  this.instanceStateMonitorActive = false;
2315
3033
  this.clearInstanceStateIdleTimer();
3034
+ this.clearInstanceStateOutputProbeTimer();
2316
3035
  this.clearInstanceStateStuckTimer();
2317
3036
  if (this.instanceStateMonitorTimer)
2318
3037
  clearInterval(this.instanceStateMonitorTimer);
@@ -2347,6 +3066,15 @@ export class Daemon extends EventEmitter {
2347
3066
  this.logger.debug(diagnostic, "Suppressing stuck notification without pending work");
2348
3067
  return;
2349
3068
  }
3069
+ // An auth-broken instance IS stuck, and stays stuck: it cannot finish the
3070
+ // turn it is holding, so pendingWork never clears and this fired on every
3071
+ // scan forever. The notification is also useless — it offers Restart/Wait
3072
+ // and asks General for help, but only the user re-logging in fixes this, and
3073
+ // they have already been told exactly that by the auth alert.
3074
+ if (this.authFailureUnresolved) {
3075
+ this.logger.debug(diagnostic, "Suppressing stuck notification — unresolved auth failure (re-login required)");
3076
+ return;
3077
+ }
2350
3078
  this.logger.warn(diagnostic, "Instance pane stuck with pending work");
2351
3079
  this.hangDetector?.emit("hang", { unchangedForMs: snapshot.unchangedForMs });
2352
3080
  }
@@ -2366,6 +3094,7 @@ export class Daemon extends EventEmitter {
2366
3094
  // fire into a stopped daemon).
2367
3095
  this.clearMcpRestartRequest();
2368
3096
  this.interactivePromptDetector.reset();
3097
+ this.blockingProcessDetector.reset();
2369
3098
  this.stopInstanceStateMonitor();
2370
3099
  this.transcriptMonitor?.stop();
2371
3100
  this.guardian?.stop();
@@ -2391,9 +3120,9 @@ export class Daemon extends EventEmitter {
2391
3120
  * Tell the fleet manager what this instance is doing right now, for the live
2392
3121
  * progress line on the cancel button.
2393
3122
  *
2394
- * Purely cosmetic — nothing decides anything from it, so it is fine that only
2395
- * backends with a transcript feed report at all (claude-code today). For the
2396
- * rest the progress line keeps showing just elapsed time.
3123
+ * Primarily cosmetic. The foreground-process detector also uses a current
3124
+ * shell activity as one of several positive signals, but never makes a state
3125
+ * decision from an arbitrary activity label alone.
2397
3126
  *
2398
3127
  * Repeats are dropped: the ticker only edits the channel message when the text
2399
3128
  * changes, and a stream of identical broadcasts would defeat that.
@@ -2409,47 +3138,291 @@ export class Daemon extends EventEmitter {
2409
3138
  activity: next,
2410
3139
  });
2411
3140
  }
3141
+ /** Effective tool_progress level, hardened against junk config values.
3142
+ * Off unless explicitly enabled: an update must never make bubbles start
3143
+ * listing tool activity the operator did not ask to broadcast. */
3144
+ toolProgressLevel() {
3145
+ const raw = this.config.tool_progress;
3146
+ return raw === "standard" || raw === "verbose" ? raw : "off";
3147
+ }
2412
3148
  /**
2413
- * Options for every system-initiated paste (startup notice, session snapshot,
2414
- * runtime-dialog keys).
3149
+ * Apply a tool-progress setting without restarting the CLI session.
2415
3150
  *
2416
- * One place, so the three call sites cannot drift apart on it. Backends with a
2417
- * native input queue do not get the retry Enter: on those, a second bare Enter
2418
- * is not the no-op the retry assumed but a queue mutation — the same reason
2419
- * `deliverMessage` refuses to probe for busy with one.
3151
+ * Drop the current accumulator at the boundary: verbose entries may contain
3152
+ * command previews which must not survive a downgrade to standard/off, and a
3153
+ * newly enabled level should start with a clean, internally consistent list.
2420
3154
  */
2421
- systemPasteOptions() {
2422
- return { retryEnter: this.backend?.supportsQueuedInput?.() !== true };
3155
+ updateToolProgress(level) {
3156
+ const previous = this.toolProgressLevel();
3157
+ this.config.tool_progress = level;
3158
+ if (this.toolProgressLevel() !== previous)
3159
+ this.resetToolProgress();
2423
3160
  }
2424
- summarizeTool(name, input) {
2425
- const inp = input;
2426
- if (!inp)
2427
- return name;
2428
- if (name === "Read")
2429
- return `Read ${inp.file_path ?? ""}`;
2430
- if (name === "Edit")
2431
- return `Edit ${inp.file_path ?? ""}`;
2432
- if (name === "Write")
2433
- return `Write ${inp.file_path ?? ""}`;
2434
- // Command name only — never the arguments. See shellCommandLabel.
2435
- if (name === "Bash")
2436
- return `$ ${shellCommandLabel(String(inp.command ?? ""))}`;
2437
- if (name === "Glob")
2438
- return `Glob ${inp.pattern ?? ""}`;
2439
- if (name === "Grep")
2440
- return `Grep ${inp.pattern ?? ""}`;
2441
- if (name === "Agent")
3161
+ /** Snapshot the configuration actually owned by this live daemon. */
3162
+ getConfigSnapshot() {
3163
+ return structuredClone(this.config);
3164
+ }
3165
+ /**
3166
+ * Apply the whitelisted hot configuration carried by FleetManager IPC.
3167
+ * null removes an optional value; invalid/unlisted fields are ignored rather
3168
+ * than allowing another client on the per-instance socket to mutate cold
3169
+ * process/session settings.
3170
+ */
3171
+ applyConfigUpdate(value) {
3172
+ if (!value || typeof value !== "object" || Array.isArray(value))
3173
+ return;
3174
+ const update = value;
3175
+ if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
3176
+ this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
3177
+ }
3178
+ if (typeof update.mcp_proxy_reply === "boolean")
3179
+ this.config.mcp_proxy_reply = update.mcp_proxy_reply;
3180
+ else if (update.mcp_proxy_reply === null)
3181
+ delete this.config.mcp_proxy_reply;
3182
+ if (typeof update.warm_cap === "number" && Number.isInteger(update.warm_cap) && update.warm_cap >= 0)
3183
+ this.config.warm_cap = update.warm_cap;
3184
+ else if (update.warm_cap === null)
3185
+ delete this.config.warm_cap;
3186
+ const nextAutoPause = typeof update.auto_pause_after === "number"
3187
+ && Number.isFinite(update.auto_pause_after)
3188
+ && update.auto_pause_after >= 0
3189
+ ? update.auto_pause_after
3190
+ : update.auto_pause_after === null ? 0 : undefined;
3191
+ if (nextAutoPause !== undefined) {
3192
+ const previousAutoPause = typeof this.config.auto_pause_after === "number" ? this.config.auto_pause_after : 0;
3193
+ if (update.auto_pause_after === null)
3194
+ delete this.config.auto_pause_after;
3195
+ else
3196
+ this.config.auto_pause_after = nextAutoPause;
3197
+ if (nextAutoPause !== previousAutoPause) {
3198
+ const isGeneral = this.config.general_topic === true || this.name === "general";
3199
+ this.autoPauseController.reconfigure((isGeneral ? 0 : nextAutoPause) * 60_000);
3200
+ }
3201
+ }
3202
+ for (const key of ["display_name", "description"]) {
3203
+ if (typeof update[key] === "string")
3204
+ this.config[key] = update[key];
3205
+ else if (update[key] === null)
3206
+ delete this.config[key];
3207
+ }
3208
+ if (Array.isArray(update.tags) && update.tags.every(tag => typeof tag === "string"))
3209
+ this.config.tags = [...update.tags];
3210
+ else if (update.tags === null)
3211
+ delete this.config.tags;
3212
+ if (["trace", "debug", "info", "warn", "error"].includes(String(update.log_level))) {
3213
+ const level = update.log_level;
3214
+ if (this.config.log_level !== level) {
3215
+ this.config.log_level = level;
3216
+ this.logger.level = level;
3217
+ }
3218
+ }
3219
+ }
3220
+ /**
3221
+ * Accumulate one semantic progress line for the channel bubble and schedule
3222
+ * a coalesced broadcast. Separate from publishActivity on purpose: that one
3223
+ * is the single-line statusline detail (terse, operator-facing), this is the
3224
+ * multi-line channel list (semantic, argument-free at `standard`) — see
3225
+ * tool-progress.ts for why the two labellers must not merge.
3226
+ */
3227
+ recordToolProgress(name, input) {
3228
+ const level = this.toolProgressLevel();
3229
+ if (level === "off")
3230
+ return;
3231
+ if (this.turnProgress.add(summarizeProgress(name, input, level))) {
3232
+ this.scheduleProgressBroadcast();
3233
+ }
3234
+ }
3235
+ /** Coalesce progress broadcasts: at most one per 3s, trailing edge kept. */
3236
+ scheduleProgressBroadcast() {
3237
+ const MIN_INTERVAL_MS = 3_000;
3238
+ const since = Date.now() - this.lastProgressBroadcastAt;
3239
+ if (since >= MIN_INTERVAL_MS) {
3240
+ this.broadcastToolProgress();
3241
+ return;
3242
+ }
3243
+ if (this.progressBroadcastTimer)
3244
+ return;
3245
+ this.progressBroadcastTimer = setTimeout(() => {
3246
+ this.progressBroadcastTimer = null;
3247
+ this.broadcastToolProgress();
3248
+ }, MIN_INTERVAL_MS - since);
3249
+ this.progressBroadcastTimer.unref?.();
3250
+ }
3251
+ broadcastToolProgress() {
3252
+ const rendered = this.turnProgress.render();
3253
+ if (rendered === this.lastProgressBroadcast)
3254
+ return;
3255
+ this.lastProgressBroadcast = rendered;
3256
+ this.lastProgressBroadcastAt = Date.now();
3257
+ this.ipcServer?.broadcast({
3258
+ type: "instance_progress",
3259
+ instanceName: this.name,
3260
+ progress: rendered,
3261
+ });
3262
+ }
3263
+ /** New turn (or turn over): drop the list and tell the fleet to clear it. */
3264
+ resetToolProgress() {
3265
+ if (this.progressBroadcastTimer) {
3266
+ clearTimeout(this.progressBroadcastTimer);
3267
+ this.progressBroadcastTimer = null;
3268
+ }
3269
+ this.turnProgress.reset();
3270
+ if (this.lastProgressBroadcast !== "") {
3271
+ this.lastProgressBroadcast = "";
3272
+ this.lastProgressBroadcastAt = Date.now();
3273
+ this.ipcServer?.broadcast({ type: "instance_progress", instanceName: this.name, progress: "" });
3274
+ }
3275
+ }
3276
+ /**
3277
+ * Options for every system-initiated paste (startup notice, session snapshot,
3278
+ * runtime-dialog keys).
3279
+ *
3280
+ * One place, so the three call sites cannot drift apart on it. Backends with a
3281
+ * native input queue do not get the retry Enter: on those, a second bare Enter
3282
+ * is not the no-op the retry assumed but a queue mutation — the same reason
3283
+ * `deliverMessage` refuses to probe for busy with one.
3284
+ */
3285
+ systemPasteOptions() {
3286
+ return { retryEnter: this.backend?.supportsQueuedInput?.() !== true };
3287
+ }
3288
+ summarizeTool(name, input) {
3289
+ const inp = input;
3290
+ if (!inp)
3291
+ return name;
3292
+ if (name === "Read")
3293
+ return `Read ${inp.file_path ?? ""}`;
3294
+ if (name === "Edit")
3295
+ return `Edit ${inp.file_path ?? ""}`;
3296
+ if (name === "Write")
3297
+ return `Write ${inp.file_path ?? ""}`;
3298
+ // Command name only — never the arguments. See shellCommandLabel.
3299
+ if (name === "Bash")
3300
+ return `$ ${shellCommandLabel(String(inp.command ?? ""))}`;
3301
+ if (name === "Glob")
3302
+ return `Glob ${inp.pattern ?? ""}`;
3303
+ if (name === "Grep")
3304
+ return `Grep ${inp.pattern ?? ""}`;
3305
+ if (name === "Agent")
2442
3306
  return "Agent (subagent)";
2443
3307
  if (name.startsWith("mcp__agend__"))
2444
3308
  return ""; // skip channel tools
2445
3309
  return name;
2446
3310
  }
3311
+ /**
3312
+ * The one place an inbound message grows its metadata wrapper ([user:]/
3313
+ * [from:] prefix, pending reactions, handoff metadata, reply instructions).
3314
+ * Both the normal queued path (pushChannelMessage) and /steer go through
3315
+ * here — a steered message must read EXACTLY like a queued one to the
3316
+ * agent, or the two drift apart in what the agent is told about replying.
3317
+ */
3318
+ formatInboundMessage(content, meta) {
3319
+ const user = meta.user || "unknown";
3320
+ const fromInstance = meta.from_instance;
3321
+ let formatted;
3322
+ if (fromInstance) {
3323
+ formatted = formatCrossInstanceInboundMessage(content, meta);
3324
+ }
3325
+ else {
3326
+ const via = meta.source ? ` via ${meta.source}` : "";
3327
+ const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
3328
+ formatted = `[user:${user}${via}${idTag}] ${content}`;
3329
+ // Reactions queued since the last real message (#432). One leading line of
3330
+ // context, present only when something is pending — a reaction no longer
3331
+ // costs a turn of its own.
3332
+ if (meta.pending_reactions) {
3333
+ formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
3334
+ }
3335
+ formatted += renderHandoffMetadata(meta);
3336
+ formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
3337
+ }
3338
+ if (meta.reply_to_text) {
3339
+ formatted += `\n(reply_to: "${meta.reply_to_text}")`;
3340
+ }
3341
+ return formatted;
3342
+ }
3343
+ /**
3344
+ * /steer: interject into the CURRENT turn instead of queueing for idle.
3345
+ *
3346
+ * Differences from pushChannelMessage, and nothing else:
3347
+ * - serialized on steerLock, not pasteLock — it must overtake, not queue
3348
+ * - deliverMessage runs with { steer: true }, which takes the busy branch
3349
+ * that pastes immediately (the codex native-queue transaction) instead of
3350
+ * waiting for idle. Verification and silent-loss fallback are the ones
3351
+ * that path already has: pane-capture visibility check, then one
3352
+ * idle-gated redelivery — so on a TUI that swallows busy input the steer
3353
+ * degrades to "next message after this turn", never silently vanishes.
3354
+ *
3355
+ * The steered text goes through formatInboundMessage so the agent sees a
3356
+ * normal inbound message, with a steering notice prepended for context.
3357
+ */
3358
+ steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
3359
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3360
+ return;
3361
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
3362
+ this.pendingWork.recordInbound();
3363
+ this.recordRecentUserMessage(content, meta);
3364
+ const formatted = "[STEERING — mid-task course correction from the user. Fold this into the CURRENT work.]\n"
3365
+ + this.formatInboundMessage(content, meta);
3366
+ const chatId = meta.chat_id;
3367
+ const messageId = meta.message_id;
3368
+ const status = (chatId && messageId)
3369
+ ? { chatId: meta.thread_id || chatId, messageId }
3370
+ : undefined;
3371
+ this.steerLock = this.steerLock.then(async () => {
3372
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3373
+ return;
3374
+ await this.wake();
3375
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3376
+ return;
3377
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3378
+ this.markTurnStarted(meta, formatted);
3379
+ }
3380
+ }).catch(err => {
3381
+ this.logger.warn({ err: err.message }, "steer delivery error");
3382
+ });
3383
+ }
3384
+ /**
3385
+ * /btw: submit a separately-labelled side question immediately while asking
3386
+ * the agent not to change the active task. Claude's native `/btw` opens a
3387
+ * branchAndResume panel whose answer never reaches the channel or transcript,
3388
+ * so channel delivery intentionally uses a normal wrapped inbound instead.
3389
+ */
3390
+ btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
3391
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3392
+ return;
3393
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
3394
+ this.pendingWork.recordInbound();
3395
+ this.recordRecentUserMessage(content, meta);
3396
+ const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
3397
+ + this.formatInboundMessage(content, meta);
3398
+ const chatId = meta.chat_id;
3399
+ const messageId = meta.message_id;
3400
+ const status = (chatId && messageId)
3401
+ ? { chatId: meta.thread_id || chatId, messageId }
3402
+ : undefined;
3403
+ this.steerLock = this.steerLock.then(async () => {
3404
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3405
+ return;
3406
+ await this.wake();
3407
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3408
+ return;
3409
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3410
+ this.markTurnStarted(meta, formatted);
3411
+ }
3412
+ }).catch(err => {
3413
+ this.logger.warn({ err: err.message }, "btw delivery error");
3414
+ });
3415
+ }
2447
3416
  /**
2448
3417
  * Push an inbound channel message to a specific MCP session.
2449
3418
  * If targetSession is provided, only send to the matching socket.
2450
3419
  * Otherwise send to the instance's own session (this.name).
2451
3420
  */
2452
- pushChannelMessage(content, meta, _targetSession) {
3421
+ pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
3422
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3423
+ this.logger.info("Pending channel delivery dropped by user cancel");
3424
+ return;
3425
+ }
2453
3426
  if (!this.tmux) {
2454
3427
  this.logger.warn("Cannot push channel message: tmux not running");
2455
3428
  return;
@@ -2483,46 +3456,19 @@ export class Daemon extends EventEmitter {
2483
3456
  const rawText = content.slice(5);
2484
3457
  this.logger.info({ user }, "Raw paste from topic mode user");
2485
3458
  this.pasteLock = this.pasteLock.then(async () => {
2486
- if (await this.deliverMessage(rawText))
3459
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3460
+ return;
3461
+ if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
2487
3462
  this.markTurnStarted(meta, rawText);
2488
3463
  }).catch(err => {
2489
3464
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
2490
3465
  });
2491
3466
  return;
2492
3467
  }
2493
- let formatted;
2494
- if (fromInstance) {
2495
- // #77: show the sender's display name for readability, keeping the machine
2496
- // instance name in parens so the recipient's send_to_instance target is valid.
2497
- const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
2498
- formatted = `[from:${fromLabel}] ${content}`;
2499
- formatted += renderHandoffMetadata(meta);
2500
- // A delegated task that requires a reply must not read like a chatty FYI —
2501
- // the "you may stay silent" line is for the latter only.
2502
- formatted += meta.requires_reply === "true"
2503
- ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2504
- : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
2505
- }
2506
- else {
2507
- const via = meta.source ? ` via ${meta.source}` : "";
2508
- const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
2509
- formatted = `[user:${user}${via}${idTag}] ${content}`;
2510
- // Reactions queued since the last real message (#432). One leading line of
2511
- // context, present only when something is pending — a reaction no longer
2512
- // costs a turn of its own.
2513
- if (meta.pending_reactions) {
2514
- formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2515
- }
2516
- formatted += renderHandoffMetadata(meta);
2517
- formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
2518
- }
2519
- if (meta.reply_to_text) {
2520
- formatted += `\n(reply_to: "${meta.reply_to_text}")`;
2521
- }
3468
+ const formatted = this.formatInboundMessage(content, meta);
2522
3469
  // Serialize deliveries: each message waits for the previous to complete,
2523
3470
  // and each waits for the CLI to be idle before pasting. Messages are never
2524
- // dropped for age — a long-busy CLI just queues them until it frees up
2525
- // (the user can press Cancel to interrupt and let the queue drain sooner).
3471
+ // dropped for age — only an explicit user Cancel invalidates queued work.
2526
3472
  const chatId = meta.chat_id;
2527
3473
  const messageId = meta.message_id;
2528
3474
  const wasQueued = this.pasteQueueDepth > 0;
@@ -2535,18 +3481,40 @@ export class Daemon extends EventEmitter {
2535
3481
  }
2536
3482
  this.pasteLock = this.pasteLock.then(async () => {
2537
3483
  try {
3484
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3485
+ this.logger.info("Pending channel delivery dropped by user cancel");
3486
+ return;
3487
+ }
2538
3488
  if (this.config.pre_task_command) {
2539
- await this.deliverMessage(this.config.pre_task_command);
3489
+ await this.deliverMessage(this.config.pre_task_command, undefined, { deliveryEpoch });
3490
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3491
+ return;
2540
3492
  }
2541
3493
  if (this.pendingInstructionsNotice) {
2542
3494
  this.pendingInstructionsNotice = false;
2543
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
3495
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
3496
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3497
+ return;
2544
3498
  }
2545
3499
  const status = (chatId && messageId)
2546
3500
  ? { chatId: meta.thread_id || chatId, messageId }
2547
3501
  : undefined;
2548
- if (await this.deliverMessage(formatted, status))
3502
+ // A fresh delivery begins a fresh turn — its bubble must not inherit
3503
+ // the previous turn's tool list.
3504
+ this.resetToolProgress();
3505
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
2549
3506
  this.markTurnStarted(meta, formatted);
3507
+ }
3508
+ else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
3509
+ const error = this.tmux?.getLastPasteError?.() ?? "target pane rejected the delivery";
3510
+ this.ipcServer?.broadcast({
3511
+ type: "cross_instance_delivery_failed",
3512
+ senderSession: meta.from_instance,
3513
+ targetInstance: this.name,
3514
+ correlationId: meta.correlation_id ?? "unknown",
3515
+ error,
3516
+ });
3517
+ }
2550
3518
  }
2551
3519
  finally {
2552
3520
  this.pasteQueueDepth--;
@@ -2572,31 +3540,55 @@ export class Daemon extends EventEmitter {
2572
3540
  * immediately and own the application-level queue themselves. The pasteLock remains
2573
3541
  * serial in both cases so separate PTY writes can never overlap.
2574
3542
  */
2575
- async deliverMessage(formatted, status) {
3543
+ async deliverMessage(formatted, status, opts) {
3544
+ const cancelled = () => opts?.deliveryEpoch !== undefined
3545
+ && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
3546
+ if (cancelled() || this.stormWindow?.isStopped())
3547
+ return false;
2576
3548
  // Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
2577
3549
  const fenceCount = (formatted.match(/```/g) || []).length;
2578
3550
  if (fenceCount % 2 !== 0) {
2579
3551
  // Odd number of fences = unclosed. Remove all code fences from the message.
2580
3552
  formatted = formatted.replace(/```/g, "");
2581
3553
  }
3554
+ // Direct paths such as /steer, /btw, /raw and schedules do not pass the
3555
+ // FleetManager idle gate. Hold them at the final common pane-write path so
3556
+ // a tmux storm cannot force any text into a half-booted CLI.
3557
+ if (this.stormWindow?.isDeliveryHeld(this.name)) {
3558
+ this.logger.warn("Pane delivery held during tmux server recovery");
3559
+ await this.stormWindow.waitForDeliveryAllowed(this.name);
3560
+ if (cancelled() || this.stormWindow.isStopped())
3561
+ return false;
3562
+ }
2582
3563
  // Before anything reads the window id: a spawn in progress is about to change it.
2583
3564
  await this.waitForSpawnToSettle();
3565
+ if (cancelled())
3566
+ return false;
3567
+ if (this.refuseFatalStartupDelivery(status))
3568
+ return false;
2584
3569
  let windowId = this.getWindowId();
2585
3570
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
2586
3571
  let handingOffToNativeQueue = false;
2587
3572
  // If the CLI is busy, either hand the complete submission to its native input
2588
3573
  // queue or wait for idle. Native queue support is an explicit backend capability:
2589
3574
  // normal Enter input has steering/interrupt semantics in several other CLIs.
2590
- if (windowId && this.controlClient && !this.controlClient.isIdle(windowId)) {
3575
+ if (windowId && this.controlClient && !(await this.isPaneReadyForDelivery(windowId))) {
2591
3576
  if (status)
2592
3577
  this.emit("message_queued", status);
2593
- if (supportsQueuedInput) {
3578
+ if (supportsQueuedInput || opts?.steer) {
3579
+ // Native queue (codex), or an explicit /steer: hand the complete
3580
+ // paste+Enter transaction to the busy CLI now. For steer this is the
3581
+ // point — the user asked to interject, and the transaction's
3582
+ // visibility check + idle-gated fallback catch TUIs that swallow
3583
+ // busy input (see steerMessage).
2594
3584
  handingOffToNativeQueue = true;
2595
- this.logger.debug("CLI busy — handing message to backend-native input queue");
3585
+ this.logger.debug(opts?.steer ? "CLI busy — steering into the running turn" : "CLI busy — handing message to backend-native input queue");
2596
3586
  }
2597
3587
  else {
2598
3588
  this.logger.debug("CLI busy — queuing message until idle");
2599
- const becameIdle = await this.controlClient.waitUntilIdle(windowId);
3589
+ const becameIdle = await this.waitForPaneReadyForDelivery(windowId);
3590
+ if (cancelled())
3591
+ return false;
2600
3592
  if (!becameIdle) {
2601
3593
  // The pane never freed up. Report the failure instead of pasting into a
2602
3594
  // wedged CLI (where the text would sit unsubmitted and the next message
@@ -2608,11 +3600,291 @@ export class Daemon extends EventEmitter {
2608
3600
  }
2609
3601
  }
2610
3602
  }
3603
+ // F3 (#kiro-enter-drop): an earlier delivery's Enter may have been dropped,
3604
+ // leaving that message in the input row. Pasting on top would submit both as
3605
+ // one. Submit the stranded text first (a bare Enter — a no-op on an empty
3606
+ // prompt, verified live), then wait for the prompt to come back before our
3607
+ // own paste. The wait happens OUTSIDE the pane lock like every other wait.
3608
+ if (windowId && this.controlClient && !handingOffToNativeQueue) {
3609
+ // Only a positively verified clear input row ("idle") lets the paste
3610
+ // proceed. After "submitted" (the stranded text's turn is running) or
3611
+ // "busy" (the pane changed under us, or could not be read) the prompt
3612
+ // must come back AND the check runs again — the stranded text may still
3613
+ // be there. "failed" (the Enter could not be sent) fails the delivery:
3614
+ // pasting now would submit both messages as one.
3615
+ for (let round = 0;; round++) {
3616
+ // Budget check BEFORE the next mutating call: each round may send one
3617
+ // recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
3618
+ if (round >= STRANDED_INPUT_MAX_ROUNDS) {
3619
+ this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
3620
+ if (status)
3621
+ this.emit("message_failed", status); // ❌
3622
+ return false;
3623
+ }
3624
+ const stranded = await this.submitStrandedInputIfAny(windowId);
3625
+ if (cancelled())
3626
+ return false;
3627
+ if (stranded === "idle")
3628
+ break;
3629
+ if (stranded === "failed") {
3630
+ this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
3631
+ if (status)
3632
+ this.emit("message_failed", status); // ❌
3633
+ return false;
3634
+ }
3635
+ const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
3636
+ if (cancelled())
3637
+ return false;
3638
+ if (!readyAgain) {
3639
+ this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
3640
+ if (status)
3641
+ this.emit("message_failed", status); // ❌
3642
+ return false;
3643
+ }
3644
+ }
3645
+ }
2611
3646
  // Everything above is *waiting*; everything below *writes*. Only the write is
2612
3647
  // held under the pane lock — holding it across the idle wait (up to 30 min)
2613
3648
  // would starve the runtime-dialog dismisser, which is often the very thing
2614
3649
  // that would let the pane go idle again.
2615
- return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
3650
+ return this.paneWriteLock.run(async () => {
3651
+ if (cancelled())
3652
+ return false;
3653
+ // Re-checked here because the flag can be set during the waits above
3654
+ // (a spawn's dialog scan runs concurrently with a queued delivery).
3655
+ if (this.refuseFatalStartupDelivery(status))
3656
+ return false;
3657
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3658
+ });
3659
+ }
3660
+ /**
3661
+ * Bottom-anchored readiness for TUIs that drop Enter while busy (see
3662
+ * CliBackend.dropsEnterWhileBusy). Output silence is necessary but not
3663
+ * sufficient for them: Kiro paints nothing for the whole duration of a shell
3664
+ * or MCP tool call (and for each 10s backend retry), yet an Enter arriving
3665
+ * then is discarded. Reproduced deterministically: `sleep 9` tool, paste at
3666
+ * t+6s → text stranded in the prompt row, next message submitted both.
3667
+ * Other backends keep the silence-based gate byte-for-byte.
3668
+ */
3669
+ async isPaneReadyForDelivery(windowId) {
3670
+ if (!this.isPaneIdleForDelivery(windowId))
3671
+ return false;
3672
+ if (this.backend?.dropsEnterWhileBusy?.() !== true)
3673
+ return true;
3674
+ const prompt = this.backend.getBottomReadyPattern?.();
3675
+ if (!prompt || !this.tmux)
3676
+ return true;
3677
+ try {
3678
+ const pane = await this.tmux.capturePane();
3679
+ if (this.backend.getBusyPattern?.()?.test(pane))
3680
+ return false;
3681
+ if (bottomRowIsReady(pane, prompt))
3682
+ return true;
3683
+ // FAIL CLOSED on any non-blank screen without the prompt at the bottom.
3684
+ // The obvious "unknown layout → fall back to silence" shortcut re-opened
3685
+ // the bug: a long tool output scrolls the turn's own `N% !>` row out of
3686
+ // the visible pane (capture-pane -p is the viewport only), the bottom row
3687
+ // is tool output, the pane is quiet — and a paste there strands the text
3688
+ // exactly as before. The prompt row is the only evidence Kiro accepts an
3689
+ // Enter; a modal or panel is handled by the dialog dismisser, and the
3690
+ // bounded wait reports a failure rather than pasting into a screen we do
3691
+ // not understand. That includes a completely BLANK capture: a clear /
3692
+ // redraw / alternate-screen transition paints nothing for a frame, tmux
3693
+ // still accepts a paste, and "nothing visible" is not evidence the TUI is
3694
+ // idle. The next 250ms poll sees the prompt if it is coming.
3695
+ return false;
3696
+ }
3697
+ catch (err) {
3698
+ // Same rule for a capture that fails: no positive proof, no paste. The
3699
+ // poll retries; a persistently unreadable pane ends in the bounded
3700
+ // timeout's ❌ rather than in a silently stranded message.
3701
+ this.logger.debug({ err }, "capture-pane failed during the readiness check — treating the pane as not ready");
3702
+ return false;
3703
+ }
3704
+ }
3705
+ /**
3706
+ * Like waitForPaneIdleForDelivery, plus the bottom-anchored prompt check for
3707
+ * Enter-dropping TUIs. Polls at a coarse cadence (each poll is a capture-pane)
3708
+ * only after the pane is already quiet, so the common fast-idle delivery still
3709
+ * costs a single capture.
3710
+ */
3711
+ async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000) {
3712
+ if (this.backend?.dropsEnterWhileBusy?.() !== true) {
3713
+ return this.waitForPaneIdleForDelivery(windowId, timeoutMs);
3714
+ }
3715
+ const deadline = Date.now() + timeoutMs;
3716
+ for (;;) {
3717
+ const remaining = deadline - Date.now();
3718
+ if (remaining <= 0)
3719
+ return false;
3720
+ const idle = await this.waitForPaneIdleForDelivery(windowId, remaining);
3721
+ if (!idle)
3722
+ return false;
3723
+ if (await this.isPaneReadyForDelivery(windowId))
3724
+ return true;
3725
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
3726
+ }
3727
+ }
3728
+ /**
3729
+ * F3: if an AgEnD message is sitting unsubmitted in the input row (a previous
3730
+ * delivery's Enter was dropped), submit it with one bare Enter under the pane
3731
+ * lock. Only acts on a positive signature — the `[user:`/`[from:` marker every
3732
+ * pasted message carries — never on Kiro's own placeholder hints.
3733
+ *
3734
+ * Outcomes: "idle" = positively verified nothing stranded (the caller may
3735
+ * paste); "submitted" = an Enter went out for the stranded text; "busy" = the
3736
+ * pane is not at its prompt or could not be read (the caller must wait for
3737
+ * the prompt and run this check AGAIN before pasting — never assume someone
3738
+ * else submitted it); "failed" = the Enter could not be sent (the delivery
3739
+ * must fail rather than paste on top of the stranded text).
3740
+ */
3741
+ async submitStrandedInputIfAny(windowId) {
3742
+ if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
3743
+ return "idle";
3744
+ const prompt = this.backend.getBottomReadyPattern?.();
3745
+ if (!prompt)
3746
+ return "idle";
3747
+ // Pre-check outside the lock so the common case (verified clear) never
3748
+ // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
3749
+ // lock; an unreadable pane is re-examined under it, not waved through.
3750
+ const pre = await this.strandedInputState(prompt);
3751
+ if (pre === "clear")
3752
+ return "idle";
3753
+ if (pre === "busy")
3754
+ return "busy";
3755
+ if (pre === "stranded") {
3756
+ this.logger.warn({ windowId }, "Unsubmitted message found in the input row — submitting it before this delivery");
3757
+ }
3758
+ return this.paneWriteLock.run(async () => {
3759
+ // Re-check UNDER the lock right before the key goes out: between the
3760
+ // pre-check and acquiring the lock the CLI may have started generating
3761
+ // (the stranded text got submitted by someone else, or the dismisser
3762
+ // acted), or a fatal startup screen may have come up. A bare Enter is a
3763
+ // no-op at an empty prompt, but into a modal it is a confirmation.
3764
+ if (this.fatalStartupBlocked)
3765
+ return "busy";
3766
+ const state = await this.strandedInputState(prompt);
3767
+ if (state === "clear")
3768
+ return "idle";
3769
+ if (state !== "stranded")
3770
+ return "busy"; // busy, or unreadable: no evidence the prompt is free
3771
+ const sent = await this.sendDeliveryEnter("stranded-input-submit");
3772
+ return sent ? "submitted" : "failed";
3773
+ });
3774
+ }
3775
+ /**
3776
+ * "stranded": an AgEnD message sits in the input row; "clear": the prompt is
3777
+ * at the bottom with no stranded text; "busy": generating, or the prompt is
3778
+ * not at the bottom (incl. a blank frame); "unknown": the pane could not be
3779
+ * read. Only "clear" is positive evidence.
3780
+ */
3781
+ async strandedInputState(prompt) {
3782
+ let pane;
3783
+ try {
3784
+ pane = await this.tmux.capturePane();
3785
+ }
3786
+ catch {
3787
+ return "unknown";
3788
+ }
3789
+ if (this.backend?.getBusyPattern?.()?.test(pane))
3790
+ return "busy";
3791
+ if (strandedAgendMessageInInput(pane, prompt))
3792
+ return "stranded";
3793
+ return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
3794
+ }
3795
+ /**
3796
+ * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
3797
+ * turn that made the pane look idle keeps producing output. Confirm the
3798
+ * SUBMISSION instead: the pasted text is gone from the input row ("absent"),
3799
+ * or the TUI's own busy marker is up. "unknown" = the pane could not be read;
3800
+ * it never counts as confirmed.
3801
+ */
3802
+ async pasteResidue(formatted) {
3803
+ const prompt = this.backend?.getBottomReadyPattern?.();
3804
+ if (!prompt || !this.tmux)
3805
+ return "absent";
3806
+ let pane;
3807
+ try {
3808
+ pane = await this.tmux.capturePane();
3809
+ }
3810
+ catch {
3811
+ return "unknown";
3812
+ }
3813
+ if (this.backend?.getBusyPattern?.()?.test(pane))
3814
+ return "absent"; // generating = submitted
3815
+ return pasteLeftInInput(pane, prompt, formatted) ? "present" : "absent";
3816
+ }
3817
+ /**
3818
+ * Submission = the legacy idle→busy output signal AND the pasted text has
3819
+ * verifiably left the input row. The first alone is what produced false ✅s:
3820
+ * the turn that made the pane look idle keeps emitting output after our
3821
+ * (dropped) Enter — and an unreadable pane is not a verified absence either.
3822
+ */
3823
+ async confirmSubmittedAfterEnter(windowId, enterAt, formatted) {
3824
+ const sawOutput = await this.confirmBusyAfterEnter(windowId, enterAt);
3825
+ const residue = await this.pasteResidue(formatted);
3826
+ if (residue === "unknown")
3827
+ this.logger.warn("Could not read the pane after Enter — not confirming the submission");
3828
+ return sawOutput && residue === "absent";
3829
+ }
3830
+ /**
3831
+ * While the CLI is parked on a fatal startup screen (see fatalStartupBlocked),
3832
+ * no pane write may happen: paste+Enter into the corrupt-config modal would
3833
+ * confirm "Exit and fix manually" and exit the CLI. The message is failed (❌)
3834
+ * rather than held — the accompanying config_error notification tells the user
3835
+ * what to fix, and holding would only build a queue that floods the pane the
3836
+ * moment the flag clears.
3837
+ */
3838
+ refuseFatalStartupDelivery(status) {
3839
+ if (!this.fatalStartupBlocked)
3840
+ return false;
3841
+ this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
3842
+ if (status)
3843
+ this.emit("message_failed", status); // ❌
3844
+ return true;
3845
+ }
3846
+ /**
3847
+ * A raw `%output` silence check is authoritative for ordinary TUIs. It is not
3848
+ * for a backend such as agy whose status-line hook periodically redraws an
3849
+ * identical screen. In that case trust an idle pane-state snapshot only when
3850
+ * it was captured after both the latest output and the latest control-client
3851
+ * observation reset.
3852
+ */
3853
+ isPaneIdleForDelivery(windowId) {
3854
+ if (!this.controlClient)
3855
+ return true;
3856
+ if (this.controlClient.isIdle(windowId))
3857
+ return true;
3858
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true || this.instanceState !== "idle")
3859
+ return false;
3860
+ const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
3861
+ if (lastOutputAt === undefined || !this.instanceStateMachine)
3862
+ return false;
3863
+ const observedAt = this.instanceStateMachine.snapshot().observedAt;
3864
+ return observedAt >= lastOutputAt
3865
+ && observedAt >= this.controlClient.getObservationResetAt();
3866
+ }
3867
+ waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
3868
+ if (!this.controlClient)
3869
+ return Promise.resolve(true);
3870
+ if (this.backend?.hasPeriodicPaneRedraw?.() !== true) {
3871
+ return this.controlClient.waitUntilIdle(windowId, timeoutMs);
3872
+ }
3873
+ if (this.isPaneIdleForDelivery(windowId))
3874
+ return Promise.resolve(true);
3875
+ return new Promise(resolve => {
3876
+ const check = setInterval(() => {
3877
+ if (!this.isPaneIdleForDelivery(windowId))
3878
+ return;
3879
+ clearInterval(check);
3880
+ clearTimeout(timer);
3881
+ resolve(true);
3882
+ }, 50);
3883
+ const timer = setTimeout(() => {
3884
+ clearInterval(check);
3885
+ resolve(false);
3886
+ }, timeoutMs);
3887
+ });
2616
3888
  }
2617
3889
  /**
2618
3890
  * The write half of a delivery: paste → Enter → confirm, with retries.
@@ -2640,7 +3912,16 @@ export class Daemon extends EventEmitter {
2640
3912
  const pasteStartedAt = Date.now();
2641
3913
  const pasted = await this.tmux.pasteBuffer(formatted);
2642
3914
  if (!pasted) {
2643
- this.logger.warn({ attempt }, "pasteBuffer failed — recovering window and backing off");
3915
+ const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
3916
+ const recoverable = this.tmux.isLastPasteFailureRecoverable?.() ?? true;
3917
+ this.logger[recoverable ? "warn" : "error"]({ attempt, tmuxError, recoverable }, recoverable
3918
+ ? "pasteBuffer failed — recovering window and backing off"
3919
+ : "pasteBuffer failed — non-retryable tmux error");
3920
+ if (!recoverable) {
3921
+ if (status)
3922
+ this.emit("message_failed", status);
3923
+ return false;
3924
+ }
2644
3925
  windowId = (await this.recoverWindow()) ?? windowId;
2645
3926
  if (attempt < maxAttempts)
2646
3927
  await new Promise(r => setTimeout(r, 2000));
@@ -2722,7 +4003,9 @@ export class Daemon extends EventEmitter {
2722
4003
  }
2723
4004
  const repasted = await this.tmux.pasteBuffer(formatted);
2724
4005
  if (!repasted) {
2725
- this.logger.error("Idle-gated redelivery paste failed after native-queue silent loss");
4006
+ this.logger.error({
4007
+ tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
4008
+ }, "Idle-gated redelivery paste failed after native-queue silent loss");
2726
4009
  if (status)
2727
4010
  this.emit("message_failed", status); // ❌
2728
4011
  return false;
@@ -2762,7 +4045,36 @@ export class Daemon extends EventEmitter {
2762
4045
  this.emit("message_failed", status); // ❌
2763
4046
  return false;
2764
4047
  }
2765
- if (windowId && this.controlClient) {
4048
+ if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
4049
+ // F2: output after Enter is necessary but not sufficient — the paste
4050
+ // must also have LEFT the input row.
4051
+ let submitted = await this.confirmSubmittedAfterEnter(windowId, enterAt, formatted);
4052
+ if (!submitted) {
4053
+ // The Enter landed while the TUI was still busy (it kept the text but
4054
+ // dropped the key). A retry is only useful once the prompt is back —
4055
+ // bounded, since we hold the pane lock here.
4056
+ this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
4057
+ const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
4058
+ const retryAt = Date.now();
4059
+ if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
4060
+ if (status)
4061
+ this.emit("message_failed", status); // ❌
4062
+ return false;
4063
+ }
4064
+ submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
4065
+ }
4066
+ if (submitted) {
4067
+ if (status)
4068
+ this.emit("message_confirmed", status); // ✅
4069
+ }
4070
+ else {
4071
+ this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
4072
+ if (status)
4073
+ this.emit("message_failed", status); // ❌
4074
+ return false;
4075
+ }
4076
+ }
4077
+ else if (windowId && this.controlClient) {
2766
4078
  let becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
2767
4079
  if (!becameBusy) {
2768
4080
  this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
@@ -2922,8 +4234,13 @@ export class Daemon extends EventEmitter {
2922
4234
  // for this turn: the agent proved it can still speak for itself.
2923
4235
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
2924
4236
  this.turnOutboundDelivered = true;
4237
+ if (tool === "reply")
4238
+ this.turnReplyDelivered = true;
4239
+ }
4240
+ const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4241
+ if (!sent) {
4242
+ this.logger.warn({ tool, requestId }, "Tool result could not be returned — originating MCP socket is closed");
2925
4243
  }
2926
- this.ipcServer?.send(socket, { requestId, result, error });
2927
4244
  };
2928
4245
  // Repo checkout — handled locally in daemon (no fleet-manager)
2929
4246
  if (tool === "checkout_repo") {
@@ -2936,13 +4253,7 @@ export class Daemon extends EventEmitter {
2936
4253
  }
2937
4254
  if (tool === "set_display_name" || tool === "set_description") {
2938
4255
  const type = tool === "set_display_name" ? "fleet_set_display_name" : "fleet_set_description";
2939
- const fleetReqId = `${tool === "set_display_name" ? "dn" : "desc"}_${requestId}`;
2940
- this.ipcServer?.broadcast({
2941
- type,
2942
- payload: args,
2943
- meta: { instance_name: this.name },
2944
- fleetRequestId: fleetReqId,
2945
- });
4256
+ const fleetReqId = `${tool === "set_display_name" ? "dn" : "desc"}_${++this.fleetRequestSeq}_${requestId}`;
2946
4257
  const timeout = setTimeout(() => {
2947
4258
  this.pendingIpcRequests.delete(fleetReqId);
2948
4259
  respond(null, `${tool} timed out`);
@@ -2951,16 +4262,16 @@ export class Daemon extends EventEmitter {
2951
4262
  clearTimeout(timeout);
2952
4263
  respond(respMsg.result, respMsg.error);
2953
4264
  });
2954
- return;
2955
- }
2956
- if (tool === TASK_TOOL) {
2957
- const fleetReqId = `task_${requestId}`;
2958
4265
  this.ipcServer?.broadcast({
2959
- type: "fleet_task",
4266
+ type,
2960
4267
  payload: args,
2961
4268
  meta: { instance_name: this.name },
2962
4269
  fleetRequestId: fleetReqId,
2963
4270
  });
4271
+ return;
4272
+ }
4273
+ if (tool === TASK_TOOL) {
4274
+ const fleetReqId = `task_${++this.fleetRequestSeq}_${requestId}`;
2964
4275
  const timeout = setTimeout(() => {
2965
4276
  this.pendingIpcRequests.delete(fleetReqId);
2966
4277
  respond(null, `Task operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
@@ -2969,6 +4280,12 @@ export class Daemon extends EventEmitter {
2969
4280
  clearTimeout(timeout);
2970
4281
  respond(respMsg.result, respMsg.error);
2971
4282
  });
4283
+ this.ipcServer?.broadcast({
4284
+ type: "fleet_task",
4285
+ payload: args,
4286
+ meta: { instance_name: this.name },
4287
+ fleetRequestId: fleetReqId,
4288
+ });
2972
4289
  return;
2973
4290
  }
2974
4291
  if (DECISION_TOOLS.has(tool)) {
@@ -2977,13 +4294,7 @@ export class Daemon extends EventEmitter {
2977
4294
  list_decisions: "fleet_decision_list",
2978
4295
  update_decision: "fleet_decision_update",
2979
4296
  };
2980
- const fleetReqId = `dec_${requestId}`;
2981
- this.ipcServer?.broadcast({
2982
- type: typeMap[tool],
2983
- payload: args,
2984
- meta: { instance_name: this.name, working_directory: this.config.working_directory },
2985
- fleetRequestId: fleetReqId,
2986
- });
4297
+ const fleetReqId = `dec_${++this.fleetRequestSeq}_${requestId}`;
2987
4298
  const timeout = setTimeout(() => {
2988
4299
  this.pendingIpcRequests.delete(fleetReqId);
2989
4300
  respond(null, `Decision operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
@@ -2992,6 +4303,12 @@ export class Daemon extends EventEmitter {
2992
4303
  clearTimeout(timeout);
2993
4304
  respond(respMsg.result, respMsg.error);
2994
4305
  });
4306
+ this.ipcServer?.broadcast({
4307
+ type: typeMap[tool],
4308
+ payload: args,
4309
+ meta: { instance_name: this.name, working_directory: this.config.working_directory },
4310
+ fleetRequestId: fleetReqId,
4311
+ });
2995
4312
  return;
2996
4313
  }
2997
4314
  if (SCHEDULE_TOOLS.has(tool)) {
@@ -3003,13 +4320,7 @@ export class Daemon extends EventEmitter {
3003
4320
  };
3004
4321
  // Use fleetRequestId (not requestId) to avoid MCP server resolving the
3005
4322
  // pending tool call prematurely when it receives the broadcast.
3006
- const fleetReqId = `sched_${requestId}`;
3007
- this.ipcServer?.broadcast({
3008
- type: typeMap[tool],
3009
- payload: args,
3010
- meta: { chat_id: this.lastChatId, thread_id: this.lastThreadId, instance_name: this.name },
3011
- fleetRequestId: fleetReqId,
3012
- });
4323
+ const fleetReqId = `sched_${++this.fleetRequestSeq}_${requestId}`;
3013
4324
  // Wait for fleet_schedule_response via pending request map
3014
4325
  const timeout = setTimeout(() => {
3015
4326
  this.pendingIpcRequests.delete(fleetReqId);
@@ -3019,6 +4330,12 @@ export class Daemon extends EventEmitter {
3019
4330
  clearTimeout(timeout);
3020
4331
  respond(respMsg.result, respMsg.error);
3021
4332
  });
4333
+ this.ipcServer?.broadcast({
4334
+ type: typeMap[tool],
4335
+ payload: args,
4336
+ meta: { chat_id: this.lastChatId, thread_id: this.lastThreadId, instance_name: this.name },
4337
+ fleetRequestId: fleetReqId,
4338
+ });
3022
4339
  return;
3023
4340
  }
3024
4341
  if (CROSS_INSTANCE_TOOLS.has(tool)) {
@@ -3026,15 +4343,8 @@ export class Daemon extends EventEmitter {
3026
4343
  if (this.topicMode && this.ipcServer) {
3027
4344
  // Use fleetRequestId (not requestId) to avoid MCP server resolving the
3028
4345
  // pending tool call prematurely when it receives the broadcast.
3029
- const fleetReqId = `xmsg_${requestId}`;
4346
+ const fleetReqId = `xmsg_${++this.fleetRequestSeq}_${requestId}`;
3030
4347
  const senderSessionName = this.socketSessionNames.get(socket);
3031
- this.ipcServer.broadcast({
3032
- type: "fleet_outbound",
3033
- tool,
3034
- args,
3035
- fleetRequestId: fleetReqId,
3036
- senderSessionName,
3037
- });
3038
4348
  // Shared table, so delete_instance and the deployment tools get the same
3039
4349
  // budget here as the MCP client expects (the ad-hoc list omitted them).
3040
4350
  const crossTimeoutMs = daemonBudgetMs(tool);
@@ -3046,6 +4356,13 @@ export class Daemon extends EventEmitter {
3046
4356
  clearTimeout(timeout);
3047
4357
  respond(respMsg.result, respMsg.error);
3048
4358
  });
4359
+ this.ipcServer.broadcast({
4360
+ type: "fleet_outbound",
4361
+ tool,
4362
+ args,
4363
+ fleetRequestId: fleetReqId,
4364
+ senderSessionName,
4365
+ });
3049
4366
  }
3050
4367
  else {
3051
4368
  respond(null, "Cross-instance messaging requires topic mode");
@@ -3079,8 +4396,24 @@ export class Daemon extends EventEmitter {
3079
4396
  // The fleet manager's IPC client receives this and routes to shared adapter.
3080
4397
  // Use fleetRequestId (not requestId) to avoid other MCP sessions on this daemon
3081
4398
  // from prematurely resolving their pending requests when they receive the broadcast.
3082
- const fleetReqId = `tool_${requestId}`;
4399
+ // requestId is local to one MCP server process. Claude may keep multiple
4400
+ // MCP children attached (main session, resumed session, subagent); those
4401
+ // processes all emit requestId=1,2,... and previously overwrote this
4402
+ // shared pending map. The wrong caller could then receive another POST's
4403
+ // success + messageId while its own reply had never been confirmed.
4404
+ const fleetReqId = `tool_${++this.fleetRequestSeq}_${requestId}`;
3083
4405
  const outboundKey = fleetReqId;
4406
+ const timeout = setTimeout(() => {
4407
+ this.pendingIpcRequests.delete(outboundKey);
4408
+ respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
4409
+ }, daemonBudgetMs(tool));
4410
+ this.pendingIpcRequests.set(outboundKey, (respMsg) => {
4411
+ clearTimeout(timeout);
4412
+ respond(respMsg.result, respMsg.error);
4413
+ });
4414
+ // Register the waiter before broadcasting. A fast/mock adapter can settle
4415
+ // immediately; broadcasting first left a response-before-registration
4416
+ // window that degraded into a timeout despite a completed POST.
3084
4417
  this.ipcServer?.broadcast({
3085
4418
  type: "fleet_outbound",
3086
4419
  tool,
@@ -3091,14 +4424,6 @@ export class Daemon extends EventEmitter {
3091
4424
  // from a secondary world, and message ids are not portable across bots.
3092
4425
  adapterId: this.lastAdapterId,
3093
4426
  });
3094
- const timeout = setTimeout(() => {
3095
- this.pendingIpcRequests.delete(outboundKey);
3096
- respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
3097
- }, daemonBudgetMs(tool));
3098
- this.pendingIpcRequests.set(outboundKey, (respMsg) => {
3099
- clearTimeout(timeout);
3100
- respond(respMsg.result, respMsg.error);
3101
- });
3102
4427
  return;
3103
4428
  }
3104
4429
  const adapter = adapters[0];
@@ -3108,7 +4433,10 @@ export class Daemon extends EventEmitter {
3108
4433
  }
3109
4434
  /** Build config object for the CLI backend */
3110
4435
  buildBackendConfig() {
3111
- const isCliMode = this.config.agent_mode === "cli" || (this.config.agent_mode == null && this.config.backend === "antigravity");
4436
+ // Antigravity 1.1.17 has no per-workspace MCP config, but its global MCP
4437
+ // children inherit the CLI process environment. The backend installs one
4438
+ // environment-selecting launcher, so MCP is now the safe default here too.
4439
+ const isCliMode = this.config.agent_mode === "cli";
3112
4440
  const sockPath = join(this.instanceDir, "channel.sock");
3113
4441
  let serverJs = join(__dirname, "channel", "mcp-server.js");
3114
4442
  if (!existsSync(serverJs)) {
@@ -3245,6 +4573,8 @@ export class Daemon extends EventEmitter {
3245
4573
  });
3246
4574
  }
3247
4575
  const agentPort = parseInt(process.env.AGEND_PORT ?? "19280", 10);
4576
+ const backendName = this.config.backend ?? "claude-code";
4577
+ const backendOptions = this.config.backend_options?.[backendName];
3248
4578
  return {
3249
4579
  workingDirectory: this.config.working_directory,
3250
4580
  instanceDir: this.instanceDir,
@@ -3264,6 +4594,7 @@ export class Daemon extends EventEmitter {
3264
4594
  instructions,
3265
4595
  agentMode: isCliMode ? "cli" : "mcp",
3266
4596
  agentPort: isCliMode ? agentPort : undefined,
4597
+ backendOptions,
3267
4598
  };
3268
4599
  }
3269
4600
  /**
@@ -3364,9 +4695,35 @@ export class Daemon extends EventEmitter {
3364
4695
  throw new Error("No backend configured — cannot spawn CLI window");
3365
4696
  }
3366
4697
  const attemptedResume = !this.skipResume;
3367
- const alive = await this.trySpawn();
4698
+ // A resume launch may get a longer budget than a fresh one (kiro: the
4699
+ // conversation must come back from the backend before anything paints).
4700
+ const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
4701
+ let alive = await this.trySpawn(false, resumeBudget);
4702
+ if (!alive && attemptedResume) {
4703
+ // Resume failed. Before abandoning the session:
4704
+ // 1. If the backend is unreachable (fleet-level memory, or this very pane
4705
+ // shows the outage text), clearing the session would trade a
4706
+ // transient outage for permanent conversation loss — and the fresh
4707
+ // start would hang on the same backend. Fail the startup and keep the
4708
+ // session; the fleet's delayed retry comes back once it is up.
4709
+ // 2. Otherwise, for backends that ask for it, retry resume ONCE — the
4710
+ // first miss is usually slowness, not a broken session.
4711
+ await this.noteStartupPaneForBackendOutage();
4712
+ await this.failStartupIfBackendUnreachable();
4713
+ if (this.backend.retriesResumeOnStartupFailure?.() === true) {
4714
+ this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
4715
+ await this.killProcessTree();
4716
+ await this.tmux.killWindow();
4717
+ alive = await this.trySpawn(false, resumeBudget);
4718
+ if (!alive) {
4719
+ await this.noteStartupPaneForBackendOutage();
4720
+ await this.failStartupIfBackendUnreachable();
4721
+ }
4722
+ }
4723
+ }
3368
4724
  if (!alive) {
3369
- // First attempt failed (stale --resume, crash, rate limit, etc.)
4725
+ // Resume (or a fresh start) failed for a reason we do not recognise as an
4726
+ // outage (stale --resume, crash, rate limit, etc.).
3370
4727
  // Clean slate: clear session-id, skip resume, and retry once.
3371
4728
  this.logger.warn("CLI startup failed — clearing session-id and retrying without resume");
3372
4729
  const sidFile = join(this.instanceDir, "session-id");
@@ -3377,7 +4734,7 @@ export class Daemon extends EventEmitter {
3377
4734
  this.skipResume = true;
3378
4735
  await this.killProcessTree();
3379
4736
  await this.tmux.killWindow();
3380
- const retryAlive = await this.trySpawn();
4737
+ const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
3381
4738
  if (!retryAlive) {
3382
4739
  await this.killProcessTree();
3383
4740
  await this.tmux.killWindow();
@@ -3386,6 +4743,9 @@ export class Daemon extends EventEmitter {
3386
4743
  }
3387
4744
  else if (attemptedResume) {
3388
4745
  resumedSuccessfully = true;
4746
+ // A resume needs the backend: its success is positive proof the backend
4747
+ // is reachable again (a fresh prompt is local and proves nothing).
4748
+ this.backendOutage?.clear(this.backendKey());
3389
4749
  }
3390
4750
  this.lastSpawnAt = Date.now();
3391
4751
  this.skipResume = false; // CLI started successfully — reset for next spawn
@@ -3396,6 +4756,122 @@ export class Daemon extends EventEmitter {
3396
4756
  }
3397
4757
  return resumedSuccessfully;
3398
4758
  }
4759
+ /**
4760
+ * Startup budget for this launch: the backend's override (resume-aware), never
4761
+ * below a user-configured startup_timeout_ms; undefined = trySpawn's default.
4762
+ */
4763
+ startupBudgetFor(resume) {
4764
+ const override = this.backend?.getStartupBudgetMs?.({ resume });
4765
+ if (override == null)
4766
+ return undefined;
4767
+ const configured = this.config.startup_timeout_ms;
4768
+ return configured != null ? Math.max(configured, override) : override;
4769
+ }
4770
+ /** Effective wake budget = max(caller timeout, backend resume override, configured startup_timeout_ms). */
4771
+ wakeBudgetMs(timeoutMs) {
4772
+ return Math.max(timeoutMs, this.startupBudgetFor(!this.skipResume) ?? 0, this.config.startup_timeout_ms ?? 0);
4773
+ }
4774
+ /** The fleet-level backend key this instance runs on (matches the lifecycle's backendOf). */
4775
+ backendKey() {
4776
+ return this.runtimeIdentity?.backend ?? this.config.backend ?? this.backend?.binaryName ?? "unknown";
4777
+ }
4778
+ /**
4779
+ * After a failed launch, read the pane for the backend's fleet-wide outage
4780
+ * text (e.g. kiro's `dispatch failure (timeout) … kiro.dev`) and record it.
4781
+ * The lifecycle only learns about outages from RUNNING instances; during a
4782
+ * post-update herd nothing is running yet, so the startup path must report
4783
+ * what it sees or the first instance in would clear its session for nothing.
4784
+ */
4785
+ async noteStartupPaneForBackendOutage() {
4786
+ if (!this.backendOutage || !this.backend || !this.tmux)
4787
+ return;
4788
+ const outagePattern = this.backend.getErrorPatterns?.().find(p => p.fleetWide && p.type === "network");
4789
+ if (!outagePattern)
4790
+ return;
4791
+ try {
4792
+ const pane = await this.tmux.capturePane();
4793
+ if (!outagePattern.pattern.test(pane))
4794
+ return;
4795
+ const detail = this.resolveErrorMessage(pane, outagePattern);
4796
+ this.backendOutage.record(this.backendKey(), this.name, detail);
4797
+ this.logger.warn({ detail }, "Startup pane shows the backend outage text");
4798
+ }
4799
+ catch { /* capture failed — nothing to learn */ }
4800
+ }
4801
+ /**
4802
+ * Fail the startup WITHOUT touching the session while the backend is known to
4803
+ * be down. The failed window is torn down first: the alternative — a live but
4804
+ * never-ready CLI left behind while the daemon reports `crashed` — would keep
4805
+ * the health monitor from ever seeing a dead pane and retrying.
4806
+ */
4807
+ async failStartupIfBackendUnreachable() {
4808
+ if (!this.backendOutage?.isActive(this.backendKey()))
4809
+ return;
4810
+ this.logger.warn("Backend unreachable — keeping the session and failing startup for a delayed retry");
4811
+ await this.killProcessTree();
4812
+ await this.tmux.killWindow();
4813
+ throw new BackendUnreachableStartupError(this.backendKey());
4814
+ }
4815
+ /**
4816
+ * Crash-respawn ran into the backend outage: the ordinary path would leave
4817
+ * `crashed` + a paused monitor + nothing scheduled (and count it as a crash).
4818
+ * Hand the instance to the fleet instead — the lifecycle stops this daemon
4819
+ * and the fleet's delayed startup retry re-creates it later with the session
4820
+ * intact. Returns true when someone took the hand-off.
4821
+ */
4822
+ handOffBackendUnreachableRespawn(err) {
4823
+ if (!(err instanceof BackendUnreachableStartupError))
4824
+ return false;
4825
+ if (this.listenerCount("startup_backend_unreachable") === 0)
4826
+ return false;
4827
+ this.logger.warn({ backend: err.backend }, "Respawn blocked by backend outage — handing off to the fleet's delayed startup retry");
4828
+ this.healthCheckPaused = true;
4829
+ this.emit("startup_backend_unreachable", { name: this.name, backend: err.backend });
4830
+ return true;
4831
+ }
4832
+ /**
4833
+ * Dispose a Daemon whose start() rejected. The lifecycle registers a daemon
4834
+ * only after start() resolves, so nothing else owns this object — and by the
4835
+ * time the spawn fails it has already bound its IPC server (channel.sock),
4836
+ * written daemon.pid and possibly created a window. Left alone, each failed
4837
+ * start leaks one live-but-unreachable server handle (the next start unlinks
4838
+ * the socket path and binds a new one), multiplied by the automatic startup
4839
+ * retries. Idempotent, and fast: no graceful CLI quit, the CLI never reached
4840
+ * its prompt.
4841
+ */
4842
+ async abortStartup() {
4843
+ if (this.startupAborted)
4844
+ return;
4845
+ this.startupAborted = true;
4846
+ this.freezeRuntimeMonitors();
4847
+ this.pendingIpcRequests.clear();
4848
+ try {
4849
+ await this.killProcessTree();
4850
+ }
4851
+ catch { /* nothing running */ }
4852
+ if (this.tmux) {
4853
+ const windowId = this.tmux.getWindowId();
4854
+ try {
4855
+ await this.tmux.killWindow();
4856
+ }
4857
+ catch { /* window may not exist */ }
4858
+ if (windowId)
4859
+ this.controlClient?.unregisterWindow(windowId);
4860
+ }
4861
+ try {
4862
+ await this.ipcServer?.close();
4863
+ }
4864
+ catch (err) {
4865
+ this.logger.debug({ err }, "IPC server close failed during startup abort");
4866
+ }
4867
+ this.ipcServer = null;
4868
+ for (const file of ["daemon.pid", "window-id"]) {
4869
+ try {
4870
+ unlinkSync(join(this.instanceDir, file));
4871
+ }
4872
+ catch { /* absent */ }
4873
+ }
4874
+ }
3399
4875
  /** Kill the entire process tree of the current tmux pane (CLI + MCP server). */
3400
4876
  async killProcessTree(signal = "SIGTERM") {
3401
4877
  if (!this.tmux)
@@ -3416,6 +4892,15 @@ export class Daemon extends EventEmitter {
3416
4892
  * Returns true if CLI is ready, false if it failed or got stuck.
3417
4893
  */
3418
4894
  async trySpawn(reuseWindow = false, startupTimeoutMs) {
4895
+ if (!this.spawnGate)
4896
+ return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
4897
+ return this.spawnGate.run({
4898
+ instanceName: this.name,
4899
+ workingDirectory: this.config.working_directory,
4900
+ reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
4901
+ }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
4902
+ }
4903
+ async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
3419
4904
  const backendConfig = this.buildBackendConfig();
3420
4905
  // Compare freshly-built instructions against the last value the agent was
3421
4906
  // told about. Computed for ALL backends (not gated by
@@ -3480,6 +4965,9 @@ export class Daemon extends EventEmitter {
3480
4965
  const cmd = `${envPrefix} ` + this.backend.buildCommand(backendConfig);
3481
4966
  // Ensure tmux session exists (may have been destroyed if all windows died)
3482
4967
  await TmuxManager.ensureSession(this.tmuxSessionName);
4968
+ if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
4969
+ this.emit("tmux_server_crash", this.name);
4970
+ }
3483
4971
  let windowId;
3484
4972
  if (reuseWindow) {
3485
4973
  this.controlClient?.unregisterWindow(this.tmux.getWindowId());
@@ -3534,7 +5022,10 @@ export class Daemon extends EventEmitter {
3534
5022
  if (!await this.tmux.isWindowAlive())
3535
5023
  return false;
3536
5024
  const ready = await this.dismissDialogsUntilReady(3);
3537
- if (ready)
5025
+ // A fatal startup screen returns true (the spawn is over — respawning
5026
+ // would only re-show the same screen) but is NOT ready-for-delivery:
5027
+ // fatalStartupBlocked gates every pane write in deliverMessage.
5028
+ if (ready && !this.fatalStartupBlocked)
3538
5029
  this.firstDeliveryDelay.recordReady();
3539
5030
  return ready;
3540
5031
  }
@@ -3557,6 +5048,26 @@ export class Daemon extends EventEmitter {
3557
5048
  let matched = false;
3558
5049
  for (const dialog of startupDialogs) {
3559
5050
  if (dialog.pattern.test(pane)) {
5051
+ // A fatal startup screen cannot be dismissed by keypresses (e.g. a
5052
+ // corrupt claude.json modal offering only "exit" / "reset config").
5053
+ // It must be caught HERE and not fall through to the ready check:
5054
+ // the modal contains the `❯` selector the ready pattern matches, so
5055
+ // it would otherwise pass as ready and queued messages would be
5056
+ // typed into the modal. Report the error (pause stops delivery) and
5057
+ // stop the retry loop — like the sign-in screen case below, only a
5058
+ // human fixing the config ends this state.
5059
+ if (dialog.fatal) {
5060
+ // Block deliveries BEFORE the emit: the pty_error pause handler
5061
+ // is async, and a queued message would otherwise win the race
5062
+ // and be pasted into the modal.
5063
+ this.fatalStartupBlocked = true;
5064
+ if (!this.fatalStartupReported) {
5065
+ this.fatalStartupReported = true;
5066
+ this.logger.warn({ description: dialog.description }, "Fatal startup screen detected — reporting instead of dismissing");
5067
+ this.emit("pty_error", { name: this.name, ...dialog.fatal });
5068
+ }
5069
+ return true;
5070
+ }
3560
5071
  this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
3561
5072
  // Restart is exactly when inbound messages pile up, and nothing gates
3562
5073
  // delivery on `spawning`. Take the pane lock for the key sequence so a
@@ -3589,8 +5100,31 @@ export class Daemon extends EventEmitter {
3589
5100
  if (matched)
3590
5101
  continue;
3591
5102
  // CLI is ready (pattern defined by each backend)
3592
- if (this.backend.getReadyPattern().test(pane))
5103
+ if (this.backend.getReadyPattern().test(pane)) {
5104
+ // A real ready prompt (with no fatal dialog on screen — those are
5105
+ // matched above, before this check) means the fatal screen is gone,
5106
+ // e.g. the user fixed the config and the instance respawned.
5107
+ this.fatalStartupBlocked = false;
3593
5108
  return true;
5109
+ }
5110
+ // A CLI parked at its own sign-in screen is an AUTH incident: no dialog
5111
+ // key can dismiss it and no ready pattern will ever appear. Report it as
5112
+ // an auth error (the lifecycle double-checks with the token-free probe)
5113
+ // instead of letting it decay into crash/hang/MCP-died noise, and stop
5114
+ // the retry loop — only re-login (see /login) ends this state.
5115
+ const loginScreen = LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenPattern;
5116
+ if (loginScreen?.test(pane) && !this.loginScreenReported) {
5117
+ this.loginScreenReported = true;
5118
+ this.authFailureUnresolved = true;
5119
+ this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
5120
+ this.emit("pty_error", {
5121
+ name: this.name,
5122
+ type: "auth_error",
5123
+ action: "pause",
5124
+ message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
5125
+ });
5126
+ return true;
5127
+ }
3594
5128
  // Fatal: command not found (must match full phrase to avoid false positives
3595
5129
  // like Kiro's "agent X not found, using default")
3596
5130
  if (/command not found|: not found$/m.test(pane))
@@ -3627,9 +5161,29 @@ export class Daemon extends EventEmitter {
3627
5161
  // back from statusline.json — that would re-arm --continue and re-loop.
3628
5162
  if (this.skipResume)
3629
5163
  return;
3630
- const sid = this.backend?.getSessionId();
3631
- if (sid) {
3632
- writeFileSync(join(this.instanceDir, "session-id"), sid);
5164
+ try {
5165
+ const sid = this.backend?.getSessionId();
5166
+ if (!sid)
5167
+ return;
5168
+ const path = join(this.instanceDir, "session-id");
5169
+ // Idle observations happen after turns. Avoid needless writes (and mtime
5170
+ // churn) while still updating the marker when /new changes the session.
5171
+ try {
5172
+ if (readFileSync(path, "utf-8").trim() === sid)
5173
+ return;
5174
+ }
5175
+ catch { /* first checkpoint */ }
5176
+ writeFileSync(path, sid);
5177
+ this.sessionCheckpointWarningEmitted = false;
5178
+ this.logger.debug("Session id checkpointed");
5179
+ }
5180
+ catch (err) {
5181
+ // Session discovery is best-effort and must never break the pane state
5182
+ // monitor. Existing stop/pause checkpoints get another chance later.
5183
+ if (!this.sessionCheckpointWarningEmitted) {
5184
+ this.sessionCheckpointWarningEmitted = true;
5185
+ this.logger.warn({ err: err.message }, "Session id checkpoint failed");
5186
+ }
3633
5187
  }
3634
5188
  }
3635
5189
  readContextPercentage() {