@songsid/agend 2.1.12-beta.7 → 2.1.12-beta.9

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 (55) hide show
  1. package/dist/backend/claude-code.d.ts +34 -1
  2. package/dist/backend/claude-code.js +123 -0
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/types.d.ts +21 -0
  5. package/dist/backend/types.js.map +1 -1
  6. package/dist/channel/adapters/discord.js +4 -0
  7. package/dist/channel/adapters/discord.js.map +1 -1
  8. package/dist/cli.js +22 -7
  9. package/dist/cli.js.map +1 -1
  10. package/dist/command-table.d.ts +1 -1
  11. package/dist/command-table.js +7 -1
  12. package/dist/command-table.js.map +1 -1
  13. package/dist/daemon.d.ts +43 -2
  14. package/dist/daemon.js +274 -72
  15. package/dist/daemon.js.map +1 -1
  16. package/dist/fleet-context.d.ts +10 -1
  17. package/dist/fleet-manager.d.ts +32 -2
  18. package/dist/fleet-manager.js +213 -6
  19. package/dist/fleet-manager.js.map +1 -1
  20. package/dist/kiro-db-reader.d.ts +65 -0
  21. package/dist/kiro-db-reader.js +258 -0
  22. package/dist/kiro-db-reader.js.map +1 -0
  23. package/dist/kiro-transcript-lane.d.ts +56 -0
  24. package/dist/kiro-transcript-lane.js +141 -0
  25. package/dist/kiro-transcript-lane.js.map +1 -0
  26. package/dist/kiro-transcript-worker.d.ts +1 -0
  27. package/dist/kiro-transcript-worker.js +28 -0
  28. package/dist/kiro-transcript-worker.js.map +1 -0
  29. package/dist/locale.js +24 -0
  30. package/dist/locale.js.map +1 -1
  31. package/dist/pane-input-residue.d.ts +2 -0
  32. package/dist/pane-input-residue.js +5 -1
  33. package/dist/pane-input-residue.js.map +1 -1
  34. package/dist/profile-control.d.ts +20 -0
  35. package/dist/profile-control.js +308 -0
  36. package/dist/profile-control.js.map +1 -0
  37. package/dist/runtime-cpu-profile.d.ts +37 -0
  38. package/dist/runtime-cpu-profile.js +123 -0
  39. package/dist/runtime-cpu-profile.js.map +1 -0
  40. package/dist/service-installer.d.ts +6 -0
  41. package/dist/service-installer.js +18 -2
  42. package/dist/service-installer.js.map +1 -1
  43. package/dist/topic-commands.d.ts +12 -1
  44. package/dist/topic-commands.js +33 -1
  45. package/dist/topic-commands.js.map +1 -1
  46. package/dist/transcript-delta-reader.d.ts +28 -0
  47. package/dist/transcript-delta-reader.js +148 -0
  48. package/dist/transcript-delta-reader.js.map +1 -0
  49. package/dist/transcript-monitor.d.ts +2 -0
  50. package/dist/transcript-monitor.js +19 -3
  51. package/dist/transcript-monitor.js.map +1 -1
  52. package/dist/transcript-sources.d.ts +12 -47
  53. package/dist/transcript-sources.js +54 -235
  54. package/dist/transcript-sources.js.map +1 -1
  55. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -30,13 +30,14 @@ import { writeSecretFile } from "./secret-file.js";
30
30
  import { PaneWriteLock } from "./pane-write-lock.js";
31
31
  import { buildFleetInstructions } from "./instructions.js";
32
32
  import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
33
- import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
33
+ import { agendMessageInInput, bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
34
34
  import { TurnReplyGuard } from "./turn-reply-guard.js";
35
35
  import { t } from "./locale.js";
36
36
  import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
37
37
  import { deliveryContentDigest } from "./delivery-outbox.js";
38
38
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
39
39
  import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
40
+ import { TranscriptDeltaReader } from "./transcript-delta-reader.js";
40
41
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
41
42
  import { codexLiveRowMatches } from "./backend/codex.js";
42
43
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
@@ -68,6 +69,10 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
68
69
  const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
69
70
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
70
71
  /** Point a resumed CLI at its one backend-native instruction source. */
72
+ /** The outbox evidence for a delivery the CLI's transcript shows taken (#758). */
73
+ function transcriptMarkerEvidence(kind) {
74
+ return kind === "user" ? "transcript-marker" : "transcript-marker-queued";
75
+ }
71
76
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
72
77
  const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
73
78
  ? "AGENTS.md"
@@ -1765,7 +1770,8 @@ export class Daemon extends EventEmitter {
1765
1770
  this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
1766
1771
  return;
1767
1772
  }
1768
- this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1773
+ this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
1774
+ ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof");
1769
1775
  }
1770
1776
  /**
1771
1777
  * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
@@ -1794,7 +1800,7 @@ export class Daemon extends EventEmitter {
1794
1800
  const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1795
1801
  if (found === "user" || found === "queued") {
1796
1802
  outcome = "delivered";
1797
- evidence = found === "user" ? "transcript-marker" : "transcript-marker-queued";
1803
+ evidence = transcriptMarkerEvidence(found);
1798
1804
  break;
1799
1805
  }
1800
1806
  last = found;
@@ -2073,6 +2079,15 @@ export class Daemon extends EventEmitter {
2073
2079
  // conversation store via a pluggable source. Backends with no known
2074
2080
  // source stay inert exactly as before.
2075
2081
  this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
2082
+ const transcriptMonitor = this.transcriptMonitor;
2083
+ const transcriptFence = this.launchFenceEpoch;
2084
+ // Baseline in the isolate before this daemon accepts new work. A
2085
+ // stopped/replaced launch may never re-arm polling after this await.
2086
+ await transcriptMonitor.initialize();
2087
+ if (this.startupAborted || this.launchFenceEpoch !== transcriptFence || this.transcriptMonitor !== transcriptMonitor) {
2088
+ transcriptMonitor.stop();
2089
+ return;
2090
+ }
2076
2091
  // 5. Wire transcript events
2077
2092
  const ackIfPending = () => {
2078
2093
  if (!this.pendingAckMessage || !this.adapter)
@@ -2843,7 +2858,13 @@ export class Daemon extends EventEmitter {
2843
2858
  // was just stopped or paused (#1160 review).
2844
2859
  if (this.runtimeMonitorsFrozen || this.healthCheckPaused)
2845
2860
  return;
2846
- this.transcriptMonitor?.resetOffset();
2861
+ // Reset is synchronous for file sources, but Kiro's baseline is
2862
+ // read off-thread. Finish it before starting the replacement CLI,
2863
+ // otherwise its first work could become part of that baseline.
2864
+ if (!await this.resetTranscriptBeforeAdmission())
2865
+ return;
2866
+ if (this.runtimeMonitorsFrozen || this.healthCheckPaused)
2867
+ return;
2847
2868
  // Kill orphan MCP server from the crashed CLI session.
2848
2869
  // MCP server writes its PID to channel.mcp.pid on startup.
2849
2870
  try {
@@ -4252,50 +4273,77 @@ export class Daemon extends EventEmitter {
4252
4273
  // concurrency coordination, first output + idle is `budgetMs`, the dialog
4253
4274
  // scan is STARTUP_DIALOG_BUDGET_MS — so the outcome is always trySpawn's
4254
4275
  // own verdict. A soft timer only makes a slow wake visible in the log.
4255
- const transition = this.autoPauseController.wakeOnDeliver(async () => {
4256
- const slow = setTimeout(() => {
4257
- this.logger.warn({ budgetMs }, "Wake is exceeding its budget — still waiting for the spawn (it cannot be cancelled)");
4258
- }, budgetMs + STARTUP_DIALOG_BUDGET_MS);
4259
- slow.unref?.();
4276
+ const wakeFence = this.launchFenceEpoch;
4277
+ const wakeGeneration = this.spawnGeneration;
4278
+ const isCurrent = () => !this.startupAborted && this.launchFenceEpoch === wakeFence
4279
+ && this.spawnGeneration === wakeGeneration && this.pauseWakeState === "waking";
4280
+ // The shared transition covers baseline initialization as well as spawn.
4281
+ // Every wake().then(deliver) caller must wait for the same admission edge.
4282
+ const transition = (async () => {
4260
4283
  try {
4261
- const ready = await this.trySpawn(true, budgetMs);
4262
- if (!ready)
4263
- throw new Error(`Wake failed: the CLI did not become ready (budget ${budgetMs}ms)`);
4284
+ await this.autoPauseController.wakeOnDeliver(async () => {
4285
+ const slow = setTimeout(() => {
4286
+ this.logger.warn({ budgetMs }, "Wake is exceeding its budget — still waiting for the spawn (it cannot be cancelled)");
4287
+ }, budgetMs + STARTUP_DIALOG_BUDGET_MS);
4288
+ slow.unref?.();
4289
+ try {
4290
+ const ready = await this.trySpawn(true, budgetMs);
4291
+ if (!ready)
4292
+ throw new Error(`Wake failed: the CLI did not become ready (budget ${budgetMs}ms)`);
4293
+ }
4294
+ finally {
4295
+ clearTimeout(slow);
4296
+ }
4297
+ });
4298
+ if (!isCurrent())
4299
+ throw new Error("Wake cancelled: the launch was superseded");
4300
+ if (!await this.resetTranscriptBeforeAdmission() || !isCurrent()) {
4301
+ throw new Error("Wake cancelled: transcript initialization was superseded");
4302
+ }
4303
+ this.pauseWakeState = "active";
4304
+ this.healthCheckPaused = false;
4305
+ this.pauseRequested = false;
4306
+ // trySpawn resolved only after the new CLI reached its ready prompt.
4307
+ // Discard any recovery gate retained while monitors were frozen.
4308
+ this.clearErrorRecoveryGate();
4309
+ clearPausedMarker(this.instanceDir);
4310
+ this.resumeRuntimeMonitors();
4311
+ this.logger.info("Instance auto-woke");
4312
+ this.ipcServer?.broadcast({
4313
+ type: "instance_state", instanceName: this.name, state: this.instanceState, pausedAt: null,
4314
+ });
4315
+ this.emit("auto_woke", { name: this.name });
4316
+ }
4317
+ catch (err) {
4318
+ if (isCurrent()) {
4319
+ this.pauseWakeState = "paused";
4320
+ this.healthCheckPaused = true;
4321
+ }
4322
+ this.logger.error({ err: err.message }, "Instance wake failed");
4323
+ throw err;
4264
4324
  }
4265
4325
  finally {
4266
- clearTimeout(slow);
4326
+ this.endSpawn();
4267
4327
  }
4268
- });
4328
+ })();
4269
4329
  this.pauseWakeTransition = transition;
4270
4330
  try {
4271
4331
  await transition;
4272
- this.pauseWakeState = "active";
4273
- this.healthCheckPaused = false;
4274
- this.pauseRequested = false;
4275
- // trySpawn resolved only after the new CLI reached its ready prompt.
4276
- // Discard any recovery gate retained while monitors were frozen.
4277
- this.clearErrorRecoveryGate();
4278
- clearPausedMarker(this.instanceDir);
4279
- this.transcriptMonitor?.resetOffset();
4280
- this.resumeRuntimeMonitors();
4281
- this.logger.info("Instance auto-woke");
4282
- this.ipcServer?.broadcast({
4283
- type: "instance_state", instanceName: this.name, state: this.instanceState, pausedAt: null,
4284
- });
4285
- this.emit("auto_woke", { name: this.name });
4286
- }
4287
- catch (err) {
4288
- this.pauseWakeState = "paused";
4289
- this.healthCheckPaused = true;
4290
- this.logger.error({ err: err.message }, "Instance wake failed");
4291
- throw err;
4292
4332
  }
4293
4333
  finally {
4294
- this.endSpawn();
4295
4334
  if (this.pauseWakeTransition === transition)
4296
4335
  this.pauseWakeTransition = null;
4297
4336
  }
4298
4337
  }
4338
+ /** A reset baseline must settle before a launch admits new work. */
4339
+ async resetTranscriptBeforeAdmission() {
4340
+ const monitor = this.transcriptMonitor;
4341
+ const fence = this.launchFenceEpoch, generation = this.spawnGeneration;
4342
+ monitor?.resetOffset();
4343
+ await monitor?.initialize();
4344
+ return !this.startupAborted && this.launchFenceEpoch === fence
4345
+ && this.spawnGeneration === generation && this.transcriptMonitor === monitor;
4346
+ }
4299
4347
  /**
4300
4348
  * One line per edge, until the instance is flapping. The edges themselves are
4301
4349
  * still published to every observer; only the log is limited, so the next
@@ -6143,7 +6191,7 @@ export class Daemon extends EventEmitter {
6143
6191
  const canHandOff = (supportsQueuedInput || opts?.steer)
6144
6192
  && readiness === "busy"
6145
6193
  && (await this.probeBlockingDialog()).state === "clear"
6146
- && await this.hasPositiveDeliveryInput();
6194
+ && await this.hasPositiveDeliveryInput(true);
6147
6195
  if (canHandOff) {
6148
6196
  // Native queue (codex), or an explicit /steer: hand the complete
6149
6197
  // paste+Enter transaction to the busy CLI now. For steer this is the
@@ -6240,7 +6288,7 @@ export class Daemon extends EventEmitter {
6240
6288
  if (probe.state !== "clear")
6241
6289
  return "dialog";
6242
6290
  }
6243
- if (!(await this.hasPositiveDeliveryInput()))
6291
+ if (!(await this.hasPositiveDeliveryInput(handingOffToNativeQueue)))
6244
6292
  return "dialog";
6245
6293
  // #829: a CLI that restores a cancelled prompt into its input box would
6246
6294
  // have this message pasted onto it and both submitted as one. Clear it
@@ -6262,6 +6310,9 @@ export class Daemon extends EventEmitter {
6262
6310
  if (residue !== "clear")
6263
6311
  return this.failDelivery(verdict, status, "input-residue", residue);
6264
6312
  const written = await this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true, writeCurrent);
6313
+ // The hand-off's last capture had no box: nothing was begun or written — wait for readiness, then try again.
6314
+ if (written === "handoff-box-unread")
6315
+ return "dialog";
6265
6316
  // Fenced before its first write: not attempted — redo after a spawn, else drop without a ❌.
6266
6317
  return written === false && verdict.fenced ? stale() : written;
6267
6318
  });
@@ -6809,8 +6860,26 @@ export class Daemon extends EventEmitter {
6809
6860
  }
6810
6861
  return true;
6811
6862
  }
6812
- /** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
6813
- async hasPositiveDeliveryInput() {
6863
+ /**
6864
+ * Codex can paint a prompt before the TTY enters raw mode. Both are required.
6865
+ *
6866
+ * A hand-off into a busy pane (native queue or steer) pastes and presses Enter at once, so a backend with a structural
6867
+ * box reader must see its box on a fresh capture first: a pane whose box cannot be read — a modal the dialog table
6868
+ * does not know, a frame the reader refuses, a failed capture — is not one to type into (#1169 review). Asked
6869
+ * before the hand-off is chosen and again under the pane lock, right before the write; false waits for readiness.
6870
+ */
6871
+ async hasPositiveDeliveryInput(handOff = false) {
6872
+ if (handOff && this.backend?.readInputRow) {
6873
+ if (!this.tmux)
6874
+ return false;
6875
+ try {
6876
+ if (this.backend.readInputRow(await this.tmux.capturePane()) === null)
6877
+ return false;
6878
+ }
6879
+ catch {
6880
+ return false;
6881
+ }
6882
+ }
6814
6883
  if (!this.needsStartupInputProof())
6815
6884
  return true;
6816
6885
  const check = this.backend?.isDeliveryInputReadyPane;
@@ -7494,6 +7563,15 @@ export class Daemon extends EventEmitter {
7494
7563
  verdict.fenced = !verdict.durableBeginCommitted;
7495
7564
  return false;
7496
7565
  }
7566
+ // A hand-off pastes and presses Enter into a busy pane: the box must still be readable on this, the last capture
7567
+ // before the write (#1169 review) — a modal that appeared after the under-lock check would take that Enter. Before
7568
+ // the durable begin the caller waits for readiness outside the lock and tries again; after it (a paste retry),
7569
+ // nothing was written and the attempt ends like a paste that failed.
7570
+ if (handingOffToNativeQueue && this.backend?.readInputRow && !pasteBaseline?.inputReadable) {
7571
+ if (!verdict.durableBeginCommitted)
7572
+ return "handoff-box-unread";
7573
+ return this.failDelivery(verdict, status, "paste", "handoff-input-unreadable");
7574
+ }
7497
7575
  // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
7498
7576
  // Commit the submission fence at the last possible point before the
7499
7577
  // first side effect; a crash during those waits remains safely replayable.
@@ -7523,6 +7601,11 @@ export class Daemon extends EventEmitter {
7523
7601
  path: attemptEvidence.transcriptPath,
7524
7602
  offset: attemptEvidence.transcriptOffset,
7525
7603
  };
7604
+ // From here every proof of this write may also ask the CLI's transcript (the pane can lose the echo to the
7605
+ // reply, or still be painting the paste) — this delivery's exact marker only.
7606
+ if (["claude-code", "codex"].includes(attemptEvidence.backend)) {
7607
+ signature.transcript = this.transcriptProofFor(verdict.transcriptCheckpoint, durableAttempt.deliveryId, spawnAtWrite, stillCurrent, kind => { verdict.transcriptProof = kind; });
7608
+ }
7526
7609
  }
7527
7610
  }
7528
7611
  if (rawPaste)
@@ -7700,7 +7783,7 @@ export class Daemon extends EventEmitter {
7700
7783
  // The recovery Enter can be accepted before Codex paints its new
7701
7784
  // transcript. One immediate capture is not a failure verdict: give
7702
7785
  // that echo/queue a bounded chance to appear, without re-pasting.
7703
- const afterEnter = this.backend?.isDeliveryInputReadyPane
7786
+ const afterEnter = this.structuredInputEvidence()
7704
7787
  ? await this.lateCodexSubmissionProof(signature, pasteBaseline, true)
7705
7788
  : await this.confirmSubmitted(signature, pasteBaseline);
7706
7789
  if (afterEnter === "submitted") {
@@ -7714,8 +7797,8 @@ export class Daemon extends EventEmitter {
7714
7797
  // another turn's output — and treating that as proof re-confirms a
7715
7798
  // message nobody submitted. Output is corroboration; text sitting in
7716
7799
  // the input row is disqualifying, and disqualifying evidence wins.
7717
- if (this.backend?.isDeliveryInputReadyPane && afterEnter !== "stranded") {
7718
- this.logger.warn({ phase: "native-queue-submit", proof: afterEnter, strandedAt }, "Codex recovery Enter outcome uncertain — no hard failure or duplicate paste");
7800
+ if (this.structuredInputEvidence() && afterEnter !== "stranded") {
7801
+ this.logger.warn({ phase: "native-queue-submit", proof: afterEnter, strandedAt }, `${this.cliLabel()} recovery Enter outcome uncertain — no hard failure or duplicate paste`);
7719
7802
  verdict.phase = "native-queue-submit";
7720
7803
  verdict.proof = afterEnter;
7721
7804
  return false;
@@ -7723,11 +7806,15 @@ export class Daemon extends EventEmitter {
7723
7806
  this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
7724
7807
  return this.failDelivery(verdict, status, "native-queue-submit", afterEnter);
7725
7808
  }
7726
- if (this.backend?.isDeliveryInputReadyPane) {
7809
+ // A structural box reader (claude-code, #1200) is in the same position as Codex once the turn has run: the
7810
+ // message may have been taken and its echo scrolled out, the queue drained, the box empty — or the capture
7811
+ // failed. None of that proves the paste was lost, so nothing short of positive evidence is a reason to paste
7812
+ // again (#1353 review); "unverifiable" used to, and could submit the message twice.
7813
+ if (this.backend?.isDeliveryInputReadyPane || this.backend?.readInputRow) {
7727
7814
  // In Codex a missing viewport echo is inconclusive, not a proof of
7728
7815
  // loss. Another paste could run the same request twice. Leave the
7729
7816
  // already-pasted delivery at 👀 and let the next observation decide.
7730
- this.logger.warn({ phase: "native-queue-proof", proof: settled }, "Codex native-queue outcome uncertain — not re-pasting");
7817
+ this.logger.warn({ phase: "native-queue-proof", proof: settled }, `${this.cliLabel()} native-queue outcome uncertain — not re-pasting`);
7731
7818
  verdict.phase = "native-queue-proof";
7732
7819
  verdict.proof = settled;
7733
7820
  return false;
@@ -7824,7 +7911,7 @@ export class Daemon extends EventEmitter {
7824
7911
  verdict.proof = "output-edge";
7825
7912
  }
7826
7913
  else {
7827
- const proof = this.backend?.isDeliveryInputReadyPane
7914
+ const proof = this.structuredInputEvidence()
7828
7915
  ? await this.lateCodexSubmissionProof(signature, pasteBaseline)
7829
7916
  : await this.confirmSubmitted(signature, pasteBaseline);
7830
7917
  if (proof === "submitted") {
@@ -7832,12 +7919,12 @@ export class Daemon extends EventEmitter {
7832
7919
  this.emit("message_confirmed", status);
7833
7920
  return true;
7834
7921
  }
7835
- if (this.backend?.isDeliveryInputReadyPane && proof !== "stranded") {
7922
+ if (this.structuredInputEvidence() && proof !== "stranded") {
7836
7923
  // This is the observed #910 race: the CLI processed the message
7837
7924
  // although its echo had not appeared within the proof window. A
7838
7925
  // missing viewport signature cannot establish non-delivery; keep
7839
7926
  // 👀 and never emit the sender's hard ❌ or re-paste blindly.
7840
- this.logger.warn({ phase: "post-submit-proof", proof }, "Codex delivery outcome uncertain — no hard failure or duplicate paste");
7927
+ this.logger.warn({ phase: "post-submit-proof", proof }, `${this.cliLabel()} delivery outcome uncertain — no hard failure or duplicate paste`);
7841
7928
  verdict.phase = "post-submit-proof";
7842
7929
  verdict.proof = proof;
7843
7930
  return false;
@@ -7910,7 +7997,7 @@ export class Daemon extends EventEmitter {
7910
7997
  return dialogClear && this.spawnGeneration === spawnGeneration && this.getWindowId() === windowId;
7911
7998
  }
7912
7999
  canProveSubmission() {
7913
- return !!this.backend?.getBottomReadyPattern?.();
8000
+ return this.readsInput();
7914
8001
  }
7915
8002
  /** Positive submission retires the startup transient guard for this spawn. */
7916
8003
  submittedProof() {
@@ -7947,18 +8034,22 @@ export class Daemon extends EventEmitter {
7947
8034
  }
7948
8035
  return busy;
7949
8036
  }
7950
- if (this.backend?.isDeliveryInputReadyPane) {
8037
+ if (this.structuredInputEvidence()) {
7951
8038
  // Codex's first post-wake redraw may hide the echo for a few seconds.
7952
8039
  // Absence from a viewport is NOT proof the paste was lost, so only a
7953
8040
  // positively identified strand authorizes another Enter. Never re-paste
7954
8041
  // here: the CLI may already be processing the unique message_id.
7955
8042
  let proof = "unproven";
7956
8043
  const firstDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8044
+ // A structural box reader (claude-code) sees its box still holding the paste for a moment after an Enter it did
8045
+ // take — Claude paints the submit late (captured live on 2.1.293: ~40 captures in a row). So for it a strand
8046
+ // proves a swallowed Enter only once it outlasts the window; an early one is polled through. Codex keeps its rule.
8047
+ const strandMustPersist = !!this.backend?.readInputRow;
7957
8048
  for (;;) {
7958
8049
  proof = await this.confirmSubmitted(signature, baseline);
7959
8050
  if (proof === "submitted")
7960
8051
  return true;
7961
- if (proof === "stranded" || Date.now() >= firstDeadline)
8052
+ if ((proof === "stranded" && !strandMustPersist) || Date.now() >= firstDeadline)
7962
8053
  break;
7963
8054
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
7964
8055
  }
@@ -8008,13 +8099,65 @@ export class Daemon extends EventEmitter {
8008
8099
  async confirmSubmitted(signature, baseline) {
8009
8100
  if (!this.tmux)
8010
8101
  return "unproven";
8011
- let pane;
8102
+ let pane = null;
8012
8103
  try {
8013
8104
  pane = await this.tmux.capturePane();
8014
8105
  }
8015
- catch {
8016
- return "unproven";
8017
- }
8106
+ catch { /* the transcript may still say */ }
8107
+ const onPane = pane === null ? "unproven" : this.judgeSubmission(pane, signature, baseline);
8108
+ if (onPane === "submitted")
8109
+ return onPane;
8110
+ const t = signature.transcript;
8111
+ const found = await this.transcriptLook(signature);
8112
+ // One synchronous stretch from here: the fence is asked after the last await, and only then is the hit kept and the
8113
+ // spawn's guard retired — a stop or respawn that lands between the look and this line leaves both untouched.
8114
+ if (!found || !t?.current())
8115
+ return onPane;
8116
+ if (!t.provenBy) {
8117
+ t.provenBy = found;
8118
+ t.onProof?.(found);
8119
+ }
8120
+ return this.submittedProof();
8121
+ }
8122
+ /**
8123
+ * The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read) or
8124
+ * one it queued (taken into its queue). Either is the CLI having taken the
8125
+ * message, so it outranks the pane — including a box that still shows the paste a moment after Enter (Claude paints
8126
+ * the submit late, captured live on 2.1.293), which on its own reads as a strand. Positive only; never a downgrade.
8127
+ */
8128
+ async transcriptLook(signature) {
8129
+ const t = signature.transcript;
8130
+ if (!t || !t.current())
8131
+ return null;
8132
+ if (t.provenBy)
8133
+ return t.provenBy;
8134
+ const found = await t.reader.look();
8135
+ // No side effect here: the caller keeps the hit and retires the guard only after its own fence check.
8136
+ return found === "user" || found === "queued" ? found : null;
8137
+ }
8138
+ /**
8139
+ * A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
8140
+ * and the caller's own fence — so a look that lands after any of them is never used (#1380 review).
8141
+ */
8142
+ transcriptProofFor(checkpoint, deliveryId, spawnAtWrite, stillCurrent, onProof) {
8143
+ const launchAtWrite = this.launchFenceEpoch;
8144
+ return {
8145
+ reader: new TranscriptDeltaReader(checkpoint.path, checkpoint.offset, checkpoint.backend, deliveryId),
8146
+ current: () => !this.deliveryWritesStopping && this.spawnGeneration === spawnAtWrite
8147
+ && this.launchFenceEpoch === launchAtWrite && (stillCurrent?.() ?? true),
8148
+ onProof,
8149
+ };
8150
+ }
8151
+ /** The CLI this daemon drives, for log lines shared by every backend ("claude-code", "codex", …). */
8152
+ cliLabel() {
8153
+ return this.config.backend ?? this.backend?.binaryName ?? "CLI";
8154
+ }
8155
+ /**
8156
+ * The verdict one snapshot supports — the live viewport, or (for a unique signature) the scrollback a late proof
8157
+ * reads. One judge for both, so a history read cannot vouch for what the live one refuses: an unreadable box, or our
8158
+ * text still in it (#1353 review).
8159
+ */
8160
+ judgeSubmission(pane, signature, baseline) {
8018
8161
  // Without a way to tell the input row from the transcript, "the text is on
8019
8162
  // screen" cannot distinguish submitted from stranded — that ambiguity IS
8020
8163
  // the bug. Such a backend gets an explicit verdict rather than a guess:
@@ -8024,8 +8167,7 @@ export class Daemon extends EventEmitter {
8024
8167
  // for the first and recovers on the second, which is what it did before —
8025
8168
  // the fix here is for backends that CAN be read, not a new guess for those
8026
8169
  // that cannot.
8027
- const prompt = this.backend?.getBottomReadyPattern?.();
8028
- if (!prompt) {
8170
+ if (!this.readsInput()) {
8029
8171
  const seen = this.paneEvidence(pane, signature);
8030
8172
  if (signature.unique && seen.payload > 0)
8031
8173
  return "unverifiable";
@@ -8042,6 +8184,11 @@ export class Daemon extends EventEmitter {
8042
8184
  && !this.backend.getBusyPattern?.()?.test(pane)) {
8043
8185
  return after.payload > 0 ? "unverifiable" : "unproven";
8044
8186
  }
8187
+ // A structural reader (claude-code) that finds no input box on this screen — a dialog in front of it, a layout it
8188
+ // does not know — cannot place our text on either side of the box: the same verdicts as a readerless backend.
8189
+ if (this.backend?.readInputRow && !after.inputReadable) {
8190
+ return after.payload > 0 ? "unverifiable" : "unproven";
8191
+ }
8045
8192
  // 1. Disqualifying evidence, checked FIRST and never overridden by the
8046
8193
  // corroborating evidence below: our text is sitting in the input row, so
8047
8194
  // it was not submitted — whatever else is on screen.
@@ -8052,8 +8199,22 @@ export class Daemon extends EventEmitter {
8052
8199
  // Otherwise an older stranded message with the same opening would be
8053
8200
  // read as ours, we would press Enter to "recover" it, and the turn IT
8054
8201
  // starts would confirm a message that never reached the pane.
8055
- if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
8202
+ //
8203
+ // For a structural box reader (claude-code), "did not already show it" needs a baseline whose box was READ: an
8204
+ // unreadable one (a dialog, a redraw the reader refused) says nothing about what the box held, and counting it as
8205
+ // empty would claim an older message as ours and press Enter on it (#1353 review). The prompt-row backends (codex,
8206
+ // kiro) keep their existing attribution.
8207
+ const attributable = baseline != null
8208
+ && (!this.backend?.readInputRow || (baseline.inputReadable && after.inputReadable));
8209
+ if (after.strandedInput && (signature.unique || (attributable && baseline.strandedInput === false)))
8210
+ return "stranded";
8211
+ // A paste the CLI shows collapsed carries no signature at all. One the box did not hold before we pasted is
8212
+ // ours, and it is still in the box — whatever echo or output is on screen (#1200).
8213
+ if (attributable && after.collapsedPastes > baseline.collapsedPastes)
8056
8214
  return "stranded";
8215
+ // Something sits in the box that cannot be attributed either way. Only a unique signature seen outside the box
8216
+ // (below) can still prove this delivery; nothing else on screen may.
8217
+ const unattributedResidue = !attributable && (after.strandedInput || after.collapsedPastes > 0);
8057
8218
  // 2. Positive evidence. A unique signature needs no baseline: no earlier
8058
8219
  // message can carry this delivery's message_id, so finding it outside
8059
8220
  // the input row is proof in itself — which also means a momentary
@@ -8061,12 +8222,16 @@ export class Daemon extends EventEmitter {
8061
8222
  // message into a re-paste.
8062
8223
  if (signature.unique && after.payload > 0)
8063
8224
  return this.submittedProof();
8225
+ if (unattributedResidue)
8226
+ return "unverifiable";
8064
8227
  // 3. Otherwise the evidence must be NEW relative to the pane as it was
8065
8228
  // before we pasted: a queue marker left by an earlier message, or an
8066
8229
  // older transcript entry that opens the same way, are on screen either
8067
8230
  // way and would otherwise confirm a paste that never landed.
8068
8231
  if (baseline) {
8069
- if (after.queued > baseline.queued)
8232
+ // A queue is new only against a box that was read before the paste (a reader backend): an unreadable baseline
8233
+ // says nothing about whether the queue was already there.
8234
+ if (after.queued > baseline.queued && (!this.backend?.readInputRow || baseline.inputReadable))
8070
8235
  return this.submittedProof();
8071
8236
  if (after.payload > baseline.payload)
8072
8237
  return this.submittedProof();
@@ -8094,12 +8259,10 @@ export class Daemon extends EventEmitter {
8094
8259
  return proof;
8095
8260
  if (signature.unique && this.tmux?.capturePaneWithHistory) {
8096
8261
  try {
8262
+ // Scrollback can hold the echo the viewport lost — but it is judged exactly like the viewport: a box that
8263
+ // cannot be read, or that still holds our text, is never upgraded by an echo further up.
8097
8264
  const history = await this.tmux.capturePaneWithHistory(300);
8098
- const seen = this.paneEvidence(history, signature);
8099
- const known = !this.backend?.isDeliveryInputReadyPane
8100
- || this.backend.isDeliveryInputReadyPane(history)
8101
- || this.backend.getBusyPattern?.()?.test(history);
8102
- if (known && seen.payload > 0 && !seen.strandedInput)
8265
+ if (this.judgeSubmission(history, signature, baseline) === "submitted")
8103
8266
  return this.submittedProof();
8104
8267
  }
8105
8268
  catch { /* a failed history read proves neither delivery nor loss */ }
@@ -8123,9 +8286,8 @@ export class Daemon extends EventEmitter {
8123
8286
  try {
8124
8287
  const pane = await this.tmux.capturePane();
8125
8288
  const evidence = this.paneEvidence(pane, signature);
8126
- const prompt = this.backend?.getBottomReadyPattern?.();
8127
- if (prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
8128
- && strandedAgendMessageInInput(pane, prompt)) {
8289
+ const input = this.inputRegion(pane);
8290
+ if (input && agendMessageInInput(input.text)) {
8129
8291
  // Whatever we paste now lands after it, and one Enter submits both as
8130
8292
  // a single message. Nothing here can undo that; saying so beats
8131
8293
  // letting two messages silently merge.
@@ -8144,16 +8306,45 @@ export class Daemon extends EventEmitter {
8144
8306
  return null;
8145
8307
  }
8146
8308
  paneEvidence(pane, signature) {
8147
- const marker = this.backend?.getQueuedInputMarker?.();
8148
- const prompt = this.backend?.getBottomReadyPattern?.();
8149
- const input = prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
8150
- ? inputAreaText(pane, prompt) : null;
8309
+ const input = this.inputRegion(pane);
8310
+ // A structural box reader vouches for the queue itself (InputBox.queued): a marker's words elsewhere on the pane —
8311
+ // a reply quoting it, an old block — are not a queue (#1169 review). Other backends count their marker rows.
8312
+ const marker = this.backend?.readInputRow ? null : this.backend?.getQueuedInputMarker?.();
8151
8313
  return {
8152
- queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
8314
+ queued: this.backend?.readInputRow
8315
+ ? (input?.queued ? 1 : 0)
8316
+ : marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
8153
8317
  payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
8154
- strandedInput: input != null && inputShowsPastedText(input, signature.value),
8318
+ strandedInput: input != null && inputShowsPastedText(input.text, signature.value),
8319
+ collapsedPastes: input?.collapsedPastes ?? 0,
8320
+ inputReadable: input != null,
8155
8321
  };
8156
8322
  }
8323
+ /**
8324
+ * The input box as it stands on this screen: the backend's structural reader when it has one (claude-code, #1200),
8325
+ * else the prompt-row heuristic (codex — only on a screen it vouches for — and kiro). null: no box to read here.
8326
+ */
8327
+ inputRegion(pane) {
8328
+ if (this.backend?.readInputRow)
8329
+ return this.backend.readInputRow(pane);
8330
+ const prompt = this.backend?.getBottomReadyPattern?.();
8331
+ if (!prompt || (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane)))
8332
+ return null;
8333
+ const text = inputAreaText(pane, prompt);
8334
+ return text == null ? null : { text, collapsedPastes: 0 };
8335
+ }
8336
+ /** Whether this backend's input box can be read at all (a given screen may still have none). */
8337
+ readsInput() {
8338
+ return !!this.backend?.readInputRow || !!this.backend?.getBottomReadyPattern?.();
8339
+ }
8340
+ /**
8341
+ * Backends whose pane evidence is structured (codex's input/footer pair, a structural box reader): there, our text
8342
+ * missing from the viewport is inconclusive — an echo can arrive late or scroll away — and never a loss to re-paste
8343
+ * over. Only text positively seen in the box (a strand) authorizes another Enter.
8344
+ */
8345
+ structuredInputEvidence() {
8346
+ return !!this.backend?.isDeliveryInputReadyPane || !!this.backend?.readInputRow;
8347
+ }
8157
8348
  /**
8158
8349
  * What identifies THIS message on screen. The routing envelope carries a
8159
8350
  * message_id that no earlier transcript entry can share, so prefer it: the
@@ -8220,6 +8411,17 @@ export class Daemon extends EventEmitter {
8220
8411
  if (!(await this.sendDeliveryEnter(label, guard?.current)))
8221
8412
  return false;
8222
8413
  let proof = await this.confirmSubmitted(signature, baseline);
8414
+ // A structural box reader sees the paste in the box until the CLI digests the Enter (claude-code repaints a moment
8415
+ // later): give that a bounded window before calling it stranded or lost, as the delivery ladder does.
8416
+ if (this.backend?.readInputRow) {
8417
+ const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8418
+ while (proof !== "submitted" && Date.now() < deadline) {
8419
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8420
+ if (guard && !guard.current())
8421
+ return false;
8422
+ proof = await this.confirmSubmitted(signature, baseline);
8423
+ }
8424
+ }
8223
8425
  if (proof === "unverifiable") {
8224
8426
  if (this.backend?.isDeliveryInputReadyPane) {
8225
8427
  // A Codex screen without a structurally current input/footer pair is