@songsid/agend 2.1.5 → 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 +57 -0
  11. package/dist/backend/codex.js +183 -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 +54 -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 +76 -0
  57. package/dist/daemon.js +489 -103
  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 +425 -2
  69. package/dist/fleet-manager.js +2055 -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`
@@ -83,6 +87,8 @@ export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
83
87
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
84
88
  /** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
85
89
  export const PERIODIC_REDRAW_PROBE_MS = 25;
90
+ /** Do not turn a streaming Codex turn into one capture-pane per burst. */
91
+ export const STRUCTURED_IDLE_PROBE_MS = 500;
86
92
  /** A foreground server/port-forward should hand control back or be acknowledged. */
87
93
  export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
88
94
  const LAST_INBOUND_FILE = "last-inbound-at";
@@ -432,7 +438,23 @@ export class BackendUnreachableStartupError extends Error {
432
438
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
433
439
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
434
440
  /** A passive startup phase must clear within this bound before an Enter is sent. */
435
- 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;
436
458
  const INPUT_TRANSIENT_POLL_MS = 250;
437
459
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
438
460
  const STRANDED_INPUT_MAX_ROUNDS = 3;
@@ -875,6 +897,11 @@ export class Daemon extends EventEmitter {
875
897
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
876
898
  dialogParkedKey = null;
877
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;
878
905
  backgroundSessionRecoveryAttempted = false;
879
906
  /** Whether the last spawn started a fresh session (not resumed). */
880
907
  isNewSession = false;
@@ -889,6 +916,9 @@ export class Daemon extends EventEmitter {
889
916
  instanceStateMonitorTimer = null;
890
917
  instanceStateIdleTimer = null;
891
918
  instanceStateOutputProbeTimer = null;
919
+ /** Consecutive structural idle captures for a noisy, periodically-redrawing
920
+ * pane. Two are required before a working -> idle transition. */
921
+ instanceStatePeriodicIdleConfirmations = 0;
892
922
  instanceStateStuckTimer = null;
893
923
  instanceStateOutputListener = null;
894
924
  instanceStateOutputEventName = null;
@@ -941,6 +971,11 @@ export class Daemon extends EventEmitter {
941
971
  fatalStartupBlocked = false;
942
972
  /** model_error seen mid-turn: notify only if it survives to the idle screen. */
943
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;
944
979
  /**
945
980
  * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
946
981
  * pause — see the pausePending consumption site for why waiting for idle there
@@ -1323,7 +1358,7 @@ export class Daemon extends EventEmitter {
1323
1358
  // (statusline transcript); codex/kiro/opencode read their CLI's own
1324
1359
  // conversation store via a pluggable source. Backends with no known
1325
1360
  // source stay inert exactly as before.
1326
- 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()));
1327
1362
  // 5. Wire transcript events
1328
1363
  const ackIfPending = () => {
1329
1364
  if (!this.pendingAckMessage || !this.adapter)
@@ -2162,6 +2197,7 @@ export class Daemon extends EventEmitter {
2162
2197
  if (!alive)
2163
2198
  return;
2164
2199
  const pane = await this.tmux.capturePane();
2200
+ const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2165
2201
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2166
2202
  if (interactivePrompt) {
2167
2203
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2178,7 +2214,7 @@ export class Daemon extends EventEmitter {
2178
2214
  const hasPendingWork = this.pendingWork.hasPendingWork();
2179
2215
  if (!hasPendingWork)
2180
2216
  this.blockingProcessDetector.reset();
2181
- const blockingProcess = hasPendingWork
2217
+ const blockingProcess = hasPendingWork && !inputBlockedDialog
2182
2218
  ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
2183
2219
  : null;
2184
2220
  if (blockingProcess) {
@@ -2200,27 +2236,80 @@ export class Daemon extends EventEmitter {
2200
2236
  blockingSeen = dialog;
2201
2237
  if (dialog.holdOnly)
2202
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
+ }
2203
2247
  // These keys go straight into the pane. Sent while a message delivery is
2204
2248
  // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
2205
2249
  // submits it half-composed — the user sees a message that vanished. Skip
2206
2250
  // (not queue) when the pane is busy: this poller runs every 5s, and the
2207
2251
  // dialog will still be on screen next tick.
2252
+ let resolved = false;
2208
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
+ }
2209
2270
  this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
2210
2271
  const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
2211
2272
  for (const key of dialog.keys) {
2273
+ let sent = false;
2212
2274
  if (SPECIAL_KEYS.has(key)) {
2213
- await this.tmux.sendSpecialKey(key);
2275
+ sent = await this.tmux.sendSpecialKey(key);
2214
2276
  }
2215
2277
  else {
2216
- 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;
2217
2286
  }
2218
2287
  await new Promise(r => setTimeout(r, 200));
2219
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
+ }
2220
2306
  });
2221
2307
  if (!dismissed) {
2222
2308
  this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2223
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
+ }
2224
2313
  this.trackDialogParked(blockingSeen);
2225
2314
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2226
2315
  }
@@ -2539,6 +2628,13 @@ export class Daemon extends EventEmitter {
2539
2628
  this.inputTransientGuardGeneration = null;
2540
2629
  this.freezeRuntimeMonitors();
2541
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
+ }
2542
2638
  if (this.adapter)
2543
2639
  await this.adapter.stop();
2544
2640
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -2818,6 +2914,15 @@ export class Daemon extends EventEmitter {
2818
2914
  }
2819
2915
  applyInstanceStateSnapshot(snapshot, pane) {
2820
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
+ }
2821
2926
  this.instanceState = snapshot.state;
2822
2927
  // OpenCode creates its session lazily on the first submitted message.
2823
2928
  // Waiting until stop/pause to persist that id loses resume state when the
@@ -3230,13 +3335,13 @@ export class Daemon extends EventEmitter {
3230
3335
  * visible cell. Probe the pane once per burst: a changed capture is real work;
3231
3336
  * an identical ready capture remains idle and emits no state edge.
3232
3337
  */
3233
- scheduleInstanceStateOutputProbe() {
3338
+ scheduleInstanceStateOutputProbe(delayMs = PERIODIC_REDRAW_PROBE_MS) {
3234
3339
  if (this.instanceStateOutputProbeTimer)
3235
3340
  return;
3236
3341
  this.instanceStateOutputProbeTimer = setTimeout(() => {
3237
3342
  this.instanceStateOutputProbeTimer = null;
3238
3343
  void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
3239
- }, PERIODIC_REDRAW_PROBE_MS);
3344
+ }, delayMs);
3240
3345
  this.instanceStateOutputProbeTimer.unref?.();
3241
3346
  }
3242
3347
  clearInstanceStateStuckTimer() {
@@ -3291,6 +3396,7 @@ export class Daemon extends EventEmitter {
3291
3396
  const captureStartedAt = Date.now();
3292
3397
  try {
3293
3398
  const pane = await this.tmux.capturePane();
3399
+ this.updateInputBlockedState(pane);
3294
3400
  // Output received while capture-pane was in flight makes this snapshot
3295
3401
  // stale. Its output handler has already armed a new debounce.
3296
3402
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
@@ -3319,11 +3425,98 @@ export class Daemon extends EventEmitter {
3319
3425
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
3320
3426
  // decisions about *now*. The old double-observe expressed that by calling
3321
3427
  // observe twice, which silently disabled the "content moved" branch.
3322
- const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3323
- settled,
3324
- changeAt: observedChangeAt,
3325
- forceBusy: paneActivity !== null,
3326
- });
3428
+ const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
3429
+ let snapshot;
3430
+ if (structuredPeriodicIdle && reason === "output_probe") {
3431
+ const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3432
+ if (idlePane) {
3433
+ if (this.instanceState === "idle") {
3434
+ // Cosmetic output must not manufacture an idle -> working edge.
3435
+ this.instanceStatePeriodicIdleConfirmations = 2;
3436
+ }
3437
+ else {
3438
+ this.instanceStatePeriodicIdleConfirmations++;
3439
+ }
3440
+ if (this.instanceStatePeriodicIdleConfirmations < 2) {
3441
+ // One positive frame is intentionally not enough to bless a
3442
+ // working pane while the starfield is still repainting.
3443
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3444
+ }
3445
+ else {
3446
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3447
+ settled: true,
3448
+ changeAt: observedChangeAt,
3449
+ });
3450
+ }
3451
+ }
3452
+ else {
3453
+ // A live busy marker or an unfamiliar layout outranks the broad Codex
3454
+ // ready pattern during the noisy-redraw path. Unknown is working.
3455
+ this.instanceStatePeriodicIdleConfirmations = 0;
3456
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3457
+ }
3458
+ }
3459
+ else if (structuredPeriodicIdle && reason !== "idle_debounce" && !settled) {
3460
+ // Startup, safety, and explicit state probes must not bless a broad
3461
+ // Codex ready match when the structural layout is unknown or visibly
3462
+ // busy. The settled debounce below remains the compatibility path for
3463
+ // drafts and older layouts that simply lack the new footer.
3464
+ //
3465
+ // ONLY while output is still recent. recordOutput sets working
3466
+ // unconditionally, and the safety sweep fires for every daemon on a
3467
+ // timer — so without this guard a Codex that had gone quiet was flipped
3468
+ // to working by the next sweep and could never come back, because a
3469
+ // quiet pane produces no further output to re-evaluate it. That state
3470
+ // holds the Cancel button open, disables auto-pause, and makes the hang
3471
+ // detector report a perfectly idle instance as stuck. A settled capture
3472
+ // has no recent output to defend and falls through to the legacy
3473
+ // ready/busy reading below.
3474
+ //
3475
+ // The `settled` guard alone is not enough for the case this whole
3476
+ // branch exists to serve. While the Astra star field keeps repainting,
3477
+ // output never stops arriving, so the capture is NEVER settled — and a
3478
+ // sweep landing on an already-idle animated pane flipped it to working
3479
+ // once a minute, then back an instant later once two probes re-proved
3480
+ // it. Each flicker is a spurious pair of state edges for the fleet and
3481
+ // resets the idleSince this daemon reports when it asks to auto-pause.
3482
+ //
3483
+ // So the same rule the output_probe branch already applies holds here:
3484
+ // cosmetic output must not manufacture an idle -> working edge. A pane
3485
+ // that is STRUCTURALLY idle and already known idle stays idle.
3486
+ //
3487
+ // It must be re-read as SETTLED to do that. Falling through to the
3488
+ // ordinary reading is not equivalent: that path is given the real
3489
+ // `settled` (false, because the animation is still painting), and an
3490
+ // unsettled capture of a CHANGED pane is working by definition — so a
3491
+ // sweep landing on a frame no probe had captured yet still flipped an
3492
+ // idle pane over. Treating this capture as settled is what lets the
3493
+ // ready/busy reading decide on the frame itself.
3494
+ const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3495
+ if (idlePane && this.instanceState === "idle") {
3496
+ this.instanceStatePeriodicIdleConfirmations = 2;
3497
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3498
+ settled: true,
3499
+ changeAt: observedChangeAt,
3500
+ });
3501
+ }
3502
+ else {
3503
+ this.instanceStatePeriodicIdleConfirmations = 0;
3504
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3505
+ }
3506
+ }
3507
+ else {
3508
+ // Structural proof is an additional working-stage escape hatch only.
3509
+ // Settled captures retain the legacy ready/busy semantics for drafts,
3510
+ // older Codex layouts, and panes without a Context footer.
3511
+ if (structuredPeriodicIdle && settled) {
3512
+ this.instanceStatePeriodicIdleConfirmations = 0;
3513
+ }
3514
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3515
+ settled,
3516
+ changeAt: observedChangeAt,
3517
+ forceBusy: paneActivity !== null,
3518
+ });
3519
+ }
3327
3520
  this.applyInstanceStateSnapshot(snapshot, pane);
3328
3521
  // Backends without a transcript feed can still say what they are doing, if
3329
3522
  // their TUI names it. Free-riding on a capture that already happened: no
@@ -3332,10 +3525,11 @@ export class Daemon extends EventEmitter {
3332
3525
  if (this.backend?.getPaneActivity) {
3333
3526
  this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
3334
3527
  }
3335
- if (snapshot.state === "idle") {
3528
+ const currentState = this.instanceState;
3529
+ if (currentState === "idle") {
3336
3530
  this.clearInstanceStateStuckTimer();
3337
3531
  }
3338
- else if (snapshot.state === "working") {
3532
+ else if (currentState === "working") {
3339
3533
  // If control mode missed the pane change, the safety capture becomes
3340
3534
  // the new progress timestamp and re-arms both deadlines.
3341
3535
  if (!expectedOutputAt)
@@ -3357,11 +3551,17 @@ export class Daemon extends EventEmitter {
3357
3551
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
3358
3552
  return;
3359
3553
  this.instanceStateLastOutputAt = event.at;
3360
- if (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle") {
3554
+ const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
3555
+ if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
3361
3556
  // Do not emit idle→working solely because agy repainted an identical
3362
- // footer. The short capture probe below still observes genuine pane
3363
- // changes promptly and moves the state to working.
3364
- this.scheduleInstanceStateOutputProbe();
3557
+ // footer. Codex additionally uses a structural probe while working: two
3558
+ // positive prompt captures can recover from an animation stream that
3559
+ // never leaves a two-second quiet window.
3560
+ if (this.instanceState !== "idle") {
3561
+ this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(event.at));
3562
+ this.scheduleInstanceStateStuckDeadline(event.at);
3563
+ }
3564
+ this.scheduleInstanceStateOutputProbe(canProvePeriodicIdle ? STRUCTURED_IDLE_PROBE_MS : PERIODIC_REDRAW_PROBE_MS);
3365
3565
  this.scheduleInstanceStateIdleCapture();
3366
3566
  return;
3367
3567
  }
@@ -3397,6 +3597,7 @@ export class Daemon extends EventEmitter {
3397
3597
  this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
3398
3598
  this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
3399
3599
  this.instanceStateLastOutputAt = 0;
3600
+ this.instanceStatePeriodicIdleConfirmations = 0;
3400
3601
  this.instanceStateMonitorActive = true;
3401
3602
  if (this.controlClient) {
3402
3603
  this.instanceStateSafetyListener = () => { void this.captureAndEvaluateInstanceState("safety_sweep"); };
@@ -3414,6 +3615,7 @@ export class Daemon extends EventEmitter {
3414
3615
  }
3415
3616
  stopInstanceStateMonitor() {
3416
3617
  this.instanceStateMonitorActive = false;
3618
+ this.instanceStatePeriodicIdleConfirmations = 0;
3417
3619
  this.clearInstanceStateIdleTimer();
3418
3620
  this.clearInstanceStateOutputProbeTimer();
3419
3621
  this.clearInstanceStateStuckTimer();
@@ -3431,6 +3633,10 @@ export class Daemon extends EventEmitter {
3431
3633
  this.instanceStateSafetyListener = null;
3432
3634
  }
3433
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
+ }
3434
3640
  const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
3435
3641
  const diagnostic = {
3436
3642
  backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
@@ -3730,6 +3936,28 @@ export class Daemon extends EventEmitter {
3730
3936
  }
3731
3937
  return formatted;
3732
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
+ }
3733
3961
  /** Tell the fleet when an already-accepted cross-instance pane write failed. */
3734
3962
  reportCrossInstanceDeliveryFailure(meta, error) {
3735
3963
  if (!meta.from_instance)
@@ -3785,10 +4013,14 @@ export class Daemon extends EventEmitter {
3785
4013
  await this.wake();
3786
4014
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3787
4015
  return;
3788
- 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 })) {
3789
4018
  this.markTurnStarted(meta, formatted);
3790
4019
  }
3791
- 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.
3792
4024
  this.reportCrossInstanceDeliveryFailure(meta);
3793
4025
  }
3794
4026
  }).catch(err => {
@@ -3918,10 +4150,16 @@ export class Daemon extends EventEmitter {
3918
4150
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3919
4151
  // the previous turn's tool list.
3920
4152
  this.resetToolProgress();
3921
- 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 })) {
3922
4155
  this.markTurnStarted(meta, formatted);
3923
4156
  }
3924
- 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.
3925
4163
  this.reportCrossInstanceDeliveryFailure(meta);
3926
4164
  }
3927
4165
  }
@@ -3953,6 +4191,9 @@ export class Daemon extends EventEmitter {
3953
4191
  * serial in both cases so separate PTY writes can never overlap.
3954
4192
  */
3955
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 };
3956
4197
  const cancelled = () => opts?.deliveryEpoch !== undefined
3957
4198
  && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
3958
4199
  if (cancelled() || this.stormWindow?.isStopped())
@@ -3976,7 +4217,7 @@ export class Daemon extends EventEmitter {
3976
4217
  await this.waitForSpawnToSettle();
3977
4218
  if (cancelled())
3978
4219
  return false;
3979
- if (this.refuseFatalStartupDelivery(status))
4220
+ if (this.refuseFatalStartupDelivery(verdict, status))
3980
4221
  return false;
3981
4222
  let windowId = this.getWindowId();
3982
4223
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
@@ -4018,9 +4259,7 @@ export class Daemon extends EventEmitter {
4018
4259
  // wedged CLI (where the text would sit unsubmitted and the next message
4019
4260
  // would land on top of it) — and instead of holding the queue silently.
4020
4261
  this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
4021
- if (status)
4022
- this.emit("message_failed", status); // ❌
4023
- return false;
4262
+ return this.failDelivery(verdict, status);
4024
4263
  }
4025
4264
  }
4026
4265
  }
@@ -4041,9 +4280,7 @@ export class Daemon extends EventEmitter {
4041
4280
  // recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
4042
4281
  if (round >= STRANDED_INPUT_MAX_ROUNDS) {
4043
4282
  this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
4044
- if (status)
4045
- this.emit("message_failed", status); // ❌
4046
- return false;
4283
+ return this.failDelivery(verdict, status);
4047
4284
  }
4048
4285
  const stranded = await this.submitStrandedInputIfAny(windowId);
4049
4286
  if (cancelled())
@@ -4052,18 +4289,14 @@ export class Daemon extends EventEmitter {
4052
4289
  break;
4053
4290
  if (stranded === "failed") {
4054
4291
  this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
4055
- if (status)
4056
- this.emit("message_failed", status); // ❌
4057
- return false;
4292
+ return this.failDelivery(verdict, status);
4058
4293
  }
4059
4294
  const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
4060
4295
  if (cancelled())
4061
4296
  return false;
4062
4297
  if (!readyAgain) {
4063
4298
  this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
4064
- if (status)
4065
- this.emit("message_failed", status); // ❌
4066
- return false;
4299
+ return this.failDelivery(verdict, status);
4067
4300
  }
4068
4301
  }
4069
4302
  }
@@ -4075,15 +4308,13 @@ export class Daemon extends EventEmitter {
4075
4308
  const outcome = await this.paneWriteLock.run(async () => {
4076
4309
  if (cancelled())
4077
4310
  return false;
4078
- if (this.refuseFatalStartupDelivery(status))
4311
+ if (this.refuseFatalStartupDelivery(verdict, status))
4079
4312
  return false;
4080
4313
  // A passive startup phase may paint after the outer readiness probe.
4081
4314
  // It clears without input, so waiting under the pane lock cannot starve
4082
4315
  // a dialog dismisser and closes the final clear→paste TOCTOU window.
4083
4316
  if (!(await this.waitForInputTransientToClear("pre-write"))) {
4084
- if (status)
4085
- this.emit("message_failed", status); // ❌
4086
- return false;
4317
+ return this.failDelivery(verdict, status);
4087
4318
  }
4088
4319
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4089
4320
  // whenever it likes — a resume prompt can be painted between "clear" and
@@ -4094,7 +4325,7 @@ export class Daemon extends EventEmitter {
4094
4325
  if (probe.state !== "clear")
4095
4326
  return "dialog";
4096
4327
  }
4097
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
4328
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
4098
4329
  });
4099
4330
  if (outcome !== "dialog")
4100
4331
  return outcome;
@@ -4103,18 +4334,14 @@ export class Daemon extends EventEmitter {
4103
4334
  // an honest failure rather than a message that never lands.
4104
4335
  if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
4105
4336
  this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
4106
- if (status)
4107
- this.emit("message_failed", status); // ❌
4108
- return false;
4337
+ return this.failDelivery(verdict, status);
4109
4338
  }
4110
4339
  this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
4111
4340
  const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
4112
4341
  if (cancelled())
4113
4342
  return false;
4114
4343
  if (!clear) {
4115
- if (status)
4116
- this.emit("message_failed", status); // ❌
4117
- return false;
4344
+ return this.failDelivery(verdict, status);
4118
4345
  }
4119
4346
  }
4120
4347
  }
@@ -4149,13 +4376,47 @@ export class Daemon extends EventEmitter {
4149
4376
  return null;
4150
4377
  }
4151
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
+ }
4152
4410
  async probeInputTransient() {
4153
4411
  if (this.guardedInputTransients().length === 0 || !this.tmux)
4154
4412
  return { state: "clear" };
4155
4413
  try {
4156
4414
  const pane = await this.tmux.capturePane();
4157
4415
  const transient = this.inputTransientInPane(pane);
4158
- 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" };
4159
4420
  }
4160
4421
  catch (err) {
4161
4422
  this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
@@ -4171,7 +4432,12 @@ export class Daemon extends EventEmitter {
4171
4432
  const generation = this.inputTransientGuardGeneration;
4172
4433
  if (generation === null || generation !== this.spawnGeneration)
4173
4434
  return true;
4174
- 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);
4175
4441
  let observedDescription = null;
4176
4442
  for (;;) {
4177
4443
  if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
@@ -4189,12 +4455,18 @@ export class Daemon extends EventEmitter {
4189
4455
  observedDescription = probe.transient.description;
4190
4456
  this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4191
4457
  }
4192
- const remaining = deadline - Date.now();
4193
- if (remaining <= 0) {
4194
- 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");
4195
4467
  return false;
4196
4468
  }
4197
- await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4469
+ await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
4198
4470
  }
4199
4471
  }
4200
4472
  static dialogKey(dialog) {
@@ -4202,8 +4474,33 @@ export class Daemon extends EventEmitter {
4202
4474
  }
4203
4475
  /** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
4204
4476
  static dialogMatches(dialog, pane) {
4477
+ dialog.pattern.lastIndex = 0;
4205
4478
  return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
4206
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
+ }
4207
4504
  /**
4208
4505
  * Tri-state liveness. tmux's own isWindowAlive folds every query failure
4209
4506
  * into `false`, so a transient tmux hiccup would read as "the CLI died" —
@@ -4369,7 +4666,7 @@ export class Daemon extends EventEmitter {
4369
4666
  const deadline = Date.now() + timeoutMs;
4370
4667
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4371
4668
  let unknownStreak = 0;
4372
- let transientDeadline = 0;
4669
+ let transientBudget = null;
4373
4670
  for (;;) {
4374
4671
  const remaining = deadline - Date.now();
4375
4672
  if (remaining <= 0)
@@ -4382,15 +4679,19 @@ export class Daemon extends EventEmitter {
4382
4679
  const transient = await this.probeInputTransient();
4383
4680
  if (transient.state === "active") {
4384
4681
  unknownStreak = 0;
4385
- transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4386
- if (Date.now() >= transientDeadline) {
4387
- 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");
4388
4689
  return false;
4389
4690
  }
4390
4691
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4391
4692
  continue;
4392
4693
  }
4393
- transientDeadline = 0;
4694
+ transientBudget = null;
4394
4695
  if (transient.state === "unknown") {
4395
4696
  if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4396
4697
  this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
@@ -4536,12 +4837,11 @@ export class Daemon extends EventEmitter {
4536
4837
  * what to fix, and holding would only build a queue that floods the pane the
4537
4838
  * moment the flag clears.
4538
4839
  */
4539
- refuseFatalStartupDelivery(status) {
4840
+ refuseFatalStartupDelivery(verdict, status) {
4540
4841
  if (!this.fatalStartupBlocked)
4541
4842
  return false;
4542
4843
  this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
4543
- if (status)
4544
- this.emit("message_failed", status); // ❌
4844
+ this.failDelivery(verdict, status);
4545
4845
  return true;
4546
4846
  }
4547
4847
  /**
@@ -4556,7 +4856,7 @@ export class Daemon extends EventEmitter {
4556
4856
  return true;
4557
4857
  if (this.controlClient.isIdle(windowId))
4558
4858
  return true;
4559
- if (this.backend?.hasPeriodicPaneRedraw?.() !== true || this.instanceState !== "idle")
4859
+ if (!this.canTrustRedrawIdleState() || this.instanceState !== "idle")
4560
4860
  return false;
4561
4861
  const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
4562
4862
  if (lastOutputAt === undefined || !this.instanceStateMachine)
@@ -4568,7 +4868,7 @@ export class Daemon extends EventEmitter {
4568
4868
  waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
4569
4869
  if (!this.controlClient)
4570
4870
  return Promise.resolve(true);
4571
- if (this.backend?.hasPeriodicPaneRedraw?.() !== true) {
4871
+ if (!this.canTrustRedrawIdleState()) {
4572
4872
  return this.controlClient.waitUntilIdle(windowId, timeoutMs);
4573
4873
  }
4574
4874
  if (this.isPaneIdleForDelivery(windowId))
@@ -4587,6 +4887,20 @@ export class Daemon extends EventEmitter {
4587
4887
  }, timeoutMs);
4588
4888
  });
4589
4889
  }
4890
+ /**
4891
+ * Antigravity has always opted into pane-state delivery readiness because its
4892
+ * footer redraws while idle. Codex's structural idle proof is useful only
4893
+ * while the event-driven state monitor is actually running; without that
4894
+ * monitor there is no fresh proof to trust, so preserve the legacy
4895
+ * TmuxControlClient.waitUntilIdle fallback. This matters during isolated
4896
+ * startup/recovery paths as well as in tests: merely having a matcher must
4897
+ * never turn a missing observer into a 30-minute polling wait.
4898
+ */
4899
+ canTrustRedrawIdleState() {
4900
+ if (this.backend?.hasPeriodicPaneRedraw?.() === true)
4901
+ return true;
4902
+ return this.instanceStateMonitorActive && !!this.backend?.isPeriodicRedrawIdlePane;
4903
+ }
4590
4904
  /**
4591
4905
  * The write half of a delivery: paste → Enter → confirm, with retries.
4592
4906
  *
@@ -4606,7 +4920,10 @@ export class Daemon extends EventEmitter {
4606
4920
  }
4607
4921
  return sent;
4608
4922
  }
4609
- 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 }) {
4610
4927
  const signature = this.submissionSignature(formatted, submissionId);
4611
4928
  let windowId = initialWindowId;
4612
4929
  // Bug A: paste with backoff. Transient failures are usually a stale window id
@@ -4625,9 +4942,9 @@ export class Daemon extends EventEmitter {
4625
4942
  ? "pasteBuffer failed — recovering window and backing off"
4626
4943
  : "pasteBuffer failed — non-retryable tmux error");
4627
4944
  if (!recoverable) {
4628
- if (status)
4629
- this.emit("message_failed", status);
4630
- 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);
4631
4948
  }
4632
4949
  windowId = (await this.recoverWindow()) ?? windowId;
4633
4950
  if (attempt < maxAttempts)
@@ -4657,9 +4974,7 @@ export class Daemon extends EventEmitter {
4657
4974
  }
4658
4975
  let enterAt = Date.now();
4659
4976
  if (!(await this.sendDeliveryEnter("initial-submit"))) {
4660
- if (status)
4661
- this.emit("message_failed", status); // ❌
4662
- return false;
4977
+ return this.failDelivery(verdict, status);
4663
4978
  }
4664
4979
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
4665
4980
  // paste — not only during the post-ready redraw (#479): on slower hosts it
@@ -4742,9 +5057,7 @@ export class Daemon extends EventEmitter {
4742
5057
  this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4743
5058
  const strandedAt = Date.now();
4744
5059
  if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4745
- if (status)
4746
- this.emit("message_failed", status); // ❌
4747
- return false;
5060
+ return this.failDelivery(verdict, status);
4748
5061
  }
4749
5062
  const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4750
5063
  if (afterEnter === "submitted") {
@@ -4759,9 +5072,7 @@ export class Daemon extends EventEmitter {
4759
5072
  // message nobody submitted. Output is corroboration; text sitting in
4760
5073
  // the input row is disqualifying, and disqualifying evidence wins.
4761
5074
  this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
4762
- if (status)
4763
- this.emit("message_failed", status); // ❌
4764
- return false;
5075
+ return this.failDelivery(verdict, status);
4765
5076
  }
4766
5077
  // "unproven": nothing of ours is on screen — the paste itself was lost,
4767
5078
  // so pasting it again cannot duplicate anything.
@@ -4770,16 +5081,12 @@ export class Daemon extends EventEmitter {
4770
5081
  this.logger.error({
4771
5082
  tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
4772
5083
  }, "Idle-gated redelivery paste failed after native-queue silent loss");
4773
- if (status)
4774
- this.emit("message_failed", status); // ❌
4775
- return false;
5084
+ return this.failDelivery(verdict, status);
4776
5085
  }
4777
5086
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4778
5087
  const retryAt = Date.now();
4779
5088
  if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
4780
- if (status)
4781
- this.emit("message_failed", status); // ❌
4782
- return false;
5089
+ return this.failDelivery(verdict, status);
4783
5090
  }
4784
5091
  if (windowId && this.controlClient) {
4785
5092
  if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
@@ -4794,9 +5101,7 @@ export class Daemon extends EventEmitter {
4794
5101
  return true;
4795
5102
  }
4796
5103
  this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
4797
- if (status)
4798
- this.emit("message_failed", status); // ❌
4799
- return false;
5104
+ return this.failDelivery(verdict, status);
4800
5105
  }
4801
5106
  if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
4802
5107
  // F2: output after Enter is necessary but not sufficient — the paste
@@ -4810,9 +5115,7 @@ export class Daemon extends EventEmitter {
4810
5115
  const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
4811
5116
  const retryAt = Date.now();
4812
5117
  if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
4813
- if (status)
4814
- this.emit("message_failed", status); // ❌
4815
- return false;
5118
+ return this.failDelivery(verdict, status);
4816
5119
  }
4817
5120
  submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
4818
5121
  }
@@ -4822,9 +5125,7 @@ export class Daemon extends EventEmitter {
4822
5125
  }
4823
5126
  else {
4824
5127
  this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
4825
- if (status)
4826
- this.emit("message_failed", status); // ❌
4827
- return false;
5128
+ return this.failDelivery(verdict, status);
4828
5129
  }
4829
5130
  }
4830
5131
  else if (windowId && this.controlClient) {
@@ -4849,9 +5150,7 @@ export class Daemon extends EventEmitter {
4849
5150
  // forever and the next delivery pasted on top — submitting two messages
4850
5151
  // as one. Say so instead.
4851
5152
  this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
4852
- if (status)
4853
- this.emit("message_failed", status); // ❌
4854
- return false;
5153
+ return this.failDelivery(verdict, status);
4855
5154
  }
4856
5155
  }
4857
5156
  else {
@@ -4864,9 +5163,7 @@ export class Daemon extends EventEmitter {
4864
5163
  return true;
4865
5164
  }
4866
5165
  this.logger.error("Message delivery failed after retries — window not ready");
4867
- if (status)
4868
- this.emit("message_failed", status); // ❌
4869
- return false;
5166
+ return this.failDelivery(verdict, status);
4870
5167
  }
4871
5168
  /**
4872
5169
  * The single place a pasted message is judged submitted, shared by the
@@ -5327,7 +5624,12 @@ export class Daemon extends EventEmitter {
5327
5624
  this.ipcServer?.broadcast({
5328
5625
  type: typeMap[tool],
5329
5626
  payload: args,
5330
- 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
+ },
5331
5633
  fleetRequestId: fleetReqId,
5332
5634
  });
5333
5635
  return;
@@ -5517,10 +5819,17 @@ export class Daemon extends EventEmitter {
5517
5819
  // profile instead of `full`. Every tool's schema is resent on every API call,
5518
5820
  // so full (44 tools) costs a general ~22k chars per turn — the single largest
5519
5821
  // slice of its prompt. Explicit config still wins.
5520
- const defaultToolSet = this.config.general_topic ? "general" : undefined;
5521
- const toolSet = this.config.tool_set ?? defaultToolSet;
5522
- if (toolSet)
5523
- 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;
5524
5833
  if (this.config.display_name)
5525
5834
  mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
5526
5835
  if (this.config.description)
@@ -5953,6 +6262,10 @@ export class Daemon extends EventEmitter {
5953
6262
  this.startupAborted = true;
5954
6263
  this.freezeRuntimeMonitors();
5955
6264
  this.pendingIpcRequests.clear();
6265
+ if (this.museUsageRelay) {
6266
+ await this.museUsageRelay.stop().catch(() => { });
6267
+ this.museUsageRelay = null;
6268
+ }
5956
6269
  try {
5957
6270
  await this.killProcessTree();
5958
6271
  }
@@ -6008,6 +6321,32 @@ export class Daemon extends EventEmitter {
6008
6321
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
6009
6322
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
6010
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
+ }
6011
6350
  /**
6012
6351
  * Tell the agent its instructions moved, once the CLI is idle.
6013
6352
  *
@@ -6143,7 +6482,30 @@ export class Daemon extends EventEmitter {
6143
6482
  if (backendConfig.agentMode === "cli" && backendConfig.agentPort) {
6144
6483
  envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`;
6145
6484
  }
6146
- 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);
6147
6509
  // Ensure tmux session exists (may have been destroyed if all windows died)
6148
6510
  await TmuxManager.ensureSession(this.tmuxSessionName);
6149
6511
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
@@ -6579,6 +6941,30 @@ export class Daemon extends EventEmitter {
6579
6941
  }, "Snapshot written");
6580
6942
  return snapshot;
6581
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
+ }
6582
6968
  /** Collect ring buffer data for handover to a replacement instance. */
6583
6969
  collectHandoverContext() {
6584
6970
  const lines = [];