@songsid/agend 2.1.5-beta.2 → 2.1.5-beta.21

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 (96) hide show
  1. package/dist/backend/claude-code.d.ts +12 -0
  2. package/dist/backend/claude-code.js +14 -2
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/codex.d.ts +25 -1
  5. package/dist/backend/codex.js +76 -0
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend/kiro.js +30 -2
  8. package/dist/backend/kiro.js.map +1 -1
  9. package/dist/backend/types.d.ts +70 -0
  10. package/dist/backend/types.js +23 -1
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/channel/adapters/discord.d.ts +17 -2
  13. package/dist/channel/adapters/discord.js +82 -8
  14. package/dist/channel/adapters/discord.js.map +1 -1
  15. package/dist/channel/adapters/telegram.d.ts +2 -2
  16. package/dist/channel/adapters/telegram.js +8 -8
  17. package/dist/channel/adapters/telegram.js.map +1 -1
  18. package/dist/channel/types.d.ts +22 -1
  19. package/dist/cli.js +37 -37
  20. package/dist/cli.js.map +1 -1
  21. package/dist/config.d.ts +4 -0
  22. package/dist/config.js +4 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/daemon.d.ts +181 -21
  25. package/dist/daemon.js +1054 -225
  26. package/dist/daemon.js.map +1 -1
  27. package/dist/deadline.d.ts +23 -0
  28. package/dist/deadline.js +29 -0
  29. package/dist/deadline.js.map +1 -0
  30. package/dist/fleet-context.d.ts +17 -1
  31. package/dist/fleet-context.js.map +1 -1
  32. package/dist/fleet-manager.d.ts +189 -2
  33. package/dist/fleet-manager.js +843 -124
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/full-restart.d.ts +20 -0
  36. package/dist/full-restart.js +39 -0
  37. package/dist/full-restart.js.map +1 -0
  38. package/dist/instance-lifecycle.d.ts +8 -2
  39. package/dist/instance-lifecycle.js +47 -3
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/instance-removal.d.ts +13 -0
  42. package/dist/instance-removal.js +14 -0
  43. package/dist/instance-removal.js.map +1 -0
  44. package/dist/instructions.js +2 -2
  45. package/dist/instructions.js.map +1 -1
  46. package/dist/locale.js +54 -4
  47. package/dist/locale.js.map +1 -1
  48. package/dist/login-controller.d.ts +31 -4
  49. package/dist/login-controller.js +52 -4
  50. package/dist/login-controller.js.map +1 -1
  51. package/dist/login-flows.d.ts +29 -0
  52. package/dist/login-flows.js +48 -1
  53. package/dist/login-flows.js.map +1 -1
  54. package/dist/outbound-handlers.d.ts +1 -0
  55. package/dist/outbound-handlers.js +118 -29
  56. package/dist/outbound-handlers.js.map +1 -1
  57. package/dist/outbound-schemas.d.ts +7 -0
  58. package/dist/outbound-schemas.js +3 -0
  59. package/dist/outbound-schemas.js.map +1 -1
  60. package/dist/pane-input-residue.d.ts +25 -2
  61. package/dist/pane-input-residue.js +31 -2
  62. package/dist/pane-input-residue.js.map +1 -1
  63. package/dist/restart-progress.d.ts +35 -5
  64. package/dist/restart-progress.js +177 -34
  65. package/dist/restart-progress.js.map +1 -1
  66. package/dist/service-restart-selection.d.ts +27 -0
  67. package/dist/service-restart-selection.js +25 -0
  68. package/dist/service-restart-selection.js.map +1 -0
  69. package/dist/topic-commands.d.ts +27 -2
  70. package/dist/topic-commands.js +59 -13
  71. package/dist/topic-commands.js.map +1 -1
  72. package/dist/turn-reply-guard.d.ts +41 -0
  73. package/dist/turn-reply-guard.js +77 -0
  74. package/dist/turn-reply-guard.js.map +1 -0
  75. package/dist/ui/dashboard.html +34 -8
  76. package/dist/ui/view.html +32 -18
  77. package/dist/ui/web-terminal/terminal.html +1 -0
  78. package/dist/update-marker.d.ts +11 -1
  79. package/dist/update-marker.js +19 -4
  80. package/dist/update-marker.js.map +1 -1
  81. package/dist/update-progress.d.ts +1 -1
  82. package/dist/update-progress.js +12 -0
  83. package/dist/update-progress.js.map +1 -1
  84. package/dist/usage/providers.d.ts +39 -0
  85. package/dist/usage/providers.js +69 -3
  86. package/dist/usage/providers.js.map +1 -1
  87. package/dist/view-api.d.ts +1 -0
  88. package/dist/view-api.js +12 -2
  89. package/dist/view-api.js.map +1 -1
  90. package/dist/web-api.d.ts +22 -1
  91. package/dist/web-api.js +25 -1
  92. package/dist/web-api.js.map +1 -1
  93. package/dist/web-terminal-http.d.ts +8 -0
  94. package/dist/web-terminal-http.js +29 -3
  95. package/dist/web-terminal-http.js.map +1 -1
  96. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
@@ -23,7 +23,9 @@ import { writeSecretFile } from "./secret-file.js";
23
23
  import { PaneWriteLock } from "./pane-write-lock.js";
24
24
  import { buildFleetInstructions } from "./instructions.js";
25
25
  import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
26
- import { bottomRowIsReady, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
26
+ import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
27
+ import { TurnReplyGuard } from "./turn-reply-guard.js";
28
+ import { t } from "./locale.js";
27
29
  const __filename = fileURLToPath(import.meta.url);
28
30
  const __dirname = dirname(__filename);
29
31
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -34,6 +36,8 @@ const TASK_TOOL = "task";
34
36
  // Tools whose success proves the agent got a message out this turn. While any
35
37
  // of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
36
38
  const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
39
+ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
40
+ 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.";
37
41
  /** Point a resumed CLI at its one backend-native instruction source. */
38
42
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
39
43
  const source = binaryName === "codex" || binaryName === "grok"
@@ -47,6 +51,33 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
47
51
  : join(instanceDir, "fleet-instructions.md");
48
52
  return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
49
53
  }
54
+ /**
55
+ * Whether this backend has to be told in its pane that its instructions moved.
56
+ *
57
+ * A backend that re-reads them on resume already received the new ones from the
58
+ * resume itself, so the notice is pure interruption. Both places that can send
59
+ * it — the warmup paste and the deferred pendingInstructionsNotice — ask through
60
+ * here, so the two cannot drift apart again: the warmup path used to omit this
61
+ * check entirely and told claude-code to reload instructions it had just read.
62
+ */
63
+ export function backendNeedsPaneReloadNotice(backend) {
64
+ return !backend?.instructionsReloadedOnResume;
65
+ }
66
+ /**
67
+ * What the warmup should do about changed instructions.
68
+ *
69
+ * `skip` — nothing to say, or this backend learns of the change by itself.
70
+ * `defer` — worth saying, but nobody is talking to the instance; say it when a
71
+ * real message next arrives rather than provoking an unsolicited reply.
72
+ * `paste` — say it now, into a pane that already has a delivery in flight.
73
+ */
74
+ export function warmupNoticeAction(opts) {
75
+ if (!opts.warmupNeeded)
76
+ return "skip";
77
+ if (!backendNeedsPaneReloadNotice(opts.backend))
78
+ return "skip";
79
+ return opts.pasteQueueDepth === 0 ? "defer" : "paste";
80
+ }
50
81
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
51
82
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
52
83
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
@@ -350,15 +381,31 @@ export class PendingWorkTracker {
350
381
  // An async pane poll can finish after a newer inbound. Do not let its stale
351
382
  // observation clear work which had not arrived when the pane was captured.
352
383
  if (now < this.lastInboundAt)
353
- return;
384
+ return false;
354
385
  this.lastIdleAt = now;
355
386
  this.lastIdleOrder = ++this.sequence;
387
+ return true;
356
388
  }
357
389
  hasPendingWork() {
358
390
  return this.lastInboundOrder > this.lastIdleOrder;
359
391
  }
360
392
  }
361
393
  const NORMAL_ENTER_SETTLE_MS = 500;
394
+ /** How long to keep looking for proof after a retry Enter before failing it. */
395
+ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
396
+ const POST_ENTER_PROOF_POLL_MS = 250;
397
+ /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
398
+ const BASELINE_CAPTURE_ATTEMPTS = 3;
399
+ const BASELINE_CAPTURE_RETRY_MS = 150;
400
+ /** Occurrences of `needle` in `haystack` (plain text, no regex semantics). */
401
+ function countOccurrences(haystack, needle) {
402
+ if (!needle)
403
+ return 0;
404
+ let n = 0;
405
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length))
406
+ n++;
407
+ return n;
408
+ }
362
409
  /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
363
410
  const BOTTOM_READY_POLL_MS = 250;
364
411
  /** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
@@ -384,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
384
431
  }
385
432
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
386
433
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
434
+ /** A passive startup phase must clear within this bound before an Enter is sent. */
435
+ const INPUT_TRANSIENT_WAIT_MS = 30_000;
436
+ const INPUT_TRANSIENT_POLL_MS = 250;
387
437
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
388
438
  const STRANDED_INPUT_MAX_ROUNDS = 3;
389
439
  const FIRST_ENTER_SETTLE_MS = 1_750;
@@ -810,6 +860,14 @@ export class Daemon extends EventEmitter {
810
860
  spawnSettled = null;
811
861
  resolveSpawnSettled = null;
812
862
  spawnDepth = 0;
863
+ /** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
864
+ spawnGeneration = 0;
865
+ /**
866
+ * Keeps passive startup-transient checks alive through the first positively
867
+ * submitted post-spawn message. Without this tail, a transient painted just
868
+ * after the startup scan can still swallow that message's Enter.
869
+ */
870
+ inputTransientGuardGeneration = null;
813
871
  skipResume = false;
814
872
  startupAborted = false;
815
873
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -843,19 +901,14 @@ export class Daemon extends EventEmitter {
843
901
  instanceStateMonitorActive = false;
844
902
  sessionCheckpointWarningEmitted = false;
845
903
  statePollInFlight = false;
846
- // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
847
- // Set when an inbound message lands in the CLI; cleared on the idle edge that
848
- // ends the turn. With a dead MCP server and no successful channel tool call in
849
- // between, the agent's answer exists only on screen — the daemon relays it.
850
- turnHadInbound = false;
851
- turnOutboundDelivered = false;
852
- /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
853
- turnReplyDelivered = false;
854
- turnCorrelationId;
855
- turnInboundMarker;
904
+ // One generation-scoped source of truth for human-turn reply completion.
905
+ // It also supplies the older dead-MCP/malformed-call recovery paths, so those
906
+ // paths cannot disagree about whether the turn already spoke to the channel.
907
+ turnReplyGuard = new TurnReplyGuard();
856
908
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
857
909
  lastMalformedToolCallSignature;
858
910
  proxyReplySeq = 0;
911
+ lastReplyDropWarningAt = 0;
859
912
  autoPauseController;
860
913
  pauseRequested = false;
861
914
  /**
@@ -1032,6 +1085,33 @@ export class Daemon extends EventEmitter {
1032
1085
  static errorPatternKey(ep) {
1033
1086
  return `${ep.type}:${ep.pattern.source}`;
1034
1087
  }
1088
+ /**
1089
+ * Undo the auth suspicion a pattern match armed, once the backend's token-free
1090
+ * probe has said the credentials are fine.
1091
+ *
1092
+ * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1093
+ * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1094
+ * daemon permanently suspicious of an auth failure that never existed. That
1095
+ * state is not inert: authFailureUnresolved suppresses the stuck/hang
1096
+ * notification and holds MCP auto-restart (a later real MCP death is then read
1097
+ * as already-confirmed auth trouble rather than re-verified), and the recovery
1098
+ * gate suppresses further error detection until a ready pattern shows up.
1099
+ *
1100
+ * Only what an auth match armed is rolled back. If some other pattern has
1101
+ * since armed the recovery gate, that one is still live and stays.
1102
+ */
1103
+ clearSuspectedAuthFailure() {
1104
+ if (!this.authFailureUnresolved && !this.loginScreenReported)
1105
+ return false;
1106
+ this.authFailureUnresolved = false;
1107
+ this.loginScreenReported = false;
1108
+ if (this.lastDetectedErrorType === "auth_error") {
1109
+ this.clearErrorRecoveryGate();
1110
+ this.lastDetectedErrorType = null;
1111
+ }
1112
+ this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1113
+ return true;
1114
+ }
1035
1115
  clearErrorRecoveryGate() {
1036
1116
  this.errorWaitingForRecovery = false;
1037
1117
  this.errorDetectedAt = 0;
@@ -1230,48 +1310,7 @@ export class Daemon extends EventEmitter {
1230
1310
  // the instructions actually changed since the agent last saw them.
1231
1311
  // Skipping the no-op reload saves 10-30s of agent time on every restart
1232
1312
  // where instructions are unchanged.
1233
- (async () => {
1234
- try {
1235
- if (!this.warmupNeeded) {
1236
- this.logger.debug("Warmup skipped — instructions unchanged");
1237
- return;
1238
- }
1239
- // Skip warmup if no one is talking to this instance (avoid triggering
1240
- // unsolicited agent replies on idle instances after fleet restart).
1241
- if (this.pasteQueueDepth === 0) {
1242
- this.logger.debug("Warmup deferred — no pending inbound messages");
1243
- // Convert to pendingInstructionsNotice so it fires on next real message.
1244
- this.pendingInstructionsNotice = true;
1245
- try {
1246
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
1247
- }
1248
- catch { }
1249
- return;
1250
- }
1251
- const wid = existsSync(join(this.instanceDir, "window-id"))
1252
- ? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
1253
- if (wid && this.controlClient) {
1254
- await this.controlClient.waitForIdle(wid, 120_000);
1255
- }
1256
- else {
1257
- await new Promise(r => setTimeout(r, 5000));
1258
- }
1259
- // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
1260
- // delivery is already in flight or queued. Without the lock the notice and
1261
- // that delivery race into the same pane.
1262
- await this.paneWriteLock.run(async () => {
1263
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
1264
- });
1265
- // Record the value the agent has now been told about so the next
1266
- // unchanged restart skips the reload.
1267
- try {
1268
- writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
1269
- }
1270
- catch { /* best effort */ }
1271
- this.logger.debug("Warmup sent after idle");
1272
- }
1273
- catch { /* non-fatal */ }
1274
- })();
1313
+ void this.runWarmupInstructionNotice();
1275
1314
  if (!this.config.lightweight) {
1276
1315
  // 3. Pipe-pane for prompt detection. Rotate first so a ballooned log from a
1277
1316
  // previous stuck splash (hundreds of MB of ANSI frames) is truncated before
@@ -1913,12 +1952,8 @@ export class Daemon extends EventEmitter {
1913
1952
  // --continue again and crash in the same way → loop. Clear the session id
1914
1953
  // and skip resume so the next spawn starts fresh. (skipResume also stops
1915
1954
  // saveSessionId below from resurrecting the id from statusline.json.)
1916
- if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
1917
- this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
1918
- try {
1919
- unlinkSync(join(this.instanceDir, "session-id"));
1920
- }
1921
- catch { /* may not exist */ }
1955
+ if (this.paneSaysNoConversation(lastOutput)) {
1956
+ this.setSessionAside("cli_reported_no_conversation");
1922
1957
  this.skipResume = true;
1923
1958
  }
1924
1959
  // Append to crash history
@@ -2498,6 +2533,10 @@ export class Daemon extends EventEmitter {
2498
2533
  }
2499
2534
  async stop() {
2500
2535
  this.logger.info("Stopping daemon instance");
2536
+ this.turnReplyGuard.reset();
2537
+ // Invalidate any bounded pre-Enter wait from the process generation being
2538
+ // stopped. It must fail closed, not press Enter in a replacement pane.
2539
+ this.inputTransientGuardGeneration = null;
2501
2540
  this.freezeRuntimeMonitors();
2502
2541
  this.pendingIpcRequests.clear();
2503
2542
  if (this.adapter)
@@ -2791,7 +2830,7 @@ export class Daemon extends EventEmitter {
2791
2830
  // Only a transition back to idle completes pending work. Repeated idle
2792
2831
  // observations between enqueue and paste must not clear a newer inbound.
2793
2832
  if (snapshot.state === "idle" && previous !== "idle") {
2794
- this.pendingWork.recordIdle(snapshot.observedAt);
2833
+ const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
2795
2834
  // The turn is over. A transcript can end on a tool_use with no matching
2796
2835
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2797
2836
  // the last tool pinned to the progress line for the rest of the session.
@@ -2799,7 +2838,8 @@ export class Daemon extends EventEmitter {
2799
2838
  this.resetToolProgress();
2800
2839
  // Must run before the mcpRestartPending branch below: the pane text is the
2801
2840
  // only copy of the answer, and the revival restart is about to clear it.
2802
- this.maybeProxyReplyOnTurnEnd(pane);
2841
+ if (acceptedIdle)
2842
+ this.maybeProxyReplyOnTurnEnd(pane);
2803
2843
  }
2804
2844
  if (snapshot.state !== previous) {
2805
2845
  this.logger.info({
@@ -2850,13 +2890,17 @@ export class Daemon extends EventEmitter {
2850
2890
  // a task result, and a stale one (sol's review of #515).
2851
2891
  if (meta.from_instance || !meta.chat_id)
2852
2892
  return;
2853
- this.turnHadInbound = true;
2854
- this.turnOutboundDelivered = false;
2855
- this.turnReplyDelivered = false;
2856
- this.turnCorrelationId = meta.correlation_id || undefined;
2857
2893
  // The last non-empty line of what we pasted: everything on screen after it
2858
2894
  // is the agent's own output.
2859
- this.turnInboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2895
+ const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2896
+ this.turnReplyGuard.arm({
2897
+ adapterId: meta.adapter_id || undefined,
2898
+ chatId: meta.chat_id,
2899
+ threadId: meta.thread_id || undefined,
2900
+ messageId: meta.message_id || undefined,
2901
+ correlationId: meta.correlation_id || undefined,
2902
+ inboundMarker,
2903
+ });
2860
2904
  }
2861
2905
  /**
2862
2906
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -2866,56 +2910,197 @@ export class Daemon extends EventEmitter {
2866
2910
  * here (edge-triggered, then reset) is what makes it at most once per turn.
2867
2911
  */
2868
2912
  maybeProxyReplyOnTurnEnd(pane) {
2869
- const hadInbound = this.turnHadInbound;
2870
- const delivered = this.turnOutboundDelivered;
2871
- const replyDelivered = this.turnReplyDelivered;
2872
- const correlationId = this.turnCorrelationId;
2873
- const inboundMarker = this.turnInboundMarker;
2874
- this.turnHadInbound = false;
2875
- this.turnOutboundDelivered = false;
2876
- this.turnReplyDelivered = false;
2877
- this.turnCorrelationId = undefined;
2878
- this.turnInboundMarker = undefined;
2879
- if (!hadInbound || this.isPaused)
2913
+ const turn = this.turnReplyGuard.snapshot();
2914
+ if (!turn || this.isPaused)
2915
+ return;
2916
+ if (turn.replyDelivered) {
2917
+ if (turn.phase === "recovering") {
2918
+ this.emit("reply_drop_recovered", {
2919
+ name: this.name,
2920
+ correlationId: turn.target.correlationId,
2921
+ generation: turn.generation,
2922
+ });
2923
+ }
2924
+ this.turnReplyGuard.complete(turn.generation);
2925
+ return;
2926
+ }
2927
+ // A second idle edge ends the one permitted recovery turn. Never create a
2928
+ // third turn or guess at terminal text; make the failure visible instead.
2929
+ if (turn.phase === "recovering") {
2930
+ this.turnReplyGuard.complete(turn.generation);
2931
+ this.reportUnrecoveredReplyDrop(turn);
2880
2932
  return;
2933
+ }
2881
2934
  // Claude sometimes prints a broken XML tool call as plain text and returns
2882
2935
  // idle without ever invoking `reply`. This is independent of MCP liveness:
2883
2936
  // the model malformed the call before the server could receive it.
2884
- if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2885
- const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2937
+ if (this.isClaudeCodeBackend() && pane) {
2938
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
2886
2939
  if (malformed) {
2887
2940
  const stale = malformed.signature === this.lastMalformedToolCallSignature;
2888
2941
  this.lastMalformedToolCallSignature = malformed.signature;
2889
2942
  if (!stale) {
2890
- const recovered = malformed.text != null;
2891
- this.logger.warn({ correlationId, recovered }, recovered
2943
+ this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
2892
2944
  ? "Malformed Claude tool call detected — attempting to recover reply text"
2893
2945
  : "Malformed Claude tool call detected — reply text could not be extracted");
2894
- this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2895
- if (malformed.text)
2896
- this.sendRecoveredMalformedReply(malformed.text, correlationId);
2946
+ if (malformed.text) {
2947
+ this.queueMalformedReplyRecovery(turn, malformed.text);
2948
+ }
2949
+ else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
2950
+ this.emit("malformed_tool_call", {
2951
+ name: this.name,
2952
+ correlationId: turn.target.correlationId,
2953
+ recovered: false,
2954
+ recoveryStarted: true,
2955
+ });
2956
+ this.startReplyRecovery(turn, "malformed_call");
2957
+ }
2958
+ else {
2959
+ this.emit("malformed_tool_call", {
2960
+ name: this.name,
2961
+ correlationId: turn.target.correlationId,
2962
+ recovered: false,
2963
+ });
2964
+ this.turnReplyGuard.complete(turn.generation);
2965
+ }
2966
+ }
2967
+ else {
2968
+ this.turnReplyGuard.complete(turn.generation);
2897
2969
  }
2898
2970
  // Never let the broader dead-MCP fallback relay the XML/chrome too.
2899
2971
  return;
2900
2972
  }
2901
2973
  }
2902
- if (delivered)
2974
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
2975
+ // tools still suppress that proxy, but only a delivered `reply` satisfies
2976
+ // the human-facing completion guard below.
2977
+ if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
2978
+ && mcpServerState(this.instanceDir).state === "dead") {
2979
+ this.turnReplyGuard.complete(turn.generation);
2980
+ void this.sendProxyReply(pane, turn.target);
2903
2981
  return;
2904
- // Opt-in: raw pane text can carry secrets past the regex redaction.
2905
- if (this.config.mcp_proxy_reply !== true)
2982
+ }
2983
+ if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
2984
+ this.turnReplyGuard.complete(turn.generation);
2906
2985
  return;
2907
- // "dead" only: unknown means not started or exited cleanly — never proxy on it.
2908
- if (mcpServerState(this.instanceDir).state !== "dead")
2986
+ }
2987
+ if (turn.replyAttempted) {
2988
+ // A provider timeout can be "applied, then timed out". Retrying it would
2989
+ // risk a duplicate; report the unknown result and stop here.
2990
+ this.turnReplyGuard.complete(turn.generation);
2991
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
2909
2992
  return;
2910
- void this.sendProxyReply(pane, inboundMarker, correlationId);
2993
+ }
2994
+ this.startReplyRecovery(turn, "no_valid_call");
2911
2995
  }
2912
2996
  isClaudeCodeBackend() {
2913
2997
  return this.runtimeIdentity?.backend === "claude-code"
2914
2998
  || this.config.backend === "claude-code"
2915
2999
  || this.backend?.binaryName === "claude";
2916
3000
  }
3001
+ replyCompletionGuardEnabled() {
3002
+ return this.backend?.replyCompletionGuard === true;
3003
+ }
3004
+ startReplyRecovery(turn, reason) {
3005
+ if (!this.turnReplyGuard.beginRecovery(turn.generation))
3006
+ return;
3007
+ this.emit("reply_drop_detected", {
3008
+ name: this.name,
3009
+ correlationId: turn.target.correlationId,
3010
+ generation: turn.generation,
3011
+ reason,
3012
+ recoveryStarted: true,
3013
+ });
3014
+ void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
3015
+ this.queueReplyRecoveryPrompt(turn);
3016
+ }
3017
+ reportReplyDropWithoutRetry(turn, reason) {
3018
+ this.emit("reply_drop_detected", {
3019
+ name: this.name,
3020
+ correlationId: turn.target.correlationId,
3021
+ generation: turn.generation,
3022
+ reason,
3023
+ recoveryStarted: false,
3024
+ });
3025
+ void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
3026
+ }
3027
+ queueReplyRecoveryPrompt(turn) {
3028
+ const deliveryEpoch = this.deliveryEpoch;
3029
+ this.pasteQueueDepth++;
3030
+ this.pasteLock = this.pasteLock.then(async () => {
3031
+ try {
3032
+ const current = this.turnReplyGuard.snapshot();
3033
+ if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3034
+ return;
3035
+ if (current.replyDelivered) {
3036
+ this.turnReplyGuard.complete(turn.generation);
3037
+ this.emit("reply_drop_recovered", {
3038
+ name: this.name,
3039
+ correlationId: current.target.correlationId,
3040
+ generation: current.generation,
3041
+ });
3042
+ return;
3043
+ }
3044
+ const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
3045
+ deliveryEpoch,
3046
+ });
3047
+ if (!delivered) {
3048
+ this.turnReplyGuard.complete(turn.generation);
3049
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3050
+ }
3051
+ }
3052
+ finally {
3053
+ this.pasteQueueDepth--;
3054
+ }
3055
+ }).catch(err => {
3056
+ this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
3057
+ this.turnReplyGuard.complete(turn.generation);
3058
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3059
+ });
3060
+ }
3061
+ queueMalformedReplyRecovery(turn, text) {
3062
+ // Serialize the delivery decision ahead of any new pane input. If direct
3063
+ // delivery is positively acknowledged, there is no recovery turn. If its
3064
+ // outcome is unknown, do not ask the model to duplicate it.
3065
+ const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
3066
+ this.pasteQueueDepth++;
3067
+ this.pasteLock = this.pasteLock.then(async () => {
3068
+ try {
3069
+ const delivered = await delivery;
3070
+ this.emit("malformed_tool_call", {
3071
+ name: this.name,
3072
+ correlationId: turn.target.correlationId,
3073
+ recovered: delivered,
3074
+ });
3075
+ this.turnReplyGuard.complete(turn.generation);
3076
+ if (!delivered)
3077
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3078
+ }
3079
+ finally {
3080
+ this.pasteQueueDepth--;
3081
+ }
3082
+ }).catch(err => {
3083
+ this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
3084
+ this.turnReplyGuard.complete(turn.generation);
3085
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3086
+ });
3087
+ }
3088
+ reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
3089
+ this.emit("reply_drop_unrecovered", {
3090
+ name: this.name,
3091
+ correlationId: turn.target.correlationId,
3092
+ generation: turn.generation,
3093
+ reason,
3094
+ });
3095
+ const now = Date.now();
3096
+ if (this.lastReplyDropWarningAt !== 0
3097
+ && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
3098
+ return;
3099
+ this.lastReplyDropWarningAt = now;
3100
+ void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
3101
+ }
2917
3102
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2918
- async sendProxyReply(pane, inboundMarker, correlationId) {
3103
+ async sendProxyReply(pane, target) {
2919
3104
  try {
2920
3105
  // The idle capture normally hands its pane in; fall back only when the
2921
3106
  // edge came from a path without one.
@@ -2923,74 +3108,110 @@ export class Daemon extends EventEmitter {
2923
3108
  pane = await this.tmux?.capturePane();
2924
3109
  if (!pane)
2925
3110
  return;
2926
- const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
3111
+ const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
2927
3112
  if (!text) {
2928
3113
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
2929
3114
  return;
2930
3115
  }
2931
3116
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2932
- if (correlationId)
2933
- body += `\n\n(correlation_id: ${correlationId})`;
2934
- this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2935
- this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2936
- this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply");
3117
+ if (target.correlationId)
3118
+ body += `\n\n(correlation_id: ${target.correlationId})`;
3119
+ this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
3120
+ const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
3121
+ if (delivered)
3122
+ this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
2937
3123
  }
2938
3124
  catch (err) {
2939
3125
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2940
3126
  }
2941
3127
  }
2942
- /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2943
- sendRecoveredMalformedReply(text, correlationId) {
2944
- try {
2945
- this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2946
- }
2947
- catch (err) {
2948
- this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2949
- }
2950
- }
2951
3128
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2952
- deliverDaemonReply(body, requestPrefix, logLabel) {
3129
+ deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
2953
3130
  const args = { text: body };
2954
- if (this.lastChatId) {
2955
- args.chat_id = this.lastChatId;
2956
- if (this.lastThreadId)
2957
- args.thread_id = this.lastThreadId;
3131
+ const chatId = target?.chatId ?? this.lastChatId;
3132
+ const threadId = target?.threadId ?? this.lastThreadId;
3133
+ const adapterId = target?.adapterId ?? this.lastAdapterId;
3134
+ if (chatId) {
3135
+ args.chat_id = chatId;
3136
+ if (threadId)
3137
+ args.thread_id = threadId;
2958
3138
  }
2959
3139
  const adapters = this.messageBus.getAllAdapters();
2960
3140
  if (adapters.length > 0) {
2961
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2962
- if (error)
2963
- this.logger.error({ error }, `${logLabel} failed`);
2964
- else
2965
- this.logger.info(`${logLabel} delivered`);
3141
+ // With one adapter there is nothing to choose and adapters[0] is exact.
3142
+ // With several, "no adapter named" is a routing failure, not a default:
3143
+ // picking the first sends one world's chat id through another world's bot.
3144
+ const adapter = adapterId
3145
+ ? this.messageBus.getAdapter(adapterId)
3146
+ : (adapters.length === 1 ? adapters[0] : undefined);
3147
+ if (!adapter) {
3148
+ this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
3149
+ return Promise.resolve(false);
3150
+ }
3151
+ return new Promise(resolve => {
3152
+ let settled = false;
3153
+ const finish = (delivered, error) => {
3154
+ if (settled)
3155
+ return;
3156
+ settled = true;
3157
+ clearTimeout(timeout);
3158
+ if (delivered)
3159
+ this.logger.info(`${logLabel} delivered`);
3160
+ else
3161
+ this.logger.error({ error }, `${logLabel} failed`);
3162
+ resolve(delivered);
3163
+ };
3164
+ const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
3165
+ timeout.unref?.();
3166
+ try {
3167
+ if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
3168
+ finish(false, "reply route unavailable");
3169
+ }
3170
+ }
3171
+ catch {
3172
+ finish(false, "adapter reply threw before returning a promise");
3173
+ }
2966
3174
  });
2967
- return;
2968
3175
  }
2969
3176
  if (!this.ipcServer) {
2970
3177
  this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2971
- return;
3178
+ return Promise.resolve(false);
2972
3179
  }
2973
- const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2974
- const timeout = setTimeout(() => {
2975
- this.pendingIpcRequests.delete(fleetReqId);
2976
- this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2977
- }, daemonBudgetMs("reply"));
2978
- timeout.unref?.();
2979
- // Register before broadcast: an in-process/fast adapter can answer in the
2980
- // same tick, and a waiter installed afterwards would miss that response.
2981
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2982
- clearTimeout(timeout);
2983
- if (respMsg.error)
2984
- this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
2985
- else
2986
- this.logger.info(`${logLabel} delivered`);
2987
- });
2988
- this.ipcServer.broadcast({
2989
- type: "fleet_outbound",
2990
- tool: "reply",
2991
- args,
2992
- fleetRequestId: fleetReqId,
2993
- adapterId: this.lastAdapterId,
3180
+ return new Promise(resolve => {
3181
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
3182
+ const timeout = setTimeout(() => {
3183
+ this.pendingIpcRequests.delete(fleetReqId);
3184
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
3185
+ resolve(false);
3186
+ }, daemonBudgetMs("reply"));
3187
+ timeout.unref?.();
3188
+ // Register before broadcast: an in-process/fast adapter can answer in the
3189
+ // same tick, and a waiter installed afterwards would miss that response.
3190
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3191
+ clearTimeout(timeout);
3192
+ const delivered = !respMsg.error && respMsg.result != null;
3193
+ if (!delivered)
3194
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3195
+ else
3196
+ this.logger.info(`${logLabel} delivered`);
3197
+ resolve(delivered);
3198
+ });
3199
+ try {
3200
+ this.ipcServer.broadcast({
3201
+ type: "fleet_outbound",
3202
+ tool: "reply",
3203
+ args,
3204
+ fleetRequestId: fleetReqId,
3205
+ adapterId,
3206
+ statusOnly,
3207
+ });
3208
+ }
3209
+ catch {
3210
+ clearTimeout(timeout);
3211
+ this.pendingIpcRequests.delete(fleetReqId);
3212
+ this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
3213
+ resolve(false);
3214
+ }
2994
3215
  });
2995
3216
  }
2996
3217
  clearInstanceStateIdleTimer() {
@@ -3557,7 +3778,7 @@ export class Daemon extends EventEmitter {
3557
3778
  await this.wake();
3558
3779
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3559
3780
  return;
3560
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3781
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3561
3782
  this.markTurnStarted(meta, formatted);
3562
3783
  }
3563
3784
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3595,7 +3816,7 @@ export class Daemon extends EventEmitter {
3595
3816
  await this.wake();
3596
3817
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3597
3818
  return;
3598
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3819
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3599
3820
  this.markTurnStarted(meta, formatted);
3600
3821
  }
3601
3822
  }).catch(err => {
@@ -3647,8 +3868,7 @@ export class Daemon extends EventEmitter {
3647
3868
  this.pasteLock = this.pasteLock.then(async () => {
3648
3869
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3649
3870
  return;
3650
- if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
3651
- this.markTurnStarted(meta, rawText);
3871
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
3652
3872
  }).catch(err => {
3653
3873
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
3654
3874
  });
@@ -3691,7 +3911,7 @@ export class Daemon extends EventEmitter {
3691
3911
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3692
3912
  // the previous turn's tool list.
3693
3913
  this.resetToolProgress();
3694
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
3914
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3695
3915
  this.markTurnStarted(meta, formatted);
3696
3916
  }
3697
3917
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3840,16 +4060,24 @@ export class Daemon extends EventEmitter {
3840
4060
  }
3841
4061
  }
3842
4062
  }
3843
- // Everything above is *waiting*; everything below *writes*. Only the write is
3844
- // held under the pane lock — holding it across the idle wait (up to 30 min)
3845
- // would starve the runtime-dialog dismisser, which is often the very thing
3846
- // that would let the pane go idle again.
4063
+ // Everything above is *waiting*; everything below owns the write lock.
4064
+ // Long idle/dialog waits stay outside because the runtime dismisser may be
4065
+ // what makes progress. The one under-lock wait below is explicitly passive:
4066
+ // it clears by itself and closes a repaint race immediately before paste.
3847
4067
  for (let round = 0;; round++) {
3848
4068
  const outcome = await this.paneWriteLock.run(async () => {
3849
4069
  if (cancelled())
3850
4070
  return false;
3851
4071
  if (this.refuseFatalStartupDelivery(status))
3852
4072
  return false;
4073
+ // A passive startup phase may paint after the outer readiness probe.
4074
+ // It clears without input, so waiting under the pane lock cannot starve
4075
+ // a dialog dismisser and closes the final clear→paste TOCTOU window.
4076
+ if (!(await this.waitForInputTransientToClear("pre-write"))) {
4077
+ if (status)
4078
+ this.emit("message_failed", status); // ❌
4079
+ return false;
4080
+ }
3853
4081
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
3854
4082
  // whenever it likes — a resume prompt can be painted between "clear" and
3855
4083
  // this critical section. Re-probe here, right before the write; a
@@ -3859,7 +4087,7 @@ export class Daemon extends EventEmitter {
3859
4087
  if (probe.state !== "clear")
3860
4088
  return "dialog";
3861
4089
  }
3862
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
4090
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3863
4091
  });
3864
4092
  if (outcome !== "dialog")
3865
4093
  return outcome;
@@ -3893,6 +4121,75 @@ export class Daemon extends EventEmitter {
3893
4121
  deliveryBlockingDialogs() {
3894
4122
  return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
3895
4123
  }
4124
+ /** Passive input-unavailable phases relevant to the current spawn only. */
4125
+ guardedInputTransients() {
4126
+ if (this.inputTransientGuardGeneration === null
4127
+ || this.inputTransientGuardGeneration !== this.spawnGeneration)
4128
+ return [];
4129
+ return this.backend?.getInputUnavailableTransients?.() ?? [];
4130
+ }
4131
+ /** Match a passive transient against the current interactive screen. */
4132
+ inputTransientInPane(pane) {
4133
+ for (const transient of this.guardedInputTransients()) {
4134
+ // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4135
+ // authority because users and agents routinely quote diagnostic text.
4136
+ transient.pattern.lastIndex = 0;
4137
+ if (!transient.pattern.test(pane))
4138
+ continue;
4139
+ if (transient.isActive(pane))
4140
+ return transient;
4141
+ }
4142
+ return null;
4143
+ }
4144
+ /** Capture failure is unknown, never evidence that input is available. */
4145
+ async probeInputTransient() {
4146
+ if (this.guardedInputTransients().length === 0 || !this.tmux)
4147
+ return { state: "clear" };
4148
+ try {
4149
+ const pane = await this.tmux.capturePane();
4150
+ const transient = this.inputTransientInPane(pane);
4151
+ return transient ? { state: "active", transient } : { state: "clear" };
4152
+ }
4153
+ catch (err) {
4154
+ this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
4155
+ return { state: "unknown" };
4156
+ }
4157
+ }
4158
+ /**
4159
+ * Wait under the pane lock for a passive transient to disappear. Unlike a
4160
+ * dialog, this state clears by itself, so the wait cannot starve a keypress
4161
+ * dismisser. Stop or a replacement spawn invalidates the captured identity.
4162
+ */
4163
+ async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
4164
+ const generation = this.inputTransientGuardGeneration;
4165
+ if (generation === null || generation !== this.spawnGeneration)
4166
+ return true;
4167
+ const deadline = Date.now() + timeoutMs;
4168
+ let observedDescription = null;
4169
+ for (;;) {
4170
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4171
+ this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
4172
+ return false;
4173
+ }
4174
+ const probe = await this.probeInputTransient();
4175
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4176
+ this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
4177
+ return false;
4178
+ }
4179
+ if (probe.state === "clear")
4180
+ return true;
4181
+ if (probe.state === "active" && observedDescription !== probe.transient.description) {
4182
+ observedDescription = probe.transient.description;
4183
+ this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4184
+ }
4185
+ const remaining = deadline - Date.now();
4186
+ if (remaining <= 0) {
4187
+ this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
4188
+ return false;
4189
+ }
4190
+ await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4191
+ }
4192
+ }
3896
4193
  static dialogKey(dialog) {
3897
4194
  return `${dialog.pattern.source}/${dialog.pattern.flags}`;
3898
4195
  }
@@ -4003,10 +4300,10 @@ export class Daemon extends EventEmitter {
4003
4300
  }
4004
4301
  /**
4005
4302
  * Why a pane is not deliverable matters. "busy" may be handed to a native
4006
- * input queue or steered (after its own dialog probe); "dialog" and "unknown"
4007
- * must never be — a paste + Enter into a dialog answers it with its default,
4008
- * and an unreadable pane proves nothing. The silence gate comes first and
4009
- * costs no capture, so a CLI that is simply working never pays for the probe.
4303
+ * input queue or steered (after its own dialog probe); "dialog", "transient"
4304
+ * and "unknown" must never be — a paste + Enter into a dialog answers it
4305
+ * with its default, while a startup transient swallows it and an unreadable
4306
+ * pane proves nothing. The silence gate comes first and costs no capture.
4010
4307
  */
4011
4308
  async paneReadinessForDelivery(windowId) {
4012
4309
  if (!this.isPaneIdleForDelivery(windowId))
@@ -4014,6 +4311,11 @@ export class Daemon extends EventEmitter {
4014
4311
  const probe = await this.probeBlockingDialog();
4015
4312
  if (probe.state !== "clear")
4016
4313
  return probe.state;
4314
+ const transient = await this.probeInputTransient();
4315
+ if (transient.state === "active")
4316
+ return "transient";
4317
+ if (transient.state === "unknown")
4318
+ return "unknown";
4017
4319
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4018
4320
  return "ready";
4019
4321
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4060,6 +4362,7 @@ export class Daemon extends EventEmitter {
4060
4362
  const deadline = Date.now() + timeoutMs;
4061
4363
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4062
4364
  let unknownStreak = 0;
4365
+ let transientDeadline = 0;
4063
4366
  for (;;) {
4064
4367
  const remaining = deadline - Date.now();
4065
4368
  if (remaining <= 0)
@@ -4069,6 +4372,26 @@ export class Daemon extends EventEmitter {
4069
4372
  return false;
4070
4373
  // The idle wait just resolved, so re-reading the silence gate would only
4071
4374
  // add a stale-cache race; ask the pane the remaining questions directly.
4375
+ const transient = await this.probeInputTransient();
4376
+ if (transient.state === "active") {
4377
+ unknownStreak = 0;
4378
+ transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4379
+ if (Date.now() >= transientDeadline) {
4380
+ this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
4381
+ return false;
4382
+ }
4383
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4384
+ continue;
4385
+ }
4386
+ transientDeadline = 0;
4387
+ if (transient.state === "unknown") {
4388
+ if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4389
+ this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
4390
+ return false;
4391
+ }
4392
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4393
+ continue;
4394
+ }
4072
4395
  if (bottomGated) {
4073
4396
  if (await this.isPaneReadyForDelivery(windowId))
4074
4397
  return true;
@@ -4104,15 +4427,16 @@ export class Daemon extends EventEmitter {
4104
4427
  * must fail rather than paste on top of the stranded text).
4105
4428
  */
4106
4429
  async submitStrandedInputIfAny(windowId) {
4107
- if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
4430
+ if (!this.tmux || !this.backend)
4108
4431
  return "idle";
4109
4432
  const prompt = this.backend.getBottomReadyPattern?.();
4110
4433
  if (!prompt)
4111
4434
  return "idle";
4435
+ const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
4112
4436
  // Pre-check outside the lock so the common case (verified clear) never
4113
4437
  // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
4114
4438
  // lock; an unreadable pane is re-examined under it, not waved through.
4115
- const pre = await this.strandedInputState(prompt);
4439
+ const pre = await this.strandedInputState(prompt, requireBottomReady);
4116
4440
  if (pre === "clear")
4117
4441
  return "idle";
4118
4442
  if (pre === "busy")
@@ -4128,7 +4452,7 @@ export class Daemon extends EventEmitter {
4128
4452
  // no-op at an empty prompt, but into a modal it is a confirmation.
4129
4453
  if (this.fatalStartupBlocked)
4130
4454
  return "busy";
4131
- const state = await this.strandedInputState(prompt);
4455
+ const state = await this.strandedInputState(prompt, requireBottomReady);
4132
4456
  if (state === "clear")
4133
4457
  return "idle";
4134
4458
  if (state !== "stranded")
@@ -4138,12 +4462,12 @@ export class Daemon extends EventEmitter {
4138
4462
  });
4139
4463
  }
4140
4464
  /**
4141
- * "stranded": an AgEnD message sits in the input row; "clear": the prompt is
4142
- * at the bottom with no stranded text; "busy": generating, or the prompt is
4143
- * not at the bottom (incl. a blank frame); "unknown": the pane could not be
4144
- * read. Only "clear" is positive evidence.
4465
+ * "stranded": an AgEnD message sits in the input row; "clear": no strand is
4466
+ * visible under the backend's readiness policy; "busy": generating, or (for
4467
+ * a bottom-gated backend) the prompt is not at the bottom; "unknown": the
4468
+ * pane could not be read. Only "clear" permits the next paste.
4145
4469
  */
4146
- async strandedInputState(prompt) {
4470
+ async strandedInputState(prompt, requireBottomReady) {
4147
4471
  let pane;
4148
4472
  try {
4149
4473
  pane = await this.tmux.capturePane();
@@ -4151,11 +4475,16 @@ export class Daemon extends EventEmitter {
4151
4475
  catch {
4152
4476
  return "unknown";
4153
4477
  }
4478
+ if (this.inputTransientInPane(pane))
4479
+ return "busy";
4154
4480
  if (this.backend?.getBusyPattern?.()?.test(pane))
4155
4481
  return "busy";
4156
4482
  if (strandedAgendMessageInInput(pane, prompt))
4157
4483
  return "stranded";
4158
- return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4484
+ // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4485
+ // does not: this preflight runs only after its ordinary silence gate and is
4486
+ // enabled solely to recover a positively identified old AgEnD strand.
4487
+ return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4159
4488
  }
4160
4489
  /**
4161
4490
  * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
@@ -4259,6 +4588,8 @@ export class Daemon extends EventEmitter {
4259
4588
  * an invariant maintained by comments.
4260
4589
  */
4261
4590
  async sendDeliveryEnter(phase) {
4591
+ if (!(await this.waitForInputTransientToClear(phase)))
4592
+ return false;
4262
4593
  const sent = await this.tmux.sendSpecialKey("Enter");
4263
4594
  if (!sent) {
4264
4595
  this.logger.error({
@@ -4268,13 +4599,17 @@ export class Daemon extends EventEmitter {
4268
4599
  }
4269
4600
  return sent;
4270
4601
  }
4271
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4602
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4603
+ const signature = this.submissionSignature(formatted, submissionId);
4272
4604
  let windowId = initialWindowId;
4273
4605
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4274
4606
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4275
4607
  const maxAttempts = 3;
4276
4608
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4277
4609
  const pasteStartedAt = Date.now();
4610
+ // Read the pane BEFORE writing to it, so the submission check can require
4611
+ // evidence this paste ADDED rather than evidence that was already there.
4612
+ const pasteBaseline = await this.capturePaneEvidence(signature);
4278
4613
  const pasted = await this.tmux.pasteBuffer(formatted);
4279
4614
  if (!pasted) {
4280
4615
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4356,16 +4691,73 @@ export class Daemon extends EventEmitter {
4356
4691
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4357
4692
  if (handingOffToNativeQueue) {
4358
4693
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4359
- if (await this.nativeQueuePasteVisible(formatted)) {
4694
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4695
+ if (proof === "submitted") {
4360
4696
  if (status)
4361
4697
  this.emit("message_confirmed", status); // ✅ native queue accepted
4362
4698
  return true;
4363
4699
  }
4364
- // Silent loss: fall back once to the normal idle-gated path.
4365
- this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
4700
+ if (proof === "unverifiable") {
4701
+ // Our text is on the pane but cannot be shown to have left the input
4702
+ // row — either the backend exposes no input row (only /steer reaches
4703
+ // this path on such a backend) or the pre-paste pane was unreadable.
4704
+ // Re-pasting on that would deliver the message twice, so accept as
4705
+ // this path always has, and record that it is unproven.
4706
+ this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
4707
+ if (status)
4708
+ this.emit("message_confirmed", status); // ✅ (best-effort)
4709
+ return true;
4710
+ }
4711
+ // Not submitted (or not provably submitted): fall back once to the
4712
+ // normal idle-gated path, which is the only one with a full
4713
+ // confirmation ladder. "stranded" is the case that used to be a silent
4714
+ // ✅ — the text was visible, so the old check called it delivered.
4715
+ this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
4366
4716
  if (windowId && this.controlClient) {
4367
4717
  await this.controlClient.waitUntilIdle(windowId);
4368
4718
  }
4719
+ // Re-read before doing anything: the CLI had a whole turn to flush its
4720
+ // queue while we waited, and the right recovery differs per outcome.
4721
+ // Re-pasting a payload that is STILL in the input row appends it to
4722
+ // itself — paste-buffer writes at the cursor — and the next Enter then
4723
+ // submits the message twice over. That is the trap this fix would walk
4724
+ // into if it treated "not confirmed" as one state.
4725
+ const settled = await this.confirmSubmitted(signature, pasteBaseline);
4726
+ if (settled === "submitted") {
4727
+ this.logger.info("Native-queue message submitted itself while we waited for idle");
4728
+ if (status)
4729
+ this.emit("message_confirmed", status); // ✅
4730
+ return true;
4731
+ }
4732
+ if (settled === "stranded") {
4733
+ // The text is already there in full: it needs submitting, not sending
4734
+ // again. The prompt is back now, so an Enter can land.
4735
+ this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4736
+ const strandedAt = Date.now();
4737
+ if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4738
+ if (status)
4739
+ this.emit("message_failed", status); // ❌
4740
+ return false;
4741
+ }
4742
+ const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4743
+ if (afterEnter === "submitted") {
4744
+ if (status)
4745
+ this.emit("message_confirmed", status); // ✅
4746
+ return true;
4747
+ }
4748
+ // Deliberately NOT "or the pane went busy". This branch runs because
4749
+ // the text was still in the input row; if the recovery Enter was
4750
+ // swallowed too, the pane is busy with something else — a late redraw,
4751
+ // another turn's output — and treating that as proof re-confirms a
4752
+ // message nobody submitted. Output is corroboration; text sitting in
4753
+ // the input row is disqualifying, and disqualifying evidence wins.
4754
+ this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
4755
+ if (status)
4756
+ this.emit("message_failed", status); // ❌
4757
+ return false;
4758
+ }
4759
+ // "unproven": nothing of ours is on screen — the paste itself was lost,
4760
+ // so pasting it again cannot duplicate anything.
4369
4761
  const repasted = await this.tmux.pasteBuffer(formatted);
4370
4762
  if (!repasted) {
4371
4763
  this.logger.error({
@@ -4383,24 +4775,13 @@ export class Daemon extends EventEmitter {
4383
4775
  return false;
4384
4776
  }
4385
4777
  if (windowId && this.controlClient) {
4386
- let becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4387
- if (!becameBusy) {
4388
- this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
4389
- const retry2At = Date.now();
4390
- if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
4391
- if (status)
4392
- this.emit("message_failed", status); // ❌
4393
- return false;
4394
- }
4395
- becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
4396
- }
4397
- if (becameBusy) {
4778
+ if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
4398
4779
  if (status)
4399
4780
  this.emit("message_confirmed", status); // ✅
4400
4781
  return true;
4401
4782
  }
4402
4783
  }
4403
- else if (await this.nativeQueuePasteVisible(formatted)) {
4784
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4404
4785
  if (status)
4405
4786
  this.emit("message_confirmed", status); // ✅
4406
4787
  return true;
@@ -4440,17 +4821,17 @@ export class Daemon extends EventEmitter {
4440
4821
  }
4441
4822
  }
4442
4823
  else if (windowId && this.controlClient) {
4443
- let becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
4444
- if (!becameBusy) {
4445
- this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4446
- const retryAt = Date.now();
4447
- if (!(await this.sendDeliveryEnter("idle-to-busy-retry"))) {
4448
- if (status)
4449
- this.emit("message_failed", status); // ❌
4450
- return false;
4451
- }
4452
- becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4453
- }
4824
+ // An idle delivery used to be judged by output alone: anything the pane
4825
+ // printed after Enter counted as "submitted". Waking a paused instance
4826
+ // breaks that — the CLI's final redraw arrives right as we press Enter,
4827
+ // so a swallowed Enter is confirmed by the redraw it was lost to, and
4828
+ // the message is never in a turn (#745 left this path on the old rule).
4829
+ //
4830
+ // Where the input row can be read, it decides: text still sitting there
4831
+ // is disqualifying and no amount of output may override it. Where it
4832
+ // cannot (a backend with no prompt pattern), the output signal remains
4833
+ // exactly as before — this must not regress backends we cannot read.
4834
+ const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
4454
4835
  if (becameBusy) {
4455
4836
  if (status)
4456
4837
  this.emit("message_confirmed", status); // ✅
@@ -4481,31 +4862,260 @@ export class Daemon extends EventEmitter {
4481
4862
  return false;
4482
4863
  }
4483
4864
  /**
4484
- * True when a busy native-queue paste appears to have landed: Codex shows a
4485
- * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
4486
- * Used only to detect silent paste loss — not as a general ready check.
4865
+ * The single place a pasted message is judged submitted, shared by the
4866
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
4867
+ *
4868
+ * It replaces three divergent weak checks, the weakest of which accepted "the
4869
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
4870
+ * the failure it was meant to catch: text stranded in the input row is on
4871
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
4872
+ * anywhere. Evidence is weighed in order of what it can prove, and the
4873
+ * disqualifying evidence is checked first.
4487
4874
  */
4488
- async nativeQueuePasteVisible(formatted) {
4489
- if (!this.tmux)
4875
+ /**
4876
+ * Whether this backend exposes an input row we can actually read, i.e. whether
4877
+ * confirmSubmitted can ever return a verdict stronger than a guess.
4878
+ *
4879
+ * This is a property of the BACKEND, not of one delivery's verdict. Keying the
4880
+ * output-signal fallback off the verdict instead let codex — which can be read
4881
+ * — fall back to "the pane printed something" whenever a paste left no trace,
4882
+ * which is the same false confirmation one layer down.
4883
+ */
4884
+ canProveSubmission() {
4885
+ return !!this.backend?.getBottomReadyPattern?.();
4886
+ }
4887
+ /** Positive submission retires the startup transient guard for this spawn. */
4888
+ submittedProof() {
4889
+ if (this.inputTransientGuardGeneration === this.spawnGeneration) {
4890
+ this.inputTransientGuardGeneration = null;
4891
+ }
4892
+ return "submitted";
4893
+ }
4894
+ /**
4895
+ * Decide a submission after an Enter, with one bounded retry.
4896
+ *
4897
+ * Backends we can read must SHOW the text left the input row: neither a
4898
+ * strand nor a vanished paste may be upgraded to success by output, because
4899
+ * the output that would do the upgrading is usually the very redraw the Enter
4900
+ * was lost to. Backends we cannot read keep the legacy output signal — there
4901
+ * is nothing better available for them, and taking it away would fail every
4902
+ * one of their deliveries.
4903
+ *
4904
+ * One ladder, used by every post-Enter confirmation, so an escape hatch
4905
+ * cannot reappear in just one of them (it did: three copies, two of which
4906
+ * still confirmed on output alone).
4907
+ */
4908
+ async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
4909
+ if (!this.canProveSubmission()) {
4910
+ let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
4911
+ if (!busy) {
4912
+ this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4913
+ const retryAt = Date.now();
4914
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4915
+ return false;
4916
+ busy = await this.confirmBusyAfterEnter(windowId, retryAt);
4917
+ }
4918
+ return busy;
4919
+ }
4920
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4921
+ return true;
4922
+ // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
4923
+ // how we got here. Then poll briefly rather than judging on one capture:
4924
+ // the key takes a moment to be digested, and a single early look would
4925
+ // report a failure for a message that did go out.
4926
+ this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
4927
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
4490
4928
  return false;
4491
- try {
4492
- const pane = await this.tmux.capturePane();
4493
- if (pane.includes("↳"))
4929
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4930
+ return false;
4931
+ const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
4932
+ for (;;) {
4933
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4494
4934
  return true;
4495
- for (const line of formatted.split(/\r?\n/)) {
4496
- const t = line.trim();
4497
- if (t.length >= 8 && pane.includes(t))
4498
- return true;
4935
+ if (Date.now() >= deadline)
4936
+ return false;
4937
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
4938
+ }
4939
+ }
4940
+ async confirmSubmitted(signature, baseline) {
4941
+ if (!this.tmux)
4942
+ return "unproven";
4943
+ let pane;
4944
+ try {
4945
+ pane = await this.tmux.capturePane();
4946
+ }
4947
+ catch {
4948
+ return "unproven";
4949
+ }
4950
+ // Without a way to tell the input row from the transcript, "the text is on
4951
+ // screen" cannot distinguish submitted from stranded — that ambiguity IS
4952
+ // the bug. Such a backend gets an explicit verdict rather than a guess:
4953
+ // "unverifiable" (something of ours appeared, but not provably out of the
4954
+ // input row) or "unproven" (nothing of ours appeared, so the paste itself
4955
+ // was lost). The caller keeps that backend's existing best-effort handling
4956
+ // for the first and recovers on the second, which is what it did before —
4957
+ // the fix here is for backends that CAN be read, not a new guess for those
4958
+ // that cannot.
4959
+ const prompt = this.backend?.getBottomReadyPattern?.();
4960
+ if (!prompt) {
4961
+ const seen = this.paneEvidence(pane, signature);
4962
+ if (signature.unique && seen.payload > 0)
4963
+ return "unverifiable";
4964
+ return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
4965
+ ? "unverifiable"
4966
+ : "unproven";
4967
+ }
4968
+ const after = this.paneEvidence(pane, signature);
4969
+ // 1. Disqualifying evidence, checked FIRST and never overridden by the
4970
+ // corroborating evidence below: our text is sitting in the input row, so
4971
+ // it was not submitted — whatever else is on screen.
4972
+ //
4973
+ // It must be OUR text. A unique signature settles that on its own; a
4974
+ // body-derived one cannot, so it is only attributed to this delivery
4975
+ // when the input row did not already show it before we pasted.
4976
+ // Otherwise an older stranded message with the same opening would be
4977
+ // read as ours, we would press Enter to "recover" it, and the turn IT
4978
+ // starts would confirm a message that never reached the pane.
4979
+ if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
4980
+ return "stranded";
4981
+ // 2. Positive evidence. A unique signature needs no baseline: no earlier
4982
+ // message can carry this delivery's message_id, so finding it outside
4983
+ // the input row is proof in itself — which also means a momentary
4984
+ // failure to read the pane BEFORE pasting cannot turn a delivered
4985
+ // message into a re-paste.
4986
+ if (signature.unique && after.payload > 0)
4987
+ return this.submittedProof();
4988
+ // 3. Otherwise the evidence must be NEW relative to the pane as it was
4989
+ // before we pasted: a queue marker left by an earlier message, or an
4990
+ // older transcript entry that opens the same way, are on screen either
4991
+ // way and would otherwise confirm a paste that never landed.
4992
+ if (baseline) {
4993
+ if (after.queued > baseline.queued)
4994
+ return this.submittedProof();
4995
+ if (after.payload > baseline.payload)
4996
+ return this.submittedProof();
4997
+ // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
4998
+ return "unproven";
4999
+ }
5000
+ // No baseline and nothing that identifies this delivery on its own. "The
5001
+ // paste was lost" is a guess, not a finding, and acting on it re-pastes a
5002
+ // message that may well have been submitted — so say the evidence is
5003
+ // missing instead of inventing a verdict.
5004
+ this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
5005
+ return "unverifiable";
5006
+ }
5007
+ /**
5008
+ * What the pane currently shows of a given message. Taken once before the
5009
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
5010
+ * echo rather than accepting whatever was already there.
5011
+ */
5012
+ async capturePaneEvidence(signature) {
5013
+ if (!this.tmux)
5014
+ return null;
5015
+ // Bounded re-probe: a capture that fails here costs the delivery its only
5016
+ // "before" picture, and a momentary failure should not decide anything.
5017
+ for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
5018
+ try {
5019
+ const pane = await this.tmux.capturePane();
5020
+ const evidence = this.paneEvidence(pane, signature);
5021
+ const prompt = this.backend?.getBottomReadyPattern?.();
5022
+ if (prompt && strandedAgendMessageInInput(pane, prompt)) {
5023
+ // Whatever we paste now lands after it, and one Enter submits both as
5024
+ // a single message. Nothing here can undo that; saying so beats
5025
+ // letting two messages silently merge.
5026
+ this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
5027
+ }
5028
+ return evidence;
4499
5029
  }
4500
- const compact = formatted.replace(/\s+/g, " ").trim();
4501
- if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
4502
- return true;
5030
+ catch {
5031
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
5032
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4503
5033
  }
5034
+ }
5035
+ // Still unreadable: confirmSubmitted falls back to evidence that stands on
5036
+ // its own (a unique message_id) and refuses to guess without it.
5037
+ this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
5038
+ return null;
5039
+ }
5040
+ paneEvidence(pane, signature) {
5041
+ const marker = this.backend?.getQueuedInputMarker?.();
5042
+ const prompt = this.backend?.getBottomReadyPattern?.();
5043
+ const input = prompt ? inputAreaText(pane, prompt) : null;
5044
+ return {
5045
+ queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
5046
+ payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
5047
+ strandedInput: input != null && inputShowsPastedText(input, signature.value),
5048
+ };
5049
+ }
5050
+ /**
5051
+ * What identifies THIS message on screen. The routing envelope carries a
5052
+ * message_id that no earlier transcript entry can share, so prefer it: the
5053
+ * body alone is not distinctive enough — short repeated instructions ("carry
5054
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
5055
+ * paste that never landed. System pastes carry no envelope and fall back to
5056
+ * the body signature, which the baseline comparison still ties to this paste.
5057
+ */
5058
+ submissionSignature(formatted, trustedId) {
5059
+ // The id must come from the message's own metadata, never from scanning the
5060
+ // rendered text: agents and users discuss message ids in the body all the
5061
+ // time ("check message_id: abc"), and a body-scanned value would match an
5062
+ // older transcript entry quoting the same thing — then be trusted as unique
5063
+ // and confirm a paste that never landed. Only the value AgEnD itself put in
5064
+ // the handoff metadata identifies THIS delivery.
5065
+ const id = trustedId?.trim();
5066
+ if (id)
5067
+ return { value: `message_id:${id}`, unique: true };
5068
+ return { value: pastedTextSignature(formatted), unique: false };
5069
+ }
5070
+ /**
5071
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
5072
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
5073
+ * at all, so a swallowed Enter left the notice sitting in the input row where
5074
+ * the next delivery submitted both as one message. Shares confirmSubmitted
5075
+ * with the delivery path — having one primitive is the point.
5076
+ *
5077
+ * Best effort by design: these notices must never fail a delivery or throw.
5078
+ * The caller already holds paneWriteLock.
5079
+ */
5080
+ async submitSystemPaste(text, label) {
5081
+ if (!this.tmux)
5082
+ return false;
5083
+ const signature = this.submissionSignature(text);
5084
+ const baseline = await this.capturePaneEvidence(signature);
5085
+ if (!(await this.tmux.pasteBuffer(text))) {
5086
+ this.logger.warn({ label }, "System paste failed to reach the pane");
4504
5087
  return false;
4505
5088
  }
4506
- catch {
5089
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5090
+ if (!(await this.sendDeliveryEnter(label)))
5091
+ return false;
5092
+ let proof = await this.confirmSubmitted(signature, baseline);
5093
+ if (proof === "unverifiable") {
5094
+ // No input row to read: this backend gets exactly what the old pasteText
5095
+ // path gave it, including the unconditional second Enter for queue-less
5096
+ // TUIs that swallow the first. Narrowing that to one Enter on the grounds
5097
+ // that "the text is visible" would be the very inference this change
5098
+ // exists to remove — visible text is also what a strand looks like.
5099
+ if (this.systemPasteOptions().retryEnter) {
5100
+ await new Promise(r => setTimeout(r, 1_000));
5101
+ await this.sendDeliveryEnter(`${label}-defensive-retry`);
5102
+ }
5103
+ return true; // best effort, exactly as before — nothing here is verified
5104
+ }
5105
+ if (proof !== "submitted") {
5106
+ // A bare Enter is a no-op at an empty prompt, so this is safe even if the
5107
+ // first one did land; when the text is still in the input row it is the
5108
+ // submit it never got. Re-pasting would append the text to itself.
5109
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5110
+ if (!(await this.sendDeliveryEnter(`${label}-retry`)))
5111
+ return false;
5112
+ proof = await this.confirmSubmitted(signature, baseline);
5113
+ }
5114
+ if (proof !== "submitted") {
5115
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
4507
5116
  return false;
4508
5117
  }
5118
+ return true;
4509
5119
  }
4510
5120
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4511
5121
  async recoverWindow() {
@@ -4598,14 +5208,21 @@ export class Daemon extends EventEmitter {
4598
5208
  if (this.socketSessionNames.get(socket) === this.name) {
4599
5209
  this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
4600
5210
  }
5211
+ // A tool invocation is not delivery evidence. Capture which obligation it
5212
+ // belongs to now, then settle it only after the adapter/fleet responds.
5213
+ // Explicitly mapped sibling sessions must not satisfy this instance's turn;
5214
+ // an unmapped socket is retained for legacy/tests where mcp_ready was not
5215
+ // observed before the first tool call.
5216
+ const sourceSession = this.socketSessionNames.get(socket);
5217
+ const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5218
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
5219
+ : null;
4601
5220
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
4602
5221
  const respond = (result, error) => {
4603
5222
  // A message that verifiably went out stands down the dead-MCP proxy reply
4604
5223
  // for this turn: the agent proved it can still speak for itself.
4605
5224
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
4606
- this.turnOutboundDelivered = true;
4607
- if (tool === "reply")
4608
- this.turnReplyDelivered = true;
5225
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
4609
5226
  }
4610
5227
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4611
5228
  if (!sent) {
@@ -4796,7 +5413,15 @@ export class Daemon extends EventEmitter {
4796
5413
  });
4797
5414
  return;
4798
5415
  }
4799
- const adapter = adapters[0];
5416
+ // Same rule as deliverDaemonReply: one adapter is unambiguous, several with
5417
+ // no bound world is a routing failure rather than a first-wins guess.
5418
+ const adapter = this.lastAdapterId
5419
+ ? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
5420
+ : (adapters.length === 1 ? adapters[0] : undefined);
5421
+ if (!adapter) {
5422
+ respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
5423
+ return;
5424
+ }
4800
5425
  if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
4801
5426
  respond(null, `Unknown tool: ${tool}`);
4802
5427
  }
@@ -4987,7 +5612,16 @@ export class Daemon extends EventEmitter {
4987
5612
  try {
4988
5613
  // Messages can arrive during a restart and be queued on pasteLock before the
4989
5614
  // snapshot lands; both write to the pane, so both go through the same lock.
4990
- await this.paneWriteLock.run(() => this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`, this.systemPasteOptions()));
5615
+ const restoreNotice = `[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`;
5616
+ const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
5617
+ if (!injected) {
5618
+ // rotation-state.json was deleted when the prompt was built, so there is
5619
+ // nothing left to retry from: the restore is gone either way. Say so —
5620
+ // reporting it as injected is how a lost context restore stays invisible.
5621
+ this.logger.error("Session snapshot could not be submitted — session continues without context");
5622
+ this.emit("snapshot_failed", this.name);
5623
+ return;
5624
+ }
4991
5625
  this.logger.info("Injected session snapshot as first message");
4992
5626
  this.emit("snapshot_injected", this.name);
4993
5627
  }
@@ -5003,6 +5637,17 @@ export class Daemon extends EventEmitter {
5003
5637
  * into exactly the window this exists to close.
5004
5638
  */
5005
5639
  beginSpawn() {
5640
+ // A restarted CLI has no trustworthy turn edge for the process it replaced.
5641
+ // v1 deliberately does not persist obligations across restarts: missing one
5642
+ // warning is safer than treating the replacement's startup idle as the old
5643
+ // turn ending and injecting a stale recovery prompt.
5644
+ this.turnReplyGuard.reset();
5645
+ if (this.spawnDepth === 0) {
5646
+ this.spawnGeneration++;
5647
+ this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
5648
+ ? this.spawnGeneration
5649
+ : null;
5650
+ }
5006
5651
  this.spawnDepth++;
5007
5652
  this.spawning = true;
5008
5653
  if (!this.spawnSettled) {
@@ -5057,6 +5702,69 @@ export class Daemon extends EventEmitter {
5057
5702
  }
5058
5703
  }
5059
5704
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
5705
+ /**
5706
+ * Positive proof from the CLI that there is no conversation left to resume.
5707
+ *
5708
+ * Only this may justify abandoning a session. A startup that merely ran out of
5709
+ * budget proves nothing: a healthy cold start on a loaded host looks exactly
5710
+ * the same, which is how 9 instances silently lost their history.
5711
+ *
5712
+ * Deliberately does NOT match a bare "--continue": the pane text can mention
5713
+ * the flag (a usage error, an echoed invocation) without the CLI ever saying
5714
+ * the conversation is gone.
5715
+ */
5716
+ paneSaysNoConversation(paneText) {
5717
+ if (!paneText)
5718
+ return false;
5719
+ return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
5720
+ .test(paneText);
5721
+ }
5722
+ /** How many consecutive startups have failed without proving the session is gone. */
5723
+ unprovenResumeFailures = 0;
5724
+ /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
5725
+ static MAX_UNPROVEN_RESUME_FAILURES = 3;
5726
+ /**
5727
+ * Set the session aside instead of deleting it.
5728
+ *
5729
+ * The whole defect being fixed here is silent, irreversible loss of a user's
5730
+ * conversation. Renaming costs nothing and turns a wrong call into something
5731
+ * recoverable: the id is still on disk under a dated name.
5732
+ */
5733
+ setSessionAside(reason) {
5734
+ const sidFile = join(this.instanceDir, "session-id");
5735
+ try {
5736
+ if (!existsSync(sidFile))
5737
+ return;
5738
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
5739
+ renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
5740
+ this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
5741
+ this.pruneAbandonedSessions();
5742
+ }
5743
+ catch (err) {
5744
+ // Never let bookkeeping block a spawn; falling back to the old behaviour
5745
+ // is still better than not starting.
5746
+ this.logger.warn({ err: err.message }, "Could not set session aside");
5747
+ try {
5748
+ unlinkSync(sidFile);
5749
+ }
5750
+ catch { /* may not exist */ }
5751
+ }
5752
+ }
5753
+ /** Keep the newest few set-aside sessions; they are insurance, not an archive. */
5754
+ pruneAbandonedSessions(keep = 5) {
5755
+ try {
5756
+ const files = readdirSync(this.instanceDir)
5757
+ .filter((f) => f.startsWith("session-id.abandoned-"))
5758
+ .sort();
5759
+ for (const f of files.slice(0, Math.max(0, files.length - keep))) {
5760
+ try {
5761
+ unlinkSync(join(this.instanceDir, f));
5762
+ }
5763
+ catch { /* best effort */ }
5764
+ }
5765
+ }
5766
+ catch { /* best effort */ }
5767
+ }
5060
5768
  async spawnClaudeWindow() {
5061
5769
  this.beginSpawn();
5062
5770
  let resumedSuccessfully = false;
@@ -5080,7 +5788,7 @@ export class Daemon extends EventEmitter {
5080
5788
  // first miss is usually slowness, not a broken session.
5081
5789
  await this.noteStartupPaneForBackendOutage();
5082
5790
  await this.failStartupIfBackendUnreachable();
5083
- if (this.backend.retriesResumeOnStartupFailure?.() === true) {
5791
+ if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
5084
5792
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
5085
5793
  await this.killProcessTree();
5086
5794
  await this.tmux.killWindow();
@@ -5092,16 +5800,39 @@ export class Daemon extends EventEmitter {
5092
5800
  }
5093
5801
  }
5094
5802
  if (!alive) {
5095
- // Resume (or a fresh start) failed for a reason we do not recognise as an
5096
- // outage (stale --resume, crash, rate limit, etc.).
5097
- // Clean slate: clear session-id, skip resume, and retry once.
5098
- this.logger.warn("CLI startup failed — clearing session-id and retrying without resume");
5099
- const sidFile = join(this.instanceDir, "session-id");
5100
- try {
5101
- unlinkSync(sidFile);
5803
+ // The session may only be abandoned on positive proof that there is
5804
+ // nothing to resume. Running out of budget is not proof: on a loaded host
5805
+ // a healthy cold start misses the same deadline as a broken session, and
5806
+ // treating the two alike is what silently discarded users' conversations.
5807
+ if (attemptedResume) {
5808
+ let paneText;
5809
+ try {
5810
+ paneText = await this.tmux?.capturePaneWithHistory(50);
5811
+ }
5812
+ catch { /* pane may be gone */ }
5813
+ const proven = this.paneSaysNoConversation(paneText);
5814
+ if (!proven) {
5815
+ this.unprovenResumeFailures++;
5816
+ if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
5817
+ // Keep the session and fail this attempt; the fleet retries with
5818
+ // backoff, which is also how the backend-outage path behaves.
5819
+ await this.killProcessTree();
5820
+ await this.tmux.killWindow();
5821
+ throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
5822
+ + "— session kept, will retry");
5823
+ }
5824
+ // Escape hatch: a genuinely broken session must still recover. Say so
5825
+ // out loud — this start does NOT continue the previous conversation.
5826
+ this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
5827
+ + "The previous conversation is NOT continued; its context is lost to this session.");
5828
+ this.emit("context_lost", this.name, "resume_repeatedly_failed");
5829
+ }
5830
+ this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
5831
+ this.skipResume = true;
5102
5832
  }
5103
- catch { /* may not exist */ }
5104
- this.skipResume = true;
5833
+ // A fresh start that also failed retries once, as before. It never clears
5834
+ // a session: nothing about a failed fresh launch says the stored
5835
+ // conversation is unusable.
5105
5836
  await this.killProcessTree();
5106
5837
  await this.tmux.killWindow();
5107
5838
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
@@ -5270,6 +6001,79 @@ export class Daemon extends EventEmitter {
5270
6001
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
5271
6002
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
5272
6003
  }
6004
+ /**
6005
+ * Tell the agent its instructions moved, once the CLI is idle.
6006
+ *
6007
+ * A named method rather than an inline closure so the wiring itself is
6008
+ * testable: warmupNoticeAction's own tests pass even if this call site stops
6009
+ * consulting it, which is exactly the bug this path had — the decision was
6010
+ * made inline and never asked whether the backend needed telling at all.
6011
+ *
6012
+ * Never throws: a missed reload notice must not fail a spawn.
6013
+ */
6014
+ async runWarmupInstructionNotice() {
6015
+ try {
6016
+ const action = warmupNoticeAction({
6017
+ warmupNeeded: this.warmupNeeded,
6018
+ backend: this.backend ?? undefined,
6019
+ pasteQueueDepth: this.pasteQueueDepth,
6020
+ });
6021
+ if (action === "skip") {
6022
+ this.logger.debug(this.warmupNeeded
6023
+ ? "Warmup skipped — this backend reloads instructions on resume"
6024
+ : "Warmup skipped — instructions unchanged");
6025
+ // When the backend learns of the change on its own, record what it now
6026
+ // holds: leaving prev-instructions behind would keep every later
6027
+ // restart comparing against a value the agent has long since replaced.
6028
+ if (this.warmupNeeded) {
6029
+ try {
6030
+ writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
6031
+ }
6032
+ catch { /* best effort */ }
6033
+ }
6034
+ return;
6035
+ }
6036
+ if (action === "defer") {
6037
+ this.logger.debug("Warmup deferred — no pending inbound messages");
6038
+ // Convert to pendingInstructionsNotice so it fires on next real message.
6039
+ this.pendingInstructionsNotice = true;
6040
+ try {
6041
+ writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
6042
+ }
6043
+ catch { }
6044
+ return;
6045
+ }
6046
+ const wid = existsSync(join(this.instanceDir, "window-id"))
6047
+ ? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
6048
+ if (wid && this.controlClient) {
6049
+ await this.controlClient.waitForIdle(wid, 120_000);
6050
+ }
6051
+ else {
6052
+ await new Promise(r => setTimeout(r, 5000));
6053
+ }
6054
+ // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
6055
+ // delivery is already in flight or queued. Without the lock the notice and
6056
+ // that delivery race into the same pane.
6057
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
6058
+ if (!told) {
6059
+ // Recording the new instructions here would mark the agent as told about
6060
+ // a notice it never received, and every later restart would then skip
6061
+ // the reload. Leave the snapshot behind and hand it to the deferred
6062
+ // notice, which fires on the next real message.
6063
+ this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
6064
+ this.pendingInstructionsNotice = true;
6065
+ return;
6066
+ }
6067
+ // Record the value the agent has now been told about so the next
6068
+ // unchanged restart skips the reload.
6069
+ try {
6070
+ writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
6071
+ }
6072
+ catch { /* best effort */ }
6073
+ this.logger.debug("Warmup sent after idle");
6074
+ }
6075
+ catch { /* non-fatal */ }
6076
+ }
5273
6077
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
5274
6078
  const backendConfig = this.buildBackendConfig();
5275
6079
  // Compare freshly-built instructions against the last value the agent was
@@ -5298,7 +6102,7 @@ export class Daemon extends EventEmitter {
5298
6102
  // For backends that don't re-read instructions on resume (kiro/codex/
5299
6103
  // gemini), also notify the agent on next message instead of forcing a new
5300
6104
  // session. Resume is preserved so context isn't lost.
5301
- if (!backendConfig.skipResume && !this.backend.instructionsReloadedOnResume && this.warmupNeeded) {
6105
+ if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
5302
6106
  if (prev) {
5303
6107
  this.logger.info("Instructions changed — will notify agent on next message");
5304
6108
  this.pendingInstructionsNotice = true;
@@ -5443,6 +6247,7 @@ export class Daemon extends EventEmitter {
5443
6247
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
5444
6248
  let cleanReadyPolls = 0;
5445
6249
  let lastDialog = null;
6250
+ let lastTransient = null;
5446
6251
  let captureFailures = 0;
5447
6252
  let attempts = 0;
5448
6253
  do {
@@ -5464,6 +6269,18 @@ export class Daemon extends EventEmitter {
5464
6269
  continue;
5465
6270
  }
5466
6271
  try {
6272
+ // Some TUIs expose a real-looking input row before they accept Enter.
6273
+ // This phase clears by itself: never send a key, and let its structural
6274
+ // current-screen matcher outrank the otherwise-valid ready pattern.
6275
+ const transient = this.inputTransientInPane(pane);
6276
+ if (transient) {
6277
+ cleanReadyPolls = 0;
6278
+ lastDialog = null;
6279
+ lastTransient = transient;
6280
+ await sleep();
6281
+ continue;
6282
+ }
6283
+ lastTransient = null;
5467
6284
  // Try each startup dialog pattern before checking ready state
5468
6285
  let matched = false;
5469
6286
  lastDialog = null;
@@ -5591,7 +6408,10 @@ export class Daemon extends EventEmitter {
5591
6408
  await sleep();
5592
6409
  } while (remaining() > 0);
5593
6410
  // Budget exhausted. Never silently: say which way it went.
5594
- if (lastDialog) {
6411
+ if (lastTransient) {
6412
+ this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
6413
+ }
6414
+ else if (lastDialog) {
5595
6415
  this.logger.warn({ description: lastDialog.description, attempts, budgetMs }, "Startup scan exhausted with a dialog still on screen — reporting the CLI alive; deliveries stay blocked until it is answered");
5596
6416
  }
5597
6417
  else {
@@ -5607,12 +6427,21 @@ export class Daemon extends EventEmitter {
5607
6427
  updateLastChat(chatId, threadId, adapterId) {
5608
6428
  if (!chatId)
5609
6429
  return;
6430
+ const chatChanged = this.lastChatId !== chatId;
5610
6431
  this.lastChatId = chatId;
5611
6432
  // An unthreaded inbound must clear a previous topic rather than leaking it
5612
6433
  // into the next reply target.
5613
6434
  this.lastThreadId = threadId || undefined;
5614
6435
  if (adapterId)
5615
6436
  this.lastAdapterId = adapterId;
6437
+ else if (chatChanged) {
6438
+ // A NEW chat with no adapter named alongside it: the old adapter belonged
6439
+ // to the old chat, and keeping it asserts a pairing nobody supplied. That
6440
+ // is how one platform's chat id ends up being sent through another
6441
+ // platform's bot. Unknown is recoverable (a single-adapter fleet is
6442
+ // unambiguous anyway); a confidently wrong world is not.
6443
+ this.lastAdapterId = undefined;
6444
+ }
5616
6445
  try {
5617
6446
  writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
5618
6447
  }