@songsid/agend 2.1.11-beta.6 → 2.1.12-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +23 -15
  2. package/README.zh-TW.md +50 -11
  3. package/dist/backend/claude-code.d.ts +2 -0
  4. package/dist/backend/claude-code.js +63 -0
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/types.d.ts +25 -0
  7. package/dist/backend/types.js.map +1 -1
  8. package/dist/classic-bindings.d.ts +48 -0
  9. package/dist/classic-bindings.js +128 -0
  10. package/dist/classic-bindings.js.map +1 -0
  11. package/dist/classic-channel-manager.d.ts +21 -0
  12. package/dist/classic-channel-manager.js +31 -0
  13. package/dist/classic-channel-manager.js.map +1 -1
  14. package/dist/daemon.d.ts +41 -0
  15. package/dist/daemon.js +299 -16
  16. package/dist/daemon.js.map +1 -1
  17. package/dist/delivery-reconciliation.d.ts +13 -0
  18. package/dist/delivery-reconciliation.js +71 -7
  19. package/dist/delivery-reconciliation.js.map +1 -1
  20. package/dist/delivery-reconciliation.test.js +115 -2
  21. package/dist/delivery-reconciliation.test.js.map +1 -1
  22. package/dist/fleet-manager.d.ts +14 -1
  23. package/dist/fleet-manager.js +75 -8
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.js +4 -2
  26. package/dist/instance-lifecycle.js.map +1 -1
  27. package/dist/interaction-observation.d.ts +32 -0
  28. package/dist/interaction-observation.js +118 -0
  29. package/dist/interaction-observation.js.map +1 -0
  30. package/dist/kiro-engine-status.d.ts +2 -0
  31. package/dist/kiro-engine-status.js +3 -1
  32. package/dist/kiro-engine-status.js.map +1 -1
  33. package/dist/locale.js +22 -0
  34. package/dist/locale.js.map +1 -1
  35. package/dist/outbound-handlers.d.ts +2 -0
  36. package/dist/outbound-handlers.js +28 -11
  37. package/dist/outbound-handlers.js.map +1 -1
  38. package/dist/outbound-schemas.js +1 -1
  39. package/dist/outbound-schemas.js.map +1 -1
  40. package/dist/output-log-tail.d.ts +21 -0
  41. package/dist/output-log-tail.js +89 -0
  42. package/dist/output-log-tail.js.map +1 -0
  43. package/dist/usage/providers.d.ts +11 -0
  44. package/dist/usage/providers.js +34 -7
  45. package/dist/usage/providers.js.map +1 -1
  46. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -34,6 +34,8 @@ 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 { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
37
39
  const __filename = fileURLToPath(import.meta.url);
38
40
  const __dirname = dirname(__filename);
39
41
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -440,6 +442,18 @@ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
440
442
  /** A final bounded observation before an ambiguous Codex submit can be classified. */
441
443
  const CODEX_LATE_PROOF_MS = 5_000;
442
444
  const POST_ENTER_PROOF_POLL_MS = 250;
445
+ /**
446
+ * A readerless CLI's delivery is proven by its own transcript, which lags the pane by a flush. Only a delta that was
447
+ * read and carried no marker for this long is held against the delivery — a late flush must not raise a false ⚠️.
448
+ * Ten seconds is a chosen grace window: success markers arrive in about a second, and the bound keeps the failure
449
+ * path small — the fleet lane and the shared pump budget stay held until the verdict (Phase 2c "lane held until
450
+ * verdict", Option A #1207), so a lost paste blocks its lane for ~10 s. No flush-visibility claim is made here: a
451
+ * flush landing after the window still settles `uncertain`, and only a final-file scan (see the PR evidence) shows
452
+ * every recorded delivery did reach its transcript.
453
+ */
454
+ const TRANSCRIPT_PROOF_WINDOW_MS = 10_000;
455
+ const TRANSCRIPT_PROOF_POLL_MS = 1_000;
456
+ const OUTPUT_EDGE_ONLY_EVIDENCE = "output-edge-only; submission-unverifiable";
443
457
  /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
444
458
  const BASELINE_CAPTURE_ATTEMPTS = 3;
445
459
  const BASELINE_CAPTURE_RETRY_MS = 150;
@@ -851,17 +865,20 @@ export class InteractivePromptDetector {
851
865
  constructor(stableMs = 10_000) {
852
866
  this.stableMs = stableMs;
853
867
  }
854
- observe(pane, now = Date.now(), outputAt = 0) {
855
- const tail = sanitizePaneTail(pane, 5);
856
- const tailText = tail.join("\n");
857
- let matched = null;
868
+ /** Recognition is persistent; observe() separately deduplicates notifications. */
869
+ match(pane) {
870
+ const tailText = sanitizePaneTail(pane, 5).join("\n");
858
871
  for (const candidate of INTERACTIVE_PROMPT_PATTERNS) {
859
872
  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;
873
+ if (match)
874
+ return { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
864
875
  }
876
+ return null;
877
+ }
878
+ observe(pane, now = Date.now(), outputAt = 0) {
879
+ const tail = sanitizePaneTail(pane, 5);
880
+ const tailText = tail.join("\n");
881
+ const matched = this.match(pane);
865
882
  if (!matched) {
866
883
  this.reset();
867
884
  this.lastOutputAt = outputAt;
@@ -1055,6 +1072,7 @@ export class Daemon extends EventEmitter {
1055
1072
  dialogParkedSince = 0;
1056
1073
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
1057
1074
  dialogParkedKey = null;
1075
+ dialogParkedInteractionEpisode = null;
1058
1076
  dialogParkedReported = false;
1059
1077
  /**
1060
1078
  * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
@@ -1080,6 +1098,13 @@ export class Daemon extends EventEmitter {
1080
1098
  capacityNudge = null;
1081
1099
  /** Current stdin-owning runtime dialog, independent from execution state. */
1082
1100
  inputBlockedDialogKey = null;
1101
+ interactionObservation = new InteractionObservation();
1102
+ interactionConfirmationTimer = null;
1103
+ interactionOutputRevision = 0;
1104
+ interactionObservedOutputRevision = 0;
1105
+ interactionCaptureSerial = 0;
1106
+ interactionDialogKey = null;
1107
+ acceptedInstanceSnapshot = null;
1083
1108
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1084
1109
  autoResolvedDialogKey = null;
1085
1110
  autoResolvedDialogGeneration = 0;
@@ -1633,8 +1658,82 @@ export class Daemon extends EventEmitter {
1633
1658
  this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
1634
1659
  return;
1635
1660
  }
1661
+ // A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
1662
+ // after Enter, which a redraw that wiped the paste satisfies too (#758). Its transcript can say more.
1663
+ if (verdict.proof === "output-edge") {
1664
+ const checkpoint = verdict.transcriptCheckpoint;
1665
+ if (delivery && checkpoint && ["claude-code", "codex"].includes(checkpoint.backend)) {
1666
+ void this.proveDeliveryFromTranscript(delivery, checkpoint);
1667
+ return;
1668
+ }
1669
+ // No transcript to ask: nothing to hold against it and nothing to prove it with. Today's outcome, honestly labelled.
1670
+ this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
1671
+ return;
1672
+ }
1636
1673
  this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1637
1674
  }
1675
+ /**
1676
+ * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
1677
+ *
1678
+ * - marker found → delivered (`transcript-marker`; `transcript-marker-queued` when the CLI has it queued, not yet read);
1679
+ * - delta read, no marker for the whole window → uncertain (`unverifiable-no-transcript-marker`) — the pane printed
1680
+ * something, yet the CLI never took the message in. Never a re-paste: the message may still land;
1681
+ * - transcript unreadable on the last look → today's outcome, labelled — there is nothing to hold against it.
1682
+ *
1683
+ * It runs after the paste lock is released, but the fleet lane and the shared pump budget stay held until the
1684
+ * verdict settles the row (Phase 2c "lane held until verdict", Option A #1207): a success settles in about a
1685
+ * second, a delivery whose marker never arrives holds them for the bounded proof window (see
1686
+ * TRANSCRIPT_PROOF_WINDOW_MS). It never finishes a row that stop has handed to reconciliation; a pause or respawn
1687
+ * only ends the wait, falling back to today's outcome.
1688
+ */
1689
+ async proveDeliveryFromTranscript(delivery, checkpoint) {
1690
+ const spawn = this.spawnGeneration;
1691
+ const fence = this.launchFenceEpoch;
1692
+ let last = "unavailable";
1693
+ let outcome = "delivered";
1694
+ let evidence = OUTPUT_EDGE_ONLY_EVIDENCE;
1695
+ try {
1696
+ for (let polls = 0; polls <= TRANSCRIPT_PROOF_WINDOW_MS / TRANSCRIPT_PROOF_POLL_MS; polls++) {
1697
+ if (this.deliveryWritesStopping)
1698
+ return; // reconciliation owns the row now
1699
+ const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1700
+ if (found === "user" || found === "queued") {
1701
+ outcome = "delivered";
1702
+ evidence = found === "user" ? "transcript-marker" : "transcript-marker-queued";
1703
+ break;
1704
+ }
1705
+ last = found;
1706
+ if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch) {
1707
+ last = "unavailable";
1708
+ break;
1709
+ }
1710
+ await new Promise(r => setTimeout(r, TRANSCRIPT_PROOF_POLL_MS));
1711
+ }
1712
+ // Only a delta that was read on the last look and still lacks the marker is held against the delivery. A
1713
+ // pause/freeze or respawn during the final sleep must end the wait with today's labelled outcome, not
1714
+ // `uncertain`: re-check the same fixed epochs after the last await, before settling (#1207 P2).
1715
+ if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch)
1716
+ last = "unavailable";
1717
+ if (last === "no-match" && evidence === OUTPUT_EDGE_ONLY_EVIDENCE) {
1718
+ outcome = "uncertain";
1719
+ evidence = "unverifiable-no-transcript-marker";
1720
+ }
1721
+ }
1722
+ catch (err) {
1723
+ this.logger.debug({ err }, "Transcript proof of a delivery failed — keeping the output-edge outcome");
1724
+ }
1725
+ // The settlement itself can throw (e.g. the outbox store is unhealthy), and this runs on a detached `void`
1726
+ // promise: an uncaught throw is an unhandled rejection, which the fleet restart path turns into a global fault.
1727
+ // Settle observably and leave the row for reconciliation — never a false completion, never another paste (#1207 P2).
1728
+ try {
1729
+ if (this.deliveryWritesStopping)
1730
+ return;
1731
+ this.finishDurableDelivery(delivery, outcome, evidence);
1732
+ }
1733
+ catch (err) {
1734
+ this.logger.error({ err, deliveryId: delivery.deliveryId, attemptNo: delivery.attemptNo, outcome, evidence }, "Durable delivery settlement failed — row left for reconciliation");
1735
+ }
1736
+ }
1638
1737
  deferDurableDelivery(meta, reason) {
1639
1738
  const delivery = this.durableDeliveryAttempt(meta);
1640
1739
  if (delivery === null || delivery === false)
@@ -1691,6 +1790,19 @@ export class Daemon extends EventEmitter {
1691
1790
  return false;
1692
1791
  }
1693
1792
  }
1793
+ /**
1794
+ * #1206: rotate + attach the pipe-pane log feed. An attach failure warns
1795
+ * (like the wake path) instead of vanishing silently and leaving the
1796
+ * instance with no log feed. Extracted so the failure path is unit-testable
1797
+ * without driving the real lifecycle.
1798
+ */
1799
+ async attachPipePaneLog() {
1800
+ const outputLog = join(this.instanceDir, "output.log");
1801
+ await rotateLogIfNeededAsync(outputLog);
1802
+ await this.tmux.pipeOutput(outputLog).catch(err => {
1803
+ this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
1804
+ });
1805
+ }
1694
1806
  async start() {
1695
1807
  ensureInstanceDir(this.instanceDir);
1696
1808
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1854,9 +1966,7 @@ export class Daemon extends EventEmitter {
1854
1966
  // previous stuck splash (hundreds of MB of ANSI frames) is truncated before
1855
1967
  // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
1856
1968
  // 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(() => { });
1969
+ await this.attachPipePaneLog();
1860
1970
  // 4. Transcript monitor. claude-code is handled inside the monitor
1861
1971
  // (statusline transcript); codex/kiro/opencode read their CLI's own
1862
1972
  // conversation store via a pluggable source. Backends with no known
@@ -2811,14 +2921,28 @@ export class Daemon extends EventEmitter {
2811
2921
  // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
2812
2922
  const pollSpawn = this.spawnGeneration;
2813
2923
  const pollFence = this.launchFenceEpoch;
2814
- const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch;
2924
+ const pollOwner = this.interactionOwner();
2925
+ const captureOrder = ++this.interactionCaptureSerial;
2926
+ const captureMono = performance.now();
2927
+ const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
2928
+ || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
2815
2929
  try {
2816
2930
  const alive = await this.tmux.isWindowAlive();
2931
+ if (stale())
2932
+ return;
2817
2933
  if (!alive)
2818
2934
  return;
2935
+ const captureAt = Date.now();
2819
2936
  const pane = await this.tmux.capturePane();
2820
2937
  if (stale())
2821
2938
  return;
2939
+ if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
2940
+ this.unverifyInteraction(captureMono, captureOrder);
2941
+ }
2942
+ else {
2943
+ if (!this.observeInteractionPane(pane, captureAt, captureMono, dialogs, captureOrder))
2944
+ return;
2945
+ }
2822
2946
  this.takeNudgeBaselines(pane, patterns);
2823
2947
  this.tickCapacityNudge(pane);
2824
2948
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
@@ -2901,12 +3025,21 @@ export class Daemon extends EventEmitter {
2901
3025
  // Re-read under the lock: the first capture may have gone stale
2902
3026
  // while a delivery was finishing. A stale danger menu must never
2903
3027
  // receive a blind key sequence.
3028
+ const currentAt = Date.now();
3029
+ const currentMono = performance.now();
3030
+ const currentOrder = ++this.interactionCaptureSerial;
2904
3031
  const currentPane = await this.tmux.capturePane();
2905
3032
  // Every await below can outlive the spawn / monitors this poll started under: after each one, before
2906
3033
  // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
2907
3034
  if (stale())
2908
3035
  return;
2909
3036
  if (!Daemon.dialogMatches(dialog, currentPane)) {
3037
+ if (this.instanceStateLastOutputAt >= currentAt && this.instanceStateLastOutputAt > 0) {
3038
+ this.unverifyInteraction(currentMono, currentOrder);
3039
+ return;
3040
+ }
3041
+ if (!this.observeInteractionPane(currentPane, currentAt, currentMono, dialogs, currentOrder))
3042
+ return;
2910
3043
  this.updateInputBlockedState(currentPane, dialogs);
2911
3044
  this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
2912
3045
  return;
@@ -2952,6 +3085,9 @@ export class Daemon extends EventEmitter {
2952
3085
  return;
2953
3086
  }
2954
3087
  if (dialog.verifyAfterKeys) {
3088
+ const afterKeysAt = Date.now();
3089
+ const afterKeysMono = performance.now();
3090
+ const afterKeysOrder = ++this.interactionCaptureSerial;
2955
3091
  const afterKeysPane = await this.tmux.capturePane();
2956
3092
  if (stale())
2957
3093
  return;
@@ -2962,6 +3098,12 @@ export class Daemon extends EventEmitter {
2962
3098
  this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
2963
3099
  return;
2964
3100
  }
3101
+ if (this.instanceStateLastOutputAt >= afterKeysAt && this.instanceStateLastOutputAt > 0) {
3102
+ this.unverifyInteraction(afterKeysMono, afterKeysOrder);
3103
+ return;
3104
+ }
3105
+ if (!this.observeInteractionPane(afterKeysPane, afterKeysAt, afterKeysMono, dialogs, afterKeysOrder))
3106
+ return;
2965
3107
  this.updateInputBlockedState(afterKeysPane, dialogs);
2966
3108
  if (dialog.postDismissNotice) {
2967
3109
  resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
@@ -3010,6 +3152,8 @@ export class Daemon extends EventEmitter {
3010
3152
  this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
3011
3153
  }
3012
3154
  catch {
3155
+ if (!stale())
3156
+ this.unverifyInteraction(captureMono, captureOrder);
3013
3157
  // capturePane can fail if window is transitioning — ignore
3014
3158
  }
3015
3159
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
@@ -3683,12 +3827,107 @@ export class Daemon extends EventEmitter {
3683
3827
  return this.isPaused ? "paused" : this.instanceState;
3684
3828
  }
3685
3829
  getInstanceStateSnapshot() {
3686
- return this.instanceStateMachine?.snapshot() ?? {
3830
+ const snapshot = this.instanceStateMachine?.snapshot() ?? {
3687
3831
  state: this.instanceState,
3688
3832
  unchangedForMs: 0,
3689
3833
  observedAt: Date.now(),
3690
3834
  stateChangedAt: Date.now(),
3691
3835
  };
3836
+ // A machine observation rejected by the modal hold never became a control
3837
+ // state. A query must not publish that unaccepted idle as permission to act.
3838
+ if (this.inputBlockedDialogKey !== null) {
3839
+ snapshot.state = this.instanceState;
3840
+ snapshot.stateChangedAt = this.acceptedInstanceSnapshot?.stateChangedAt ?? snapshot.stateChangedAt;
3841
+ }
3842
+ return { ...snapshot, interaction: this.getInteractionSnapshot() };
3843
+ }
3844
+ interactionOwner() {
3845
+ return { bootId: this.bootId, spawnGeneration: this.spawnGeneration,
3846
+ launchAttempt: this.launchAttempt, launchFenceEpoch: this.launchFenceEpoch };
3847
+ }
3848
+ /** Cached, pure observation read; it never captures, constructs a backend or writes stdin. */
3849
+ getInteractionSnapshot() {
3850
+ return this.interactionObservation.snapshot(this.interactionOwner());
3851
+ }
3852
+ publishInteraction() {
3853
+ const interaction = this.getInteractionSnapshot();
3854
+ // Presentation telemetry must not interrupt the execution-state/hold path.
3855
+ try {
3856
+ this.emit("instance_interaction", { name: this.name, interaction });
3857
+ }
3858
+ catch (err) {
3859
+ this.logger.debug({ err }, "Interaction listener failed");
3860
+ }
3861
+ try {
3862
+ this.ipcServer?.broadcast({ type: "instance_interaction", instanceName: this.name, interaction });
3863
+ }
3864
+ catch (err) {
3865
+ this.logger.debug({ err }, "Interaction broadcast failed");
3866
+ }
3867
+ }
3868
+ clearInteractionConfirmation() {
3869
+ if (this.interactionConfirmationTimer)
3870
+ clearTimeout(this.interactionConfirmationTimer);
3871
+ this.interactionConfirmationTimer = null;
3872
+ }
3873
+ unverifyInteraction(monotonicAt = performance.now(), order = ++this.interactionCaptureSerial) {
3874
+ if (!this.interactionObservation.unverify(this.interactionOwner(), monotonicAt, order))
3875
+ return;
3876
+ this.clearInteractionConfirmation();
3877
+ this.publishInteraction();
3878
+ }
3879
+ interactionEvidence(pane, dialogs) {
3880
+ const login = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
3881
+ let evidence = null;
3882
+ if (login?.loginScreenActive?.(pane)) {
3883
+ evidence = { kind: "login", identity: pane.trim() };
3884
+ }
3885
+ else {
3886
+ // Bare backend regexes are not proof of a live modal. The canonical
3887
+ // isActive predicate replaces pattern.test(), preserving table order.
3888
+ const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
3889
+ && Daemon.dialogMatches(d, pane));
3890
+ if (dialog) {
3891
+ const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
3892
+ .split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
3893
+ .filter(row => row.trim() !== "").join("\n");
3894
+ evidence = { kind: dialog.interactionKind ?? "dialog", dialogKey: Daemon.dialogKey(dialog), identity: `${dialog.pattern.source}\n${identity}` };
3895
+ }
3896
+ else {
3897
+ const weak = this.interactivePromptDetector.match(pane);
3898
+ if (weak)
3899
+ evidence = { kind: "suspected_terminal_input", identity: sanitizePaneTail(pane, 5).join("\n"), suspected: true };
3900
+ }
3901
+ }
3902
+ // No captured text leaves this function or the private observer identity.
3903
+ return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
3904
+ }
3905
+ observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.backend?.getRuntimeDialogs?.() ?? [], order = ++this.interactionCaptureSerial, allowConfirmation = true) {
3906
+ const owner = this.interactionOwner();
3907
+ const evidence = this.interactionEvidence(pane, dialogs);
3908
+ if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
3909
+ return false;
3910
+ this.interactionDialogKey = evidence?.dialogKey ?? null;
3911
+ this.interactionObservedOutputRevision = this.interactionOutputRevision;
3912
+ const interaction = this.getInteractionSnapshot();
3913
+ this.publishInteraction();
3914
+ if (interaction.phase !== "candidate" || interaction.suspected) {
3915
+ this.clearInteractionConfirmation();
3916
+ return true;
3917
+ }
3918
+ // One bounded extra read per ordinary observation. A confirmation that
3919
+ // finds a changed request waits for the existing monitor's next capture;
3920
+ // it cannot recursively create a permanent fast poll.
3921
+ if (allowConfirmation && !this.interactionConfirmationTimer) {
3922
+ this.interactionConfirmationTimer = setTimeout(() => {
3923
+ this.interactionConfirmationTimer = null;
3924
+ if (!sameInteractionOwner(owner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning)
3925
+ return;
3926
+ void this.captureAndEvaluateInstanceState("interaction_confirmation");
3927
+ }, INTERACTION_CONFIRM_MS);
3928
+ this.interactionConfirmationTimer.unref?.();
3929
+ }
3930
+ return true;
3692
3931
  }
3693
3932
  /**
3694
3933
  * Answer a FleetManager state query. Most callers only need the cached state,
@@ -3954,6 +4193,7 @@ export class Daemon extends EventEmitter {
3954
4193
  return;
3955
4194
  }
3956
4195
  this.instanceState = snapshot.state;
4196
+ this.acceptedInstanceSnapshot = { ...snapshot };
3957
4197
  // OpenCode creates its session lazily on the first submitted message.
3958
4198
  // Waiting until stop/pause to persist that id loses resume state when the
3959
4199
  // fleet is SIGKILLed or the host reboots.
@@ -4481,11 +4721,17 @@ export class Daemon extends EventEmitter {
4481
4721
  }
4482
4722
  this.statePollInFlight = true;
4483
4723
  const captureStartedAt = Date.now();
4724
+ const interactionCaptureAt = performance.now();
4725
+ const interactionCaptureOrder = ++this.interactionCaptureSerial;
4726
+ const interactionOwner = this.interactionOwner();
4484
4727
  const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
4485
4728
  const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
4486
4729
  && !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
4487
4730
  try {
4488
- const pane = await this.tmux.capturePane();
4731
+ const pane = reason === "interaction_confirmation"
4732
+ ? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
4733
+ if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
4734
+ return;
4489
4735
  // An old capture must not retire a new launch's transient guard either.
4490
4736
  if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4491
4737
  this.resetFooterFallback();
@@ -4499,7 +4745,6 @@ export class Daemon extends EventEmitter {
4499
4745
  this.resetFooterFallback();
4500
4746
  return;
4501
4747
  }
4502
- this.updateInputBlockedState(pane);
4503
4748
  // A monitor/lifecycle capture can see a busy moment between fleet polls.
4504
4749
  // Even a stale capture of changed content disproves continuous stability;
4505
4750
  // invalidate it before the output-freshness check can return early.
@@ -4512,6 +4757,7 @@ export class Daemon extends EventEmitter {
4512
4757
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
4513
4758
  || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
4514
4759
  if (outputMovedDuringCapture) {
4760
+ this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
4515
4761
  if (reason === "delivery_idle_gate")
4516
4762
  this.resetFooterFallback();
4517
4763
  if (reason === "output_probe") {
@@ -4537,6 +4783,11 @@ export class Daemon extends EventEmitter {
4537
4783
  }
4538
4784
  return;
4539
4785
  }
4786
+ if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
4787
+ return;
4788
+ if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
4789
+ return;
4790
+ this.updateInputBlockedState(pane);
4540
4791
  // Advance the shared 10s timer only for a fresh capture of this launch.
4541
4792
  const deliveryFallback = deliveryCandidate === true
4542
4793
  ? this.deliveryInputReadyPane(pane) : deliveryCandidate;
@@ -4709,6 +4960,8 @@ export class Daemon extends EventEmitter {
4709
4960
  }
4710
4961
  }
4711
4962
  catch (err) {
4963
+ if (currentDeliveryCapture() && sameInteractionOwner(interactionOwner, this.interactionOwner()))
4964
+ this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
4712
4965
  if (reason === "delivery_idle_gate")
4713
4966
  this.resetFooterFallback();
4714
4967
  this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
@@ -4724,6 +4977,7 @@ export class Daemon extends EventEmitter {
4724
4977
  if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
4725
4978
  return;
4726
4979
  this.instanceStateLastOutputAt = event.at;
4980
+ this.interactionOutputRevision++;
4727
4981
  const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
4728
4982
  if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
4729
4983
  // Do not emit idle→working solely because agy repainted an identical
@@ -4787,6 +5041,7 @@ export class Daemon extends EventEmitter {
4787
5041
  void this.captureAndEvaluateInstanceState("initial");
4788
5042
  }
4789
5043
  stopInstanceStateMonitor() {
5044
+ this.clearInteractionConfirmation();
4790
5045
  this.instanceStateMonitorActive = false;
4791
5046
  this.instanceStatePeriodicIdleConfirmations = 0;
4792
5047
  this.clearInstanceStateIdleTimer();
@@ -4845,6 +5100,8 @@ export class Daemon extends EventEmitter {
4845
5100
  freezeRuntimeMonitors() {
4846
5101
  this.runtimeMonitorsFrozen = true;
4847
5102
  this.launchFenceEpoch++;
5103
+ this.interactionObservation.reset(this.interactionOwner());
5104
+ this.clearInteractionConfirmation();
4848
5105
  if (this.healthCheckTimer) {
4849
5106
  clearTimeout(this.healthCheckTimer);
4850
5107
  this.healthCheckTimer = null;
@@ -6113,10 +6370,18 @@ export class Daemon extends EventEmitter {
6113
6370
  this.logger.info({ dialog: this.dialogParkedKey }, "Blocking dialog is gone from the pane");
6114
6371
  this.dialogParkedSince = 0;
6115
6372
  this.dialogParkedKey = null;
6373
+ this.dialogParkedInteractionEpisode = null;
6116
6374
  this.dialogParkedReported = false;
6117
6375
  return;
6118
6376
  }
6119
6377
  const key = Daemon.dialogKey(dialog);
6378
+ const interaction = this.getInteractionSnapshot();
6379
+ const episode = this.interactionDialogKey === key ? interaction.episode : null;
6380
+ if (episode !== null && this.dialogParkedInteractionEpisode !== null && episode !== this.dialogParkedInteractionEpisode) {
6381
+ this.dialogParkedSince = 0;
6382
+ }
6383
+ if (episode !== null)
6384
+ this.dialogParkedInteractionEpisode = episode;
6120
6385
  if (this.dialogParkedSince === 0 || this.dialogParkedKey !== key) {
6121
6386
  this.dialogParkedSince = Date.now();
6122
6387
  this.dialogParkedKey = key;
@@ -6126,7 +6391,8 @@ export class Daemon extends EventEmitter {
6126
6391
  if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
6127
6392
  this.dialogParkedReported = true;
6128
6393
  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 });
6394
+ this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
6395
+ ...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}) });
6130
6396
  }
6131
6397
  }
6132
6398
  /**
@@ -6882,6 +7148,13 @@ export class Daemon extends EventEmitter {
6882
7148
  return false;
6883
7149
  }
6884
7150
  verdict.durableBeginCommitted = true;
7151
+ if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
7152
+ verdict.transcriptCheckpoint = {
7153
+ backend: attemptEvidence.backend,
7154
+ path: attemptEvidence.transcriptPath,
7155
+ offset: attemptEvidence.transcriptOffset,
7156
+ };
7157
+ }
6885
7158
  }
6886
7159
  if (rawPaste)
6887
7160
  verdict.paneWriteStarted = true;
@@ -7174,6 +7447,12 @@ export class Daemon extends EventEmitter {
7174
7447
  if (becameBusy) {
7175
7448
  if (status)
7176
7449
  this.emit("message_confirmed", status); // ✅
7450
+ // A backend whose input row cannot be read was confirmed by "the pane printed something after Enter" — which a
7451
+ // redraw that wiped the paste also satisfies (#758). The outbox must not call that a submission proof.
7452
+ // A steer keeps its pre-#758 settlement (steer-accepted / positive submission proof): routing it into the
7453
+ // transcript-proof wait would hold a steer to `uncertain` whenever the CLI files no transcript marker (#1207 P2).
7454
+ if (!rawPaste && !steer && !this.canProveSubmission())
7455
+ verdict.proof = "output-edge";
7177
7456
  }
7178
7457
  else {
7179
7458
  const proof = this.backend?.isDeliveryInputReadyPane
@@ -8176,6 +8455,8 @@ export class Daemon extends EventEmitter {
8176
8455
  this.turnReplyGuard.reset();
8177
8456
  if (this.spawnDepth === 0) {
8178
8457
  this.spawnGeneration++;
8458
+ this.interactionObservation.reset(this.interactionOwner());
8459
+ this.clearInteractionConfirmation();
8179
8460
  this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
8180
8461
  ? this.spawnGeneration
8181
8462
  : null;
@@ -8850,6 +9131,8 @@ export class Daemon extends EventEmitter {
8850
9131
  // Every launched command re-arms the passive-transient check, including a
8851
9132
  // retry inside the same spawn: its load is a new one.
8852
9133
  this.launchAttempt++;
9134
+ this.interactionObservation.reset(this.interactionOwner());
9135
+ this.clearInteractionConfirmation();
8853
9136
  // A launch that cannot paint a passive transient (Codex: a fresh start
8854
9137
  // never shows "Resuming session…") must not honour one quoted on screen.
8855
9138
  if (this.backend.launchMayShowInputTransient?.() === false)