@songsid/agend 2.1.2-beta.15 → 2.1.2-beta.16

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 (122) hide show
  1. package/dist/access-path.js +15 -6
  2. package/dist/access-path.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +37 -0
  4. package/dist/backend/claude-code.js +43 -1
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/codex.js +22 -0
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/grok.d.ts +23 -0
  9. package/dist/backend/grok.js +25 -0
  10. package/dist/backend/grok.js.map +1 -1
  11. package/dist/backend/kiro.d.ts +20 -0
  12. package/dist/backend/kiro.js +59 -0
  13. package/dist/backend/kiro.js.map +1 -1
  14. package/dist/backend/types.d.ts +27 -0
  15. package/dist/backend/types.js +1 -1
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/channel/adapters/discord.d.ts +15 -0
  18. package/dist/channel/adapters/discord.js +90 -1
  19. package/dist/channel/adapters/discord.js.map +1 -1
  20. package/dist/channel/adapters/telegram.d.ts +21 -0
  21. package/dist/channel/adapters/telegram.js +82 -0
  22. package/dist/channel/adapters/telegram.js.map +1 -1
  23. package/dist/channel/ipc-bridge.d.ts +9 -1
  24. package/dist/channel/ipc-bridge.js +12 -3
  25. package/dist/channel/ipc-bridge.js.map +1 -1
  26. package/dist/channel/ipc-timeouts.d.ts +40 -0
  27. package/dist/channel/ipc-timeouts.js +58 -0
  28. package/dist/channel/ipc-timeouts.js.map +1 -0
  29. package/dist/channel/mcp-server.js +25 -14
  30. package/dist/channel/mcp-server.js.map +1 -1
  31. package/dist/channel/mcp-tools.js +4 -2
  32. package/dist/channel/mcp-tools.js.map +1 -1
  33. package/dist/channel/reconnect-backoff.d.ts +17 -0
  34. package/dist/channel/reconnect-backoff.js +21 -0
  35. package/dist/channel/reconnect-backoff.js.map +1 -0
  36. package/dist/channel/types.d.ts +29 -0
  37. package/dist/classic-channel-manager.js +1 -5
  38. package/dist/classic-channel-manager.js.map +1 -1
  39. package/dist/cli.js +129 -50
  40. package/dist/cli.js.map +1 -1
  41. package/dist/completion.d.ts +27 -0
  42. package/dist/completion.js +121 -0
  43. package/dist/completion.js.map +1 -0
  44. package/dist/config-validator.js +9 -0
  45. package/dist/config-validator.js.map +1 -1
  46. package/dist/config.js +1 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/cost-guard.d.ts +3 -1
  49. package/dist/cost-guard.js +3 -1
  50. package/dist/cost-guard.js.map +1 -1
  51. package/dist/daemon.d.ts +164 -5
  52. package/dist/daemon.js +711 -290
  53. package/dist/daemon.js.map +1 -1
  54. package/dist/event-log.d.ts +31 -0
  55. package/dist/event-log.js +96 -0
  56. package/dist/event-log.js.map +1 -1
  57. package/dist/fleet-manager.d.ts +188 -1
  58. package/dist/fleet-manager.js +697 -61
  59. package/dist/fleet-manager.js.map +1 -1
  60. package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -1
  61. package/dist/hang-detector.d.ts +19 -13
  62. package/dist/hang-detector.js +19 -49
  63. package/dist/hang-detector.js.map +1 -1
  64. package/dist/instance-lifecycle.js +25 -0
  65. package/dist/instance-lifecycle.js.map +1 -1
  66. package/dist/locale.js +1 -0
  67. package/dist/locale.js.map +1 -1
  68. package/dist/mcp-liveness.d.ts +21 -0
  69. package/dist/mcp-liveness.js +27 -0
  70. package/dist/mcp-liveness.js.map +1 -0
  71. package/dist/outbound-handlers.d.ts +7 -0
  72. package/dist/outbound-handlers.js +79 -5
  73. package/dist/outbound-handlers.js.map +1 -1
  74. package/dist/outbound-schemas.d.ts +7 -2
  75. package/dist/outbound-schemas.js +13 -2
  76. package/dist/outbound-schemas.js.map +1 -1
  77. package/dist/pane-write-lock.d.ts +48 -0
  78. package/dist/pane-write-lock.js +73 -0
  79. package/dist/pane-write-lock.js.map +1 -0
  80. package/dist/process-memory.d.ts +31 -0
  81. package/dist/process-memory.js +79 -0
  82. package/dist/process-memory.js.map +1 -0
  83. package/dist/quickstart.js +17 -16
  84. package/dist/quickstart.js.map +1 -1
  85. package/dist/scheduler/db.js +3 -0
  86. package/dist/scheduler/db.js.map +1 -1
  87. package/dist/sd-notify.d.ts +27 -0
  88. package/dist/sd-notify.js +33 -1
  89. package/dist/sd-notify.js.map +1 -1
  90. package/dist/secret-file.d.ts +33 -0
  91. package/dist/secret-file.js +36 -0
  92. package/dist/secret-file.js.map +1 -0
  93. package/dist/setup-wizard.js +9 -7
  94. package/dist/setup-wizard.js.map +1 -1
  95. package/dist/tmux-control.d.ts +58 -5
  96. package/dist/tmux-control.js +102 -14
  97. package/dist/tmux-control.js.map +1 -1
  98. package/dist/tmux-manager.d.ts +31 -1
  99. package/dist/tmux-manager.js +40 -11
  100. package/dist/tmux-manager.js.map +1 -1
  101. package/dist/topic-commands.d.ts +22 -2
  102. package/dist/topic-commands.js +102 -19
  103. package/dist/topic-commands.js.map +1 -1
  104. package/dist/types.d.ts +6 -0
  105. package/dist/ui/view.html +123 -2
  106. package/dist/usage/providers.d.ts +50 -0
  107. package/dist/usage/providers.js +611 -0
  108. package/dist/usage/providers.js.map +1 -0
  109. package/dist/usage/usage-api.d.ts +50 -0
  110. package/dist/usage/usage-api.js +111 -0
  111. package/dist/usage/usage-api.js.map +1 -0
  112. package/dist/view-api.js +1 -0
  113. package/dist/view-api.js.map +1 -1
  114. package/dist/web-api.js +5 -2
  115. package/dist/web-api.js.map +1 -1
  116. package/package.json +4 -1
  117. package/dist/channel/tool-tracker.d.ts +0 -13
  118. package/dist/channel/tool-tracker.js +0 -58
  119. package/dist/channel/tool-tracker.js.map +0 -1
  120. package/dist/daemon-entry.d.ts +0 -1
  121. package/dist/daemon-entry.js +0 -30
  122. package/dist/daemon-entry.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -1,19 +1,23 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, chmodSync, renameSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { rotateLogIfNeeded } from "./logger.js";
7
+ import { mcpServerState } from "./mcp-liveness.js";
7
8
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
8
9
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
9
10
  import { TranscriptMonitor } from "./transcript-monitor.js";
10
11
  import { ContextGuardian } from "./context-guardian.js";
11
12
  import { IpcServer } from "./channel/ipc-bridge.js";
13
+ import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
12
14
  import { MessageBus } from "./channel/message-bus.js";
13
15
  import { shellQuote } from "./backend/types.js";
14
16
  import { getTmuxSession } from "./config.js";
15
17
  import { routeToolCall } from "./channel/tool-router.js";
16
18
  import { HangDetector } from "./hang-detector.js";
19
+ import { writeSecretFile } from "./secret-file.js";
20
+ import { PaneWriteLock } from "./pane-write-lock.js";
17
21
  import { buildFleetInstructions } from "./instructions.js";
18
22
  const __filename = fileURLToPath(import.meta.url);
19
23
  const __dirname = dirname(__filename);
@@ -38,9 +42,9 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
38
42
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
39
43
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
40
44
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
41
- /** @deprecated State detection is event-driven; this now aliases the safety sweep. */
42
- export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
43
45
  const LAST_INBOUND_FILE = "last-inbound-at";
46
+ /** Minimum gap between "health check is failing" notifications for one instance. */
47
+ const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
44
48
  /**
45
49
  * Whether two working directories belong to the same project, so a fleet-scoped
46
50
  * decision recorded in one reaches the other. Covers the worktree/checkout
@@ -104,6 +108,40 @@ export function writeLastInboundAt(instanceDir, timestamp) {
104
108
  writeFileSync(temp, String(timestamp));
105
109
  renameSync(temp, target);
106
110
  }
111
+ /**
112
+ * Render the handoff/routing metadata that rides along with an inbound message
113
+ * into the block actually pasted into the agent's pane.
114
+ *
115
+ * These fields were all populated by the sender (outbound-handlers builds them
116
+ * into `ipcMeta`) and delivered over IPC, but never rendered — so the receiving
117
+ * agent could not see them, and several documented flows could not work:
118
+ *
119
+ * - `report_result` requires a `correlation_id` the agent had no way to know, so
120
+ * the "correlation_id not recognized" warning fired on essentially every call.
121
+ * - Delegation cancel buttons are retired by correlation id, so they never retired.
122
+ * - `react`, `edit_message` and `reply.reply_to` need `message_id`, which the tool
123
+ * descriptions tell the agent to take "from the inbound block".
124
+ * - `download_attachment` needs `attachment_file_id`.
125
+ * - `requires_reply` was invisible, so a delegated task looked like an FYI.
126
+ *
127
+ * Only non-empty fields are emitted, so a plain user message gains at most a
128
+ * message_id line.
129
+ */
130
+ export function renderHandoffMetadata(meta) {
131
+ const rows = [];
132
+ const add = (label, value) => {
133
+ if (value && value.trim())
134
+ rows.push(`${label}: ${value.trim()}`);
135
+ };
136
+ add("message_id", meta.message_id);
137
+ add("correlation_id", meta.correlation_id);
138
+ add("request_kind", meta.request_kind);
139
+ add("task_summary", meta.task_summary);
140
+ add("working_directory", meta.working_directory);
141
+ add("branch", meta.branch);
142
+ add("attachment_file_id", meta.attachment_file_id);
143
+ return rows.length ? `\n(${rows.join(" | ")})` : "";
144
+ }
107
145
  /** Headless inactivity timer used by the daemon and unit tests. */
108
146
  export class AutoPauseController {
109
147
  thresholdMs;
@@ -148,24 +186,47 @@ export class AutoPauseController {
148
186
  * Pane motion wins over a ready match because several backends keep their ready
149
187
  * marker in a persistent header/footer while generating. A stable ready pane is
150
188
  * idle; changing content is working; stable non-ready content eventually sticks.
189
+ *
190
+ * ## Why an optional busy pattern exists
191
+ *
192
+ * "Motion wins" degrades badly when a backend's ready marker is *always* on
193
+ * screen. `stuck` requires `!ready`, so for such a backend the state can only ever
194
+ * be idle or working — the stuck edge, and with it `handleStuckTransition` and the
195
+ * hang notification, is unreachable. A frozen CLI is then reported as idle, which
196
+ * also clears pending work: the message the user sent is quietly booked as done.
197
+ *
198
+ * claude-code was exactly this case (see ClaudeCodeBackend.getReadyPattern). A
199
+ * backend that can point at a marker meaning "generating right now" supplies
200
+ * `getBusyPattern()`, and that match vetoes ready regardless of what the ready
201
+ * pattern says.
151
202
  */
152
203
  export class PaneStateMachine {
153
204
  stuckTimeoutMs;
154
205
  readyPattern;
206
+ busyPattern;
155
207
  lastPaneHash = null;
156
208
  lastPaneChangeAt;
157
209
  lastObservedAt;
158
210
  stateChangedAt;
159
211
  currentState = "idle";
160
- constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now()) {
212
+ constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
161
213
  this.stuckTimeoutMs = stuckTimeoutMs;
162
214
  // Stateful g/y regexes mutate lastIndex and can alternate true/false across
163
215
  // polls. State detection must be deterministic for identical pane content.
164
216
  this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
217
+ this.busyPattern = busyPattern
218
+ ? new RegExp(busyPattern.source, busyPattern.flags.replace(/[gy]/g, ""))
219
+ : null;
165
220
  this.lastPaneChangeAt = now;
166
221
  this.lastObservedAt = now;
167
222
  this.stateChangedAt = now;
168
223
  }
224
+ /** Ready, unless the backend can positively see that it is still generating. */
225
+ isReady(pane) {
226
+ if (this.busyPattern?.test(pane))
227
+ return false;
228
+ return this.readyPattern.test(pane);
229
+ }
169
230
  observe(pane, now = Date.now()) {
170
231
  const paneHash = createHash("sha256").update(pane).digest("hex");
171
232
  const firstObservation = this.lastPaneHash === null;
@@ -175,7 +236,7 @@ export class PaneStateMachine {
175
236
  this.lastPaneChangeAt = now;
176
237
  }
177
238
  this.lastObservedAt = now;
178
- const ready = this.readyPattern.test(pane);
239
+ const ready = this.isReady(pane);
179
240
  const nextState = firstObservation
180
241
  ? ready ? "idle" : "working"
181
242
  : paneChanged
@@ -241,6 +302,21 @@ const FIRST_ENTER_SETTLE_MS = 1_750;
241
302
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
242
303
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
243
304
  const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
305
+ /** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
306
+ const CONFIRM_BUSY_POLLS = 10;
307
+ const CONFIRM_BUSY_POLL_MS = 200;
308
+ /**
309
+ * Ceiling on the whole confirmation, including time re-earned after a control-mode
310
+ * reconnect. Without it a reconnect loop could restart the poll indefinitely and
311
+ * hold the pane write lock along with it.
312
+ */
313
+ const CONFIRM_BUSY_MAX_WAIT_MS = 10_000;
314
+ /**
315
+ * How long a delivery waits for an in-flight spawn. Comfortably past the default
316
+ * 25s startup timeout plus dialog dismissal; past it we fall back to the old
317
+ * behaviour rather than holding a message indefinitely.
318
+ */
319
+ const SPAWN_SETTLE_MAX_WAIT_MS = 60_000;
244
320
  /**
245
321
  * One-shot timing gate for the first paste after a CLI reaches its ready
246
322
  * prompt. Recent tmux versions expose a short interval where the prompt is
@@ -299,7 +375,6 @@ export class Daemon extends EventEmitter {
299
375
  ipcServer = null;
300
376
  messageBus;
301
377
  transcriptMonitor = null;
302
- toolTracker = null;
303
378
  guardian = null;
304
379
  adapter = null;
305
380
  pendingIpcRequests = new Map();
@@ -310,6 +385,8 @@ export class Daemon extends EventEmitter {
310
385
  // Pending ack: react 🫡 on first transcript activity after receiving a message
311
386
  pendingAckMessage = null;
312
387
  // Tool status tracking for channel adapter
388
+ /** Last activity published to the fleet manager; null when nothing is running. */
389
+ currentActivity = null;
313
390
  toolStatusMessageId = null;
314
391
  toolStatusLines = [];
315
392
  toolStatusDebounce = null;
@@ -325,9 +402,19 @@ export class Daemon extends EventEmitter {
325
402
  lastSpawnAt = 0;
326
403
  crashTimestamps = [];
327
404
  healthCheckPaused = false;
405
+ lastHealthErrorNotifyAt = 0;
328
406
  /** CLI pane availability, independent from the daemon process and tri-state. */
329
407
  processStatus = "running";
330
408
  spawning = false;
409
+ /**
410
+ * Resolves when the in-flight spawn finishes; null when none is running.
411
+ *
412
+ * `spawning` alone can only be polled. Delivery needs to *wait*, and a boolean
413
+ * poll would either busy-loop or race the flag being cleared.
414
+ */
415
+ spawnSettled = null;
416
+ resolveSpawnSettled = null;
417
+ spawnDepth = 0;
331
418
  skipResume = false;
332
419
  backgroundSessionRecoveryAttempted = false;
333
420
  /** Whether the last spawn started a fresh session (not resumed). */
@@ -350,6 +437,7 @@ export class Daemon extends EventEmitter {
350
437
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
351
438
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
352
439
  instanceStateReadyPattern = null;
440
+ instanceStateBusyPattern = null;
353
441
  instanceStateMonitorActive = false;
354
442
  statePollInFlight = false;
355
443
  autoPauseController;
@@ -370,7 +458,13 @@ export class Daemon extends EventEmitter {
370
458
  recentEvents = [];
371
459
  recentToolActivity = [];
372
460
  snapshotConsumed = false;
461
+ /** Orders inbound channel messages against each other (queue-depth accounting
462
+ * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
463
+ * reach the pane — {@link paneWriteLock} does. */
373
464
  pasteLock = Promise.resolve();
465
+ /** Mutual exclusion for *every* write into the pane, whichever subsystem it
466
+ * comes from. See PaneWriteLock for why interleaving is destructive. */
467
+ paneWriteLock = new PaneWriteLock();
374
468
  pendingInstructionsUpdate;
375
469
  pendingInstructionsNotice = false;
376
470
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -383,6 +477,10 @@ export class Daemon extends EventEmitter {
383
477
  firstDeliveryDelay = new FirstDeliveryDelay();
384
478
  // PTY error pattern monitoring
385
479
  errorMonitorTimer = null;
480
+ /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
481
+ static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
482
+ lastMcpDeathNotifiedAt = 0;
483
+ mcpDeathNotifiedForPid = null;
386
484
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
387
485
  runtimeMonitorsFrozen = false;
388
486
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
@@ -509,9 +607,6 @@ export class Daemon extends EventEmitter {
509
607
  if ((type === "fleet_schedule_response" || type === "fleet_outbound_response" || type === "fleet_decision_response" || type === "fleet_task_response" || type === "fleet_display_name_response" || type === "fleet_description_response") && msg.fleetRequestId) {
510
608
  key = String(msg.fleetRequestId);
511
609
  }
512
- else if (type === "fleet_outbound_response" && msg.requestId != null) {
513
- key = `fleet_out_${msg.requestId}`;
514
- }
515
610
  if (key && this.pendingIpcRequests.has(key)) {
516
611
  const handler = this.pendingIpcRequests.get(key);
517
612
  this.pendingIpcRequests.delete(key);
@@ -662,7 +757,12 @@ export class Daemon extends EventEmitter {
662
757
  else {
663
758
  await new Promise(r => setTimeout(r, 5000));
664
759
  }
665
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
760
+ // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
761
+ // delivery is already in flight or queued. Without the lock the notice and
762
+ // that delivery race into the same pane.
763
+ await this.paneWriteLock.run(async () => {
764
+ await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
765
+ });
666
766
  // Record the value the agent has now been told about so the next
667
767
  // unchanged restart skips the reload.
668
768
  try {
@@ -695,27 +795,31 @@ export class Daemon extends EventEmitter {
695
795
  this.transcriptMonitor.on("tool_use", (name, input) => {
696
796
  this.logger.debug({ tool: name }, "Tool use");
697
797
  ackIfPending();
698
- this.hangDetector?.recordActivity();
699
798
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
700
799
  this.recordRecentToolActivity(this.summarizeTool(name, input));
800
+ this.publishActivity(this.summarizeTool(name, input));
701
801
  });
702
802
  this.transcriptMonitor.on("tool_result", (name, _output) => {
703
- this.hangDetector?.recordActivity();
704
803
  this.recordRecentEvent({ type: "tool_result", name });
804
+ // The tool finished; whatever comes next has not started yet. Better to
805
+ // show only elapsed time than to leave a stale "Bash: npm test" on screen.
806
+ this.publishActivity(null);
705
807
  });
706
808
  this.transcriptMonitor.on("assistant_text", (text) => {
707
809
  this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
708
810
  ackIfPending();
709
- this.hangDetector?.recordActivity();
710
811
  this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
711
812
  });
712
813
  this.transcriptMonitor.startPolling();
713
- // HangDetector remains the fleet-manager notification event bridge. Its
714
- // legacy silence timer is intentionally not started: pane state transitions
715
- // below are now the sole source of hang events.
814
+ // HangDetector is the notification bridge to the fleet manager; pane state
815
+ // transitions below are the sole source of hang events (see hang-detector.ts).
816
+ // `hang_detector.enabled: false` opts out of the notification entirely.
817
+ // NOTE: `hang_detector.timeout_minutes` is NOT read here — the stuck timeout
818
+ // the pane monitor actually uses is resolved separately below. The two have
819
+ // long been separate; documented rather than silently ignored.
716
820
  const hangConfig = this.config.hang_detector;
717
821
  if (hangConfig?.enabled !== false) {
718
- this.hangDetector = new HangDetector(hangConfig?.timeout_minutes ?? 10);
822
+ this.hangDetector = new HangDetector();
719
823
  }
720
824
  // 8. Context guardian
721
825
  const statusFile = join(this.instanceDir, "statusline.json");
@@ -723,7 +827,6 @@ export class Daemon extends EventEmitter {
723
827
  this.guardian.startWatching();
724
828
  this.guardian.on("status_update", () => {
725
829
  this.saveSessionId();
726
- this.hangDetector?.recordStatuslineUpdate();
727
830
  });
728
831
  // Context rotation removed: all CLI backends have built-in auto-compact.
729
832
  // Crash recovery (health check + respawn with snapshot) is retained below.
@@ -743,6 +846,40 @@ export class Daemon extends EventEmitter {
743
846
  this.startInstanceStateMonitor();
744
847
  this.logger.info(`${this.name} ready`);
745
848
  }
849
+ /**
850
+ * Detect a CRASHED MCP server and report it once.
851
+ *
852
+ * The MCP server writes its pid to channel.mcp.pid at startup and unlinks it on
853
+ * a clean exit, so "file present + pid dead" specifically means it died without
854
+ * cleaning up (crash / OOM / SIGKILL). A missing file is either "not started
855
+ * yet" or "exited cleanly" — neither is an incident, and an orphan exit implies
856
+ * the CLI itself died, which the crash detector already covers.
857
+ *
858
+ * The daemon deliberately does NOT try to respawn it: MCP is a stdio protocol
859
+ * where the CLI spawns the server and owns its pipes, so a daemon-spawned
860
+ * process would have no client reading it. Only the CLI can restore its own
861
+ * tools — hence notify, and let the operator decide about restarting.
862
+ */
863
+ checkMcpServerAlive() {
864
+ if (this.isPaused)
865
+ return;
866
+ const status = mcpServerState(this.instanceDir);
867
+ if (status.state === "unknown")
868
+ return;
869
+ if (status.state === "alive") {
870
+ this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
871
+ return;
872
+ }
873
+ // Dead: report once per pid, and at most once per cooldown window.
874
+ if (this.mcpDeathNotifiedForPid === status.pid)
875
+ return;
876
+ if (Date.now() - this.lastMcpDeathNotifiedAt < Daemon.MCP_DEATH_COOLDOWN_MS)
877
+ return;
878
+ this.mcpDeathNotifiedForPid = status.pid;
879
+ this.lastMcpDeathNotifiedAt = Date.now();
880
+ this.logger.error({ pid: status.pid }, "MCP server process is gone — instance has no agend tools");
881
+ this.emit("mcp_died", { name: this.name, pid: status.pid });
882
+ }
746
883
  startHealthCheck() {
747
884
  if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
748
885
  return;
@@ -758,254 +895,285 @@ export class Daemon extends EventEmitter {
758
895
  this.healthCheckTimer = null;
759
896
  if (this.runtimeMonitorsFrozen)
760
897
  return;
761
- // Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/<name>`).
762
- // Stop the loop permanently otherwise every tick triggers a respawn, whose
763
- // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
764
- // spamming errors every ~30s forever.
765
- if (!existsSync(this.instanceDir)) {
766
- this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
767
- this.healthCheckPaused = true;
768
- this.healthCheckTimer = null;
769
- return;
770
- }
771
- if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
772
- scheduleNext();
773
- return;
774
- }
775
- // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
776
- const cliLabel = this.backend?.binaryName ?? "CLI";
777
- let paneStatus = await this.tmux.getPaneStatus();
778
- // Auto-pause intentionally exits the pane process. A health tick that
779
- // began just before pause must not classify that exit as a crash.
780
- if (this.isPaused || this.pauseWakeState === "waking") {
781
- scheduleNext();
782
- return;
783
- }
784
- if (paneStatus?.alive) {
785
- // Instance output.log is fed by tmux pipe-pane and was previously never
786
- // rotated (only fleet.log / daemon.log were). Cap growth every tick.
787
- if (!this.config.lightweight) {
788
- rotateLogIfNeeded(join(this.instanceDir, "output.log"));
898
+ // The whole tick is guarded: an unguarded throw in here (a tmux hiccup,
899
+ // ENOSPC on the crash-history write) became an unhandled rejection, which
900
+ // the CLI turned into stopAll() + exit(1) for the ENTIRE fleet, and it also
901
+ // ended this instance's health loop because scheduleNext() sat after the
902
+ // throwing code. Catch, log, and keep checking.
903
+ try {
904
+ // Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/<name>`).
905
+ // Stop the loop permanently — otherwise every tick triggers a respawn, whose
906
+ // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
907
+ // spamming errors every ~30s forever.
908
+ if (!existsSync(this.instanceDir)) {
909
+ this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
910
+ this.healthCheckPaused = true;
911
+ this.healthCheckTimer = null;
912
+ this.emitSupervisionEnded("instance directory was removed from disk", "Recreate the instance, or delete it from fleet.yaml.");
913
+ return;
914
+ }
915
+ if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
916
+ scheduleNext();
917
+ return;
918
+ }
919
+ // The CLI owns the MCP server process, so the daemon can only observe it.
920
+ this.checkMcpServerAlive();
921
+ // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
922
+ const cliLabel = this.backend?.binaryName ?? "CLI";
923
+ let paneStatus = await this.tmux.getPaneStatus();
924
+ // Auto-pause intentionally exits the pane process. A health tick that
925
+ // began just before pause must not classify that exit as a crash.
926
+ if (this.isPaused || this.pauseWakeState === "waking") {
927
+ scheduleNext();
928
+ return;
789
929
  }
790
- scheduleNext();
791
- return;
792
- }
793
- // A null status is ambiguous: it can be a transient `tmux list-panes`
794
- // failure (e.g. tmux busy during a fleet-restart storm) rather than a
795
- // real exit. Re-confirm once after a short delay before treating it as
796
- // a crash. A non-null {alive:false} is a definite dead pane (real exit)
797
- // and needs no recheck.
798
- if (paneStatus === null) {
799
- await new Promise(r => setTimeout(r, 1500));
800
- paneStatus = await this.tmux.getPaneStatus();
801
930
  if (paneStatus?.alive) {
802
- this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck transient query failure, ignoring`);
931
+ // Instance output.log is fed by tmux pipe-pane and was previously never
932
+ // rotated (only fleet.log / daemon.log were). Cap growth every tick.
933
+ if (!this.config.lightweight) {
934
+ rotateLogIfNeeded(join(this.instanceDir, "output.log"));
935
+ }
803
936
  scheduleNext();
804
937
  return;
805
938
  }
806
- }
807
- // paneStatus === null → window gone entirely (e.g. tmux server crash)
808
- // paneStatus.alive === false pane dead, exit code available
809
- const exitCode = paneStatus?.exitCode;
810
- this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
811
- // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
812
- if (paneStatus && exitCode === 0) {
813
- this.setProcessStatus("stopped");
814
- this.logger.info("CLI exited normally (code 0) — pausing health check");
815
- await this.tmux.killWindow();
816
- this.healthCheckPaused = true;
817
- return;
818
- }
819
- this.setProcessStatus("crashed");
820
- // Distinguish tmux server crash from single window crash.
821
- // nullReason records *why* getPaneStatus returned null (for diagnosing
822
- // whether this was a real window loss or a transient query failure).
823
- let crashType = "window";
824
- let nullReason;
825
- if (!paneStatus) {
826
- const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
827
- if (!serverAlive) {
828
- crashType = "server";
829
- nullReason = "server_gone";
830
- this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
831
- // Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
832
- Daemon.tmuxServerCrashTimestamps.push(Date.now());
833
- const cutoff = Date.now() - 5 * 60_000;
834
- Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
835
- if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
836
- Daemon.tmuxServerPaused = true;
837
- this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
838
- this.emit("tmux_server_crash", this.name);
839
- if (!Daemon.tmuxServerRecoveryTimer) {
840
- Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
841
- Daemon.tmuxServerRecoveryTimer = null;
842
- Daemon.tmuxServerPaused = false;
843
- }, 30_000);
844
- }
939
+ // A null status is ambiguous: it can be a transient `tmux list-panes`
940
+ // failure (e.g. tmux busy during a fleet-restart storm) rather than a
941
+ // real exit. Re-confirm once after a short delay before treating it as
942
+ // a crash. A non-null {alive:false} is a definite dead pane (real exit)
943
+ // and needs no recheck.
944
+ if (paneStatus === null) {
945
+ await new Promise(r => setTimeout(r, 1500));
946
+ paneStatus = await this.tmux.getPaneStatus();
947
+ if (paneStatus?.alive) {
948
+ this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
845
949
  scheduleNext();
846
950
  return;
847
951
  }
848
- await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
849
952
  }
850
- else {
851
- // null but server alive: window-level disappearance. Probe whether
852
- // the window truly no longer exists vs a transient query glitch.
853
- nullReason = "no_window";
854
- try {
855
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
856
- if (windows.some(w => w.name === this.name))
857
- nullReason = "window_present_query_glitch";
953
+ // paneStatus === null → window gone entirely (e.g. tmux server crash)
954
+ // paneStatus.alive === false pane dead, exit code available
955
+ const exitCode = paneStatus?.exitCode;
956
+ this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
957
+ // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
958
+ if (paneStatus && exitCode === 0) {
959
+ this.setProcessStatus("stopped");
960
+ this.logger.info("CLI exited normally (code 0) — pausing health check");
961
+ await this.tmux.killWindow();
962
+ this.healthCheckPaused = true;
963
+ this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.");
964
+ return;
965
+ }
966
+ this.setProcessStatus("crashed");
967
+ // Distinguish tmux server crash from single window crash.
968
+ // nullReason records *why* getPaneStatus returned null (for diagnosing
969
+ // whether this was a real window loss or a transient query failure).
970
+ let crashType = "window";
971
+ let nullReason;
972
+ if (!paneStatus) {
973
+ const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
974
+ if (!serverAlive) {
975
+ crashType = "server";
976
+ nullReason = "server_gone";
977
+ this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
978
+ // Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
979
+ Daemon.tmuxServerCrashTimestamps.push(Date.now());
980
+ const cutoff = Date.now() - 5 * 60_000;
981
+ Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
982
+ if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
983
+ Daemon.tmuxServerPaused = true;
984
+ this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
985
+ this.emit("tmux_server_crash", this.name);
986
+ if (!Daemon.tmuxServerRecoveryTimer) {
987
+ Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
988
+ Daemon.tmuxServerRecoveryTimer = null;
989
+ Daemon.tmuxServerPaused = false;
990
+ }, 30_000);
991
+ }
992
+ scheduleNext();
993
+ return;
994
+ }
995
+ await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
858
996
  }
859
- catch {
860
- nullReason = "query_error";
997
+ else {
998
+ // null but server alive: window-level disappearance. Probe whether
999
+ // the window truly no longer exists vs a transient query glitch.
1000
+ nullReason = "no_window";
1001
+ try {
1002
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName);
1003
+ if (windows.some(w => w.name === this.name))
1004
+ nullReason = "window_present_query_glitch";
1005
+ }
1006
+ catch {
1007
+ nullReason = "query_error";
1008
+ }
1009
+ this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
861
1010
  }
862
- this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
863
1011
  }
864
- }
865
- else {
866
- this.logger.warn({ exitCode }, `${cliLabel} process exited`);
867
- }
868
- // Capture last output before killing. Best-effort even when the pane is
869
- // gone (paneStatus null) gives the crash record something to diagnose
870
- // from instead of an empty lastOutput.
871
- let lastOutput;
872
- try {
873
- const raw = await this.tmux.capturePaneWithHistory(50);
874
- // Strip ANSI escape codes for readability
875
- const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
876
- lastOutput = cleaned.trimEnd() || undefined;
877
- }
878
- catch { /* best effort pane may already be gone */ }
879
- // Kill the dead window (remain-on-exit keeps it around) before respawn
880
- if (paneStatus) {
881
- await this.tmux.killWindow();
882
- }
883
- // Detect claude-code background session conflict recover without counting as crash
884
- if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
885
- if (!this.backgroundSessionRecoveryAttempted) {
886
- this.backgroundSessionRecoveryAttempted = true;
887
- this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
888
- const sidFile = join(this.instanceDir, "session-id");
1012
+ else {
1013
+ this.logger.warn({ exitCode }, `${cliLabel} process exited`);
1014
+ }
1015
+ // Capture last output before killing. Best-effort even when the pane is
1016
+ // gone (paneStatus null) gives the crash record something to diagnose
1017
+ // from instead of an empty lastOutput.
1018
+ let lastOutput;
1019
+ try {
1020
+ const raw = await this.tmux.capturePaneWithHistory(50);
1021
+ // Strip ANSI escape codes for readability
1022
+ const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1023
+ lastOutput = cleaned.trimEnd() || undefined;
1024
+ }
1025
+ catch { /* best effort — pane may already be gone */ }
1026
+ // Kill the dead window (remain-on-exit keeps it around) before respawn
1027
+ if (paneStatus) {
1028
+ await this.tmux.killWindow();
1029
+ }
1030
+ // Detect claude-code background session conflict — recover without counting as crash
1031
+ if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
1032
+ if (!this.backgroundSessionRecoveryAttempted) {
1033
+ this.backgroundSessionRecoveryAttempted = true;
1034
+ this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
1035
+ const sidFile = join(this.instanceDir, "session-id");
1036
+ try {
1037
+ unlinkSync(sidFile);
1038
+ }
1039
+ catch { }
1040
+ this.skipResume = true;
1041
+ await new Promise(r => setTimeout(r, 2_000));
1042
+ try {
1043
+ await this.spawnClaudeWindow();
1044
+ this.setProcessStatus("running");
1045
+ this.logger.info("Recovered from background session conflict");
1046
+ this.emit("crash_respawn", this.name);
1047
+ }
1048
+ catch (err) {
1049
+ this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
1050
+ }
1051
+ return; // Don't count as crash
1052
+ }
1053
+ // Already attempted recovery — fall through to normal crash handling
1054
+ }
1055
+ // Detect a --continue/--resume failure (no conversation to resume). The
1056
+ // session-id file persists across the crash, so a blind respawn would add
1057
+ // --continue again and crash in the same way → loop. Clear the session id
1058
+ // and skip resume so the next spawn starts fresh. (skipResume also stops
1059
+ // saveSessionId below from resurrecting the id from statusline.json.)
1060
+ if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
1061
+ this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
889
1062
  try {
890
- unlinkSync(sidFile);
1063
+ unlinkSync(join(this.instanceDir, "session-id"));
891
1064
  }
892
- catch { }
1065
+ catch { /* may not exist */ }
893
1066
  this.skipResume = true;
894
- await new Promise(r => setTimeout(r, 2_000));
1067
+ }
1068
+ // Append to crash history
1069
+ this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
1070
+ if (max_retries <= 0) {
1071
+ this.healthCheckPaused = true;
1072
+ this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
1073
+ this.emitSupervisionEnded("the CLI died and automatic restart is disabled (restart_policy.max_retries is 0)", "Start it manually, or raise max_retries in fleet.yaml.");
1074
+ return;
1075
+ }
1076
+ // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
1077
+ this.crashTimestamps.push(Date.now());
1078
+ const crashWindowMs = 5 * 60_000;
1079
+ this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
1080
+ if (this.crashTimestamps.length >= 3) {
1081
+ this.healthCheckPaused = true;
1082
+ this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
1083
+ // P1: Persist crash state so next process restart skips resume
895
1084
  try {
896
- await this.spawnClaudeWindow();
897
- this.setProcessStatus("running");
898
- this.logger.info("Recovered from background session conflict");
899
- this.emit("crash_respawn", this.name);
900
- }
901
- catch (err) {
902
- this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
1085
+ writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
1086
+ crashesInWindow: this.crashTimestamps.length,
1087
+ lastCrashAt: Date.now(),
1088
+ resumeDisabled: true,
1089
+ }));
903
1090
  }
904
- return; // Don't count as crash
1091
+ catch { /* best effort */ }
1092
+ this.emit("crash_loop", this.name);
1093
+ return; // don't schedule next — paused
905
1094
  }
906
- // Already attempted recovery fall through to normal crash handling
907
- }
908
- // Detect a --continue/--resume failure (no conversation to resume). The
909
- // session-id file persists across the crash, so a blind respawn would add
910
- // --continue again and crash in the same way → loop. Clear the session id
911
- // and skip resume so the next spawn starts fresh. (skipResume also stops
912
- // saveSessionId below from resurrecting the id from statusline.json.)
913
- if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
914
- this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
915
- try {
916
- unlinkSync(join(this.instanceDir, "session-id"));
1095
+ // Reset crash count if enough time has passed
1096
+ if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
1097
+ this.crashCount = 0;
917
1098
  }
918
- catch { /* may not exist */ }
919
- this.skipResume = true;
920
- }
921
- // Append to crash history
922
- this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
923
- if (max_retries <= 0) {
924
- this.healthCheckPaused = true;
925
- this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
926
- return;
927
- }
928
- // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
929
- this.crashTimestamps.push(Date.now());
930
- const crashWindowMs = 5 * 60_000;
931
- this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
932
- if (this.crashTimestamps.length >= 3) {
933
- this.healthCheckPaused = true;
934
- this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
935
- // P1: Persist crash state so next process restart skips resume
936
- try {
937
- writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
938
- crashesInWindow: this.crashTimestamps.length,
939
- lastCrashAt: Date.now(),
940
- resumeDisabled: true,
941
- }));
1099
+ this.crashCount++;
1100
+ this.lastCrashAt = Date.now();
1101
+ if (this.crashCount > max_retries) {
1102
+ this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
1103
+ this.healthCheckPaused = true;
1104
+ this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
1105
+ return; // don't schedule next — given up
942
1106
  }
943
- catch { /* best effort */ }
944
- this.emit("crash_loop", this.name);
945
- return; // don't schedule next paused
946
- }
947
- // Reset crash count if enough time has passed
948
- if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
949
- this.crashCount = 0;
950
- }
951
- this.crashCount++;
952
- this.lastCrashAt = Date.now();
953
- if (this.crashCount > max_retries) {
954
- this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
955
- return; // don't schedule next — given up
956
- }
957
- // Calculate backoff delay
958
- const delay = backoff === "exponential"
959
- ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
960
- : 1000 * this.crashCount;
961
- this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
962
- await new Promise(r => setTimeout(r, delay));
963
- try {
964
- this.saveSessionId();
965
- this.transcriptMonitor?.resetOffset();
966
- // Kill orphan MCP server from the crashed CLI session.
967
- // MCP server writes its PID to channel.mcp.pid on startup.
1107
+ // Calculate backoff delay
1108
+ const delay = backoff === "exponential"
1109
+ ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
1110
+ : 1000 * this.crashCount;
1111
+ this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
1112
+ await new Promise(r => setTimeout(r, delay));
968
1113
  try {
969
- const pidFile = join(this.instanceDir, "channel.mcp.pid");
970
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
971
- process.kill(pid, "SIGTERM");
972
- this.logger.info({ pid }, "Killed orphan MCP server");
973
- }
974
- catch { /* no pid file or process already dead */ }
975
- // Kill any same-name windows before respawn to prevent orphans.
976
- // Wrapped in try-catch: if tmux server is dead, listWindows throws —
977
- // must not block spawnClaudeWindow (which calls ensureSession).
978
- try {
979
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
980
- for (const w of windows) {
981
- if (w.name === this.name) {
982
- const tm = new TmuxManager(this.tmuxSessionName, w.id);
983
- await tm.killWindow();
984
- }
1114
+ this.saveSessionId();
1115
+ this.transcriptMonitor?.resetOffset();
1116
+ // Kill orphan MCP server from the crashed CLI session.
1117
+ // MCP server writes its PID to channel.mcp.pid on startup.
1118
+ try {
1119
+ const pidFile = join(this.instanceDir, "channel.mcp.pid");
1120
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
1121
+ process.kill(pid, "SIGTERM");
1122
+ this.logger.info({ pid }, "Killed orphan MCP server");
985
1123
  }
986
- }
987
- catch { /* tmux server may be dead ensureSession in trySpawn will recover */ }
988
- // Write snapshot before spawn consumed only if resume fails
989
- this.writeRotationSnapshot("crash");
990
- // Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
991
- const resumed = await this.spawnClaudeWindow();
992
- if (!resumed) {
993
- // Resume failed → fresh session → inject snapshot for context
994
- await this.injectSnapshotMessage();
995
- }
996
- else {
997
- // Clean up stale snapshot — resume restored full context
1124
+ catch { /* no pid file or process already dead */ }
1125
+ // Kill any same-name windows before respawn to prevent orphans.
1126
+ // Wrapped in try-catch: if tmux server is dead, listWindows throws —
1127
+ // must not block spawnClaudeWindow (which calls ensureSession).
998
1128
  try {
999
- unlinkSync(join(this.instanceDir, "rotation-state.json"));
1129
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName);
1130
+ for (const w of windows) {
1131
+ if (w.name === this.name) {
1132
+ const tm = new TmuxManager(this.tmuxSessionName, w.id);
1133
+ await tm.killWindow();
1134
+ }
1135
+ }
1000
1136
  }
1001
- catch { /* may not exist */ }
1137
+ catch { /* tmux server may be dead — ensureSession in trySpawn will recover */ }
1138
+ // Write snapshot before spawn — consumed only if resume fails
1139
+ this.writeRotationSnapshot("crash");
1140
+ // Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
1141
+ const resumed = await this.spawnClaudeWindow();
1142
+ if (!resumed) {
1143
+ // Resume failed → fresh session → inject snapshot for context
1144
+ await this.injectSnapshotMessage();
1145
+ }
1146
+ else {
1147
+ // Clean up stale snapshot — resume restored full context
1148
+ try {
1149
+ unlinkSync(join(this.instanceDir, "rotation-state.json"));
1150
+ }
1151
+ catch { /* may not exist */ }
1152
+ }
1153
+ this.setProcessStatus("running");
1154
+ this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
1155
+ this.emit("crash_respawn", this.name);
1156
+ }
1157
+ catch (err) {
1158
+ this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
1002
1159
  }
1003
- this.setProcessStatus("running");
1004
- this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
1005
- this.emit("crash_respawn", this.name);
1006
1160
  }
1007
1161
  catch (err) {
1008
- this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
1162
+ this.logger.error({ err }, "Health check tick failed — continuing");
1163
+ // Surface it to the operator, not just the log — a health check that
1164
+ // keeps throwing means this instance is no longer being supervised.
1165
+ // Throttled: the tick repeats every ~30s, so an unnotified persistent
1166
+ // fault would otherwise post twice a minute forever.
1167
+ const now = Date.now();
1168
+ if (now - this.lastHealthErrorNotifyAt > HEALTH_ERROR_NOTIFY_INTERVAL_MS) {
1169
+ this.lastHealthErrorNotifyAt = now;
1170
+ this.emit("health_check_error", {
1171
+ name: this.name,
1172
+ message: err instanceof Error ? err.message : String(err),
1173
+ });
1174
+ }
1175
+ scheduleNext();
1176
+ return;
1009
1177
  }
1010
1178
  scheduleNext();
1011
1179
  }, healthCheckIntervalMs);
@@ -1017,6 +1185,21 @@ export class Daemon extends EventEmitter {
1017
1185
  * A dead remain-on-exit pane still contains the old ready prompt, so allowing
1018
1186
  * the pane monitor to capture it would re-create a false idle state.
1019
1187
  */
1188
+ /**
1189
+ * Announce that this instance is no longer being supervised.
1190
+ *
1191
+ * Four health-check exits set `healthCheckPaused = true` and returned without
1192
+ * telling anyone: a clean CLI exit, `max_retries <= 0`, crash retries exhausted,
1193
+ * and the instance directory being deleted. Only the crash-LOOP case emitted an
1194
+ * event, so a fleet could quietly contain a dead instance that still looked fine
1195
+ * on the dashboard — while messages routed to it queued or failed with a bare ❌.
1196
+ *
1197
+ * `crash_loop` already had this treatment; this is the same bridge for the other
1198
+ * four, carrying a human-readable cause and the operator's next step.
1199
+ */
1200
+ emitSupervisionEnded(reason, remedy) {
1201
+ this.emit("supervision_ended", { name: this.name, reason, remedy });
1202
+ }
1020
1203
  setProcessStatus(status) {
1021
1204
  if (this.processStatus === status)
1022
1205
  return;
@@ -1062,6 +1245,7 @@ export class Daemon extends EventEmitter {
1062
1245
  if (!this.backend)
1063
1246
  return; // lightweight mode has no backend
1064
1247
  const readyPattern = this.backend.getReadyPattern();
1248
+ const busyPattern = this.backend.getBusyPattern?.() ?? null;
1065
1249
  this.errorMonitorTimer = setInterval(async () => {
1066
1250
  if (!this.tmux || this.spawning)
1067
1251
  return;
@@ -1074,20 +1258,30 @@ export class Daemon extends EventEmitter {
1074
1258
  for (const dialog of dialogs) {
1075
1259
  if (!dialog.pattern.test(pane))
1076
1260
  continue;
1077
- this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1078
- const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1079
- for (const key of dialog.keys) {
1080
- if (SPECIAL_KEYS.has(key)) {
1081
- await this.tmux.sendSpecialKey(key);
1082
- }
1083
- else {
1084
- await this.tmux.pasteText(key);
1261
+ // These keys go straight into the pane. Sent while a message delivery is
1262
+ // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
1263
+ // submits it half-composed — the user sees a message that vanished. Skip
1264
+ // (not queue) when the pane is busy: this poller runs every 5s, and the
1265
+ // dialog will still be on screen next tick.
1266
+ const dismissed = await this.paneWriteLock.tryRun(async () => {
1267
+ this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1268
+ const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1269
+ for (const key of dialog.keys) {
1270
+ if (SPECIAL_KEYS.has(key)) {
1271
+ await this.tmux.sendSpecialKey(key);
1272
+ }
1273
+ else {
1274
+ await this.tmux.pasteText(key, this.systemPasteOptions());
1275
+ }
1276
+ await new Promise(r => setTimeout(r, 200));
1085
1277
  }
1086
- await new Promise(r => setTimeout(r, 200));
1278
+ });
1279
+ if (!dismissed) {
1280
+ this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
1087
1281
  }
1088
- return; // Dialog dismissed, skip error checks this cycle
1282
+ return; // Dialog handled (or deliberately deferred): skip error checks this cycle
1089
1283
  }
1090
- this.evaluateErrorPatterns(pane, patterns, readyPattern);
1284
+ this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
1091
1285
  }
1092
1286
  catch {
1093
1287
  // capturePane can fail if window is transitioning — ignore
@@ -1095,7 +1289,7 @@ export class Daemon extends EventEmitter {
1095
1289
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
1096
1290
  }
1097
1291
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
1098
- evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now()) {
1292
+ evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
1099
1293
  // Count occurrences across the WHOLE pane (not just text after the last
1100
1294
  // ready prompt). Clone with `g` so stateful backend regexes cannot leak
1101
1295
  // lastIndex between monitor cycles.
@@ -1103,10 +1297,15 @@ export class Daemon extends EventEmitter {
1103
1297
  const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1104
1298
  return (pane.match(new RegExp(pattern.source, flags)) || []).length;
1105
1299
  };
1300
+ // Same veto as the state machine: a backend whose ready marker is permanently
1301
+ // on screen would otherwise "recover" on the very first tick after the error,
1302
+ // rebaselining the occurrence count while the error is still displayed — one
1303
+ // notification, a false recovery log, then silence.
1304
+ const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
1106
1305
  // State: waiting for recovery. A missing/outdated ready pattern must not
1107
1306
  // suppress every future error forever, so the gate has a hard deadline.
1108
1307
  if (this.errorWaitingForRecovery) {
1109
- if (readyPattern.test(pane)) {
1308
+ if (looksReady()) {
1110
1309
  const downtime = Math.round((now - this.errorDetectedAt) / 1000);
1111
1310
  const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
1112
1311
  if (active) {
@@ -1205,6 +1404,12 @@ export class Daemon extends EventEmitter {
1205
1404
  * Interrupt the CLI's current generation (cancel button / `/cancel`).
1206
1405
  * Direct tmux key event (not a paste) so it registers as the interrupt key.
1207
1406
  * kiro-cli interrupts on Ctrl+C; the others (claude-code, codex, …) on Escape.
1407
+ *
1408
+ * Deliberately NOT taken through `paneWriteLock`. Cancel is the user's way out
1409
+ * of a pane that is busy or wedged — queueing it behind the very delivery chain
1410
+ * it exists to unblock would make the button useless exactly when it is needed.
1411
+ * The cost is accepted: an Escape landing between a paste and its Enter discards
1412
+ * that message, which is what the user asked for anyway.
1208
1413
  */
1209
1414
  async sendEscape() {
1210
1415
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
@@ -1242,7 +1447,6 @@ export class Daemon extends EventEmitter {
1242
1447
  this.toolStatusDebounce = null;
1243
1448
  }
1244
1449
  this.pendingIpcRequests.clear();
1245
- this.hangDetector?.stop();
1246
1450
  if (this.adapter)
1247
1451
  await this.adapter.stop();
1248
1452
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -1269,7 +1473,12 @@ export class Daemon extends EventEmitter {
1269
1473
  if (!killed)
1270
1474
  this.logger.warn("CLI did not exit gracefully within 3s, force killing window");
1271
1475
  // Always kill window — remain-on-exit keeps dead panes around after CLI exits
1476
+ const stoppedWindowId = this.tmux.getWindowId();
1272
1477
  await this.tmux.killWindow();
1478
+ // The control client is shared across the fleet and outlives this daemon, so
1479
+ // a registration left behind here is re-resolved on every reconnect forever.
1480
+ if (stoppedWindowId)
1481
+ this.controlClient?.unregisterWindow(stoppedWindowId);
1273
1482
  const windowIdFile = join(this.instanceDir, "window-id");
1274
1483
  try {
1275
1484
  unlinkSync(windowIdFile);
@@ -1405,7 +1614,7 @@ export class Daemon extends EventEmitter {
1405
1614
  return;
1406
1615
  }
1407
1616
  this.pauseWakeState = "waking";
1408
- this.spawning = true;
1617
+ this.beginSpawn();
1409
1618
  const transition = this.autoPauseController.wakeOnDeliver(async () => {
1410
1619
  let timeout;
1411
1620
  try {
@@ -1446,7 +1655,7 @@ export class Daemon extends EventEmitter {
1446
1655
  throw err;
1447
1656
  }
1448
1657
  finally {
1449
- this.spawning = false;
1658
+ this.endSpawn();
1450
1659
  if (this.pauseWakeTransition === transition)
1451
1660
  this.pauseWakeTransition = null;
1452
1661
  }
@@ -1458,6 +1667,10 @@ export class Daemon extends EventEmitter {
1458
1667
  // observations between enqueue and paste must not clear a newer inbound.
1459
1668
  if (snapshot.state === "idle" && previous !== "idle") {
1460
1669
  this.pendingWork.recordIdle(snapshot.observedAt);
1670
+ // The turn is over. A transcript can end on a tool_use with no matching
1671
+ // tool_result (interrupted, crashed, cancelled), which would otherwise leave
1672
+ // the last tool pinned to the progress line for the rest of the session.
1673
+ this.publishActivity(null);
1461
1674
  }
1462
1675
  if (snapshot.state !== previous) {
1463
1676
  this.logger.info({
@@ -1548,6 +1761,13 @@ export class Daemon extends EventEmitter {
1548
1761
  this.instanceStateMachine.observe(pane, observedChangeAt);
1549
1762
  const snapshot = this.instanceStateMachine.observe(pane, Date.now());
1550
1763
  this.applyInstanceStateSnapshot(snapshot, pane);
1764
+ // Backends without a transcript feed can still say what they are doing, if
1765
+ // their TUI names it. Free-riding on a capture that already happened: no
1766
+ // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
1767
+ // safety sweep), which matches the progress ticker's own 60s interval.
1768
+ if (this.backend?.getPaneActivity) {
1769
+ this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
1770
+ }
1551
1771
  if (snapshot.state === "idle") {
1552
1772
  this.clearInstanceStateStuckTimer();
1553
1773
  }
@@ -1602,7 +1822,8 @@ export class Daemon extends EventEmitter {
1602
1822
  ? rawConfig.idle_debounce_ms
1603
1823
  : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1604
1824
  this.instanceStateReadyPattern = this.backend.getReadyPattern();
1605
- this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs);
1825
+ this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
1826
+ this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
1606
1827
  this.instanceStateLastOutputAt = 0;
1607
1828
  this.instanceStateMonitorActive = true;
1608
1829
  if (this.controlClient) {
@@ -1643,6 +1864,12 @@ export class Daemon extends EventEmitter {
1643
1864
  paneTail: sanitizePaneTail(pane),
1644
1865
  readyPattern: readyPattern.toString(),
1645
1866
  readyMatched: deterministicReadyPattern.test(pane),
1867
+ // A pane that is stuck *while* the ready marker matches means the busy
1868
+ // pattern is what held it back — worth seeing when tuning either regex.
1869
+ busyPattern: this.instanceStateBusyPattern?.toString(),
1870
+ busyMatched: this.instanceStateBusyPattern
1871
+ ? new RegExp(this.instanceStateBusyPattern.source, this.instanceStateBusyPattern.flags.replace(/[gy]/g, "")).test(pane)
1872
+ : undefined,
1646
1873
  unchangedForMs: snapshot.unchangedForMs,
1647
1874
  pendingWork: this.pendingWork.hasPendingWork(),
1648
1875
  };
@@ -1685,6 +1912,40 @@ export class Daemon extends EventEmitter {
1685
1912
  return this.messageBus;
1686
1913
  }
1687
1914
  // ── Tool status tracking ──────────────────────────────────────
1915
+ /**
1916
+ * Tell the fleet manager what this instance is doing right now, for the live
1917
+ * progress line on the cancel button.
1918
+ *
1919
+ * Purely cosmetic — nothing decides anything from it, so it is fine that only
1920
+ * backends with a transcript feed report at all (claude-code today). For the
1921
+ * rest the progress line keeps showing just elapsed time.
1922
+ *
1923
+ * Repeats are dropped: the ticker only edits the channel message when the text
1924
+ * changes, and a stream of identical broadcasts would defeat that.
1925
+ */
1926
+ publishActivity(activity) {
1927
+ const next = activity && activity.trim() ? activity.trim() : null;
1928
+ if (next === this.currentActivity)
1929
+ return;
1930
+ this.currentActivity = next;
1931
+ this.ipcServer?.broadcast({
1932
+ type: "instance_activity",
1933
+ instanceName: this.name,
1934
+ activity: next,
1935
+ });
1936
+ }
1937
+ /**
1938
+ * Options for every system-initiated paste (startup notice, session snapshot,
1939
+ * runtime-dialog keys).
1940
+ *
1941
+ * One place, so the three call sites cannot drift apart on it. Backends with a
1942
+ * native input queue do not get the retry Enter: on those, a second bare Enter
1943
+ * is not the no-op the retry assumed but a queue mutation — the same reason
1944
+ * `deliverMessage` refuses to probe for busy with one.
1945
+ */
1946
+ systemPasteOptions() {
1947
+ return { retryEnter: this.backend?.supportsQueuedInput?.() !== true };
1948
+ }
1688
1949
  summarizeTool(name, input) {
1689
1950
  const inp = input;
1690
1951
  if (!inp)
@@ -1774,7 +2035,6 @@ export class Daemon extends EventEmitter {
1774
2035
  writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
1775
2036
  this.pendingInstructionsUpdate = undefined;
1776
2037
  }
1777
- this.hangDetector?.recordInbound();
1778
2038
  this.pendingWork.recordInbound();
1779
2039
  // v3: record user messages for rotation snapshot
1780
2040
  this.recordRecentUserMessage(content, meta);
@@ -1797,12 +2057,26 @@ export class Daemon extends EventEmitter {
1797
2057
  // #77: show the sender's display name for readability, keeping the machine
1798
2058
  // instance name in parens so the recipient's send_to_instance target is valid.
1799
2059
  const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
1800
- formatted = `[from:${fromLabel}] ${content}\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)`;
2060
+ formatted = `[from:${fromLabel}] ${content}`;
2061
+ formatted += renderHandoffMetadata(meta);
2062
+ // A delegated task that requires a reply must not read like a chatty FYI —
2063
+ // the "you may stay silent" line is for the latter only.
2064
+ formatted += meta.requires_reply === "true"
2065
+ ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2066
+ : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
1801
2067
  }
1802
2068
  else {
1803
2069
  const via = meta.source ? ` via ${meta.source}` : "";
1804
2070
  const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
1805
- formatted = `[user:${user}${via}${idTag}] ${content}\n(Reply using the reply tool — do NOT respond with direct text)`;
2071
+ formatted = `[user:${user}${via}${idTag}] ${content}`;
2072
+ // Reactions queued since the last real message (#432). One leading line of
2073
+ // context, present only when something is pending — a reaction no longer
2074
+ // costs a turn of its own.
2075
+ if (meta.pending_reactions) {
2076
+ formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2077
+ }
2078
+ formatted += renderHandoffMetadata(meta);
2079
+ formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
1806
2080
  }
1807
2081
  if (meta.reply_to_text) {
1808
2082
  formatted += `\n(reply_to: "${meta.reply_to_text}")`;
@@ -1866,6 +2140,8 @@ export class Daemon extends EventEmitter {
1866
2140
  // Odd number of fences = unclosed. Remove all code fences from the message.
1867
2141
  formatted = formatted.replace(/```/g, "");
1868
2142
  }
2143
+ // Before anything reads the window id: a spawn in progress is about to change it.
2144
+ await this.waitForSpawnToSettle();
1869
2145
  let windowId = this.getWindowId();
1870
2146
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
1871
2147
  let handingOffToNativeQueue = false;
@@ -1881,9 +2157,33 @@ export class Daemon extends EventEmitter {
1881
2157
  }
1882
2158
  else {
1883
2159
  this.logger.debug("CLI busy — queuing message until idle");
1884
- await this.controlClient.waitUntilIdle(windowId);
2160
+ const becameIdle = await this.controlClient.waitUntilIdle(windowId);
2161
+ if (!becameIdle) {
2162
+ // The pane never freed up. Report the failure instead of pasting into a
2163
+ // wedged CLI (where the text would sit unsubmitted and the next message
2164
+ // would land on top of it) — and instead of holding the queue silently.
2165
+ this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
2166
+ if (status)
2167
+ this.emit("message_failed", status); // ❌
2168
+ return false;
2169
+ }
1885
2170
  }
1886
2171
  }
2172
+ // Everything above is *waiting*; everything below *writes*. Only the write is
2173
+ // held under the pane lock — holding it across the idle wait (up to 30 min)
2174
+ // would starve the runtime-dialog dismisser, which is often the very thing
2175
+ // that would let the pane go idle again.
2176
+ return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
2177
+ }
2178
+ /**
2179
+ * The write half of a delivery: paste → Enter → confirm, with retries.
2180
+ *
2181
+ * Always called under {@link paneWriteLock}. Split out from `deliverMessage`
2182
+ * precisely so the lock's scope is visible at the call site rather than being
2183
+ * an invariant maintained by comments.
2184
+ */
2185
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
2186
+ let windowId = initialWindowId;
1887
2187
  // Bug A: paste with backoff. Transient failures are usually a stale window id
1888
2188
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
1889
2189
  const maxAttempts = 3;
@@ -1966,8 +2266,20 @@ export class Daemon extends EventEmitter {
1966
2266
  await this.tmux.sendSpecialKey("Enter");
1967
2267
  becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
1968
2268
  }
1969
- if (becameBusy && status)
1970
- this.emit("message_confirmed", status); // ✅
2269
+ if (becameBusy) {
2270
+ if (status)
2271
+ this.emit("message_confirmed", status); // ✅
2272
+ }
2273
+ else {
2274
+ // Both Enters were swallowed: the text is sitting UNSUBMITTED in the
2275
+ // CLI's input box. This used to return true, so the reaction stayed at 👀
2276
+ // forever and the next delivery pasted on top — submitting two messages
2277
+ // as one. Say so instead.
2278
+ this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
2279
+ if (status)
2280
+ this.emit("message_failed", status); // ❌
2281
+ return false;
2282
+ }
1971
2283
  }
1972
2284
  else {
1973
2285
  // No control client to observe output: fall back to the legacy double-Enter.
@@ -2012,6 +2324,7 @@ export class Daemon extends EventEmitter {
2012
2324
  }
2013
2325
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
2014
2326
  async recoverWindow() {
2327
+ const previousWindowId = this.tmux?.getWindowId();
2015
2328
  try {
2016
2329
  const windows = await TmuxManager.listWindows(this.tmuxSessionName);
2017
2330
  const match = windows.find(w => w.name === this.name);
@@ -2019,6 +2332,12 @@ export class Daemon extends EventEmitter {
2019
2332
  return undefined;
2020
2333
  this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
2021
2334
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
2335
+ // The window we were talking to is gone; leaving it registered means the
2336
+ // control client re-resolves a dead id — one tmux subprocess — on every
2337
+ // reconnect, for the life of the fleet process.
2338
+ if (previousWindowId && previousWindowId !== match.id) {
2339
+ this.controlClient?.unregisterWindow(previousWindowId);
2340
+ }
2022
2341
  await this.controlClient?.registerWindow(match.id);
2023
2342
  this.bindInstanceStateOutputListener(match.id);
2024
2343
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
@@ -2029,12 +2348,38 @@ export class Daemon extends EventEmitter {
2029
2348
  return undefined;
2030
2349
  }
2031
2350
  }
2032
- /** Poll up to ~2s (200ms × 10) for the pane to emit output after `since`. */
2351
+ /**
2352
+ * Poll up to ~2s (200ms × 10) for the pane to emit output after `since`.
2353
+ *
2354
+ * A `false` here is not "no output" — it is read as **"the message was pasted but
2355
+ * never submitted"**, which re-sends Enter and then reports ❌ to the user. So a
2356
+ * false negative costs a possible double submit and a failure notice for a
2357
+ * message that actually arrived.
2358
+ *
2359
+ * That is exactly what a control-mode reconnect used to produce. `connect()`
2360
+ * drops every output timestamp, so `hasOutputSince` answers `false` for a pane
2361
+ * that did react — the evidence was thrown away, not absent. Blind time is
2362
+ * therefore not counted against the budget: after a reset the poll restarts from
2363
+ * the moment observation resumed. A hard wall-clock cap keeps a reconnect loop
2364
+ * from extending this forever.
2365
+ */
2033
2366
  async confirmBusyAfterEnter(windowId, since) {
2034
- for (let i = 0; i < 10; i++) {
2035
- await new Promise(r => setTimeout(r, 200));
2036
- if (this.controlClient.hasOutputSince(windowId, since))
2367
+ const client = this.controlClient;
2368
+ const hardDeadline = Date.now() + CONFIRM_BUSY_MAX_WAIT_MS;
2369
+ let observedFrom = since;
2370
+ let polls = 0;
2371
+ while (polls < CONFIRM_BUSY_POLLS) {
2372
+ await new Promise(r => setTimeout(r, CONFIRM_BUSY_POLL_MS));
2373
+ if (client.hasOutputSince(windowId, observedFrom))
2037
2374
  return true;
2375
+ const resetAt = client.getObservationResetAt();
2376
+ if (resetAt > observedFrom && Date.now() < hardDeadline) {
2377
+ this.logger.debug({ windowId, resetAt }, "Control mode reconnected mid-confirmation — restarting the Enter check from when we could see again");
2378
+ observedFrom = resetAt;
2379
+ polls = 0;
2380
+ continue;
2381
+ }
2382
+ polls++;
2038
2383
  }
2039
2384
  return false;
2040
2385
  }
@@ -2105,8 +2450,8 @@ export class Daemon extends EventEmitter {
2105
2450
  });
2106
2451
  const timeout = setTimeout(() => {
2107
2452
  this.pendingIpcRequests.delete(fleetReqId);
2108
- respond(null, "Task operation timed out after 30s");
2109
- }, 30_000);
2453
+ respond(null, `Task operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2454
+ }, daemonBudgetMs(tool));
2110
2455
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2111
2456
  clearTimeout(timeout);
2112
2457
  respond(respMsg.result, respMsg.error);
@@ -2128,8 +2473,8 @@ export class Daemon extends EventEmitter {
2128
2473
  });
2129
2474
  const timeout = setTimeout(() => {
2130
2475
  this.pendingIpcRequests.delete(fleetReqId);
2131
- respond(null, "Decision operation timed out after 30s");
2132
- }, 30_000);
2476
+ respond(null, `Decision operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2477
+ }, daemonBudgetMs(tool));
2133
2478
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2134
2479
  clearTimeout(timeout);
2135
2480
  respond(respMsg.result, respMsg.error);
@@ -2155,8 +2500,8 @@ export class Daemon extends EventEmitter {
2155
2500
  // Wait for fleet_schedule_response via pending request map
2156
2501
  const timeout = setTimeout(() => {
2157
2502
  this.pendingIpcRequests.delete(fleetReqId);
2158
- respond(null, "Schedule operation timed out after 30s");
2159
- }, 30_000);
2503
+ respond(null, `Schedule operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2504
+ }, daemonBudgetMs(tool));
2160
2505
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2161
2506
  clearTimeout(timeout);
2162
2507
  respond(respMsg.result, respMsg.error);
@@ -2177,7 +2522,9 @@ export class Daemon extends EventEmitter {
2177
2522
  fleetRequestId: fleetReqId,
2178
2523
  senderSessionName,
2179
2524
  });
2180
- const crossTimeoutMs = (tool === "start_instance" || tool === "create_instance" || tool === "replace_instance") ? 60_000 : 30_000;
2525
+ // Shared table, so delete_instance and the deployment tools get the same
2526
+ // budget here as the MCP client expects (the ad-hoc list omitted them).
2527
+ const crossTimeoutMs = daemonBudgetMs(tool);
2181
2528
  const timeout = setTimeout(() => {
2182
2529
  this.pendingIpcRequests.delete(fleetReqId);
2183
2530
  respond(null, `Cross-instance operation timed out after ${crossTimeoutMs / 1000}s`);
@@ -2220,8 +2567,8 @@ export class Daemon extends EventEmitter {
2220
2567
  this.ipcServer?.broadcast({ type: "fleet_outbound", tool, args, fleetRequestId: fleetReqId });
2221
2568
  const timeout = setTimeout(() => {
2222
2569
  this.pendingIpcRequests.delete(outboundKey);
2223
- respond(null, "Fleet outbound timed out after 30s");
2224
- }, 30_000);
2570
+ respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
2571
+ }, daemonBudgetMs(tool));
2225
2572
  this.pendingIpcRequests.set(outboundKey, (respMsg) => {
2226
2573
  clearTimeout(timeout);
2227
2574
  respond(respMsg.result, respMsg.error);
@@ -2410,7 +2757,9 @@ export class Daemon extends EventEmitter {
2410
2757
  // Small delay to let the CLI fully render its ready prompt
2411
2758
  await new Promise(r => setTimeout(r, 1_000));
2412
2759
  try {
2413
- await this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`);
2760
+ // Messages can arrive during a restart and be queued on pasteLock before the
2761
+ // snapshot lands; both write to the pane, so both go through the same lock.
2762
+ await this.paneWriteLock.run(() => this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`, this.systemPasteOptions()));
2414
2763
  this.logger.info("Injected session snapshot as first message");
2415
2764
  this.emit("snapshot_injected", this.name);
2416
2765
  }
@@ -2419,9 +2768,69 @@ export class Daemon extends EventEmitter {
2419
2768
  this.emit("snapshot_failed", this.name);
2420
2769
  }
2421
2770
  }
2771
+ /**
2772
+ * Depth, not a flag: the wake path marks a spawn and then calls trySpawn, which
2773
+ * marks another. With a plain boolean the inner one's completion would clear the
2774
+ * latch while the outer spawn was still dismissing dialogs, releasing delivery
2775
+ * into exactly the window this exists to close.
2776
+ */
2777
+ beginSpawn() {
2778
+ this.spawnDepth++;
2779
+ this.spawning = true;
2780
+ if (!this.spawnSettled) {
2781
+ this.spawnSettled = new Promise(resolve => { this.resolveSpawnSettled = resolve; });
2782
+ }
2783
+ }
2784
+ endSpawn() {
2785
+ this.spawnDepth = Math.max(0, this.spawnDepth - 1);
2786
+ if (this.spawnDepth > 0)
2787
+ return; // an enclosing spawn is still running
2788
+ this.spawning = false;
2789
+ const resolve = this.resolveSpawnSettled;
2790
+ this.spawnSettled = null;
2791
+ this.resolveSpawnSettled = null;
2792
+ resolve?.();
2793
+ }
2794
+ /**
2795
+ * Hold until the CLI has finished starting up.
2796
+ *
2797
+ * Startup is not a quiet period: `dismissDialogsUntilReady` is clicking through
2798
+ * trust prompts and session pickers. A pane showing a modal dialog produces no
2799
+ * output, so `waitUntilIdle` reports it idle and a queued message gets pasted
2800
+ * *into the dialog* — where the text is discarded and the Enter picks a menu
2801
+ * item. The paste and the Enter both "succeed", so this loses the message
2802
+ * without even a ❌.
2803
+ *
2804
+ * The pane write lock (#414) does not cover this: it serialises each key
2805
+ * sequence, but it is released between one dialog and the next.
2806
+ *
2807
+ * Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
2808
+ * holding the lock the spawn itself needs would deadlock.
2809
+ */
2810
+ async waitForSpawnToSettle() {
2811
+ const settled = this.spawnSettled;
2812
+ if (!settled)
2813
+ return;
2814
+ this.logger.debug("Holding delivery until the CLI has finished starting up");
2815
+ let timer;
2816
+ const cap = new Promise(resolve => {
2817
+ timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
2818
+ timer.unref?.();
2819
+ });
2820
+ try {
2821
+ await Promise.race([settled, cap]);
2822
+ }
2823
+ finally {
2824
+ if (timer)
2825
+ clearTimeout(timer);
2826
+ }
2827
+ if (this.spawning) {
2828
+ this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
2829
+ }
2830
+ }
2422
2831
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
2423
2832
  async spawnClaudeWindow() {
2424
- this.spawning = true;
2833
+ this.beginSpawn();
2425
2834
  let resumedSuccessfully = false;
2426
2835
  try {
2427
2836
  this.toolStatusLines = [];
@@ -2458,7 +2867,7 @@ export class Daemon extends EventEmitter {
2458
2867
  this.backgroundSessionRecoveryAttempted = false;
2459
2868
  }
2460
2869
  finally {
2461
- this.spawning = false;
2870
+ this.endSpawn();
2462
2871
  }
2463
2872
  return resumedSuccessfully;
2464
2873
  }
@@ -2529,11 +2938,13 @@ export class Daemon extends EventEmitter {
2529
2938
  // the global web token) from impersonating instances.
2530
2939
  const agentTokenPath = join(this.instanceDir, "agent.token");
2531
2940
  const agentToken = randomBytes(32).toString("hex");
2532
- writeFileSync(agentTokenPath, agentToken, { mode: 0o600 });
2533
- try {
2534
- chmodSync(agentTokenPath, 0o600);
2941
+ const tokenWrite = writeSecretFile(agentTokenPath, agentToken);
2942
+ if (!tokenWrite.ok) {
2943
+ // Do not fail the spawn over it — an instance that cannot start is worse
2944
+ // than one whose token is readable. But say so, loudly: the whole point of
2945
+ // this token is that other local processes cannot use it.
2946
+ this.logger.error({ path: agentTokenPath, mode: tokenWrite.mode?.toString(8), reason: tokenWrite.reason }, "Agent token file is not owner-only — other local users can impersonate this instance");
2535
2947
  }
2536
- catch { }
2537
2948
  // AGEND_HOME points the child's agent-cli at the same data dir the daemon
2538
2949
  // is using, so it can locate <instanceDir>/agent.token.
2539
2950
  const agendHome = join(this.instanceDir, "..", "..");
@@ -2551,7 +2962,12 @@ export class Daemon extends EventEmitter {
2551
2962
  windowId = this.tmux.getWindowId();
2552
2963
  }
2553
2964
  else {
2965
+ // A crash respawn makes a brand new window. The dead one stays registered
2966
+ // unless we say so — the respawn branch above already does.
2967
+ const retired = this.tmux.getWindowId();
2554
2968
  windowId = await this.tmux.createWindow(cmd, resolvedCwd, this.name);
2969
+ if (retired && retired !== windowId)
2970
+ this.controlClient?.unregisterWindow(retired);
2555
2971
  }
2556
2972
  writeFileSync(join(this.instanceDir, "window-id"), windowId);
2557
2973
  // Enable remain-on-exit to capture exit codes on crash
@@ -2617,15 +3033,20 @@ export class Daemon extends EventEmitter {
2617
3033
  for (const dialog of startupDialogs) {
2618
3034
  if (dialog.pattern.test(pane)) {
2619
3035
  this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
2620
- for (const key of dialog.keys) {
2621
- if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
2622
- await this.tmux.sendSpecialKey(key);
2623
- }
2624
- else {
2625
- await this.tmux.sendKeys(key);
3036
+ // Restart is exactly when inbound messages pile up, and nothing gates
3037
+ // delivery on `spawning`. Take the pane lock for the key sequence so a
3038
+ // queued message cannot be pasted into a half-dismissed trust dialog.
3039
+ await this.paneWriteLock.run(async () => {
3040
+ for (const key of dialog.keys) {
3041
+ if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
3042
+ await this.tmux.sendSpecialKey(key);
3043
+ }
3044
+ else {
3045
+ await this.tmux.sendKeys(key);
3046
+ }
3047
+ await new Promise(r => setTimeout(r, 200));
2626
3048
  }
2627
- await new Promise(r => setTimeout(r, 200));
2628
- }
3049
+ });
2629
3050
  // Wait for next screen to render
2630
3051
  if (this.controlClient) {
2631
3052
  const wid = readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim();