@songsid/agend 2.1.5-beta.9 → 2.1.6-beta.1

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 (86) hide show
  1. package/dist/backend/claude-code.d.ts +12 -0
  2. package/dist/backend/claude-code.js +12 -0
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/codex.d.ts +41 -1
  5. package/dist/backend/codex.js +126 -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 +61 -0
  10. package/dist/backend/types.js.map +1 -1
  11. package/dist/channel/adapters/discord.d.ts +17 -2
  12. package/dist/channel/adapters/discord.js +64 -5
  13. package/dist/channel/adapters/discord.js.map +1 -1
  14. package/dist/channel/adapters/telegram.d.ts +41 -2
  15. package/dist/channel/adapters/telegram.js +116 -20
  16. package/dist/channel/adapters/telegram.js.map +1 -1
  17. package/dist/channel/factory.js +6 -1
  18. package/dist/channel/factory.js.map +1 -1
  19. package/dist/channel/types.d.ts +33 -1
  20. package/dist/classic-channel-manager.d.ts +23 -3
  21. package/dist/classic-channel-manager.js +35 -0
  22. package/dist/classic-channel-manager.js.map +1 -1
  23. package/dist/config-validator.js +23 -0
  24. package/dist/config-validator.js.map +1 -1
  25. package/dist/config.d.ts +4 -0
  26. package/dist/config.js +5 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/daemon.d.ts +158 -21
  29. package/dist/daemon.js +1099 -200
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/deadline.d.ts +23 -0
  32. package/dist/deadline.js +29 -0
  33. package/dist/deadline.js.map +1 -0
  34. package/dist/fleet-context.d.ts +15 -1
  35. package/dist/fleet-context.js.map +1 -1
  36. package/dist/fleet-manager.d.ts +186 -3
  37. package/dist/fleet-manager.js +833 -119
  38. package/dist/fleet-manager.js.map +1 -1
  39. package/dist/general-knowledge/skills/fleet-config/SKILL.md +26 -2
  40. package/dist/instance-lifecycle.d.ts +8 -2
  41. package/dist/instance-lifecycle.js +47 -3
  42. package/dist/instance-lifecycle.js.map +1 -1
  43. package/dist/instance-removal.d.ts +13 -0
  44. package/dist/instance-removal.js +14 -0
  45. package/dist/instance-removal.js.map +1 -0
  46. package/dist/instructions.js +2 -2
  47. package/dist/instructions.js.map +1 -1
  48. package/dist/locale.js +28 -2
  49. package/dist/locale.js.map +1 -1
  50. package/dist/login-controller.d.ts +31 -4
  51. package/dist/login-controller.js +52 -4
  52. package/dist/login-controller.js.map +1 -1
  53. package/dist/login-flows.d.ts +29 -0
  54. package/dist/login-flows.js +48 -1
  55. package/dist/login-flows.js.map +1 -1
  56. package/dist/outbound-handlers.d.ts +1 -0
  57. package/dist/outbound-handlers.js +118 -29
  58. package/dist/outbound-handlers.js.map +1 -1
  59. package/dist/outbound-schemas.d.ts +7 -0
  60. package/dist/outbound-schemas.js +3 -0
  61. package/dist/outbound-schemas.js.map +1 -1
  62. package/dist/pane-input-residue.d.ts +25 -2
  63. package/dist/pane-input-residue.js +31 -2
  64. package/dist/pane-input-residue.js.map +1 -1
  65. package/dist/restart-progress.js +3 -21
  66. package/dist/restart-progress.js.map +1 -1
  67. package/dist/settings-api.d.ts +1 -1
  68. package/dist/settings-api.js +38 -6
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/topic-commands.d.ts +27 -2
  71. package/dist/topic-commands.js +37 -6
  72. package/dist/topic-commands.js.map +1 -1
  73. package/dist/turn-reply-guard.d.ts +41 -0
  74. package/dist/turn-reply-guard.js +77 -0
  75. package/dist/turn-reply-guard.js.map +1 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/ui/dashboard.html +38 -10
  78. package/dist/ui/settings.html +93 -15
  79. package/dist/ui/view.html +35 -20
  80. package/dist/view-api.d.ts +1 -0
  81. package/dist/view-api.js +12 -2
  82. package/dist/view-api.js.map +1 -1
  83. package/dist/web-api.d.ts +22 -1
  84. package/dist/web-api.js +25 -1
  85. package/dist/web-api.js.map +1 -1
  86. 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"
@@ -79,6 +83,8 @@ export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
79
83
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
80
84
  /** Coalesce one cosmetic-redraw burst before comparing the visible pane. */
81
85
  export const PERIODIC_REDRAW_PROBE_MS = 25;
86
+ /** Do not turn a streaming Codex turn into one capture-pane per burst. */
87
+ export const STRUCTURED_IDLE_PROBE_MS = 500;
82
88
  /** A foreground server/port-forward should hand control back or be acknowledged. */
83
89
  export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
84
90
  const LAST_INBOUND_FILE = "last-inbound-at";
@@ -377,15 +383,31 @@ export class PendingWorkTracker {
377
383
  // An async pane poll can finish after a newer inbound. Do not let its stale
378
384
  // observation clear work which had not arrived when the pane was captured.
379
385
  if (now < this.lastInboundAt)
380
- return;
386
+ return false;
381
387
  this.lastIdleAt = now;
382
388
  this.lastIdleOrder = ++this.sequence;
389
+ return true;
383
390
  }
384
391
  hasPendingWork() {
385
392
  return this.lastInboundOrder > this.lastIdleOrder;
386
393
  }
387
394
  }
388
395
  const NORMAL_ENTER_SETTLE_MS = 500;
396
+ /** How long to keep looking for proof after a retry Enter before failing it. */
397
+ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
398
+ const POST_ENTER_PROOF_POLL_MS = 250;
399
+ /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
400
+ const BASELINE_CAPTURE_ATTEMPTS = 3;
401
+ const BASELINE_CAPTURE_RETRY_MS = 150;
402
+ /** Occurrences of `needle` in `haystack` (plain text, no regex semantics). */
403
+ function countOccurrences(haystack, needle) {
404
+ if (!needle)
405
+ return 0;
406
+ let n = 0;
407
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length))
408
+ n++;
409
+ return n;
410
+ }
389
411
  /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
390
412
  const BOTTOM_READY_POLL_MS = 250;
391
413
  /** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
@@ -411,6 +433,9 @@ export class BackendUnreachableStartupError extends Error {
411
433
  }
412
434
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
413
435
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
436
+ /** A passive startup phase must clear within this bound before an Enter is sent. */
437
+ const INPUT_TRANSIENT_WAIT_MS = 30_000;
438
+ const INPUT_TRANSIENT_POLL_MS = 250;
414
439
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
415
440
  const STRANDED_INPUT_MAX_ROUNDS = 3;
416
441
  const FIRST_ENTER_SETTLE_MS = 1_750;
@@ -837,6 +862,14 @@ export class Daemon extends EventEmitter {
837
862
  spawnSettled = null;
838
863
  resolveSpawnSettled = null;
839
864
  spawnDepth = 0;
865
+ /** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
866
+ spawnGeneration = 0;
867
+ /**
868
+ * Keeps passive startup-transient checks alive through the first positively
869
+ * submitted post-spawn message. Without this tail, a transient painted just
870
+ * after the startup scan can still swallow that message's Enter.
871
+ */
872
+ inputTransientGuardGeneration = null;
840
873
  skipResume = false;
841
874
  startupAborted = false;
842
875
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -858,6 +891,9 @@ export class Daemon extends EventEmitter {
858
891
  instanceStateMonitorTimer = null;
859
892
  instanceStateIdleTimer = null;
860
893
  instanceStateOutputProbeTimer = null;
894
+ /** Consecutive structural idle captures for a noisy, periodically-redrawing
895
+ * pane. Two are required before a working -> idle transition. */
896
+ instanceStatePeriodicIdleConfirmations = 0;
861
897
  instanceStateStuckTimer = null;
862
898
  instanceStateOutputListener = null;
863
899
  instanceStateOutputEventName = null;
@@ -870,19 +906,14 @@ export class Daemon extends EventEmitter {
870
906
  instanceStateMonitorActive = false;
871
907
  sessionCheckpointWarningEmitted = false;
872
908
  statePollInFlight = false;
873
- // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
874
- // Set when an inbound message lands in the CLI; cleared on the idle edge that
875
- // ends the turn. With a dead MCP server and no successful channel tool call in
876
- // between, the agent's answer exists only on screen — the daemon relays it.
877
- turnHadInbound = false;
878
- turnOutboundDelivered = false;
879
- /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
880
- turnReplyDelivered = false;
881
- turnCorrelationId;
882
- turnInboundMarker;
909
+ // One generation-scoped source of truth for human-turn reply completion.
910
+ // It also supplies the older dead-MCP/malformed-call recovery paths, so those
911
+ // paths cannot disagree about whether the turn already spoke to the channel.
912
+ turnReplyGuard = new TurnReplyGuard();
883
913
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
884
914
  lastMalformedToolCallSignature;
885
915
  proxyReplySeq = 0;
916
+ lastReplyDropWarningAt = 0;
886
917
  autoPauseController;
887
918
  pauseRequested = false;
888
919
  /**
@@ -1059,6 +1090,33 @@ export class Daemon extends EventEmitter {
1059
1090
  static errorPatternKey(ep) {
1060
1091
  return `${ep.type}:${ep.pattern.source}`;
1061
1092
  }
1093
+ /**
1094
+ * Undo the auth suspicion a pattern match armed, once the backend's token-free
1095
+ * probe has said the credentials are fine.
1096
+ *
1097
+ * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1098
+ * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1099
+ * daemon permanently suspicious of an auth failure that never existed. That
1100
+ * state is not inert: authFailureUnresolved suppresses the stuck/hang
1101
+ * notification and holds MCP auto-restart (a later real MCP death is then read
1102
+ * as already-confirmed auth trouble rather than re-verified), and the recovery
1103
+ * gate suppresses further error detection until a ready pattern shows up.
1104
+ *
1105
+ * Only what an auth match armed is rolled back. If some other pattern has
1106
+ * since armed the recovery gate, that one is still live and stays.
1107
+ */
1108
+ clearSuspectedAuthFailure() {
1109
+ if (!this.authFailureUnresolved && !this.loginScreenReported)
1110
+ return false;
1111
+ this.authFailureUnresolved = false;
1112
+ this.loginScreenReported = false;
1113
+ if (this.lastDetectedErrorType === "auth_error") {
1114
+ this.clearErrorRecoveryGate();
1115
+ this.lastDetectedErrorType = null;
1116
+ }
1117
+ this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1118
+ return true;
1119
+ }
1062
1120
  clearErrorRecoveryGate() {
1063
1121
  this.errorWaitingForRecovery = false;
1064
1122
  this.errorDetectedAt = 0;
@@ -1899,12 +1957,8 @@ export class Daemon extends EventEmitter {
1899
1957
  // --continue again and crash in the same way → loop. Clear the session id
1900
1958
  // and skip resume so the next spawn starts fresh. (skipResume also stops
1901
1959
  // saveSessionId below from resurrecting the id from statusline.json.)
1902
- if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
1903
- this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
1904
- try {
1905
- unlinkSync(join(this.instanceDir, "session-id"));
1906
- }
1907
- catch { /* may not exist */ }
1960
+ if (this.paneSaysNoConversation(lastOutput)) {
1961
+ this.setSessionAside("cli_reported_no_conversation");
1908
1962
  this.skipResume = true;
1909
1963
  }
1910
1964
  // Append to crash history
@@ -2484,6 +2538,10 @@ export class Daemon extends EventEmitter {
2484
2538
  }
2485
2539
  async stop() {
2486
2540
  this.logger.info("Stopping daemon instance");
2541
+ this.turnReplyGuard.reset();
2542
+ // Invalidate any bounded pre-Enter wait from the process generation being
2543
+ // stopped. It must fail closed, not press Enter in a replacement pane.
2544
+ this.inputTransientGuardGeneration = null;
2487
2545
  this.freezeRuntimeMonitors();
2488
2546
  this.pendingIpcRequests.clear();
2489
2547
  if (this.adapter)
@@ -2777,7 +2835,7 @@ export class Daemon extends EventEmitter {
2777
2835
  // Only a transition back to idle completes pending work. Repeated idle
2778
2836
  // observations between enqueue and paste must not clear a newer inbound.
2779
2837
  if (snapshot.state === "idle" && previous !== "idle") {
2780
- this.pendingWork.recordIdle(snapshot.observedAt);
2838
+ const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
2781
2839
  // The turn is over. A transcript can end on a tool_use with no matching
2782
2840
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2783
2841
  // the last tool pinned to the progress line for the rest of the session.
@@ -2785,7 +2843,8 @@ export class Daemon extends EventEmitter {
2785
2843
  this.resetToolProgress();
2786
2844
  // Must run before the mcpRestartPending branch below: the pane text is the
2787
2845
  // only copy of the answer, and the revival restart is about to clear it.
2788
- this.maybeProxyReplyOnTurnEnd(pane);
2846
+ if (acceptedIdle)
2847
+ this.maybeProxyReplyOnTurnEnd(pane);
2789
2848
  }
2790
2849
  if (snapshot.state !== previous) {
2791
2850
  this.logger.info({
@@ -2836,13 +2895,17 @@ export class Daemon extends EventEmitter {
2836
2895
  // a task result, and a stale one (sol's review of #515).
2837
2896
  if (meta.from_instance || !meta.chat_id)
2838
2897
  return;
2839
- this.turnHadInbound = true;
2840
- this.turnOutboundDelivered = false;
2841
- this.turnReplyDelivered = false;
2842
- this.turnCorrelationId = meta.correlation_id || undefined;
2843
2898
  // The last non-empty line of what we pasted: everything on screen after it
2844
2899
  // is the agent's own output.
2845
- this.turnInboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2900
+ const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2901
+ this.turnReplyGuard.arm({
2902
+ adapterId: meta.adapter_id || undefined,
2903
+ chatId: meta.chat_id,
2904
+ threadId: meta.thread_id || undefined,
2905
+ messageId: meta.message_id || undefined,
2906
+ correlationId: meta.correlation_id || undefined,
2907
+ inboundMarker,
2908
+ });
2846
2909
  }
2847
2910
  /**
2848
2911
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -2852,56 +2915,198 @@ export class Daemon extends EventEmitter {
2852
2915
  * here (edge-triggered, then reset) is what makes it at most once per turn.
2853
2916
  */
2854
2917
  maybeProxyReplyOnTurnEnd(pane) {
2855
- const hadInbound = this.turnHadInbound;
2856
- const delivered = this.turnOutboundDelivered;
2857
- const replyDelivered = this.turnReplyDelivered;
2858
- const correlationId = this.turnCorrelationId;
2859
- const inboundMarker = this.turnInboundMarker;
2860
- this.turnHadInbound = false;
2861
- this.turnOutboundDelivered = false;
2862
- this.turnReplyDelivered = false;
2863
- this.turnCorrelationId = undefined;
2864
- this.turnInboundMarker = undefined;
2865
- if (!hadInbound || this.isPaused)
2918
+ const turn = this.turnReplyGuard.snapshot();
2919
+ if (!turn || this.isPaused)
2920
+ return;
2921
+ if (turn.replyDelivered) {
2922
+ if (turn.phase === "recovering") {
2923
+ this.emit("reply_drop_recovered", {
2924
+ name: this.name,
2925
+ correlationId: turn.target.correlationId,
2926
+ generation: turn.generation,
2927
+ });
2928
+ }
2929
+ this.turnReplyGuard.complete(turn.generation);
2930
+ return;
2931
+ }
2932
+ // A second idle edge ends the one permitted recovery turn. Never create a
2933
+ // third turn or guess at terminal text; make the failure visible instead.
2934
+ if (turn.phase === "recovering") {
2935
+ this.turnReplyGuard.complete(turn.generation);
2936
+ this.reportUnrecoveredReplyDrop(turn);
2866
2937
  return;
2938
+ }
2867
2939
  // Claude sometimes prints a broken XML tool call as plain text and returns
2868
2940
  // idle without ever invoking `reply`. This is independent of MCP liveness:
2869
2941
  // the model malformed the call before the server could receive it.
2870
- if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2871
- const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2942
+ if (this.isClaudeCodeBackend() && pane) {
2943
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
2872
2944
  if (malformed) {
2873
2945
  const stale = malformed.signature === this.lastMalformedToolCallSignature;
2874
2946
  this.lastMalformedToolCallSignature = malformed.signature;
2875
2947
  if (!stale) {
2876
- const recovered = malformed.text != null;
2877
- this.logger.warn({ correlationId, recovered }, recovered
2948
+ this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
2878
2949
  ? "Malformed Claude tool call detected — attempting to recover reply text"
2879
2950
  : "Malformed Claude tool call detected — reply text could not be extracted");
2880
- this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2881
- if (malformed.text)
2882
- this.sendRecoveredMalformedReply(malformed.text, correlationId);
2951
+ if (malformed.text) {
2952
+ this.queueMalformedReplyRecovery(turn, malformed.text);
2953
+ }
2954
+ else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
2955
+ this.emit("malformed_tool_call", {
2956
+ name: this.name,
2957
+ correlationId: turn.target.correlationId,
2958
+ recovered: false,
2959
+ recoveryStarted: true,
2960
+ });
2961
+ this.startReplyRecovery(turn, "malformed_call");
2962
+ }
2963
+ else {
2964
+ this.emit("malformed_tool_call", {
2965
+ name: this.name,
2966
+ correlationId: turn.target.correlationId,
2967
+ recovered: false,
2968
+ });
2969
+ this.turnReplyGuard.complete(turn.generation);
2970
+ }
2971
+ }
2972
+ else {
2973
+ this.turnReplyGuard.complete(turn.generation);
2883
2974
  }
2884
2975
  // Never let the broader dead-MCP fallback relay the XML/chrome too.
2885
2976
  return;
2886
2977
  }
2887
2978
  }
2888
- if (delivered)
2979
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
2980
+ // tools still suppress that proxy, but only a delivered `reply` satisfies
2981
+ // the human-facing completion guard below.
2982
+ if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
2983
+ && mcpServerState(this.instanceDir).state === "dead") {
2984
+ this.turnReplyGuard.complete(turn.generation);
2985
+ void this.sendProxyReply(pane, turn.target);
2889
2986
  return;
2890
- // Opt-in: raw pane text can carry secrets past the regex redaction.
2891
- if (this.config.mcp_proxy_reply !== true)
2987
+ }
2988
+ if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
2989
+ this.turnReplyGuard.complete(turn.generation);
2892
2990
  return;
2893
- // "dead" only: unknown means not started or exited cleanly — never proxy on it.
2894
- if (mcpServerState(this.instanceDir).state !== "dead")
2991
+ }
2992
+ if (turn.replyAttempted) {
2993
+ // A provider timeout can be "applied, then timed out". Retrying it would
2994
+ // risk a duplicate; report the unknown result and stop here.
2995
+ this.turnReplyGuard.complete(turn.generation);
2996
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
2895
2997
  return;
2896
- void this.sendProxyReply(pane, inboundMarker, correlationId);
2998
+ }
2999
+ this.startReplyRecovery(turn, "no_valid_call");
2897
3000
  }
2898
3001
  isClaudeCodeBackend() {
2899
3002
  return this.runtimeIdentity?.backend === "claude-code"
2900
3003
  || this.config.backend === "claude-code"
2901
3004
  || this.backend?.binaryName === "claude";
2902
3005
  }
3006
+ replyCompletionGuardEnabled() {
3007
+ return this.config.reply_completion_guard !== false
3008
+ && this.backend?.replyCompletionGuard === true;
3009
+ }
3010
+ startReplyRecovery(turn, reason) {
3011
+ if (!this.turnReplyGuard.beginRecovery(turn.generation))
3012
+ return;
3013
+ this.emit("reply_drop_detected", {
3014
+ name: this.name,
3015
+ correlationId: turn.target.correlationId,
3016
+ generation: turn.generation,
3017
+ reason,
3018
+ recoveryStarted: true,
3019
+ });
3020
+ void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
3021
+ this.queueReplyRecoveryPrompt(turn);
3022
+ }
3023
+ reportReplyDropWithoutRetry(turn, reason) {
3024
+ this.emit("reply_drop_detected", {
3025
+ name: this.name,
3026
+ correlationId: turn.target.correlationId,
3027
+ generation: turn.generation,
3028
+ reason,
3029
+ recoveryStarted: false,
3030
+ });
3031
+ void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
3032
+ }
3033
+ queueReplyRecoveryPrompt(turn) {
3034
+ const deliveryEpoch = this.deliveryEpoch;
3035
+ this.pasteQueueDepth++;
3036
+ this.pasteLock = this.pasteLock.then(async () => {
3037
+ try {
3038
+ const current = this.turnReplyGuard.snapshot();
3039
+ if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3040
+ return;
3041
+ if (current.replyDelivered) {
3042
+ this.turnReplyGuard.complete(turn.generation);
3043
+ this.emit("reply_drop_recovered", {
3044
+ name: this.name,
3045
+ correlationId: current.target.correlationId,
3046
+ generation: current.generation,
3047
+ });
3048
+ return;
3049
+ }
3050
+ const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
3051
+ deliveryEpoch,
3052
+ });
3053
+ if (!delivered) {
3054
+ this.turnReplyGuard.complete(turn.generation);
3055
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3056
+ }
3057
+ }
3058
+ finally {
3059
+ this.pasteQueueDepth--;
3060
+ }
3061
+ }).catch(err => {
3062
+ this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
3063
+ this.turnReplyGuard.complete(turn.generation);
3064
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3065
+ });
3066
+ }
3067
+ queueMalformedReplyRecovery(turn, text) {
3068
+ // Serialize the delivery decision ahead of any new pane input. If direct
3069
+ // delivery is positively acknowledged, there is no recovery turn. If its
3070
+ // outcome is unknown, do not ask the model to duplicate it.
3071
+ const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
3072
+ this.pasteQueueDepth++;
3073
+ this.pasteLock = this.pasteLock.then(async () => {
3074
+ try {
3075
+ const delivered = await delivery;
3076
+ this.emit("malformed_tool_call", {
3077
+ name: this.name,
3078
+ correlationId: turn.target.correlationId,
3079
+ recovered: delivered,
3080
+ });
3081
+ this.turnReplyGuard.complete(turn.generation);
3082
+ if (!delivered)
3083
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3084
+ }
3085
+ finally {
3086
+ this.pasteQueueDepth--;
3087
+ }
3088
+ }).catch(err => {
3089
+ this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
3090
+ this.turnReplyGuard.complete(turn.generation);
3091
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3092
+ });
3093
+ }
3094
+ reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
3095
+ this.emit("reply_drop_unrecovered", {
3096
+ name: this.name,
3097
+ correlationId: turn.target.correlationId,
3098
+ generation: turn.generation,
3099
+ reason,
3100
+ });
3101
+ const now = Date.now();
3102
+ if (this.lastReplyDropWarningAt !== 0
3103
+ && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
3104
+ return;
3105
+ this.lastReplyDropWarningAt = now;
3106
+ void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
3107
+ }
2903
3108
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2904
- async sendProxyReply(pane, inboundMarker, correlationId) {
3109
+ async sendProxyReply(pane, target) {
2905
3110
  try {
2906
3111
  // The idle capture normally hands its pane in; fall back only when the
2907
3112
  // edge came from a path without one.
@@ -2909,74 +3114,110 @@ export class Daemon extends EventEmitter {
2909
3114
  pane = await this.tmux?.capturePane();
2910
3115
  if (!pane)
2911
3116
  return;
2912
- const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
3117
+ const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
2913
3118
  if (!text) {
2914
3119
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
2915
3120
  return;
2916
3121
  }
2917
3122
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2918
- if (correlationId)
2919
- body += `\n\n(correlation_id: ${correlationId})`;
2920
- this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2921
- this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2922
- this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply");
3123
+ if (target.correlationId)
3124
+ body += `\n\n(correlation_id: ${target.correlationId})`;
3125
+ this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
3126
+ const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
3127
+ if (delivered)
3128
+ this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
2923
3129
  }
2924
3130
  catch (err) {
2925
3131
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2926
3132
  }
2927
3133
  }
2928
- /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2929
- sendRecoveredMalformedReply(text, correlationId) {
2930
- try {
2931
- this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2932
- }
2933
- catch (err) {
2934
- this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2935
- }
2936
- }
2937
3134
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2938
- deliverDaemonReply(body, requestPrefix, logLabel) {
3135
+ deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
2939
3136
  const args = { text: body };
2940
- if (this.lastChatId) {
2941
- args.chat_id = this.lastChatId;
2942
- if (this.lastThreadId)
2943
- args.thread_id = this.lastThreadId;
3137
+ const chatId = target?.chatId ?? this.lastChatId;
3138
+ const threadId = target?.threadId ?? this.lastThreadId;
3139
+ const adapterId = target?.adapterId ?? this.lastAdapterId;
3140
+ if (chatId) {
3141
+ args.chat_id = chatId;
3142
+ if (threadId)
3143
+ args.thread_id = threadId;
2944
3144
  }
2945
3145
  const adapters = this.messageBus.getAllAdapters();
2946
3146
  if (adapters.length > 0) {
2947
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2948
- if (error)
2949
- this.logger.error({ error }, `${logLabel} failed`);
2950
- else
2951
- this.logger.info(`${logLabel} delivered`);
3147
+ // With one adapter there is nothing to choose and adapters[0] is exact.
3148
+ // With several, "no adapter named" is a routing failure, not a default:
3149
+ // picking the first sends one world's chat id through another world's bot.
3150
+ const adapter = adapterId
3151
+ ? this.messageBus.getAdapter(adapterId)
3152
+ : (adapters.length === 1 ? adapters[0] : undefined);
3153
+ if (!adapter) {
3154
+ this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
3155
+ return Promise.resolve(false);
3156
+ }
3157
+ return new Promise(resolve => {
3158
+ let settled = false;
3159
+ const finish = (delivered, error) => {
3160
+ if (settled)
3161
+ return;
3162
+ settled = true;
3163
+ clearTimeout(timeout);
3164
+ if (delivered)
3165
+ this.logger.info(`${logLabel} delivered`);
3166
+ else
3167
+ this.logger.error({ error }, `${logLabel} failed`);
3168
+ resolve(delivered);
3169
+ };
3170
+ const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
3171
+ timeout.unref?.();
3172
+ try {
3173
+ if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
3174
+ finish(false, "reply route unavailable");
3175
+ }
3176
+ }
3177
+ catch {
3178
+ finish(false, "adapter reply threw before returning a promise");
3179
+ }
2952
3180
  });
2953
- return;
2954
3181
  }
2955
3182
  if (!this.ipcServer) {
2956
3183
  this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2957
- return;
3184
+ return Promise.resolve(false);
2958
3185
  }
2959
- const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2960
- const timeout = setTimeout(() => {
2961
- this.pendingIpcRequests.delete(fleetReqId);
2962
- this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2963
- }, daemonBudgetMs("reply"));
2964
- timeout.unref?.();
2965
- // Register before broadcast: an in-process/fast adapter can answer in the
2966
- // same tick, and a waiter installed afterwards would miss that response.
2967
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2968
- clearTimeout(timeout);
2969
- if (respMsg.error)
2970
- this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
2971
- else
2972
- this.logger.info(`${logLabel} delivered`);
2973
- });
2974
- this.ipcServer.broadcast({
2975
- type: "fleet_outbound",
2976
- tool: "reply",
2977
- args,
2978
- fleetRequestId: fleetReqId,
2979
- adapterId: this.lastAdapterId,
3186
+ return new Promise(resolve => {
3187
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
3188
+ const timeout = setTimeout(() => {
3189
+ this.pendingIpcRequests.delete(fleetReqId);
3190
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
3191
+ resolve(false);
3192
+ }, daemonBudgetMs("reply"));
3193
+ timeout.unref?.();
3194
+ // Register before broadcast: an in-process/fast adapter can answer in the
3195
+ // same tick, and a waiter installed afterwards would miss that response.
3196
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3197
+ clearTimeout(timeout);
3198
+ const delivered = !respMsg.error && respMsg.result != null;
3199
+ if (!delivered)
3200
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3201
+ else
3202
+ this.logger.info(`${logLabel} delivered`);
3203
+ resolve(delivered);
3204
+ });
3205
+ try {
3206
+ this.ipcServer.broadcast({
3207
+ type: "fleet_outbound",
3208
+ tool: "reply",
3209
+ args,
3210
+ fleetRequestId: fleetReqId,
3211
+ adapterId,
3212
+ statusOnly,
3213
+ });
3214
+ }
3215
+ catch {
3216
+ clearTimeout(timeout);
3217
+ this.pendingIpcRequests.delete(fleetReqId);
3218
+ this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
3219
+ resolve(false);
3220
+ }
2980
3221
  });
2981
3222
  }
2982
3223
  clearInstanceStateIdleTimer() {
@@ -2994,13 +3235,13 @@ export class Daemon extends EventEmitter {
2994
3235
  * visible cell. Probe the pane once per burst: a changed capture is real work;
2995
3236
  * an identical ready capture remains idle and emits no state edge.
2996
3237
  */
2997
- scheduleInstanceStateOutputProbe() {
3238
+ scheduleInstanceStateOutputProbe(delayMs = PERIODIC_REDRAW_PROBE_MS) {
2998
3239
  if (this.instanceStateOutputProbeTimer)
2999
3240
  return;
3000
3241
  this.instanceStateOutputProbeTimer = setTimeout(() => {
3001
3242
  this.instanceStateOutputProbeTimer = null;
3002
3243
  void this.captureAndEvaluateInstanceState("output_probe", this.instanceStateLastOutputAt);
3003
- }, PERIODIC_REDRAW_PROBE_MS);
3244
+ }, delayMs);
3004
3245
  this.instanceStateOutputProbeTimer.unref?.();
3005
3246
  }
3006
3247
  clearInstanceStateStuckTimer() {
@@ -3083,11 +3324,98 @@ export class Daemon extends EventEmitter {
3083
3324
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
3084
3325
  // decisions about *now*. The old double-observe expressed that by calling
3085
3326
  // observe twice, which silently disabled the "content moved" branch.
3086
- const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3087
- settled,
3088
- changeAt: observedChangeAt,
3089
- forceBusy: paneActivity !== null,
3090
- });
3327
+ const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
3328
+ let snapshot;
3329
+ if (structuredPeriodicIdle && reason === "output_probe") {
3330
+ const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3331
+ if (idlePane) {
3332
+ if (this.instanceState === "idle") {
3333
+ // Cosmetic output must not manufacture an idle -> working edge.
3334
+ this.instanceStatePeriodicIdleConfirmations = 2;
3335
+ }
3336
+ else {
3337
+ this.instanceStatePeriodicIdleConfirmations++;
3338
+ }
3339
+ if (this.instanceStatePeriodicIdleConfirmations < 2) {
3340
+ // One positive frame is intentionally not enough to bless a
3341
+ // working pane while the starfield is still repainting.
3342
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3343
+ }
3344
+ else {
3345
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3346
+ settled: true,
3347
+ changeAt: observedChangeAt,
3348
+ });
3349
+ }
3350
+ }
3351
+ else {
3352
+ // A live busy marker or an unfamiliar layout outranks the broad Codex
3353
+ // ready pattern during the noisy-redraw path. Unknown is working.
3354
+ this.instanceStatePeriodicIdleConfirmations = 0;
3355
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3356
+ }
3357
+ }
3358
+ else if (structuredPeriodicIdle && reason !== "idle_debounce" && !settled) {
3359
+ // Startup, safety, and explicit state probes must not bless a broad
3360
+ // Codex ready match when the structural layout is unknown or visibly
3361
+ // busy. The settled debounce below remains the compatibility path for
3362
+ // drafts and older layouts that simply lack the new footer.
3363
+ //
3364
+ // ONLY while output is still recent. recordOutput sets working
3365
+ // unconditionally, and the safety sweep fires for every daemon on a
3366
+ // timer — so without this guard a Codex that had gone quiet was flipped
3367
+ // to working by the next sweep and could never come back, because a
3368
+ // quiet pane produces no further output to re-evaluate it. That state
3369
+ // holds the Cancel button open, disables auto-pause, and makes the hang
3370
+ // detector report a perfectly idle instance as stuck. A settled capture
3371
+ // has no recent output to defend and falls through to the legacy
3372
+ // ready/busy reading below.
3373
+ //
3374
+ // The `settled` guard alone is not enough for the case this whole
3375
+ // branch exists to serve. While the Astra star field keeps repainting,
3376
+ // output never stops arriving, so the capture is NEVER settled — and a
3377
+ // sweep landing on an already-idle animated pane flipped it to working
3378
+ // once a minute, then back an instant later once two probes re-proved
3379
+ // it. Each flicker is a spurious pair of state edges for the fleet and
3380
+ // resets the idleSince this daemon reports when it asks to auto-pause.
3381
+ //
3382
+ // So the same rule the output_probe branch already applies holds here:
3383
+ // cosmetic output must not manufacture an idle -> working edge. A pane
3384
+ // that is STRUCTURALLY idle and already known idle stays idle.
3385
+ //
3386
+ // It must be re-read as SETTLED to do that. Falling through to the
3387
+ // ordinary reading is not equivalent: that path is given the real
3388
+ // `settled` (false, because the animation is still painting), and an
3389
+ // unsettled capture of a CHANGED pane is working by definition — so a
3390
+ // sweep landing on a frame no probe had captured yet still flipped an
3391
+ // idle pane over. Treating this capture as settled is what lets the
3392
+ // ready/busy reading decide on the frame itself.
3393
+ const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3394
+ if (idlePane && this.instanceState === "idle") {
3395
+ this.instanceStatePeriodicIdleConfirmations = 2;
3396
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3397
+ settled: true,
3398
+ changeAt: observedChangeAt,
3399
+ });
3400
+ }
3401
+ else {
3402
+ this.instanceStatePeriodicIdleConfirmations = 0;
3403
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3404
+ }
3405
+ }
3406
+ else {
3407
+ // Structural proof is an additional working-stage escape hatch only.
3408
+ // Settled captures retain the legacy ready/busy semantics for drafts,
3409
+ // older Codex layouts, and panes without a Context footer.
3410
+ if (structuredPeriodicIdle && settled) {
3411
+ this.instanceStatePeriodicIdleConfirmations = 0;
3412
+ }
3413
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
3414
+ settled,
3415
+ changeAt: observedChangeAt,
3416
+ forceBusy: paneActivity !== null,
3417
+ });
3418
+ }
3091
3419
  this.applyInstanceStateSnapshot(snapshot, pane);
3092
3420
  // Backends without a transcript feed can still say what they are doing, if
3093
3421
  // their TUI names it. Free-riding on a capture that already happened: no
@@ -3096,10 +3424,11 @@ export class Daemon extends EventEmitter {
3096
3424
  if (this.backend?.getPaneActivity) {
3097
3425
  this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
3098
3426
  }
3099
- if (snapshot.state === "idle") {
3427
+ const currentState = this.instanceState;
3428
+ if (currentState === "idle") {
3100
3429
  this.clearInstanceStateStuckTimer();
3101
3430
  }
3102
- else if (snapshot.state === "working") {
3431
+ else if (currentState === "working") {
3103
3432
  // If control mode missed the pane change, the safety capture becomes
3104
3433
  // the new progress timestamp and re-arms both deadlines.
3105
3434
  if (!expectedOutputAt)
@@ -3121,11 +3450,17 @@ export class Daemon extends EventEmitter {
3121
3450
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
3122
3451
  return;
3123
3452
  this.instanceStateLastOutputAt = event.at;
3124
- if (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle") {
3453
+ const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
3454
+ if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
3125
3455
  // Do not emit idle→working solely because agy repainted an identical
3126
- // footer. The short capture probe below still observes genuine pane
3127
- // changes promptly and moves the state to working.
3128
- this.scheduleInstanceStateOutputProbe();
3456
+ // footer. Codex additionally uses a structural probe while working: two
3457
+ // positive prompt captures can recover from an animation stream that
3458
+ // never leaves a two-second quiet window.
3459
+ if (this.instanceState !== "idle") {
3460
+ this.applyInstanceStateSnapshot(this.instanceStateMachine.recordOutput(event.at));
3461
+ this.scheduleInstanceStateStuckDeadline(event.at);
3462
+ }
3463
+ this.scheduleInstanceStateOutputProbe(canProvePeriodicIdle ? STRUCTURED_IDLE_PROBE_MS : PERIODIC_REDRAW_PROBE_MS);
3129
3464
  this.scheduleInstanceStateIdleCapture();
3130
3465
  return;
3131
3466
  }
@@ -3161,6 +3496,7 @@ export class Daemon extends EventEmitter {
3161
3496
  this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
3162
3497
  this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
3163
3498
  this.instanceStateLastOutputAt = 0;
3499
+ this.instanceStatePeriodicIdleConfirmations = 0;
3164
3500
  this.instanceStateMonitorActive = true;
3165
3501
  if (this.controlClient) {
3166
3502
  this.instanceStateSafetyListener = () => { void this.captureAndEvaluateInstanceState("safety_sweep"); };
@@ -3178,6 +3514,7 @@ export class Daemon extends EventEmitter {
3178
3514
  }
3179
3515
  stopInstanceStateMonitor() {
3180
3516
  this.instanceStateMonitorActive = false;
3517
+ this.instanceStatePeriodicIdleConfirmations = 0;
3181
3518
  this.clearInstanceStateIdleTimer();
3182
3519
  this.clearInstanceStateOutputProbeTimer();
3183
3520
  this.clearInstanceStateStuckTimer();
@@ -3323,6 +3660,12 @@ export class Daemon extends EventEmitter {
3323
3660
  if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
3324
3661
  this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
3325
3662
  }
3663
+ if (typeof update.reply_completion_guard === "boolean") {
3664
+ this.config.reply_completion_guard = update.reply_completion_guard;
3665
+ }
3666
+ else if (update.reply_completion_guard === null) {
3667
+ delete this.config.reply_completion_guard;
3668
+ }
3326
3669
  if (typeof update.mcp_proxy_reply === "boolean")
3327
3670
  this.config.mcp_proxy_reply = update.mcp_proxy_reply;
3328
3671
  else if (update.mcp_proxy_reply === null)
@@ -3543,7 +3886,7 @@ export class Daemon extends EventEmitter {
3543
3886
  await this.wake();
3544
3887
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3545
3888
  return;
3546
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3889
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3547
3890
  this.markTurnStarted(meta, formatted);
3548
3891
  }
3549
3892
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3581,7 +3924,7 @@ export class Daemon extends EventEmitter {
3581
3924
  await this.wake();
3582
3925
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3583
3926
  return;
3584
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3927
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3585
3928
  this.markTurnStarted(meta, formatted);
3586
3929
  }
3587
3930
  }).catch(err => {
@@ -3633,8 +3976,7 @@ export class Daemon extends EventEmitter {
3633
3976
  this.pasteLock = this.pasteLock.then(async () => {
3634
3977
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3635
3978
  return;
3636
- if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
3637
- this.markTurnStarted(meta, rawText);
3979
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
3638
3980
  }).catch(err => {
3639
3981
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
3640
3982
  });
@@ -3677,7 +4019,7 @@ export class Daemon extends EventEmitter {
3677
4019
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3678
4020
  // the previous turn's tool list.
3679
4021
  this.resetToolProgress();
3680
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
4022
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3681
4023
  this.markTurnStarted(meta, formatted);
3682
4024
  }
3683
4025
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3826,16 +4168,24 @@ export class Daemon extends EventEmitter {
3826
4168
  }
3827
4169
  }
3828
4170
  }
3829
- // Everything above is *waiting*; everything below *writes*. Only the write is
3830
- // held under the pane lock — holding it across the idle wait (up to 30 min)
3831
- // would starve the runtime-dialog dismisser, which is often the very thing
3832
- // that would let the pane go idle again.
4171
+ // Everything above is *waiting*; everything below owns the write lock.
4172
+ // Long idle/dialog waits stay outside because the runtime dismisser may be
4173
+ // what makes progress. The one under-lock wait below is explicitly passive:
4174
+ // it clears by itself and closes a repaint race immediately before paste.
3833
4175
  for (let round = 0;; round++) {
3834
4176
  const outcome = await this.paneWriteLock.run(async () => {
3835
4177
  if (cancelled())
3836
4178
  return false;
3837
4179
  if (this.refuseFatalStartupDelivery(status))
3838
4180
  return false;
4181
+ // A passive startup phase may paint after the outer readiness probe.
4182
+ // It clears without input, so waiting under the pane lock cannot starve
4183
+ // a dialog dismisser and closes the final clear→paste TOCTOU window.
4184
+ if (!(await this.waitForInputTransientToClear("pre-write"))) {
4185
+ if (status)
4186
+ this.emit("message_failed", status); // ❌
4187
+ return false;
4188
+ }
3839
4189
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
3840
4190
  // whenever it likes — a resume prompt can be painted between "clear" and
3841
4191
  // this critical section. Re-probe here, right before the write; a
@@ -3845,7 +4195,7 @@ export class Daemon extends EventEmitter {
3845
4195
  if (probe.state !== "clear")
3846
4196
  return "dialog";
3847
4197
  }
3848
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
4198
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3849
4199
  });
3850
4200
  if (outcome !== "dialog")
3851
4201
  return outcome;
@@ -3879,6 +4229,75 @@ export class Daemon extends EventEmitter {
3879
4229
  deliveryBlockingDialogs() {
3880
4230
  return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
3881
4231
  }
4232
+ /** Passive input-unavailable phases relevant to the current spawn only. */
4233
+ guardedInputTransients() {
4234
+ if (this.inputTransientGuardGeneration === null
4235
+ || this.inputTransientGuardGeneration !== this.spawnGeneration)
4236
+ return [];
4237
+ return this.backend?.getInputUnavailableTransients?.() ?? [];
4238
+ }
4239
+ /** Match a passive transient against the current interactive screen. */
4240
+ inputTransientInPane(pane) {
4241
+ for (const transient of this.guardedInputTransients()) {
4242
+ // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4243
+ // authority because users and agents routinely quote diagnostic text.
4244
+ transient.pattern.lastIndex = 0;
4245
+ if (!transient.pattern.test(pane))
4246
+ continue;
4247
+ if (transient.isActive(pane))
4248
+ return transient;
4249
+ }
4250
+ return null;
4251
+ }
4252
+ /** Capture failure is unknown, never evidence that input is available. */
4253
+ async probeInputTransient() {
4254
+ if (this.guardedInputTransients().length === 0 || !this.tmux)
4255
+ return { state: "clear" };
4256
+ try {
4257
+ const pane = await this.tmux.capturePane();
4258
+ const transient = this.inputTransientInPane(pane);
4259
+ return transient ? { state: "active", transient } : { state: "clear" };
4260
+ }
4261
+ catch (err) {
4262
+ this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
4263
+ return { state: "unknown" };
4264
+ }
4265
+ }
4266
+ /**
4267
+ * Wait under the pane lock for a passive transient to disappear. Unlike a
4268
+ * dialog, this state clears by itself, so the wait cannot starve a keypress
4269
+ * dismisser. Stop or a replacement spawn invalidates the captured identity.
4270
+ */
4271
+ async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
4272
+ const generation = this.inputTransientGuardGeneration;
4273
+ if (generation === null || generation !== this.spawnGeneration)
4274
+ return true;
4275
+ const deadline = Date.now() + timeoutMs;
4276
+ let observedDescription = null;
4277
+ for (;;) {
4278
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4279
+ this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
4280
+ return false;
4281
+ }
4282
+ const probe = await this.probeInputTransient();
4283
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4284
+ this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
4285
+ return false;
4286
+ }
4287
+ if (probe.state === "clear")
4288
+ return true;
4289
+ if (probe.state === "active" && observedDescription !== probe.transient.description) {
4290
+ observedDescription = probe.transient.description;
4291
+ this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4292
+ }
4293
+ const remaining = deadline - Date.now();
4294
+ if (remaining <= 0) {
4295
+ this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
4296
+ return false;
4297
+ }
4298
+ await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4299
+ }
4300
+ }
3882
4301
  static dialogKey(dialog) {
3883
4302
  return `${dialog.pattern.source}/${dialog.pattern.flags}`;
3884
4303
  }
@@ -3989,10 +4408,10 @@ export class Daemon extends EventEmitter {
3989
4408
  }
3990
4409
  /**
3991
4410
  * Why a pane is not deliverable matters. "busy" may be handed to a native
3992
- * input queue or steered (after its own dialog probe); "dialog" and "unknown"
3993
- * must never be — a paste + Enter into a dialog answers it with its default,
3994
- * and an unreadable pane proves nothing. The silence gate comes first and
3995
- * costs no capture, so a CLI that is simply working never pays for the probe.
4411
+ * input queue or steered (after its own dialog probe); "dialog", "transient"
4412
+ * and "unknown" must never be — a paste + Enter into a dialog answers it
4413
+ * with its default, while a startup transient swallows it and an unreadable
4414
+ * pane proves nothing. The silence gate comes first and costs no capture.
3996
4415
  */
3997
4416
  async paneReadinessForDelivery(windowId) {
3998
4417
  if (!this.isPaneIdleForDelivery(windowId))
@@ -4000,6 +4419,11 @@ export class Daemon extends EventEmitter {
4000
4419
  const probe = await this.probeBlockingDialog();
4001
4420
  if (probe.state !== "clear")
4002
4421
  return probe.state;
4422
+ const transient = await this.probeInputTransient();
4423
+ if (transient.state === "active")
4424
+ return "transient";
4425
+ if (transient.state === "unknown")
4426
+ return "unknown";
4003
4427
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4004
4428
  return "ready";
4005
4429
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4046,6 +4470,7 @@ export class Daemon extends EventEmitter {
4046
4470
  const deadline = Date.now() + timeoutMs;
4047
4471
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4048
4472
  let unknownStreak = 0;
4473
+ let transientDeadline = 0;
4049
4474
  for (;;) {
4050
4475
  const remaining = deadline - Date.now();
4051
4476
  if (remaining <= 0)
@@ -4055,6 +4480,26 @@ export class Daemon extends EventEmitter {
4055
4480
  return false;
4056
4481
  // The idle wait just resolved, so re-reading the silence gate would only
4057
4482
  // add a stale-cache race; ask the pane the remaining questions directly.
4483
+ const transient = await this.probeInputTransient();
4484
+ if (transient.state === "active") {
4485
+ unknownStreak = 0;
4486
+ transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4487
+ if (Date.now() >= transientDeadline) {
4488
+ this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
4489
+ return false;
4490
+ }
4491
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4492
+ continue;
4493
+ }
4494
+ transientDeadline = 0;
4495
+ if (transient.state === "unknown") {
4496
+ if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4497
+ this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
4498
+ return false;
4499
+ }
4500
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4501
+ continue;
4502
+ }
4058
4503
  if (bottomGated) {
4059
4504
  if (await this.isPaneReadyForDelivery(windowId))
4060
4505
  return true;
@@ -4090,15 +4535,16 @@ export class Daemon extends EventEmitter {
4090
4535
  * must fail rather than paste on top of the stranded text).
4091
4536
  */
4092
4537
  async submitStrandedInputIfAny(windowId) {
4093
- if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
4538
+ if (!this.tmux || !this.backend)
4094
4539
  return "idle";
4095
4540
  const prompt = this.backend.getBottomReadyPattern?.();
4096
4541
  if (!prompt)
4097
4542
  return "idle";
4543
+ const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
4098
4544
  // Pre-check outside the lock so the common case (verified clear) never
4099
4545
  // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
4100
4546
  // lock; an unreadable pane is re-examined under it, not waved through.
4101
- const pre = await this.strandedInputState(prompt);
4547
+ const pre = await this.strandedInputState(prompt, requireBottomReady);
4102
4548
  if (pre === "clear")
4103
4549
  return "idle";
4104
4550
  if (pre === "busy")
@@ -4114,7 +4560,7 @@ export class Daemon extends EventEmitter {
4114
4560
  // no-op at an empty prompt, but into a modal it is a confirmation.
4115
4561
  if (this.fatalStartupBlocked)
4116
4562
  return "busy";
4117
- const state = await this.strandedInputState(prompt);
4563
+ const state = await this.strandedInputState(prompt, requireBottomReady);
4118
4564
  if (state === "clear")
4119
4565
  return "idle";
4120
4566
  if (state !== "stranded")
@@ -4124,12 +4570,12 @@ export class Daemon extends EventEmitter {
4124
4570
  });
4125
4571
  }
4126
4572
  /**
4127
- * "stranded": an AgEnD message sits in the input row; "clear": the prompt is
4128
- * at the bottom with no stranded text; "busy": generating, or the prompt is
4129
- * not at the bottom (incl. a blank frame); "unknown": the pane could not be
4130
- * read. Only "clear" is positive evidence.
4573
+ * "stranded": an AgEnD message sits in the input row; "clear": no strand is
4574
+ * visible under the backend's readiness policy; "busy": generating, or (for
4575
+ * a bottom-gated backend) the prompt is not at the bottom; "unknown": the
4576
+ * pane could not be read. Only "clear" permits the next paste.
4131
4577
  */
4132
- async strandedInputState(prompt) {
4578
+ async strandedInputState(prompt, requireBottomReady) {
4133
4579
  let pane;
4134
4580
  try {
4135
4581
  pane = await this.tmux.capturePane();
@@ -4137,11 +4583,16 @@ export class Daemon extends EventEmitter {
4137
4583
  catch {
4138
4584
  return "unknown";
4139
4585
  }
4586
+ if (this.inputTransientInPane(pane))
4587
+ return "busy";
4140
4588
  if (this.backend?.getBusyPattern?.()?.test(pane))
4141
4589
  return "busy";
4142
4590
  if (strandedAgendMessageInInput(pane, prompt))
4143
4591
  return "stranded";
4144
- return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4592
+ // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4593
+ // does not: this preflight runs only after its ordinary silence gate and is
4594
+ // enabled solely to recover a positively identified old AgEnD strand.
4595
+ return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4145
4596
  }
4146
4597
  /**
4147
4598
  * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
@@ -4206,7 +4657,7 @@ export class Daemon extends EventEmitter {
4206
4657
  return true;
4207
4658
  if (this.controlClient.isIdle(windowId))
4208
4659
  return true;
4209
- if (this.backend?.hasPeriodicPaneRedraw?.() !== true || this.instanceState !== "idle")
4660
+ if (!this.canTrustRedrawIdleState() || this.instanceState !== "idle")
4210
4661
  return false;
4211
4662
  const lastOutputAt = this.controlClient.getLastOutputAt(windowId);
4212
4663
  if (lastOutputAt === undefined || !this.instanceStateMachine)
@@ -4218,7 +4669,7 @@ export class Daemon extends EventEmitter {
4218
4669
  waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
4219
4670
  if (!this.controlClient)
4220
4671
  return Promise.resolve(true);
4221
- if (this.backend?.hasPeriodicPaneRedraw?.() !== true) {
4672
+ if (!this.canTrustRedrawIdleState()) {
4222
4673
  return this.controlClient.waitUntilIdle(windowId, timeoutMs);
4223
4674
  }
4224
4675
  if (this.isPaneIdleForDelivery(windowId))
@@ -4237,6 +4688,20 @@ export class Daemon extends EventEmitter {
4237
4688
  }, timeoutMs);
4238
4689
  });
4239
4690
  }
4691
+ /**
4692
+ * Antigravity has always opted into pane-state delivery readiness because its
4693
+ * footer redraws while idle. Codex's structural idle proof is useful only
4694
+ * while the event-driven state monitor is actually running; without that
4695
+ * monitor there is no fresh proof to trust, so preserve the legacy
4696
+ * TmuxControlClient.waitUntilIdle fallback. This matters during isolated
4697
+ * startup/recovery paths as well as in tests: merely having a matcher must
4698
+ * never turn a missing observer into a 30-minute polling wait.
4699
+ */
4700
+ canTrustRedrawIdleState() {
4701
+ if (this.backend?.hasPeriodicPaneRedraw?.() === true)
4702
+ return true;
4703
+ return this.instanceStateMonitorActive && !!this.backend?.isPeriodicRedrawIdlePane;
4704
+ }
4240
4705
  /**
4241
4706
  * The write half of a delivery: paste → Enter → confirm, with retries.
4242
4707
  *
@@ -4245,6 +4710,8 @@ export class Daemon extends EventEmitter {
4245
4710
  * an invariant maintained by comments.
4246
4711
  */
4247
4712
  async sendDeliveryEnter(phase) {
4713
+ if (!(await this.waitForInputTransientToClear(phase)))
4714
+ return false;
4248
4715
  const sent = await this.tmux.sendSpecialKey("Enter");
4249
4716
  if (!sent) {
4250
4717
  this.logger.error({
@@ -4254,13 +4721,17 @@ export class Daemon extends EventEmitter {
4254
4721
  }
4255
4722
  return sent;
4256
4723
  }
4257
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4724
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4725
+ const signature = this.submissionSignature(formatted, submissionId);
4258
4726
  let windowId = initialWindowId;
4259
4727
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4260
4728
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4261
4729
  const maxAttempts = 3;
4262
4730
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4263
4731
  const pasteStartedAt = Date.now();
4732
+ // Read the pane BEFORE writing to it, so the submission check can require
4733
+ // evidence this paste ADDED rather than evidence that was already there.
4734
+ const pasteBaseline = await this.capturePaneEvidence(signature);
4264
4735
  const pasted = await this.tmux.pasteBuffer(formatted);
4265
4736
  if (!pasted) {
4266
4737
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4342,16 +4813,73 @@ export class Daemon extends EventEmitter {
4342
4813
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4343
4814
  if (handingOffToNativeQueue) {
4344
4815
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4345
- if (await this.nativeQueuePasteVisible(formatted)) {
4816
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4817
+ if (proof === "submitted") {
4346
4818
  if (status)
4347
4819
  this.emit("message_confirmed", status); // ✅ native queue accepted
4348
4820
  return true;
4349
4821
  }
4350
- // Silent loss: fall back once to the normal idle-gated path.
4351
- this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
4822
+ if (proof === "unverifiable") {
4823
+ // Our text is on the pane but cannot be shown to have left the input
4824
+ // row — either the backend exposes no input row (only /steer reaches
4825
+ // this path on such a backend) or the pre-paste pane was unreadable.
4826
+ // Re-pasting on that would deliver the message twice, so accept as
4827
+ // this path always has, and record that it is unproven.
4828
+ this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
4829
+ if (status)
4830
+ this.emit("message_confirmed", status); // ✅ (best-effort)
4831
+ return true;
4832
+ }
4833
+ // Not submitted (or not provably submitted): fall back once to the
4834
+ // normal idle-gated path, which is the only one with a full
4835
+ // confirmation ladder. "stranded" is the case that used to be a silent
4836
+ // ✅ — the text was visible, so the old check called it delivered.
4837
+ this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
4352
4838
  if (windowId && this.controlClient) {
4353
4839
  await this.controlClient.waitUntilIdle(windowId);
4354
4840
  }
4841
+ // Re-read before doing anything: the CLI had a whole turn to flush its
4842
+ // queue while we waited, and the right recovery differs per outcome.
4843
+ // Re-pasting a payload that is STILL in the input row appends it to
4844
+ // itself — paste-buffer writes at the cursor — and the next Enter then
4845
+ // submits the message twice over. That is the trap this fix would walk
4846
+ // into if it treated "not confirmed" as one state.
4847
+ const settled = await this.confirmSubmitted(signature, pasteBaseline);
4848
+ if (settled === "submitted") {
4849
+ this.logger.info("Native-queue message submitted itself while we waited for idle");
4850
+ if (status)
4851
+ this.emit("message_confirmed", status); // ✅
4852
+ return true;
4853
+ }
4854
+ if (settled === "stranded") {
4855
+ // The text is already there in full: it needs submitting, not sending
4856
+ // again. The prompt is back now, so an Enter can land.
4857
+ this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4858
+ const strandedAt = Date.now();
4859
+ if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4860
+ if (status)
4861
+ this.emit("message_failed", status); // ❌
4862
+ return false;
4863
+ }
4864
+ const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4865
+ if (afterEnter === "submitted") {
4866
+ if (status)
4867
+ this.emit("message_confirmed", status); // ✅
4868
+ return true;
4869
+ }
4870
+ // Deliberately NOT "or the pane went busy". This branch runs because
4871
+ // the text was still in the input row; if the recovery Enter was
4872
+ // swallowed too, the pane is busy with something else — a late redraw,
4873
+ // another turn's output — and treating that as proof re-confirms a
4874
+ // message nobody submitted. Output is corroboration; text sitting in
4875
+ // the input row is disqualifying, and disqualifying evidence wins.
4876
+ this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
4877
+ if (status)
4878
+ this.emit("message_failed", status); // ❌
4879
+ return false;
4880
+ }
4881
+ // "unproven": nothing of ours is on screen — the paste itself was lost,
4882
+ // so pasting it again cannot duplicate anything.
4355
4883
  const repasted = await this.tmux.pasteBuffer(formatted);
4356
4884
  if (!repasted) {
4357
4885
  this.logger.error({
@@ -4369,24 +4897,13 @@ export class Daemon extends EventEmitter {
4369
4897
  return false;
4370
4898
  }
4371
4899
  if (windowId && this.controlClient) {
4372
- let becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4373
- if (!becameBusy) {
4374
- this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
4375
- const retry2At = Date.now();
4376
- if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
4377
- if (status)
4378
- this.emit("message_failed", status); // ❌
4379
- return false;
4380
- }
4381
- becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
4382
- }
4383
- if (becameBusy) {
4900
+ if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
4384
4901
  if (status)
4385
4902
  this.emit("message_confirmed", status); // ✅
4386
4903
  return true;
4387
4904
  }
4388
4905
  }
4389
- else if (await this.nativeQueuePasteVisible(formatted)) {
4906
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4390
4907
  if (status)
4391
4908
  this.emit("message_confirmed", status); // ✅
4392
4909
  return true;
@@ -4426,17 +4943,17 @@ export class Daemon extends EventEmitter {
4426
4943
  }
4427
4944
  }
4428
4945
  else if (windowId && this.controlClient) {
4429
- let becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
4430
- if (!becameBusy) {
4431
- this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4432
- const retryAt = Date.now();
4433
- if (!(await this.sendDeliveryEnter("idle-to-busy-retry"))) {
4434
- if (status)
4435
- this.emit("message_failed", status); // ❌
4436
- return false;
4437
- }
4438
- becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4439
- }
4946
+ // An idle delivery used to be judged by output alone: anything the pane
4947
+ // printed after Enter counted as "submitted". Waking a paused instance
4948
+ // breaks that — the CLI's final redraw arrives right as we press Enter,
4949
+ // so a swallowed Enter is confirmed by the redraw it was lost to, and
4950
+ // the message is never in a turn (#745 left this path on the old rule).
4951
+ //
4952
+ // Where the input row can be read, it decides: text still sitting there
4953
+ // is disqualifying and no amount of output may override it. Where it
4954
+ // cannot (a backend with no prompt pattern), the output signal remains
4955
+ // exactly as before — this must not regress backends we cannot read.
4956
+ const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
4440
4957
  if (becameBusy) {
4441
4958
  if (status)
4442
4959
  this.emit("message_confirmed", status); // ✅
@@ -4467,31 +4984,260 @@ export class Daemon extends EventEmitter {
4467
4984
  return false;
4468
4985
  }
4469
4986
  /**
4470
- * True when a busy native-queue paste appears to have landed: Codex shows a
4471
- * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
4472
- * Used only to detect silent paste loss — not as a general ready check.
4987
+ * The single place a pasted message is judged submitted, shared by the
4988
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
4989
+ *
4990
+ * It replaces three divergent weak checks, the weakest of which accepted "the
4991
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
4992
+ * the failure it was meant to catch: text stranded in the input row is on
4993
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
4994
+ * anywhere. Evidence is weighed in order of what it can prove, and the
4995
+ * disqualifying evidence is checked first.
4473
4996
  */
4474
- async nativeQueuePasteVisible(formatted) {
4475
- if (!this.tmux)
4997
+ /**
4998
+ * Whether this backend exposes an input row we can actually read, i.e. whether
4999
+ * confirmSubmitted can ever return a verdict stronger than a guess.
5000
+ *
5001
+ * This is a property of the BACKEND, not of one delivery's verdict. Keying the
5002
+ * output-signal fallback off the verdict instead let codex — which can be read
5003
+ * — fall back to "the pane printed something" whenever a paste left no trace,
5004
+ * which is the same false confirmation one layer down.
5005
+ */
5006
+ canProveSubmission() {
5007
+ return !!this.backend?.getBottomReadyPattern?.();
5008
+ }
5009
+ /** Positive submission retires the startup transient guard for this spawn. */
5010
+ submittedProof() {
5011
+ if (this.inputTransientGuardGeneration === this.spawnGeneration) {
5012
+ this.inputTransientGuardGeneration = null;
5013
+ }
5014
+ return "submitted";
5015
+ }
5016
+ /**
5017
+ * Decide a submission after an Enter, with one bounded retry.
5018
+ *
5019
+ * Backends we can read must SHOW the text left the input row: neither a
5020
+ * strand nor a vanished paste may be upgraded to success by output, because
5021
+ * the output that would do the upgrading is usually the very redraw the Enter
5022
+ * was lost to. Backends we cannot read keep the legacy output signal — there
5023
+ * is nothing better available for them, and taking it away would fail every
5024
+ * one of their deliveries.
5025
+ *
5026
+ * One ladder, used by every post-Enter confirmation, so an escape hatch
5027
+ * cannot reappear in just one of them (it did: three copies, two of which
5028
+ * still confirmed on output alone).
5029
+ */
5030
+ async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
5031
+ if (!this.canProveSubmission()) {
5032
+ let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
5033
+ if (!busy) {
5034
+ this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
5035
+ const retryAt = Date.now();
5036
+ if (!(await this.sendDeliveryEnter(retryPhase)))
5037
+ return false;
5038
+ busy = await this.confirmBusyAfterEnter(windowId, retryAt);
5039
+ }
5040
+ return busy;
5041
+ }
5042
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
5043
+ return true;
5044
+ // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
5045
+ // how we got here. Then poll briefly rather than judging on one capture:
5046
+ // the key takes a moment to be digested, and a single early look would
5047
+ // report a failure for a message that did go out.
5048
+ this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
5049
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
4476
5050
  return false;
4477
- try {
4478
- const pane = await this.tmux.capturePane();
4479
- if (pane.includes("↳"))
5051
+ if (!(await this.sendDeliveryEnter(retryPhase)))
5052
+ return false;
5053
+ const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
5054
+ for (;;) {
5055
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4480
5056
  return true;
4481
- for (const line of formatted.split(/\r?\n/)) {
4482
- const t = line.trim();
4483
- if (t.length >= 8 && pane.includes(t))
4484
- return true;
5057
+ if (Date.now() >= deadline)
5058
+ return false;
5059
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5060
+ }
5061
+ }
5062
+ async confirmSubmitted(signature, baseline) {
5063
+ if (!this.tmux)
5064
+ return "unproven";
5065
+ let pane;
5066
+ try {
5067
+ pane = await this.tmux.capturePane();
5068
+ }
5069
+ catch {
5070
+ return "unproven";
5071
+ }
5072
+ // Without a way to tell the input row from the transcript, "the text is on
5073
+ // screen" cannot distinguish submitted from stranded — that ambiguity IS
5074
+ // the bug. Such a backend gets an explicit verdict rather than a guess:
5075
+ // "unverifiable" (something of ours appeared, but not provably out of the
5076
+ // input row) or "unproven" (nothing of ours appeared, so the paste itself
5077
+ // was lost). The caller keeps that backend's existing best-effort handling
5078
+ // for the first and recovers on the second, which is what it did before —
5079
+ // the fix here is for backends that CAN be read, not a new guess for those
5080
+ // that cannot.
5081
+ const prompt = this.backend?.getBottomReadyPattern?.();
5082
+ if (!prompt) {
5083
+ const seen = this.paneEvidence(pane, signature);
5084
+ if (signature.unique && seen.payload > 0)
5085
+ return "unverifiable";
5086
+ return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
5087
+ ? "unverifiable"
5088
+ : "unproven";
5089
+ }
5090
+ const after = this.paneEvidence(pane, signature);
5091
+ // 1. Disqualifying evidence, checked FIRST and never overridden by the
5092
+ // corroborating evidence below: our text is sitting in the input row, so
5093
+ // it was not submitted — whatever else is on screen.
5094
+ //
5095
+ // It must be OUR text. A unique signature settles that on its own; a
5096
+ // body-derived one cannot, so it is only attributed to this delivery
5097
+ // when the input row did not already show it before we pasted.
5098
+ // Otherwise an older stranded message with the same opening would be
5099
+ // read as ours, we would press Enter to "recover" it, and the turn IT
5100
+ // starts would confirm a message that never reached the pane.
5101
+ if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
5102
+ return "stranded";
5103
+ // 2. Positive evidence. A unique signature needs no baseline: no earlier
5104
+ // message can carry this delivery's message_id, so finding it outside
5105
+ // the input row is proof in itself — which also means a momentary
5106
+ // failure to read the pane BEFORE pasting cannot turn a delivered
5107
+ // message into a re-paste.
5108
+ if (signature.unique && after.payload > 0)
5109
+ return this.submittedProof();
5110
+ // 3. Otherwise the evidence must be NEW relative to the pane as it was
5111
+ // before we pasted: a queue marker left by an earlier message, or an
5112
+ // older transcript entry that opens the same way, are on screen either
5113
+ // way and would otherwise confirm a paste that never landed.
5114
+ if (baseline) {
5115
+ if (after.queued > baseline.queued)
5116
+ return this.submittedProof();
5117
+ if (after.payload > baseline.payload)
5118
+ return this.submittedProof();
5119
+ // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
5120
+ return "unproven";
5121
+ }
5122
+ // No baseline and nothing that identifies this delivery on its own. "The
5123
+ // paste was lost" is a guess, not a finding, and acting on it re-pastes a
5124
+ // message that may well have been submitted — so say the evidence is
5125
+ // missing instead of inventing a verdict.
5126
+ this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
5127
+ return "unverifiable";
5128
+ }
5129
+ /**
5130
+ * What the pane currently shows of a given message. Taken once before the
5131
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
5132
+ * echo rather than accepting whatever was already there.
5133
+ */
5134
+ async capturePaneEvidence(signature) {
5135
+ if (!this.tmux)
5136
+ return null;
5137
+ // Bounded re-probe: a capture that fails here costs the delivery its only
5138
+ // "before" picture, and a momentary failure should not decide anything.
5139
+ for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
5140
+ try {
5141
+ const pane = await this.tmux.capturePane();
5142
+ const evidence = this.paneEvidence(pane, signature);
5143
+ const prompt = this.backend?.getBottomReadyPattern?.();
5144
+ if (prompt && strandedAgendMessageInInput(pane, prompt)) {
5145
+ // Whatever we paste now lands after it, and one Enter submits both as
5146
+ // a single message. Nothing here can undo that; saying so beats
5147
+ // letting two messages silently merge.
5148
+ this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
5149
+ }
5150
+ return evidence;
4485
5151
  }
4486
- const compact = formatted.replace(/\s+/g, " ").trim();
4487
- if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
4488
- return true;
5152
+ catch {
5153
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
5154
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4489
5155
  }
5156
+ }
5157
+ // Still unreadable: confirmSubmitted falls back to evidence that stands on
5158
+ // its own (a unique message_id) and refuses to guess without it.
5159
+ this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
5160
+ return null;
5161
+ }
5162
+ paneEvidence(pane, signature) {
5163
+ const marker = this.backend?.getQueuedInputMarker?.();
5164
+ const prompt = this.backend?.getBottomReadyPattern?.();
5165
+ const input = prompt ? inputAreaText(pane, prompt) : null;
5166
+ return {
5167
+ queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
5168
+ payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
5169
+ strandedInput: input != null && inputShowsPastedText(input, signature.value),
5170
+ };
5171
+ }
5172
+ /**
5173
+ * What identifies THIS message on screen. The routing envelope carries a
5174
+ * message_id that no earlier transcript entry can share, so prefer it: the
5175
+ * body alone is not distinctive enough — short repeated instructions ("carry
5176
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
5177
+ * paste that never landed. System pastes carry no envelope and fall back to
5178
+ * the body signature, which the baseline comparison still ties to this paste.
5179
+ */
5180
+ submissionSignature(formatted, trustedId) {
5181
+ // The id must come from the message's own metadata, never from scanning the
5182
+ // rendered text: agents and users discuss message ids in the body all the
5183
+ // time ("check message_id: abc"), and a body-scanned value would match an
5184
+ // older transcript entry quoting the same thing — then be trusted as unique
5185
+ // and confirm a paste that never landed. Only the value AgEnD itself put in
5186
+ // the handoff metadata identifies THIS delivery.
5187
+ const id = trustedId?.trim();
5188
+ if (id)
5189
+ return { value: `message_id:${id}`, unique: true };
5190
+ return { value: pastedTextSignature(formatted), unique: false };
5191
+ }
5192
+ /**
5193
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
5194
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
5195
+ * at all, so a swallowed Enter left the notice sitting in the input row where
5196
+ * the next delivery submitted both as one message. Shares confirmSubmitted
5197
+ * with the delivery path — having one primitive is the point.
5198
+ *
5199
+ * Best effort by design: these notices must never fail a delivery or throw.
5200
+ * The caller already holds paneWriteLock.
5201
+ */
5202
+ async submitSystemPaste(text, label) {
5203
+ if (!this.tmux)
5204
+ return false;
5205
+ const signature = this.submissionSignature(text);
5206
+ const baseline = await this.capturePaneEvidence(signature);
5207
+ if (!(await this.tmux.pasteBuffer(text))) {
5208
+ this.logger.warn({ label }, "System paste failed to reach the pane");
4490
5209
  return false;
4491
5210
  }
4492
- catch {
5211
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5212
+ if (!(await this.sendDeliveryEnter(label)))
4493
5213
  return false;
5214
+ let proof = await this.confirmSubmitted(signature, baseline);
5215
+ if (proof === "unverifiable") {
5216
+ // No input row to read: this backend gets exactly what the old pasteText
5217
+ // path gave it, including the unconditional second Enter for queue-less
5218
+ // TUIs that swallow the first. Narrowing that to one Enter on the grounds
5219
+ // that "the text is visible" would be the very inference this change
5220
+ // exists to remove — visible text is also what a strand looks like.
5221
+ if (this.systemPasteOptions().retryEnter) {
5222
+ await new Promise(r => setTimeout(r, 1_000));
5223
+ await this.sendDeliveryEnter(`${label}-defensive-retry`);
5224
+ }
5225
+ return true; // best effort, exactly as before — nothing here is verified
4494
5226
  }
5227
+ if (proof !== "submitted") {
5228
+ // A bare Enter is a no-op at an empty prompt, so this is safe even if the
5229
+ // first one did land; when the text is still in the input row it is the
5230
+ // submit it never got. Re-pasting would append the text to itself.
5231
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5232
+ if (!(await this.sendDeliveryEnter(`${label}-retry`)))
5233
+ return false;
5234
+ proof = await this.confirmSubmitted(signature, baseline);
5235
+ }
5236
+ if (proof !== "submitted") {
5237
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
5238
+ return false;
5239
+ }
5240
+ return true;
4495
5241
  }
4496
5242
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4497
5243
  async recoverWindow() {
@@ -4584,14 +5330,21 @@ export class Daemon extends EventEmitter {
4584
5330
  if (this.socketSessionNames.get(socket) === this.name) {
4585
5331
  this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
4586
5332
  }
5333
+ // A tool invocation is not delivery evidence. Capture which obligation it
5334
+ // belongs to now, then settle it only after the adapter/fleet responds.
5335
+ // Explicitly mapped sibling sessions must not satisfy this instance's turn;
5336
+ // an unmapped socket is retained for legacy/tests where mcp_ready was not
5337
+ // observed before the first tool call.
5338
+ const sourceSession = this.socketSessionNames.get(socket);
5339
+ const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5340
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
5341
+ : null;
4587
5342
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
4588
5343
  const respond = (result, error) => {
4589
5344
  // A message that verifiably went out stands down the dead-MCP proxy reply
4590
5345
  // for this turn: the agent proved it can still speak for itself.
4591
5346
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
4592
- this.turnOutboundDelivered = true;
4593
- if (tool === "reply")
4594
- this.turnReplyDelivered = true;
5347
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
4595
5348
  }
4596
5349
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4597
5350
  if (!sent) {
@@ -4782,7 +5535,15 @@ export class Daemon extends EventEmitter {
4782
5535
  });
4783
5536
  return;
4784
5537
  }
4785
- const adapter = adapters[0];
5538
+ // Same rule as deliverDaemonReply: one adapter is unambiguous, several with
5539
+ // no bound world is a routing failure rather than a first-wins guess.
5540
+ const adapter = this.lastAdapterId
5541
+ ? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
5542
+ : (adapters.length === 1 ? adapters[0] : undefined);
5543
+ if (!adapter) {
5544
+ respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
5545
+ return;
5546
+ }
4786
5547
  if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
4787
5548
  respond(null, `Unknown tool: ${tool}`);
4788
5549
  }
@@ -4973,7 +5734,16 @@ export class Daemon extends EventEmitter {
4973
5734
  try {
4974
5735
  // Messages can arrive during a restart and be queued on pasteLock before the
4975
5736
  // snapshot lands; both write to the pane, so both go through the same lock.
4976
- 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()));
5737
+ 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.`;
5738
+ const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
5739
+ if (!injected) {
5740
+ // rotation-state.json was deleted when the prompt was built, so there is
5741
+ // nothing left to retry from: the restore is gone either way. Say so —
5742
+ // reporting it as injected is how a lost context restore stays invisible.
5743
+ this.logger.error("Session snapshot could not be submitted — session continues without context");
5744
+ this.emit("snapshot_failed", this.name);
5745
+ return;
5746
+ }
4977
5747
  this.logger.info("Injected session snapshot as first message");
4978
5748
  this.emit("snapshot_injected", this.name);
4979
5749
  }
@@ -4989,6 +5759,17 @@ export class Daemon extends EventEmitter {
4989
5759
  * into exactly the window this exists to close.
4990
5760
  */
4991
5761
  beginSpawn() {
5762
+ // A restarted CLI has no trustworthy turn edge for the process it replaced.
5763
+ // v1 deliberately does not persist obligations across restarts: missing one
5764
+ // warning is safer than treating the replacement's startup idle as the old
5765
+ // turn ending and injecting a stale recovery prompt.
5766
+ this.turnReplyGuard.reset();
5767
+ if (this.spawnDepth === 0) {
5768
+ this.spawnGeneration++;
5769
+ this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
5770
+ ? this.spawnGeneration
5771
+ : null;
5772
+ }
4992
5773
  this.spawnDepth++;
4993
5774
  this.spawning = true;
4994
5775
  if (!this.spawnSettled) {
@@ -5043,6 +5824,69 @@ export class Daemon extends EventEmitter {
5043
5824
  }
5044
5825
  }
5045
5826
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
5827
+ /**
5828
+ * Positive proof from the CLI that there is no conversation left to resume.
5829
+ *
5830
+ * Only this may justify abandoning a session. A startup that merely ran out of
5831
+ * budget proves nothing: a healthy cold start on a loaded host looks exactly
5832
+ * the same, which is how 9 instances silently lost their history.
5833
+ *
5834
+ * Deliberately does NOT match a bare "--continue": the pane text can mention
5835
+ * the flag (a usage error, an echoed invocation) without the CLI ever saying
5836
+ * the conversation is gone.
5837
+ */
5838
+ paneSaysNoConversation(paneText) {
5839
+ if (!paneText)
5840
+ return false;
5841
+ return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
5842
+ .test(paneText);
5843
+ }
5844
+ /** How many consecutive startups have failed without proving the session is gone. */
5845
+ unprovenResumeFailures = 0;
5846
+ /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
5847
+ static MAX_UNPROVEN_RESUME_FAILURES = 3;
5848
+ /**
5849
+ * Set the session aside instead of deleting it.
5850
+ *
5851
+ * The whole defect being fixed here is silent, irreversible loss of a user's
5852
+ * conversation. Renaming costs nothing and turns a wrong call into something
5853
+ * recoverable: the id is still on disk under a dated name.
5854
+ */
5855
+ setSessionAside(reason) {
5856
+ const sidFile = join(this.instanceDir, "session-id");
5857
+ try {
5858
+ if (!existsSync(sidFile))
5859
+ return;
5860
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
5861
+ renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
5862
+ this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
5863
+ this.pruneAbandonedSessions();
5864
+ }
5865
+ catch (err) {
5866
+ // Never let bookkeeping block a spawn; falling back to the old behaviour
5867
+ // is still better than not starting.
5868
+ this.logger.warn({ err: err.message }, "Could not set session aside");
5869
+ try {
5870
+ unlinkSync(sidFile);
5871
+ }
5872
+ catch { /* may not exist */ }
5873
+ }
5874
+ }
5875
+ /** Keep the newest few set-aside sessions; they are insurance, not an archive. */
5876
+ pruneAbandonedSessions(keep = 5) {
5877
+ try {
5878
+ const files = readdirSync(this.instanceDir)
5879
+ .filter((f) => f.startsWith("session-id.abandoned-"))
5880
+ .sort();
5881
+ for (const f of files.slice(0, Math.max(0, files.length - keep))) {
5882
+ try {
5883
+ unlinkSync(join(this.instanceDir, f));
5884
+ }
5885
+ catch { /* best effort */ }
5886
+ }
5887
+ }
5888
+ catch { /* best effort */ }
5889
+ }
5046
5890
  async spawnClaudeWindow() {
5047
5891
  this.beginSpawn();
5048
5892
  let resumedSuccessfully = false;
@@ -5066,7 +5910,7 @@ export class Daemon extends EventEmitter {
5066
5910
  // first miss is usually slowness, not a broken session.
5067
5911
  await this.noteStartupPaneForBackendOutage();
5068
5912
  await this.failStartupIfBackendUnreachable();
5069
- if (this.backend.retriesResumeOnStartupFailure?.() === true) {
5913
+ if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
5070
5914
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
5071
5915
  await this.killProcessTree();
5072
5916
  await this.tmux.killWindow();
@@ -5078,16 +5922,39 @@ export class Daemon extends EventEmitter {
5078
5922
  }
5079
5923
  }
5080
5924
  if (!alive) {
5081
- // Resume (or a fresh start) failed for a reason we do not recognise as an
5082
- // outage (stale --resume, crash, rate limit, etc.).
5083
- // Clean slate: clear session-id, skip resume, and retry once.
5084
- this.logger.warn("CLI startup failed — clearing session-id and retrying without resume");
5085
- const sidFile = join(this.instanceDir, "session-id");
5086
- try {
5087
- unlinkSync(sidFile);
5925
+ // The session may only be abandoned on positive proof that there is
5926
+ // nothing to resume. Running out of budget is not proof: on a loaded host
5927
+ // a healthy cold start misses the same deadline as a broken session, and
5928
+ // treating the two alike is what silently discarded users' conversations.
5929
+ if (attemptedResume) {
5930
+ let paneText;
5931
+ try {
5932
+ paneText = await this.tmux?.capturePaneWithHistory(50);
5933
+ }
5934
+ catch { /* pane may be gone */ }
5935
+ const proven = this.paneSaysNoConversation(paneText);
5936
+ if (!proven) {
5937
+ this.unprovenResumeFailures++;
5938
+ if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
5939
+ // Keep the session and fail this attempt; the fleet retries with
5940
+ // backoff, which is also how the backend-outage path behaves.
5941
+ await this.killProcessTree();
5942
+ await this.tmux.killWindow();
5943
+ throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
5944
+ + "— session kept, will retry");
5945
+ }
5946
+ // Escape hatch: a genuinely broken session must still recover. Say so
5947
+ // out loud — this start does NOT continue the previous conversation.
5948
+ this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
5949
+ + "The previous conversation is NOT continued; its context is lost to this session.");
5950
+ this.emit("context_lost", this.name, "resume_repeatedly_failed");
5951
+ }
5952
+ this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
5953
+ this.skipResume = true;
5088
5954
  }
5089
- catch { /* may not exist */ }
5090
- this.skipResume = true;
5955
+ // A fresh start that also failed retries once, as before. It never clears
5956
+ // a session: nothing about a failed fresh launch says the stored
5957
+ // conversation is unusable.
5091
5958
  await this.killProcessTree();
5092
5959
  await this.tmux.killWindow();
5093
5960
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
@@ -5309,9 +6176,16 @@ export class Daemon extends EventEmitter {
5309
6176
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
5310
6177
  // delivery is already in flight or queued. Without the lock the notice and
5311
6178
  // that delivery race into the same pane.
5312
- await this.paneWriteLock.run(async () => {
5313
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
5314
- });
6179
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
6180
+ if (!told) {
6181
+ // Recording the new instructions here would mark the agent as told about
6182
+ // a notice it never received, and every later restart would then skip
6183
+ // the reload. Leave the snapshot behind and hand it to the deferred
6184
+ // notice, which fires on the next real message.
6185
+ this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
6186
+ this.pendingInstructionsNotice = true;
6187
+ return;
6188
+ }
5315
6189
  // Record the value the agent has now been told about so the next
5316
6190
  // unchanged restart skips the reload.
5317
6191
  try {
@@ -5495,6 +6369,7 @@ export class Daemon extends EventEmitter {
5495
6369
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
5496
6370
  let cleanReadyPolls = 0;
5497
6371
  let lastDialog = null;
6372
+ let lastTransient = null;
5498
6373
  let captureFailures = 0;
5499
6374
  let attempts = 0;
5500
6375
  do {
@@ -5516,6 +6391,18 @@ export class Daemon extends EventEmitter {
5516
6391
  continue;
5517
6392
  }
5518
6393
  try {
6394
+ // Some TUIs expose a real-looking input row before they accept Enter.
6395
+ // This phase clears by itself: never send a key, and let its structural
6396
+ // current-screen matcher outrank the otherwise-valid ready pattern.
6397
+ const transient = this.inputTransientInPane(pane);
6398
+ if (transient) {
6399
+ cleanReadyPolls = 0;
6400
+ lastDialog = null;
6401
+ lastTransient = transient;
6402
+ await sleep();
6403
+ continue;
6404
+ }
6405
+ lastTransient = null;
5519
6406
  // Try each startup dialog pattern before checking ready state
5520
6407
  let matched = false;
5521
6408
  lastDialog = null;
@@ -5643,7 +6530,10 @@ export class Daemon extends EventEmitter {
5643
6530
  await sleep();
5644
6531
  } while (remaining() > 0);
5645
6532
  // Budget exhausted. Never silently: say which way it went.
5646
- if (lastDialog) {
6533
+ if (lastTransient) {
6534
+ this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
6535
+ }
6536
+ else if (lastDialog) {
5647
6537
  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");
5648
6538
  }
5649
6539
  else {
@@ -5659,12 +6549,21 @@ export class Daemon extends EventEmitter {
5659
6549
  updateLastChat(chatId, threadId, adapterId) {
5660
6550
  if (!chatId)
5661
6551
  return;
6552
+ const chatChanged = this.lastChatId !== chatId;
5662
6553
  this.lastChatId = chatId;
5663
6554
  // An unthreaded inbound must clear a previous topic rather than leaking it
5664
6555
  // into the next reply target.
5665
6556
  this.lastThreadId = threadId || undefined;
5666
6557
  if (adapterId)
5667
6558
  this.lastAdapterId = adapterId;
6559
+ else if (chatChanged) {
6560
+ // A NEW chat with no adapter named alongside it: the old adapter belonged
6561
+ // to the old chat, and keeping it asserts a pairing nobody supplied. That
6562
+ // is how one platform's chat id ends up being sent through another
6563
+ // platform's bot. Unknown is recoverable (a single-adapter fleet is
6564
+ // unambiguous anyway); a confidently wrong world is not.
6565
+ this.lastAdapterId = undefined;
6566
+ }
5668
6567
  try {
5669
6568
  writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
5670
6569
  }