@songsid/agend 2.1.11-beta.4 → 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.
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;
@@ -1202,6 +1222,8 @@ export class Daemon extends EventEmitter {
1202
1222
  * comes from. See PaneWriteLock for why interleaving is destructive. */
1203
1223
  paneWriteLock = new PaneWriteLock();
1204
1224
  deliveryWritesStopping = false;
1225
+ /** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
1226
+ recentPastes = [];
1205
1227
  pendingInstructionsUpdate;
1206
1228
  pendingInstructionsNotice = false;
1207
1229
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -1603,6 +1625,14 @@ export class Daemon extends EventEmitter {
1603
1625
  this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
1604
1626
  return;
1605
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
+ }
1606
1636
  this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
1607
1637
  }
1608
1638
  deferDurableDelivery(meta, reason) {
@@ -1665,19 +1695,29 @@ export class Daemon extends EventEmitter {
1665
1695
  ensureInstanceDir(this.instanceDir);
1666
1696
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1667
1697
  this.logger.info(`Starting ${this.name}`);
1668
- // 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.
1669
1701
  const crashStatePath = join(this.instanceDir, "crash-state.json");
1670
1702
  try {
1671
1703
  if (existsSync(crashStatePath)) {
1672
- const state = JSON.parse(readFileSync(crashStatePath, "utf-8"));
1673
- if (state.resumeDisabled) {
1674
- this.skipResume = true;
1675
- 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 */ }
1676
1717
  }
1677
- unlinkSync(crashStatePath);
1678
1718
  }
1679
1719
  }
1680
- catch { /* corrupt file — ignore */ }
1720
+ catch { /* unreadable or corrupt file — ignore */ }
1681
1721
  // Restore last reply target so a fleet-topic instance can reply correctly
1682
1722
  // BEFORE its first post-restart inbound arrives (otherwise lastChatId is empty
1683
1723
  // → reply tool sends to "" → Discord 404).
@@ -3208,6 +3248,9 @@ export class Daemon extends EventEmitter {
3208
3248
  * transaction; the interrupt key still cancels the CLI generation.
3209
3249
  */
3210
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();
3211
3254
  this.deliveryEpoch = fleetEpoch === undefined
3212
3255
  ? this.deliveryEpoch + 1
3213
3256
  : Math.max(this.deliveryEpoch, fleetEpoch);
@@ -3990,7 +4033,11 @@ export class Daemon extends EventEmitter {
3990
4033
  * message can arrive mid-turn and sit queued, and marking at arrival would let
3991
4034
  * the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
3992
4035
  */
3993
- 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;
3994
4041
  // Channel turns only. A cross-instance inbound (from_instance, empty
3995
4042
  // chat_id) does not update lastChatId, so its proxy reply would land in
3996
4043
  // whatever USER topic spoke to this instance last — the wrong audience for
@@ -4031,6 +4078,12 @@ export class Daemon extends EventEmitter {
4031
4078
  this.turnReplyGuard.complete(turn.generation);
4032
4079
  return;
4033
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
+ }
4034
4087
  // A second idle edge ends the one permitted recovery turn. Never create a
4035
4088
  // third turn or guess at terminal text; make the failure visible instead.
4036
4089
  if (turn.phase === "recovering") {
@@ -4110,6 +4163,11 @@ export class Daemon extends EventEmitter {
4110
4163
  && this.backend?.replyCompletionGuard === true;
4111
4164
  }
4112
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
+ }
4113
4171
  if (!this.turnReplyGuard.beginRecovery(turn.generation))
4114
4172
  return;
4115
4173
  this.emit("reply_drop_detected", {
@@ -4137,8 +4195,8 @@ export class Daemon extends EventEmitter {
4137
4195
  this.pasteQueueDepth++;
4138
4196
  this.pasteLock = this.pasteLock.then(async () => {
4139
4197
  try {
4140
- const current = this.turnReplyGuard.snapshot();
4141
- if (!current || current.generation !== turn.generation || current.phase !== "recovering")
4198
+ const current = this.currentReplyRecovery(turn.generation, deliveryEpoch);
4199
+ if (!current)
4142
4200
  return;
4143
4201
  if (current.completionDelivered) {
4144
4202
  this.turnReplyGuard.complete(turn.generation);
@@ -4152,6 +4210,8 @@ export class Daemon extends EventEmitter {
4152
4210
  const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
4153
4211
  deliveryEpoch,
4154
4212
  });
4213
+ if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
4214
+ return;
4155
4215
  if (!delivered) {
4156
4216
  this.turnReplyGuard.complete(turn.generation);
4157
4217
  this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
@@ -4162,14 +4222,28 @@ export class Daemon extends EventEmitter {
4162
4222
  }
4163
4223
  }).catch(err => {
4164
4224
  this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
4225
+ if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
4226
+ return;
4165
4227
  this.turnReplyGuard.complete(turn.generation);
4166
4228
  this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
4167
4229
  });
4168
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
+ }
4169
4242
  queueMalformedReplyRecovery(turn, text) {
4170
4243
  // Serialize the delivery decision ahead of any new pane input. If direct
4171
4244
  // delivery is positively acknowledged, there is no recovery turn. If its
4172
4245
  // outcome is unknown, do not ask the model to duplicate it.
4246
+ const deliveryEpoch = this.deliveryEpoch;
4173
4247
  const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
4174
4248
  this.pasteQueueDepth++;
4175
4249
  this.pasteLock = this.pasteLock.then(async () => {
@@ -4180,17 +4254,24 @@ export class Daemon extends EventEmitter {
4180
4254
  correlationId: turn.target.correlationId,
4181
4255
  recovered: delivered,
4182
4256
  });
4257
+ const current = this.turnReplyGuard.snapshot();
4183
4258
  this.turnReplyGuard.complete(turn.generation);
4184
- if (!delivered)
4259
+ if (!delivered && current?.generation === turn.generation && !current.cancelledByUser
4260
+ && this.isDeliveryEpochCurrent(deliveryEpoch)) {
4185
4261
  this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4262
+ }
4186
4263
  }
4187
4264
  finally {
4188
4265
  this.pasteQueueDepth--;
4189
4266
  }
4190
4267
  }).catch(err => {
4191
4268
  this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
4269
+ const current = this.turnReplyGuard.snapshot();
4192
4270
  this.turnReplyGuard.complete(turn.generation);
4193
- 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
+ }
4194
4275
  });
4195
4276
  }
4196
4277
  reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
@@ -4621,7 +4702,10 @@ export class Daemon extends EventEmitter {
4621
4702
  // the new progress timestamp and re-arms both deadlines.
4622
4703
  if (!expectedOutputAt)
4623
4704
  this.instanceStateLastOutputAt = captureStartedAt;
4624
- 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);
4625
4709
  }
4626
4710
  }
4627
4711
  catch (err) {
@@ -4684,7 +4768,7 @@ export class Daemon extends EventEmitter {
4684
4768
  : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
4685
4769
  this.instanceStateReadyPattern = this.backend.getReadyPattern();
4686
4770
  this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
4687
- 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);
4688
4772
  this.instanceStateLastOutputAt = 0;
4689
4773
  this.instanceStatePeriodicIdleConfirmations = 0;
4690
4774
  this.instanceStateMonitorActive = true;
@@ -4994,6 +5078,16 @@ export class Daemon extends EventEmitter {
4994
5078
  return ""; // skip channel tools
4995
5079
  return name;
4996
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
+ }
4997
5091
  /**
4998
5092
  * The one place an inbound message grows its metadata wrapper ([user:]/
4999
5093
  * [from:] prefix, pending reactions, handoff metadata, reply instructions).
@@ -5103,8 +5197,7 @@ export class Daemon extends EventEmitter {
5103
5197
  this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
5104
5198
  this.pendingWork.recordInbound();
5105
5199
  this.recordRecentUserMessage(content, meta);
5106
- const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
5107
- + this.formatInboundMessage(content, meta);
5200
+ const formatted = Daemon.steerEnvelope(this.formatInboundMessage(content, meta));
5108
5201
  const status = channelStatus(meta);
5109
5202
  const durableAttempt = this.durableDeliveryAttempt(meta, content);
5110
5203
  if (durableAttempt === false) {
@@ -5131,7 +5224,7 @@ export class Daemon extends EventEmitter {
5131
5224
  durableAttempt: durableAttempt || undefined,
5132
5225
  })) {
5133
5226
  this.finishDurableSubmission(durableAttempt, verdict);
5134
- this.markTurnStarted(meta, formatted);
5227
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5135
5228
  }
5136
5229
  else if (durableAttempt && verdict.paneWriteStarted) {
5137
5230
  this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
@@ -5186,7 +5279,7 @@ export class Daemon extends EventEmitter {
5186
5279
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
5187
5280
  return;
5188
5281
  if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
5189
- this.markTurnStarted(meta, formatted);
5282
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5190
5283
  }
5191
5284
  }).catch(err => {
5192
5285
  this.logger.warn({ err: err.message }, "btw delivery error");
@@ -5299,7 +5392,7 @@ export class Daemon extends EventEmitter {
5299
5392
  durableAttempt: durableAttempt || undefined,
5300
5393
  })) {
5301
5394
  this.finishDurableSubmission(durable, verdict);
5302
- this.markTurnStarted(meta, formatted);
5395
+ this.markTurnStarted(meta, formatted, deliveryEpoch);
5303
5396
  }
5304
5397
  else if (durableAttempt && verdict.paneWriteStarted) {
5305
5398
  this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
@@ -5403,6 +5496,7 @@ export class Daemon extends EventEmitter {
5403
5496
  // this spawn generation. A spawn that starts afterwards is detected inside
5404
5497
  // the critical section, where this delivery backs out and redoes itself.
5405
5498
  const settleGeneration = this.spawnGeneration;
5499
+ const settleFence = this.launchFenceEpoch;
5406
5500
  if (cancelled())
5407
5501
  return false;
5408
5502
  if (this.refuseFatalStartupDelivery(verdict, status))
@@ -5527,7 +5621,28 @@ export class Daemon extends EventEmitter {
5527
5621
  }
5528
5622
  if (!(await this.hasPositiveDeliveryInput()))
5529
5623
  return "dialog";
5530
- 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;
5531
5646
  });
5532
5647
  if (outcome === "spawn-started") {
5533
5648
  // The pane changed under this delivery: its queued paste must not land
@@ -6291,6 +6406,114 @@ export class Daemon extends EventEmitter {
6291
6406
  * else submitted it); "failed" = the Enter could not be sent (the delivery
6292
6407
  * must fail rather than paste on top of the stranded text).
6293
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
+ }
6294
6517
  async submitStrandedInputIfAny(windowId) {
6295
6518
  if (!this.tmux || !this.backend)
6296
6519
  return "idle";
@@ -6612,10 +6835,15 @@ export class Daemon extends EventEmitter {
6612
6835
  async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
6613
6836
  // Last and defaulted so the positional callers that ignore the outcome stay
6614
6837
  // readable; deliverMessage, the only one that reports, always passes its own.
6615
- 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) {
6616
6842
  const signature = this.submissionSignature(formatted, submissionId);
6617
6843
  const rawPaste = durableAttempt?.submissionMode === "raw_paste";
6618
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;
6619
6847
  // Bug A: paste with backoff. Transient failures are usually a stale window id
6620
6848
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
6621
6849
  const maxAttempts = 3;
@@ -6625,6 +6853,12 @@ export class Daemon extends EventEmitter {
6625
6853
  // Read the pane BEFORE writing to it, so the submission check can require
6626
6854
  // evidence this paste ADDED rather than evidence that was already there.
6627
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
+ }
6628
6862
  // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
6629
6863
  // Commit the submission fence at the last possible point before the
6630
6864
  // first side effect; a crash during those waits remains safely replayable.
@@ -6637,6 +6871,10 @@ export class Daemon extends EventEmitter {
6637
6871
  pastedContentSha256: durableAttempt.contentSha256 ?? null,
6638
6872
  pastedBytesSha256: deliveryContentDigest(formatted),
6639
6873
  };
6874
+ if (stillCurrent && !stillCurrent()) {
6875
+ verdict.fenced = true;
6876
+ return false;
6877
+ }
6640
6878
  if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
6641
6879
  verdict.durableBeginRejected = true;
6642
6880
  verdict.phase = "submission-begin";
@@ -6651,7 +6889,10 @@ export class Daemon extends EventEmitter {
6651
6889
  // failed/throwing write as uncertain rather than retrying bytes into the
6652
6890
  // same pane; the write-ahead Enter marker later separates safe pre-Enter
6653
6891
  // recovery from a possibly executed command.
6892
+ const pasteGeneration = this.spawnGeneration;
6654
6893
  const pasted = await this.tmux.pasteBuffer(formatted);
6894
+ if (pasted)
6895
+ this.rememberPaste(formatted, pasteGeneration);
6655
6896
  if (!pasted) {
6656
6897
  if (rawPaste) {
6657
6898
  verdict.phase = "raw-paste-write";
@@ -6757,6 +6998,18 @@ export class Daemon extends EventEmitter {
6757
6998
  verdict.proof = proof;
6758
6999
  return false;
6759
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
+ }
6760
7013
  // Re-pasting on a backend without structured Codex pane evidence
6761
7014
  // would risk duplication; retain its legacy best-effort behavior.
6762
7015
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
@@ -6985,6 +7238,29 @@ export class Daemon extends EventEmitter {
6985
7238
  * — fall back to "the pane printed something" whenever a paste left no trace,
6986
7239
  * which is the same false confirmation one layer down.
6987
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
+ }
6988
7264
  canProveSubmission() {
6989
7265
  return !!this.backend?.getBottomReadyPattern?.();
6990
7266
  }
@@ -7286,10 +7562,12 @@ export class Daemon extends EventEmitter {
7286
7562
  else {
7287
7563
  baseline = await this.capturePaneEvidence(signature);
7288
7564
  }
7565
+ const pasteGeneration = this.spawnGeneration;
7289
7566
  if (!(await this.tmux.pasteBuffer(text))) {
7290
7567
  this.logger.warn({ label }, "System paste failed to reach the pane");
7291
7568
  return false;
7292
7569
  }
7570
+ this.rememberPaste(text, pasteGeneration);
7293
7571
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
7294
7572
  if (!(await this.sendDeliveryEnter(label, guard?.current)))
7295
7573
  return false;