@songsid/agend 2.1.7-beta.3 → 2.1.7-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli-instructions.md +5 -0
  4. package/dist/agent-cli.js +13 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +48 -5
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/codex-session-lookup.d.ts +59 -0
  10. package/dist/backend/codex-session-lookup.js +139 -0
  11. package/dist/backend/codex-session-lookup.js.map +1 -0
  12. package/dist/backend/codex.d.ts +29 -0
  13. package/dist/backend/codex.js +183 -10
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/types.d.ts +21 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/channel/mcp-operation-response.d.ts +3 -0
  18. package/dist/channel/mcp-operation-response.js +21 -0
  19. package/dist/channel/mcp-operation-response.js.map +1 -0
  20. package/dist/channel/mcp-operation-response.test.d.ts +1 -0
  21. package/dist/channel/mcp-operation-response.test.js +23 -0
  22. package/dist/channel/mcp-operation-response.test.js.map +1 -0
  23. package/dist/channel/mcp-server.js +20 -5
  24. package/dist/channel/mcp-server.js.map +1 -1
  25. package/dist/channel/mcp-tools.js +2 -0
  26. package/dist/channel/mcp-tools.js.map +1 -1
  27. package/dist/cli.js +68 -2
  28. package/dist/cli.js.map +1 -1
  29. package/dist/daemon.d.ts +34 -0
  30. package/dist/daemon.js +415 -41
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +174 -0
  33. package/dist/delivery-outbox.js +824 -0
  34. package/dist/delivery-outbox.js.map +1 -0
  35. package/dist/delivery-outbox.test.d.ts +1 -0
  36. package/dist/delivery-outbox.test.js +597 -0
  37. package/dist/delivery-outbox.test.js.map +1 -0
  38. package/dist/delivery-queue-evidence.d.ts +15 -0
  39. package/dist/delivery-queue-evidence.js +35 -0
  40. package/dist/delivery-queue-evidence.js.map +1 -0
  41. package/dist/delivery-queue-evidence.test.d.ts +1 -0
  42. package/dist/delivery-queue-evidence.test.js +23 -0
  43. package/dist/delivery-queue-evidence.test.js.map +1 -0
  44. package/dist/delivery-reconciliation.d.ts +49 -0
  45. package/dist/delivery-reconciliation.js +296 -0
  46. package/dist/delivery-reconciliation.js.map +1 -0
  47. package/dist/delivery-reconciliation.test.d.ts +1 -0
  48. package/dist/delivery-reconciliation.test.js +411 -0
  49. package/dist/delivery-reconciliation.test.js.map +1 -0
  50. package/dist/fleet-manager.d.ts +39 -1
  51. package/dist/fleet-manager.js +345 -50
  52. package/dist/fleet-manager.js.map +1 -1
  53. package/dist/instance-lifecycle.d.ts +18 -0
  54. package/dist/instance-lifecycle.js +116 -3
  55. package/dist/instance-lifecycle.js.map +1 -1
  56. package/dist/instructions.js +3 -0
  57. package/dist/instructions.js.map +1 -1
  58. package/dist/locale.js +6 -0
  59. package/dist/locale.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +25 -1
  61. package/dist/outbound-handlers.js +119 -8
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +7 -0
  64. package/dist/outbound-schemas.js +14 -0
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/pane-write-lock.d.ts +2 -0
  67. package/dist/pane-write-lock.js +19 -0
  68. package/dist/pane-write-lock.js.map +1 -1
  69. package/dist/remote-credential-check.d.ts +39 -0
  70. package/dist/remote-credential-check.js +81 -0
  71. package/dist/remote-credential-check.js.map +1 -0
  72. package/dist/scheduler/scheduler.d.ts +5 -1
  73. package/dist/scheduler/scheduler.js +60 -20
  74. package/dist/scheduler/scheduler.js.map +1 -1
  75. package/dist/scheduler/scheduler.test.js +41 -2
  76. package/dist/scheduler/scheduler.test.js.map +1 -1
  77. package/dist/scheduler/types.d.ts +1 -1
  78. package/dist/tmux-manager.d.ts +3 -3
  79. package/dist/tmux-manager.js +6 -6
  80. package/dist/tmux-manager.js.map +1 -1
  81. package/dist/tool-permissions.js +6 -6
  82. package/dist/tool-permissions.js.map +1 -1
  83. package/dist/transcript-monitor.d.ts +3 -1
  84. package/dist/transcript-monitor.js +15 -0
  85. package/dist/transcript-monitor.js.map +1 -1
  86. package/dist/transcript-sources.d.ts +9 -0
  87. package/dist/transcript-sources.js +14 -0
  88. package/dist/transcript-sources.js.map +1 -1
  89. package/dist/usage/providers.d.ts +11 -6
  90. package/dist/usage/providers.js +17 -10
  91. package/dist/usage/providers.js.map +1 -1
  92. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- import { createHash, randomBytes } from "node:crypto";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { rotateLogIfNeeded } from "./logger.js";
7
7
  import { mcpServerState } from "./mcp-liveness.js";
@@ -30,10 +30,11 @@ import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignat
30
30
  import { TurnReplyGuard } from "./turn-reply-guard.js";
31
31
  import { t } from "./locale.js";
32
32
  import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
33
+ import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
33
34
  const __filename = fileURLToPath(import.meta.url);
34
35
  const __dirname = dirname(__filename);
35
36
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
36
- const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "start_instance", "restart_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "describe_instance"]);
37
+ const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "broadcast", "list_instances", "start_instance", "restart_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "delivery_status", "describe_instance"]);
37
38
  const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
38
39
  const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]);
39
40
  const TASK_TOOL = "task";
@@ -95,6 +96,8 @@ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
95
96
  export const PERIODIC_REDRAW_PROBE_MS = 25;
96
97
  /** Do not turn a streaming Codex turn into one capture-pane per burst. */
97
98
  export const STRUCTURED_IDLE_PROBE_MS = 500;
99
+ /** How long an unrecognised-but-empty composer must stay unchanged before it counts as idle (#978). */
100
+ export const UNKNOWN_LAYOUT_STABLE_MS = 10_000;
98
101
  /** A foreground server/port-forward should hand control back or be acknowledged. */
99
102
  export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
100
103
  const LAST_INBOUND_FILE = "last-inbound-at";
@@ -326,9 +329,12 @@ export class PaneStateMachine {
326
329
  * the caller has them.
327
330
  * @param opts.forceBusy the backend has positively identified a still-running
328
331
  * foreground tool even though its TUI keeps an old ready footer visible.
332
+ * @param opts.forceReady the backend has positively proved an idle composer
333
+ * the ready pattern cannot read (an unknown footer layout); the busy
334
+ * pattern still vetoes it.
329
335
  */
330
336
  observe(pane, now = Date.now(), opts = {}) {
331
- const { settled = false, changeAt = now, forceBusy = false } = opts;
337
+ const { settled = false, changeAt = now, forceBusy = false, forceReady = false } = opts;
332
338
  const paneHash = createHash("sha256").update(pane).digest("hex");
333
339
  const firstObservation = this.lastPaneHash === null;
334
340
  const paneChanged = this.lastPaneHash !== paneHash;
@@ -340,7 +346,9 @@ export class PaneStateMachine {
340
346
  // Some TUIs keep their ready footer visible while a foreground tool owns
341
347
  // stdin. Backends can positively identify that tool from the pane; in that
342
348
  // case the old prompt must not clear pending work or retire Cancel.
343
- const ready = !forceBusy && this.isReady(pane);
349
+ // forceReady: the backend proved readiness itself (#978 stable unknown
350
+ // footer); a visible busy marker still vetoes it.
351
+ const ready = !forceBusy && (forceReady ? !this.busyPattern?.test(pane) : this.isReady(pane));
344
352
  const nextState = firstObservation
345
353
  ? ready ? "idle" : "working"
346
354
  : paneChanged && !settled
@@ -908,6 +916,9 @@ export class Daemon extends EventEmitter {
908
916
  spawnGate;
909
917
  stormWindow;
910
918
  backendOutage;
919
+ /** Identity of this live Daemon object; changes on object stop/restart. */
920
+ bootId = randomUUID();
921
+ deliveryOutbox;
911
922
  logger;
912
923
  tmuxSessionName;
913
924
  tmux = null;
@@ -996,6 +1007,10 @@ export class Daemon extends EventEmitter {
996
1007
  /** Consecutive structural idle captures for a noisy, periodically-redrawing
997
1008
  * pane. Two are required before a working -> idle transition. */
998
1009
  instanceStatePeriodicIdleConfirmations = 0;
1010
+ /** #978: last unrecognised-but-empty-composer capture and since when it has been unchanged. */
1011
+ unknownLayoutPaneKey = null;
1012
+ unknownLayoutStableSince = 0;
1013
+ unknownLayoutWarned = false;
999
1014
  instanceStateStuckTimer = null;
1000
1015
  instanceStateOutputListener = null;
1001
1016
  instanceStateOutputEventName = null;
@@ -1081,6 +1096,7 @@ export class Daemon extends EventEmitter {
1081
1096
  /** Mutual exclusion for *every* write into the pane, whichever subsystem it
1082
1097
  * comes from. See PaneWriteLock for why interleaving is destructive. */
1083
1098
  paneWriteLock = new PaneWriteLock();
1099
+ deliveryWritesStopping = false;
1084
1100
  pendingInstructionsUpdate;
1085
1101
  pendingInstructionsNotice = false;
1086
1102
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -1313,6 +1329,109 @@ export class Daemon extends EventEmitter {
1313
1329
  const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
1314
1330
  this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
1315
1331
  }
1332
+ /** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
1333
+ setDeliveryOutboxPort(port) {
1334
+ this.deliveryOutbox = port;
1335
+ }
1336
+ /** The fleet's other same-backend instances' working directories, read at spawn (#984). */
1337
+ peerWorkingDirectories;
1338
+ setPeerWorkingDirectories(peers) {
1339
+ this.peerWorkingDirectories = peers;
1340
+ }
1341
+ /** Fence pane writers before lifecycle captures pre-kill reconciliation evidence. */
1342
+ fenceDeliveryWritesForStop() {
1343
+ if (this.deliveryWritesStopping)
1344
+ return;
1345
+ this.deliveryWritesStopping = true;
1346
+ this.deliveryEpoch++;
1347
+ }
1348
+ /** Bounded drain: timeout remains safe because Enter has a write-ahead fence. */
1349
+ waitForDeliveryWritesToDrain(timeoutMs) {
1350
+ return this.paneWriteLock.waitForIdle(timeoutMs);
1351
+ }
1352
+ durableDeliveryAttempt(meta) {
1353
+ const deliveryId = meta.delivery_id;
1354
+ if (!deliveryId)
1355
+ return null;
1356
+ const attemptNo = Number(meta.delivery_attempt);
1357
+ if (!Number.isSafeInteger(attemptNo) || attemptNo < 1)
1358
+ return false;
1359
+ return { deliveryId, attemptNo };
1360
+ }
1361
+ beginDurableDelivery(delivery, evidence) {
1362
+ if (!this.deliveryOutbox) {
1363
+ this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason: "outbox port unavailable before pane write" });
1364
+ return false;
1365
+ }
1366
+ const outcome = this.deliveryOutbox.begin(delivery.deliveryId, this.bootId, delivery.attemptNo, evidence);
1367
+ // A repeated begin is idempotent at the store but must not authorize a
1368
+ // second pane write. Only the first caller that acquired the permit acts.
1369
+ return outcome === "begun";
1370
+ }
1371
+ async durableAttemptEvidence(windowId, handingOffToNativeQueue, steer, rawPaste = false) {
1372
+ let checkpoint = null;
1373
+ try {
1374
+ checkpoint = await this.transcriptMonitor?.reconciliationCheckpoint() ?? null;
1375
+ }
1376
+ catch (err) {
1377
+ this.logger.debug({ err }, "Could not checkpoint transcript before durable delivery");
1378
+ }
1379
+ const submissionMode = rawPaste ? "raw_paste" : steer
1380
+ ? "steer"
1381
+ : handingOffToNativeQueue ? "native_queue_handoff" : "idle_submit";
1382
+ const backend = this.config.backend ?? this.backend?.binaryName ?? "unknown";
1383
+ // The daemon does not synchronously probe CLI versions on the delivery
1384
+ // path. Until lifecycle supplies a version tied to this exact process,
1385
+ // queue handoffs persist an unknown resume contract and reconciliation
1386
+ // must fail closed rather than infer loss from transcript absence.
1387
+ const backendVersion = null;
1388
+ return {
1389
+ backend,
1390
+ backendVersion,
1391
+ windowId: windowId ?? this.tmux?.getWindowId() ?? null,
1392
+ transcriptPath: checkpoint?.path ?? null,
1393
+ transcriptOffset: checkpoint?.offset ?? null,
1394
+ transcriptSessionId: checkpoint?.sessionId ?? null,
1395
+ submissionMode,
1396
+ queueResumePolicy: queueResumePolicyForAttempt(backend, backendVersion, submissionMode),
1397
+ };
1398
+ }
1399
+ abortDurableDelivery(delivery, reason) {
1400
+ if (!delivery)
1401
+ return;
1402
+ this.deliveryOutbox?.abort(delivery.deliveryId, this.bootId, delivery.attemptNo, reason);
1403
+ }
1404
+ retryDurableDeliveryBeforeBegin(delivery, reason) {
1405
+ if (!delivery)
1406
+ return;
1407
+ if (this.deliveryOutbox) {
1408
+ this.deliveryOutbox.retryBeforeBegin(delivery.deliveryId, this.bootId, delivery.attemptNo, reason, 5_000);
1409
+ }
1410
+ else {
1411
+ this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
1412
+ }
1413
+ }
1414
+ finishDurableDelivery(delivery, outcome, evidence) {
1415
+ if (!delivery)
1416
+ return;
1417
+ this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1418
+ }
1419
+ finishDurableSubmission(delivery, verdict) {
1420
+ // Legacy backends may accept a pane write without enough pane evidence to
1421
+ // claim submission. Keep that row inspectable instead of recording a false
1422
+ // delivered state; reconciliation can classify it in a later phase.
1423
+ if (verdict.proof === "unverified") {
1424
+ this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
1425
+ return;
1426
+ }
1427
+ this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1428
+ }
1429
+ deferDurableDelivery(meta, reason) {
1430
+ const delivery = this.durableDeliveryAttempt(meta);
1431
+ if (delivery === null || delivery === false)
1432
+ return;
1433
+ this.retryDurableDeliveryBeforeBegin(delivery, reason);
1434
+ }
1316
1435
  async start() {
1317
1436
  mkdirSync(this.instanceDir, { recursive: true });
1318
1437
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1411,12 +1530,12 @@ export class Daemon extends EventEmitter {
1411
1530
  void this.wake().then(() => {
1412
1531
  this.pushChannelMessage(msg.content, meta, targetSession, deliveryEpoch);
1413
1532
  }).catch(err => {
1533
+ this.deferDurableDelivery(meta, `target wake failed before pane submission: ${err.message}`);
1414
1534
  this.logger.error({ err: err.message }, "Wake failed for inbound delivery");
1415
1535
  });
1416
1536
  }
1417
1537
  else if (msg.type === "raw_paste") {
1418
- // Paste raw text directly to CLI without [user:] wrapping.
1419
- this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true);
1538
+ this.handleRawPasteIpcMessage(msg);
1420
1539
  }
1421
1540
  else if (msg.type === "config_update") {
1422
1541
  this.applyConfigUpdate(msg.config);
@@ -2691,24 +2810,76 @@ export class Daemon extends EventEmitter {
2691
2810
  isDeliveryEpochCurrent(epoch) {
2692
2811
  return epoch === this.deliveryEpoch;
2693
2812
  }
2694
- queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false) {
2695
- if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch))
2813
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false, durableAttempt) {
2814
+ if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
2815
+ if (durableAttempt)
2816
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste target unavailable before pane queue");
2696
2817
  return;
2818
+ }
2697
2819
  this.pasteLock = this.pasteLock.then(async () => {
2698
2820
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2699
2821
  this.logger.info("Pending raw delivery dropped by user cancel");
2822
+ if (durableAttempt)
2823
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while queued");
2700
2824
  return;
2701
2825
  }
2702
- await this.wake();
2703
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
2826
+ const verdict = { reached: false };
2827
+ try {
2828
+ await this.wake();
2829
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
2830
+ if (durableAttempt)
2831
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while waking");
2832
+ return;
2833
+ }
2834
+ const delivered = await this.deliverMessage(rawText, undefined, {
2835
+ deliveryEpoch,
2836
+ verdict,
2837
+ durableAttempt,
2838
+ preserveExactBytes: true,
2839
+ });
2840
+ if (delivered)
2841
+ this.finishDurableSubmission(durableAttempt ?? null, verdict);
2842
+ else if (durableAttempt && verdict.paneWriteStarted) {
2843
+ this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "unverified"}`);
2844
+ }
2845
+ else if (durableAttempt && verdict.durableBeginCommitted) {
2846
+ this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "no-pane-write"}`);
2847
+ }
2848
+ else if (durableAttempt) {
2849
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "deferred"}`);
2850
+ }
2851
+ }
2852
+ catch (err) {
2853
+ if (durableAttempt && verdict.paneWriteStarted)
2854
+ this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
2855
+ else if (durableAttempt && verdict.durableBeginCommitted)
2856
+ this.abortDurableDelivery(durableAttempt, err.message);
2857
+ else if (durableAttempt)
2858
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
2859
+ this.logger.warn({ err: err.message }, "raw_paste delivery error");
2704
2860
  return;
2705
- await this.deliverMessage(rawText, undefined, { deliveryEpoch });
2861
+ }
2706
2862
  if (confirmClear)
2707
2863
  await this.confirmBackendClearDialog();
2708
2864
  this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
2709
- }).catch(err => {
2710
- this.logger.warn({ err: err.message }, "raw_paste delivery error");
2711
2865
  });
2866
+ this.pasteLock = this.pasteLock.catch(err => {
2867
+ this.logger.warn({ err: err.message }, "raw_paste delivery queue error");
2868
+ });
2869
+ }
2870
+ /** Keep the IPC raw route's durable mode attached to the parsed attempt. */
2871
+ handleRawPasteIpcMessage(msg) {
2872
+ // Paste raw text directly to CLI without [user:] wrapping.
2873
+ const deliveryMeta = {
2874
+ delivery_id: typeof msg.delivery_id === "string" ? msg.delivery_id : "",
2875
+ delivery_attempt: typeof msg.delivery_attempt === "string" ? msg.delivery_attempt : "",
2876
+ };
2877
+ const durableAttempt = this.durableDeliveryAttempt(deliveryMeta);
2878
+ if (durableAttempt === false) {
2879
+ this.deferDurableDelivery(deliveryMeta, "invalid raw_paste delivery attempt metadata");
2880
+ return;
2881
+ }
2882
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true, durableAttempt ? { ...durableAttempt, submissionMode: "raw_paste" } : undefined);
2712
2883
  }
2713
2884
  /**
2714
2885
  * Confirm a backend's terminal-level /clear prompt after the channel-level
@@ -2856,6 +3027,7 @@ export class Daemon extends EventEmitter {
2856
3027
  }
2857
3028
  async stop() {
2858
3029
  this.logger.info("Stopping daemon instance");
3030
+ this.fenceDeliveryWritesForStop();
2859
3031
  this.turnReplyGuard.reset();
2860
3032
  // Invalidate any bounded pre-Enter wait from the process generation being
2861
3033
  // stopped. It must fail closed, not press Enter in a replacement pane.
@@ -3699,9 +3871,39 @@ export class Daemon extends EventEmitter {
3699
3871
  }
3700
3872
  else {
3701
3873
  // A live busy marker or an unfamiliar layout outranks the broad Codex
3702
- // ready pattern during the noisy-redraw path. Unknown is working.
3874
+ // ready pattern during the noisy-redraw path. Unknown is working —
3875
+ // but not forever (#978): a footer the proof does not recognise
3876
+ // would otherwise pin an idle CLI in `working` and queue every
3877
+ // delivery indefinitely. If the backend still sees its own live,
3878
+ // empty composer and the pane has not changed (cosmetic glyphs
3879
+ // aside) for UNKNOWN_LAYOUT_STABLE_MS, read it as settled and ready.
3703
3880
  this.instanceStatePeriodicIdleConfirmations = 0;
3704
- snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3881
+ const stableFallback = this.backend?.isStableUnknownLayoutIdlePane;
3882
+ const now = Date.now();
3883
+ if (stableFallback?.call(this.backend, pane)) {
3884
+ const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
3885
+ if (key !== this.unknownLayoutPaneKey) {
3886
+ this.unknownLayoutPaneKey = key;
3887
+ this.unknownLayoutStableSince = now;
3888
+ }
3889
+ }
3890
+ else {
3891
+ this.unknownLayoutPaneKey = null;
3892
+ }
3893
+ if (this.unknownLayoutPaneKey !== null && now - this.unknownLayoutStableSince >= UNKNOWN_LAYOUT_STABLE_MS) {
3894
+ if (!this.unknownLayoutWarned) {
3895
+ this.unknownLayoutWarned = true;
3896
+ this.logger.warn({ backend: this.backend?.binaryName }, "Unrecognised idle footer layout; falling back to a stable empty composer for readiness");
3897
+ }
3898
+ snapshot = this.instanceStateMachine.observe(pane, now, {
3899
+ settled: true,
3900
+ changeAt: observedChangeAt,
3901
+ forceReady: true,
3902
+ });
3903
+ }
3904
+ else {
3905
+ snapshot = this.instanceStateMachine.recordOutput(observedChangeAt);
3906
+ }
3705
3907
  }
3706
3908
  }
3707
3909
  else if (structuredPeriodicIdle && reason !== "idle_debounce" && !settled) {
@@ -4182,6 +4384,9 @@ export class Daemon extends EventEmitter {
4182
4384
  if (meta.reply_to_text) {
4183
4385
  formatted += `\n(reply_to: "${meta.reply_to_text}")`;
4184
4386
  }
4387
+ if (meta.delivery_id) {
4388
+ formatted = `[agend-delivery-id:${meta.delivery_id}]\n${formatted}`;
4389
+ }
4185
4390
  return formatted;
4186
4391
  }
4187
4392
  /**
@@ -4204,7 +4409,10 @@ export class Daemon extends EventEmitter {
4204
4409
  verdict.reached = true;
4205
4410
  verdict.phase = phase;
4206
4411
  verdict.proof = proof;
4207
- if (status)
4412
+ // Durable cross-instance rows are still retryable before a pane write. Their
4413
+ // terminal state is announced by the outbox only after its retry/TTL policy
4414
+ // is exhausted; emitting ❌ here would expose a transient gate timeout.
4415
+ if (status && !verdict.durableAttempt)
4208
4416
  this.emit("message_failed", status); // ❌
4209
4417
  return false;
4210
4418
  }
@@ -4240,8 +4448,10 @@ export class Daemon extends EventEmitter {
4240
4448
  * normal inbound message, with a steering notice prepended for context.
4241
4449
  */
4242
4450
  steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
4243
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4451
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
4452
+ this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
4244
4453
  return;
4454
+ }
4245
4455
  // The fleet facade prevents a steer from overtaking an item still in its
4246
4456
  // idle queue. Close the second race window here: an earlier item may already
4247
4457
  // have reached this daemon's paste queue by the time the steer IPC arrives.
@@ -4256,25 +4466,60 @@ export class Daemon extends EventEmitter {
4256
4466
  const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
4257
4467
  + this.formatInboundMessage(content, meta);
4258
4468
  const status = channelStatus(meta);
4469
+ const durableAttempt = this.durableDeliveryAttempt(meta);
4470
+ if (durableAttempt === false) {
4471
+ this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
4472
+ return;
4473
+ }
4259
4474
  this.steerLock = this.steerLock.then(async () => {
4260
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4475
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
4476
+ this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
4261
4477
  return;
4478
+ }
4262
4479
  await this.wake();
4263
- if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4480
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
4481
+ this.deferDurableDelivery(meta, "delivery epoch changed while waking for steer");
4264
4482
  return;
4483
+ }
4265
4484
  const verdict = { reached: false };
4266
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id, verdict })) {
4267
- this.markTurnStarted(meta, formatted);
4485
+ try {
4486
+ if (await this.deliverMessage(formatted, status, {
4487
+ steer: true,
4488
+ deliveryEpoch,
4489
+ submissionId: meta.message_id,
4490
+ verdict,
4491
+ durableAttempt: durableAttempt || undefined,
4492
+ })) {
4493
+ this.finishDurableSubmission(durableAttempt, verdict);
4494
+ this.markTurnStarted(meta, formatted);
4495
+ }
4496
+ else if (durableAttempt && verdict.paneWriteStarted) {
4497
+ this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
4498
+ }
4499
+ else if (durableAttempt && verdict.durableBeginCommitted) {
4500
+ this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
4501
+ }
4502
+ else if (durableAttempt) {
4503
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "deferred"}`);
4504
+ }
4505
+ else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4506
+ // Legacy non-outbox path retains its existing failure surface.
4507
+ this.reportCrossInstanceDeliveryFailure(meta, verdict);
4508
+ }
4268
4509
  }
4269
- else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4270
- // Same rule as the queued path: a steer that never got to try is not a
4271
- // delivery failure. This holder is the steer's own, which is the point
4272
- // — it runs on steerLock while a queued delivery runs on pasteLock.
4273
- this.reportCrossInstanceDeliveryFailure(meta, verdict);
4510
+ catch (err) {
4511
+ if (durableAttempt && verdict.paneWriteStarted)
4512
+ this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
4513
+ else if (durableAttempt && verdict.durableBeginCommitted)
4514
+ this.abortDurableDelivery(durableAttempt, err.message);
4515
+ else if (durableAttempt)
4516
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
4517
+ throw err;
4274
4518
  }
4275
4519
  }).catch(err => {
4520
+ this.deferDurableDelivery(meta, `target wake/steer failed before pane submission: ${err.message}`);
4276
4521
  this.logger.warn({ err: err.message }, "steer delivery error");
4277
- if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4522
+ if (!durableAttempt && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4278
4523
  this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
4279
4524
  }
4280
4525
  });
@@ -4315,10 +4560,12 @@ export class Daemon extends EventEmitter {
4315
4560
  pushChannelMessage(content, meta, _targetSession, deliveryEpoch = this.deliveryEpoch) {
4316
4561
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
4317
4562
  this.logger.info("Pending channel delivery dropped by user cancel");
4563
+ this.deferDurableDelivery(meta, "delivery epoch changed before queue admission");
4318
4564
  return;
4319
4565
  }
4320
4566
  if (!this.tmux) {
4321
4567
  this.logger.warn("Cannot push channel message: tmux not running");
4568
+ this.deferDurableDelivery(meta, "target tmux is unavailable");
4322
4569
  return;
4323
4570
  }
4324
4571
  // Remember (and persist) the reply target. Only real channel messages have a
@@ -4375,9 +4622,12 @@ export class Daemon extends EventEmitter {
4375
4622
  this.emit("message_queued", queuedStatus);
4376
4623
  }
4377
4624
  this.pasteLock = this.pasteLock.then(async () => {
4625
+ let durable = null;
4626
+ let verdict = { reached: false };
4378
4627
  try {
4379
4628
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
4380
4629
  this.logger.info("Pending channel delivery dropped by user cancel");
4630
+ this.deferDurableDelivery(meta, "delivery epoch changed while queued");
4381
4631
  return;
4382
4632
  }
4383
4633
  if (this.config.pre_task_command) {
@@ -4395,18 +4645,60 @@ export class Daemon extends EventEmitter {
4395
4645
  // A fresh delivery begins a fresh turn — its bubble must not inherit
4396
4646
  // the previous turn's tool list.
4397
4647
  this.resetToolProgress();
4398
- const verdict = { reached: false };
4399
- if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id, verdict })) {
4648
+ verdict = { reached: false };
4649
+ const durableAttempt = this.durableDeliveryAttempt(meta);
4650
+ if (durableAttempt === false) {
4651
+ this.deferDurableDelivery(meta, "invalid durable delivery attempt metadata");
4652
+ return;
4653
+ }
4654
+ durable = durableAttempt;
4655
+ if (await this.deliverMessage(formatted, status, {
4656
+ deliveryEpoch,
4657
+ submissionId: meta.message_id,
4658
+ verdict,
4659
+ durableAttempt: durableAttempt || undefined,
4660
+ })) {
4661
+ this.finishDurableSubmission(durable, verdict);
4400
4662
  this.markTurnStarted(meta, formatted);
4401
4663
  }
4664
+ else if (durableAttempt && verdict.paneWriteStarted) {
4665
+ this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
4666
+ }
4667
+ else if (durableAttempt && verdict.durableBeginCommitted) {
4668
+ this.abortDurableDelivery(durable, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
4669
+ }
4670
+ else if (durableAttempt) {
4671
+ this.retryDurableDeliveryBeforeBegin(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "deferred"}`);
4672
+ }
4402
4673
  else if (meta.from_instance && verdict.reached
4403
4674
  && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4404
4675
  // Only a delivery that reached a verdict is reported. A pane that is
4405
4676
  // not ready yet, a cancel, a storm hold or a shutdown all return
4406
4677
  // false without one, and telling the sender its message was lost
4407
4678
  // there would be the false ❌ of #826 in its other form.
4679
+ this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
4408
4680
  this.reportCrossInstanceDeliveryFailure(meta, verdict);
4409
4681
  }
4682
+ else if (verdict.reached) {
4683
+ this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
4684
+ }
4685
+ else if (verdict.paneWriteStarted) {
4686
+ this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
4687
+ }
4688
+ else {
4689
+ this.abortDurableDelivery(durable, "delivery did not reach pane submission");
4690
+ }
4691
+ }
4692
+ catch (err) {
4693
+ if (durable && verdict.paneWriteStarted)
4694
+ this.finishDurableDelivery(durable, "uncertain", err.message);
4695
+ else if (durable && verdict.durableBeginCommitted)
4696
+ this.abortDurableDelivery(durable, err.message);
4697
+ else if (durable)
4698
+ this.retryDurableDeliveryBeforeBegin(durable, err.message);
4699
+ else
4700
+ throw err;
4701
+ this.logger.warn({ err: err.message }, "Durable pane delivery deferred after exception");
4410
4702
  }
4411
4703
  finally {
4412
4704
  this.pasteQueueDepth--;
@@ -4439,13 +4731,15 @@ export class Daemon extends EventEmitter {
4439
4731
  // The caller passes its own holder when it needs the answer; a system paste
4440
4732
  // that ignores the outcome gets a throwaway.
4441
4733
  const verdict = opts?.verdict ?? { reached: false };
4734
+ if (opts?.durableAttempt)
4735
+ verdict.durableAttempt = true;
4442
4736
  const cancelled = () => opts?.deliveryEpoch !== undefined
4443
4737
  && !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
4444
4738
  if (cancelled() || this.stormWindow?.isStopped())
4445
4739
  return false;
4446
4740
  // Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
4447
4741
  const fenceCount = (formatted.match(/```/g) || []).length;
4448
- if (fenceCount % 2 !== 0) {
4742
+ if (!opts?.preserveExactBytes && fenceCount % 2 !== 0) {
4449
4743
  // Odd number of fences = unclosed. Remove all code fences from the message.
4450
4744
  formatted = formatted.replace(/```/g, "");
4451
4745
  }
@@ -4513,7 +4807,9 @@ export class Daemon extends EventEmitter {
4513
4807
  // The pane never freed up. Report the failure instead of pasting into a
4514
4808
  // wedged CLI (where the text would sit unsubmitted and the next message
4515
4809
  // would land on top of it) — and instead of holding the queue silently.
4516
- this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
4810
+ this.logger[verdict.durableAttempt ? "warn" : "error"](verdict.durableAttempt
4811
+ ? "Pane still busy after the idle wait — durable delivery remains retryable"
4812
+ : "Pane still busy after the idle wait — reporting delivery failure");
4517
4813
  return this.failDelivery(verdict, status, "readiness", "timeout-before-write");
4518
4814
  }
4519
4815
  }
@@ -4591,7 +4887,7 @@ export class Daemon extends EventEmitter {
4591
4887
  }
4592
4888
  if (!(await this.hasPositiveDeliveryInput()))
4593
4889
  return "dialog";
4594
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
4890
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true);
4595
4891
  });
4596
4892
  if (outcome === "spawn-started") {
4597
4893
  // The pane changed under this delivery: its queued paste must not land
@@ -5345,13 +5641,20 @@ export class Daemon extends EventEmitter {
5345
5641
  * precisely so the lock's scope is visible at the call site rather than being
5346
5642
  * an invariant maintained by comments.
5347
5643
  */
5348
- async sendDeliveryEnter(phase, stillCurrent) {
5644
+ async sendDeliveryEnter(phase, stillCurrent, durableAttempt) {
5349
5645
  if (!(await this.waitForInputTransientToClear(phase)))
5350
5646
  return false;
5351
5647
  // A recovery Enter may have waited for a transient while cancel or spawn
5352
5648
  // replaced the delivery. Check at the last point before the tmux write.
5353
- if (stillCurrent && !stillCurrent())
5649
+ if (this.deliveryWritesStopping || (stillCurrent && !stillCurrent()))
5650
+ return false;
5651
+ // W1: persist the "Enter may have happened" boundary before tmux receives
5652
+ // the key. A crash after this commit is never auto-replayed without a
5653
+ // positive transcript marker, even if the stale pane still shows composer.
5654
+ if (durableAttempt && !this.deliveryOutbox?.markEnterStarted(durableAttempt.deliveryId, this.bootId, durableAttempt.attemptNo)) {
5655
+ this.logger.error({ phase, deliveryId: durableAttempt.deliveryId }, "Could not commit durable Enter write-ahead fence");
5354
5656
  return false;
5657
+ }
5355
5658
  const sent = await this.tmux.sendSpecialKey("Enter");
5356
5659
  if (!sent) {
5357
5660
  this.logger.error({
@@ -5364,19 +5667,46 @@ export class Daemon extends EventEmitter {
5364
5667
  async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
5365
5668
  // Last and defaulted so the positional callers that ignore the outcome stay
5366
5669
  // readable; deliverMessage, the only one that reports, always passes its own.
5367
- verdict = { reached: false }) {
5670
+ verdict = { reached: false }, durableAttempt, steer = false) {
5368
5671
  const signature = this.submissionSignature(formatted, submissionId);
5672
+ const rawPaste = durableAttempt?.submissionMode === "raw_paste";
5369
5673
  let windowId = initialWindowId;
5370
5674
  // Bug A: paste with backoff. Transient failures are usually a stale window id
5371
5675
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
5372
5676
  const maxAttempts = 3;
5677
+ let beginChecked = false;
5373
5678
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5374
5679
  const pasteStartedAt = Date.now();
5375
5680
  // Read the pane BEFORE writing to it, so the submission check can require
5376
5681
  // evidence this paste ADDED rather than evidence that was already there.
5377
5682
  const pasteBaseline = await this.capturePaneEvidence(signature);
5683
+ // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
5684
+ // Commit the submission fence at the last possible point before the
5685
+ // first side effect; a crash during those waits remains safely replayable.
5686
+ if (durableAttempt && !beginChecked) {
5687
+ beginChecked = true;
5688
+ const attemptEvidence = await this.durableAttemptEvidence(windowId, handingOffToNativeQueue, steer, durableAttempt.submissionMode === "raw_paste");
5689
+ if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
5690
+ verdict.durableBeginRejected = true;
5691
+ verdict.phase = "submission-begin";
5692
+ verdict.proof = "permit-rejected";
5693
+ return false;
5694
+ }
5695
+ verdict.durableBeginCommitted = true;
5696
+ }
5697
+ if (rawPaste)
5698
+ verdict.paneWriteStarted = true;
5699
+ // For raw commands the paste itself may alter the live composer. Treat a
5700
+ // failed/throwing write as uncertain rather than retrying bytes into the
5701
+ // same pane; the write-ahead Enter marker later separates safe pre-Enter
5702
+ // recovery from a possibly executed command.
5378
5703
  const pasted = await this.tmux.pasteBuffer(formatted);
5379
5704
  if (!pasted) {
5705
+ if (rawPaste) {
5706
+ verdict.phase = "raw-paste-write";
5707
+ verdict.proof = "paste-result-unknown";
5708
+ return false;
5709
+ }
5380
5710
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
5381
5711
  const recoverable = this.tmux.isLastPasteFailureRecoverable?.() ?? true;
5382
5712
  this.logger[recoverable ? "warn" : "error"]({ attempt, tmuxError, recoverable }, recoverable
@@ -5392,6 +5722,10 @@ export class Daemon extends EventEmitter {
5392
5722
  await new Promise(r => setTimeout(r, 2000));
5393
5723
  continue;
5394
5724
  }
5725
+ // From this point a process/window transition can leave the user payload
5726
+ // in the pane even if later proof is cancelled or unreadable. Such a row
5727
+ // must be uncertain, never aborted back to replayable queued state.
5728
+ verdict.paneWriteStarted = true;
5395
5729
  // Settle the bracketed paste, then submit. When control mode can observe
5396
5730
  // the pane, wait for the paste's own render to go quiet instead of
5397
5731
  // trusting a fixed delay (see waitForPasteSettle). The fixed delays —
@@ -5414,7 +5748,7 @@ export class Daemon extends EventEmitter {
5414
5748
  settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
5415
5749
  }
5416
5750
  let enterAt = Date.now();
5417
- if (!(await this.sendDeliveryEnter("initial-submit"))) {
5751
+ if (!(await this.sendDeliveryEnter("initial-submit", undefined, durableAttempt))) {
5418
5752
  return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
5419
5753
  }
5420
5754
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
@@ -5430,7 +5764,7 @@ export class Daemon extends EventEmitter {
5430
5764
  // re-baselined to the second Enter so leftover paste-render output between
5431
5765
  // the two cannot be what "confirms" the submission.
5432
5766
  let enterRetry = false;
5433
- if (this.backend?.requiresDeliveryEnterRetry?.() === true) {
5767
+ if (!rawPaste && this.backend?.requiresDeliveryEnterRetry?.() === true) {
5434
5768
  await new Promise(r => setTimeout(r, 1_000));
5435
5769
  const retryAt = Date.now();
5436
5770
  if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
@@ -5475,10 +5809,19 @@ export class Daemon extends EventEmitter {
5475
5809
  // Re-pasting on a backend without structured Codex pane evidence
5476
5810
  // would risk duplication; retain its legacy best-effort behavior.
5477
5811
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
5812
+ verdict.phase = "best-effort-submission";
5813
+ verdict.proof = "unverified";
5478
5814
  if (status)
5479
5815
  this.emit("message_confirmed", status); // ✅ (best-effort)
5480
5816
  return true;
5481
5817
  }
5818
+ // raw_paste has no unique marker and is not allowed to re-paste or
5819
+ // issue another Enter after its one committed submission attempt.
5820
+ if (rawPaste) {
5821
+ verdict.phase = "raw-paste-native-queue-proof";
5822
+ verdict.proof = proof;
5823
+ return false;
5824
+ }
5482
5825
  // Not submitted (or not provably submitted): fall back once to the
5483
5826
  // normal idle-gated path, which is the only one with a full
5484
5827
  // confirmation ladder. "stranded" is the case that used to be a silent
@@ -5588,6 +5931,11 @@ export class Daemon extends EventEmitter {
5588
5931
  }
5589
5932
  }
5590
5933
  if (!submitted) {
5934
+ if (rawPaste) {
5935
+ verdict.phase = "raw-paste-submit-proof";
5936
+ verdict.proof = "no-positive-acceptance-proof";
5937
+ return false;
5938
+ }
5591
5939
  // Legacy system pastes have no unique envelope id. Preserve their
5592
5940
  // existing bounded retry until they can be tied to a specific turn.
5593
5941
  this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
@@ -5618,7 +5966,7 @@ export class Daemon extends EventEmitter {
5618
5966
  // is disqualifying and no amount of output may override it. Where it
5619
5967
  // cannot (a backend with no prompt pattern), the output signal remains
5620
5968
  // exactly as before — this must not regress backends we cannot read.
5621
- const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
5969
+ const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry", !rawPaste);
5622
5970
  if (becameBusy) {
5623
5971
  if (status)
5624
5972
  this.emit("message_confirmed", status); // ✅
@@ -5647,6 +5995,14 @@ export class Daemon extends EventEmitter {
5647
5995
  }
5648
5996
  }
5649
5997
  else {
5998
+ // No control client means a raw command has no acceptance proof. The
5999
+ // write-ahead Enter fence keeps it uncertain after this point; never
6000
+ // send a second Enter that could execute a command twice.
6001
+ if (rawPaste) {
6002
+ verdict.phase = "raw-paste-submit-proof";
6003
+ verdict.proof = "unverified";
6004
+ return true;
6005
+ }
5650
6006
  // No control client to observe output: fall back to the legacy double-Enter.
5651
6007
  await new Promise(r => setTimeout(r, 1000));
5652
6008
  await this.sendDeliveryEnter("unobserved-defensive-retry");
@@ -5702,10 +6058,12 @@ export class Daemon extends EventEmitter {
5702
6058
  * cannot reappear in just one of them (it did: three copies, two of which
5703
6059
  * still confirmed on output alone).
5704
6060
  */
5705
- async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
6061
+ async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase, allowRecoveryEnter = true) {
5706
6062
  if (!this.canProveSubmission()) {
5707
6063
  let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
5708
6064
  if (!busy) {
6065
+ if (!allowRecoveryEnter)
6066
+ return false;
5709
6067
  this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
5710
6068
  const retryAt = Date.now();
5711
6069
  if (!(await this.sendDeliveryEnter(retryPhase)))
@@ -5729,7 +6087,7 @@ export class Daemon extends EventEmitter {
5729
6087
  break;
5730
6088
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5731
6089
  }
5732
- if (proof !== "stranded")
6090
+ if (proof !== "stranded" || !allowRecoveryEnter)
5733
6091
  return false;
5734
6092
  if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
5735
6093
  return false;
@@ -5756,6 +6114,8 @@ export class Daemon extends EventEmitter {
5756
6114
  // how we got here. Then poll briefly rather than judging on one capture:
5757
6115
  // the key takes a moment to be digested, and a single early look would
5758
6116
  // report a failure for a message that did go out.
6117
+ if (!allowRecoveryEnter)
6118
+ return false;
5759
6119
  this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
5760
6120
  if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
5761
6121
  return false;
@@ -6081,6 +6441,7 @@ export class Daemon extends EventEmitter {
6081
6441
  const tool = msg.tool;
6082
6442
  const args = (msg.args ?? {});
6083
6443
  const requestId = msg.requestId;
6444
+ const operationId = typeof msg.operationId === "string" ? msg.operationId : undefined;
6084
6445
  this.logger.debug({ tool, requestId }, "Tool call from MCP server");
6085
6446
  // A tool call can only come from a live MCP server of ours — proof of life
6086
6447
  // that outranks whatever the pid slot says.
@@ -6103,10 +6464,13 @@ export class Daemon extends EventEmitter {
6103
6464
  if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
6104
6465
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
6105
6466
  }
6106
- const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
6467
+ const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
6107
6468
  if (!sent) {
6108
6469
  this.logger.warn({ tool, requestId }, "Tool result could not be returned — originating MCP socket is closed");
6109
6470
  }
6471
+ else if (operationId) {
6472
+ this.emit("mcp_tool_response_written", { operationId, daemonBootId: this.bootId });
6473
+ }
6110
6474
  };
6111
6475
  // Repo checkout — handled locally in daemon (no fleet-manager)
6112
6476
  if (tool === "checkout_repo") {
@@ -6231,6 +6595,7 @@ export class Daemon extends EventEmitter {
6231
6595
  type: "fleet_outbound",
6232
6596
  tool,
6233
6597
  args,
6598
+ ...(operationId ? { operationId, sourceDaemonBootId: this.bootId } : {}),
6234
6599
  fleetRequestId: fleetReqId,
6235
6600
  senderSessionName,
6236
6601
  });
@@ -6289,6 +6654,7 @@ export class Daemon extends EventEmitter {
6289
6654
  type: "fleet_outbound",
6290
6655
  tool,
6291
6656
  args,
6657
+ ...(operationId ? { operationId, sourceDaemonBootId: this.bootId } : {}),
6292
6658
  fleetRequestId: fleetReqId,
6293
6659
  // Preserve the exact adapter world that supplied the chat context. An
6294
6660
  // instance binding is only a fallback: persisted/runtime context can be
@@ -6481,6 +6847,7 @@ export class Daemon extends EventEmitter {
6481
6847
  agentMode: isCliMode ? "cli" : "mcp",
6482
6848
  agentPort: isCliMode ? agentPort : undefined,
6483
6849
  backendOptions,
6850
+ peerWorkingDirectories: this.peerWorkingDirectories,
6484
6851
  };
6485
6852
  }
6486
6853
  /**
@@ -7186,6 +7553,13 @@ export class Daemon extends EventEmitter {
7186
7553
  }
7187
7554
  }
7188
7555
  const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
7556
+ // e.g. Codex could not read its session DB and chose a fallback (#984):
7557
+ // the operator should know which conversation this launch continues.
7558
+ const launchWarning = this.backend.consumeLaunchWarning?.() ?? null;
7559
+ if (launchWarning) {
7560
+ this.logger.warn({ warning: launchWarning }, "Backend launch fell back");
7561
+ this.emit("backend_launch_warning", { name: this.name, message: launchWarning });
7562
+ }
7189
7563
  // Ensure tmux session exists (may have been destroyed if all windows died)
7190
7564
  await TmuxManager.ensureSession(this.tmuxSessionName);
7191
7565
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {