@songsid/agend 2.1.6-beta.1 → 2.1.6-beta.10

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 (184) hide show
  1. package/dist/agent-endpoint.d.ts +18 -0
  2. package/dist/agent-endpoint.js +53 -2
  3. package/dist/agent-endpoint.js.map +1 -1
  4. package/dist/apply-job.d.ts +114 -0
  5. package/dist/apply-job.js +214 -0
  6. package/dist/apply-job.js.map +1 -0
  7. package/dist/backend/claude-code.d.ts +25 -0
  8. package/dist/backend/claude-code.js +127 -0
  9. package/dist/backend/claude-code.js.map +1 -1
  10. package/dist/backend/codex.d.ts +41 -0
  11. package/dist/backend/codex.js +133 -0
  12. package/dist/backend/codex.js.map +1 -1
  13. package/dist/backend/credential-profile.d.ts +159 -0
  14. package/dist/backend/credential-profile.js +340 -0
  15. package/dist/backend/credential-profile.js.map +1 -0
  16. package/dist/backend/factory.js +4 -1
  17. package/dist/backend/factory.js.map +1 -1
  18. package/dist/backend/kiro-auth-store.d.ts +25 -0
  19. package/dist/backend/kiro-auth-store.js +61 -0
  20. package/dist/backend/kiro-auth-store.js.map +1 -0
  21. package/dist/backend/kiro.d.ts +8 -0
  22. package/dist/backend/kiro.js +31 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/muse.d.ts +90 -0
  25. package/dist/backend/muse.js +437 -0
  26. package/dist/backend/muse.js.map +1 -0
  27. package/dist/backend/types.d.ts +41 -0
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/access-manager.d.ts +20 -0
  30. package/dist/channel/access-manager.js +25 -0
  31. package/dist/channel/access-manager.js.map +1 -1
  32. package/dist/channel/adapters/discord.d.ts +17 -0
  33. package/dist/channel/adapters/discord.js +72 -11
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.d.ts +6 -0
  36. package/dist/channel/adapters/telegram.js +36 -0
  37. package/dist/channel/adapters/telegram.js.map +1 -1
  38. package/dist/channel/markdown-chunk.d.ts +8 -3
  39. package/dist/channel/markdown-chunk.js +12 -3
  40. package/dist/channel/markdown-chunk.js.map +1 -1
  41. package/dist/channel/mcp-server.js +9 -12
  42. package/dist/channel/mcp-server.js.map +1 -1
  43. package/dist/channel/mcp-tools.d.ts +6 -2
  44. package/dist/channel/mcp-tools.js +6 -36
  45. package/dist/channel/mcp-tools.js.map +1 -1
  46. package/dist/channel/tool-router.js +32 -3
  47. package/dist/channel/tool-router.js.map +1 -1
  48. package/dist/channel/types.d.ts +18 -0
  49. package/dist/cli.js +113 -0
  50. package/dist/cli.js.map +1 -1
  51. package/dist/config-validator.js +45 -5
  52. package/dist/config-validator.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +96 -0
  54. package/dist/connection-secrets.js +25 -0
  55. package/dist/connection-secrets.js.map +1 -0
  56. package/dist/daemon.d.ts +61 -0
  57. package/dist/daemon.js +359 -88
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/event-log.d.ts +9 -0
  60. package/dist/event-log.js +21 -0
  61. package/dist/event-log.js.map +1 -1
  62. package/dist/fleet-level-config.d.ts +42 -0
  63. package/dist/fleet-level-config.js +78 -0
  64. package/dist/fleet-level-config.js.map +1 -0
  65. package/dist/fleet-lock.d.ts +21 -0
  66. package/dist/fleet-lock.js +42 -8
  67. package/dist/fleet-lock.js.map +1 -1
  68. package/dist/fleet-manager.d.ts +417 -2
  69. package/dist/fleet-manager.js +1981 -182
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
  72. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
  73. package/dist/instance-config-impact.d.ts +55 -0
  74. package/dist/instance-config-impact.js +152 -0
  75. package/dist/instance-config-impact.js.map +1 -0
  76. package/dist/instance-lifecycle.js +8 -4
  77. package/dist/instance-lifecycle.js.map +1 -1
  78. package/dist/locale.js +24 -0
  79. package/dist/locale.js.map +1 -1
  80. package/dist/logger.js +42 -25
  81. package/dist/logger.js.map +1 -1
  82. package/dist/muse-usage-relay.d.ts +72 -0
  83. package/dist/muse-usage-relay.js +419 -0
  84. package/dist/muse-usage-relay.js.map +1 -0
  85. package/dist/outbound-handlers.d.ts +5 -1
  86. package/dist/outbound-handlers.js +199 -1
  87. package/dist/outbound-handlers.js.map +1 -1
  88. package/dist/outbound-schemas.d.ts +7 -5
  89. package/dist/outbound-schemas.js +3 -2
  90. package/dist/outbound-schemas.js.map +1 -1
  91. package/dist/provider-probe.d.ts +48 -0
  92. package/dist/provider-probe.js +121 -0
  93. package/dist/provider-probe.js.map +1 -0
  94. package/dist/provider-secret-registry.d.ts +98 -0
  95. package/dist/provider-secret-registry.js +334 -0
  96. package/dist/provider-secret-registry.js.map +1 -0
  97. package/dist/quickstart-api.d.ts +164 -0
  98. package/dist/quickstart-api.js +351 -0
  99. package/dist/quickstart-api.js.map +1 -0
  100. package/dist/quickstart.js +23 -50
  101. package/dist/quickstart.js.map +1 -1
  102. package/dist/scheduler/db.js +17 -5
  103. package/dist/scheduler/db.js.map +1 -1
  104. package/dist/scheduler/db.test.js +34 -1
  105. package/dist/scheduler/db.test.js.map +1 -1
  106. package/dist/scheduler/types.d.ts +4 -0
  107. package/dist/scheduler/types.js.map +1 -1
  108. package/dist/secret-store.d.ts +25 -0
  109. package/dist/secret-store.js +165 -0
  110. package/dist/secret-store.js.map +1 -0
  111. package/dist/self-restart-limit.d.ts +21 -0
  112. package/dist/self-restart-limit.js +129 -0
  113. package/dist/self-restart-limit.js.map +1 -0
  114. package/dist/settings-api.d.ts +116 -0
  115. package/dist/settings-api.js +530 -5
  116. package/dist/settings-api.js.map +1 -1
  117. package/dist/setup-auth.d.ts +113 -0
  118. package/dist/setup-auth.js +181 -0
  119. package/dist/setup-auth.js.map +1 -0
  120. package/dist/setup-form.d.ts +18 -0
  121. package/dist/setup-form.js +315 -0
  122. package/dist/setup-form.js.map +1 -0
  123. package/dist/setup-host.d.ts +162 -0
  124. package/dist/setup-host.js +496 -0
  125. package/dist/setup-host.js.map +1 -0
  126. package/dist/setup-marker.d.ts +8 -0
  127. package/dist/setup-marker.js +39 -0
  128. package/dist/setup-marker.js.map +1 -0
  129. package/dist/setup-tunnel-consent.d.ts +53 -0
  130. package/dist/setup-tunnel-consent.js +82 -0
  131. package/dist/setup-tunnel-consent.js.map +1 -0
  132. package/dist/setup-wizard.js +4 -0
  133. package/dist/setup-wizard.js.map +1 -1
  134. package/dist/steer-capability.js +4 -1
  135. package/dist/steer-capability.js.map +1 -1
  136. package/dist/tips.js +1 -1
  137. package/dist/tips.js.map +1 -1
  138. package/dist/tmux-manager.js +29 -4
  139. package/dist/tmux-manager.js.map +1 -1
  140. package/dist/tool-permissions-notice.d.ts +38 -0
  141. package/dist/tool-permissions-notice.js +92 -0
  142. package/dist/tool-permissions-notice.js.map +1 -0
  143. package/dist/tool-permissions.d.ts +120 -0
  144. package/dist/tool-permissions.js +277 -0
  145. package/dist/tool-permissions.js.map +1 -0
  146. package/dist/topic-commands.js +1 -0
  147. package/dist/topic-commands.js.map +1 -1
  148. package/dist/transcript-sources.d.ts +12 -1
  149. package/dist/transcript-sources.js +19 -3
  150. package/dist/transcript-sources.js.map +1 -1
  151. package/dist/tunnel/cloudflared.d.ts +71 -0
  152. package/dist/tunnel/cloudflared.js +447 -0
  153. package/dist/tunnel/cloudflared.js.map +1 -0
  154. package/dist/tunnel/lease.d.ts +75 -0
  155. package/dist/tunnel/lease.js +209 -0
  156. package/dist/tunnel/lease.js.map +1 -0
  157. package/dist/tunnel/manager.d.ts +56 -0
  158. package/dist/tunnel/manager.js +167 -0
  159. package/dist/tunnel/manager.js.map +1 -0
  160. package/dist/tunnel/types.d.ts +119 -0
  161. package/dist/tunnel/types.js +33 -0
  162. package/dist/tunnel/types.js.map +1 -0
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/dashboard.html +4 -3
  165. package/dist/ui/settings.html +808 -156
  166. package/dist/ui/view.html +2 -2
  167. package/dist/usage/i18n-keys.d.ts +1 -1
  168. package/dist/usage/i18n-keys.js +1 -1
  169. package/dist/usage/i18n-keys.js.map +1 -1
  170. package/dist/usage/providers.d.ts +23 -14
  171. package/dist/usage/providers.js +220 -58
  172. package/dist/usage/providers.js.map +1 -1
  173. package/dist/usage/usage-api.d.ts +13 -0
  174. package/dist/usage/usage-api.js +69 -1
  175. package/dist/usage/usage-api.js.map +1 -1
  176. package/dist/web-api.js +8 -5
  177. package/dist/web-api.js.map +1 -1
  178. package/dist/web-auth.d.ts +75 -0
  179. package/dist/web-auth.js +208 -2
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-terminal.d.ts +30 -1
  182. package/dist/web-terminal.js +86 -3
  183. package/dist/web-terminal.js.map +1 -1
  184. package/package.json +2 -1
package/dist/daemon.js CHANGED
@@ -9,6 +9,9 @@ import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
9
9
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
10
10
  import { TranscriptMonitor } from "./transcript-monitor.js";
11
11
  import { createTranscriptSource } from "./transcript-sources.js";
12
+ import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
13
+ import { resolveToolSet } from "./tool-permissions.js";
14
+ import { getAgendHome } from "./paths.js";
12
15
  import { LOGIN_FLOWS } from "./login-flows.js";
13
16
  import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
14
17
  import { ContextGuardian } from "./context-guardian.js";
@@ -26,6 +29,7 @@ import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata,
26
29
  import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
27
30
  import { TurnReplyGuard } from "./turn-reply-guard.js";
28
31
  import { t } from "./locale.js";
32
+ import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
29
33
  const __filename = fileURLToPath(import.meta.url);
30
34
  const __dirname = dirname(__filename);
31
35
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -40,7 +44,7 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
40
44
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
41
45
  /** Point a resumed CLI at its one backend-native instruction source. */
42
46
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
43
- const source = binaryName === "codex" || binaryName === "grok"
47
+ const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
44
48
  ? "AGENTS.md"
45
49
  : binaryName === "kiro-cli"
46
50
  ? `.kiro/steering/agend-${instanceName}.md`
@@ -434,7 +438,23 @@ export class BackendUnreachableStartupError extends Error {
434
438
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
435
439
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
436
440
  /** A passive startup phase must clear within this bound before an Enter is sent. */
437
- const INPUT_TRANSIENT_WAIT_MS = 30_000;
441
+ /**
442
+ * How long a passive startup/resume transient may sit WITHOUT REPAINTING before
443
+ * delivery gives up. It is a stall budget, not a total: a CLI that is still
444
+ * painting its resume is making progress, and #826 is what happens when the two
445
+ * are confused — codex's resume is the only declared transient in the fleet
446
+ * (backend/codex.ts), a woken paused codex routinely takes longer than this to
447
+ * come back, and the flat 30s cap turned that into ❌ plus a discarded message
448
+ * on a pane that was seconds from ready.
449
+ */
450
+ const INPUT_TRANSIENT_STALL_MS = 30_000;
451
+ /**
452
+ * The absolute ceiling for the same wait, so a transient that repaints forever
453
+ * (a spinner on a wedged resume) still ends in an honest failure. Bounded, as
454
+ * every other delivery wait is — just bounded by "no progress", not by a guess
455
+ * at how long a resume takes.
456
+ */
457
+ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
438
458
  const INPUT_TRANSIENT_POLL_MS = 250;
439
459
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
440
460
  const STRANDED_INPUT_MAX_ROUNDS = 3;
@@ -877,6 +897,11 @@ export class Daemon extends EventEmitter {
877
897
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
878
898
  dialogParkedKey = null;
879
899
  dialogParkedReported = false;
900
+ /** Current stdin-owning runtime dialog, independent from execution state. */
901
+ inputBlockedDialogKey = null;
902
+ /** Generation/signature fence for safety dialogs while their screen is still visible. */
903
+ autoResolvedDialogKey = null;
904
+ autoResolvedDialogGeneration = 0;
880
905
  backgroundSessionRecoveryAttempted = false;
881
906
  /** Whether the last spawn started a fresh session (not resumed). */
882
907
  isNewSession = false;
@@ -946,6 +971,11 @@ export class Daemon extends EventEmitter {
946
971
  fatalStartupBlocked = false;
947
972
  /** model_error seen mid-turn: notify only if it survives to the idle screen. */
948
973
  pendingModelErrorKey = null;
974
+ /** Optional per-instance localhost relay used to observe Muse usage events. */
975
+ museUsageRelay = null;
976
+ /** Set after an in-run relay failure so subsequent Muse respawns go direct. */
977
+ museRelayFallback = false;
978
+ museRelayFallbackInFlight = null;
949
979
  /**
950
980
  * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
951
981
  * pause — see the pausePending consumption site for why waiting for idle there
@@ -1328,7 +1358,7 @@ export class Daemon extends EventEmitter {
1328
1358
  // (statusline transcript); codex/kiro/opencode read their CLI's own
1329
1359
  // conversation store via a pluggable source. Backends with no known
1330
1360
  // source stay inert exactly as before.
1331
- this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
1361
+ this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
1332
1362
  // 5. Wire transcript events
1333
1363
  const ackIfPending = () => {
1334
1364
  if (!this.pendingAckMessage || !this.adapter)
@@ -2167,6 +2197,7 @@ export class Daemon extends EventEmitter {
2167
2197
  if (!alive)
2168
2198
  return;
2169
2199
  const pane = await this.tmux.capturePane();
2200
+ const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2170
2201
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2171
2202
  if (interactivePrompt) {
2172
2203
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2183,7 +2214,7 @@ export class Daemon extends EventEmitter {
2183
2214
  const hasPendingWork = this.pendingWork.hasPendingWork();
2184
2215
  if (!hasPendingWork)
2185
2216
  this.blockingProcessDetector.reset();
2186
- const blockingProcess = hasPendingWork
2217
+ const blockingProcess = hasPendingWork && !inputBlockedDialog
2187
2218
  ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
2188
2219
  : null;
2189
2220
  if (blockingProcess) {
@@ -2205,27 +2236,80 @@ export class Daemon extends EventEmitter {
2205
2236
  blockingSeen = dialog;
2206
2237
  if (dialog.holdOnly)
2207
2238
  break; // recognised, deliberately not answered; trackDialogParked reports it
2239
+ const autoKey = dialog.autoResolutionKey;
2240
+ if (dialog.verifyAfterKeys && autoKey
2241
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
2242
+ && this.autoResolvedDialogKey === autoKey) {
2243
+ // A previous poll sent the safety choice but the CLI has not
2244
+ // repainted yet. Never send another Enter into the same screen.
2245
+ continue;
2246
+ }
2208
2247
  // These keys go straight into the pane. Sent while a message delivery is
2209
2248
  // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
2210
2249
  // submits it half-composed — the user sees a message that vanished. Skip
2211
2250
  // (not queue) when the pane is busy: this poller runs every 5s, and the
2212
2251
  // dialog will still be on screen next tick.
2252
+ let resolved = false;
2213
2253
  const dismissed = await this.paneWriteLock.tryRun(async () => {
2254
+ // Re-read under the lock: the first capture may have gone stale
2255
+ // while a delivery was finishing. A stale danger menu must never
2256
+ // receive a blind key sequence.
2257
+ const currentPane = await this.tmux.capturePane();
2258
+ if (!Daemon.dialogMatches(dialog, currentPane)) {
2259
+ this.updateInputBlockedState(currentPane, dialogs);
2260
+ return;
2261
+ }
2262
+ if (dialog.verifyAfterKeys && autoKey
2263
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
2264
+ && this.autoResolvedDialogKey === autoKey)
2265
+ return;
2266
+ if (dialog.verifyAfterKeys && autoKey) {
2267
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
2268
+ this.autoResolvedDialogKey = autoKey;
2269
+ }
2214
2270
  this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
2215
2271
  const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
2216
2272
  for (const key of dialog.keys) {
2273
+ let sent = false;
2217
2274
  if (SPECIAL_KEYS.has(key)) {
2218
- await this.tmux.sendSpecialKey(key);
2275
+ sent = await this.tmux.sendSpecialKey(key);
2219
2276
  }
2220
2277
  else {
2221
- await this.tmux.pasteText(key, this.systemPasteOptions());
2278
+ sent = await this.tmux.pasteText(key, this.systemPasteOptions());
2279
+ }
2280
+ if (!sent) {
2281
+ if (dialog.verifyAfterKeys && autoKey) {
2282
+ this.autoResolvedDialogGeneration = 0;
2283
+ this.autoResolvedDialogKey = null;
2284
+ }
2285
+ return;
2222
2286
  }
2223
2287
  await new Promise(r => setTimeout(r, 200));
2224
2288
  }
2289
+ if (dialog.verifyAfterKeys) {
2290
+ const afterKeysPane = await this.tmux.capturePane();
2291
+ const dialogStillActive = dialog.inputBlocked
2292
+ ? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
2293
+ : Daemon.dialogMatches(dialog, afterKeysPane);
2294
+ if (dialogStillActive) {
2295
+ this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
2296
+ return;
2297
+ }
2298
+ this.updateInputBlockedState(afterKeysPane, dialogs);
2299
+ if (dialog.postDismissNotice) {
2300
+ resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
2301
+ }
2302
+ else {
2303
+ resolved = true;
2304
+ }
2305
+ }
2225
2306
  });
2226
2307
  if (!dismissed) {
2227
2308
  this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2228
2309
  }
2310
+ else if (dialog.verifyAfterKeys && !resolved) {
2311
+ this.logger.warn({ dialog: dialog.description }, "Safety dialog choice was sent but follow-up verification or notice submission failed");
2312
+ }
2229
2313
  this.trackDialogParked(blockingSeen);
2230
2314
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2231
2315
  }
@@ -2544,6 +2628,13 @@ export class Daemon extends EventEmitter {
2544
2628
  this.inputTransientGuardGeneration = null;
2545
2629
  this.freezeRuntimeMonitors();
2546
2630
  this.pendingIpcRequests.clear();
2631
+ if (this.museUsageRelay) {
2632
+ await this.museUsageRelay.stop().catch(() => { });
2633
+ this.museUsageRelay = null;
2634
+ }
2635
+ else if (this.backend?.binaryName === "muse") {
2636
+ clearMuseUsageSnapshot(this.instanceDir);
2637
+ }
2547
2638
  if (this.adapter)
2548
2639
  await this.adapter.stop();
2549
2640
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -2823,6 +2914,15 @@ export class Daemon extends EventEmitter {
2823
2914
  }
2824
2915
  applyInstanceStateSnapshot(snapshot, pane) {
2825
2916
  const previous = this.instanceState;
2917
+ // A live safety menu owns stdin. It may still contain Claude's persistent
2918
+ // `❯` ready marker, but that is not an idle turn and must not clear pending
2919
+ // work, retire Cancel, arm auto-pause, or enter the generic stuck path.
2920
+ // Keep the public execution state unchanged until the menu disappears; the
2921
+ // dedicated input_blocked event carries the reason to observers.
2922
+ if (this.inputBlockedDialogKey !== null) {
2923
+ this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
2924
+ return;
2925
+ }
2826
2926
  this.instanceState = snapshot.state;
2827
2927
  // OpenCode creates its session lazily on the first submitted message.
2828
2928
  // Waiting until stop/pause to persist that id loses resume state when the
@@ -3296,6 +3396,7 @@ export class Daemon extends EventEmitter {
3296
3396
  const captureStartedAt = Date.now();
3297
3397
  try {
3298
3398
  const pane = await this.tmux.capturePane();
3399
+ this.updateInputBlockedState(pane);
3299
3400
  // Output received while capture-pane was in flight makes this snapshot
3300
3401
  // stale. Its output handler has already armed a new debounce.
3301
3402
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
@@ -3532,6 +3633,10 @@ export class Daemon extends EventEmitter {
3532
3633
  this.instanceStateSafetyListener = null;
3533
3634
  }
3534
3635
  handleStuckTransition(pane, snapshot, readyPattern) {
3636
+ if (this.inputBlockedDialogKey !== null) {
3637
+ this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing stuck notification while CLI input is blocked by a dialog");
3638
+ return;
3639
+ }
3535
3640
  const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
3536
3641
  const diagnostic = {
3537
3642
  backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
@@ -3831,6 +3936,28 @@ export class Daemon extends EventEmitter {
3831
3936
  }
3832
3937
  return formatted;
3833
3938
  }
3939
+ /**
3940
+ * Whether ONE delivery reached a verdict, carried by that delivery.
3941
+ *
3942
+ * `deliverMessage` returns false for two unrelated things: a delivery that
3943
+ * cannot land (window gone, a pane that never came back, an unanswered
3944
+ * dialog) and a delivery that was never attempted (user cancel, a tmux storm,
3945
+ * shutdown). Only the first is a failure to tell the sender about; reporting
3946
+ * the second told a sending agent its message was lost while the fleet was
3947
+ * merely standing down (#826).
3948
+ *
3949
+ * It is per call and not a field on the daemon because deliveries do NOT all
3950
+ * share one lock: an ordinary delivery is serialised on `pasteLock`, a steer
3951
+ * on `steerLock`, and the two run concurrently by design. A field would let a
3952
+ * steer's verdict decide whether a queued message's sender is told its
3953
+ * delivery failed — a false ❌ carrying the wrong correlation id.
3954
+ */
3955
+ failDelivery(verdict, status) {
3956
+ verdict.reached = true;
3957
+ if (status)
3958
+ this.emit("message_failed", status); // ❌
3959
+ return false;
3960
+ }
3834
3961
  /** Tell the fleet when an already-accepted cross-instance pane write failed. */
3835
3962
  reportCrossInstanceDeliveryFailure(meta, error) {
3836
3963
  if (!meta.from_instance)
@@ -3886,10 +4013,14 @@ export class Daemon extends EventEmitter {
3886
4013
  await this.wake();
3887
4014
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3888
4015
  return;
3889
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
4016
+ const verdict = { reached: false };
4017
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id, verdict })) {
3890
4018
  this.markTurnStarted(meta, formatted);
3891
4019
  }
3892
- else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4020
+ else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4021
+ // Same rule as the queued path: a steer that never got to try is not a
4022
+ // delivery failure. This holder is the steer's own, which is the point
4023
+ // — it runs on steerLock while a queued delivery runs on pasteLock.
3893
4024
  this.reportCrossInstanceDeliveryFailure(meta);
3894
4025
  }
3895
4026
  }).catch(err => {
@@ -4019,10 +4150,16 @@ export class Daemon extends EventEmitter {
4019
4150
  // A fresh delivery begins a fresh turn — its bubble must not inherit
4020
4151
  // the previous turn's tool list.
4021
4152
  this.resetToolProgress();
4022
- if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
4153
+ const verdict = { reached: false };
4154
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id, verdict })) {
4023
4155
  this.markTurnStarted(meta, formatted);
4024
4156
  }
4025
- else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4157
+ else if (meta.from_instance && verdict.reached
4158
+ && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4159
+ // Only a delivery that reached a verdict is reported. A pane that is
4160
+ // not ready yet, a cancel, a storm hold or a shutdown all return
4161
+ // false without one, and telling the sender its message was lost
4162
+ // there would be the false ❌ of #826 in its other form.
4026
4163
  this.reportCrossInstanceDeliveryFailure(meta);
4027
4164
  }
4028
4165
  }
@@ -4054,6 +4191,9 @@ export class Daemon extends EventEmitter {
4054
4191
  * serial in both cases so separate PTY writes can never overlap.
4055
4192
  */
4056
4193
  async deliverMessage(formatted, status, opts) {
4194
+ // The caller passes its own holder when it needs the answer; a system paste
4195
+ // that ignores the outcome gets a throwaway.
4196
+ const verdict = opts?.verdict ?? { reached: false };
4057
4197
  const cancelled = () => opts?.deliveryEpoch !== undefined
4058
4198
  && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
4059
4199
  if (cancelled() || this.stormWindow?.isStopped())
@@ -4077,7 +4217,7 @@ export class Daemon extends EventEmitter {
4077
4217
  await this.waitForSpawnToSettle();
4078
4218
  if (cancelled())
4079
4219
  return false;
4080
- if (this.refuseFatalStartupDelivery(status))
4220
+ if (this.refuseFatalStartupDelivery(verdict, status))
4081
4221
  return false;
4082
4222
  let windowId = this.getWindowId();
4083
4223
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
@@ -4119,9 +4259,7 @@ export class Daemon extends EventEmitter {
4119
4259
  // wedged CLI (where the text would sit unsubmitted and the next message
4120
4260
  // would land on top of it) — and instead of holding the queue silently.
4121
4261
  this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
4122
- if (status)
4123
- this.emit("message_failed", status); // ❌
4124
- return false;
4262
+ return this.failDelivery(verdict, status);
4125
4263
  }
4126
4264
  }
4127
4265
  }
@@ -4142,9 +4280,7 @@ export class Daemon extends EventEmitter {
4142
4280
  // recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
4143
4281
  if (round >= STRANDED_INPUT_MAX_ROUNDS) {
4144
4282
  this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
4145
- if (status)
4146
- this.emit("message_failed", status); // ❌
4147
- return false;
4283
+ return this.failDelivery(verdict, status);
4148
4284
  }
4149
4285
  const stranded = await this.submitStrandedInputIfAny(windowId);
4150
4286
  if (cancelled())
@@ -4153,18 +4289,14 @@ export class Daemon extends EventEmitter {
4153
4289
  break;
4154
4290
  if (stranded === "failed") {
4155
4291
  this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
4156
- if (status)
4157
- this.emit("message_failed", status); // ❌
4158
- return false;
4292
+ return this.failDelivery(verdict, status);
4159
4293
  }
4160
4294
  const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
4161
4295
  if (cancelled())
4162
4296
  return false;
4163
4297
  if (!readyAgain) {
4164
4298
  this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
4165
- if (status)
4166
- this.emit("message_failed", status); // ❌
4167
- return false;
4299
+ return this.failDelivery(verdict, status);
4168
4300
  }
4169
4301
  }
4170
4302
  }
@@ -4176,15 +4308,13 @@ export class Daemon extends EventEmitter {
4176
4308
  const outcome = await this.paneWriteLock.run(async () => {
4177
4309
  if (cancelled())
4178
4310
  return false;
4179
- if (this.refuseFatalStartupDelivery(status))
4311
+ if (this.refuseFatalStartupDelivery(verdict, status))
4180
4312
  return false;
4181
4313
  // A passive startup phase may paint after the outer readiness probe.
4182
4314
  // It clears without input, so waiting under the pane lock cannot starve
4183
4315
  // a dialog dismisser and closes the final clear→paste TOCTOU window.
4184
4316
  if (!(await this.waitForInputTransientToClear("pre-write"))) {
4185
- if (status)
4186
- this.emit("message_failed", status); // ❌
4187
- return false;
4317
+ return this.failDelivery(verdict, status);
4188
4318
  }
4189
4319
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4190
4320
  // whenever it likes — a resume prompt can be painted between "clear" and
@@ -4195,7 +4325,7 @@ export class Daemon extends EventEmitter {
4195
4325
  if (probe.state !== "clear")
4196
4326
  return "dialog";
4197
4327
  }
4198
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
4328
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
4199
4329
  });
4200
4330
  if (outcome !== "dialog")
4201
4331
  return outcome;
@@ -4204,18 +4334,14 @@ export class Daemon extends EventEmitter {
4204
4334
  // an honest failure rather than a message that never lands.
4205
4335
  if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
4206
4336
  this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
4207
- if (status)
4208
- this.emit("message_failed", status); // ❌
4209
- return false;
4337
+ return this.failDelivery(verdict, status);
4210
4338
  }
4211
4339
  this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
4212
4340
  const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
4213
4341
  if (cancelled())
4214
4342
  return false;
4215
4343
  if (!clear) {
4216
- if (status)
4217
- this.emit("message_failed", status); // ❌
4218
- return false;
4344
+ return this.failDelivery(verdict, status);
4219
4345
  }
4220
4346
  }
4221
4347
  }
@@ -4250,13 +4376,47 @@ export class Daemon extends EventEmitter {
4250
4376
  return null;
4251
4377
  }
4252
4378
  /** Capture failure is unknown, never evidence that input is available. */
4379
+ /**
4380
+ * The budget for waiting out a passive transient, measured in progress rather
4381
+ * than in wall clock.
4382
+ *
4383
+ * `observe(pane)` answers "may I keep waiting?". The stall timer restarts
4384
+ * every time the pane text changes, so a resume that is still painting never
4385
+ * runs out; a screen that has not changed for INPUT_TRANSIENT_STALL_MS, or a
4386
+ * transient that outlives the ceiling, does. Both ends stay bounded — the
4387
+ * point of #826 was never that the wait should be unlimited, it was that a
4388
+ * flat 30s could not tell "slow" from "stuck".
4389
+ */
4390
+ transientProgressBudget(overallDeadline) {
4391
+ const ceiling = Math.min(overallDeadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4392
+ let lastPane = null;
4393
+ let stallDeadline = 0;
4394
+ const budget = {
4395
+ stalled: false,
4396
+ observe(pane) {
4397
+ const now = Date.now();
4398
+ if (pane !== lastPane) {
4399
+ lastPane = pane;
4400
+ stallDeadline = now + INPUT_TRANSIENT_STALL_MS;
4401
+ }
4402
+ if (now >= ceiling)
4403
+ return false;
4404
+ budget.stalled = now >= stallDeadline;
4405
+ return !budget.stalled;
4406
+ },
4407
+ };
4408
+ return budget;
4409
+ }
4253
4410
  async probeInputTransient() {
4254
4411
  if (this.guardedInputTransients().length === 0 || !this.tmux)
4255
4412
  return { state: "clear" };
4256
4413
  try {
4257
4414
  const pane = await this.tmux.capturePane();
4258
4415
  const transient = this.inputTransientInPane(pane);
4259
- return transient ? { state: "active", transient } : { state: "clear" };
4416
+ // The pane text comes back with the verdict: a transient that is still
4417
+ // repainting is still working, and that is the only thing separating a
4418
+ // slow resume from a wedged one.
4419
+ return transient ? { state: "active", transient, pane } : { state: "clear" };
4260
4420
  }
4261
4421
  catch (err) {
4262
4422
  this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
@@ -4272,7 +4432,12 @@ export class Daemon extends EventEmitter {
4272
4432
  const generation = this.inputTransientGuardGeneration;
4273
4433
  if (generation === null || generation !== this.spawnGeneration)
4274
4434
  return true;
4275
- const deadline = Date.now() + timeoutMs;
4435
+ // Same progress rule as the outer readiness wait, for the same reason: this
4436
+ // runs under the pane write lock, so a flat cap here turned a resume that
4437
+ // repainted one second too late into a discarded message. Holding the lock
4438
+ // while the pane is still painting costs nothing — every other writer is
4439
+ // waiting on the same screen.
4440
+ const budget = this.transientProgressBudget(Date.now() + timeoutMs);
4276
4441
  let observedDescription = null;
4277
4442
  for (;;) {
4278
4443
  if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
@@ -4290,12 +4455,18 @@ export class Daemon extends EventEmitter {
4290
4455
  observedDescription = probe.transient.description;
4291
4456
  this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4292
4457
  }
4293
- const remaining = deadline - Date.now();
4294
- if (remaining <= 0) {
4295
- this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
4458
+ // "unknown" (capture-pane failed) has to consume the budget too, or an
4459
+ // unreadable pane would loop forever now that the flat deadline is gone.
4460
+ // It never "changes", so it stalls out on the same rule.
4461
+ if (!budget.observe(probe.state === "active" ? probe.pane : "\u0000unreadable")) {
4462
+ this.logger.error({
4463
+ phase, generation, transient: observedDescription,
4464
+ stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
4465
+ ceilingMs: timeoutMs,
4466
+ }, "CLI input stayed unavailable — refusing to send Enter");
4296
4467
  return false;
4297
4468
  }
4298
- await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4469
+ await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
4299
4470
  }
4300
4471
  }
4301
4472
  static dialogKey(dialog) {
@@ -4303,8 +4474,33 @@ export class Daemon extends EventEmitter {
4303
4474
  }
4304
4475
  /** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
4305
4476
  static dialogMatches(dialog, pane) {
4477
+ dialog.pattern.lastIndex = 0;
4306
4478
  return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
4307
4479
  }
4480
+ /** Refresh the separate stdin-blocked observation from one pane capture. */
4481
+ updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
4482
+ const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
4483
+ const nextKey = active ? Daemon.dialogKey(active) : null;
4484
+ if (nextKey !== this.inputBlockedDialogKey) {
4485
+ this.inputBlockedDialogKey = nextKey;
4486
+ this.emit("input_blocked", {
4487
+ name: this.name,
4488
+ blocked: active !== null,
4489
+ description: active?.description,
4490
+ });
4491
+ }
4492
+ if (!active && this.autoResolvedDialogGeneration === this.spawnGeneration) {
4493
+ // The old screen is gone. A later dangerous command in the same spawn
4494
+ // must get its own one-shot answer.
4495
+ this.autoResolvedDialogKey = null;
4496
+ this.autoResolvedDialogGeneration = 0;
4497
+ }
4498
+ return active;
4499
+ }
4500
+ /** Whether a runtime prompt currently owns the pane's stdin. */
4501
+ isInputBlocked() {
4502
+ return this.inputBlockedDialogKey !== null;
4503
+ }
4308
4504
  /**
4309
4505
  * Tri-state liveness. tmux's own isWindowAlive folds every query failure
4310
4506
  * into `false`, so a transient tmux hiccup would read as "the CLI died" —
@@ -4470,7 +4666,7 @@ export class Daemon extends EventEmitter {
4470
4666
  const deadline = Date.now() + timeoutMs;
4471
4667
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4472
4668
  let unknownStreak = 0;
4473
- let transientDeadline = 0;
4669
+ let transientBudget = null;
4474
4670
  for (;;) {
4475
4671
  const remaining = deadline - Date.now();
4476
4672
  if (remaining <= 0)
@@ -4483,15 +4679,19 @@ export class Daemon extends EventEmitter {
4483
4679
  const transient = await this.probeInputTransient();
4484
4680
  if (transient.state === "active") {
4485
4681
  unknownStreak = 0;
4486
- transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4487
- if (Date.now() >= transientDeadline) {
4488
- this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
4682
+ transientBudget ||= this.transientProgressBudget(deadline);
4683
+ if (!transientBudget.observe(transient.pane)) {
4684
+ this.logger.error({
4685
+ transient: transient.transient.description,
4686
+ stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
4687
+ ceilingMs: INPUT_TRANSIENT_WAIT_MS,
4688
+ }, "CLI input stayed unavailable during the delivery-readiness wait");
4489
4689
  return false;
4490
4690
  }
4491
4691
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4492
4692
  continue;
4493
4693
  }
4494
- transientDeadline = 0;
4694
+ transientBudget = null;
4495
4695
  if (transient.state === "unknown") {
4496
4696
  if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4497
4697
  this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
@@ -4637,12 +4837,11 @@ export class Daemon extends EventEmitter {
4637
4837
  * what to fix, and holding would only build a queue that floods the pane the
4638
4838
  * moment the flag clears.
4639
4839
  */
4640
- refuseFatalStartupDelivery(status) {
4840
+ refuseFatalStartupDelivery(verdict, status) {
4641
4841
  if (!this.fatalStartupBlocked)
4642
4842
  return false;
4643
4843
  this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
4644
- if (status)
4645
- this.emit("message_failed", status); // ❌
4844
+ this.failDelivery(verdict, status);
4646
4845
  return true;
4647
4846
  }
4648
4847
  /**
@@ -4721,7 +4920,10 @@ export class Daemon extends EventEmitter {
4721
4920
  }
4722
4921
  return sent;
4723
4922
  }
4724
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4923
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
4924
+ // Last and defaulted so the positional callers that ignore the outcome stay
4925
+ // readable; deliverMessage, the only one that reports, always passes its own.
4926
+ verdict = { reached: false }) {
4725
4927
  const signature = this.submissionSignature(formatted, submissionId);
4726
4928
  let windowId = initialWindowId;
4727
4929
  // Bug A: paste with backoff. Transient failures are usually a stale window id
@@ -4740,9 +4942,9 @@ export class Daemon extends EventEmitter {
4740
4942
  ? "pasteBuffer failed — recovering window and backing off"
4741
4943
  : "pasteBuffer failed — non-retryable tmux error");
4742
4944
  if (!recoverable) {
4743
- if (status)
4744
- this.emit("message_failed", status);
4745
- return false;
4945
+ // A tmux error the window cannot be recovered from: the text will
4946
+ // never reach this pane, so it is a verdict like the others.
4947
+ return this.failDelivery(verdict, status);
4746
4948
  }
4747
4949
  windowId = (await this.recoverWindow()) ?? windowId;
4748
4950
  if (attempt < maxAttempts)
@@ -4772,9 +4974,7 @@ export class Daemon extends EventEmitter {
4772
4974
  }
4773
4975
  let enterAt = Date.now();
4774
4976
  if (!(await this.sendDeliveryEnter("initial-submit"))) {
4775
- if (status)
4776
- this.emit("message_failed", status); // ❌
4777
- return false;
4977
+ return this.failDelivery(verdict, status);
4778
4978
  }
4779
4979
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
4780
4980
  // paste — not only during the post-ready redraw (#479): on slower hosts it
@@ -4857,9 +5057,7 @@ export class Daemon extends EventEmitter {
4857
5057
  this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4858
5058
  const strandedAt = Date.now();
4859
5059
  if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4860
- if (status)
4861
- this.emit("message_failed", status); // ❌
4862
- return false;
5060
+ return this.failDelivery(verdict, status);
4863
5061
  }
4864
5062
  const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4865
5063
  if (afterEnter === "submitted") {
@@ -4874,9 +5072,7 @@ export class Daemon extends EventEmitter {
4874
5072
  // message nobody submitted. Output is corroboration; text sitting in
4875
5073
  // the input row is disqualifying, and disqualifying evidence wins.
4876
5074
  this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
4877
- if (status)
4878
- this.emit("message_failed", status); // ❌
4879
- return false;
5075
+ return this.failDelivery(verdict, status);
4880
5076
  }
4881
5077
  // "unproven": nothing of ours is on screen — the paste itself was lost,
4882
5078
  // so pasting it again cannot duplicate anything.
@@ -4885,16 +5081,12 @@ export class Daemon extends EventEmitter {
4885
5081
  this.logger.error({
4886
5082
  tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
4887
5083
  }, "Idle-gated redelivery paste failed after native-queue silent loss");
4888
- if (status)
4889
- this.emit("message_failed", status); // ❌
4890
- return false;
5084
+ return this.failDelivery(verdict, status);
4891
5085
  }
4892
5086
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4893
5087
  const retryAt = Date.now();
4894
5088
  if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
4895
- if (status)
4896
- this.emit("message_failed", status); // ❌
4897
- return false;
5089
+ return this.failDelivery(verdict, status);
4898
5090
  }
4899
5091
  if (windowId && this.controlClient) {
4900
5092
  if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
@@ -4909,9 +5101,7 @@ export class Daemon extends EventEmitter {
4909
5101
  return true;
4910
5102
  }
4911
5103
  this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
4912
- if (status)
4913
- this.emit("message_failed", status); // ❌
4914
- return false;
5104
+ return this.failDelivery(verdict, status);
4915
5105
  }
4916
5106
  if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
4917
5107
  // F2: output after Enter is necessary but not sufficient — the paste
@@ -4925,9 +5115,7 @@ export class Daemon extends EventEmitter {
4925
5115
  const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
4926
5116
  const retryAt = Date.now();
4927
5117
  if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
4928
- if (status)
4929
- this.emit("message_failed", status); // ❌
4930
- return false;
5118
+ return this.failDelivery(verdict, status);
4931
5119
  }
4932
5120
  submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
4933
5121
  }
@@ -4937,9 +5125,7 @@ export class Daemon extends EventEmitter {
4937
5125
  }
4938
5126
  else {
4939
5127
  this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
4940
- if (status)
4941
- this.emit("message_failed", status); // ❌
4942
- return false;
5128
+ return this.failDelivery(verdict, status);
4943
5129
  }
4944
5130
  }
4945
5131
  else if (windowId && this.controlClient) {
@@ -4964,9 +5150,7 @@ export class Daemon extends EventEmitter {
4964
5150
  // forever and the next delivery pasted on top — submitting two messages
4965
5151
  // as one. Say so instead.
4966
5152
  this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
4967
- if (status)
4968
- this.emit("message_failed", status); // ❌
4969
- return false;
5153
+ return this.failDelivery(verdict, status);
4970
5154
  }
4971
5155
  }
4972
5156
  else {
@@ -4979,9 +5163,7 @@ export class Daemon extends EventEmitter {
4979
5163
  return true;
4980
5164
  }
4981
5165
  this.logger.error("Message delivery failed after retries — window not ready");
4982
- if (status)
4983
- this.emit("message_failed", status); // ❌
4984
- return false;
5166
+ return this.failDelivery(verdict, status);
4985
5167
  }
4986
5168
  /**
4987
5169
  * The single place a pasted message is judged submitted, shared by the
@@ -5442,7 +5624,12 @@ export class Daemon extends EventEmitter {
5442
5624
  this.ipcServer?.broadcast({
5443
5625
  type: typeMap[tool],
5444
5626
  payload: args,
5445
- meta: { chat_id: this.lastChatId, thread_id: this.lastThreadId, instance_name: this.name },
5627
+ meta: {
5628
+ chat_id: this.lastChatId,
5629
+ thread_id: this.lastThreadId,
5630
+ adapter_id: this.lastAdapterId,
5631
+ instance_name: this.name,
5632
+ },
5446
5633
  fleetRequestId: fleetReqId,
5447
5634
  });
5448
5635
  return;
@@ -5632,10 +5819,17 @@ export class Daemon extends EventEmitter {
5632
5819
  // profile instead of `full`. Every tool's schema is resent on every API call,
5633
5820
  // so full (44 tools) costs a general ~22k chars per turn — the single largest
5634
5821
  // slice of its prompt. Explicit config still wins.
5635
- const defaultToolSet = this.config.general_topic ? "general" : undefined;
5636
- const toolSet = this.config.tool_set ?? defaultToolSet;
5637
- if (toolSet)
5638
- mcpEnv.AGEND_TOOL_SET = toolSet;
5822
+ // One rule for "is this a general", shared with everywhere else that asks.
5823
+ // The daemon used to read `general_topic` alone while `isGeneralInstance`
5824
+ // also accepted the name, so an instance called `general` without the flag
5825
+ // was a worker here and a general everywhere else.
5826
+ const isGeneral = this.config.general_topic === true || this.name === "general";
5827
+ // No third argument for an ordinary instance: the default is `worker` now,
5828
+ // and naming it here would be a second place to change it.
5829
+ const toolSet = isGeneral
5830
+ ? resolveToolSet(this.config, this.name, "general")
5831
+ : resolveToolSet(this.config, this.name);
5832
+ mcpEnv.AGEND_TOOL_SET = toolSet;
5639
5833
  if (this.config.display_name)
5640
5834
  mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
5641
5835
  if (this.config.description)
@@ -6068,6 +6262,10 @@ export class Daemon extends EventEmitter {
6068
6262
  this.startupAborted = true;
6069
6263
  this.freezeRuntimeMonitors();
6070
6264
  this.pendingIpcRequests.clear();
6265
+ if (this.museUsageRelay) {
6266
+ await this.museUsageRelay.stop().catch(() => { });
6267
+ this.museUsageRelay = null;
6268
+ }
6071
6269
  try {
6072
6270
  await this.killProcessTree();
6073
6271
  }
@@ -6123,6 +6321,32 @@ export class Daemon extends EventEmitter {
6123
6321
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
6124
6322
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
6125
6323
  }
6324
+ /**
6325
+ * A relay crash must not make the daemon kill a live Muse process. The
6326
+ * supervisor normally reclaims the same port; when that bounded recovery
6327
+ * fails, mark usage unavailable and leave Muse untouched. A later natural
6328
+ * spawn sees museRelayFallback and starts direct, without interrupting the
6329
+ * current conversation or destroying its session.
6330
+ */
6331
+ switchMuseToDirect() {
6332
+ if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
6333
+ return this.museRelayFallbackInFlight ?? Promise.resolve();
6334
+ }
6335
+ this.museRelayFallbackInFlight = (async () => {
6336
+ this.museRelayFallback = true;
6337
+ this.logger.warn("Muse usage relay could not recover — usage is unavailable until the next natural Muse restart");
6338
+ try {
6339
+ if (this.museUsageRelay) {
6340
+ await this.museUsageRelay.stop().catch(() => { });
6341
+ this.museUsageRelay = null;
6342
+ }
6343
+ }
6344
+ catch (err) {
6345
+ this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
6346
+ }
6347
+ })().finally(() => { this.museRelayFallbackInFlight = null; });
6348
+ return this.museRelayFallbackInFlight;
6349
+ }
6126
6350
  /**
6127
6351
  * Tell the agent its instructions moved, once the CLI is idle.
6128
6352
  *
@@ -6258,7 +6482,30 @@ export class Daemon extends EventEmitter {
6258
6482
  if (backendConfig.agentMode === "cli" && backendConfig.agentPort) {
6259
6483
  envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`;
6260
6484
  }
6261
- const cmd = `${envPrefix} ` + this.backend.buildCommand(backendConfig);
6485
+ // Muse's usage relay must be listening before its command is assembled.
6486
+ // Preparation is best-effort: if the loopback port cannot be bound, leave
6487
+ // museBaseUrl unset and let Muse connect directly so usage never blocks a
6488
+ // conversation.
6489
+ let launchConfig = backendConfig;
6490
+ if (this.backend?.binaryName === "muse" && !this.museRelayFallback) {
6491
+ try {
6492
+ this.museUsageRelay ??= new MuseUsageRelay({
6493
+ instanceDir: this.instanceDir,
6494
+ onUnavailable: () => { void this.switchMuseToDirect(); },
6495
+ });
6496
+ const baseUrl = await this.museUsageRelay.start();
6497
+ launchConfig = { ...backendConfig, museBaseUrl: baseUrl };
6498
+ }
6499
+ catch (err) {
6500
+ this.logger.warn({ reason: err instanceof Error ? err.message : "relay unavailable" }, "Muse usage relay unavailable — using direct connection");
6501
+ if (this.museUsageRelay) {
6502
+ await this.museUsageRelay.stop().catch(() => { });
6503
+ this.museUsageRelay = null;
6504
+ }
6505
+ clearMuseUsageSnapshot(this.instanceDir);
6506
+ }
6507
+ }
6508
+ const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
6262
6509
  // Ensure tmux session exists (may have been destroyed if all windows died)
6263
6510
  await TmuxManager.ensureSession(this.tmuxSessionName);
6264
6511
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
@@ -6694,6 +6941,30 @@ export class Daemon extends EventEmitter {
6694
6941
  }, "Snapshot written");
6695
6942
  return snapshot;
6696
6943
  }
6944
+ /**
6945
+ * The credential profile's store this instance's CLI writes to, if any.
6946
+ *
6947
+ * The conversation lives in the same file as the login, so an instance on a
6948
+ * profile keeps its transcript there too — reading the shared store would
6949
+ * follow whichever *other* agent happens to be talking, or find nothing.
6950
+ * Built with the same `getAgendHome()` the launch prefix uses, or the two
6951
+ * would disagree about where the profile is.
6952
+ */
6953
+ credentialProfileStore() {
6954
+ const backendName = this.config.backend ?? "claude-code";
6955
+ let profile;
6956
+ try {
6957
+ profile = resolveCredentialProfile(this.config.backend_options?.[backendName]);
6958
+ }
6959
+ catch {
6960
+ // A malformed name is refused where it is written; a transcript reader
6961
+ // must not take the instance down over it.
6962
+ return undefined;
6963
+ }
6964
+ if (!profile)
6965
+ return undefined;
6966
+ return credentialProfileStoreHome(getAgendHome(), backendName, profile);
6967
+ }
6697
6968
  /** Collect ring buffer data for handover to a replacement instance. */
6698
6969
  collectHandoverContext() {
6699
6970
  const lines = [];