@songsid/agend 2.1.5-beta.12 → 2.1.5-beta.14

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
@@ -23,7 +23,7 @@ import { writeSecretFile } from "./secret-file.js";
23
23
  import { PaneWriteLock } from "./pane-write-lock.js";
24
24
  import { buildFleetInstructions } from "./instructions.js";
25
25
  import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
26
- import { bottomRowIsReady, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
26
+ import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
27
27
  const __filename = fileURLToPath(import.meta.url);
28
28
  const __dirname = dirname(__filename);
29
29
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -386,6 +386,18 @@ export class PendingWorkTracker {
386
386
  }
387
387
  }
388
388
  const NORMAL_ENTER_SETTLE_MS = 500;
389
+ /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
390
+ const BASELINE_CAPTURE_ATTEMPTS = 3;
391
+ const BASELINE_CAPTURE_RETRY_MS = 150;
392
+ /** Occurrences of `needle` in `haystack` (plain text, no regex semantics). */
393
+ function countOccurrences(haystack, needle) {
394
+ if (!needle)
395
+ return 0;
396
+ let n = 0;
397
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length))
398
+ n++;
399
+ return n;
400
+ }
389
401
  /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
390
402
  const BOTTOM_READY_POLL_MS = 250;
391
403
  /** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
@@ -1059,6 +1071,33 @@ export class Daemon extends EventEmitter {
1059
1071
  static errorPatternKey(ep) {
1060
1072
  return `${ep.type}:${ep.pattern.source}`;
1061
1073
  }
1074
+ /**
1075
+ * Undo the auth suspicion a pattern match armed, once the backend's token-free
1076
+ * probe has said the credentials are fine.
1077
+ *
1078
+ * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1079
+ * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1080
+ * daemon permanently suspicious of an auth failure that never existed. That
1081
+ * state is not inert: authFailureUnresolved suppresses the stuck/hang
1082
+ * notification and holds MCP auto-restart (a later real MCP death is then read
1083
+ * as already-confirmed auth trouble rather than re-verified), and the recovery
1084
+ * gate suppresses further error detection until a ready pattern shows up.
1085
+ *
1086
+ * Only what an auth match armed is rolled back. If some other pattern has
1087
+ * since armed the recovery gate, that one is still live and stays.
1088
+ */
1089
+ clearSuspectedAuthFailure() {
1090
+ if (!this.authFailureUnresolved && !this.loginScreenReported)
1091
+ return false;
1092
+ this.authFailureUnresolved = false;
1093
+ this.loginScreenReported = false;
1094
+ if (this.lastDetectedErrorType === "auth_error") {
1095
+ this.clearErrorRecoveryGate();
1096
+ this.lastDetectedErrorType = null;
1097
+ }
1098
+ this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1099
+ return true;
1100
+ }
1062
1101
  clearErrorRecoveryGate() {
1063
1102
  this.errorWaitingForRecovery = false;
1064
1103
  this.errorDetectedAt = 0;
@@ -3539,7 +3578,7 @@ export class Daemon extends EventEmitter {
3539
3578
  await this.wake();
3540
3579
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3541
3580
  return;
3542
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3581
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3543
3582
  this.markTurnStarted(meta, formatted);
3544
3583
  }
3545
3584
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3577,7 +3616,7 @@ export class Daemon extends EventEmitter {
3577
3616
  await this.wake();
3578
3617
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3579
3618
  return;
3580
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3619
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3581
3620
  this.markTurnStarted(meta, formatted);
3582
3621
  }
3583
3622
  }).catch(err => {
@@ -3673,7 +3712,7 @@ export class Daemon extends EventEmitter {
3673
3712
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3674
3713
  // the previous turn's tool list.
3675
3714
  this.resetToolProgress();
3676
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
3715
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3677
3716
  this.markTurnStarted(meta, formatted);
3678
3717
  }
3679
3718
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3841,7 +3880,7 @@ export class Daemon extends EventEmitter {
3841
3880
  if (probe.state !== "clear")
3842
3881
  return "dialog";
3843
3882
  }
3844
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3883
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3845
3884
  });
3846
3885
  if (outcome !== "dialog")
3847
3886
  return outcome;
@@ -4250,13 +4289,17 @@ export class Daemon extends EventEmitter {
4250
4289
  }
4251
4290
  return sent;
4252
4291
  }
4253
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4292
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4293
+ const signature = this.submissionSignature(formatted, submissionId);
4254
4294
  let windowId = initialWindowId;
4255
4295
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4256
4296
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4257
4297
  const maxAttempts = 3;
4258
4298
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4259
4299
  const pasteStartedAt = Date.now();
4300
+ // Read the pane BEFORE writing to it, so the submission check can require
4301
+ // evidence this paste ADDED rather than evidence that was already there.
4302
+ const pasteBaseline = await this.capturePaneEvidence(signature);
4260
4303
  const pasted = await this.tmux.pasteBuffer(formatted);
4261
4304
  if (!pasted) {
4262
4305
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4338,16 +4381,70 @@ export class Daemon extends EventEmitter {
4338
4381
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4339
4382
  if (handingOffToNativeQueue) {
4340
4383
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4341
- if (await this.nativeQueuePasteVisible(formatted)) {
4384
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4385
+ if (proof === "submitted") {
4342
4386
  if (status)
4343
4387
  this.emit("message_confirmed", status); // ✅ native queue accepted
4344
4388
  return true;
4345
4389
  }
4346
- // Silent loss: fall back once to the normal idle-gated path.
4347
- this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
4390
+ if (proof === "unverifiable") {
4391
+ // Our text is on the pane but cannot be shown to have left the input
4392
+ // row — either the backend exposes no input row (only /steer reaches
4393
+ // this path on such a backend) or the pre-paste pane was unreadable.
4394
+ // Re-pasting on that would deliver the message twice, so accept as
4395
+ // this path always has, and record that it is unproven.
4396
+ this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
4397
+ if (status)
4398
+ this.emit("message_confirmed", status); // ✅ (best-effort)
4399
+ return true;
4400
+ }
4401
+ // Not submitted (or not provably submitted): fall back once to the
4402
+ // normal idle-gated path, which is the only one with a full
4403
+ // confirmation ladder. "stranded" is the case that used to be a silent
4404
+ // ✅ — the text was visible, so the old check called it delivered.
4405
+ this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
4348
4406
  if (windowId && this.controlClient) {
4349
4407
  await this.controlClient.waitUntilIdle(windowId);
4350
4408
  }
4409
+ // Re-read before doing anything: the CLI had a whole turn to flush its
4410
+ // queue while we waited, and the right recovery differs per outcome.
4411
+ // Re-pasting a payload that is STILL in the input row appends it to
4412
+ // itself — paste-buffer writes at the cursor — and the next Enter then
4413
+ // submits the message twice over. That is the trap this fix would walk
4414
+ // into if it treated "not confirmed" as one state.
4415
+ const settled = await this.confirmSubmitted(signature, pasteBaseline);
4416
+ if (settled === "submitted") {
4417
+ this.logger.info("Native-queue message submitted itself while we waited for idle");
4418
+ if (status)
4419
+ this.emit("message_confirmed", status); // ✅
4420
+ return true;
4421
+ }
4422
+ if (settled === "stranded") {
4423
+ // The text is already there in full: it needs submitting, not sending
4424
+ // again. The prompt is back now, so an Enter can land.
4425
+ this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4426
+ const strandedAt = Date.now();
4427
+ if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4428
+ if (status)
4429
+ this.emit("message_failed", status); // ❌
4430
+ return false;
4431
+ }
4432
+ const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4433
+ const turnStarted = windowId && this.controlClient
4434
+ ? await this.confirmBusyAfterEnter(windowId, strandedAt)
4435
+ : false;
4436
+ if (afterEnter === "submitted" || turnStarted) {
4437
+ if (status)
4438
+ this.emit("message_confirmed", status); // ✅
4439
+ return true;
4440
+ }
4441
+ this.logger.error("Stranded message could not be submitted by Enter");
4442
+ if (status)
4443
+ this.emit("message_failed", status); // ❌
4444
+ return false;
4445
+ }
4446
+ // "unproven": nothing of ours is on screen — the paste itself was lost,
4447
+ // so pasting it again cannot duplicate anything.
4351
4448
  const repasted = await this.tmux.pasteBuffer(formatted);
4352
4449
  if (!repasted) {
4353
4450
  this.logger.error({
@@ -4382,7 +4479,7 @@ export class Daemon extends EventEmitter {
4382
4479
  return true;
4383
4480
  }
4384
4481
  }
4385
- else if (await this.nativeQueuePasteVisible(formatted)) {
4482
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4386
4483
  if (status)
4387
4484
  this.emit("message_confirmed", status); // ✅
4388
4485
  return true;
@@ -4463,31 +4560,195 @@ export class Daemon extends EventEmitter {
4463
4560
  return false;
4464
4561
  }
4465
4562
  /**
4466
- * True when a busy native-queue paste appears to have landed: Codex shows a
4467
- * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
4468
- * Used only to detect silent paste loss — not as a general ready check.
4563
+ * The single place a pasted message is judged submitted, shared by the
4564
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
4565
+ *
4566
+ * It replaces three divergent weak checks, the weakest of which accepted "the
4567
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
4568
+ * the failure it was meant to catch: text stranded in the input row is on
4569
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
4570
+ * anywhere. Evidence is weighed in order of what it can prove, and the
4571
+ * disqualifying evidence is checked first.
4469
4572
  */
4470
- async nativeQueuePasteVisible(formatted) {
4573
+ async confirmSubmitted(signature, baseline) {
4471
4574
  if (!this.tmux)
4472
- return false;
4575
+ return "unproven";
4576
+ let pane;
4473
4577
  try {
4474
- const pane = await this.tmux.capturePane();
4475
- if (pane.includes("↳"))
4476
- return true;
4477
- for (const line of formatted.split(/\r?\n/)) {
4478
- const t = line.trim();
4479
- if (t.length >= 8 && pane.includes(t))
4480
- return true;
4578
+ pane = await this.tmux.capturePane();
4579
+ }
4580
+ catch {
4581
+ return "unproven";
4582
+ }
4583
+ // Without a way to tell the input row from the transcript, "the text is on
4584
+ // screen" cannot distinguish submitted from stranded — that ambiguity IS
4585
+ // the bug. Such a backend gets an explicit verdict rather than a guess:
4586
+ // "unverifiable" (something of ours appeared, but not provably out of the
4587
+ // input row) or "unproven" (nothing of ours appeared, so the paste itself
4588
+ // was lost). The caller keeps that backend's existing best-effort handling
4589
+ // for the first and recovers on the second, which is what it did before —
4590
+ // the fix here is for backends that CAN be read, not a new guess for those
4591
+ // that cannot.
4592
+ const prompt = this.backend?.getBottomReadyPattern?.();
4593
+ if (!prompt) {
4594
+ const seen = this.paneEvidence(pane, signature);
4595
+ if (signature.unique && seen.payload > 0)
4596
+ return "unverifiable";
4597
+ return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
4598
+ ? "unverifiable"
4599
+ : "unproven";
4600
+ }
4601
+ const after = this.paneEvidence(pane, signature);
4602
+ // 1. Disqualifying evidence, checked FIRST and never overridden by the
4603
+ // corroborating evidence below: our text is sitting in the input row, so
4604
+ // it was not submitted — whatever else is on screen.
4605
+ //
4606
+ // It must be OUR text. A unique signature settles that on its own; a
4607
+ // body-derived one cannot, so it is only attributed to this delivery
4608
+ // when the input row did not already show it before we pasted.
4609
+ // Otherwise an older stranded message with the same opening would be
4610
+ // read as ours, we would press Enter to "recover" it, and the turn IT
4611
+ // starts would confirm a message that never reached the pane.
4612
+ if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
4613
+ return "stranded";
4614
+ // 2. Positive evidence. A unique signature needs no baseline: no earlier
4615
+ // message can carry this delivery's message_id, so finding it outside
4616
+ // the input row is proof in itself — which also means a momentary
4617
+ // failure to read the pane BEFORE pasting cannot turn a delivered
4618
+ // message into a re-paste.
4619
+ if (signature.unique && after.payload > 0)
4620
+ return "submitted";
4621
+ // 3. Otherwise the evidence must be NEW relative to the pane as it was
4622
+ // before we pasted: a queue marker left by an earlier message, or an
4623
+ // older transcript entry that opens the same way, are on screen either
4624
+ // way and would otherwise confirm a paste that never landed.
4625
+ if (baseline) {
4626
+ if (after.queued > baseline.queued)
4627
+ return "submitted";
4628
+ if (after.payload > baseline.payload)
4629
+ return "submitted";
4630
+ // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
4631
+ return "unproven";
4632
+ }
4633
+ // No baseline and nothing that identifies this delivery on its own. "The
4634
+ // paste was lost" is a guess, not a finding, and acting on it re-pastes a
4635
+ // message that may well have been submitted — so say the evidence is
4636
+ // missing instead of inventing a verdict.
4637
+ this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
4638
+ return "unverifiable";
4639
+ }
4640
+ /**
4641
+ * What the pane currently shows of a given message. Taken once before the
4642
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
4643
+ * echo rather than accepting whatever was already there.
4644
+ */
4645
+ async capturePaneEvidence(signature) {
4646
+ if (!this.tmux)
4647
+ return null;
4648
+ // Bounded re-probe: a capture that fails here costs the delivery its only
4649
+ // "before" picture, and a momentary failure should not decide anything.
4650
+ for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
4651
+ try {
4652
+ const pane = await this.tmux.capturePane();
4653
+ const evidence = this.paneEvidence(pane, signature);
4654
+ const prompt = this.backend?.getBottomReadyPattern?.();
4655
+ if (prompt && strandedAgendMessageInInput(pane, prompt)) {
4656
+ // Whatever we paste now lands after it, and one Enter submits both as
4657
+ // a single message. Nothing here can undo that; saying so beats
4658
+ // letting two messages silently merge.
4659
+ this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
4660
+ }
4661
+ return evidence;
4481
4662
  }
4482
- const compact = formatted.replace(/\s+/g, " ").trim();
4483
- if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
4484
- return true;
4663
+ catch {
4664
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
4665
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4485
4666
  }
4667
+ }
4668
+ // Still unreadable: confirmSubmitted falls back to evidence that stands on
4669
+ // its own (a unique message_id) and refuses to guess without it.
4670
+ this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
4671
+ return null;
4672
+ }
4673
+ paneEvidence(pane, signature) {
4674
+ const marker = this.backend?.getQueuedInputMarker?.();
4675
+ const prompt = this.backend?.getBottomReadyPattern?.();
4676
+ const input = prompt ? inputAreaText(pane, prompt) : null;
4677
+ return {
4678
+ queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
4679
+ payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
4680
+ strandedInput: input != null && inputShowsPastedText(input, signature.value),
4681
+ };
4682
+ }
4683
+ /**
4684
+ * What identifies THIS message on screen. The routing envelope carries a
4685
+ * message_id that no earlier transcript entry can share, so prefer it: the
4686
+ * body alone is not distinctive enough — short repeated instructions ("carry
4687
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
4688
+ * paste that never landed. System pastes carry no envelope and fall back to
4689
+ * the body signature, which the baseline comparison still ties to this paste.
4690
+ */
4691
+ submissionSignature(formatted, trustedId) {
4692
+ // The id must come from the message's own metadata, never from scanning the
4693
+ // rendered text: agents and users discuss message ids in the body all the
4694
+ // time ("check message_id: abc"), and a body-scanned value would match an
4695
+ // older transcript entry quoting the same thing — then be trusted as unique
4696
+ // and confirm a paste that never landed. Only the value AgEnD itself put in
4697
+ // the handoff metadata identifies THIS delivery.
4698
+ const id = trustedId?.trim();
4699
+ if (id)
4700
+ return { value: `message_id:${id}`, unique: true };
4701
+ return { value: pastedTextSignature(formatted), unique: false };
4702
+ }
4703
+ /**
4704
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
4705
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
4706
+ * at all, so a swallowed Enter left the notice sitting in the input row where
4707
+ * the next delivery submitted both as one message. Shares confirmSubmitted
4708
+ * with the delivery path — having one primitive is the point.
4709
+ *
4710
+ * Best effort by design: these notices must never fail a delivery or throw.
4711
+ * The caller already holds paneWriteLock.
4712
+ */
4713
+ async submitSystemPaste(text, label) {
4714
+ if (!this.tmux)
4715
+ return false;
4716
+ const signature = this.submissionSignature(text);
4717
+ const baseline = await this.capturePaneEvidence(signature);
4718
+ if (!(await this.tmux.pasteBuffer(text))) {
4719
+ this.logger.warn({ label }, "System paste failed to reach the pane");
4486
4720
  return false;
4487
4721
  }
4488
- catch {
4722
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4723
+ if (!(await this.sendDeliveryEnter(label)))
4724
+ return false;
4725
+ let proof = await this.confirmSubmitted(signature, baseline);
4726
+ if (proof === "unverifiable") {
4727
+ // No input row to read: this backend gets exactly what the old pasteText
4728
+ // path gave it, including the unconditional second Enter for queue-less
4729
+ // TUIs that swallow the first. Narrowing that to one Enter on the grounds
4730
+ // that "the text is visible" would be the very inference this change
4731
+ // exists to remove — visible text is also what a strand looks like.
4732
+ if (this.systemPasteOptions().retryEnter) {
4733
+ await new Promise(r => setTimeout(r, 1_000));
4734
+ await this.sendDeliveryEnter(`${label}-defensive-retry`);
4735
+ }
4736
+ return true; // best effort, exactly as before — nothing here is verified
4737
+ }
4738
+ if (proof !== "submitted") {
4739
+ // A bare Enter is a no-op at an empty prompt, so this is safe even if the
4740
+ // first one did land; when the text is still in the input row it is the
4741
+ // submit it never got. Re-pasting would append the text to itself.
4742
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4743
+ if (!(await this.sendDeliveryEnter(`${label}-retry`)))
4744
+ return false;
4745
+ proof = await this.confirmSubmitted(signature, baseline);
4746
+ }
4747
+ if (proof !== "submitted") {
4748
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
4489
4749
  return false;
4490
4750
  }
4751
+ return true;
4491
4752
  }
4492
4753
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4493
4754
  async recoverWindow() {
@@ -4969,7 +5230,16 @@ export class Daemon extends EventEmitter {
4969
5230
  try {
4970
5231
  // Messages can arrive during a restart and be queued on pasteLock before the
4971
5232
  // snapshot lands; both write to the pane, so both go through the same lock.
4972
- await this.paneWriteLock.run(() => this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`, this.systemPasteOptions()));
5233
+ const restoreNotice = `[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`;
5234
+ const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
5235
+ if (!injected) {
5236
+ // rotation-state.json was deleted when the prompt was built, so there is
5237
+ // nothing left to retry from: the restore is gone either way. Say so —
5238
+ // reporting it as injected is how a lost context restore stays invisible.
5239
+ this.logger.error("Session snapshot could not be submitted — session continues without context");
5240
+ this.emit("snapshot_failed", this.name);
5241
+ return;
5242
+ }
4973
5243
  this.logger.info("Injected session snapshot as first message");
4974
5244
  this.emit("snapshot_injected", this.name);
4975
5245
  }
@@ -5391,9 +5661,16 @@ export class Daemon extends EventEmitter {
5391
5661
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
5392
5662
  // delivery is already in flight or queued. Without the lock the notice and
5393
5663
  // that delivery race into the same pane.
5394
- await this.paneWriteLock.run(async () => {
5395
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
5396
- });
5664
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
5665
+ if (!told) {
5666
+ // Recording the new instructions here would mark the agent as told about
5667
+ // a notice it never received, and every later restart would then skip
5668
+ // the reload. Leave the snapshot behind and hand it to the deferred
5669
+ // notice, which fires on the next real message.
5670
+ this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
5671
+ this.pendingInstructionsNotice = true;
5672
+ return;
5673
+ }
5397
5674
  // Record the value the agent has now been told about so the next
5398
5675
  // unchanged restart skips the reload.
5399
5676
  try {