@songsid/agend 2.1.11-beta.3 → 2.1.11-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 (47) hide show
  1. package/dist/backend/codex.d.ts +2 -0
  2. package/dist/backend/codex.js +55 -14
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/muse.d.ts +50 -5
  5. package/dist/backend/muse.js +166 -32
  6. package/dist/backend/muse.js.map +1 -1
  7. package/dist/backend/types.d.ts +39 -1
  8. package/dist/backend/types.js.map +1 -1
  9. package/dist/command-table.d.ts +33 -0
  10. package/dist/command-table.js +45 -0
  11. package/dist/command-table.js.map +1 -1
  12. package/dist/daemon.d.ts +93 -1
  13. package/dist/daemon.js +456 -31
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/darwin-memory.d.ts +34 -0
  16. package/dist/darwin-memory.js +160 -0
  17. package/dist/darwin-memory.js.map +1 -0
  18. package/dist/fleet-manager.d.ts +10 -0
  19. package/dist/fleet-manager.js +30 -7
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/host-memory.d.ts +3 -2
  22. package/dist/host-memory.js +14 -0
  23. package/dist/host-memory.js.map +1 -1
  24. package/dist/instance-lifecycle.d.ts +11 -0
  25. package/dist/instance-lifecycle.js +45 -8
  26. package/dist/instance-lifecycle.js.map +1 -1
  27. package/dist/locale.js +11 -0
  28. package/dist/locale.js.map +1 -1
  29. package/dist/memory-pressure.d.ts +20 -1
  30. package/dist/memory-pressure.js +87 -6
  31. package/dist/memory-pressure.js.map +1 -1
  32. package/dist/resource-report.d.ts +1 -1
  33. package/dist/resource-report.js +5 -4
  34. package/dist/resource-report.js.map +1 -1
  35. package/dist/spawn-gate.d.ts +4 -0
  36. package/dist/spawn-gate.js +36 -8
  37. package/dist/spawn-gate.js.map +1 -1
  38. package/dist/tmux-manager.d.ts +7 -0
  39. package/dist/tmux-manager.js +19 -0
  40. package/dist/tmux-manager.js.map +1 -1
  41. package/dist/topic-commands.d.ts +16 -2
  42. package/dist/topic-commands.js +85 -94
  43. package/dist/topic-commands.js.map +1 -1
  44. package/dist/turn-reply-guard.d.ts +3 -0
  45. package/dist/turn-reply-guard.js +12 -2
  46. package/dist/turn-reply-guard.js.map +1 -1
  47. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -300,11 +300,14 @@ export class PaneStateMachine {
300
300
  busyPattern;
301
301
  lastPaneHash = null;
302
302
  lastPaneChangeAt;
303
+ lastProgressTick = null;
304
+ progressTick;
303
305
  lastObservedAt;
304
306
  stateChangedAt;
305
307
  currentState = "idle";
306
- constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
308
+ constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern, progressTick) {
307
309
  this.stuckTimeoutMs = stuckTimeoutMs;
310
+ this.progressTick = progressTick ?? null;
308
311
  // Stateful g/y regexes mutate lastIndex and can alternate true/false across
309
312
  // polls. State detection must be deterministic for identical pane content.
310
313
  this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
@@ -350,7 +353,18 @@ export class PaneStateMachine {
350
353
  const paneChanged = this.lastPaneHash !== paneHash;
351
354
  if (paneChanged) {
352
355
  this.lastPaneHash = paneHash;
353
- this.lastPaneChangeAt = changeAt;
356
+ // Never backwards: a capture taken for an old output event (the stuck-deadline probe passes the time of the LAST
357
+ // output, minutes ago) must not erase the progress a later capture already proved (#1188).
358
+ this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, changeAt);
359
+ }
360
+ // The CLI's own turn clock (Codex: the elapsed seconds of its live status row). A counter that moved since the last
361
+ // look is proof of life whatever the rest of the screen did; one that stands still is not — a frozen pane stays
362
+ // detectable.
363
+ if (this.progressTick) {
364
+ const tick = this.progressTick(pane);
365
+ if (tick !== null && tick !== this.lastProgressTick)
366
+ this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, now);
367
+ this.lastProgressTick = tick;
354
368
  }
355
369
  this.lastObservedAt = now;
356
370
  // Some TUIs keep their ready footer visible while a foreground tool owns
@@ -376,7 +390,7 @@ export class PaneStateMachine {
376
390
  }
377
391
  /** Record pane motion from tmux control mode without capturing pane content. */
378
392
  recordOutput(now = Date.now()) {
379
- this.lastPaneChangeAt = now;
393
+ this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, now);
380
394
  this.lastObservedAt = now;
381
395
  if (this.currentState !== "working") {
382
396
  this.currentState = "working";
@@ -536,6 +550,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
536
550
  const INPUT_TRANSIENT_POLL_MS = 250;
537
551
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
538
552
  const STRANDED_INPUT_MAX_ROUNDS = 3;
553
+ /** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
554
+ const INPUT_RESIDUE_MAX_ROUNDS = 60;
555
+ const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
556
+ const INPUT_RESIDUE_POLL_MS = 100;
557
+ /** #829: how many of the latest pastes can prove a draft is AgEnD's own. */
558
+ const RECENT_PASTES_KEPT = 8;
539
559
  /** Build a status holder from channel metadata, keeping chat and thread apart. */
540
560
  function channelStatus(meta) {
541
561
  const chatId = meta.chat_id;
@@ -1042,6 +1062,22 @@ export class Daemon extends EventEmitter {
1042
1062
  * dialog on screen at every poll and ignores nothing.
1043
1063
  */
1044
1064
  dialogAnswers = null;
1065
+ /**
1066
+ * The last error occurrence handed to the lifecycle: what an action that needs the screen it was seen on (a
1067
+ * `nudge_continue` arming) reads back, with the spawn / monitors it was seen under.
1068
+ */
1069
+ lastErrorEpisode = null;
1070
+ /**
1071
+ * An armed "tell the agent to keep going" (Codex model at capacity). One per episode; the monitor owns it: every poll
1072
+ * cancels it when anything changed, and it fires at most once. The due time is on the monotonic clock.
1073
+ */
1074
+ /**
1075
+ * How many occurrences of each `nudge_continue` pattern were already on screen when this spawn first showed a live
1076
+ * composer (null until then): resumed scrollback, i.e. HISTORY. An occurrence is a new episode only beyond that — a
1077
+ * resumed session whose scrollback holds an old capacity line must not be told to "keep going" for it (#949).
1078
+ */
1079
+ nudgeBaselines = null;
1080
+ capacityNudge = null;
1045
1081
  /** Current stdin-owning runtime dialog, independent from execution state. */
1046
1082
  inputBlockedDialogKey = null;
1047
1083
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -1186,6 +1222,8 @@ export class Daemon extends EventEmitter {
1186
1222
  * comes from. See PaneWriteLock for why interleaving is destructive. */
1187
1223
  paneWriteLock = new PaneWriteLock();
1188
1224
  deliveryWritesStopping = false;
1225
+ /** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
1226
+ recentPastes = [];
1189
1227
  pendingInstructionsUpdate;
1190
1228
  pendingInstructionsNotice = false;
1191
1229
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -1587,6 +1625,14 @@ export class Daemon extends EventEmitter {
1587
1625
  this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
1588
1626
  return;
1589
1627
  }
1628
+ // A steer into a busy pane of a CLI whose input row cannot be read: the paste and the one Enter went through and the
1629
+ // delivery's own unique marker is on the pane. That is "accepted into the live turn's input" — not "the model has read
1630
+ // it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
1631
+ // (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
1632
+ if (verdict.proof === "steer-marker-on-pane") {
1633
+ this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
1634
+ return;
1635
+ }
1590
1636
  this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1591
1637
  }
1592
1638
  deferDurableDelivery(meta, reason) {
@@ -1649,19 +1695,29 @@ export class Daemon extends EventEmitter {
1649
1695
  ensureInstanceDir(this.instanceDir);
1650
1696
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1651
1697
  this.logger.info(`Starting ${this.name}`);
1652
- // P1: Read crash state from previous run — skip resume if last run was a crash loop
1698
+ // Recovery intent is one-shot, but only the reader may consume it. Keep
1699
+ // an unreadable marker for a later start; clear successfully read bytes
1700
+ // even when the JSON is corrupt so it cannot remain forever.
1653
1701
  const crashStatePath = join(this.instanceDir, "crash-state.json");
1654
1702
  try {
1655
1703
  if (existsSync(crashStatePath)) {
1656
- const state = JSON.parse(readFileSync(crashStatePath, "utf-8"));
1657
- if (state.resumeDisabled) {
1658
- this.skipResume = true;
1659
- this.logger.warn("Previous crash loop detected — starting without resume");
1704
+ const content = readFileSync(crashStatePath, "utf-8");
1705
+ try {
1706
+ const state = JSON.parse(content);
1707
+ if (state?.resumeDisabled === true) {
1708
+ this.skipResume = true;
1709
+ this.logger.warn("Previous crash loop detected — starting without resume");
1710
+ }
1711
+ }
1712
+ finally {
1713
+ try {
1714
+ unlinkSync(crashStatePath);
1715
+ }
1716
+ catch { /* best effort */ }
1660
1717
  }
1661
- unlinkSync(crashStatePath);
1662
1718
  }
1663
1719
  }
1664
- catch { /* corrupt file — ignore */ }
1720
+ catch { /* unreadable or corrupt file — ignore */ }
1665
1721
  // Restore last reply target so a fleet-topic instance can reply correctly
1666
1722
  // BEFORE its first post-restart inbound arrives (otherwise lastChatId is empty
1667
1723
  // → reply tool sends to "" → Discord 404).
@@ -2763,6 +2819,8 @@ export class Daemon extends EventEmitter {
2763
2819
  const pane = await this.tmux.capturePane();
2764
2820
  if (stale())
2765
2821
  return;
2822
+ this.takeNudgeBaselines(pane, patterns);
2823
+ this.tickCapacityNudge(pane);
2766
2824
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2767
2825
  // A sign-in screen that appears AFTER the startup scan ended (first run:
2768
2826
  // theme → login can take longer than its budget) is the same auth
@@ -3142,6 +3200,7 @@ export class Daemon extends EventEmitter {
3142
3200
  if (ep.type === "auth_error")
3143
3201
  this.authFailureUnresolved = true;
3144
3202
  this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
3203
+ this.lastErrorEpisode = { key, pattern: ep.pattern, pane, spawn: this.spawnGeneration, fence: this.launchFenceEpoch };
3145
3204
  this.emit("pty_error", { name: this.name, ...ep, message });
3146
3205
  }
3147
3206
  /**
@@ -3189,6 +3248,9 @@ export class Daemon extends EventEmitter {
3189
3248
  * transaction; the interrupt key still cancels the CLI generation.
3190
3249
  */
3191
3250
  clearPendingDeliveries(fleetEpoch) {
3251
+ // FleetManager calls this synchronously before sendEscape. Cancel only the
3252
+ // current human turn; leave already-forwarded tool requests to settle.
3253
+ this.turnReplyGuard.cancelByUser();
3192
3254
  this.deliveryEpoch = fleetEpoch === undefined
3193
3255
  ? this.deliveryEpoch + 1
3194
3256
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -3971,7 +4033,11 @@ export class Daemon extends EventEmitter {
3971
4033
  * message can arrive mid-turn and sit queued, and marking at arrival would let
3972
4034
  * the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
3973
4035
  */
3974
- markTurnStarted(meta, deliveredText) {
4036
+ markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch) {
4037
+ // Cancel may have interrupted an in-flight paste before its promise settled.
4038
+ // Its late success must not re-arm the turn that the user just cancelled.
4039
+ if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4040
+ return;
3975
4041
  // Channel turns only. A cross-instance inbound (from_instance, empty
3976
4042
  // chat_id) does not update lastChatId, so its proxy reply would land in
3977
4043
  // whatever USER topic spoke to this instance last — the wrong audience for
@@ -4012,6 +4078,12 @@ export class Daemon extends EventEmitter {
4012
4078
  this.turnReplyGuard.complete(turn.generation);
4013
4079
  return;
4014
4080
  }
4081
+ // An intentional stop is not a dropped reply. Successful in-flight replies
4082
+ // still settle above, but cancellation cannot start another model turn.
4083
+ if (turn.cancelledByUser) {
4084
+ this.turnReplyGuard.complete(turn.generation);
4085
+ return;
4086
+ }
4015
4087
  // A second idle edge ends the one permitted recovery turn. Never create a
4016
4088
  // third turn or guess at terminal text; make the failure visible instead.
4017
4089
  if (turn.phase === "recovering") {
@@ -4091,6 +4163,11 @@ export class Daemon extends EventEmitter {
4091
4163
  && this.backend?.replyCompletionGuard === true;
4092
4164
  }
4093
4165
  startReplyRecovery(turn, reason) {
4166
+ const current = this.turnReplyGuard.snapshot();
4167
+ if (current?.generation === turn.generation && current.cancelledByUser) {
4168
+ this.turnReplyGuard.complete(turn.generation);
4169
+ return;
4170
+ }
4094
4171
  if (!this.turnReplyGuard.beginRecovery(turn.generation))
4095
4172
  return;
4096
4173
  this.emit("reply_drop_detected", {
@@ -4118,8 +4195,8 @@ export class Daemon extends EventEmitter {
4118
4195
  this.pasteQueueDepth++;
4119
4196
  this.pasteLock = this.pasteLock.then(async () => {
4120
4197
  try {
4121
- const current = this.turnReplyGuard.snapshot();
4122
- if (!current || current.generation !== turn.generation || current.phase !== "recovering")
4198
+ const current = this.currentReplyRecovery(turn.generation, deliveryEpoch);
4199
+ if (!current)
4123
4200
  return;
4124
4201
  if (current.completionDelivered) {
4125
4202
  this.turnReplyGuard.complete(turn.generation);
@@ -4133,6 +4210,8 @@ export class Daemon extends EventEmitter {
4133
4210
  const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
4134
4211
  deliveryEpoch,
4135
4212
  });
4213
+ if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
4214
+ return;
4136
4215
  if (!delivered) {
4137
4216
  this.turnReplyGuard.complete(turn.generation);
4138
4217
  this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
@@ -4143,14 +4222,28 @@ export class Daemon extends EventEmitter {
4143
4222
  }
4144
4223
  }).catch(err => {
4145
4224
  this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
4225
+ if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
4226
+ return;
4146
4227
  this.turnReplyGuard.complete(turn.generation);
4147
4228
  this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
4148
4229
  });
4149
4230
  }
4231
+ /** Recheck after queue waits and IO; cancellation must not become a failure notice. */
4232
+ currentReplyRecovery(generation, deliveryEpoch) {
4233
+ const current = this.turnReplyGuard.snapshot();
4234
+ if (!current || current.generation !== generation || current.phase !== "recovering")
4235
+ return null;
4236
+ if (current.cancelledByUser || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
4237
+ this.turnReplyGuard.complete(generation);
4238
+ return null;
4239
+ }
4240
+ return current;
4241
+ }
4150
4242
  queueMalformedReplyRecovery(turn, text) {
4151
4243
  // Serialize the delivery decision ahead of any new pane input. If direct
4152
4244
  // delivery is positively acknowledged, there is no recovery turn. If its
4153
4245
  // outcome is unknown, do not ask the model to duplicate it.
4246
+ const deliveryEpoch = this.deliveryEpoch;
4154
4247
  const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
4155
4248
  this.pasteQueueDepth++;
4156
4249
  this.pasteLock = this.pasteLock.then(async () => {
@@ -4161,17 +4254,24 @@ export class Daemon extends EventEmitter {
4161
4254
  correlationId: turn.target.correlationId,
4162
4255
  recovered: delivered,
4163
4256
  });
4257
+ const current = this.turnReplyGuard.snapshot();
4164
4258
  this.turnReplyGuard.complete(turn.generation);
4165
- if (!delivered)
4259
+ if (!delivered && current?.generation === turn.generation && !current.cancelledByUser
4260
+ && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4166
4261
  this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4262
+ }
4167
4263
  }
4168
4264
  finally {
4169
4265
  this.pasteQueueDepth--;
4170
4266
  }
4171
4267
  }).catch(err => {
4172
4268
  this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
4269
+ const current = this.turnReplyGuard.snapshot();
4173
4270
  this.turnReplyGuard.complete(turn.generation);
4174
- this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4271
+ if (current?.generation === turn.generation && !current.cancelledByUser
4272
+ && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4273
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4274
+ }
4175
4275
  });
4176
4276
  }
4177
4277
  reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
@@ -4602,7 +4702,10 @@ export class Daemon extends EventEmitter {
4602
4702
  // the new progress timestamp and re-arms both deadlines.
4603
4703
  if (!expectedOutputAt)
4604
4704
  this.instanceStateLastOutputAt = captureStartedAt;
4605
- this.scheduleInstanceStateStuckDeadline(this.instanceStateLastOutputAt || captureStartedAt);
4705
+ // The deadline follows the state machine's latest sign of life (output events, content changes and the CLI's own
4706
+ // turn clock all feed it). Keyed on the output clock alone it would be "now" again the moment a long quiet turn
4707
+ // proved alive, and re-fire in a loop.
4708
+ this.scheduleInstanceStateStuckDeadline(snapshot.observedAt - snapshot.unchangedForMs || captureStartedAt);
4606
4709
  }
4607
4710
  }
4608
4711
  catch (err) {
@@ -4665,7 +4768,7 @@ export class Daemon extends EventEmitter {
4665
4768
  : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
4666
4769
  this.instanceStateReadyPattern = this.backend.getReadyPattern();
4667
4770
  this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
4668
- this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
4771
+ this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern, this.backend.getLiveProgressTick ? (pane) => this.backend.getLiveProgressTick(pane) : null);
4669
4772
  this.instanceStateLastOutputAt = 0;
4670
4773
  this.instanceStatePeriodicIdleConfirmations = 0;
4671
4774
  this.instanceStateMonitorActive = true;
@@ -4975,6 +5078,16 @@ export class Daemon extends EventEmitter {
4975
5078
  return ""; // skip channel tools
4976
5079
  return name;
4977
5080
  }
5081
+ /**
5082
+ * A steered message reads like a queued one plus a banner. The delivery marker (`[agend-delivery-id:…]`, when the message
5083
+ * is a durable delivery) must LEAD the persisted user message — restart reconciliation recognises a delivery only by a
5084
+ * marker at the start of the body (#1197) — so the banner goes right after it, not in front of it.
5085
+ */
5086
+ static steerEnvelope(inbound) {
5087
+ const banner = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n";
5088
+ const marker = inbound.match(/^\[agend-delivery-id:[^\]\r\n]+\]\r?\n/);
5089
+ return marker ? marker[0] + banner + inbound.slice(marker[0].length) : banner + inbound;
5090
+ }
4978
5091
  /**
4979
5092
  * The one place an inbound message grows its metadata wrapper ([user:]/
4980
5093
  * [from:] prefix, pending reactions, handoff metadata, reply instructions).
@@ -5084,8 +5197,7 @@ export class Daemon extends EventEmitter {
5084
5197
  this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
5085
5198
  this.pendingWork.recordInbound();
5086
5199
  this.recordRecentUserMessage(content, meta);
5087
- const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
5088
- + this.formatInboundMessage(content, meta);
5200
+ const formatted = Daemon.steerEnvelope(this.formatInboundMessage(content, meta));
5089
5201
  const status = channelStatus(meta);
5090
5202
  const durableAttempt = this.durableDeliveryAttempt(meta, content);
5091
5203
  if (durableAttempt === false) {
@@ -5112,7 +5224,7 @@ export class Daemon extends EventEmitter {
5112
5224
  durableAttempt: durableAttempt || undefined,
5113
5225
  })) {
5114
5226
  this.finishDurableSubmission(durableAttempt, verdict);
5115
- this.markTurnStarted(meta, formatted);
5227
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5116
5228
  }
5117
5229
  else if (durableAttempt && verdict.paneWriteStarted) {
5118
5230
  this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
@@ -5167,7 +5279,7 @@ export class Daemon extends EventEmitter {
5167
5279
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
5168
5280
  return;
5169
5281
  if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
5170
- this.markTurnStarted(meta, formatted);
5282
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5171
5283
  }
5172
5284
  }).catch(err => {
5173
5285
  this.logger.warn({ err: err.message }, "btw delivery error");
@@ -5280,7 +5392,7 @@ export class Daemon extends EventEmitter {
5280
5392
  durableAttempt: durableAttempt || undefined,
5281
5393
  })) {
5282
5394
  this.finishDurableSubmission(durable, verdict);
5283
- this.markTurnStarted(meta, formatted);
5395
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5284
5396
  }
5285
5397
  else if (durableAttempt && verdict.paneWriteStarted) {
5286
5398
  this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
@@ -5384,6 +5496,7 @@ export class Daemon extends EventEmitter {
5384
5496
  // this spawn generation. A spawn that starts afterwards is detected inside
5385
5497
  // the critical section, where this delivery backs out and redoes itself.
5386
5498
  const settleGeneration = this.spawnGeneration;
5499
+ const settleFence = this.launchFenceEpoch;
5387
5500
  if (cancelled())
5388
5501
  return false;
5389
5502
  if (this.refuseFatalStartupDelivery(verdict, status))
@@ -5508,7 +5621,28 @@ export class Daemon extends EventEmitter {
5508
5621
  }
5509
5622
  if (!(await this.hasPositiveDeliveryInput()))
5510
5623
  return "dialog";
5511
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true);
5624
+ // #829: a CLI that restores a cancelled prompt into its input box would
5625
+ // have this message pasted onto it and both submitted as one. Clear it
5626
+ // first, or do not write at all. From here every await is fenced: a
5627
+ // cancel, stop, freeze or spawn ends this attempt unwritten.
5628
+ // The CLI process is its spawn generation; a recovered window id is the
5629
+ // same process, a pause/stop/respawn is not (launchFenceEpoch moves even
5630
+ // when a failed pause resumes the monitors).
5631
+ const writeCurrent = () => !cancelled()
5632
+ && !this.deliveryWritesStopping
5633
+ && !this.runtimeMonitorsFrozen
5634
+ && !this.spawning
5635
+ && settleGeneration === this.spawnGeneration
5636
+ && settleFence === this.launchFenceEpoch;
5637
+ const stale = () => (this.spawning || settleGeneration !== this.spawnGeneration ? "spawn-started" : false);
5638
+ const residue = await this.clearRestoredInputDraft(handingOffToNativeQueue, writeCurrent);
5639
+ if (residue === "stale")
5640
+ return stale();
5641
+ if (residue !== "clear")
5642
+ return this.failDelivery(verdict, status, "input-residue", residue);
5643
+ const written = await this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true, writeCurrent);
5644
+ // Fenced before its first write: not attempted — redo after a spawn, else drop without a ❌.
5645
+ return written === false && verdict.fenced ? stale() : written;
5512
5646
  });
5513
5647
  if (outcome === "spawn-started") {
5514
5648
  // The pane changed under this delivery: its queued paste must not land
@@ -5809,6 +5943,104 @@ export class Daemon extends EventEmitter {
5809
5943
  this.trackDialogParked(null);
5810
5944
  return { state: "clear" };
5811
5945
  }
5946
+ /**
5947
+ * Arm the "keep going" nudge for the error occurrence just reported (`pattern` must be the one the lifecycle was
5948
+ * handed). The nudge is sent once, `delayMs` from now on the MONOTONIC clock (a wall-clock step changes nothing),
5949
+ * and only if nothing has changed by then — see {@link tickCapacityNudge}. Returns false when there is nothing to arm.
5950
+ */
5951
+ armCapacityNudge(pattern, delayMs) {
5952
+ const episode = this.lastErrorEpisode;
5953
+ if (!episode || episode.pattern !== pattern)
5954
+ return false;
5955
+ if (episode.spawn !== this.spawnGeneration || episode.fence !== this.launchFenceEpoch)
5956
+ return false;
5957
+ if (this.isPaused || this.getProcessStatus() === "stopped")
5958
+ return false;
5959
+ // Only an occurrence that appeared during this spawn's live session: before the baseline exists, or not beyond it,
5960
+ // it is scrollback from a resumed session.
5961
+ const baseline = this.nudgeBaselines?.get(episode.key);
5962
+ if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
5963
+ return false;
5964
+ this.capacityNudge = {
5965
+ pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
5966
+ epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
5967
+ };
5968
+ this.logger.info({ delayMs }, "Model-capacity nudge armed");
5969
+ return true;
5970
+ }
5971
+ /** Drop an armed nudge (the user stopped / paused the instance, or the lifecycle gave up on it). */
5972
+ cancelCapacityNudge(reason) {
5973
+ if (!this.capacityNudge)
5974
+ return;
5975
+ this.capacityNudge = null;
5976
+ this.logger.info({ reason }, "Model-capacity nudge cancelled");
5977
+ }
5978
+ /** The first live composer of a spawn fixes what is history (see {@link nudgeBaselines}). */
5979
+ takeNudgeBaselines(pane, patterns) {
5980
+ if (!this.isCodexLivePaneSnapshot(pane))
5981
+ return;
5982
+ const baselines = this.nudgeBaselines ?? new Map();
5983
+ for (const ep of patterns) {
5984
+ if (ep.action !== "nudge_continue")
5985
+ continue;
5986
+ const key = Daemon.errorPatternKey(ep);
5987
+ const count = Daemon.countOccurrences(ep.pattern, pane);
5988
+ // History that has scrolled away must not hide the next real occurrence.
5989
+ baselines.set(key, Math.min(baselines.get(key) ?? count, count));
5990
+ }
5991
+ this.nudgeBaselines = baselines;
5992
+ }
5993
+ static countOccurrences(pattern, pane) {
5994
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
5995
+ return (pane.match(new RegExp(pattern.source, flags)) || []).length;
5996
+ }
5997
+ /**
5998
+ * One monitor poll's look at an armed nudge. It is CANCELLED — never postponed — when anything about the instance
5999
+ * moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane no
6000
+ * longer being exactly the one the error was seen on (it moved on by itself, a person typed, another dialog), the
6001
+ * agent working, or a message waiting for delivery. Only when it is due and still untouched is it fired — once — and
6002
+ * the injection re-verifies everything under the pane-write lock.
6003
+ */
6004
+ tickCapacityNudge(pane) {
6005
+ const nudge = this.capacityNudge;
6006
+ if (!nudge)
6007
+ return;
6008
+ const gone = nudge.spawn !== this.spawnGeneration || nudge.fence !== this.launchFenceEpoch ? "the instance was stopped, paused or respawned"
6009
+ : !this.isDeliveryEpochCurrent(nudge.epoch) ? "the user cancelled"
6010
+ : this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" ? "the instance is not running"
6011
+ : pane !== nudge.pane ? "the screen changed"
6012
+ : this.instanceState === "working" || this.pasteQueueDepth > 0 ? "the instance has work of its own"
6013
+ : null;
6014
+ if (gone) {
6015
+ this.cancelCapacityNudge(gone);
6016
+ return;
6017
+ }
6018
+ if (performance.now() < nudge.dueAt)
6019
+ return;
6020
+ this.capacityNudge = null; // fired: once per episode
6021
+ void this.sendCapacityNudge(nudge).catch(err => this.logger.warn({ err: err.message }, "Model-capacity nudge failed"));
6022
+ }
6023
+ async sendCapacityNudge(nudge) {
6024
+ // Everything the nudge was armed under, asked again after EVERY await that precedes a write — inside the paste
6025
+ // primitive too (its capture, its paste, its pause, its Enter, its retry): once any of it is false nothing more is
6026
+ // written. A message queued behind the lock, or the agent starting a turn, is "work of its own" like at the poll.
6027
+ const current = () => nudge.spawn === this.spawnGeneration && nudge.fence === this.launchFenceEpoch
6028
+ && this.isDeliveryEpochCurrent(nudge.epoch) && !this.isPaused && !this.healthCheckPaused
6029
+ && !this.startupAborted && this.getProcessStatus() !== "stopped"
6030
+ && this.pasteQueueDepth === 0 && this.instanceState !== "working";
6031
+ // Exclusive with every delivery: nothing can paste a turn between the verdict below and the nudge.
6032
+ await this.paneWriteLock.run(async () => {
6033
+ if (!this.tmux)
6034
+ return;
6035
+ // ONE capture decides: it must be exactly the screen the error was seen on AND an idle composer. (A second picture
6036
+ // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
6037
+ const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
6038
+ current,
6039
+ accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
6040
+ });
6041
+ this.logger.info({ sent }, "Model-capacity nudge attempted");
6042
+ });
6043
+ }
5812
6044
  /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
5813
6045
  static screenOf(dialog, pane) {
5814
6046
  return dialog.requestIdentity?.(pane) ?? pane;
@@ -6174,6 +6406,114 @@ export class Daemon extends EventEmitter {
6174
6406
  * else submitted it); "failed" = the Enter could not be sent (the delivery
6175
6407
  * must fail rather than paste on top of the stranded text).
6176
6408
  */
6409
+ /** #829: remember what this pane generation was given, so a draft can be proven to be ours. */
6410
+ /**
6411
+ * #829: remember what was pasted, under the spawn generation it was written
6412
+ * to — fixed by the caller BEFORE its paste await, so a write that lands
6413
+ * while the CLI is replaced is never credited to the replacement.
6414
+ */
6415
+ rememberPaste(text, generation) {
6416
+ this.recentPastes.push({ generation, text });
6417
+ if (this.recentPastes.length > RECENT_PASTES_KEPT)
6418
+ this.recentPastes.splice(0, this.recentPastes.length - RECENT_PASTES_KEPT);
6419
+ }
6420
+ /**
6421
+ * #829: whether the input box shows one of this generation's own pastes —
6422
+ * exactly as the backend draws it, wrapping included (inputDraftShows). A
6423
+ * marker that looks like AgEnD's, a collapsed paste whose content cannot be
6424
+ * seen, or text that differs only in spacing proves nothing.
6425
+ */
6426
+ ownsInputDraft(draft) {
6427
+ const shows = this.backend?.inputDraftShows;
6428
+ if (!shows)
6429
+ return false;
6430
+ return this.recentPastes.some(p => p.generation === this.spawnGeneration && shows.call(this.backend, draft, p.text));
6431
+ }
6432
+ /**
6433
+ * #829: empty the CLI's input box of a draft the CLI put back there itself
6434
+ * (muse restores a cancelled prompt) before a delivery is pasted. Only for a
6435
+ * backend that can read its box and names the keys that clear it; every other
6436
+ * backend is untouched.
6437
+ *
6438
+ * Only a draft that is provably one of this pane's own pastes is cleared, one
6439
+ * line per round, and every round must leave the first or last rows of what
6440
+ * was there — anything else in the box is somebody's typing and stops the
6441
+ * clearing. `current` is the caller's fixed fence (cancel, stop, freeze,
6442
+ * pause, spawn): it is asked after every await, and a stale answer means
6443
+ * nothing more is sent. A capture that fails, a box that does not empty or a
6444
+ * draft that is not ours stops the delivery; it is never pasted onto.
6445
+ */
6446
+ async clearRestoredInputDraft(handingOffToNativeQueue, current) {
6447
+ const backend = this.backend;
6448
+ const tmux = this.tmux;
6449
+ const keys = backend?.getClearInputKeys?.();
6450
+ if (!backend?.inputDraft || !keys?.length || handingOffToNativeQueue || !tmux)
6451
+ return "clear";
6452
+ // undefined: the capture itself failed. null: read, but not a layout the backend knows.
6453
+ const read = async () => {
6454
+ let pane;
6455
+ try {
6456
+ pane = await tmux.capturePane();
6457
+ }
6458
+ catch {
6459
+ return undefined;
6460
+ }
6461
+ return backend.inputDraft(pane);
6462
+ };
6463
+ const first = await read();
6464
+ if (!current())
6465
+ return "stale";
6466
+ if (first === undefined) {
6467
+ this.logger.warn("Could not read the input box before this delivery — not writing it");
6468
+ return "unreadable";
6469
+ }
6470
+ // A screen the backend does not recognise keeps today's behaviour; the
6471
+ // readiness checks before this one decide whether it is writable at all.
6472
+ if (first === null || first.rows.length === 0)
6473
+ return "clear";
6474
+ if (!this.ownsInputDraft(first)) {
6475
+ this.logger.warn({ rows: first.rows.length }, "The input box holds text AgEnD did not put there — not clearing it, and not writing this delivery onto it");
6476
+ return "foreign-draft";
6477
+ }
6478
+ const original = first.rows;
6479
+ // A round removes whole lines from one end: what is left is the original's
6480
+ // first or last rows, unchanged.
6481
+ const remainderOk = (rows) => rows.length <= original.length
6482
+ && (rows.every((r, k) => r === original[k]) || rows.every((r, k) => r === original[original.length - rows.length + k]));
6483
+ const same = (a, b) => a.length === b.length && a.every((r, k) => r === b[k]);
6484
+ const rounds = Math.min(INPUT_RESIDUE_MAX_ROUNDS, original.length + 3);
6485
+ this.logger.info({ rows: original.length }, "Clearing a restored draft from the input box before this delivery");
6486
+ // `current` is asked after every await; between one of those answers and
6487
+ // the next key there is no await.
6488
+ let rows = original;
6489
+ for (let round = 0; round < rounds; round++) {
6490
+ const before = rows;
6491
+ const sent = await tmux.sendKeySequence(keys);
6492
+ if (!current())
6493
+ return "stale";
6494
+ if (!sent)
6495
+ return "not-cleared";
6496
+ const deadline = Date.now() + INPUT_RESIDUE_ROUND_WAIT_MS;
6497
+ let next;
6498
+ do {
6499
+ await new Promise(r => setTimeout(r, INPUT_RESIDUE_POLL_MS));
6500
+ next = await read();
6501
+ if (!current())
6502
+ return "stale";
6503
+ } while (next && next.rows.length > 0 && same(next.rows, before) && Date.now() < deadline);
6504
+ if (!next)
6505
+ return "not-cleared";
6506
+ if (next.rows.length === 0)
6507
+ return "clear";
6508
+ if (!remainderOk(next.rows)) {
6509
+ this.logger.warn({ rows: next.rows.length }, "The input box changed to text AgEnD did not put there — stopped clearing, not writing this delivery");
6510
+ return "foreign-draft";
6511
+ }
6512
+ rows = next.rows;
6513
+ }
6514
+ this.logger.error({ rows: rows.length }, "The input box did not empty — not writing this delivery onto it");
6515
+ return "not-cleared";
6516
+ }
6177
6517
  async submitStrandedInputIfAny(windowId) {
6178
6518
  if (!this.tmux || !this.backend)
6179
6519
  return "idle";
@@ -6495,10 +6835,15 @@ export class Daemon extends EventEmitter {
6495
6835
  async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
6496
6836
  // Last and defaulted so the positional callers that ignore the outcome stay
6497
6837
  // readable; deliverMessage, the only one that reports, always passes its own.
6498
- verdict = { reached: false }, durableAttempt, steer = false) {
6838
+ verdict = { reached: false }, durableAttempt, steer = false,
6839
+ // #829: the caller's fence, asked once more after the last await before the
6840
+ // first side effect. Stale means unwritten: false, with no failure verdict.
6841
+ stillCurrent) {
6499
6842
  const signature = this.submissionSignature(formatted, submissionId);
6500
6843
  const rawPaste = durableAttempt?.submissionMode === "raw_paste";
6501
6844
  let windowId = initialWindowId;
6845
+ // The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
6846
+ const spawnAtWrite = this.spawnGeneration;
6502
6847
  // Bug A: paste with backoff. Transient failures are usually a stale window id
6503
6848
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
6504
6849
  const maxAttempts = 3;
@@ -6508,6 +6853,12 @@ export class Daemon extends EventEmitter {
6508
6853
  // Read the pane BEFORE writing to it, so the submission check can require
6509
6854
  // evidence this paste ADDED rather than evidence that was already there.
6510
6855
  const pasteBaseline = await this.capturePaneEvidence(signature);
6856
+ // Every attempt, retries included: a stop or respawn during a recovery wait ends it unwritten.
6857
+ // After a durable begin, the caller's abort path owns the row; only an attempt with no begin may be redone.
6858
+ if (stillCurrent && !stillCurrent()) {
6859
+ verdict.fenced = !verdict.durableBeginCommitted;
6860
+ return false;
6861
+ }
6511
6862
  // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
6512
6863
  // Commit the submission fence at the last possible point before the
6513
6864
  // first side effect; a crash during those waits remains safely replayable.
@@ -6520,6 +6871,10 @@ export class Daemon extends EventEmitter {
6520
6871
  pastedContentSha256: durableAttempt.contentSha256 ?? null,
6521
6872
  pastedBytesSha256: deliveryContentDigest(formatted),
6522
6873
  };
6874
+ if (stillCurrent && !stillCurrent()) {
6875
+ verdict.fenced = true;
6876
+ return false;
6877
+ }
6523
6878
  if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
6524
6879
  verdict.durableBeginRejected = true;
6525
6880
  verdict.phase = "submission-begin";
@@ -6534,7 +6889,10 @@ export class Daemon extends EventEmitter {
6534
6889
  // failed/throwing write as uncertain rather than retrying bytes into the
6535
6890
  // same pane; the write-ahead Enter marker later separates safe pre-Enter
6536
6891
  // recovery from a possibly executed command.
6892
+ const pasteGeneration = this.spawnGeneration;
6537
6893
  const pasted = await this.tmux.pasteBuffer(formatted);
6894
+ if (pasted)
6895
+ this.rememberPaste(formatted, pasteGeneration);
6538
6896
  if (!pasted) {
6539
6897
  if (rawPaste) {
6540
6898
  verdict.phase = "raw-paste-write";
@@ -6640,6 +6998,18 @@ export class Daemon extends EventEmitter {
6640
6998
  verdict.proof = proof;
6641
6999
  return false;
6642
7000
  }
7001
+ // A steer into a busy pane of a CLI with no input-row reader (claude-code, grok, muse): it can never be shown to
7002
+ // have left the input row, and the idle→busy edge that proves an ordinary submission cannot exist in a pane that
7003
+ // is already busy. What it CAN show, and what is enough to call the steer accepted, is its own trusted unique
7004
+ // marker on the pane after the one Enter — with the dialog, spawn and window unchanged (#1197).
7005
+ if (steer && signature.unique && !this.backend?.getBottomReadyPattern?.()
7006
+ && await this.steerMarkerAccepted(signature, pasteBaseline, spawnAtWrite, windowId)) {
7007
+ verdict.phase = "steer-accepted";
7008
+ verdict.proof = "steer-marker-on-pane";
7009
+ if (status)
7010
+ this.emit("message_confirmed", status); // ✅ accepted into the live turn
7011
+ return true;
7012
+ }
6643
7013
  // Re-pasting on a backend without structured Codex pane evidence
6644
7014
  // would risk duplication; retain its legacy best-effort behavior.
6645
7015
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
@@ -6868,6 +7238,29 @@ export class Daemon extends EventEmitter {
6868
7238
  * — fall back to "the pane printed something" whenever a paste left no trace,
6869
7239
  * which is the same false confirmation one layer down.
6870
7240
  */
7241
+ /**
7242
+ * The acceptance evidence for a steer whose input row cannot be read: a FRESH capture shows this delivery's trusted unique
7243
+ * marker more often than the pre-paste baseline did, no blocking dialog owns the pane, and it is still the spawn and the
7244
+ * window the paste went into. (The marker is the `message_id:` value AgEnD put in the handoff metadata — never text
7245
+ * scanned out of a body — so an older or quoted line cannot match it.)
7246
+ */
7247
+ async steerMarkerAccepted(signature, baseline, spawnGeneration, windowId) {
7248
+ if (!this.tmux)
7249
+ return false;
7250
+ let pane;
7251
+ try {
7252
+ pane = await this.tmux.capturePane();
7253
+ }
7254
+ catch {
7255
+ return false;
7256
+ }
7257
+ // "More often than before the paste" needs a before: a baseline that could not be read is not a count of zero.
7258
+ if (!baseline || this.paneEvidence(pane, signature).payload <= baseline.payload)
7259
+ return false;
7260
+ const dialogClear = (await this.probeBlockingDialog()).state === "clear";
7261
+ // Asked last, after every await: the read evidence is only worth anything for the pane it was read from.
7262
+ return dialogClear && this.spawnGeneration === spawnGeneration && this.getWindowId() === windowId;
7263
+ }
6871
7264
  canProveSubmission() {
6872
7265
  return !!this.backend?.getBottomReadyPattern?.();
6873
7266
  }
@@ -7143,17 +7536,40 @@ export class Daemon extends EventEmitter {
7143
7536
  * Best effort by design: these notices must never fail a delivery or throw.
7144
7537
  * The caller already holds paneWriteLock.
7145
7538
  */
7146
- async submitSystemPaste(text, label) {
7539
+ /**
7540
+ * `guard` is for a paste that must not outlive the reason it was made (the continue nudge): `accept` judges the ONE
7541
+ * capture taken here — the same picture is the baseline, the expected-screen check and the idle check — and `current`
7542
+ * is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
7543
+ * (the retries included). Without a guard it is exactly the old unconditional path.
7544
+ */
7545
+ async submitSystemPaste(text, label, guard) {
7147
7546
  if (!this.tmux)
7148
7547
  return false;
7149
7548
  const signature = this.submissionSignature(text);
7150
- const baseline = await this.capturePaneEvidence(signature);
7549
+ let baseline;
7550
+ if (guard) {
7551
+ let pane;
7552
+ try {
7553
+ pane = await this.tmux.capturePane();
7554
+ }
7555
+ catch {
7556
+ return false;
7557
+ }
7558
+ if (!guard.current() || !guard.accept(pane))
7559
+ return false;
7560
+ baseline = this.paneEvidence(pane, signature);
7561
+ }
7562
+ else {
7563
+ baseline = await this.capturePaneEvidence(signature);
7564
+ }
7565
+ const pasteGeneration = this.spawnGeneration;
7151
7566
  if (!(await this.tmux.pasteBuffer(text))) {
7152
7567
  this.logger.warn({ label }, "System paste failed to reach the pane");
7153
7568
  return false;
7154
7569
  }
7570
+ this.rememberPaste(text, pasteGeneration);
7155
7571
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
7156
- if (!(await this.sendDeliveryEnter(label)))
7572
+ if (!(await this.sendDeliveryEnter(label, guard?.current)))
7157
7573
  return false;
7158
7574
  let proof = await this.confirmSubmitted(signature, baseline);
7159
7575
  if (proof === "unverifiable") {
@@ -7171,7 +7587,7 @@ export class Daemon extends EventEmitter {
7171
7587
  // exists to remove — visible text is also what a strand looks like.
7172
7588
  if (this.systemPasteOptions().retryEnter) {
7173
7589
  await new Promise(r => setTimeout(r, 1_000));
7174
- await this.sendDeliveryEnter(`${label}-defensive-retry`);
7590
+ await this.sendDeliveryEnter(`${label}-defensive-retry`, guard?.current);
7175
7591
  }
7176
7592
  return true; // best effort, exactly as before — nothing here is verified
7177
7593
  }
@@ -7180,7 +7596,7 @@ export class Daemon extends EventEmitter {
7180
7596
  // first one did land; when the text is still in the input row it is the
7181
7597
  // submit it never got. Re-pasting would append the text to itself.
7182
7598
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
7183
- if (!(await this.sendDeliveryEnter(`${label}-retry`)))
7599
+ if (!(await this.sendDeliveryEnter(`${label}-retry`, guard?.current)))
7184
7600
  return false;
7185
7601
  proof = await this.confirmSubmitted(signature, baseline);
7186
7602
  }
@@ -7748,6 +8164,9 @@ export class Daemon extends EventEmitter {
7748
8164
  */
7749
8165
  beginSpawn() {
7750
8166
  this.dialogAnswers = null;
8167
+ this.capacityNudge = null;
8168
+ this.lastErrorEpisode = null;
8169
+ this.nudgeBaselines = null;
7751
8170
  // A relaunch settles any outstanding quit watch: the CLI came back.
7752
8171
  this.clearQuitRelaunchWatch();
7753
8172
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
@@ -8133,8 +8552,7 @@ export class Daemon extends EventEmitter {
8133
8552
  * a live turn. Fail closed: an unreadable pane is never idle.
8134
8553
  */
8135
8554
  async isPaneAuthoritativelyIdle() {
8136
- const backend = this.backend;
8137
- if (!this.tmux || !backend)
8555
+ if (!this.tmux || !this.backend)
8138
8556
  return false;
8139
8557
  let pane;
8140
8558
  try {
@@ -8143,6 +8561,13 @@ export class Daemon extends EventEmitter {
8143
8561
  catch {
8144
8562
  return false;
8145
8563
  }
8564
+ return this.paneAuthoritativelyIdle(pane);
8565
+ }
8566
+ /** The verdict of {@link isPaneAuthoritativelyIdle} on a capture the caller already holds. */
8567
+ paneAuthoritativelyIdle(pane) {
8568
+ const backend = this.backend;
8569
+ if (!backend)
8570
+ return false;
8146
8571
  if (backend.getBusyPattern?.()?.test(pane))
8147
8572
  return false;
8148
8573
  for (const dialog of this.deliveryBlockingDialogs()) {