@songsid/agend 2.1.2-beta.8 → 2.1.2

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 (174) hide show
  1. package/dist/access-path.js +15 -6
  2. package/dist/access-path.js.map +1 -1
  3. package/dist/agent-cli-instructions.md +1 -0
  4. package/dist/agent-cli.js +14 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +25 -1
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/antigravity.d.ts +20 -0
  10. package/dist/backend/antigravity.js +51 -4
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/claude-code.d.ts +59 -0
  13. package/dist/backend/claude-code.js +67 -1
  14. package/dist/backend/claude-code.js.map +1 -1
  15. package/dist/backend/codex.d.ts +26 -0
  16. package/dist/backend/codex.js +100 -0
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/grok.d.ts +25 -0
  19. package/dist/backend/grok.js +51 -0
  20. package/dist/backend/grok.js.map +1 -1
  21. package/dist/backend/kiro.d.ts +23 -0
  22. package/dist/backend/kiro.js +120 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/types.d.ts +58 -0
  25. package/dist/backend/types.js +13 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.d.ts +15 -0
  28. package/dist/channel/adapters/discord.js +120 -8
  29. package/dist/channel/adapters/discord.js.map +1 -1
  30. package/dist/channel/adapters/telegram.d.ts +21 -0
  31. package/dist/channel/adapters/telegram.js +86 -0
  32. package/dist/channel/adapters/telegram.js.map +1 -1
  33. package/dist/channel/ipc-bridge.d.ts +9 -1
  34. package/dist/channel/ipc-bridge.js +12 -3
  35. package/dist/channel/ipc-bridge.js.map +1 -1
  36. package/dist/channel/ipc-timeouts.d.ts +46 -0
  37. package/dist/channel/ipc-timeouts.js +65 -0
  38. package/dist/channel/ipc-timeouts.js.map +1 -0
  39. package/dist/channel/mcp-server.js +25 -14
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +30 -1
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/reconnect-backoff.d.ts +17 -0
  44. package/dist/channel/reconnect-backoff.js +21 -0
  45. package/dist/channel/reconnect-backoff.js.map +1 -0
  46. package/dist/channel/types.d.ts +29 -0
  47. package/dist/classic-channel-manager.js +1 -5
  48. package/dist/classic-channel-manager.js.map +1 -1
  49. package/dist/cli.js +242 -91
  50. package/dist/cli.js.map +1 -1
  51. package/dist/completion.d.ts +27 -0
  52. package/dist/completion.js +121 -0
  53. package/dist/completion.js.map +1 -0
  54. package/dist/config-validator.js +21 -0
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.js +2 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/cost-guard.d.ts +3 -1
  59. package/dist/cost-guard.js +3 -1
  60. package/dist/cost-guard.js.map +1 -1
  61. package/dist/daemon.d.ts +299 -7
  62. package/dist/daemon.js +1152 -311
  63. package/dist/daemon.js.map +1 -1
  64. package/dist/event-log.d.ts +31 -0
  65. package/dist/event-log.js +96 -0
  66. package/dist/event-log.js.map +1 -1
  67. package/dist/fleet-context.d.ts +14 -0
  68. package/dist/fleet-lock.d.ts +28 -0
  69. package/dist/fleet-lock.js +130 -0
  70. package/dist/fleet-lock.js.map +1 -0
  71. package/dist/fleet-manager.d.ts +348 -6
  72. package/dist/fleet-manager.js +1860 -262
  73. package/dist/fleet-manager.js.map +1 -1
  74. package/dist/general-instance.d.ts +9 -0
  75. package/dist/general-instance.js +11 -0
  76. package/dist/general-instance.js.map +1 -0
  77. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  78. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  79. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  80. package/dist/hang-detector.d.ts +19 -13
  81. package/dist/hang-detector.js +19 -49
  82. package/dist/hang-detector.js.map +1 -1
  83. package/dist/instance-lifecycle.d.ts +58 -2
  84. package/dist/instance-lifecycle.js +231 -46
  85. package/dist/instance-lifecycle.js.map +1 -1
  86. package/dist/locale.js +7 -1
  87. package/dist/locale.js.map +1 -1
  88. package/dist/logger.js +14 -0
  89. package/dist/logger.js.map +1 -1
  90. package/dist/mcp-liveness.d.ts +21 -0
  91. package/dist/mcp-liveness.js +27 -0
  92. package/dist/mcp-liveness.js.map +1 -0
  93. package/dist/outbound-handlers.d.ts +9 -0
  94. package/dist/outbound-handlers.js +194 -31
  95. package/dist/outbound-handlers.js.map +1 -1
  96. package/dist/outbound-schemas.d.ts +11 -3
  97. package/dist/outbound-schemas.js +16 -2
  98. package/dist/outbound-schemas.js.map +1 -1
  99. package/dist/pane-write-lock.d.ts +48 -0
  100. package/dist/pane-write-lock.js +73 -0
  101. package/dist/pane-write-lock.js.map +1 -0
  102. package/dist/process-memory.d.ts +31 -0
  103. package/dist/process-memory.js +79 -0
  104. package/dist/process-memory.js.map +1 -0
  105. package/dist/quickstart.js +17 -16
  106. package/dist/quickstart.js.map +1 -1
  107. package/dist/reply-dedup.d.ts +41 -0
  108. package/dist/reply-dedup.js +0 -0
  109. package/dist/reply-dedup.js.map +1 -0
  110. package/dist/restart-progress.d.ts +41 -0
  111. package/dist/restart-progress.js +97 -0
  112. package/dist/restart-progress.js.map +1 -0
  113. package/dist/scheduler/db.js +3 -0
  114. package/dist/scheduler/db.js.map +1 -1
  115. package/dist/sd-notify.d.ts +27 -0
  116. package/dist/sd-notify.js +33 -1
  117. package/dist/sd-notify.js.map +1 -1
  118. package/dist/secret-file.d.ts +33 -0
  119. package/dist/secret-file.js +36 -0
  120. package/dist/secret-file.js.map +1 -0
  121. package/dist/service-installer.d.ts +28 -0
  122. package/dist/service-installer.js +72 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +5 -3
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/setup-wizard.js +9 -7
  127. package/dist/setup-wizard.js.map +1 -1
  128. package/dist/tmux-control.d.ts +58 -5
  129. package/dist/tmux-control.js +102 -14
  130. package/dist/tmux-control.js.map +1 -1
  131. package/dist/tmux-manager.d.ts +31 -1
  132. package/dist/tmux-manager.js +40 -11
  133. package/dist/tmux-manager.js.map +1 -1
  134. package/dist/topic-commands.d.ts +49 -9
  135. package/dist/topic-commands.js +270 -109
  136. package/dist/topic-commands.js.map +1 -1
  137. package/dist/tui-glyphs.d.ts +5 -1
  138. package/dist/tui-glyphs.js +6 -2
  139. package/dist/tui-glyphs.js.map +1 -1
  140. package/dist/types.d.ts +19 -2
  141. package/dist/ui/settings.html +1 -1
  142. package/dist/ui/view.html +123 -2
  143. package/dist/update-marker.d.ts +15 -0
  144. package/dist/update-marker.js +69 -0
  145. package/dist/update-marker.js.map +1 -0
  146. package/dist/usage/format-rich.d.ts +11 -0
  147. package/dist/usage/format-rich.js +121 -0
  148. package/dist/usage/format-rich.js.map +1 -0
  149. package/dist/usage/provider-alerts.d.ts +33 -0
  150. package/dist/usage/provider-alerts.js +57 -0
  151. package/dist/usage/provider-alerts.js.map +1 -0
  152. package/dist/usage/providers.d.ts +130 -0
  153. package/dist/usage/providers.js +1063 -0
  154. package/dist/usage/providers.js.map +1 -0
  155. package/dist/usage/statusline-usage.d.ts +20 -0
  156. package/dist/usage/statusline-usage.js +120 -0
  157. package/dist/usage/statusline-usage.js.map +1 -0
  158. package/dist/usage/usage-api.d.ts +50 -0
  159. package/dist/usage/usage-api.js +197 -0
  160. package/dist/usage/usage-api.js.map +1 -0
  161. package/dist/view-api.js +8 -3
  162. package/dist/view-api.js.map +1 -1
  163. package/dist/web-api.js +7 -3
  164. package/dist/web-api.js.map +1 -1
  165. package/dist/web-auth.d.ts +6 -0
  166. package/dist/web-auth.js +54 -0
  167. package/dist/web-auth.js.map +1 -0
  168. package/package.json +4 -1
  169. package/dist/channel/tool-tracker.d.ts +0 -13
  170. package/dist/channel/tool-tracker.js +0 -58
  171. package/dist/channel/tool-tracker.js.map +0 -1
  172. package/dist/daemon-entry.d.ts +0 -1
  173. package/dist/daemon-entry.js +0 -30
  174. package/dist/daemon-entry.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -1,18 +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
+ import { rotateLogIfNeeded } from "./logger.js";
7
+ import { mcpServerState } from "./mcp-liveness.js";
6
8
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
7
9
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
8
10
  import { TranscriptMonitor } from "./transcript-monitor.js";
9
11
  import { ContextGuardian } from "./context-guardian.js";
10
12
  import { IpcServer } from "./channel/ipc-bridge.js";
13
+ import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
11
14
  import { MessageBus } from "./channel/message-bus.js";
12
15
  import { shellQuote } from "./backend/types.js";
13
16
  import { getTmuxSession } from "./config.js";
14
17
  import { routeToolCall } from "./channel/tool-router.js";
15
18
  import { HangDetector } from "./hang-detector.js";
19
+ import { writeSecretFile } from "./secret-file.js";
20
+ import { PaneWriteLock } from "./pane-write-lock.js";
16
21
  import { buildFleetInstructions } from "./instructions.js";
17
22
  const __filename = fileURLToPath(import.meta.url);
18
23
  const __dirname = dirname(__filename);
@@ -37,9 +42,54 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
37
42
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
38
43
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
39
44
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
40
- /** @deprecated State detection is event-driven; this now aliases the safety sweep. */
41
- export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
42
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;
48
+ /**
49
+ * Whether two working directories belong to the same project, so a fleet-scoped
50
+ * decision recorded in one reaches the other. Covers the worktree/checkout
51
+ * layout AgEnD fleets actually use — `AgEnD`, `AgEnD-dev1`, `AgEnD-dev2`,
52
+ * `AgEnD-reviewer`, `AgEnD-main` are one project; `DouPo_Server` is not.
53
+ * Nesting counts too (a subdirectory of a project belongs to it).
54
+ */
55
+ export function sameProjectFamily(a, b) {
56
+ const norm = (p) => p.replace(/\/+$/, "");
57
+ const x = norm(a), y = norm(b);
58
+ if (x === y)
59
+ return true;
60
+ if (x.startsWith(y + "/") || y.startsWith(x + "/"))
61
+ return true;
62
+ // Strip a trailing worktree/role suffix: "AgEnD-dev2" → "agend".
63
+ const family = (p) => {
64
+ const base = p.slice(p.lastIndexOf("/") + 1).toLowerCase();
65
+ return base.replace(/[-_](dev\d*|main|reviewer|tester|leader|sol|codex|worktree|wt\d*)$/i, "");
66
+ };
67
+ const fx = family(x), fy = family(y);
68
+ return fx.length > 0 && fx === fy;
69
+ }
70
+ /**
71
+ * Pick the decisions worth injecting into one instance's system prompt.
72
+ *
73
+ * `scope: "fleet"` previously bypassed the project check outright, so every
74
+ * instance carried every other project's playbook — measured at 8 of 14 here,
75
+ * resent on every API call. A fleet decision must now also be relevant: global
76
+ * (no project_root), the same project, or the same project family.
77
+ *
78
+ * `isDispatcher` (a general) opts out of that narrowing on purpose: it routes
79
+ * work across all projects, so cross-project rules ("X 文件操作由 Y 負責") are
80
+ * precisely what it must know. Narrowing a general would cause misrouting.
81
+ */
82
+ export function selectRelevantDecisions(all, workDir, isDispatcher = false) {
83
+ return all.filter(d => {
84
+ if (d.project_root === workDir)
85
+ return true; // own project, any scope
86
+ if (d.scope !== "fleet")
87
+ return false; // project-scoped, elsewhere
88
+ if (!d.project_root)
89
+ return true; // truly global
90
+ return isDispatcher || sameProjectFamily(d.project_root, workDir);
91
+ });
92
+ }
43
93
  /** Read the last real channel inbound timestamp persisted across daemon restarts. */
44
94
  export function readLastInboundAt(instanceDir, now = Date.now()) {
45
95
  try {
@@ -58,6 +108,40 @@ export function writeLastInboundAt(instanceDir, timestamp) {
58
108
  writeFileSync(temp, String(timestamp));
59
109
  renameSync(temp, target);
60
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
+ }
61
145
  /** Headless inactivity timer used by the daemon and unit tests. */
62
146
  export class AutoPauseController {
63
147
  thresholdMs;
@@ -102,37 +186,111 @@ export class AutoPauseController {
102
186
  * Pane motion wins over a ready match because several backends keep their ready
103
187
  * marker in a persistent header/footer while generating. A stable ready pane is
104
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.
105
202
  */
203
+ /**
204
+ * A shell command reduced to the part that is safe to show: the program, and a
205
+ * subcommand when there is one.
206
+ *
207
+ * curl -H "Bearer sk-ant-…" https://api.com → curl
208
+ * npm test --env=API_KEY=xxx → npm test
209
+ * git push origin main → git push
210
+ *
211
+ * Arguments are dropped wholesale rather than filtered, because there is no
212
+ * reliable way to tell a secret from an ordinary argument: tokens, bearer
213
+ * headers, connection strings and `--password=` values all look like text. The
214
+ * progress line is posted to a chat channel and, for a public one, so is
215
+ * anything in it — so the rule is "never the arguments", not "not the arguments
216
+ * that look dangerous".
217
+ *
218
+ * A second token is kept only when it is a bare word (`push`, `test`), which is
219
+ * what a subcommand looks like and what a flag, path, URL, assignment or quoted
220
+ * string does not. Everything after it goes, including `git push ORIGIN MAIN`.
221
+ */
222
+ export function shellCommandLabel(command) {
223
+ // First line only: heredocs and multi-line scripts carry the payload.
224
+ const tokens = (command.split("\n")[0] ?? "").trim().split(/\s+/).filter(Boolean);
225
+ // `FOO=bar cmd …` — leading assignments are env, and their values are exactly
226
+ // the kind of thing being protected here.
227
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
228
+ tokens.shift();
229
+ const program = tokens[0];
230
+ if (!program)
231
+ return "";
232
+ const isBareWord = (t) => !!t && /^[A-Za-z][A-Za-z0-9_-]*$/.test(t);
233
+ const label = isBareWord(tokens[1]) ? `${program} ${tokens[1]}` : program;
234
+ return label.length > 40 ? `${label.slice(0, 39)}…` : label;
235
+ }
106
236
  export class PaneStateMachine {
107
237
  stuckTimeoutMs;
108
238
  readyPattern;
239
+ busyPattern;
109
240
  lastPaneHash = null;
110
241
  lastPaneChangeAt;
111
242
  lastObservedAt;
112
243
  stateChangedAt;
113
244
  currentState = "idle";
114
- constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now()) {
245
+ constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
115
246
  this.stuckTimeoutMs = stuckTimeoutMs;
116
247
  // Stateful g/y regexes mutate lastIndex and can alternate true/false across
117
248
  // polls. State detection must be deterministic for identical pane content.
118
249
  this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
250
+ this.busyPattern = busyPattern
251
+ ? new RegExp(busyPattern.source, busyPattern.flags.replace(/[gy]/g, ""))
252
+ : null;
119
253
  this.lastPaneChangeAt = now;
120
254
  this.lastObservedAt = now;
121
255
  this.stateChangedAt = now;
122
256
  }
123
- observe(pane, now = Date.now()) {
257
+ /** Ready, unless the backend can positively see that it is still generating. */
258
+ isReady(pane) {
259
+ if (this.busyPattern?.test(pane))
260
+ return false;
261
+ return this.readyPattern.test(pane);
262
+ }
263
+ /**
264
+ * @param now when this observation is being evaluated — drives the idle and
265
+ * stuck decisions.
266
+ * @param opts.changeAt when the content is believed to have changed, if that
267
+ * is earlier than `now` (the tmux output timestamp). Only the elapsed-time
268
+ * clock uses it; defaults to `now`.
269
+ * @param opts.settled the caller knows no output has arrived for the debounce
270
+ * window, so "the content differs from the last capture" says nothing about
271
+ * whether the CLI is still generating — decide on the patterns instead.
272
+ *
273
+ * Captures are event-driven, not periodic: the one taken 2s after output
274
+ * stops is compared against a capture that may be a minute old, so it
275
+ * *always* differs. Without this flag that difference reads as "still
276
+ * working" and a finished turn would not be seen as idle until the next 60s
277
+ * safety sweep. Output timestamps are the honest liveness signal here, and
278
+ * the caller has them.
279
+ */
280
+ observe(pane, now = Date.now(), opts = {}) {
281
+ const { settled = false, changeAt = now } = opts;
124
282
  const paneHash = createHash("sha256").update(pane).digest("hex");
125
283
  const firstObservation = this.lastPaneHash === null;
126
284
  const paneChanged = this.lastPaneHash !== paneHash;
127
285
  if (paneChanged) {
128
286
  this.lastPaneHash = paneHash;
129
- this.lastPaneChangeAt = now;
287
+ this.lastPaneChangeAt = changeAt;
130
288
  }
131
289
  this.lastObservedAt = now;
132
- const ready = this.readyPattern.test(pane);
290
+ const ready = this.isReady(pane);
133
291
  const nextState = firstObservation
134
292
  ? ready ? "idle" : "working"
135
- : paneChanged
293
+ : paneChanged && !settled
136
294
  ? "working"
137
295
  : ready
138
296
  ? "idle"
@@ -193,6 +351,23 @@ export class PendingWorkTracker {
193
351
  const NORMAL_ENTER_SETTLE_MS = 500;
194
352
  const FIRST_ENTER_SETTLE_MS = 1_750;
195
353
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
354
+ /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
355
+ const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
356
+ /** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
357
+ const CONFIRM_BUSY_POLLS = 10;
358
+ const CONFIRM_BUSY_POLL_MS = 200;
359
+ /**
360
+ * Ceiling on the whole confirmation, including time re-earned after a control-mode
361
+ * reconnect. Without it a reconnect loop could restart the poll indefinitely and
362
+ * hold the pane write lock along with it.
363
+ */
364
+ const CONFIRM_BUSY_MAX_WAIT_MS = 10_000;
365
+ /**
366
+ * How long a delivery waits for an in-flight spawn. Comfortably past the default
367
+ * 25s startup timeout plus dialog dismissal; past it we fall back to the old
368
+ * behaviour rather than holding a message indefinitely.
369
+ */
370
+ const SPAWN_SETTLE_MAX_WAIT_MS = 60_000;
196
371
  /**
197
372
  * One-shot timing gate for the first paste after a CLI reaches its ready
198
373
  * prompt. Recent tmux versions expose a short interval where the prompt is
@@ -237,6 +412,68 @@ export function sanitizePaneTail(pane, lineCount = 5) {
237
412
  .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
238
413
  .slice(0, 200));
239
414
  }
415
+ const INTERACTIVE_PROMPT_PATTERNS = [
416
+ { kind: "sudo_password", pattern: /^\s*\[sudo\]\s+password\s+for\s+[^:\n]+:\s*$/im },
417
+ // macOS sudo and several package installers use this exact no-echo prompt.
418
+ // Tail-only + stability gating is what makes the otherwise-generic word safe.
419
+ { kind: "password", pattern: /^\s*(?:Password|Passphrase):\s*$/im },
420
+ { kind: "confirmation", pattern: /^[^\n]{0,180}(?:\([Yy]\/[Nn]\)|\[[Yy]\/[Nn]\]|\((?:yes|no)\/(?:yes|no)\))\s*:?\s*$/im },
421
+ { kind: "confirmation", pattern: /^\s*Are you sure[^\n]{0,160}\((?:yes\/no)(?:\/\[[^\]]+\])?\)\??\s*$/im },
422
+ { kind: "press_enter", pattern: /^\s*(?:Please\s+)?Press (?:the )?Enter(?: key)?(?: to [^\n]{0,120})?[.:…]?\s*$/im },
423
+ ];
424
+ /**
425
+ * Detect terminal prompts that require a human, but only after the pane tail is
426
+ * unchanged and no control-mode output has arrived for the full grace period.
427
+ * This prevents prose such as "the installer asks [Y/n]" from notifying while
428
+ * an agent is still writing it.
429
+ */
430
+ export class InteractivePromptDetector {
431
+ stableMs;
432
+ signature = null;
433
+ stableSince = 0;
434
+ lastOutputAt = 0;
435
+ notifiedSignature = null;
436
+ constructor(stableMs = 10_000) {
437
+ this.stableMs = stableMs;
438
+ }
439
+ observe(pane, now = Date.now(), outputAt = 0) {
440
+ const tail = sanitizePaneTail(pane, 5);
441
+ const tailText = tail.join("\n");
442
+ let matched = null;
443
+ for (const candidate of INTERACTIVE_PROMPT_PATTERNS) {
444
+ const match = tailText.match(candidate.pattern);
445
+ if (!match)
446
+ continue;
447
+ matched = { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
448
+ break;
449
+ }
450
+ if (!matched) {
451
+ this.reset();
452
+ this.lastOutputAt = outputAt;
453
+ return null;
454
+ }
455
+ const signature = `${matched.kind}:${tailText}`;
456
+ const outputMoved = outputAt > this.lastOutputAt;
457
+ if (signature !== this.signature || outputMoved) {
458
+ this.signature = signature;
459
+ this.stableSince = now;
460
+ this.lastOutputAt = outputAt;
461
+ if (signature !== this.notifiedSignature)
462
+ this.notifiedSignature = null;
463
+ return null;
464
+ }
465
+ this.lastOutputAt = outputAt;
466
+ if (this.notifiedSignature === signature || now - this.stableSince < this.stableMs)
467
+ return null;
468
+ this.notifiedSignature = signature;
469
+ return matched;
470
+ }
471
+ reset() {
472
+ this.signature = null;
473
+ this.stableSince = 0;
474
+ this.notifiedSignature = null;
475
+ }
476
+ }
240
477
  export class Daemon extends EventEmitter {
241
478
  name;
242
479
  config;
@@ -251,7 +488,6 @@ export class Daemon extends EventEmitter {
251
488
  ipcServer = null;
252
489
  messageBus;
253
490
  transcriptMonitor = null;
254
- toolTracker = null;
255
491
  guardian = null;
256
492
  adapter = null;
257
493
  pendingIpcRequests = new Map();
@@ -262,6 +498,8 @@ export class Daemon extends EventEmitter {
262
498
  // Pending ack: react 🫡 on first transcript activity after receiving a message
263
499
  pendingAckMessage = null;
264
500
  // Tool status tracking for channel adapter
501
+ /** Last activity published to the fleet manager; null when nothing is running. */
502
+ currentActivity = null;
265
503
  toolStatusMessageId = null;
266
504
  toolStatusLines = [];
267
505
  toolStatusDebounce = null;
@@ -277,9 +515,19 @@ export class Daemon extends EventEmitter {
277
515
  lastSpawnAt = 0;
278
516
  crashTimestamps = [];
279
517
  healthCheckPaused = false;
518
+ lastHealthErrorNotifyAt = 0;
280
519
  /** CLI pane availability, independent from the daemon process and tri-state. */
281
520
  processStatus = "running";
282
521
  spawning = false;
522
+ /**
523
+ * Resolves when the in-flight spawn finishes; null when none is running.
524
+ *
525
+ * `spawning` alone can only be polled. Delivery needs to *wait*, and a boolean
526
+ * poll would either busy-loop or race the flag being cleared.
527
+ */
528
+ spawnSettled = null;
529
+ resolveSpawnSettled = null;
530
+ spawnDepth = 0;
283
531
  skipResume = false;
284
532
  backgroundSessionRecoveryAttempted = false;
285
533
  /** Whether the last spawn started a fresh session (not resumed). */
@@ -302,10 +550,18 @@ export class Daemon extends EventEmitter {
302
550
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
303
551
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
304
552
  instanceStateReadyPattern = null;
553
+ instanceStateBusyPattern = null;
305
554
  instanceStateMonitorActive = false;
306
555
  statePollInFlight = false;
307
556
  autoPauseController;
308
557
  pauseRequested = false;
558
+ /**
559
+ * Sticky "pause as soon as possible" (auth failure). Unlike pauseRequested —
560
+ * which the state monitor clears whenever the pane is not idle — this survives
561
+ * busy/stuck states, because an auth error fires mid-turn and a plain pause()
562
+ * would silently no-op exactly when we most need to stop feeding the CLI.
563
+ */
564
+ pausePending = false;
309
565
  pauseWakeState = "active";
310
566
  pauseWakeTransition = null;
311
567
  // Model failover: override model on next spawn when rate-limited
@@ -315,7 +571,13 @@ export class Daemon extends EventEmitter {
315
571
  recentEvents = [];
316
572
  recentToolActivity = [];
317
573
  snapshotConsumed = false;
574
+ /** Orders inbound channel messages against each other (queue-depth accounting
575
+ * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
576
+ * reach the pane — {@link paneWriteLock} does. */
318
577
  pasteLock = Promise.resolve();
578
+ /** Mutual exclusion for *every* write into the pane, whichever subsystem it
579
+ * comes from. See PaneWriteLock for why interleaving is destructive. */
580
+ paneWriteLock = new PaneWriteLock();
319
581
  pendingInstructionsUpdate;
320
582
  pendingInstructionsNotice = false;
321
583
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -328,6 +590,26 @@ export class Daemon extends EventEmitter {
328
590
  firstDeliveryDelay = new FirstDeliveryDelay();
329
591
  // PTY error pattern monitoring
330
592
  errorMonitorTimer = null;
593
+ interactivePromptDetector = new InteractivePromptDetector();
594
+ /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
595
+ static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
596
+ lastMcpDeathNotifiedAt = 0;
597
+ mcpDeathNotifiedForPid = null;
598
+ /**
599
+ * Ceiling on how long a dead MCP server waits for an idle window before the
600
+ * auto-restart fires anyway. A toolless instance cannot reply or report, so
601
+ * whatever a very long turn produces is stranded until the restart happens —
602
+ * past this point interrupting the turn costs less than staying mute.
603
+ */
604
+ static MCP_RESTART_MAX_WAIT_MS = 30 * 60_000;
605
+ /**
606
+ * Sticky "restart to revive the MCP server as soon as the pane idles" — same
607
+ * shape as pausePending, because the death is usually detected mid-turn when
608
+ * an immediate restart would destroy in-flight work. Cleared when the server
609
+ * turns up alive again (operator restarted, CLI reconnected) before we fire.
610
+ */
611
+ mcpRestartPending = false;
612
+ mcpRestartStaleTimer = null;
331
613
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
332
614
  runtimeMonitorsFrozen = false;
333
615
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
@@ -339,6 +621,10 @@ export class Daemon extends EventEmitter {
339
621
  return this.errorWaitingForRecovery || (this.healthCheckPaused && !this.isPaused) || Daemon.tmuxServerPaused;
340
622
  }
341
623
  get isPaused() { return this.pauseWakeState !== "active"; }
624
+ /** Current CLI process state for status surfaces and recovery commands. */
625
+ getProcessStatus() {
626
+ return this.processStatus;
627
+ }
342
628
  get lastPausedAt() { return this.autoPauseController.lastPausedAt; }
343
629
  getPauseWakeState() { return this.pauseWakeState; }
344
630
  /** Whether this instance is in a crash loop (3+ consecutive crashes). */
@@ -454,9 +740,6 @@ export class Daemon extends EventEmitter {
454
740
  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) {
455
741
  key = String(msg.fleetRequestId);
456
742
  }
457
- else if (type === "fleet_outbound_response" && msg.requestId != null) {
458
- key = `fleet_out_${msg.requestId}`;
459
- }
460
743
  if (key && this.pendingIpcRequests.has(key)) {
461
744
  const handler = this.pendingIpcRequests.get(key);
462
745
  this.pendingIpcRequests.delete(key);
@@ -607,7 +890,12 @@ export class Daemon extends EventEmitter {
607
890
  else {
608
891
  await new Promise(r => setTimeout(r, 5000));
609
892
  }
610
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
893
+ // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
894
+ // delivery is already in flight or queued. Without the lock the notice and
895
+ // that delivery race into the same pane.
896
+ await this.paneWriteLock.run(async () => {
897
+ await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
898
+ });
611
899
  // Record the value the agent has now been told about so the next
612
900
  // unchanged restart skips the reload.
613
901
  try {
@@ -619,8 +907,12 @@ export class Daemon extends EventEmitter {
619
907
  catch { /* non-fatal */ }
620
908
  })();
621
909
  if (!this.config.lightweight) {
622
- // 3. Pipe-pane for prompt detection
910
+ // 3. Pipe-pane for prompt detection. Rotate first so a ballooned log from a
911
+ // previous stuck splash (hundreds of MB of ANSI frames) is truncated before
912
+ // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
913
+ // keeps the writer attached after size resets.
623
914
  const outputLog = join(this.instanceDir, "output.log");
915
+ rotateLogIfNeeded(outputLog);
624
916
  await this.tmux.pipeOutput(outputLog).catch(() => { });
625
917
  // 4. Transcript monitor
626
918
  this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
@@ -636,27 +928,31 @@ export class Daemon extends EventEmitter {
636
928
  this.transcriptMonitor.on("tool_use", (name, input) => {
637
929
  this.logger.debug({ tool: name }, "Tool use");
638
930
  ackIfPending();
639
- this.hangDetector?.recordActivity();
640
931
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
641
932
  this.recordRecentToolActivity(this.summarizeTool(name, input));
933
+ this.publishActivity(this.summarizeTool(name, input));
642
934
  });
643
935
  this.transcriptMonitor.on("tool_result", (name, _output) => {
644
- this.hangDetector?.recordActivity();
645
936
  this.recordRecentEvent({ type: "tool_result", name });
937
+ // The tool finished; whatever comes next has not started yet. Better to
938
+ // show only elapsed time than to leave a stale "Bash: npm test" on screen.
939
+ this.publishActivity(null);
646
940
  });
647
941
  this.transcriptMonitor.on("assistant_text", (text) => {
648
942
  this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
649
943
  ackIfPending();
650
- this.hangDetector?.recordActivity();
651
944
  this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
652
945
  });
653
946
  this.transcriptMonitor.startPolling();
654
- // HangDetector remains the fleet-manager notification event bridge. Its
655
- // legacy silence timer is intentionally not started: pane state transitions
656
- // below are now the sole source of hang events.
947
+ // HangDetector is the notification bridge to the fleet manager; pane state
948
+ // transitions below are the sole source of hang events (see hang-detector.ts).
949
+ // `hang_detector.enabled: false` opts out of the notification entirely.
950
+ // NOTE: `hang_detector.timeout_minutes` is NOT read here — the stuck timeout
951
+ // the pane monitor actually uses is resolved separately below. The two have
952
+ // long been separate; documented rather than silently ignored.
657
953
  const hangConfig = this.config.hang_detector;
658
954
  if (hangConfig?.enabled !== false) {
659
- this.hangDetector = new HangDetector(hangConfig?.timeout_minutes ?? 10);
955
+ this.hangDetector = new HangDetector();
660
956
  }
661
957
  // 8. Context guardian
662
958
  const statusFile = join(this.instanceDir, "statusline.json");
@@ -664,7 +960,6 @@ export class Daemon extends EventEmitter {
664
960
  this.guardian.startWatching();
665
961
  this.guardian.on("status_update", () => {
666
962
  this.saveSessionId();
667
- this.hangDetector?.recordStatuslineUpdate();
668
963
  });
669
964
  // Context rotation removed: all CLI backends have built-in auto-compact.
670
965
  // Crash recovery (health check + respawn with snapshot) is retained below.
@@ -684,6 +979,89 @@ export class Daemon extends EventEmitter {
684
979
  this.startInstanceStateMonitor();
685
980
  this.logger.info(`${this.name} ready`);
686
981
  }
982
+ /**
983
+ * Detect a CRASHED MCP server and report it once.
984
+ *
985
+ * The MCP server writes its pid to channel.mcp.pid at startup and unlinks it on
986
+ * a clean exit, so "file present + pid dead" specifically means it died without
987
+ * cleaning up (crash / OOM / SIGKILL). A missing file is either "not started
988
+ * yet" or "exited cleanly" — neither is an incident, and an orphan exit implies
989
+ * the CLI itself died, which the crash detector already covers.
990
+ *
991
+ * The daemon deliberately does NOT try to respawn it: MCP is a stdio protocol
992
+ * where the CLI spawns the server and owns its pipes, so a daemon-spawned
993
+ * process would have no client reading it. Only the CLI can restore its own
994
+ * tools — hence notify, and let the operator decide about restarting.
995
+ */
996
+ checkMcpServerAlive() {
997
+ if (this.isPaused)
998
+ return;
999
+ const status = mcpServerState(this.instanceDir);
1000
+ if (status.state === "unknown")
1001
+ return;
1002
+ if (status.state === "alive") {
1003
+ this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
1004
+ this.clearMcpRestartRequest(); // tools are back — stand down a pending auto-restart
1005
+ return;
1006
+ }
1007
+ // Dead: report once per pid, and at most once per cooldown window.
1008
+ if (this.mcpDeathNotifiedForPid === status.pid)
1009
+ return;
1010
+ if (Date.now() - this.lastMcpDeathNotifiedAt < Daemon.MCP_DEATH_COOLDOWN_MS)
1011
+ return;
1012
+ this.mcpDeathNotifiedForPid = status.pid;
1013
+ this.lastMcpDeathNotifiedAt = Date.now();
1014
+ const autoRestart = this.config.mcp_auto_restart !== false;
1015
+ this.logger.error({ pid: status.pid, autoRestart }, "MCP server process is gone — instance has no agend tools");
1016
+ this.emit("mcp_died", { name: this.name, pid: status.pid, autoRestart });
1017
+ if (autoRestart)
1018
+ this.armMcpRestartWhenIdle();
1019
+ }
1020
+ /**
1021
+ * Arm an automatic instance restart to revive a dead MCP server. Fires on the
1022
+ * next busy→idle edge — or immediately when the pane is already idle, which is
1023
+ * the common case for a collab/chat instance parked between messages (no edge
1024
+ * would ever come). A turn that outlives MCP_RESTART_MAX_WAIT_MS is
1025
+ * force-restarted: see that constant for why staying mute costs more.
1026
+ */
1027
+ armMcpRestartWhenIdle() {
1028
+ if (this.mcpRestartPending)
1029
+ return;
1030
+ this.mcpRestartPending = true;
1031
+ this.mcpRestartStaleTimer = setTimeout(() => {
1032
+ this.mcpRestartStaleTimer = null;
1033
+ if (!this.mcpRestartPending || this.runtimeMonitorsFrozen)
1034
+ return;
1035
+ this.fireMcpRestartRequest("stale_timeout");
1036
+ }, Daemon.MCP_RESTART_MAX_WAIT_MS);
1037
+ this.mcpRestartStaleTimer.unref?.();
1038
+ if (this.instanceState === "idle" && this.pasteQueueDepth === 0) {
1039
+ this.fireMcpRestartRequest("already_idle");
1040
+ }
1041
+ }
1042
+ fireMcpRestartRequest(trigger) {
1043
+ // A recovery is already reshaping the CLI: crash-loop handling
1044
+ // (healthCheckPaused), a respawn in flight (spawning), or a pause
1045
+ // (isPaused / frozen monitors). Each of those paths ends in a fresh CLI —
1046
+ // and with it a fresh MCP server — or in supervision ending, where a
1047
+ // restart on top would fight the recovery. Either way the revival restart
1048
+ // is moot: cancel it rather than defer it.
1049
+ if (this.healthCheckPaused || this.spawning || this.isPaused || this.runtimeMonitorsFrozen) {
1050
+ this.logger.warn({ trigger }, "MCP revival restart cancelled — instance is already pausing/spawning/recovering");
1051
+ this.clearMcpRestartRequest();
1052
+ return;
1053
+ }
1054
+ this.clearMcpRestartRequest();
1055
+ this.logger.warn({ trigger }, "Requesting instance restart to revive its MCP server");
1056
+ this.emit("mcp_restart_requested", { name: this.name, trigger });
1057
+ }
1058
+ clearMcpRestartRequest() {
1059
+ this.mcpRestartPending = false;
1060
+ if (this.mcpRestartStaleTimer) {
1061
+ clearTimeout(this.mcpRestartStaleTimer);
1062
+ this.mcpRestartStaleTimer = null;
1063
+ }
1064
+ }
687
1065
  startHealthCheck() {
688
1066
  if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
689
1067
  return;
@@ -699,249 +1077,285 @@ export class Daemon extends EventEmitter {
699
1077
  this.healthCheckTimer = null;
700
1078
  if (this.runtimeMonitorsFrozen)
701
1079
  return;
702
- // Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/<name>`).
703
- // Stop the loop permanently otherwise every tick triggers a respawn, whose
704
- // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
705
- // spamming errors every ~30s forever.
706
- if (!existsSync(this.instanceDir)) {
707
- this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
708
- this.healthCheckPaused = true;
709
- this.healthCheckTimer = null;
710
- return;
711
- }
712
- if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
713
- scheduleNext();
714
- return;
715
- }
716
- // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
717
- const cliLabel = this.backend?.binaryName ?? "CLI";
718
- let paneStatus = await this.tmux.getPaneStatus();
719
- // Auto-pause intentionally exits the pane process. A health tick that
720
- // began just before pause must not classify that exit as a crash.
721
- if (this.isPaused || this.pauseWakeState === "waking") {
722
- scheduleNext();
723
- return;
724
- }
725
- if (paneStatus?.alive) {
726
- scheduleNext();
727
- return;
728
- }
729
- // A null status is ambiguous: it can be a transient `tmux list-panes`
730
- // failure (e.g. tmux busy during a fleet-restart storm) rather than a
731
- // real exit. Re-confirm once after a short delay before treating it as
732
- // a crash. A non-null {alive:false} is a definite dead pane (real exit)
733
- // and needs no recheck.
734
- if (paneStatus === null) {
735
- await new Promise(r => setTimeout(r, 1500));
736
- paneStatus = await this.tmux.getPaneStatus();
1080
+ // The whole tick is guarded: an unguarded throw in here (a tmux hiccup,
1081
+ // ENOSPC on the crash-history write) became an unhandled rejection, which
1082
+ // the CLI turned into stopAll() + exit(1) for the ENTIRE fleet, and it also
1083
+ // ended this instance's health loop because scheduleNext() sat after the
1084
+ // throwing code. Catch, log, and keep checking.
1085
+ try {
1086
+ // Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/<name>`).
1087
+ // Stop the loop permanently — otherwise every tick triggers a respawn, whose
1088
+ // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
1089
+ // spamming errors every ~30s forever.
1090
+ if (!existsSync(this.instanceDir)) {
1091
+ this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
1092
+ this.healthCheckPaused = true;
1093
+ this.healthCheckTimer = null;
1094
+ this.emitSupervisionEnded("instance directory was removed from disk", "Recreate the instance, or delete it from fleet.yaml.");
1095
+ return;
1096
+ }
1097
+ if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
1098
+ scheduleNext();
1099
+ return;
1100
+ }
1101
+ // The CLI owns the MCP server process, so the daemon can only observe it.
1102
+ this.checkMcpServerAlive();
1103
+ // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
1104
+ const cliLabel = this.backend?.binaryName ?? "CLI";
1105
+ let paneStatus = await this.tmux.getPaneStatus();
1106
+ // Auto-pause intentionally exits the pane process. A health tick that
1107
+ // began just before pause must not classify that exit as a crash.
1108
+ if (this.isPaused || this.pauseWakeState === "waking") {
1109
+ scheduleNext();
1110
+ return;
1111
+ }
737
1112
  if (paneStatus?.alive) {
738
- this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck transient query failure, ignoring`);
1113
+ // Instance output.log is fed by tmux pipe-pane and was previously never
1114
+ // rotated (only fleet.log / daemon.log were). Cap growth every tick.
1115
+ if (!this.config.lightweight) {
1116
+ rotateLogIfNeeded(join(this.instanceDir, "output.log"));
1117
+ }
739
1118
  scheduleNext();
740
1119
  return;
741
1120
  }
742
- }
743
- // paneStatus === null → window gone entirely (e.g. tmux server crash)
744
- // paneStatus.alive === false pane dead, exit code available
745
- const exitCode = paneStatus?.exitCode;
746
- this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
747
- // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
748
- if (paneStatus && exitCode === 0) {
749
- this.setProcessStatus("stopped");
750
- this.logger.info("CLI exited normally (code 0) — pausing health check");
751
- await this.tmux.killWindow();
752
- this.healthCheckPaused = true;
753
- return;
754
- }
755
- this.setProcessStatus("crashed");
756
- // Distinguish tmux server crash from single window crash.
757
- // nullReason records *why* getPaneStatus returned null (for diagnosing
758
- // whether this was a real window loss or a transient query failure).
759
- let crashType = "window";
760
- let nullReason;
761
- if (!paneStatus) {
762
- const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
763
- if (!serverAlive) {
764
- crashType = "server";
765
- nullReason = "server_gone";
766
- this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
767
- // Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
768
- Daemon.tmuxServerCrashTimestamps.push(Date.now());
769
- const cutoff = Date.now() - 5 * 60_000;
770
- Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
771
- if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
772
- Daemon.tmuxServerPaused = true;
773
- this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
774
- this.emit("tmux_server_crash", this.name);
775
- if (!Daemon.tmuxServerRecoveryTimer) {
776
- Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
777
- Daemon.tmuxServerRecoveryTimer = null;
778
- Daemon.tmuxServerPaused = false;
779
- }, 30_000);
780
- }
1121
+ // A null status is ambiguous: it can be a transient `tmux list-panes`
1122
+ // failure (e.g. tmux busy during a fleet-restart storm) rather than a
1123
+ // real exit. Re-confirm once after a short delay before treating it as
1124
+ // a crash. A non-null {alive:false} is a definite dead pane (real exit)
1125
+ // and needs no recheck.
1126
+ if (paneStatus === null) {
1127
+ await new Promise(r => setTimeout(r, 1500));
1128
+ paneStatus = await this.tmux.getPaneStatus();
1129
+ if (paneStatus?.alive) {
1130
+ this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
781
1131
  scheduleNext();
782
1132
  return;
783
1133
  }
784
- await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
785
1134
  }
786
- else {
787
- // null but server alive: window-level disappearance. Probe whether
788
- // the window truly no longer exists vs a transient query glitch.
789
- nullReason = "no_window";
790
- try {
791
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
792
- if (windows.some(w => w.name === this.name))
793
- nullReason = "window_present_query_glitch";
1135
+ // paneStatus === null → window gone entirely (e.g. tmux server crash)
1136
+ // paneStatus.alive === false pane dead, exit code available
1137
+ const exitCode = paneStatus?.exitCode;
1138
+ this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
1139
+ // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
1140
+ if (paneStatus && exitCode === 0) {
1141
+ this.setProcessStatus("stopped");
1142
+ this.logger.info("CLI exited normally (code 0) — pausing health check");
1143
+ await this.tmux.killWindow();
1144
+ this.healthCheckPaused = true;
1145
+ this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.");
1146
+ return;
1147
+ }
1148
+ this.setProcessStatus("crashed");
1149
+ // Distinguish tmux server crash from single window crash.
1150
+ // nullReason records *why* getPaneStatus returned null (for diagnosing
1151
+ // whether this was a real window loss or a transient query failure).
1152
+ let crashType = "window";
1153
+ let nullReason;
1154
+ if (!paneStatus) {
1155
+ const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
1156
+ if (!serverAlive) {
1157
+ crashType = "server";
1158
+ nullReason = "server_gone";
1159
+ this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
1160
+ // Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
1161
+ Daemon.tmuxServerCrashTimestamps.push(Date.now());
1162
+ const cutoff = Date.now() - 5 * 60_000;
1163
+ Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
1164
+ if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
1165
+ Daemon.tmuxServerPaused = true;
1166
+ this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
1167
+ this.emit("tmux_server_crash", this.name);
1168
+ if (!Daemon.tmuxServerRecoveryTimer) {
1169
+ Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
1170
+ Daemon.tmuxServerRecoveryTimer = null;
1171
+ Daemon.tmuxServerPaused = false;
1172
+ }, 30_000);
1173
+ }
1174
+ scheduleNext();
1175
+ return;
1176
+ }
1177
+ await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
794
1178
  }
795
- catch {
796
- nullReason = "query_error";
1179
+ else {
1180
+ // null but server alive: window-level disappearance. Probe whether
1181
+ // the window truly no longer exists vs a transient query glitch.
1182
+ nullReason = "no_window";
1183
+ try {
1184
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName);
1185
+ if (windows.some(w => w.name === this.name))
1186
+ nullReason = "window_present_query_glitch";
1187
+ }
1188
+ catch {
1189
+ nullReason = "query_error";
1190
+ }
1191
+ this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
797
1192
  }
798
- this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
799
1193
  }
800
- }
801
- else {
802
- this.logger.warn({ exitCode }, `${cliLabel} process exited`);
803
- }
804
- // Capture last output before killing. Best-effort even when the pane is
805
- // gone (paneStatus null) gives the crash record something to diagnose
806
- // from instead of an empty lastOutput.
807
- let lastOutput;
808
- try {
809
- const raw = await this.tmux.capturePaneWithHistory(50);
810
- // Strip ANSI escape codes for readability
811
- const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
812
- lastOutput = cleaned.trimEnd() || undefined;
813
- }
814
- catch { /* best effort pane may already be gone */ }
815
- // Kill the dead window (remain-on-exit keeps it around) before respawn
816
- if (paneStatus) {
817
- await this.tmux.killWindow();
818
- }
819
- // Detect claude-code background session conflict recover without counting as crash
820
- if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
821
- if (!this.backgroundSessionRecoveryAttempted) {
822
- this.backgroundSessionRecoveryAttempted = true;
823
- this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
824
- const sidFile = join(this.instanceDir, "session-id");
1194
+ else {
1195
+ this.logger.warn({ exitCode }, `${cliLabel} process exited`);
1196
+ }
1197
+ // Capture last output before killing. Best-effort even when the pane is
1198
+ // gone (paneStatus null) gives the crash record something to diagnose
1199
+ // from instead of an empty lastOutput.
1200
+ let lastOutput;
1201
+ try {
1202
+ const raw = await this.tmux.capturePaneWithHistory(50);
1203
+ // Strip ANSI escape codes for readability
1204
+ const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1205
+ lastOutput = cleaned.trimEnd() || undefined;
1206
+ }
1207
+ catch { /* best effort — pane may already be gone */ }
1208
+ // Kill the dead window (remain-on-exit keeps it around) before respawn
1209
+ if (paneStatus) {
1210
+ await this.tmux.killWindow();
1211
+ }
1212
+ // Detect claude-code background session conflict — recover without counting as crash
1213
+ if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
1214
+ if (!this.backgroundSessionRecoveryAttempted) {
1215
+ this.backgroundSessionRecoveryAttempted = true;
1216
+ this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
1217
+ const sidFile = join(this.instanceDir, "session-id");
1218
+ try {
1219
+ unlinkSync(sidFile);
1220
+ }
1221
+ catch { }
1222
+ this.skipResume = true;
1223
+ await new Promise(r => setTimeout(r, 2_000));
1224
+ try {
1225
+ await this.spawnClaudeWindow();
1226
+ this.setProcessStatus("running");
1227
+ this.logger.info("Recovered from background session conflict");
1228
+ this.emit("crash_respawn", this.name);
1229
+ }
1230
+ catch (err) {
1231
+ this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
1232
+ }
1233
+ return; // Don't count as crash
1234
+ }
1235
+ // Already attempted recovery — fall through to normal crash handling
1236
+ }
1237
+ // Detect a --continue/--resume failure (no conversation to resume). The
1238
+ // session-id file persists across the crash, so a blind respawn would add
1239
+ // --continue again and crash in the same way → loop. Clear the session id
1240
+ // and skip resume so the next spawn starts fresh. (skipResume also stops
1241
+ // saveSessionId below from resurrecting the id from statusline.json.)
1242
+ if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
1243
+ this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
825
1244
  try {
826
- unlinkSync(sidFile);
1245
+ unlinkSync(join(this.instanceDir, "session-id"));
827
1246
  }
828
- catch { }
1247
+ catch { /* may not exist */ }
829
1248
  this.skipResume = true;
830
- await new Promise(r => setTimeout(r, 2_000));
1249
+ }
1250
+ // Append to crash history
1251
+ this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
1252
+ if (max_retries <= 0) {
1253
+ this.healthCheckPaused = true;
1254
+ this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
1255
+ 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.");
1256
+ return;
1257
+ }
1258
+ // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
1259
+ this.crashTimestamps.push(Date.now());
1260
+ const crashWindowMs = 5 * 60_000;
1261
+ this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
1262
+ if (this.crashTimestamps.length >= 3) {
1263
+ this.healthCheckPaused = true;
1264
+ this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
1265
+ // P1: Persist crash state so next process restart skips resume
831
1266
  try {
832
- await this.spawnClaudeWindow();
833
- this.setProcessStatus("running");
834
- this.logger.info("Recovered from background session conflict");
835
- this.emit("crash_respawn", this.name);
1267
+ writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
1268
+ crashesInWindow: this.crashTimestamps.length,
1269
+ lastCrashAt: Date.now(),
1270
+ resumeDisabled: true,
1271
+ }));
836
1272
  }
837
- catch (err) {
838
- this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
839
- }
840
- return; // Don't count as crash
1273
+ catch { /* best effort */ }
1274
+ this.emit("crash_loop", this.name);
1275
+ return; // don't schedule next — paused
841
1276
  }
842
- // Already attempted recovery fall through to normal crash handling
843
- }
844
- // Detect a --continue/--resume failure (no conversation to resume). The
845
- // session-id file persists across the crash, so a blind respawn would add
846
- // --continue again and crash in the same way → loop. Clear the session id
847
- // and skip resume so the next spawn starts fresh. (skipResume also stops
848
- // saveSessionId below from resurrecting the id from statusline.json.)
849
- if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
850
- this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
851
- try {
852
- unlinkSync(join(this.instanceDir, "session-id"));
1277
+ // Reset crash count if enough time has passed
1278
+ if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
1279
+ this.crashCount = 0;
853
1280
  }
854
- catch { /* may not exist */ }
855
- this.skipResume = true;
856
- }
857
- // Append to crash history
858
- this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
859
- if (max_retries <= 0) {
860
- this.healthCheckPaused = true;
861
- this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
862
- return;
863
- }
864
- // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
865
- this.crashTimestamps.push(Date.now());
866
- const crashWindowMs = 5 * 60_000;
867
- this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
868
- if (this.crashTimestamps.length >= 3) {
869
- this.healthCheckPaused = true;
870
- this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
871
- // P1: Persist crash state so next process restart skips resume
872
- try {
873
- writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
874
- crashesInWindow: this.crashTimestamps.length,
875
- lastCrashAt: Date.now(),
876
- resumeDisabled: true,
877
- }));
1281
+ this.crashCount++;
1282
+ this.lastCrashAt = Date.now();
1283
+ if (this.crashCount > max_retries) {
1284
+ this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
1285
+ this.healthCheckPaused = true;
1286
+ this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
1287
+ return; // don't schedule next — given up
878
1288
  }
879
- catch { /* best effort */ }
880
- this.emit("crash_loop", this.name);
881
- return; // don't schedule next paused
882
- }
883
- // Reset crash count if enough time has passed
884
- if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
885
- this.crashCount = 0;
886
- }
887
- this.crashCount++;
888
- this.lastCrashAt = Date.now();
889
- if (this.crashCount > max_retries) {
890
- this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
891
- return; // don't schedule next — given up
892
- }
893
- // Calculate backoff delay
894
- const delay = backoff === "exponential"
895
- ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
896
- : 1000 * this.crashCount;
897
- this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
898
- await new Promise(r => setTimeout(r, delay));
899
- try {
900
- this.saveSessionId();
901
- this.transcriptMonitor?.resetOffset();
902
- // Kill orphan MCP server from the crashed CLI session.
903
- // MCP server writes its PID to channel.mcp.pid on startup.
1289
+ // Calculate backoff delay
1290
+ const delay = backoff === "exponential"
1291
+ ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
1292
+ : 1000 * this.crashCount;
1293
+ this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
1294
+ await new Promise(r => setTimeout(r, delay));
904
1295
  try {
905
- const pidFile = join(this.instanceDir, "channel.mcp.pid");
906
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
907
- process.kill(pid, "SIGTERM");
908
- this.logger.info({ pid }, "Killed orphan MCP server");
909
- }
910
- catch { /* no pid file or process already dead */ }
911
- // Kill any same-name windows before respawn to prevent orphans.
912
- // Wrapped in try-catch: if tmux server is dead, listWindows throws —
913
- // must not block spawnClaudeWindow (which calls ensureSession).
914
- try {
915
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
916
- for (const w of windows) {
917
- if (w.name === this.name) {
918
- const tm = new TmuxManager(this.tmuxSessionName, w.id);
919
- await tm.killWindow();
920
- }
1296
+ this.saveSessionId();
1297
+ this.transcriptMonitor?.resetOffset();
1298
+ // Kill orphan MCP server from the crashed CLI session.
1299
+ // MCP server writes its PID to channel.mcp.pid on startup.
1300
+ try {
1301
+ const pidFile = join(this.instanceDir, "channel.mcp.pid");
1302
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
1303
+ process.kill(pid, "SIGTERM");
1304
+ this.logger.info({ pid }, "Killed orphan MCP server");
921
1305
  }
922
- }
923
- catch { /* tmux server may be dead ensureSession in trySpawn will recover */ }
924
- // Write snapshot before spawn consumed only if resume fails
925
- this.writeRotationSnapshot("crash");
926
- // Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
927
- const resumed = await this.spawnClaudeWindow();
928
- if (!resumed) {
929
- // Resume failed → fresh session → inject snapshot for context
930
- await this.injectSnapshotMessage();
931
- }
932
- else {
933
- // Clean up stale snapshot — resume restored full context
1306
+ catch { /* no pid file or process already dead */ }
1307
+ // Kill any same-name windows before respawn to prevent orphans.
1308
+ // Wrapped in try-catch: if tmux server is dead, listWindows throws —
1309
+ // must not block spawnClaudeWindow (which calls ensureSession).
934
1310
  try {
935
- unlinkSync(join(this.instanceDir, "rotation-state.json"));
1311
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName);
1312
+ for (const w of windows) {
1313
+ if (w.name === this.name) {
1314
+ const tm = new TmuxManager(this.tmuxSessionName, w.id);
1315
+ await tm.killWindow();
1316
+ }
1317
+ }
936
1318
  }
937
- catch { /* may not exist */ }
1319
+ catch { /* tmux server may be dead — ensureSession in trySpawn will recover */ }
1320
+ // Write snapshot before spawn — consumed only if resume fails
1321
+ this.writeRotationSnapshot("crash");
1322
+ // Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
1323
+ const resumed = await this.spawnClaudeWindow();
1324
+ if (!resumed) {
1325
+ // Resume failed → fresh session → inject snapshot for context
1326
+ await this.injectSnapshotMessage();
1327
+ }
1328
+ else {
1329
+ // Clean up stale snapshot — resume restored full context
1330
+ try {
1331
+ unlinkSync(join(this.instanceDir, "rotation-state.json"));
1332
+ }
1333
+ catch { /* may not exist */ }
1334
+ }
1335
+ this.setProcessStatus("running");
1336
+ this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
1337
+ this.emit("crash_respawn", this.name);
1338
+ }
1339
+ catch (err) {
1340
+ this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
938
1341
  }
939
- this.setProcessStatus("running");
940
- this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
941
- this.emit("crash_respawn", this.name);
942
1342
  }
943
1343
  catch (err) {
944
- this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
1344
+ this.logger.error({ err }, "Health check tick failed — continuing");
1345
+ // Surface it to the operator, not just the log — a health check that
1346
+ // keeps throwing means this instance is no longer being supervised.
1347
+ // Throttled: the tick repeats every ~30s, so an unnotified persistent
1348
+ // fault would otherwise post twice a minute forever.
1349
+ const now = Date.now();
1350
+ if (now - this.lastHealthErrorNotifyAt > HEALTH_ERROR_NOTIFY_INTERVAL_MS) {
1351
+ this.lastHealthErrorNotifyAt = now;
1352
+ this.emit("health_check_error", {
1353
+ name: this.name,
1354
+ message: err instanceof Error ? err.message : String(err),
1355
+ });
1356
+ }
1357
+ scheduleNext();
1358
+ return;
945
1359
  }
946
1360
  scheduleNext();
947
1361
  }, healthCheckIntervalMs);
@@ -953,6 +1367,21 @@ export class Daemon extends EventEmitter {
953
1367
  * A dead remain-on-exit pane still contains the old ready prompt, so allowing
954
1368
  * the pane monitor to capture it would re-create a false idle state.
955
1369
  */
1370
+ /**
1371
+ * Announce that this instance is no longer being supervised.
1372
+ *
1373
+ * Four health-check exits set `healthCheckPaused = true` and returned without
1374
+ * telling anyone: a clean CLI exit, `max_retries <= 0`, crash retries exhausted,
1375
+ * and the instance directory being deleted. Only the crash-LOOP case emitted an
1376
+ * event, so a fleet could quietly contain a dead instance that still looked fine
1377
+ * on the dashboard — while messages routed to it queued or failed with a bare ❌.
1378
+ *
1379
+ * `crash_loop` already had this treatment; this is the same bridge for the other
1380
+ * four, carrying a human-readable cause and the operator's next step.
1381
+ */
1382
+ emitSupervisionEnded(reason, remedy) {
1383
+ this.emit("supervision_ended", { name: this.name, reason, remedy });
1384
+ }
956
1385
  setProcessStatus(status) {
957
1386
  if (this.processStatus === status)
958
1387
  return;
@@ -991,13 +1420,12 @@ export class Daemon extends EventEmitter {
991
1420
  return;
992
1421
  const patterns = this.backend?.getErrorPatterns?.() ?? [];
993
1422
  const dialogs = this.backend?.getRuntimeDialogs?.() ?? [];
994
- if (!patterns.length && !dialogs.length)
995
- return;
996
1423
  if (!this.tmux)
997
1424
  return;
998
1425
  if (!this.backend)
999
1426
  return; // lightweight mode has no backend
1000
1427
  const readyPattern = this.backend.getReadyPattern();
1428
+ const busyPattern = this.backend.getBusyPattern?.() ?? null;
1001
1429
  this.errorMonitorTimer = setInterval(async () => {
1002
1430
  if (!this.tmux || this.spawning)
1003
1431
  return;
@@ -1006,24 +1434,41 @@ export class Daemon extends EventEmitter {
1006
1434
  if (!alive)
1007
1435
  return;
1008
1436
  const pane = await this.tmux.capturePane();
1437
+ const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
1438
+ if (interactivePrompt) {
1439
+ this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
1440
+ this.emit("interactive_prompt", { name: this.name, ...interactivePrompt });
1441
+ // A prompt is not an error and must not enter the PTY recovery gate.
1442
+ // Continue scanning real errors in this same snapshot.
1443
+ }
1009
1444
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
1010
1445
  for (const dialog of dialogs) {
1011
1446
  if (!dialog.pattern.test(pane))
1012
1447
  continue;
1013
- this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1014
- const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1015
- for (const key of dialog.keys) {
1016
- if (SPECIAL_KEYS.has(key)) {
1017
- await this.tmux.sendSpecialKey(key);
1018
- }
1019
- else {
1020
- await this.tmux.pasteText(key);
1448
+ // These keys go straight into the pane. Sent while a message delivery is
1449
+ // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
1450
+ // submits it half-composed — the user sees a message that vanished. Skip
1451
+ // (not queue) when the pane is busy: this poller runs every 5s, and the
1452
+ // dialog will still be on screen next tick.
1453
+ const dismissed = await this.paneWriteLock.tryRun(async () => {
1454
+ this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1455
+ const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1456
+ for (const key of dialog.keys) {
1457
+ if (SPECIAL_KEYS.has(key)) {
1458
+ await this.tmux.sendSpecialKey(key);
1459
+ }
1460
+ else {
1461
+ await this.tmux.pasteText(key, this.systemPasteOptions());
1462
+ }
1463
+ await new Promise(r => setTimeout(r, 200));
1021
1464
  }
1022
- await new Promise(r => setTimeout(r, 200));
1465
+ });
1466
+ if (!dismissed) {
1467
+ this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
1023
1468
  }
1024
- return; // Dialog dismissed, skip error checks this cycle
1469
+ return; // Dialog handled (or deliberately deferred): skip error checks this cycle
1025
1470
  }
1026
- this.evaluateErrorPatterns(pane, patterns, readyPattern);
1471
+ this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
1027
1472
  }
1028
1473
  catch {
1029
1474
  // capturePane can fail if window is transitioning — ignore
@@ -1031,7 +1476,7 @@ export class Daemon extends EventEmitter {
1031
1476
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
1032
1477
  }
1033
1478
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
1034
- evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now()) {
1479
+ evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
1035
1480
  // Count occurrences across the WHOLE pane (not just text after the last
1036
1481
  // ready prompt). Clone with `g` so stateful backend regexes cannot leak
1037
1482
  // lastIndex between monitor cycles.
@@ -1039,14 +1484,34 @@ export class Daemon extends EventEmitter {
1039
1484
  const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1040
1485
  return (pane.match(new RegExp(pattern.source, flags)) || []).length;
1041
1486
  };
1487
+ // Same veto as the state machine: a backend whose ready marker is permanently
1488
+ // on screen would otherwise "recover" on the very first tick after the error,
1489
+ // rebaselining the occurrence count while the error is still displayed — one
1490
+ // notification, a false recovery log, then silence.
1491
+ const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
1042
1492
  // State: waiting for recovery. A missing/outdated ready pattern must not
1043
1493
  // suppress every future error forever, so the gate has a hard deadline.
1044
1494
  if (this.errorWaitingForRecovery) {
1045
- if (readyPattern.test(pane)) {
1495
+ if (looksReady()) {
1046
1496
  const downtime = Math.round((now - this.errorDetectedAt) / 1000);
1047
- const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
1048
- if (active) {
1049
- this.lastErrorCount.set(Daemon.errorPatternKey(active), countMatches(active.pattern));
1497
+ // Re-baseline EVERY pattern, not just the one that fired. Patterns
1498
+ // legitimately overlap — kiro prints one header ("having trouble
1499
+ // responding") above the specific cause — and only the first match is
1500
+ // reported before the `break`, leaving the others at a stale baseline.
1501
+ // Rebasing just the active one meant the first scan after recovery saw
1502
+ // count > seen on the very text we already reported and fired a second,
1503
+ // contradictory notification ("Rate limit") for an incident the user had
1504
+ // just been told about. Recovery means the pane is now history, so all
1505
+ // of it is history. Deliberately NOT done at detection time: a pattern
1506
+ // skipped for cooldown must keep its count unconsumed so it can still
1507
+ // fire once that cooldown expires.
1508
+ for (const ep of patterns) {
1509
+ const seen = countMatches(ep.pattern);
1510
+ // Only patterns actually present in the pane. Storing zeros would be a
1511
+ // no-op for detection (count 0 === absent) but grows the map with an
1512
+ // entry per pattern per recovery.
1513
+ if (seen > 0)
1514
+ this.lastErrorCount.set(Daemon.errorPatternKey(ep), seen);
1050
1515
  }
1051
1516
  this.clearErrorRecoveryGate();
1052
1517
  this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
@@ -1141,6 +1606,12 @@ export class Daemon extends EventEmitter {
1141
1606
  * Interrupt the CLI's current generation (cancel button / `/cancel`).
1142
1607
  * Direct tmux key event (not a paste) so it registers as the interrupt key.
1143
1608
  * kiro-cli interrupts on Ctrl+C; the others (claude-code, codex, …) on Escape.
1609
+ *
1610
+ * Deliberately NOT taken through `paneWriteLock`. Cancel is the user's way out
1611
+ * of a pane that is busy or wedged — queueing it behind the very delivery chain
1612
+ * it exists to unblock would make the button useless exactly when it is needed.
1613
+ * The cost is accepted: an Escape landing between a paste and its Enter discards
1614
+ * that message, which is what the user asked for anyway.
1144
1615
  */
1145
1616
  async sendEscape() {
1146
1617
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
@@ -1178,7 +1649,6 @@ export class Daemon extends EventEmitter {
1178
1649
  this.toolStatusDebounce = null;
1179
1650
  }
1180
1651
  this.pendingIpcRequests.clear();
1181
- this.hangDetector?.stop();
1182
1652
  if (this.adapter)
1183
1653
  await this.adapter.stop();
1184
1654
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -1205,7 +1675,12 @@ export class Daemon extends EventEmitter {
1205
1675
  if (!killed)
1206
1676
  this.logger.warn("CLI did not exit gracefully within 3s, force killing window");
1207
1677
  // Always kill window — remain-on-exit keeps dead panes around after CLI exits
1678
+ const stoppedWindowId = this.tmux.getWindowId();
1208
1679
  await this.tmux.killWindow();
1680
+ // The control client is shared across the fleet and outlives this daemon, so
1681
+ // a registration left behind here is re-resolved on every reconnect forever.
1682
+ if (stoppedWindowId)
1683
+ this.controlClient?.unregisterWindow(stoppedWindowId);
1209
1684
  const windowIdFile = join(this.instanceDir, "window-id");
1210
1685
  try {
1211
1686
  unlinkSync(windowIdFile);
@@ -1248,6 +1723,16 @@ export class Daemon extends EventEmitter {
1248
1723
  };
1249
1724
  }
1250
1725
  /** Gracefully stop the CLI while keeping its remain-on-exit tmux window. */
1726
+ /**
1727
+ * Mark the instance to pause as soon as its pane is idle. Used for auth
1728
+ * failures: pause() alone no-ops while the CLI is busy/stuck, which is the
1729
+ * usual state when the error surfaces. Cleared by a successful pause or wake.
1730
+ */
1731
+ requestPauseWhenIdle() {
1732
+ if (this.pauseWakeState === "paused")
1733
+ return;
1734
+ this.pausePending = true;
1735
+ }
1251
1736
  async pause() {
1252
1737
  if (this.pauseWakeState === "paused")
1253
1738
  return;
@@ -1317,6 +1802,9 @@ export class Daemon extends EventEmitter {
1317
1802
  }
1318
1803
  /** Respawn the CLI in the preserved window and block until its prompt is ready. */
1319
1804
  async wake(timeoutMs = 30_000) {
1805
+ // An explicit wake (e.g. after the user re-logs in) cancels a deferred
1806
+ // auth pause — otherwise the instance would pause again the moment it idles.
1807
+ this.pausePending = false;
1320
1808
  if (this.pauseWakeState === "active")
1321
1809
  return;
1322
1810
  if (this.pauseWakeState === "pausing")
@@ -1328,7 +1816,7 @@ export class Daemon extends EventEmitter {
1328
1816
  return;
1329
1817
  }
1330
1818
  this.pauseWakeState = "waking";
1331
- this.spawning = true;
1819
+ this.beginSpawn();
1332
1820
  const transition = this.autoPauseController.wakeOnDeliver(async () => {
1333
1821
  let timeout;
1334
1822
  try {
@@ -1369,7 +1857,7 @@ export class Daemon extends EventEmitter {
1369
1857
  throw err;
1370
1858
  }
1371
1859
  finally {
1372
- this.spawning = false;
1860
+ this.endSpawn();
1373
1861
  if (this.pauseWakeTransition === transition)
1374
1862
  this.pauseWakeTransition = null;
1375
1863
  }
@@ -1381,6 +1869,10 @@ export class Daemon extends EventEmitter {
1381
1869
  // observations between enqueue and paste must not clear a newer inbound.
1382
1870
  if (snapshot.state === "idle" && previous !== "idle") {
1383
1871
  this.pendingWork.recordIdle(snapshot.observedAt);
1872
+ // The turn is over. A transcript can end on a tool_use with no matching
1873
+ // tool_result (interrupted, crashed, cancelled), which would otherwise leave
1874
+ // the last tool pinned to the progress line for the rest of the session.
1875
+ this.publishActivity(null);
1384
1876
  }
1385
1877
  if (snapshot.state !== previous) {
1386
1878
  this.logger.info({
@@ -1396,6 +1888,18 @@ export class Daemon extends EventEmitter {
1396
1888
  }
1397
1889
  if (snapshot.state !== "idle")
1398
1890
  this.pauseRequested = false;
1891
+ // A pause deferred by an auth failure: retry the moment the pane settles.
1892
+ if (this.pausePending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
1893
+ this.pausePending = false;
1894
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
1895
+ return;
1896
+ }
1897
+ // A restart deferred by a dead MCP server: the turn it must not interrupt is
1898
+ // over. Skip auto-pause evaluation this tick — the restart supersedes it.
1899
+ if (this.mcpRestartPending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
1900
+ this.fireMcpRestartRequest("idle_edge");
1901
+ return;
1902
+ }
1399
1903
  if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
1400
1904
  this.pauseRequested = true;
1401
1905
  this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
@@ -1462,9 +1966,29 @@ export class Daemon extends EventEmitter {
1462
1966
  || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt))
1463
1967
  return;
1464
1968
  const observedChangeAt = expectedOutputAt || captureStartedAt;
1465
- this.instanceStateMachine.observe(pane, observedChangeAt);
1466
- const snapshot = this.instanceStateMachine.observe(pane, Date.now());
1969
+ // One observation per capture. This used to call observe() twice with the
1970
+ // same pane: the second call always saw an unchanged pane, so the
1971
+ // "content moved → still working" branch could never fire and the state
1972
+ // came down to the busy/ready patterns alone. That is why a single
1973
+ // mismatched spinner frame was enough to report idle mid-turn.
1974
+ const settled = this.instanceStateLastOutputAt === 0
1975
+ || captureStartedAt - this.instanceStateLastOutputAt >= this.instanceStateIdleDebounceMs;
1976
+ // The two times are genuinely different and both matter: the content
1977
+ // changed when tmux reported output (observedChangeAt), but idle/stuck are
1978
+ // decisions about *now*. The old double-observe expressed that by calling
1979
+ // observe twice, which silently disabled the "content moved" branch.
1980
+ const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
1981
+ settled,
1982
+ changeAt: observedChangeAt,
1983
+ });
1467
1984
  this.applyInstanceStateSnapshot(snapshot, pane);
1985
+ // Backends without a transcript feed can still say what they are doing, if
1986
+ // their TUI names it. Free-riding on a capture that already happened: no
1987
+ // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
1988
+ // safety sweep), which matches the progress ticker's own 60s interval.
1989
+ if (this.backend?.getPaneActivity) {
1990
+ this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
1991
+ }
1468
1992
  if (snapshot.state === "idle") {
1469
1993
  this.clearInstanceStateStuckTimer();
1470
1994
  }
@@ -1519,7 +2043,8 @@ export class Daemon extends EventEmitter {
1519
2043
  ? rawConfig.idle_debounce_ms
1520
2044
  : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1521
2045
  this.instanceStateReadyPattern = this.backend.getReadyPattern();
1522
- this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs);
2046
+ this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
2047
+ this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
1523
2048
  this.instanceStateLastOutputAt = 0;
1524
2049
  this.instanceStateMonitorActive = true;
1525
2050
  if (this.controlClient) {
@@ -1560,6 +2085,12 @@ export class Daemon extends EventEmitter {
1560
2085
  paneTail: sanitizePaneTail(pane),
1561
2086
  readyPattern: readyPattern.toString(),
1562
2087
  readyMatched: deterministicReadyPattern.test(pane),
2088
+ // A pane that is stuck *while* the ready marker matches means the busy
2089
+ // pattern is what held it back — worth seeing when tuning either regex.
2090
+ busyPattern: this.instanceStateBusyPattern?.toString(),
2091
+ busyMatched: this.instanceStateBusyPattern
2092
+ ? new RegExp(this.instanceStateBusyPattern.source, this.instanceStateBusyPattern.flags.replace(/[gy]/g, "")).test(pane)
2093
+ : undefined,
1563
2094
  unchangedForMs: snapshot.unchangedForMs,
1564
2095
  pendingWork: this.pendingWork.hasPendingWork(),
1565
2096
  };
@@ -1581,6 +2112,11 @@ export class Daemon extends EventEmitter {
1581
2112
  clearInterval(this.errorMonitorTimer);
1582
2113
  this.errorMonitorTimer = null;
1583
2114
  }
2115
+ // A pause or stop tears the CLI down anyway — the respawn brings a fresh MCP
2116
+ // server, so a deferred MCP-revival restart is moot (and its timer must not
2117
+ // fire into a stopped daemon).
2118
+ this.clearMcpRestartRequest();
2119
+ this.interactivePromptDetector.reset();
1584
2120
  this.stopInstanceStateMonitor();
1585
2121
  this.transcriptMonitor?.stop();
1586
2122
  this.guardian?.stop();
@@ -1602,6 +2138,40 @@ export class Daemon extends EventEmitter {
1602
2138
  return this.messageBus;
1603
2139
  }
1604
2140
  // ── Tool status tracking ──────────────────────────────────────
2141
+ /**
2142
+ * Tell the fleet manager what this instance is doing right now, for the live
2143
+ * progress line on the cancel button.
2144
+ *
2145
+ * Purely cosmetic — nothing decides anything from it, so it is fine that only
2146
+ * backends with a transcript feed report at all (claude-code today). For the
2147
+ * rest the progress line keeps showing just elapsed time.
2148
+ *
2149
+ * Repeats are dropped: the ticker only edits the channel message when the text
2150
+ * changes, and a stream of identical broadcasts would defeat that.
2151
+ */
2152
+ publishActivity(activity) {
2153
+ const next = activity && activity.trim() ? activity.trim() : null;
2154
+ if (next === this.currentActivity)
2155
+ return;
2156
+ this.currentActivity = next;
2157
+ this.ipcServer?.broadcast({
2158
+ type: "instance_activity",
2159
+ instanceName: this.name,
2160
+ activity: next,
2161
+ });
2162
+ }
2163
+ /**
2164
+ * Options for every system-initiated paste (startup notice, session snapshot,
2165
+ * runtime-dialog keys).
2166
+ *
2167
+ * One place, so the three call sites cannot drift apart on it. Backends with a
2168
+ * native input queue do not get the retry Enter: on those, a second bare Enter
2169
+ * is not the no-op the retry assumed but a queue mutation — the same reason
2170
+ * `deliverMessage` refuses to probe for busy with one.
2171
+ */
2172
+ systemPasteOptions() {
2173
+ return { retryEnter: this.backend?.supportsQueuedInput?.() !== true };
2174
+ }
1605
2175
  summarizeTool(name, input) {
1606
2176
  const inp = input;
1607
2177
  if (!inp)
@@ -1612,8 +2182,9 @@ export class Daemon extends EventEmitter {
1612
2182
  return `Edit ${inp.file_path ?? ""}`;
1613
2183
  if (name === "Write")
1614
2184
  return `Write ${inp.file_path ?? ""}`;
2185
+ // Command name only — never the arguments. See shellCommandLabel.
1615
2186
  if (name === "Bash")
1616
- return `$ ${String(inp.command ?? "").slice(0, 50)}`;
2187
+ return `$ ${shellCommandLabel(String(inp.command ?? ""))}`;
1617
2188
  if (name === "Glob")
1618
2189
  return `Glob ${inp.pattern ?? ""}`;
1619
2190
  if (name === "Grep")
@@ -1691,7 +2262,6 @@ export class Daemon extends EventEmitter {
1691
2262
  writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
1692
2263
  this.pendingInstructionsUpdate = undefined;
1693
2264
  }
1694
- this.hangDetector?.recordInbound();
1695
2265
  this.pendingWork.recordInbound();
1696
2266
  // v3: record user messages for rotation snapshot
1697
2267
  this.recordRecentUserMessage(content, meta);
@@ -1714,12 +2284,26 @@ export class Daemon extends EventEmitter {
1714
2284
  // #77: show the sender's display name for readability, keeping the machine
1715
2285
  // instance name in parens so the recipient's send_to_instance target is valid.
1716
2286
  const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
1717
- 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.)`;
2287
+ formatted = `[from:${fromLabel}] ${content}`;
2288
+ formatted += renderHandoffMetadata(meta);
2289
+ // A delegated task that requires a reply must not read like a chatty FYI —
2290
+ // the "you may stay silent" line is for the latter only.
2291
+ formatted += meta.requires_reply === "true"
2292
+ ? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
2293
+ : "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
1718
2294
  }
1719
2295
  else {
1720
2296
  const via = meta.source ? ` via ${meta.source}` : "";
1721
2297
  const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
1722
- formatted = `[user:${user}${via}${idTag}] ${content}\n(Reply using the reply tool — do NOT respond with direct text)`;
2298
+ formatted = `[user:${user}${via}${idTag}] ${content}`;
2299
+ // Reactions queued since the last real message (#432). One leading line of
2300
+ // context, present only when something is pending — a reaction no longer
2301
+ // costs a turn of its own.
2302
+ if (meta.pending_reactions) {
2303
+ formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
2304
+ }
2305
+ formatted += renderHandoffMetadata(meta);
2306
+ formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
1723
2307
  }
1724
2308
  if (meta.reply_to_text) {
1725
2309
  formatted += `\n(reply_to: "${meta.reply_to_text}")`;
@@ -1783,6 +2367,8 @@ export class Daemon extends EventEmitter {
1783
2367
  // Odd number of fences = unclosed. Remove all code fences from the message.
1784
2368
  formatted = formatted.replace(/```/g, "");
1785
2369
  }
2370
+ // Before anything reads the window id: a spawn in progress is about to change it.
2371
+ await this.waitForSpawnToSettle();
1786
2372
  let windowId = this.getWindowId();
1787
2373
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
1788
2374
  let handingOffToNativeQueue = false;
@@ -1798,9 +2384,33 @@ export class Daemon extends EventEmitter {
1798
2384
  }
1799
2385
  else {
1800
2386
  this.logger.debug("CLI busy — queuing message until idle");
1801
- await this.controlClient.waitUntilIdle(windowId);
2387
+ const becameIdle = await this.controlClient.waitUntilIdle(windowId);
2388
+ if (!becameIdle) {
2389
+ // The pane never freed up. Report the failure instead of pasting into a
2390
+ // wedged CLI (where the text would sit unsubmitted and the next message
2391
+ // would land on top of it) — and instead of holding the queue silently.
2392
+ this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
2393
+ if (status)
2394
+ this.emit("message_failed", status); // ❌
2395
+ return false;
2396
+ }
1802
2397
  }
1803
2398
  }
2399
+ // Everything above is *waiting*; everything below *writes*. Only the write is
2400
+ // held under the pane lock — holding it across the idle wait (up to 30 min)
2401
+ // would starve the runtime-dialog dismisser, which is often the very thing
2402
+ // that would let the pane go idle again.
2403
+ return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
2404
+ }
2405
+ /**
2406
+ * The write half of a delivery: paste → Enter → confirm, with retries.
2407
+ *
2408
+ * Always called under {@link paneWriteLock}. Split out from `deliverMessage`
2409
+ * precisely so the lock's scope is visible at the call site rather than being
2410
+ * an invariant maintained by comments.
2411
+ */
2412
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
2413
+ let windowId = initialWindowId;
1804
2414
  // Bug A: paste with backoff. Transient failures are usually a stale window id
1805
2415
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
1806
2416
  const maxAttempts = 3;
@@ -1821,19 +2431,73 @@ export class Daemon extends EventEmitter {
1821
2431
  this.logger.debug({ enterSettleMs }, "First delivery after ready — extending Enter settle delay");
1822
2432
  }
1823
2433
  await new Promise(r => setTimeout(r, enterSettleMs));
1824
- const enterAt = Date.now();
2434
+ let enterAt = Date.now();
1825
2435
  await this.tmux.sendSpecialKey("Enter");
2436
+ // Kiro may expose its ready prompt before its final startup redraw. If
2437
+ // that redraw swallows Enter, the redraw's own output makes the normal
2438
+ // busy confirmation return true and suppresses the conditional retry.
2439
+ // Kiro has no native input queue, so one defensive retry on the first
2440
+ // post-ready delivery is safe and cannot mutate a queued turn.
2441
+ if (enterSettleMs > NORMAL_ENTER_SETTLE_MS
2442
+ && this.backend?.requiresFirstDeliveryEnterRetry?.() === true) {
2443
+ await new Promise(r => setTimeout(r, 1_000));
2444
+ enterAt = Date.now();
2445
+ await this.tmux.sendSpecialKey("Enter");
2446
+ this.logger.debug("First delivery after ready — sent defensive Enter retry");
2447
+ }
1826
2448
  if (status)
1827
2449
  this.emit("message_delivered", status); // 👀
1828
- // A busy queue-capable CLI does not necessarily emit output when it accepts
1829
- // queued input. Treat the successful paste+Enter as the handoff confirmation;
1830
- // probing for idle→busy would time out and a second bare Enter could mutate
1831
- // the queue/TUI state. Idle submissions retain the swallowed-Enter safeguard.
2450
+ // Busy queue-capable CLIs (codex) may accept paste without an idle→busy
2451
+ // transition. Do NOT probe for busy — a second bare Enter can mutate the
2452
+ // queue. Instead verify the paste actually landed in the pane (TUI redraw
2453
+ // can silently swallow it). Idle submissions keep the swallowed-Enter path.
1832
2454
  if (handingOffToNativeQueue) {
2455
+ await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
2456
+ if (await this.nativeQueuePasteVisible(formatted)) {
2457
+ if (status)
2458
+ this.emit("message_confirmed", status); // ✅ native queue accepted
2459
+ return true;
2460
+ }
2461
+ // Silent loss: fall back once to the normal idle-gated path.
2462
+ this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
2463
+ if (windowId && this.controlClient) {
2464
+ await this.controlClient.waitUntilIdle(windowId);
2465
+ }
2466
+ const repasted = await this.tmux.pasteBuffer(formatted);
2467
+ if (!repasted) {
2468
+ this.logger.error("Idle-gated redelivery paste failed after native-queue silent loss");
2469
+ if (status)
2470
+ this.emit("message_failed", status); // ❌
2471
+ return false;
2472
+ }
2473
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
2474
+ const retryAt = Date.now();
2475
+ await this.tmux.sendSpecialKey("Enter");
2476
+ if (windowId && this.controlClient) {
2477
+ let becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
2478
+ if (!becameBusy) {
2479
+ this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
2480
+ const retry2At = Date.now();
2481
+ await this.tmux.sendSpecialKey("Enter");
2482
+ becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
2483
+ }
2484
+ if (becameBusy) {
2485
+ if (status)
2486
+ this.emit("message_confirmed", status); // ✅
2487
+ return true;
2488
+ }
2489
+ }
2490
+ else if (await this.nativeQueuePasteVisible(formatted)) {
2491
+ if (status)
2492
+ this.emit("message_confirmed", status); // ✅
2493
+ return true;
2494
+ }
2495
+ this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
1833
2496
  if (status)
1834
- this.emit("message_confirmed", status); // ✅ native queue accepted
2497
+ this.emit("message_failed", status); //
2498
+ return false;
1835
2499
  }
1836
- else if (windowId && this.controlClient) {
2500
+ if (windowId && this.controlClient) {
1837
2501
  let becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
1838
2502
  if (!becameBusy) {
1839
2503
  this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
@@ -1841,8 +2505,20 @@ export class Daemon extends EventEmitter {
1841
2505
  await this.tmux.sendSpecialKey("Enter");
1842
2506
  becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
1843
2507
  }
1844
- if (becameBusy && status)
1845
- this.emit("message_confirmed", status); // ✅
2508
+ if (becameBusy) {
2509
+ if (status)
2510
+ this.emit("message_confirmed", status); // ✅
2511
+ }
2512
+ else {
2513
+ // Both Enters were swallowed: the text is sitting UNSUBMITTED in the
2514
+ // CLI's input box. This used to return true, so the reaction stayed at 👀
2515
+ // forever and the next delivery pasted on top — submitting two messages
2516
+ // as one. Say so instead.
2517
+ this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
2518
+ if (status)
2519
+ this.emit("message_failed", status); // ❌
2520
+ return false;
2521
+ }
1846
2522
  }
1847
2523
  else {
1848
2524
  // No control client to observe output: fall back to the legacy double-Enter.
@@ -1858,8 +2534,36 @@ export class Daemon extends EventEmitter {
1858
2534
  this.emit("message_failed", status); // ❌
1859
2535
  return false;
1860
2536
  }
2537
+ /**
2538
+ * True when a busy native-queue paste appears to have landed: Codex shows a
2539
+ * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
2540
+ * Used only to detect silent paste loss — not as a general ready check.
2541
+ */
2542
+ async nativeQueuePasteVisible(formatted) {
2543
+ if (!this.tmux)
2544
+ return false;
2545
+ try {
2546
+ const pane = await this.tmux.capturePane();
2547
+ if (pane.includes("↳"))
2548
+ return true;
2549
+ for (const line of formatted.split(/\r?\n/)) {
2550
+ const t = line.trim();
2551
+ if (t.length >= 8 && pane.includes(t))
2552
+ return true;
2553
+ }
2554
+ const compact = formatted.replace(/\s+/g, " ").trim();
2555
+ if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
2556
+ return true;
2557
+ }
2558
+ return false;
2559
+ }
2560
+ catch {
2561
+ return false;
2562
+ }
2563
+ }
1861
2564
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
1862
2565
  async recoverWindow() {
2566
+ const previousWindowId = this.tmux?.getWindowId();
1863
2567
  try {
1864
2568
  const windows = await TmuxManager.listWindows(this.tmuxSessionName);
1865
2569
  const match = windows.find(w => w.name === this.name);
@@ -1867,6 +2571,12 @@ export class Daemon extends EventEmitter {
1867
2571
  return undefined;
1868
2572
  this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
1869
2573
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
2574
+ // The window we were talking to is gone; leaving it registered means the
2575
+ // control client re-resolves a dead id — one tmux subprocess — on every
2576
+ // reconnect, for the life of the fleet process.
2577
+ if (previousWindowId && previousWindowId !== match.id) {
2578
+ this.controlClient?.unregisterWindow(previousWindowId);
2579
+ }
1870
2580
  await this.controlClient?.registerWindow(match.id);
1871
2581
  this.bindInstanceStateOutputListener(match.id);
1872
2582
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
@@ -1877,12 +2587,38 @@ export class Daemon extends EventEmitter {
1877
2587
  return undefined;
1878
2588
  }
1879
2589
  }
1880
- /** Poll up to ~2s (200ms × 10) for the pane to emit output after `since`. */
2590
+ /**
2591
+ * Poll up to ~2s (200ms × 10) for the pane to emit output after `since`.
2592
+ *
2593
+ * A `false` here is not "no output" — it is read as **"the message was pasted but
2594
+ * never submitted"**, which re-sends Enter and then reports ❌ to the user. So a
2595
+ * false negative costs a possible double submit and a failure notice for a
2596
+ * message that actually arrived.
2597
+ *
2598
+ * That is exactly what a control-mode reconnect used to produce. `connect()`
2599
+ * drops every output timestamp, so `hasOutputSince` answers `false` for a pane
2600
+ * that did react — the evidence was thrown away, not absent. Blind time is
2601
+ * therefore not counted against the budget: after a reset the poll restarts from
2602
+ * the moment observation resumed. A hard wall-clock cap keeps a reconnect loop
2603
+ * from extending this forever.
2604
+ */
1881
2605
  async confirmBusyAfterEnter(windowId, since) {
1882
- for (let i = 0; i < 10; i++) {
1883
- await new Promise(r => setTimeout(r, 200));
1884
- if (this.controlClient.hasOutputSince(windowId, since))
2606
+ const client = this.controlClient;
2607
+ const hardDeadline = Date.now() + CONFIRM_BUSY_MAX_WAIT_MS;
2608
+ let observedFrom = since;
2609
+ let polls = 0;
2610
+ while (polls < CONFIRM_BUSY_POLLS) {
2611
+ await new Promise(r => setTimeout(r, CONFIRM_BUSY_POLL_MS));
2612
+ if (client.hasOutputSince(windowId, observedFrom))
1885
2613
  return true;
2614
+ const resetAt = client.getObservationResetAt();
2615
+ if (resetAt > observedFrom && Date.now() < hardDeadline) {
2616
+ this.logger.debug({ windowId, resetAt }, "Control mode reconnected mid-confirmation — restarting the Enter check from when we could see again");
2617
+ observedFrom = resetAt;
2618
+ polls = 0;
2619
+ continue;
2620
+ }
2621
+ polls++;
1886
2622
  }
1887
2623
  return false;
1888
2624
  }
@@ -1953,8 +2689,8 @@ export class Daemon extends EventEmitter {
1953
2689
  });
1954
2690
  const timeout = setTimeout(() => {
1955
2691
  this.pendingIpcRequests.delete(fleetReqId);
1956
- respond(null, "Task operation timed out after 30s");
1957
- }, 30_000);
2692
+ respond(null, `Task operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2693
+ }, daemonBudgetMs(tool));
1958
2694
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
1959
2695
  clearTimeout(timeout);
1960
2696
  respond(respMsg.result, respMsg.error);
@@ -1976,8 +2712,8 @@ export class Daemon extends EventEmitter {
1976
2712
  });
1977
2713
  const timeout = setTimeout(() => {
1978
2714
  this.pendingIpcRequests.delete(fleetReqId);
1979
- respond(null, "Decision operation timed out after 30s");
1980
- }, 30_000);
2715
+ respond(null, `Decision operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2716
+ }, daemonBudgetMs(tool));
1981
2717
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
1982
2718
  clearTimeout(timeout);
1983
2719
  respond(respMsg.result, respMsg.error);
@@ -2003,8 +2739,8 @@ export class Daemon extends EventEmitter {
2003
2739
  // Wait for fleet_schedule_response via pending request map
2004
2740
  const timeout = setTimeout(() => {
2005
2741
  this.pendingIpcRequests.delete(fleetReqId);
2006
- respond(null, "Schedule operation timed out after 30s");
2007
- }, 30_000);
2742
+ respond(null, `Schedule operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2743
+ }, daemonBudgetMs(tool));
2008
2744
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2009
2745
  clearTimeout(timeout);
2010
2746
  respond(respMsg.result, respMsg.error);
@@ -2025,7 +2761,9 @@ export class Daemon extends EventEmitter {
2025
2761
  fleetRequestId: fleetReqId,
2026
2762
  senderSessionName,
2027
2763
  });
2028
- const crossTimeoutMs = (tool === "start_instance" || tool === "create_instance" || tool === "replace_instance") ? 60_000 : 30_000;
2764
+ // Shared table, so delete_instance and the deployment tools get the same
2765
+ // budget here as the MCP client expects (the ad-hoc list omitted them).
2766
+ const crossTimeoutMs = daemonBudgetMs(tool);
2029
2767
  const timeout = setTimeout(() => {
2030
2768
  this.pendingIpcRequests.delete(fleetReqId);
2031
2769
  respond(null, `Cross-instance operation timed out after ${crossTimeoutMs / 1000}s`);
@@ -2052,7 +2790,11 @@ export class Daemon extends EventEmitter {
2052
2790
  }
2053
2791
  if (this.lastChatId) {
2054
2792
  args.chat_id = this.lastChatId;
2055
- if (tool === "reply")
2793
+ // Discord messages live in the channel/thread id, not the guild id in
2794
+ // chat_id. This is required for react/edit as well as reply. In
2795
+ // particular, general_topic deliberately has no configured reply thread,
2796
+ // so FleetManager cannot reconstruct this address from fleet.yaml.
2797
+ if (this.lastThreadId)
2056
2798
  args.thread_id = this.lastThreadId;
2057
2799
  }
2058
2800
  }
@@ -2065,11 +2807,20 @@ export class Daemon extends EventEmitter {
2065
2807
  // from prematurely resolving their pending requests when they receive the broadcast.
2066
2808
  const fleetReqId = `tool_${requestId}`;
2067
2809
  const outboundKey = fleetReqId;
2068
- this.ipcServer?.broadcast({ type: "fleet_outbound", tool, args, fleetRequestId: fleetReqId });
2810
+ this.ipcServer?.broadcast({
2811
+ type: "fleet_outbound",
2812
+ tool,
2813
+ args,
2814
+ fleetRequestId: fleetReqId,
2815
+ // Preserve the exact adapter world that supplied the chat context. An
2816
+ // instance binding is only a fallback: persisted/runtime context can be
2817
+ // from a secondary world, and message ids are not portable across bots.
2818
+ adapterId: this.lastAdapterId,
2819
+ });
2069
2820
  const timeout = setTimeout(() => {
2070
2821
  this.pendingIpcRequests.delete(outboundKey);
2071
- respond(null, "Fleet outbound timed out after 30s");
2072
- }, 30_000);
2822
+ respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
2823
+ }, daemonBudgetMs(tool));
2073
2824
  this.pendingIpcRequests.set(outboundKey, (respMsg) => {
2074
2825
  clearTimeout(timeout);
2075
2826
  respond(respMsg.result, respMsg.error);
@@ -2133,7 +2884,14 @@ export class Daemon extends EventEmitter {
2133
2884
  try {
2134
2885
  const all = JSON.parse(process.env.AGEND_DECISIONS);
2135
2886
  const workDir = this.config.working_directory;
2136
- decisions = all.filter(d => d.scope === "fleet" || d.project_root === workDir);
2887
+ // `scope: "fleet"` used to bypass the project check entirely, so a
2888
+ // single-project worker inherited every other project's playbook (8 of
2889
+ // 14 injected here were foreign) — resent on every API call. Now a
2890
+ // fleet decision must also be RELEVANT: global (no project_root), the
2891
+ // same project, or the same project family (worktrees/checkouts of one
2892
+ // repo). A general is exempt: it dispatches across all projects, so
2893
+ // cross-project routing rules are exactly what it needs.
2894
+ decisions = selectRelevantDecisions(all, workDir, this.config.general_topic === true);
2137
2895
  // Stable ordering so identical decision sets always build byte-identical
2138
2896
  // instructions — otherwise source ordering jitter flips the warmup hash.
2139
2897
  decisions.sort((a, b) => a.title.localeCompare(b.title));
@@ -2151,8 +2909,14 @@ export class Daemon extends EventEmitter {
2151
2909
  AGEND_BACKEND: this.runtimeIdentity?.backend ?? this.config.backend ?? this.backend?.binaryName ?? "unknown",
2152
2910
  AGEND_MODEL: this.runtimeIdentity?.model ?? this.config.model ?? "default",
2153
2911
  };
2154
- if (this.config.tool_set)
2155
- mcpEnv.AGEND_TOOL_SET = this.config.tool_set;
2912
+ // A general is a dispatcher, not a worker: default it to the `general` tool
2913
+ // profile instead of `full`. Every tool's schema is resent on every API call,
2914
+ // so full (44 tools) costs a general ~22k chars per turn — the single largest
2915
+ // slice of its prompt. Explicit config still wins.
2916
+ const defaultToolSet = this.config.general_topic ? "general" : undefined;
2917
+ const toolSet = this.config.tool_set ?? defaultToolSet;
2918
+ if (toolSet)
2919
+ mcpEnv.AGEND_TOOL_SET = toolSet;
2156
2920
  if (this.config.display_name)
2157
2921
  mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
2158
2922
  if (this.config.description)
@@ -2220,6 +2984,7 @@ export class Daemon extends EventEmitter {
2220
2984
  },
2221
2985
  skipPermissions: this.config.skipPermissions,
2222
2986
  model: this.modelOverride ?? this.config.model,
2987
+ effort: this.config.effort,
2223
2988
  kiroUi: this.config.kiro_ui,
2224
2989
  skipResume: this.skipResume,
2225
2990
  instructions,
@@ -2245,7 +3010,9 @@ export class Daemon extends EventEmitter {
2245
3010
  // Small delay to let the CLI fully render its ready prompt
2246
3011
  await new Promise(r => setTimeout(r, 1_000));
2247
3012
  try {
2248
- 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.`);
3013
+ // Messages can arrive during a restart and be queued on pasteLock before the
3014
+ // snapshot lands; both write to the pane, so both go through the same lock.
3015
+ 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()));
2249
3016
  this.logger.info("Injected session snapshot as first message");
2250
3017
  this.emit("snapshot_injected", this.name);
2251
3018
  }
@@ -2254,9 +3021,69 @@ export class Daemon extends EventEmitter {
2254
3021
  this.emit("snapshot_failed", this.name);
2255
3022
  }
2256
3023
  }
3024
+ /**
3025
+ * Depth, not a flag: the wake path marks a spawn and then calls trySpawn, which
3026
+ * marks another. With a plain boolean the inner one's completion would clear the
3027
+ * latch while the outer spawn was still dismissing dialogs, releasing delivery
3028
+ * into exactly the window this exists to close.
3029
+ */
3030
+ beginSpawn() {
3031
+ this.spawnDepth++;
3032
+ this.spawning = true;
3033
+ if (!this.spawnSettled) {
3034
+ this.spawnSettled = new Promise(resolve => { this.resolveSpawnSettled = resolve; });
3035
+ }
3036
+ }
3037
+ endSpawn() {
3038
+ this.spawnDepth = Math.max(0, this.spawnDepth - 1);
3039
+ if (this.spawnDepth > 0)
3040
+ return; // an enclosing spawn is still running
3041
+ this.spawning = false;
3042
+ const resolve = this.resolveSpawnSettled;
3043
+ this.spawnSettled = null;
3044
+ this.resolveSpawnSettled = null;
3045
+ resolve?.();
3046
+ }
3047
+ /**
3048
+ * Hold until the CLI has finished starting up.
3049
+ *
3050
+ * Startup is not a quiet period: `dismissDialogsUntilReady` is clicking through
3051
+ * trust prompts and session pickers. A pane showing a modal dialog produces no
3052
+ * output, so `waitUntilIdle` reports it idle and a queued message gets pasted
3053
+ * *into the dialog* — where the text is discarded and the Enter picks a menu
3054
+ * item. The paste and the Enter both "succeed", so this loses the message
3055
+ * without even a ❌.
3056
+ *
3057
+ * The pane write lock (#414) does not cover this: it serialises each key
3058
+ * sequence, but it is released between one dialog and the next.
3059
+ *
3060
+ * Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
3061
+ * holding the lock the spawn itself needs would deadlock.
3062
+ */
3063
+ async waitForSpawnToSettle() {
3064
+ const settled = this.spawnSettled;
3065
+ if (!settled)
3066
+ return;
3067
+ this.logger.debug("Holding delivery until the CLI has finished starting up");
3068
+ let timer;
3069
+ const cap = new Promise(resolve => {
3070
+ timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
3071
+ timer.unref?.();
3072
+ });
3073
+ try {
3074
+ await Promise.race([settled, cap]);
3075
+ }
3076
+ finally {
3077
+ if (timer)
3078
+ clearTimeout(timer);
3079
+ }
3080
+ if (this.spawning) {
3081
+ this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
3082
+ }
3083
+ }
2257
3084
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
2258
3085
  async spawnClaudeWindow() {
2259
- this.spawning = true;
3086
+ this.beginSpawn();
2260
3087
  let resumedSuccessfully = false;
2261
3088
  try {
2262
3089
  this.toolStatusLines = [];
@@ -2293,7 +3120,7 @@ export class Daemon extends EventEmitter {
2293
3120
  this.backgroundSessionRecoveryAttempted = false;
2294
3121
  }
2295
3122
  finally {
2296
- this.spawning = false;
3123
+ this.endSpawn();
2297
3124
  }
2298
3125
  return resumedSuccessfully;
2299
3126
  }
@@ -2364,11 +3191,13 @@ export class Daemon extends EventEmitter {
2364
3191
  // the global web token) from impersonating instances.
2365
3192
  const agentTokenPath = join(this.instanceDir, "agent.token");
2366
3193
  const agentToken = randomBytes(32).toString("hex");
2367
- writeFileSync(agentTokenPath, agentToken, { mode: 0o600 });
2368
- try {
2369
- chmodSync(agentTokenPath, 0o600);
3194
+ const tokenWrite = writeSecretFile(agentTokenPath, agentToken);
3195
+ if (!tokenWrite.ok) {
3196
+ // Do not fail the spawn over it — an instance that cannot start is worse
3197
+ // than one whose token is readable. But say so, loudly: the whole point of
3198
+ // this token is that other local processes cannot use it.
3199
+ 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");
2370
3200
  }
2371
- catch { }
2372
3201
  // AGEND_HOME points the child's agent-cli at the same data dir the daemon
2373
3202
  // is using, so it can locate <instanceDir>/agent.token.
2374
3203
  const agendHome = join(this.instanceDir, "..", "..");
@@ -2386,7 +3215,12 @@ export class Daemon extends EventEmitter {
2386
3215
  windowId = this.tmux.getWindowId();
2387
3216
  }
2388
3217
  else {
3218
+ // A crash respawn makes a brand new window. The dead one stays registered
3219
+ // unless we say so — the respawn branch above already does.
3220
+ const retired = this.tmux.getWindowId();
2389
3221
  windowId = await this.tmux.createWindow(cmd, resolvedCwd, this.name);
3222
+ if (retired && retired !== windowId)
3223
+ this.controlClient?.unregisterWindow(retired);
2390
3224
  }
2391
3225
  writeFileSync(join(this.instanceDir, "window-id"), windowId);
2392
3226
  // Enable remain-on-exit to capture exit codes on crash
@@ -2394,7 +3228,9 @@ export class Daemon extends EventEmitter {
2394
3228
  this.logger.warn({ err }, "Failed to set remain-on-exit — exit codes will not be captured");
2395
3229
  });
2396
3230
  if (reuseWindow && !this.config.lightweight) {
2397
- await this.tmux.pipeOutput(join(this.instanceDir, "output.log")).catch(err => {
3231
+ const outputLog = join(this.instanceDir, "output.log");
3232
+ rotateLogIfNeeded(outputLog);
3233
+ await this.tmux.pipeOutput(outputLog).catch(err => {
2398
3234
  this.logger.warn({ err }, "Failed to restore pipe-pane after wake");
2399
3235
  });
2400
3236
  }
@@ -2450,15 +3286,20 @@ export class Daemon extends EventEmitter {
2450
3286
  for (const dialog of startupDialogs) {
2451
3287
  if (dialog.pattern.test(pane)) {
2452
3288
  this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
2453
- for (const key of dialog.keys) {
2454
- if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
2455
- await this.tmux.sendSpecialKey(key);
3289
+ // Restart is exactly when inbound messages pile up, and nothing gates
3290
+ // delivery on `spawning`. Take the pane lock for the key sequence so a
3291
+ // queued message cannot be pasted into a half-dismissed trust dialog.
3292
+ await this.paneWriteLock.run(async () => {
3293
+ for (const key of dialog.keys) {
3294
+ if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
3295
+ await this.tmux.sendSpecialKey(key);
3296
+ }
3297
+ else {
3298
+ await this.tmux.sendKeys(key);
3299
+ }
3300
+ await new Promise(r => setTimeout(r, 200));
2456
3301
  }
2457
- else {
2458
- await this.tmux.sendKeys(key);
2459
- }
2460
- await new Promise(r => setTimeout(r, 200));
2461
- }
3302
+ });
2462
3303
  // Wait for next screen to render
2463
3304
  if (this.controlClient) {
2464
3305
  const wid = readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim();