@songsid/agend 2.1.11 → 2.1.12-beta.2

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 (100) hide show
  1. package/README.md +23 -15
  2. package/README.zh-TW.md +50 -11
  3. package/dist/agent-cli.js +36 -4
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +3 -1
  6. package/dist/agent-endpoint.js +7 -1
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +4 -0
  9. package/dist/backend/claude-code.js +65 -2
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/session-signals.d.ts +154 -0
  12. package/dist/backend/session-signals.js +430 -0
  13. package/dist/backend/session-signals.js.map +1 -0
  14. package/dist/backend/types.d.ts +25 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.d.ts +38 -1
  17. package/dist/channel/adapters/discord.js +131 -8
  18. package/dist/channel/adapters/discord.js.map +1 -1
  19. package/dist/channel/adapters/telegram.d.ts +19 -1
  20. package/dist/channel/adapters/telegram.js +55 -0
  21. package/dist/channel/adapters/telegram.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +5 -1
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/channel/message-queue.d.ts +6 -0
  25. package/dist/channel/message-queue.js +21 -0
  26. package/dist/channel/message-queue.js.map +1 -1
  27. package/dist/channel/sticker-download.d.ts +14 -0
  28. package/dist/channel/sticker-download.js +42 -0
  29. package/dist/channel/sticker-download.js.map +1 -0
  30. package/dist/channel/tool-router.js +12 -5
  31. package/dist/channel/tool-router.js.map +1 -1
  32. package/dist/channel/types.d.ts +52 -0
  33. package/dist/classic-bindings.d.ts +48 -0
  34. package/dist/classic-bindings.js +128 -0
  35. package/dist/classic-bindings.js.map +1 -0
  36. package/dist/classic-channel-manager.d.ts +21 -0
  37. package/dist/classic-channel-manager.js +31 -0
  38. package/dist/classic-channel-manager.js.map +1 -1
  39. package/dist/cli-flags.d.ts +8 -0
  40. package/dist/cli-flags.js +22 -0
  41. package/dist/cli-flags.js.map +1 -0
  42. package/dist/config-validator.js +25 -0
  43. package/dist/config-validator.js.map +1 -1
  44. package/dist/daemon.d.ts +61 -0
  45. package/dist/daemon.js +454 -19
  46. package/dist/daemon.js.map +1 -1
  47. package/dist/delivery-outbox.d.ts +8 -0
  48. package/dist/delivery-outbox.js.map +1 -1
  49. package/dist/delivery-reconciliation.d.ts +13 -0
  50. package/dist/delivery-reconciliation.js +71 -7
  51. package/dist/delivery-reconciliation.js.map +1 -1
  52. package/dist/delivery-reconciliation.test.js +115 -2
  53. package/dist/delivery-reconciliation.test.js.map +1 -1
  54. package/dist/event-loop-watch.d.ts +25 -0
  55. package/dist/event-loop-watch.js +40 -0
  56. package/dist/event-loop-watch.js.map +1 -0
  57. package/dist/fleet-manager.d.ts +52 -4
  58. package/dist/fleet-manager.js +323 -20
  59. package/dist/fleet-manager.js.map +1 -1
  60. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +4 -2
  61. package/dist/instance-lifecycle.js +4 -2
  62. package/dist/instance-lifecycle.js.map +1 -1
  63. package/dist/interaction-observation.d.ts +32 -0
  64. package/dist/interaction-observation.js +118 -0
  65. package/dist/interaction-observation.js.map +1 -0
  66. package/dist/kiro-engine-status.d.ts +2 -0
  67. package/dist/kiro-engine-status.js +3 -1
  68. package/dist/kiro-engine-status.js.map +1 -1
  69. package/dist/locale.js +26 -0
  70. package/dist/locale.js.map +1 -1
  71. package/dist/outbound-handlers.d.ts +2 -0
  72. package/dist/outbound-handlers.js +28 -11
  73. package/dist/outbound-handlers.js.map +1 -1
  74. package/dist/outbound-schemas.d.ts +15 -1
  75. package/dist/outbound-schemas.js +20 -2
  76. package/dist/outbound-schemas.js.map +1 -1
  77. package/dist/output-log-tail.d.ts +21 -0
  78. package/dist/output-log-tail.js +89 -0
  79. package/dist/output-log-tail.js.map +1 -0
  80. package/dist/tool-permissions.js +8 -1
  81. package/dist/tool-permissions.js.map +1 -1
  82. package/dist/turn-reply-guard.d.ts +7 -0
  83. package/dist/turn-reply-guard.js +11 -0
  84. package/dist/turn-reply-guard.js.map +1 -1
  85. package/dist/turn-resume.d.ts +80 -0
  86. package/dist/turn-resume.js +217 -0
  87. package/dist/turn-resume.js.map +1 -0
  88. package/dist/ui/view.html +8 -2
  89. package/dist/usage/format-rich.js +6 -1
  90. package/dist/usage/format-rich.js.map +1 -1
  91. package/dist/usage/i18n.d.ts +2 -0
  92. package/dist/usage/i18n.js +14 -1
  93. package/dist/usage/i18n.js.map +1 -1
  94. package/dist/usage/providers.d.ts +21 -0
  95. package/dist/usage/providers.js +36 -7
  96. package/dist/usage/providers.js.map +1 -1
  97. package/dist/usage/usage-api.d.ts +9 -0
  98. package/dist/usage/usage-api.js +31 -2
  99. package/dist/usage/usage-api.js.map +1 -1
  100. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -34,6 +34,9 @@ import { t } from "./locale.js";
34
34
  import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
35
35
  import { deliveryContentDigest } from "./delivery-outbox.js";
36
36
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
37
+ import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
38
+ import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
39
+ import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
37
40
  const __filename = fileURLToPath(import.meta.url);
38
41
  const __dirname = dirname(__filename);
39
42
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -440,6 +443,18 @@ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
440
443
  /** A final bounded observation before an ambiguous Codex submit can be classified. */
441
444
  const CODEX_LATE_PROOF_MS = 5_000;
442
445
  const POST_ENTER_PROOF_POLL_MS = 250;
446
+ /**
447
+ * A readerless CLI's delivery is proven by its own transcript, which lags the pane by a flush. Only a delta that was
448
+ * read and carried no marker for this long is held against the delivery — a late flush must not raise a false ⚠️.
449
+ * Ten seconds is a chosen grace window: success markers arrive in about a second, and the bound keeps the failure
450
+ * path small — the fleet lane and the shared pump budget stay held until the verdict (Phase 2c "lane held until
451
+ * verdict", Option A #1207), so a lost paste blocks its lane for ~10 s. No flush-visibility claim is made here: a
452
+ * flush landing after the window still settles `uncertain`, and only a final-file scan (see the PR evidence) shows
453
+ * every recorded delivery did reach its transcript.
454
+ */
455
+ const TRANSCRIPT_PROOF_WINDOW_MS = 10_000;
456
+ const TRANSCRIPT_PROOF_POLL_MS = 1_000;
457
+ const OUTPUT_EDGE_ONLY_EVIDENCE = "output-edge-only; submission-unverifiable";
443
458
  /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
444
459
  const BASELINE_CAPTURE_ATTEMPTS = 3;
445
460
  const BASELINE_CAPTURE_RETRY_MS = 150;
@@ -851,17 +866,20 @@ export class InteractivePromptDetector {
851
866
  constructor(stableMs = 10_000) {
852
867
  this.stableMs = stableMs;
853
868
  }
854
- observe(pane, now = Date.now(), outputAt = 0) {
855
- const tail = sanitizePaneTail(pane, 5);
856
- const tailText = tail.join("\n");
857
- let matched = null;
869
+ /** Recognition is persistent; observe() separately deduplicates notifications. */
870
+ match(pane) {
871
+ const tailText = sanitizePaneTail(pane, 5).join("\n");
858
872
  for (const candidate of INTERACTIVE_PROMPT_PATTERNS) {
859
873
  const match = tailText.match(candidate.pattern);
860
- if (!match)
861
- continue;
862
- matched = { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
863
- break;
874
+ if (match)
875
+ return { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
864
876
  }
877
+ return null;
878
+ }
879
+ observe(pane, now = Date.now(), outputAt = 0) {
880
+ const tail = sanitizePaneTail(pane, 5);
881
+ const tailText = tail.join("\n");
882
+ const matched = this.match(pane);
865
883
  if (!matched) {
866
884
  this.reset();
867
885
  this.lastOutputAt = outputAt;
@@ -1050,11 +1068,18 @@ export class Daemon extends EventEmitter {
1050
1068
  */
1051
1069
  launchAttempt = 0;
1052
1070
  skipResume = false;
1071
+ /**
1072
+ * #1209: whether THIS boot was asked to skip resume by a crash-loop
1073
+ * (crash-state.json). Captured at boot before spawnClaudeWindow resets
1074
+ * skipResume — the #1209 gate reads this, never the resettable flag.
1075
+ */
1076
+ bootSkippedResume = false;
1053
1077
  startupAborted = false;
1054
1078
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
1055
1079
  dialogParkedSince = 0;
1056
1080
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
1057
1081
  dialogParkedKey = null;
1082
+ dialogParkedInteractionEpisode = null;
1058
1083
  dialogParkedReported = false;
1059
1084
  /**
1060
1085
  * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
@@ -1080,6 +1105,13 @@ export class Daemon extends EventEmitter {
1080
1105
  capacityNudge = null;
1081
1106
  /** Current stdin-owning runtime dialog, independent from execution state. */
1082
1107
  inputBlockedDialogKey = null;
1108
+ interactionObservation = new InteractionObservation();
1109
+ interactionConfirmationTimer = null;
1110
+ interactionOutputRevision = 0;
1111
+ interactionObservedOutputRevision = 0;
1112
+ interactionCaptureSerial = 0;
1113
+ interactionDialogKey = null;
1114
+ acceptedInstanceSnapshot = null;
1083
1115
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1084
1116
  autoResolvedDialogKey = null;
1085
1117
  autoResolvedDialogGeneration = 0;
@@ -1517,6 +1549,11 @@ export class Daemon extends EventEmitter {
1517
1549
  const isGeneral = config.general_topic === true || name === "general";
1518
1550
  const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
1519
1551
  this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
1552
+ // #1209: every real completion clears the in-flight marker, so a
1553
+ // finished turn can never resume after a later restart. reset() is
1554
+ // deliberately NOT hooked: stop/spawn abandon the in-memory turn while
1555
+ // the on-disk interruption must survive for the resume gate.
1556
+ this.turnReplyGuard.onComplete = () => clearInFlightTurnMarker(this.instanceDir);
1520
1557
  }
1521
1558
  /** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
1522
1559
  setDeliveryOutboxPort(port) {
@@ -1633,8 +1670,82 @@ export class Daemon extends EventEmitter {
1633
1670
  this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
1634
1671
  return;
1635
1672
  }
1673
+ // A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
1674
+ // after Enter, which a redraw that wiped the paste satisfies too (#758). Its transcript can say more.
1675
+ if (verdict.proof === "output-edge") {
1676
+ const checkpoint = verdict.transcriptCheckpoint;
1677
+ if (delivery && checkpoint && ["claude-code", "codex"].includes(checkpoint.backend)) {
1678
+ void this.proveDeliveryFromTranscript(delivery, checkpoint);
1679
+ return;
1680
+ }
1681
+ // No transcript to ask: nothing to hold against it and nothing to prove it with. Today's outcome, honestly labelled.
1682
+ this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
1683
+ return;
1684
+ }
1636
1685
  this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1637
1686
  }
1687
+ /**
1688
+ * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
1689
+ *
1690
+ * - marker found → delivered (`transcript-marker`; `transcript-marker-queued` when the CLI has it queued, not yet read);
1691
+ * - delta read, no marker for the whole window → uncertain (`unverifiable-no-transcript-marker`) — the pane printed
1692
+ * something, yet the CLI never took the message in. Never a re-paste: the message may still land;
1693
+ * - transcript unreadable on the last look → today's outcome, labelled — there is nothing to hold against it.
1694
+ *
1695
+ * It runs after the paste lock is released, but the fleet lane and the shared pump budget stay held until the
1696
+ * verdict settles the row (Phase 2c "lane held until verdict", Option A #1207): a success settles in about a
1697
+ * second, a delivery whose marker never arrives holds them for the bounded proof window (see
1698
+ * TRANSCRIPT_PROOF_WINDOW_MS). It never finishes a row that stop has handed to reconciliation; a pause or respawn
1699
+ * only ends the wait, falling back to today's outcome.
1700
+ */
1701
+ async proveDeliveryFromTranscript(delivery, checkpoint) {
1702
+ const spawn = this.spawnGeneration;
1703
+ const fence = this.launchFenceEpoch;
1704
+ let last = "unavailable";
1705
+ let outcome = "delivered";
1706
+ let evidence = OUTPUT_EDGE_ONLY_EVIDENCE;
1707
+ try {
1708
+ for (let polls = 0; polls <= TRANSCRIPT_PROOF_WINDOW_MS / TRANSCRIPT_PROOF_POLL_MS; polls++) {
1709
+ if (this.deliveryWritesStopping)
1710
+ return; // reconciliation owns the row now
1711
+ const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1712
+ if (found === "user" || found === "queued") {
1713
+ outcome = "delivered";
1714
+ evidence = found === "user" ? "transcript-marker" : "transcript-marker-queued";
1715
+ break;
1716
+ }
1717
+ last = found;
1718
+ if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch) {
1719
+ last = "unavailable";
1720
+ break;
1721
+ }
1722
+ await new Promise(r => setTimeout(r, TRANSCRIPT_PROOF_POLL_MS));
1723
+ }
1724
+ // Only a delta that was read on the last look and still lacks the marker is held against the delivery. A
1725
+ // pause/freeze or respawn during the final sleep must end the wait with today's labelled outcome, not
1726
+ // `uncertain`: re-check the same fixed epochs after the last await, before settling (#1207 P2).
1727
+ if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch)
1728
+ last = "unavailable";
1729
+ if (last === "no-match" && evidence === OUTPUT_EDGE_ONLY_EVIDENCE) {
1730
+ outcome = "uncertain";
1731
+ evidence = "unverifiable-no-transcript-marker";
1732
+ }
1733
+ }
1734
+ catch (err) {
1735
+ this.logger.debug({ err }, "Transcript proof of a delivery failed — keeping the output-edge outcome");
1736
+ }
1737
+ // The settlement itself can throw (e.g. the outbox store is unhealthy), and this runs on a detached `void`
1738
+ // promise: an uncaught throw is an unhandled rejection, which the fleet restart path turns into a global fault.
1739
+ // Settle observably and leave the row for reconciliation — never a false completion, never another paste (#1207 P2).
1740
+ try {
1741
+ if (this.deliveryWritesStopping)
1742
+ return;
1743
+ this.finishDurableDelivery(delivery, outcome, evidence);
1744
+ }
1745
+ catch (err) {
1746
+ this.logger.error({ err, deliveryId: delivery.deliveryId, attemptNo: delivery.attemptNo, outcome, evidence }, "Durable delivery settlement failed — row left for reconciliation");
1747
+ }
1748
+ }
1638
1749
  deferDurableDelivery(meta, reason) {
1639
1750
  const delivery = this.durableDeliveryAttempt(meta);
1640
1751
  if (delivery === null || delivery === false)
@@ -1691,6 +1802,19 @@ export class Daemon extends EventEmitter {
1691
1802
  return false;
1692
1803
  }
1693
1804
  }
1805
+ /**
1806
+ * #1206: rotate + attach the pipe-pane log feed. An attach failure warns
1807
+ * (like the wake path) instead of vanishing silently and leaving the
1808
+ * instance with no log feed. Extracted so the failure path is unit-testable
1809
+ * without driving the real lifecycle.
1810
+ */
1811
+ async attachPipePaneLog() {
1812
+ const outputLog = join(this.instanceDir, "output.log");
1813
+ await rotateLogIfNeededAsync(outputLog);
1814
+ await this.tmux.pipeOutput(outputLog).catch(err => {
1815
+ this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
1816
+ });
1817
+ }
1694
1818
  async start() {
1695
1819
  ensureInstanceDir(this.instanceDir);
1696
1820
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1699,6 +1823,7 @@ export class Daemon extends EventEmitter {
1699
1823
  // an unreadable marker for a later start; clear successfully read bytes
1700
1824
  // even when the JSON is corrupt so it cannot remain forever.
1701
1825
  const crashStatePath = join(this.instanceDir, "crash-state.json");
1826
+ this.bootSkippedResume = false;
1702
1827
  try {
1703
1828
  if (existsSync(crashStatePath)) {
1704
1829
  const content = readFileSync(crashStatePath, "utf-8");
@@ -1706,6 +1831,7 @@ export class Daemon extends EventEmitter {
1706
1831
  const state = JSON.parse(content);
1707
1832
  if (state?.resumeDisabled === true) {
1708
1833
  this.skipResume = true;
1834
+ this.bootSkippedResume = true;
1709
1835
  this.logger.warn("Previous crash loop detected — starting without resume");
1710
1836
  }
1711
1837
  }
@@ -1844,6 +1970,10 @@ export class Daemon extends EventEmitter {
1844
1970
  }
1845
1971
  catch { /* may not exist */ }
1846
1972
  }
1973
+ // #1209: conditional continuation for the turn interrupted by the last
1974
+ // restart, if the gates allow. Detached like the warmup notice; the gate
1975
+ // consumes its one-shot marker first and never throws into boot.
1976
+ void this.maybeResumeInterruptedTurn().catch(() => { });
1847
1977
  // Warmup: wait for CLI idle, then trigger steering reload — but only when
1848
1978
  // the instructions actually changed since the agent last saw them.
1849
1979
  // Skipping the no-op reload saves 10-30s of agent time on every restart
@@ -1854,9 +1984,7 @@ export class Daemon extends EventEmitter {
1854
1984
  // previous stuck splash (hundreds of MB of ANSI frames) is truncated before
1855
1985
  // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
1856
1986
  // keeps the writer attached after size resets.
1857
- const outputLog = join(this.instanceDir, "output.log");
1858
- await rotateLogIfNeededAsync(outputLog);
1859
- await this.tmux.pipeOutput(outputLog).catch(() => { });
1987
+ await this.attachPipePaneLog();
1860
1988
  // 4. Transcript monitor. claude-code is handled inside the monitor
1861
1989
  // (statusline transcript); codex/kiro/opencode read their CLI's own
1862
1990
  // conversation store via a pluggable source. Backends with no known
@@ -2811,14 +2939,28 @@ export class Daemon extends EventEmitter {
2811
2939
  // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
2812
2940
  const pollSpawn = this.spawnGeneration;
2813
2941
  const pollFence = this.launchFenceEpoch;
2814
- const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch;
2942
+ const pollOwner = this.interactionOwner();
2943
+ const captureOrder = ++this.interactionCaptureSerial;
2944
+ const captureMono = performance.now();
2945
+ const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
2946
+ || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
2815
2947
  try {
2816
2948
  const alive = await this.tmux.isWindowAlive();
2949
+ if (stale())
2950
+ return;
2817
2951
  if (!alive)
2818
2952
  return;
2953
+ const captureAt = Date.now();
2819
2954
  const pane = await this.tmux.capturePane();
2820
2955
  if (stale())
2821
2956
  return;
2957
+ if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
2958
+ this.unverifyInteraction(captureMono, captureOrder);
2959
+ }
2960
+ else {
2961
+ if (!this.observeInteractionPane(pane, captureAt, captureMono, dialogs, captureOrder))
2962
+ return;
2963
+ }
2822
2964
  this.takeNudgeBaselines(pane, patterns);
2823
2965
  this.tickCapacityNudge(pane);
2824
2966
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
@@ -2901,12 +3043,21 @@ export class Daemon extends EventEmitter {
2901
3043
  // Re-read under the lock: the first capture may have gone stale
2902
3044
  // while a delivery was finishing. A stale danger menu must never
2903
3045
  // receive a blind key sequence.
3046
+ const currentAt = Date.now();
3047
+ const currentMono = performance.now();
3048
+ const currentOrder = ++this.interactionCaptureSerial;
2904
3049
  const currentPane = await this.tmux.capturePane();
2905
3050
  // Every await below can outlive the spawn / monitors this poll started under: after each one, before
2906
3051
  // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
2907
3052
  if (stale())
2908
3053
  return;
2909
3054
  if (!Daemon.dialogMatches(dialog, currentPane)) {
3055
+ if (this.instanceStateLastOutputAt >= currentAt && this.instanceStateLastOutputAt > 0) {
3056
+ this.unverifyInteraction(currentMono, currentOrder);
3057
+ return;
3058
+ }
3059
+ if (!this.observeInteractionPane(currentPane, currentAt, currentMono, dialogs, currentOrder))
3060
+ return;
2910
3061
  this.updateInputBlockedState(currentPane, dialogs);
2911
3062
  this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
2912
3063
  return;
@@ -2952,6 +3103,9 @@ export class Daemon extends EventEmitter {
2952
3103
  return;
2953
3104
  }
2954
3105
  if (dialog.verifyAfterKeys) {
3106
+ const afterKeysAt = Date.now();
3107
+ const afterKeysMono = performance.now();
3108
+ const afterKeysOrder = ++this.interactionCaptureSerial;
2955
3109
  const afterKeysPane = await this.tmux.capturePane();
2956
3110
  if (stale())
2957
3111
  return;
@@ -2962,6 +3116,12 @@ export class Daemon extends EventEmitter {
2962
3116
  this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
2963
3117
  return;
2964
3118
  }
3119
+ if (this.instanceStateLastOutputAt >= afterKeysAt && this.instanceStateLastOutputAt > 0) {
3120
+ this.unverifyInteraction(afterKeysMono, afterKeysOrder);
3121
+ return;
3122
+ }
3123
+ if (!this.observeInteractionPane(afterKeysPane, afterKeysAt, afterKeysMono, dialogs, afterKeysOrder))
3124
+ return;
2965
3125
  this.updateInputBlockedState(afterKeysPane, dialogs);
2966
3126
  if (dialog.postDismissNotice) {
2967
3127
  resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
@@ -3010,6 +3170,8 @@ export class Daemon extends EventEmitter {
3010
3170
  this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
3011
3171
  }
3012
3172
  catch {
3173
+ if (!stale())
3174
+ this.unverifyInteraction(captureMono, captureOrder);
3013
3175
  // capturePane can fail if window is transitioning — ignore
3014
3176
  }
3015
3177
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
@@ -3251,6 +3413,10 @@ export class Daemon extends EventEmitter {
3251
3413
  // FleetManager calls this synchronously before sendEscape. Cancel only the
3252
3414
  // current human turn; leave already-forwarded tool requests to settle.
3253
3415
  this.turnReplyGuard.cancelByUser();
3416
+ // #1209: a cancelled turn must never resume (#1199) — drop its marker.
3417
+ // (Completion paths clear via the guard's onComplete; a cancel with no
3418
+ // later idle edge would otherwise leave the marker behind.)
3419
+ clearInFlightTurnMarker(this.instanceDir);
3254
3420
  this.deliveryEpoch = fleetEpoch === undefined
3255
3421
  ? this.deliveryEpoch + 1
3256
3422
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -3683,12 +3849,107 @@ export class Daemon extends EventEmitter {
3683
3849
  return this.isPaused ? "paused" : this.instanceState;
3684
3850
  }
3685
3851
  getInstanceStateSnapshot() {
3686
- return this.instanceStateMachine?.snapshot() ?? {
3852
+ const snapshot = this.instanceStateMachine?.snapshot() ?? {
3687
3853
  state: this.instanceState,
3688
3854
  unchangedForMs: 0,
3689
3855
  observedAt: Date.now(),
3690
3856
  stateChangedAt: Date.now(),
3691
3857
  };
3858
+ // A machine observation rejected by the modal hold never became a control
3859
+ // state. A query must not publish that unaccepted idle as permission to act.
3860
+ if (this.inputBlockedDialogKey !== null) {
3861
+ snapshot.state = this.instanceState;
3862
+ snapshot.stateChangedAt = this.acceptedInstanceSnapshot?.stateChangedAt ?? snapshot.stateChangedAt;
3863
+ }
3864
+ return { ...snapshot, interaction: this.getInteractionSnapshot() };
3865
+ }
3866
+ interactionOwner() {
3867
+ return { bootId: this.bootId, spawnGeneration: this.spawnGeneration,
3868
+ launchAttempt: this.launchAttempt, launchFenceEpoch: this.launchFenceEpoch };
3869
+ }
3870
+ /** Cached, pure observation read; it never captures, constructs a backend or writes stdin. */
3871
+ getInteractionSnapshot() {
3872
+ return this.interactionObservation.snapshot(this.interactionOwner());
3873
+ }
3874
+ publishInteraction() {
3875
+ const interaction = this.getInteractionSnapshot();
3876
+ // Presentation telemetry must not interrupt the execution-state/hold path.
3877
+ try {
3878
+ this.emit("instance_interaction", { name: this.name, interaction });
3879
+ }
3880
+ catch (err) {
3881
+ this.logger.debug({ err }, "Interaction listener failed");
3882
+ }
3883
+ try {
3884
+ this.ipcServer?.broadcast({ type: "instance_interaction", instanceName: this.name, interaction });
3885
+ }
3886
+ catch (err) {
3887
+ this.logger.debug({ err }, "Interaction broadcast failed");
3888
+ }
3889
+ }
3890
+ clearInteractionConfirmation() {
3891
+ if (this.interactionConfirmationTimer)
3892
+ clearTimeout(this.interactionConfirmationTimer);
3893
+ this.interactionConfirmationTimer = null;
3894
+ }
3895
+ unverifyInteraction(monotonicAt = performance.now(), order = ++this.interactionCaptureSerial) {
3896
+ if (!this.interactionObservation.unverify(this.interactionOwner(), monotonicAt, order))
3897
+ return;
3898
+ this.clearInteractionConfirmation();
3899
+ this.publishInteraction();
3900
+ }
3901
+ interactionEvidence(pane, dialogs) {
3902
+ const login = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
3903
+ let evidence = null;
3904
+ if (login?.loginScreenActive?.(pane)) {
3905
+ evidence = { kind: "login", identity: pane.trim() };
3906
+ }
3907
+ else {
3908
+ // Bare backend regexes are not proof of a live modal. The canonical
3909
+ // isActive predicate replaces pattern.test(), preserving table order.
3910
+ const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
3911
+ && Daemon.dialogMatches(d, pane));
3912
+ if (dialog) {
3913
+ const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
3914
+ .split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
3915
+ .filter(row => row.trim() !== "").join("\n");
3916
+ evidence = { kind: dialog.interactionKind ?? "dialog", dialogKey: Daemon.dialogKey(dialog), identity: `${dialog.pattern.source}\n${identity}` };
3917
+ }
3918
+ else {
3919
+ const weak = this.interactivePromptDetector.match(pane);
3920
+ if (weak)
3921
+ evidence = { kind: "suspected_terminal_input", identity: sanitizePaneTail(pane, 5).join("\n"), suspected: true };
3922
+ }
3923
+ }
3924
+ // No captured text leaves this function or the private observer identity.
3925
+ return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
3926
+ }
3927
+ observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.backend?.getRuntimeDialogs?.() ?? [], order = ++this.interactionCaptureSerial, allowConfirmation = true) {
3928
+ const owner = this.interactionOwner();
3929
+ const evidence = this.interactionEvidence(pane, dialogs);
3930
+ if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
3931
+ return false;
3932
+ this.interactionDialogKey = evidence?.dialogKey ?? null;
3933
+ this.interactionObservedOutputRevision = this.interactionOutputRevision;
3934
+ const interaction = this.getInteractionSnapshot();
3935
+ this.publishInteraction();
3936
+ if (interaction.phase !== "candidate" || interaction.suspected) {
3937
+ this.clearInteractionConfirmation();
3938
+ return true;
3939
+ }
3940
+ // One bounded extra read per ordinary observation. A confirmation that
3941
+ // finds a changed request waits for the existing monitor's next capture;
3942
+ // it cannot recursively create a permanent fast poll.
3943
+ if (allowConfirmation && !this.interactionConfirmationTimer) {
3944
+ this.interactionConfirmationTimer = setTimeout(() => {
3945
+ this.interactionConfirmationTimer = null;
3946
+ if (!sameInteractionOwner(owner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning)
3947
+ return;
3948
+ void this.captureAndEvaluateInstanceState("interaction_confirmation");
3949
+ }, INTERACTION_CONFIRM_MS);
3950
+ this.interactionConfirmationTimer.unref?.();
3951
+ }
3952
+ return true;
3692
3953
  }
3693
3954
  /**
3694
3955
  * Answer a FleetManager state query. Most callers only need the cached state,
@@ -3954,6 +4215,7 @@ export class Daemon extends EventEmitter {
3954
4215
  return;
3955
4216
  }
3956
4217
  this.instanceState = snapshot.state;
4218
+ this.acceptedInstanceSnapshot = { ...snapshot };
3957
4219
  // OpenCode creates its session lazily on the first submitted message.
3958
4220
  // Waiting until stop/pause to persist that id loses resume state when the
3959
4221
  // fleet is SIGKILLed or the host reboots.
@@ -4055,6 +4317,125 @@ export class Daemon extends EventEmitter {
4055
4317
  correlationId: meta.correlation_id || undefined,
4056
4318
  inboundMarker,
4057
4319
  });
4320
+ // #1209: persist the in-flight turn with a seam checkpoint. A marker
4321
+ // that survives a restart is an interrupted turn by construction —
4322
+ // completion (via the guard's onComplete) and cancel paths delete it.
4323
+ // Synchronous by necessity: a crash between arm and write must not lose
4324
+ // the interruption. Reads are windowed/bounded (see turn-resume.ts).
4325
+ // A resumed continuation never re-arms (P3a): episode-once means a crash
4326
+ // during the continuation boots clean instead of chaining resumes.
4327
+ if (!meta.resumedContinuationOf) {
4328
+ const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
4329
+ const resumeCwd = this.config.working_directory ?? "";
4330
+ writeInFlightTurnMarker(this.instanceDir, {
4331
+ version: 1,
4332
+ deliveryId: meta.delivery_id || undefined,
4333
+ correlationId: meta.correlation_id || undefined,
4334
+ messageId: meta.message_id || undefined,
4335
+ chatId: meta.chat_id,
4336
+ threadId: meta.thread_id || undefined,
4337
+ adapterId: meta.adapter_id || undefined,
4338
+ backend: resumeBackend ?? "unknown",
4339
+ cwd: resumeCwd,
4340
+ armedAt: Date.now(),
4341
+ before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), {
4342
+ instanceDir: this.instanceDir,
4343
+ workingDirectory: resumeCwd,
4344
+ }),
4345
+ });
4346
+ }
4347
+ }
4348
+ /** #1209: which seam reader serves this instance (live config preferred). */
4349
+ resolveResumeBackend() {
4350
+ return normalizeResumeBackend(this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend);
4351
+ }
4352
+ /** #1209: whether the marker's delivery is still owned by the durable path. */
4353
+ isMarkerDeliveryPending(marker) {
4354
+ const id = marker.deliveryId;
4355
+ if (!id)
4356
+ return false;
4357
+ try {
4358
+ const row = this.deliveryOutbox?.get?.(id);
4359
+ return !!row && RESUME_BLOCKING_DELIVERY_STATES.includes(row.state);
4360
+ }
4361
+ catch {
4362
+ return false;
4363
+ }
4364
+ }
4365
+ /**
4366
+ * #1209 ACP-resume-like gate: one bounded continuation for the turn that
4367
+ * was in flight when the process died. The one-shot marker is consumed
4368
+ * FIRST (episode-once: at most one continuation per interrupted turn, even
4369
+ * if the inject below throws or the process dies holding the grace wait),
4370
+ * then the pure gate in turn-resume.ts decides. A hold inside the flush
4371
+ * grace sleeps once and re-gates with a fresh read; a second hold denies
4372
+ * rather than waiting forever. Returns a short outcome for logs/tests.
4373
+ */
4374
+ async maybeResumeInterruptedTurn() {
4375
+ const marker = consumeInFlightTurnMarker(this.instanceDir);
4376
+ if (!marker)
4377
+ return "none";
4378
+ // P1 fence: capture before any await. A cancel (deliveryEpoch),
4379
+ // respawn (spawnGeneration) or stop/pause (launchFenceEpoch) during the
4380
+ // grace hold must stale this gate — the push below rechecks, and the
4381
+ // push path itself carries the then-current epoch, so a post-push
4382
+ // cancel still drops the queued paste. Fence follows the write (#1192).
4383
+ const gateEpoch = this.deliveryEpoch;
4384
+ const gateSpawn = this.spawnGeneration;
4385
+ const gateFence = this.launchFenceEpoch;
4386
+ const gateFresh = () => this.deliveryEpoch === gateEpoch
4387
+ && this.spawnGeneration === gateSpawn
4388
+ && this.launchFenceEpoch === gateFence;
4389
+ const backend = this.resolveResumeBackend() ?? normalizeResumeBackend(marker.backend);
4390
+ const roots = { instanceDir: this.instanceDir, workingDirectory: this.config.working_directory ?? "" };
4391
+ // This boot's CLI spawn is what the daemon itself caused: pre-restart
4392
+ // writes (including pre-crash progress) are excluded, and the flush
4393
+ // grace holds the decision until post-spawn writes are observable.
4394
+ const daemonCausedMtimeMs = this.lastSpawnAt > 0 ? this.lastSpawnAt : -1;
4395
+ const gate = (after, nowMs) => decideTurnResume({
4396
+ marker,
4397
+ before: marker.before,
4398
+ after,
4399
+ daemonCausedMtimeMs,
4400
+ nowMs,
4401
+ crashLoopBoot: this.bootSkippedResume,
4402
+ deliveryPending: this.isMarkerDeliveryPending(marker),
4403
+ });
4404
+ const readAfter = () => backend ? readResumeFingerprint(backend, roots) : null;
4405
+ let decision = gate(readAfter(), Date.now());
4406
+ if (decision.action === "wait") {
4407
+ const waitMs = decision.waitMs;
4408
+ await new Promise(resolve => setTimeout(resolve, waitMs));
4409
+ const recheck = gate(readAfter(), Date.now());
4410
+ decision = recheck.action === "wait"
4411
+ ? {
4412
+ action: "skip",
4413
+ reason: "unobservable",
4414
+ detail: "still inside the flush grace after the hold; default-deny",
4415
+ }
4416
+ : recheck;
4417
+ }
4418
+ // The hold above is the only await on this path: recheck the fence
4419
+ // after it (and before the write). Stale → skip quietly, never an
4420
+ // error notice — the cancel/stop owner already reported itself.
4421
+ if (!gateFresh()) {
4422
+ this.logger.info("Interrupted-turn continuation dropped — cancel/stop/pause/respawn during the grace hold");
4423
+ return "skipped-stale";
4424
+ }
4425
+ if (decision.action === "inject") {
4426
+ const continuation = buildResumeContinuation(marker);
4427
+ try {
4428
+ this.pushChannelMessage(continuation.text, continuation.meta);
4429
+ }
4430
+ catch (err) {
4431
+ this.logger.warn({ err }, "Interrupted-turn continuation failed to queue");
4432
+ return "inject-failed";
4433
+ }
4434
+ this.logger.info({ correlationId: marker.correlationId }, "Injected one bounded continuation for the interrupted turn");
4435
+ return "injected";
4436
+ }
4437
+ this.logger.info({ reason: decision.reason, detail: decision.detail }, "Interrupted turn NOT resumed");
4438
+ return `skipped-${decision.reason}`;
4058
4439
  }
4059
4440
  /**
4060
4441
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -4481,11 +4862,17 @@ export class Daemon extends EventEmitter {
4481
4862
  }
4482
4863
  this.statePollInFlight = true;
4483
4864
  const captureStartedAt = Date.now();
4865
+ const interactionCaptureAt = performance.now();
4866
+ const interactionCaptureOrder = ++this.interactionCaptureSerial;
4867
+ const interactionOwner = this.interactionOwner();
4484
4868
  const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
4485
4869
  const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
4486
4870
  && !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
4487
4871
  try {
4488
- const pane = await this.tmux.capturePane();
4872
+ const pane = reason === "interaction_confirmation"
4873
+ ? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
4874
+ if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
4875
+ return;
4489
4876
  // An old capture must not retire a new launch's transient guard either.
4490
4877
  if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4491
4878
  this.resetFooterFallback();
@@ -4499,7 +4886,6 @@ export class Daemon extends EventEmitter {
4499
4886
  this.resetFooterFallback();
4500
4887
  return;
4501
4888
  }
4502
- this.updateInputBlockedState(pane);
4503
4889
  // A monitor/lifecycle capture can see a busy moment between fleet polls.
4504
4890
  // Even a stale capture of changed content disproves continuous stability;
4505
4891
  // invalidate it before the output-freshness check can return early.
@@ -4512,6 +4898,7 @@ export class Daemon extends EventEmitter {
4512
4898
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
4513
4899
  || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
4514
4900
  if (outputMovedDuringCapture) {
4901
+ this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
4515
4902
  if (reason === "delivery_idle_gate")
4516
4903
  this.resetFooterFallback();
4517
4904
  if (reason === "output_probe") {
@@ -4537,6 +4924,11 @@ export class Daemon extends EventEmitter {
4537
4924
  }
4538
4925
  return;
4539
4926
  }
4927
+ if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
4928
+ return;
4929
+ if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
4930
+ return;
4931
+ this.updateInputBlockedState(pane);
4540
4932
  // Advance the shared 10s timer only for a fresh capture of this launch.
4541
4933
  const deliveryFallback = deliveryCandidate === true
4542
4934
  ? this.deliveryInputReadyPane(pane) : deliveryCandidate;
@@ -4709,6 +5101,8 @@ export class Daemon extends EventEmitter {
4709
5101
  }
4710
5102
  }
4711
5103
  catch (err) {
5104
+ if (currentDeliveryCapture() && sameInteractionOwner(interactionOwner, this.interactionOwner()))
5105
+ this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
4712
5106
  if (reason === "delivery_idle_gate")
4713
5107
  this.resetFooterFallback();
4714
5108
  this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
@@ -4724,6 +5118,7 @@ export class Daemon extends EventEmitter {
4724
5118
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
4725
5119
  return;
4726
5120
  this.instanceStateLastOutputAt = event.at;
5121
+ this.interactionOutputRevision++;
4727
5122
  const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
4728
5123
  if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
4729
5124
  // Do not emit idle→working solely because agy repainted an identical
@@ -4787,6 +5182,7 @@ export class Daemon extends EventEmitter {
4787
5182
  void this.captureAndEvaluateInstanceState("initial");
4788
5183
  }
4789
5184
  stopInstanceStateMonitor() {
5185
+ this.clearInteractionConfirmation();
4790
5186
  this.instanceStateMonitorActive = false;
4791
5187
  this.instanceStatePeriodicIdleConfirmations = 0;
4792
5188
  this.clearInstanceStateIdleTimer();
@@ -4845,6 +5241,8 @@ export class Daemon extends EventEmitter {
4845
5241
  freezeRuntimeMonitors() {
4846
5242
  this.runtimeMonitorsFrozen = true;
4847
5243
  this.launchFenceEpoch++;
5244
+ this.interactionObservation.reset(this.interactionOwner());
5245
+ this.clearInteractionConfirmation();
4848
5246
  if (this.healthCheckTimer) {
4849
5247
  clearTimeout(this.healthCheckTimer);
4850
5248
  this.healthCheckTimer = null;
@@ -6113,10 +6511,18 @@ export class Daemon extends EventEmitter {
6113
6511
  this.logger.info({ dialog: this.dialogParkedKey }, "Blocking dialog is gone from the pane");
6114
6512
  this.dialogParkedSince = 0;
6115
6513
  this.dialogParkedKey = null;
6514
+ this.dialogParkedInteractionEpisode = null;
6116
6515
  this.dialogParkedReported = false;
6117
6516
  return;
6118
6517
  }
6119
6518
  const key = Daemon.dialogKey(dialog);
6519
+ const interaction = this.getInteractionSnapshot();
6520
+ const episode = this.interactionDialogKey === key ? interaction.episode : null;
6521
+ if (episode !== null && this.dialogParkedInteractionEpisode !== null && episode !== this.dialogParkedInteractionEpisode) {
6522
+ this.dialogParkedSince = 0;
6523
+ }
6524
+ if (episode !== null)
6525
+ this.dialogParkedInteractionEpisode = episode;
6120
6526
  if (this.dialogParkedSince === 0 || this.dialogParkedKey !== key) {
6121
6527
  this.dialogParkedSince = Date.now();
6122
6528
  this.dialogParkedKey = key;
@@ -6126,7 +6532,8 @@ export class Daemon extends EventEmitter {
6126
6532
  if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
6127
6533
  this.dialogParkedReported = true;
6128
6534
  this.logger.warn({ dialog: dialog.description, parkedForMs: Date.now() - this.dialogParkedSince }, "CLI dialog is still on screen — not auto-answering, reporting for a human");
6129
- this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true });
6535
+ this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
6536
+ ...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}) });
6130
6537
  }
6131
6538
  }
6132
6539
  /**
@@ -6882,6 +7289,13 @@ export class Daemon extends EventEmitter {
6882
7289
  return false;
6883
7290
  }
6884
7291
  verdict.durableBeginCommitted = true;
7292
+ if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
7293
+ verdict.transcriptCheckpoint = {
7294
+ backend: attemptEvidence.backend,
7295
+ path: attemptEvidence.transcriptPath,
7296
+ offset: attemptEvidence.transcriptOffset,
7297
+ };
7298
+ }
6885
7299
  }
6886
7300
  if (rawPaste)
6887
7301
  verdict.paneWriteStarted = true;
@@ -7174,6 +7588,12 @@ export class Daemon extends EventEmitter {
7174
7588
  if (becameBusy) {
7175
7589
  if (status)
7176
7590
  this.emit("message_confirmed", status); // ✅
7591
+ // A backend whose input row cannot be read was confirmed by "the pane printed something after Enter" — which a
7592
+ // redraw that wiped the paste also satisfies (#758). The outbox must not call that a submission proof.
7593
+ // A steer keeps its pre-#758 settlement (steer-accepted / positive submission proof): routing it into the
7594
+ // transcript-proof wait would hold a steer to `uncertain` whenever the CLI files no transcript marker (#1207 P2).
7595
+ if (!rawPaste && !steer && !this.canProveSubmission())
7596
+ verdict.proof = "output-edge";
7177
7597
  }
7178
7598
  else {
7179
7599
  const proof = this.backend?.isDeliveryInputReadyPane
@@ -7725,6 +8145,12 @@ export class Daemon extends EventEmitter {
7725
8145
  // for this turn: the agent proved it can still speak for itself.
7726
8146
  if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
7727
8147
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
8148
+ // #1209 P2: the user got an answer — the turn is over for resume
8149
+ // purposes even before the idle edge completes the guard. Without
8150
+ // this, a restart in the reply-to-idle window would re-inject a
8151
+ // continuation for an already-answered turn.
8152
+ if (replyAttempt?.completionAction)
8153
+ clearInFlightTurnMarker(this.instanceDir);
7728
8154
  }
7729
8155
  const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
7730
8156
  if (!sent) {
@@ -7743,16 +8169,21 @@ export class Daemon extends EventEmitter {
7743
8169
  this.handleReleaseRepo(args, respond);
7744
8170
  return;
7745
8171
  }
7746
- if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis") {
8172
+ if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis"
8173
+ || tool === "list_stickers" || tool === "preview_stickers") {
7747
8174
  const type = tool === "set_display_name" ? "fleet_set_display_name"
7748
8175
  : tool === "set_description" ? "fleet_set_description"
7749
8176
  : tool === "list_emojis" ? "fleet_list_emojis"
7750
- : tool === "preview_emojis" ? "fleet_preview_emojis" : "fleet_set_persona_emoji";
8177
+ : tool === "preview_emojis" ? "fleet_preview_emojis"
8178
+ : tool === "list_stickers" ? "fleet_list_stickers"
8179
+ : tool === "preview_stickers" ? "fleet_preview_stickers" : "fleet_set_persona_emoji";
7751
8180
  const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
7752
8181
  const timeout = setTimeout(() => {
7753
8182
  this.pendingIpcRequests.delete(fleetReqId);
7754
8183
  respond(null, `${tool} timed out`);
7755
- }, 10_000);
8184
+ // The shared budget (ipc-timeouts.ts), so the MCP ceiling stays above it and this message is the one seen;
8185
+ // a preview downloads pictures and needs more than the old fixed 10s.
8186
+ }, daemonBudgetMs(tool));
7756
8187
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
7757
8188
  clearTimeout(timeout);
7758
8189
  respond(respMsg.result, respMsg.error);
@@ -8176,6 +8607,8 @@ export class Daemon extends EventEmitter {
8176
8607
  this.turnReplyGuard.reset();
8177
8608
  if (this.spawnDepth === 0) {
8178
8609
  this.spawnGeneration++;
8610
+ this.interactionObservation.reset(this.interactionOwner());
8611
+ this.clearInteractionConfirmation();
8179
8612
  this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
8180
8613
  ? this.spawnGeneration
8181
8614
  : null;
@@ -8850,6 +9283,8 @@ export class Daemon extends EventEmitter {
8850
9283
  // Every launched command re-arms the passive-transient check, including a
8851
9284
  // retry inside the same spawn: its load is a new one.
8852
9285
  this.launchAttempt++;
9286
+ this.interactionObservation.reset(this.interactionOwner());
9287
+ this.clearInteractionConfirmation();
8853
9288
  // A launch that cannot paint a passive transient (Codex: a fresh start
8854
9289
  // never shows "Resuming session…") must not honour one quoted on screen.
8855
9290
  if (this.backend.launchMayShowInputTransient?.() === false)