@songsid/agend 2.1.5-beta.8 → 2.1.5

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 +25 -1
  5. package/dist/backend/codex.js +76 -0
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend/kiro.js +30 -2
  8. package/dist/backend/kiro.js.map +1 -1
  9. package/dist/backend/types.d.ts +48 -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 +143 -21
  29. package/dist/daemon.js +969 -185
  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 +185 -3
  37. package/dist/fleet-manager.js +782 -127
  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"
@@ -377,15 +381,31 @@ export class PendingWorkTracker {
377
381
  // An async pane poll can finish after a newer inbound. Do not let its stale
378
382
  // observation clear work which had not arrived when the pane was captured.
379
383
  if (now < this.lastInboundAt)
380
- return;
384
+ return false;
381
385
  this.lastIdleAt = now;
382
386
  this.lastIdleOrder = ++this.sequence;
387
+ return true;
383
388
  }
384
389
  hasPendingWork() {
385
390
  return this.lastInboundOrder > this.lastIdleOrder;
386
391
  }
387
392
  }
388
393
  const NORMAL_ENTER_SETTLE_MS = 500;
394
+ /** How long to keep looking for proof after a retry Enter before failing it. */
395
+ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
396
+ const POST_ENTER_PROOF_POLL_MS = 250;
397
+ /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
398
+ const BASELINE_CAPTURE_ATTEMPTS = 3;
399
+ const BASELINE_CAPTURE_RETRY_MS = 150;
400
+ /** Occurrences of `needle` in `haystack` (plain text, no regex semantics). */
401
+ function countOccurrences(haystack, needle) {
402
+ if (!needle)
403
+ return 0;
404
+ let n = 0;
405
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length))
406
+ n++;
407
+ return n;
408
+ }
389
409
  /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
390
410
  const BOTTOM_READY_POLL_MS = 250;
391
411
  /** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
@@ -411,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
411
431
  }
412
432
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
413
433
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
434
+ /** A passive startup phase must clear within this bound before an Enter is sent. */
435
+ const INPUT_TRANSIENT_WAIT_MS = 30_000;
436
+ const INPUT_TRANSIENT_POLL_MS = 250;
414
437
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
415
438
  const STRANDED_INPUT_MAX_ROUNDS = 3;
416
439
  const FIRST_ENTER_SETTLE_MS = 1_750;
@@ -837,6 +860,14 @@ export class Daemon extends EventEmitter {
837
860
  spawnSettled = null;
838
861
  resolveSpawnSettled = null;
839
862
  spawnDepth = 0;
863
+ /** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
864
+ spawnGeneration = 0;
865
+ /**
866
+ * Keeps passive startup-transient checks alive through the first positively
867
+ * submitted post-spawn message. Without this tail, a transient painted just
868
+ * after the startup scan can still swallow that message's Enter.
869
+ */
870
+ inputTransientGuardGeneration = null;
840
871
  skipResume = false;
841
872
  startupAborted = false;
842
873
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -870,19 +901,14 @@ export class Daemon extends EventEmitter {
870
901
  instanceStateMonitorActive = false;
871
902
  sessionCheckpointWarningEmitted = false;
872
903
  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;
904
+ // One generation-scoped source of truth for human-turn reply completion.
905
+ // It also supplies the older dead-MCP/malformed-call recovery paths, so those
906
+ // paths cannot disagree about whether the turn already spoke to the channel.
907
+ turnReplyGuard = new TurnReplyGuard();
883
908
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
884
909
  lastMalformedToolCallSignature;
885
910
  proxyReplySeq = 0;
911
+ lastReplyDropWarningAt = 0;
886
912
  autoPauseController;
887
913
  pauseRequested = false;
888
914
  /**
@@ -1059,6 +1085,33 @@ export class Daemon extends EventEmitter {
1059
1085
  static errorPatternKey(ep) {
1060
1086
  return `${ep.type}:${ep.pattern.source}`;
1061
1087
  }
1088
+ /**
1089
+ * Undo the auth suspicion a pattern match armed, once the backend's token-free
1090
+ * probe has said the credentials are fine.
1091
+ *
1092
+ * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1093
+ * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1094
+ * daemon permanently suspicious of an auth failure that never existed. That
1095
+ * state is not inert: authFailureUnresolved suppresses the stuck/hang
1096
+ * notification and holds MCP auto-restart (a later real MCP death is then read
1097
+ * as already-confirmed auth trouble rather than re-verified), and the recovery
1098
+ * gate suppresses further error detection until a ready pattern shows up.
1099
+ *
1100
+ * Only what an auth match armed is rolled back. If some other pattern has
1101
+ * since armed the recovery gate, that one is still live and stays.
1102
+ */
1103
+ clearSuspectedAuthFailure() {
1104
+ if (!this.authFailureUnresolved && !this.loginScreenReported)
1105
+ return false;
1106
+ this.authFailureUnresolved = false;
1107
+ this.loginScreenReported = false;
1108
+ if (this.lastDetectedErrorType === "auth_error") {
1109
+ this.clearErrorRecoveryGate();
1110
+ this.lastDetectedErrorType = null;
1111
+ }
1112
+ this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1113
+ return true;
1114
+ }
1062
1115
  clearErrorRecoveryGate() {
1063
1116
  this.errorWaitingForRecovery = false;
1064
1117
  this.errorDetectedAt = 0;
@@ -1899,12 +1952,8 @@ export class Daemon extends EventEmitter {
1899
1952
  // --continue again and crash in the same way → loop. Clear the session id
1900
1953
  // and skip resume so the next spawn starts fresh. (skipResume also stops
1901
1954
  // 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 */ }
1955
+ if (this.paneSaysNoConversation(lastOutput)) {
1956
+ this.setSessionAside("cli_reported_no_conversation");
1908
1957
  this.skipResume = true;
1909
1958
  }
1910
1959
  // Append to crash history
@@ -2484,6 +2533,10 @@ export class Daemon extends EventEmitter {
2484
2533
  }
2485
2534
  async stop() {
2486
2535
  this.logger.info("Stopping daemon instance");
2536
+ this.turnReplyGuard.reset();
2537
+ // Invalidate any bounded pre-Enter wait from the process generation being
2538
+ // stopped. It must fail closed, not press Enter in a replacement pane.
2539
+ this.inputTransientGuardGeneration = null;
2487
2540
  this.freezeRuntimeMonitors();
2488
2541
  this.pendingIpcRequests.clear();
2489
2542
  if (this.adapter)
@@ -2777,7 +2830,7 @@ export class Daemon extends EventEmitter {
2777
2830
  // Only a transition back to idle completes pending work. Repeated idle
2778
2831
  // observations between enqueue and paste must not clear a newer inbound.
2779
2832
  if (snapshot.state === "idle" && previous !== "idle") {
2780
- this.pendingWork.recordIdle(snapshot.observedAt);
2833
+ const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
2781
2834
  // The turn is over. A transcript can end on a tool_use with no matching
2782
2835
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2783
2836
  // the last tool pinned to the progress line for the rest of the session.
@@ -2785,7 +2838,8 @@ export class Daemon extends EventEmitter {
2785
2838
  this.resetToolProgress();
2786
2839
  // Must run before the mcpRestartPending branch below: the pane text is the
2787
2840
  // only copy of the answer, and the revival restart is about to clear it.
2788
- this.maybeProxyReplyOnTurnEnd(pane);
2841
+ if (acceptedIdle)
2842
+ this.maybeProxyReplyOnTurnEnd(pane);
2789
2843
  }
2790
2844
  if (snapshot.state !== previous) {
2791
2845
  this.logger.info({
@@ -2836,13 +2890,17 @@ export class Daemon extends EventEmitter {
2836
2890
  // a task result, and a stale one (sol's review of #515).
2837
2891
  if (meta.from_instance || !meta.chat_id)
2838
2892
  return;
2839
- this.turnHadInbound = true;
2840
- this.turnOutboundDelivered = false;
2841
- this.turnReplyDelivered = false;
2842
- this.turnCorrelationId = meta.correlation_id || undefined;
2843
2893
  // The last non-empty line of what we pasted: everything on screen after it
2844
2894
  // is the agent's own output.
2845
- this.turnInboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2895
+ const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2896
+ this.turnReplyGuard.arm({
2897
+ adapterId: meta.adapter_id || undefined,
2898
+ chatId: meta.chat_id,
2899
+ threadId: meta.thread_id || undefined,
2900
+ messageId: meta.message_id || undefined,
2901
+ correlationId: meta.correlation_id || undefined,
2902
+ inboundMarker,
2903
+ });
2846
2904
  }
2847
2905
  /**
2848
2906
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -2852,56 +2910,198 @@ export class Daemon extends EventEmitter {
2852
2910
  * here (edge-triggered, then reset) is what makes it at most once per turn.
2853
2911
  */
2854
2912
  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)
2913
+ const turn = this.turnReplyGuard.snapshot();
2914
+ if (!turn || this.isPaused)
2866
2915
  return;
2916
+ if (turn.replyDelivered) {
2917
+ if (turn.phase === "recovering") {
2918
+ this.emit("reply_drop_recovered", {
2919
+ name: this.name,
2920
+ correlationId: turn.target.correlationId,
2921
+ generation: turn.generation,
2922
+ });
2923
+ }
2924
+ this.turnReplyGuard.complete(turn.generation);
2925
+ return;
2926
+ }
2927
+ // A second idle edge ends the one permitted recovery turn. Never create a
2928
+ // third turn or guess at terminal text; make the failure visible instead.
2929
+ if (turn.phase === "recovering") {
2930
+ this.turnReplyGuard.complete(turn.generation);
2931
+ this.reportUnrecoveredReplyDrop(turn);
2932
+ return;
2933
+ }
2867
2934
  // Claude sometimes prints a broken XML tool call as plain text and returns
2868
2935
  // idle without ever invoking `reply`. This is independent of MCP liveness:
2869
2936
  // the model malformed the call before the server could receive it.
2870
- if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2871
- const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2937
+ if (this.isClaudeCodeBackend() && pane) {
2938
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
2872
2939
  if (malformed) {
2873
2940
  const stale = malformed.signature === this.lastMalformedToolCallSignature;
2874
2941
  this.lastMalformedToolCallSignature = malformed.signature;
2875
2942
  if (!stale) {
2876
- const recovered = malformed.text != null;
2877
- this.logger.warn({ correlationId, recovered }, recovered
2943
+ this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
2878
2944
  ? "Malformed Claude tool call detected — attempting to recover reply text"
2879
2945
  : "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);
2946
+ if (malformed.text) {
2947
+ this.queueMalformedReplyRecovery(turn, malformed.text);
2948
+ }
2949
+ else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
2950
+ this.emit("malformed_tool_call", {
2951
+ name: this.name,
2952
+ correlationId: turn.target.correlationId,
2953
+ recovered: false,
2954
+ recoveryStarted: true,
2955
+ });
2956
+ this.startReplyRecovery(turn, "malformed_call");
2957
+ }
2958
+ else {
2959
+ this.emit("malformed_tool_call", {
2960
+ name: this.name,
2961
+ correlationId: turn.target.correlationId,
2962
+ recovered: false,
2963
+ });
2964
+ this.turnReplyGuard.complete(turn.generation);
2965
+ }
2966
+ }
2967
+ else {
2968
+ this.turnReplyGuard.complete(turn.generation);
2883
2969
  }
2884
2970
  // Never let the broader dead-MCP fallback relay the XML/chrome too.
2885
2971
  return;
2886
2972
  }
2887
2973
  }
2888
- if (delivered)
2974
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
2975
+ // tools still suppress that proxy, but only a delivered `reply` satisfies
2976
+ // the human-facing completion guard below.
2977
+ if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
2978
+ && mcpServerState(this.instanceDir).state === "dead") {
2979
+ this.turnReplyGuard.complete(turn.generation);
2980
+ void this.sendProxyReply(pane, turn.target);
2889
2981
  return;
2890
- // Opt-in: raw pane text can carry secrets past the regex redaction.
2891
- if (this.config.mcp_proxy_reply !== true)
2982
+ }
2983
+ if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
2984
+ this.turnReplyGuard.complete(turn.generation);
2892
2985
  return;
2893
- // "dead" only: unknown means not started or exited cleanly — never proxy on it.
2894
- if (mcpServerState(this.instanceDir).state !== "dead")
2986
+ }
2987
+ if (turn.replyAttempted) {
2988
+ // A provider timeout can be "applied, then timed out". Retrying it would
2989
+ // risk a duplicate; report the unknown result and stop here.
2990
+ this.turnReplyGuard.complete(turn.generation);
2991
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
2895
2992
  return;
2896
- void this.sendProxyReply(pane, inboundMarker, correlationId);
2993
+ }
2994
+ this.startReplyRecovery(turn, "no_valid_call");
2897
2995
  }
2898
2996
  isClaudeCodeBackend() {
2899
2997
  return this.runtimeIdentity?.backend === "claude-code"
2900
2998
  || this.config.backend === "claude-code"
2901
2999
  || this.backend?.binaryName === "claude";
2902
3000
  }
3001
+ replyCompletionGuardEnabled() {
3002
+ return this.config.reply_completion_guard !== false
3003
+ && this.backend?.replyCompletionGuard === true;
3004
+ }
3005
+ startReplyRecovery(turn, reason) {
3006
+ if (!this.turnReplyGuard.beginRecovery(turn.generation))
3007
+ return;
3008
+ this.emit("reply_drop_detected", {
3009
+ name: this.name,
3010
+ correlationId: turn.target.correlationId,
3011
+ generation: turn.generation,
3012
+ reason,
3013
+ recoveryStarted: true,
3014
+ });
3015
+ void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
3016
+ this.queueReplyRecoveryPrompt(turn);
3017
+ }
3018
+ reportReplyDropWithoutRetry(turn, reason) {
3019
+ this.emit("reply_drop_detected", {
3020
+ name: this.name,
3021
+ correlationId: turn.target.correlationId,
3022
+ generation: turn.generation,
3023
+ reason,
3024
+ recoveryStarted: false,
3025
+ });
3026
+ void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
3027
+ }
3028
+ queueReplyRecoveryPrompt(turn) {
3029
+ const deliveryEpoch = this.deliveryEpoch;
3030
+ this.pasteQueueDepth++;
3031
+ this.pasteLock = this.pasteLock.then(async () => {
3032
+ try {
3033
+ const current = this.turnReplyGuard.snapshot();
3034
+ if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3035
+ return;
3036
+ if (current.replyDelivered) {
3037
+ this.turnReplyGuard.complete(turn.generation);
3038
+ this.emit("reply_drop_recovered", {
3039
+ name: this.name,
3040
+ correlationId: current.target.correlationId,
3041
+ generation: current.generation,
3042
+ });
3043
+ return;
3044
+ }
3045
+ const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
3046
+ deliveryEpoch,
3047
+ });
3048
+ if (!delivered) {
3049
+ this.turnReplyGuard.complete(turn.generation);
3050
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3051
+ }
3052
+ }
3053
+ finally {
3054
+ this.pasteQueueDepth--;
3055
+ }
3056
+ }).catch(err => {
3057
+ this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
3058
+ this.turnReplyGuard.complete(turn.generation);
3059
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3060
+ });
3061
+ }
3062
+ queueMalformedReplyRecovery(turn, text) {
3063
+ // Serialize the delivery decision ahead of any new pane input. If direct
3064
+ // delivery is positively acknowledged, there is no recovery turn. If its
3065
+ // outcome is unknown, do not ask the model to duplicate it.
3066
+ const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
3067
+ this.pasteQueueDepth++;
3068
+ this.pasteLock = this.pasteLock.then(async () => {
3069
+ try {
3070
+ const delivered = await delivery;
3071
+ this.emit("malformed_tool_call", {
3072
+ name: this.name,
3073
+ correlationId: turn.target.correlationId,
3074
+ recovered: delivered,
3075
+ });
3076
+ this.turnReplyGuard.complete(turn.generation);
3077
+ if (!delivered)
3078
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3079
+ }
3080
+ finally {
3081
+ this.pasteQueueDepth--;
3082
+ }
3083
+ }).catch(err => {
3084
+ this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
3085
+ this.turnReplyGuard.complete(turn.generation);
3086
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3087
+ });
3088
+ }
3089
+ reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
3090
+ this.emit("reply_drop_unrecovered", {
3091
+ name: this.name,
3092
+ correlationId: turn.target.correlationId,
3093
+ generation: turn.generation,
3094
+ reason,
3095
+ });
3096
+ const now = Date.now();
3097
+ if (this.lastReplyDropWarningAt !== 0
3098
+ && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
3099
+ return;
3100
+ this.lastReplyDropWarningAt = now;
3101
+ void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
3102
+ }
2903
3103
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2904
- async sendProxyReply(pane, inboundMarker, correlationId) {
3104
+ async sendProxyReply(pane, target) {
2905
3105
  try {
2906
3106
  // The idle capture normally hands its pane in; fall back only when the
2907
3107
  // edge came from a path without one.
@@ -2909,74 +3109,110 @@ export class Daemon extends EventEmitter {
2909
3109
  pane = await this.tmux?.capturePane();
2910
3110
  if (!pane)
2911
3111
  return;
2912
- const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
3112
+ const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
2913
3113
  if (!text) {
2914
3114
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
2915
3115
  return;
2916
3116
  }
2917
3117
  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");
3118
+ if (target.correlationId)
3119
+ body += `\n\n(correlation_id: ${target.correlationId})`;
3120
+ this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
3121
+ const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
3122
+ if (delivered)
3123
+ this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
2923
3124
  }
2924
3125
  catch (err) {
2925
3126
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2926
3127
  }
2927
3128
  }
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
3129
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2938
- deliverDaemonReply(body, requestPrefix, logLabel) {
3130
+ deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
2939
3131
  const args = { text: body };
2940
- if (this.lastChatId) {
2941
- args.chat_id = this.lastChatId;
2942
- if (this.lastThreadId)
2943
- args.thread_id = this.lastThreadId;
3132
+ const chatId = target?.chatId ?? this.lastChatId;
3133
+ const threadId = target?.threadId ?? this.lastThreadId;
3134
+ const adapterId = target?.adapterId ?? this.lastAdapterId;
3135
+ if (chatId) {
3136
+ args.chat_id = chatId;
3137
+ if (threadId)
3138
+ args.thread_id = threadId;
2944
3139
  }
2945
3140
  const adapters = this.messageBus.getAllAdapters();
2946
3141
  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`);
3142
+ // With one adapter there is nothing to choose and adapters[0] is exact.
3143
+ // With several, "no adapter named" is a routing failure, not a default:
3144
+ // picking the first sends one world's chat id through another world's bot.
3145
+ const adapter = adapterId
3146
+ ? this.messageBus.getAdapter(adapterId)
3147
+ : (adapters.length === 1 ? adapters[0] : undefined);
3148
+ if (!adapter) {
3149
+ this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
3150
+ return Promise.resolve(false);
3151
+ }
3152
+ return new Promise(resolve => {
3153
+ let settled = false;
3154
+ const finish = (delivered, error) => {
3155
+ if (settled)
3156
+ return;
3157
+ settled = true;
3158
+ clearTimeout(timeout);
3159
+ if (delivered)
3160
+ this.logger.info(`${logLabel} delivered`);
3161
+ else
3162
+ this.logger.error({ error }, `${logLabel} failed`);
3163
+ resolve(delivered);
3164
+ };
3165
+ const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
3166
+ timeout.unref?.();
3167
+ try {
3168
+ if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
3169
+ finish(false, "reply route unavailable");
3170
+ }
3171
+ }
3172
+ catch {
3173
+ finish(false, "adapter reply threw before returning a promise");
3174
+ }
2952
3175
  });
2953
- return;
2954
3176
  }
2955
3177
  if (!this.ipcServer) {
2956
3178
  this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2957
- return;
3179
+ return Promise.resolve(false);
2958
3180
  }
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,
3181
+ return new Promise(resolve => {
3182
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
3183
+ const timeout = setTimeout(() => {
3184
+ this.pendingIpcRequests.delete(fleetReqId);
3185
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
3186
+ resolve(false);
3187
+ }, daemonBudgetMs("reply"));
3188
+ timeout.unref?.();
3189
+ // Register before broadcast: an in-process/fast adapter can answer in the
3190
+ // same tick, and a waiter installed afterwards would miss that response.
3191
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3192
+ clearTimeout(timeout);
3193
+ const delivered = !respMsg.error && respMsg.result != null;
3194
+ if (!delivered)
3195
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3196
+ else
3197
+ this.logger.info(`${logLabel} delivered`);
3198
+ resolve(delivered);
3199
+ });
3200
+ try {
3201
+ this.ipcServer.broadcast({
3202
+ type: "fleet_outbound",
3203
+ tool: "reply",
3204
+ args,
3205
+ fleetRequestId: fleetReqId,
3206
+ adapterId,
3207
+ statusOnly,
3208
+ });
3209
+ }
3210
+ catch {
3211
+ clearTimeout(timeout);
3212
+ this.pendingIpcRequests.delete(fleetReqId);
3213
+ this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
3214
+ resolve(false);
3215
+ }
2980
3216
  });
2981
3217
  }
2982
3218
  clearInstanceStateIdleTimer() {
@@ -3323,6 +3559,12 @@ export class Daemon extends EventEmitter {
3323
3559
  if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
3324
3560
  this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
3325
3561
  }
3562
+ if (typeof update.reply_completion_guard === "boolean") {
3563
+ this.config.reply_completion_guard = update.reply_completion_guard;
3564
+ }
3565
+ else if (update.reply_completion_guard === null) {
3566
+ delete this.config.reply_completion_guard;
3567
+ }
3326
3568
  if (typeof update.mcp_proxy_reply === "boolean")
3327
3569
  this.config.mcp_proxy_reply = update.mcp_proxy_reply;
3328
3570
  else if (update.mcp_proxy_reply === null)
@@ -3543,7 +3785,7 @@ export class Daemon extends EventEmitter {
3543
3785
  await this.wake();
3544
3786
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3545
3787
  return;
3546
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3788
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3547
3789
  this.markTurnStarted(meta, formatted);
3548
3790
  }
3549
3791
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3581,7 +3823,7 @@ export class Daemon extends EventEmitter {
3581
3823
  await this.wake();
3582
3824
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3583
3825
  return;
3584
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3826
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3585
3827
  this.markTurnStarted(meta, formatted);
3586
3828
  }
3587
3829
  }).catch(err => {
@@ -3633,8 +3875,7 @@ export class Daemon extends EventEmitter {
3633
3875
  this.pasteLock = this.pasteLock.then(async () => {
3634
3876
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3635
3877
  return;
3636
- if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
3637
- this.markTurnStarted(meta, rawText);
3878
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
3638
3879
  }).catch(err => {
3639
3880
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
3640
3881
  });
@@ -3677,7 +3918,7 @@ export class Daemon extends EventEmitter {
3677
3918
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3678
3919
  // the previous turn's tool list.
3679
3920
  this.resetToolProgress();
3680
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
3921
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3681
3922
  this.markTurnStarted(meta, formatted);
3682
3923
  }
3683
3924
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3826,16 +4067,24 @@ export class Daemon extends EventEmitter {
3826
4067
  }
3827
4068
  }
3828
4069
  }
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.
4070
+ // Everything above is *waiting*; everything below owns the write lock.
4071
+ // Long idle/dialog waits stay outside because the runtime dismisser may be
4072
+ // what makes progress. The one under-lock wait below is explicitly passive:
4073
+ // it clears by itself and closes a repaint race immediately before paste.
3833
4074
  for (let round = 0;; round++) {
3834
4075
  const outcome = await this.paneWriteLock.run(async () => {
3835
4076
  if (cancelled())
3836
4077
  return false;
3837
4078
  if (this.refuseFatalStartupDelivery(status))
3838
4079
  return false;
4080
+ // A passive startup phase may paint after the outer readiness probe.
4081
+ // It clears without input, so waiting under the pane lock cannot starve
4082
+ // a dialog dismisser and closes the final clear→paste TOCTOU window.
4083
+ if (!(await this.waitForInputTransientToClear("pre-write"))) {
4084
+ if (status)
4085
+ this.emit("message_failed", status); // ❌
4086
+ return false;
4087
+ }
3839
4088
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
3840
4089
  // whenever it likes — a resume prompt can be painted between "clear" and
3841
4090
  // this critical section. Re-probe here, right before the write; a
@@ -3845,7 +4094,7 @@ export class Daemon extends EventEmitter {
3845
4094
  if (probe.state !== "clear")
3846
4095
  return "dialog";
3847
4096
  }
3848
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
4097
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3849
4098
  });
3850
4099
  if (outcome !== "dialog")
3851
4100
  return outcome;
@@ -3879,6 +4128,75 @@ export class Daemon extends EventEmitter {
3879
4128
  deliveryBlockingDialogs() {
3880
4129
  return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
3881
4130
  }
4131
+ /** Passive input-unavailable phases relevant to the current spawn only. */
4132
+ guardedInputTransients() {
4133
+ if (this.inputTransientGuardGeneration === null
4134
+ || this.inputTransientGuardGeneration !== this.spawnGeneration)
4135
+ return [];
4136
+ return this.backend?.getInputUnavailableTransients?.() ?? [];
4137
+ }
4138
+ /** Match a passive transient against the current interactive screen. */
4139
+ inputTransientInPane(pane) {
4140
+ for (const transient of this.guardedInputTransients()) {
4141
+ // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4142
+ // authority because users and agents routinely quote diagnostic text.
4143
+ transient.pattern.lastIndex = 0;
4144
+ if (!transient.pattern.test(pane))
4145
+ continue;
4146
+ if (transient.isActive(pane))
4147
+ return transient;
4148
+ }
4149
+ return null;
4150
+ }
4151
+ /** Capture failure is unknown, never evidence that input is available. */
4152
+ async probeInputTransient() {
4153
+ if (this.guardedInputTransients().length === 0 || !this.tmux)
4154
+ return { state: "clear" };
4155
+ try {
4156
+ const pane = await this.tmux.capturePane();
4157
+ const transient = this.inputTransientInPane(pane);
4158
+ return transient ? { state: "active", transient } : { state: "clear" };
4159
+ }
4160
+ catch (err) {
4161
+ this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
4162
+ return { state: "unknown" };
4163
+ }
4164
+ }
4165
+ /**
4166
+ * Wait under the pane lock for a passive transient to disappear. Unlike a
4167
+ * dialog, this state clears by itself, so the wait cannot starve a keypress
4168
+ * dismisser. Stop or a replacement spawn invalidates the captured identity.
4169
+ */
4170
+ async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
4171
+ const generation = this.inputTransientGuardGeneration;
4172
+ if (generation === null || generation !== this.spawnGeneration)
4173
+ return true;
4174
+ const deadline = Date.now() + timeoutMs;
4175
+ let observedDescription = null;
4176
+ for (;;) {
4177
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4178
+ this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
4179
+ return false;
4180
+ }
4181
+ const probe = await this.probeInputTransient();
4182
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4183
+ this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
4184
+ return false;
4185
+ }
4186
+ if (probe.state === "clear")
4187
+ return true;
4188
+ if (probe.state === "active" && observedDescription !== probe.transient.description) {
4189
+ observedDescription = probe.transient.description;
4190
+ this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4191
+ }
4192
+ const remaining = deadline - Date.now();
4193
+ if (remaining <= 0) {
4194
+ this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
4195
+ return false;
4196
+ }
4197
+ await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4198
+ }
4199
+ }
3882
4200
  static dialogKey(dialog) {
3883
4201
  return `${dialog.pattern.source}/${dialog.pattern.flags}`;
3884
4202
  }
@@ -3989,10 +4307,10 @@ export class Daemon extends EventEmitter {
3989
4307
  }
3990
4308
  /**
3991
4309
  * 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.
4310
+ * input queue or steered (after its own dialog probe); "dialog", "transient"
4311
+ * and "unknown" must never be — a paste + Enter into a dialog answers it
4312
+ * with its default, while a startup transient swallows it and an unreadable
4313
+ * pane proves nothing. The silence gate comes first and costs no capture.
3996
4314
  */
3997
4315
  async paneReadinessForDelivery(windowId) {
3998
4316
  if (!this.isPaneIdleForDelivery(windowId))
@@ -4000,6 +4318,11 @@ export class Daemon extends EventEmitter {
4000
4318
  const probe = await this.probeBlockingDialog();
4001
4319
  if (probe.state !== "clear")
4002
4320
  return probe.state;
4321
+ const transient = await this.probeInputTransient();
4322
+ if (transient.state === "active")
4323
+ return "transient";
4324
+ if (transient.state === "unknown")
4325
+ return "unknown";
4003
4326
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4004
4327
  return "ready";
4005
4328
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4046,6 +4369,7 @@ export class Daemon extends EventEmitter {
4046
4369
  const deadline = Date.now() + timeoutMs;
4047
4370
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4048
4371
  let unknownStreak = 0;
4372
+ let transientDeadline = 0;
4049
4373
  for (;;) {
4050
4374
  const remaining = deadline - Date.now();
4051
4375
  if (remaining <= 0)
@@ -4055,6 +4379,26 @@ export class Daemon extends EventEmitter {
4055
4379
  return false;
4056
4380
  // The idle wait just resolved, so re-reading the silence gate would only
4057
4381
  // add a stale-cache race; ask the pane the remaining questions directly.
4382
+ const transient = await this.probeInputTransient();
4383
+ if (transient.state === "active") {
4384
+ unknownStreak = 0;
4385
+ transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4386
+ if (Date.now() >= transientDeadline) {
4387
+ this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
4388
+ return false;
4389
+ }
4390
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4391
+ continue;
4392
+ }
4393
+ transientDeadline = 0;
4394
+ if (transient.state === "unknown") {
4395
+ if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4396
+ this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
4397
+ return false;
4398
+ }
4399
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4400
+ continue;
4401
+ }
4058
4402
  if (bottomGated) {
4059
4403
  if (await this.isPaneReadyForDelivery(windowId))
4060
4404
  return true;
@@ -4090,15 +4434,16 @@ export class Daemon extends EventEmitter {
4090
4434
  * must fail rather than paste on top of the stranded text).
4091
4435
  */
4092
4436
  async submitStrandedInputIfAny(windowId) {
4093
- if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
4437
+ if (!this.tmux || !this.backend)
4094
4438
  return "idle";
4095
4439
  const prompt = this.backend.getBottomReadyPattern?.();
4096
4440
  if (!prompt)
4097
4441
  return "idle";
4442
+ const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
4098
4443
  // Pre-check outside the lock so the common case (verified clear) never
4099
4444
  // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
4100
4445
  // lock; an unreadable pane is re-examined under it, not waved through.
4101
- const pre = await this.strandedInputState(prompt);
4446
+ const pre = await this.strandedInputState(prompt, requireBottomReady);
4102
4447
  if (pre === "clear")
4103
4448
  return "idle";
4104
4449
  if (pre === "busy")
@@ -4114,7 +4459,7 @@ export class Daemon extends EventEmitter {
4114
4459
  // no-op at an empty prompt, but into a modal it is a confirmation.
4115
4460
  if (this.fatalStartupBlocked)
4116
4461
  return "busy";
4117
- const state = await this.strandedInputState(prompt);
4462
+ const state = await this.strandedInputState(prompt, requireBottomReady);
4118
4463
  if (state === "clear")
4119
4464
  return "idle";
4120
4465
  if (state !== "stranded")
@@ -4124,12 +4469,12 @@ export class Daemon extends EventEmitter {
4124
4469
  });
4125
4470
  }
4126
4471
  /**
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.
4472
+ * "stranded": an AgEnD message sits in the input row; "clear": no strand is
4473
+ * visible under the backend's readiness policy; "busy": generating, or (for
4474
+ * a bottom-gated backend) the prompt is not at the bottom; "unknown": the
4475
+ * pane could not be read. Only "clear" permits the next paste.
4131
4476
  */
4132
- async strandedInputState(prompt) {
4477
+ async strandedInputState(prompt, requireBottomReady) {
4133
4478
  let pane;
4134
4479
  try {
4135
4480
  pane = await this.tmux.capturePane();
@@ -4137,11 +4482,16 @@ export class Daemon extends EventEmitter {
4137
4482
  catch {
4138
4483
  return "unknown";
4139
4484
  }
4485
+ if (this.inputTransientInPane(pane))
4486
+ return "busy";
4140
4487
  if (this.backend?.getBusyPattern?.()?.test(pane))
4141
4488
  return "busy";
4142
4489
  if (strandedAgendMessageInInput(pane, prompt))
4143
4490
  return "stranded";
4144
- return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4491
+ // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4492
+ // does not: this preflight runs only after its ordinary silence gate and is
4493
+ // enabled solely to recover a positively identified old AgEnD strand.
4494
+ return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4145
4495
  }
4146
4496
  /**
4147
4497
  * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
@@ -4245,6 +4595,8 @@ export class Daemon extends EventEmitter {
4245
4595
  * an invariant maintained by comments.
4246
4596
  */
4247
4597
  async sendDeliveryEnter(phase) {
4598
+ if (!(await this.waitForInputTransientToClear(phase)))
4599
+ return false;
4248
4600
  const sent = await this.tmux.sendSpecialKey("Enter");
4249
4601
  if (!sent) {
4250
4602
  this.logger.error({
@@ -4254,13 +4606,17 @@ export class Daemon extends EventEmitter {
4254
4606
  }
4255
4607
  return sent;
4256
4608
  }
4257
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4609
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4610
+ const signature = this.submissionSignature(formatted, submissionId);
4258
4611
  let windowId = initialWindowId;
4259
4612
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4260
4613
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4261
4614
  const maxAttempts = 3;
4262
4615
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4263
4616
  const pasteStartedAt = Date.now();
4617
+ // Read the pane BEFORE writing to it, so the submission check can require
4618
+ // evidence this paste ADDED rather than evidence that was already there.
4619
+ const pasteBaseline = await this.capturePaneEvidence(signature);
4264
4620
  const pasted = await this.tmux.pasteBuffer(formatted);
4265
4621
  if (!pasted) {
4266
4622
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4342,16 +4698,73 @@ export class Daemon extends EventEmitter {
4342
4698
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4343
4699
  if (handingOffToNativeQueue) {
4344
4700
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4345
- if (await this.nativeQueuePasteVisible(formatted)) {
4701
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4702
+ if (proof === "submitted") {
4346
4703
  if (status)
4347
4704
  this.emit("message_confirmed", status); // ✅ native queue accepted
4348
4705
  return true;
4349
4706
  }
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");
4707
+ if (proof === "unverifiable") {
4708
+ // Our text is on the pane but cannot be shown to have left the input
4709
+ // row — either the backend exposes no input row (only /steer reaches
4710
+ // this path on such a backend) or the pre-paste pane was unreadable.
4711
+ // Re-pasting on that would deliver the message twice, so accept as
4712
+ // this path always has, and record that it is unproven.
4713
+ this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
4714
+ if (status)
4715
+ this.emit("message_confirmed", status); // ✅ (best-effort)
4716
+ return true;
4717
+ }
4718
+ // Not submitted (or not provably submitted): fall back once to the
4719
+ // normal idle-gated path, which is the only one with a full
4720
+ // confirmation ladder. "stranded" is the case that used to be a silent
4721
+ // ✅ — the text was visible, so the old check called it delivered.
4722
+ this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
4352
4723
  if (windowId && this.controlClient) {
4353
4724
  await this.controlClient.waitUntilIdle(windowId);
4354
4725
  }
4726
+ // Re-read before doing anything: the CLI had a whole turn to flush its
4727
+ // queue while we waited, and the right recovery differs per outcome.
4728
+ // Re-pasting a payload that is STILL in the input row appends it to
4729
+ // itself — paste-buffer writes at the cursor — and the next Enter then
4730
+ // submits the message twice over. That is the trap this fix would walk
4731
+ // into if it treated "not confirmed" as one state.
4732
+ const settled = await this.confirmSubmitted(signature, pasteBaseline);
4733
+ if (settled === "submitted") {
4734
+ this.logger.info("Native-queue message submitted itself while we waited for idle");
4735
+ if (status)
4736
+ this.emit("message_confirmed", status); // ✅
4737
+ return true;
4738
+ }
4739
+ if (settled === "stranded") {
4740
+ // The text is already there in full: it needs submitting, not sending
4741
+ // again. The prompt is back now, so an Enter can land.
4742
+ this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4743
+ const strandedAt = Date.now();
4744
+ if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4745
+ if (status)
4746
+ this.emit("message_failed", status); // ❌
4747
+ return false;
4748
+ }
4749
+ const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4750
+ if (afterEnter === "submitted") {
4751
+ if (status)
4752
+ this.emit("message_confirmed", status); // ✅
4753
+ return true;
4754
+ }
4755
+ // Deliberately NOT "or the pane went busy". This branch runs because
4756
+ // the text was still in the input row; if the recovery Enter was
4757
+ // swallowed too, the pane is busy with something else — a late redraw,
4758
+ // another turn's output — and treating that as proof re-confirms a
4759
+ // message nobody submitted. Output is corroboration; text sitting in
4760
+ // the input row is disqualifying, and disqualifying evidence wins.
4761
+ this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
4762
+ if (status)
4763
+ this.emit("message_failed", status); // ❌
4764
+ return false;
4765
+ }
4766
+ // "unproven": nothing of ours is on screen — the paste itself was lost,
4767
+ // so pasting it again cannot duplicate anything.
4355
4768
  const repasted = await this.tmux.pasteBuffer(formatted);
4356
4769
  if (!repasted) {
4357
4770
  this.logger.error({
@@ -4369,24 +4782,13 @@ export class Daemon extends EventEmitter {
4369
4782
  return false;
4370
4783
  }
4371
4784
  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) {
4785
+ if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
4384
4786
  if (status)
4385
4787
  this.emit("message_confirmed", status); // ✅
4386
4788
  return true;
4387
4789
  }
4388
4790
  }
4389
- else if (await this.nativeQueuePasteVisible(formatted)) {
4791
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4390
4792
  if (status)
4391
4793
  this.emit("message_confirmed", status); // ✅
4392
4794
  return true;
@@ -4426,17 +4828,17 @@ export class Daemon extends EventEmitter {
4426
4828
  }
4427
4829
  }
4428
4830
  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
- }
4831
+ // An idle delivery used to be judged by output alone: anything the pane
4832
+ // printed after Enter counted as "submitted". Waking a paused instance
4833
+ // breaks that — the CLI's final redraw arrives right as we press Enter,
4834
+ // so a swallowed Enter is confirmed by the redraw it was lost to, and
4835
+ // the message is never in a turn (#745 left this path on the old rule).
4836
+ //
4837
+ // Where the input row can be read, it decides: text still sitting there
4838
+ // is disqualifying and no amount of output may override it. Where it
4839
+ // cannot (a backend with no prompt pattern), the output signal remains
4840
+ // exactly as before — this must not regress backends we cannot read.
4841
+ const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
4440
4842
  if (becameBusy) {
4441
4843
  if (status)
4442
4844
  this.emit("message_confirmed", status); // ✅
@@ -4467,31 +4869,260 @@ export class Daemon extends EventEmitter {
4467
4869
  return false;
4468
4870
  }
4469
4871
  /**
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.
4872
+ * The single place a pasted message is judged submitted, shared by the
4873
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
4874
+ *
4875
+ * It replaces three divergent weak checks, the weakest of which accepted "the
4876
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
4877
+ * the failure it was meant to catch: text stranded in the input row is on
4878
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
4879
+ * anywhere. Evidence is weighed in order of what it can prove, and the
4880
+ * disqualifying evidence is checked first.
4473
4881
  */
4474
- async nativeQueuePasteVisible(formatted) {
4475
- if (!this.tmux)
4882
+ /**
4883
+ * Whether this backend exposes an input row we can actually read, i.e. whether
4884
+ * confirmSubmitted can ever return a verdict stronger than a guess.
4885
+ *
4886
+ * This is a property of the BACKEND, not of one delivery's verdict. Keying the
4887
+ * output-signal fallback off the verdict instead let codex — which can be read
4888
+ * — fall back to "the pane printed something" whenever a paste left no trace,
4889
+ * which is the same false confirmation one layer down.
4890
+ */
4891
+ canProveSubmission() {
4892
+ return !!this.backend?.getBottomReadyPattern?.();
4893
+ }
4894
+ /** Positive submission retires the startup transient guard for this spawn. */
4895
+ submittedProof() {
4896
+ if (this.inputTransientGuardGeneration === this.spawnGeneration) {
4897
+ this.inputTransientGuardGeneration = null;
4898
+ }
4899
+ return "submitted";
4900
+ }
4901
+ /**
4902
+ * Decide a submission after an Enter, with one bounded retry.
4903
+ *
4904
+ * Backends we can read must SHOW the text left the input row: neither a
4905
+ * strand nor a vanished paste may be upgraded to success by output, because
4906
+ * the output that would do the upgrading is usually the very redraw the Enter
4907
+ * was lost to. Backends we cannot read keep the legacy output signal — there
4908
+ * is nothing better available for them, and taking it away would fail every
4909
+ * one of their deliveries.
4910
+ *
4911
+ * One ladder, used by every post-Enter confirmation, so an escape hatch
4912
+ * cannot reappear in just one of them (it did: three copies, two of which
4913
+ * still confirmed on output alone).
4914
+ */
4915
+ async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
4916
+ if (!this.canProveSubmission()) {
4917
+ let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
4918
+ if (!busy) {
4919
+ this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4920
+ const retryAt = Date.now();
4921
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4922
+ return false;
4923
+ busy = await this.confirmBusyAfterEnter(windowId, retryAt);
4924
+ }
4925
+ return busy;
4926
+ }
4927
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4928
+ return true;
4929
+ // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
4930
+ // how we got here. Then poll briefly rather than judging on one capture:
4931
+ // the key takes a moment to be digested, and a single early look would
4932
+ // report a failure for a message that did go out.
4933
+ this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
4934
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
4476
4935
  return false;
4477
- try {
4478
- const pane = await this.tmux.capturePane();
4479
- if (pane.includes("↳"))
4936
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4937
+ return false;
4938
+ const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
4939
+ for (;;) {
4940
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4480
4941
  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;
4942
+ if (Date.now() >= deadline)
4943
+ return false;
4944
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
4945
+ }
4946
+ }
4947
+ async confirmSubmitted(signature, baseline) {
4948
+ if (!this.tmux)
4949
+ return "unproven";
4950
+ let pane;
4951
+ try {
4952
+ pane = await this.tmux.capturePane();
4953
+ }
4954
+ catch {
4955
+ return "unproven";
4956
+ }
4957
+ // Without a way to tell the input row from the transcript, "the text is on
4958
+ // screen" cannot distinguish submitted from stranded — that ambiguity IS
4959
+ // the bug. Such a backend gets an explicit verdict rather than a guess:
4960
+ // "unverifiable" (something of ours appeared, but not provably out of the
4961
+ // input row) or "unproven" (nothing of ours appeared, so the paste itself
4962
+ // was lost). The caller keeps that backend's existing best-effort handling
4963
+ // for the first and recovers on the second, which is what it did before —
4964
+ // the fix here is for backends that CAN be read, not a new guess for those
4965
+ // that cannot.
4966
+ const prompt = this.backend?.getBottomReadyPattern?.();
4967
+ if (!prompt) {
4968
+ const seen = this.paneEvidence(pane, signature);
4969
+ if (signature.unique && seen.payload > 0)
4970
+ return "unverifiable";
4971
+ return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
4972
+ ? "unverifiable"
4973
+ : "unproven";
4974
+ }
4975
+ const after = this.paneEvidence(pane, signature);
4976
+ // 1. Disqualifying evidence, checked FIRST and never overridden by the
4977
+ // corroborating evidence below: our text is sitting in the input row, so
4978
+ // it was not submitted — whatever else is on screen.
4979
+ //
4980
+ // It must be OUR text. A unique signature settles that on its own; a
4981
+ // body-derived one cannot, so it is only attributed to this delivery
4982
+ // when the input row did not already show it before we pasted.
4983
+ // Otherwise an older stranded message with the same opening would be
4984
+ // read as ours, we would press Enter to "recover" it, and the turn IT
4985
+ // starts would confirm a message that never reached the pane.
4986
+ if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
4987
+ return "stranded";
4988
+ // 2. Positive evidence. A unique signature needs no baseline: no earlier
4989
+ // message can carry this delivery's message_id, so finding it outside
4990
+ // the input row is proof in itself — which also means a momentary
4991
+ // failure to read the pane BEFORE pasting cannot turn a delivered
4992
+ // message into a re-paste.
4993
+ if (signature.unique && after.payload > 0)
4994
+ return this.submittedProof();
4995
+ // 3. Otherwise the evidence must be NEW relative to the pane as it was
4996
+ // before we pasted: a queue marker left by an earlier message, or an
4997
+ // older transcript entry that opens the same way, are on screen either
4998
+ // way and would otherwise confirm a paste that never landed.
4999
+ if (baseline) {
5000
+ if (after.queued > baseline.queued)
5001
+ return this.submittedProof();
5002
+ if (after.payload > baseline.payload)
5003
+ return this.submittedProof();
5004
+ // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
5005
+ return "unproven";
5006
+ }
5007
+ // No baseline and nothing that identifies this delivery on its own. "The
5008
+ // paste was lost" is a guess, not a finding, and acting on it re-pastes a
5009
+ // message that may well have been submitted — so say the evidence is
5010
+ // missing instead of inventing a verdict.
5011
+ this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
5012
+ return "unverifiable";
5013
+ }
5014
+ /**
5015
+ * What the pane currently shows of a given message. Taken once before the
5016
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
5017
+ * echo rather than accepting whatever was already there.
5018
+ */
5019
+ async capturePaneEvidence(signature) {
5020
+ if (!this.tmux)
5021
+ return null;
5022
+ // Bounded re-probe: a capture that fails here costs the delivery its only
5023
+ // "before" picture, and a momentary failure should not decide anything.
5024
+ for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
5025
+ try {
5026
+ const pane = await this.tmux.capturePane();
5027
+ const evidence = this.paneEvidence(pane, signature);
5028
+ const prompt = this.backend?.getBottomReadyPattern?.();
5029
+ if (prompt && strandedAgendMessageInInput(pane, prompt)) {
5030
+ // Whatever we paste now lands after it, and one Enter submits both as
5031
+ // a single message. Nothing here can undo that; saying so beats
5032
+ // letting two messages silently merge.
5033
+ this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
5034
+ }
5035
+ return evidence;
4485
5036
  }
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;
5037
+ catch {
5038
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
5039
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4489
5040
  }
5041
+ }
5042
+ // Still unreadable: confirmSubmitted falls back to evidence that stands on
5043
+ // its own (a unique message_id) and refuses to guess without it.
5044
+ this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
5045
+ return null;
5046
+ }
5047
+ paneEvidence(pane, signature) {
5048
+ const marker = this.backend?.getQueuedInputMarker?.();
5049
+ const prompt = this.backend?.getBottomReadyPattern?.();
5050
+ const input = prompt ? inputAreaText(pane, prompt) : null;
5051
+ return {
5052
+ queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
5053
+ payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
5054
+ strandedInput: input != null && inputShowsPastedText(input, signature.value),
5055
+ };
5056
+ }
5057
+ /**
5058
+ * What identifies THIS message on screen. The routing envelope carries a
5059
+ * message_id that no earlier transcript entry can share, so prefer it: the
5060
+ * body alone is not distinctive enough — short repeated instructions ("carry
5061
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
5062
+ * paste that never landed. System pastes carry no envelope and fall back to
5063
+ * the body signature, which the baseline comparison still ties to this paste.
5064
+ */
5065
+ submissionSignature(formatted, trustedId) {
5066
+ // The id must come from the message's own metadata, never from scanning the
5067
+ // rendered text: agents and users discuss message ids in the body all the
5068
+ // time ("check message_id: abc"), and a body-scanned value would match an
5069
+ // older transcript entry quoting the same thing — then be trusted as unique
5070
+ // and confirm a paste that never landed. Only the value AgEnD itself put in
5071
+ // the handoff metadata identifies THIS delivery.
5072
+ const id = trustedId?.trim();
5073
+ if (id)
5074
+ return { value: `message_id:${id}`, unique: true };
5075
+ return { value: pastedTextSignature(formatted), unique: false };
5076
+ }
5077
+ /**
5078
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
5079
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
5080
+ * at all, so a swallowed Enter left the notice sitting in the input row where
5081
+ * the next delivery submitted both as one message. Shares confirmSubmitted
5082
+ * with the delivery path — having one primitive is the point.
5083
+ *
5084
+ * Best effort by design: these notices must never fail a delivery or throw.
5085
+ * The caller already holds paneWriteLock.
5086
+ */
5087
+ async submitSystemPaste(text, label) {
5088
+ if (!this.tmux)
5089
+ return false;
5090
+ const signature = this.submissionSignature(text);
5091
+ const baseline = await this.capturePaneEvidence(signature);
5092
+ if (!(await this.tmux.pasteBuffer(text))) {
5093
+ this.logger.warn({ label }, "System paste failed to reach the pane");
4490
5094
  return false;
4491
5095
  }
4492
- catch {
5096
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5097
+ if (!(await this.sendDeliveryEnter(label)))
5098
+ return false;
5099
+ let proof = await this.confirmSubmitted(signature, baseline);
5100
+ if (proof === "unverifiable") {
5101
+ // No input row to read: this backend gets exactly what the old pasteText
5102
+ // path gave it, including the unconditional second Enter for queue-less
5103
+ // TUIs that swallow the first. Narrowing that to one Enter on the grounds
5104
+ // that "the text is visible" would be the very inference this change
5105
+ // exists to remove — visible text is also what a strand looks like.
5106
+ if (this.systemPasteOptions().retryEnter) {
5107
+ await new Promise(r => setTimeout(r, 1_000));
5108
+ await this.sendDeliveryEnter(`${label}-defensive-retry`);
5109
+ }
5110
+ return true; // best effort, exactly as before — nothing here is verified
5111
+ }
5112
+ if (proof !== "submitted") {
5113
+ // A bare Enter is a no-op at an empty prompt, so this is safe even if the
5114
+ // first one did land; when the text is still in the input row it is the
5115
+ // submit it never got. Re-pasting would append the text to itself.
5116
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5117
+ if (!(await this.sendDeliveryEnter(`${label}-retry`)))
5118
+ return false;
5119
+ proof = await this.confirmSubmitted(signature, baseline);
5120
+ }
5121
+ if (proof !== "submitted") {
5122
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
4493
5123
  return false;
4494
5124
  }
5125
+ return true;
4495
5126
  }
4496
5127
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4497
5128
  async recoverWindow() {
@@ -4584,14 +5215,21 @@ export class Daemon extends EventEmitter {
4584
5215
  if (this.socketSessionNames.get(socket) === this.name) {
4585
5216
  this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
4586
5217
  }
5218
+ // A tool invocation is not delivery evidence. Capture which obligation it
5219
+ // belongs to now, then settle it only after the adapter/fleet responds.
5220
+ // Explicitly mapped sibling sessions must not satisfy this instance's turn;
5221
+ // an unmapped socket is retained for legacy/tests where mcp_ready was not
5222
+ // observed before the first tool call.
5223
+ const sourceSession = this.socketSessionNames.get(socket);
5224
+ const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5225
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
5226
+ : null;
4587
5227
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
4588
5228
  const respond = (result, error) => {
4589
5229
  // A message that verifiably went out stands down the dead-MCP proxy reply
4590
5230
  // for this turn: the agent proved it can still speak for itself.
4591
5231
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
4592
- this.turnOutboundDelivered = true;
4593
- if (tool === "reply")
4594
- this.turnReplyDelivered = true;
5232
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
4595
5233
  }
4596
5234
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4597
5235
  if (!sent) {
@@ -4782,7 +5420,15 @@ export class Daemon extends EventEmitter {
4782
5420
  });
4783
5421
  return;
4784
5422
  }
4785
- const adapter = adapters[0];
5423
+ // Same rule as deliverDaemonReply: one adapter is unambiguous, several with
5424
+ // no bound world is a routing failure rather than a first-wins guess.
5425
+ const adapter = this.lastAdapterId
5426
+ ? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
5427
+ : (adapters.length === 1 ? adapters[0] : undefined);
5428
+ if (!adapter) {
5429
+ respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
5430
+ return;
5431
+ }
4786
5432
  if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
4787
5433
  respond(null, `Unknown tool: ${tool}`);
4788
5434
  }
@@ -4973,7 +5619,16 @@ export class Daemon extends EventEmitter {
4973
5619
  try {
4974
5620
  // Messages can arrive during a restart and be queued on pasteLock before the
4975
5621
  // 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()));
5622
+ 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.`;
5623
+ const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
5624
+ if (!injected) {
5625
+ // rotation-state.json was deleted when the prompt was built, so there is
5626
+ // nothing left to retry from: the restore is gone either way. Say so —
5627
+ // reporting it as injected is how a lost context restore stays invisible.
5628
+ this.logger.error("Session snapshot could not be submitted — session continues without context");
5629
+ this.emit("snapshot_failed", this.name);
5630
+ return;
5631
+ }
4977
5632
  this.logger.info("Injected session snapshot as first message");
4978
5633
  this.emit("snapshot_injected", this.name);
4979
5634
  }
@@ -4989,6 +5644,17 @@ export class Daemon extends EventEmitter {
4989
5644
  * into exactly the window this exists to close.
4990
5645
  */
4991
5646
  beginSpawn() {
5647
+ // A restarted CLI has no trustworthy turn edge for the process it replaced.
5648
+ // v1 deliberately does not persist obligations across restarts: missing one
5649
+ // warning is safer than treating the replacement's startup idle as the old
5650
+ // turn ending and injecting a stale recovery prompt.
5651
+ this.turnReplyGuard.reset();
5652
+ if (this.spawnDepth === 0) {
5653
+ this.spawnGeneration++;
5654
+ this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
5655
+ ? this.spawnGeneration
5656
+ : null;
5657
+ }
4992
5658
  this.spawnDepth++;
4993
5659
  this.spawning = true;
4994
5660
  if (!this.spawnSettled) {
@@ -5043,6 +5709,69 @@ export class Daemon extends EventEmitter {
5043
5709
  }
5044
5710
  }
5045
5711
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
5712
+ /**
5713
+ * Positive proof from the CLI that there is no conversation left to resume.
5714
+ *
5715
+ * Only this may justify abandoning a session. A startup that merely ran out of
5716
+ * budget proves nothing: a healthy cold start on a loaded host looks exactly
5717
+ * the same, which is how 9 instances silently lost their history.
5718
+ *
5719
+ * Deliberately does NOT match a bare "--continue": the pane text can mention
5720
+ * the flag (a usage error, an echoed invocation) without the CLI ever saying
5721
+ * the conversation is gone.
5722
+ */
5723
+ paneSaysNoConversation(paneText) {
5724
+ if (!paneText)
5725
+ return false;
5726
+ return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
5727
+ .test(paneText);
5728
+ }
5729
+ /** How many consecutive startups have failed without proving the session is gone. */
5730
+ unprovenResumeFailures = 0;
5731
+ /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
5732
+ static MAX_UNPROVEN_RESUME_FAILURES = 3;
5733
+ /**
5734
+ * Set the session aside instead of deleting it.
5735
+ *
5736
+ * The whole defect being fixed here is silent, irreversible loss of a user's
5737
+ * conversation. Renaming costs nothing and turns a wrong call into something
5738
+ * recoverable: the id is still on disk under a dated name.
5739
+ */
5740
+ setSessionAside(reason) {
5741
+ const sidFile = join(this.instanceDir, "session-id");
5742
+ try {
5743
+ if (!existsSync(sidFile))
5744
+ return;
5745
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
5746
+ renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
5747
+ this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
5748
+ this.pruneAbandonedSessions();
5749
+ }
5750
+ catch (err) {
5751
+ // Never let bookkeeping block a spawn; falling back to the old behaviour
5752
+ // is still better than not starting.
5753
+ this.logger.warn({ err: err.message }, "Could not set session aside");
5754
+ try {
5755
+ unlinkSync(sidFile);
5756
+ }
5757
+ catch { /* may not exist */ }
5758
+ }
5759
+ }
5760
+ /** Keep the newest few set-aside sessions; they are insurance, not an archive. */
5761
+ pruneAbandonedSessions(keep = 5) {
5762
+ try {
5763
+ const files = readdirSync(this.instanceDir)
5764
+ .filter((f) => f.startsWith("session-id.abandoned-"))
5765
+ .sort();
5766
+ for (const f of files.slice(0, Math.max(0, files.length - keep))) {
5767
+ try {
5768
+ unlinkSync(join(this.instanceDir, f));
5769
+ }
5770
+ catch { /* best effort */ }
5771
+ }
5772
+ }
5773
+ catch { /* best effort */ }
5774
+ }
5046
5775
  async spawnClaudeWindow() {
5047
5776
  this.beginSpawn();
5048
5777
  let resumedSuccessfully = false;
@@ -5066,7 +5795,7 @@ export class Daemon extends EventEmitter {
5066
5795
  // first miss is usually slowness, not a broken session.
5067
5796
  await this.noteStartupPaneForBackendOutage();
5068
5797
  await this.failStartupIfBackendUnreachable();
5069
- if (this.backend.retriesResumeOnStartupFailure?.() === true) {
5798
+ if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
5070
5799
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
5071
5800
  await this.killProcessTree();
5072
5801
  await this.tmux.killWindow();
@@ -5078,16 +5807,39 @@ export class Daemon extends EventEmitter {
5078
5807
  }
5079
5808
  }
5080
5809
  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);
5810
+ // The session may only be abandoned on positive proof that there is
5811
+ // nothing to resume. Running out of budget is not proof: on a loaded host
5812
+ // a healthy cold start misses the same deadline as a broken session, and
5813
+ // treating the two alike is what silently discarded users' conversations.
5814
+ if (attemptedResume) {
5815
+ let paneText;
5816
+ try {
5817
+ paneText = await this.tmux?.capturePaneWithHistory(50);
5818
+ }
5819
+ catch { /* pane may be gone */ }
5820
+ const proven = this.paneSaysNoConversation(paneText);
5821
+ if (!proven) {
5822
+ this.unprovenResumeFailures++;
5823
+ if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
5824
+ // Keep the session and fail this attempt; the fleet retries with
5825
+ // backoff, which is also how the backend-outage path behaves.
5826
+ await this.killProcessTree();
5827
+ await this.tmux.killWindow();
5828
+ throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
5829
+ + "— session kept, will retry");
5830
+ }
5831
+ // Escape hatch: a genuinely broken session must still recover. Say so
5832
+ // out loud — this start does NOT continue the previous conversation.
5833
+ this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
5834
+ + "The previous conversation is NOT continued; its context is lost to this session.");
5835
+ this.emit("context_lost", this.name, "resume_repeatedly_failed");
5836
+ }
5837
+ this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
5838
+ this.skipResume = true;
5088
5839
  }
5089
- catch { /* may not exist */ }
5090
- this.skipResume = true;
5840
+ // A fresh start that also failed retries once, as before. It never clears
5841
+ // a session: nothing about a failed fresh launch says the stored
5842
+ // conversation is unusable.
5091
5843
  await this.killProcessTree();
5092
5844
  await this.tmux.killWindow();
5093
5845
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
@@ -5309,9 +6061,16 @@ export class Daemon extends EventEmitter {
5309
6061
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
5310
6062
  // delivery is already in flight or queued. Without the lock the notice and
5311
6063
  // 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
- });
6064
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
6065
+ if (!told) {
6066
+ // Recording the new instructions here would mark the agent as told about
6067
+ // a notice it never received, and every later restart would then skip
6068
+ // the reload. Leave the snapshot behind and hand it to the deferred
6069
+ // notice, which fires on the next real message.
6070
+ this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
6071
+ this.pendingInstructionsNotice = true;
6072
+ return;
6073
+ }
5315
6074
  // Record the value the agent has now been told about so the next
5316
6075
  // unchanged restart skips the reload.
5317
6076
  try {
@@ -5495,6 +6254,7 @@ export class Daemon extends EventEmitter {
5495
6254
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
5496
6255
  let cleanReadyPolls = 0;
5497
6256
  let lastDialog = null;
6257
+ let lastTransient = null;
5498
6258
  let captureFailures = 0;
5499
6259
  let attempts = 0;
5500
6260
  do {
@@ -5516,6 +6276,18 @@ export class Daemon extends EventEmitter {
5516
6276
  continue;
5517
6277
  }
5518
6278
  try {
6279
+ // Some TUIs expose a real-looking input row before they accept Enter.
6280
+ // This phase clears by itself: never send a key, and let its structural
6281
+ // current-screen matcher outrank the otherwise-valid ready pattern.
6282
+ const transient = this.inputTransientInPane(pane);
6283
+ if (transient) {
6284
+ cleanReadyPolls = 0;
6285
+ lastDialog = null;
6286
+ lastTransient = transient;
6287
+ await sleep();
6288
+ continue;
6289
+ }
6290
+ lastTransient = null;
5519
6291
  // Try each startup dialog pattern before checking ready state
5520
6292
  let matched = false;
5521
6293
  lastDialog = null;
@@ -5643,7 +6415,10 @@ export class Daemon extends EventEmitter {
5643
6415
  await sleep();
5644
6416
  } while (remaining() > 0);
5645
6417
  // Budget exhausted. Never silently: say which way it went.
5646
- if (lastDialog) {
6418
+ if (lastTransient) {
6419
+ this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
6420
+ }
6421
+ else if (lastDialog) {
5647
6422
  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
6423
  }
5649
6424
  else {
@@ -5659,12 +6434,21 @@ export class Daemon extends EventEmitter {
5659
6434
  updateLastChat(chatId, threadId, adapterId) {
5660
6435
  if (!chatId)
5661
6436
  return;
6437
+ const chatChanged = this.lastChatId !== chatId;
5662
6438
  this.lastChatId = chatId;
5663
6439
  // An unthreaded inbound must clear a previous topic rather than leaking it
5664
6440
  // into the next reply target.
5665
6441
  this.lastThreadId = threadId || undefined;
5666
6442
  if (adapterId)
5667
6443
  this.lastAdapterId = adapterId;
6444
+ else if (chatChanged) {
6445
+ // A NEW chat with no adapter named alongside it: the old adapter belonged
6446
+ // to the old chat, and keeping it asserts a pairing nobody supplied. That
6447
+ // is how one platform's chat id ends up being sent through another
6448
+ // platform's bot. Unknown is recoverable (a single-adapter fleet is
6449
+ // unambiguous anyway); a confidently wrong world is not.
6450
+ this.lastAdapterId = undefined;
6451
+ }
5668
6452
  try {
5669
6453
  writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
5670
6454
  }