@songsid/agend 2.1.5-beta.11 → 2.1.5-beta.13

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.d.ts CHANGED
@@ -1081,11 +1081,44 @@ export declare class Daemon extends EventEmitter {
1081
1081
  private sendDeliveryEnter;
1082
1082
  private writeMessageToPane;
1083
1083
  /**
1084
- * True when a busy native-queue paste appears to have landed: Codex shows a
1085
- * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
1086
- * Used only to detect silent paste loss — not as a general ready check.
1084
+ * The single place a pasted message is judged submitted, shared by the
1085
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
1086
+ *
1087
+ * It replaces three divergent weak checks, the weakest of which accepted "the
1088
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
1089
+ * the failure it was meant to catch: text stranded in the input row is on
1090
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
1091
+ * anywhere. Evidence is weighed in order of what it can prove, and the
1092
+ * disqualifying evidence is checked first.
1093
+ */
1094
+ private confirmSubmitted;
1095
+ /**
1096
+ * What the pane currently shows of a given message. Taken once before the
1097
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
1098
+ * echo rather than accepting whatever was already there.
1099
+ */
1100
+ private capturePaneEvidence;
1101
+ private paneEvidence;
1102
+ /**
1103
+ * What identifies THIS message on screen. The routing envelope carries a
1104
+ * message_id that no earlier transcript entry can share, so prefer it: the
1105
+ * body alone is not distinctive enough — short repeated instructions ("carry
1106
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
1107
+ * paste that never landed. System pastes carry no envelope and fall back to
1108
+ * the body signature, which the baseline comparison still ties to this paste.
1109
+ */
1110
+ private submissionSignature;
1111
+ /**
1112
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
1113
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
1114
+ * at all, so a swallowed Enter left the notice sitting in the input row where
1115
+ * the next delivery submitted both as one message. Shares confirmSubmitted
1116
+ * with the delivery path — having one primitive is the point.
1117
+ *
1118
+ * Best effort by design: these notices must never fail a delivery or throw.
1119
+ * The caller already holds paneWriteLock.
1087
1120
  */
1088
- private nativeQueuePasteVisible;
1121
+ private submitSystemPaste;
1089
1122
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
1090
1123
  private recoverWindow;
1091
1124
  /**
@@ -1146,6 +1179,32 @@ export declare class Daemon extends EventEmitter {
1146
1179
  */
1147
1180
  private waitForSpawnToSettle;
1148
1181
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
1182
+ /**
1183
+ * Positive proof from the CLI that there is no conversation left to resume.
1184
+ *
1185
+ * Only this may justify abandoning a session. A startup that merely ran out of
1186
+ * budget proves nothing: a healthy cold start on a loaded host looks exactly
1187
+ * the same, which is how 9 instances silently lost their history.
1188
+ *
1189
+ * Deliberately does NOT match a bare "--continue": the pane text can mention
1190
+ * the flag (a usage error, an echoed invocation) without the CLI ever saying
1191
+ * the conversation is gone.
1192
+ */
1193
+ private paneSaysNoConversation;
1194
+ /** How many consecutive startups have failed without proving the session is gone. */
1195
+ private unprovenResumeFailures;
1196
+ /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
1197
+ private static readonly MAX_UNPROVEN_RESUME_FAILURES;
1198
+ /**
1199
+ * Set the session aside instead of deleting it.
1200
+ *
1201
+ * The whole defect being fixed here is silent, irreversible loss of a user's
1202
+ * conversation. Renaming costs nothing and turns a wrong call into something
1203
+ * recoverable: the id is still on disk under a dated name.
1204
+ */
1205
+ private setSessionAside;
1206
+ /** Keep the newest few set-aside sessions; they are insurance, not an archive. */
1207
+ private pruneAbandonedSessions;
1149
1208
  private spawnClaudeWindow;
1150
1209
  /**
1151
1210
  * Startup budget for this launch: the backend's override (resume-aware), never
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
@@ -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). */
@@ -1899,12 +1911,8 @@ export class Daemon extends EventEmitter {
1899
1911
  // --continue again and crash in the same way → loop. Clear the session id
1900
1912
  // and skip resume so the next spawn starts fresh. (skipResume also stops
1901
1913
  // saveSessionId below from resurrecting the id from statusline.json.)
1902
- if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
1903
- this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
1904
- try {
1905
- unlinkSync(join(this.instanceDir, "session-id"));
1906
- }
1907
- catch { /* may not exist */ }
1914
+ if (this.paneSaysNoConversation(lastOutput)) {
1915
+ this.setSessionAside("cli_reported_no_conversation");
1908
1916
  this.skipResume = true;
1909
1917
  }
1910
1918
  // Append to crash history
@@ -3543,7 +3551,7 @@ export class Daemon extends EventEmitter {
3543
3551
  await this.wake();
3544
3552
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3545
3553
  return;
3546
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3554
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3547
3555
  this.markTurnStarted(meta, formatted);
3548
3556
  }
3549
3557
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3581,7 +3589,7 @@ export class Daemon extends EventEmitter {
3581
3589
  await this.wake();
3582
3590
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3583
3591
  return;
3584
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3592
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3585
3593
  this.markTurnStarted(meta, formatted);
3586
3594
  }
3587
3595
  }).catch(err => {
@@ -3677,7 +3685,7 @@ export class Daemon extends EventEmitter {
3677
3685
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3678
3686
  // the previous turn's tool list.
3679
3687
  this.resetToolProgress();
3680
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
3688
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3681
3689
  this.markTurnStarted(meta, formatted);
3682
3690
  }
3683
3691
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3845,7 +3853,7 @@ export class Daemon extends EventEmitter {
3845
3853
  if (probe.state !== "clear")
3846
3854
  return "dialog";
3847
3855
  }
3848
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3856
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3849
3857
  });
3850
3858
  if (outcome !== "dialog")
3851
3859
  return outcome;
@@ -4254,13 +4262,17 @@ export class Daemon extends EventEmitter {
4254
4262
  }
4255
4263
  return sent;
4256
4264
  }
4257
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4265
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4266
+ const signature = this.submissionSignature(formatted, submissionId);
4258
4267
  let windowId = initialWindowId;
4259
4268
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4260
4269
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4261
4270
  const maxAttempts = 3;
4262
4271
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4263
4272
  const pasteStartedAt = Date.now();
4273
+ // Read the pane BEFORE writing to it, so the submission check can require
4274
+ // evidence this paste ADDED rather than evidence that was already there.
4275
+ const pasteBaseline = await this.capturePaneEvidence(signature);
4264
4276
  const pasted = await this.tmux.pasteBuffer(formatted);
4265
4277
  if (!pasted) {
4266
4278
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4342,16 +4354,70 @@ export class Daemon extends EventEmitter {
4342
4354
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4343
4355
  if (handingOffToNativeQueue) {
4344
4356
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4345
- if (await this.nativeQueuePasteVisible(formatted)) {
4357
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4358
+ if (proof === "submitted") {
4346
4359
  if (status)
4347
4360
  this.emit("message_confirmed", status); // ✅ native queue accepted
4348
4361
  return true;
4349
4362
  }
4350
- // Silent loss: fall back once to the normal idle-gated path.
4351
- this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
4363
+ if (proof === "unverifiable") {
4364
+ // Our text is on the pane but cannot be shown to have left the input
4365
+ // row — either the backend exposes no input row (only /steer reaches
4366
+ // this path on such a backend) or the pre-paste pane was unreadable.
4367
+ // Re-pasting on that would deliver the message twice, so accept as
4368
+ // this path always has, and record that it is unproven.
4369
+ this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
4370
+ if (status)
4371
+ this.emit("message_confirmed", status); // ✅ (best-effort)
4372
+ return true;
4373
+ }
4374
+ // Not submitted (or not provably submitted): fall back once to the
4375
+ // normal idle-gated path, which is the only one with a full
4376
+ // confirmation ladder. "stranded" is the case that used to be a silent
4377
+ // ✅ — the text was visible, so the old check called it delivered.
4378
+ this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
4352
4379
  if (windowId && this.controlClient) {
4353
4380
  await this.controlClient.waitUntilIdle(windowId);
4354
4381
  }
4382
+ // Re-read before doing anything: the CLI had a whole turn to flush its
4383
+ // queue while we waited, and the right recovery differs per outcome.
4384
+ // Re-pasting a payload that is STILL in the input row appends it to
4385
+ // itself — paste-buffer writes at the cursor — and the next Enter then
4386
+ // submits the message twice over. That is the trap this fix would walk
4387
+ // into if it treated "not confirmed" as one state.
4388
+ const settled = await this.confirmSubmitted(signature, pasteBaseline);
4389
+ if (settled === "submitted") {
4390
+ this.logger.info("Native-queue message submitted itself while we waited for idle");
4391
+ if (status)
4392
+ this.emit("message_confirmed", status); // ✅
4393
+ return true;
4394
+ }
4395
+ if (settled === "stranded") {
4396
+ // The text is already there in full: it needs submitting, not sending
4397
+ // again. The prompt is back now, so an Enter can land.
4398
+ this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
4399
+ const strandedAt = Date.now();
4400
+ if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
4401
+ if (status)
4402
+ this.emit("message_failed", status); // ❌
4403
+ return false;
4404
+ }
4405
+ const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
4406
+ const turnStarted = windowId && this.controlClient
4407
+ ? await this.confirmBusyAfterEnter(windowId, strandedAt)
4408
+ : false;
4409
+ if (afterEnter === "submitted" || turnStarted) {
4410
+ if (status)
4411
+ this.emit("message_confirmed", status); // ✅
4412
+ return true;
4413
+ }
4414
+ this.logger.error("Stranded message could not be submitted by Enter");
4415
+ if (status)
4416
+ this.emit("message_failed", status); // ❌
4417
+ return false;
4418
+ }
4419
+ // "unproven": nothing of ours is on screen — the paste itself was lost,
4420
+ // so pasting it again cannot duplicate anything.
4355
4421
  const repasted = await this.tmux.pasteBuffer(formatted);
4356
4422
  if (!repasted) {
4357
4423
  this.logger.error({
@@ -4386,7 +4452,7 @@ export class Daemon extends EventEmitter {
4386
4452
  return true;
4387
4453
  }
4388
4454
  }
4389
- else if (await this.nativeQueuePasteVisible(formatted)) {
4455
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4390
4456
  if (status)
4391
4457
  this.emit("message_confirmed", status); // ✅
4392
4458
  return true;
@@ -4467,31 +4533,195 @@ export class Daemon extends EventEmitter {
4467
4533
  return false;
4468
4534
  }
4469
4535
  /**
4470
- * True when a busy native-queue paste appears to have landed: Codex shows a
4471
- * `↳` queue marker, or a distinctive slice of the pasted text is on screen.
4472
- * Used only to detect silent paste loss — not as a general ready check.
4536
+ * The single place a pasted message is judged submitted, shared by the
4537
+ * native-queue handoff, the idle-gated redelivery and the system pastes.
4538
+ *
4539
+ * It replaces three divergent weak checks, the weakest of which accepted "the
4540
+ * pasted text is visible somewhere in the pane". That one was satisfied BY
4541
+ * the failure it was meant to catch: text stranded in the input row is on
4542
+ * screen, so a message nobody submitted was confirmed ✅ with no warning
4543
+ * anywhere. Evidence is weighed in order of what it can prove, and the
4544
+ * disqualifying evidence is checked first.
4473
4545
  */
4474
- async nativeQueuePasteVisible(formatted) {
4546
+ async confirmSubmitted(signature, baseline) {
4475
4547
  if (!this.tmux)
4476
- return false;
4548
+ return "unproven";
4549
+ let pane;
4477
4550
  try {
4478
- const pane = await this.tmux.capturePane();
4479
- if (pane.includes("↳"))
4480
- return true;
4481
- for (const line of formatted.split(/\r?\n/)) {
4482
- const t = line.trim();
4483
- if (t.length >= 8 && pane.includes(t))
4484
- return true;
4551
+ pane = await this.tmux.capturePane();
4552
+ }
4553
+ catch {
4554
+ return "unproven";
4555
+ }
4556
+ // Without a way to tell the input row from the transcript, "the text is on
4557
+ // screen" cannot distinguish submitted from stranded — that ambiguity IS
4558
+ // the bug. Such a backend gets an explicit verdict rather than a guess:
4559
+ // "unverifiable" (something of ours appeared, but not provably out of the
4560
+ // input row) or "unproven" (nothing of ours appeared, so the paste itself
4561
+ // was lost). The caller keeps that backend's existing best-effort handling
4562
+ // for the first and recovers on the second, which is what it did before —
4563
+ // the fix here is for backends that CAN be read, not a new guess for those
4564
+ // that cannot.
4565
+ const prompt = this.backend?.getBottomReadyPattern?.();
4566
+ if (!prompt) {
4567
+ const seen = this.paneEvidence(pane, signature);
4568
+ if (signature.unique && seen.payload > 0)
4569
+ return "unverifiable";
4570
+ return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
4571
+ ? "unverifiable"
4572
+ : "unproven";
4573
+ }
4574
+ const after = this.paneEvidence(pane, signature);
4575
+ // 1. Disqualifying evidence, checked FIRST and never overridden by the
4576
+ // corroborating evidence below: our text is sitting in the input row, so
4577
+ // it was not submitted — whatever else is on screen.
4578
+ //
4579
+ // It must be OUR text. A unique signature settles that on its own; a
4580
+ // body-derived one cannot, so it is only attributed to this delivery
4581
+ // when the input row did not already show it before we pasted.
4582
+ // Otherwise an older stranded message with the same opening would be
4583
+ // read as ours, we would press Enter to "recover" it, and the turn IT
4584
+ // starts would confirm a message that never reached the pane.
4585
+ if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
4586
+ return "stranded";
4587
+ // 2. Positive evidence. A unique signature needs no baseline: no earlier
4588
+ // message can carry this delivery's message_id, so finding it outside
4589
+ // the input row is proof in itself — which also means a momentary
4590
+ // failure to read the pane BEFORE pasting cannot turn a delivered
4591
+ // message into a re-paste.
4592
+ if (signature.unique && after.payload > 0)
4593
+ return "submitted";
4594
+ // 3. Otherwise the evidence must be NEW relative to the pane as it was
4595
+ // before we pasted: a queue marker left by an earlier message, or an
4596
+ // older transcript entry that opens the same way, are on screen either
4597
+ // way and would otherwise confirm a paste that never landed.
4598
+ if (baseline) {
4599
+ if (after.queued > baseline.queued)
4600
+ return "submitted";
4601
+ if (after.payload > baseline.payload)
4602
+ return "submitted";
4603
+ // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
4604
+ return "unproven";
4605
+ }
4606
+ // No baseline and nothing that identifies this delivery on its own. "The
4607
+ // paste was lost" is a guess, not a finding, and acting on it re-pastes a
4608
+ // message that may well have been submitted — so say the evidence is
4609
+ // missing instead of inventing a verdict.
4610
+ this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
4611
+ return "unverifiable";
4612
+ }
4613
+ /**
4614
+ * What the pane currently shows of a given message. Taken once before the
4615
+ * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
4616
+ * echo rather than accepting whatever was already there.
4617
+ */
4618
+ async capturePaneEvidence(signature) {
4619
+ if (!this.tmux)
4620
+ return null;
4621
+ // Bounded re-probe: a capture that fails here costs the delivery its only
4622
+ // "before" picture, and a momentary failure should not decide anything.
4623
+ for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
4624
+ try {
4625
+ const pane = await this.tmux.capturePane();
4626
+ const evidence = this.paneEvidence(pane, signature);
4627
+ const prompt = this.backend?.getBottomReadyPattern?.();
4628
+ if (prompt && strandedAgendMessageInInput(pane, prompt)) {
4629
+ // Whatever we paste now lands after it, and one Enter submits both as
4630
+ // a single message. Nothing here can undo that; saying so beats
4631
+ // letting two messages silently merge.
4632
+ this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
4633
+ }
4634
+ return evidence;
4485
4635
  }
4486
- const compact = formatted.replace(/\s+/g, " ").trim();
4487
- if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
4488
- return true;
4636
+ catch {
4637
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
4638
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4489
4639
  }
4640
+ }
4641
+ // Still unreadable: confirmSubmitted falls back to evidence that stands on
4642
+ // its own (a unique message_id) and refuses to guess without it.
4643
+ this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
4644
+ return null;
4645
+ }
4646
+ paneEvidence(pane, signature) {
4647
+ const marker = this.backend?.getQueuedInputMarker?.();
4648
+ const prompt = this.backend?.getBottomReadyPattern?.();
4649
+ const input = prompt ? inputAreaText(pane, prompt) : null;
4650
+ return {
4651
+ queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
4652
+ payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
4653
+ strandedInput: input != null && inputShowsPastedText(input, signature.value),
4654
+ };
4655
+ }
4656
+ /**
4657
+ * What identifies THIS message on screen. The routing envelope carries a
4658
+ * message_id that no earlier transcript entry can share, so prefer it: the
4659
+ * body alone is not distinctive enough — short repeated instructions ("carry
4660
+ * on", a status ping) recur verbatim, and an older copy would vouch for a
4661
+ * paste that never landed. System pastes carry no envelope and fall back to
4662
+ * the body signature, which the baseline comparison still ties to this paste.
4663
+ */
4664
+ submissionSignature(formatted, trustedId) {
4665
+ // The id must come from the message's own metadata, never from scanning the
4666
+ // rendered text: agents and users discuss message ids in the body all the
4667
+ // time ("check message_id: abc"), and a body-scanned value would match an
4668
+ // older transcript entry quoting the same thing — then be trusted as unique
4669
+ // and confirm a paste that never landed. Only the value AgEnD itself put in
4670
+ // the handoff metadata identifies THIS delivery.
4671
+ const id = trustedId?.trim();
4672
+ if (id)
4673
+ return { value: `message_id:${id}`, unique: true };
4674
+ return { value: pastedTextSignature(formatted), unique: false };
4675
+ }
4676
+ /**
4677
+ * Paste + submit + confirm for the SYSTEM messages that used to go out via
4678
+ * tmux.pasteText: one Enter (two for queue-less backends) and no verification
4679
+ * at all, so a swallowed Enter left the notice sitting in the input row where
4680
+ * the next delivery submitted both as one message. Shares confirmSubmitted
4681
+ * with the delivery path — having one primitive is the point.
4682
+ *
4683
+ * Best effort by design: these notices must never fail a delivery or throw.
4684
+ * The caller already holds paneWriteLock.
4685
+ */
4686
+ async submitSystemPaste(text, label) {
4687
+ if (!this.tmux)
4688
+ return false;
4689
+ const signature = this.submissionSignature(text);
4690
+ const baseline = await this.capturePaneEvidence(signature);
4691
+ if (!(await this.tmux.pasteBuffer(text))) {
4692
+ this.logger.warn({ label }, "System paste failed to reach the pane");
4490
4693
  return false;
4491
4694
  }
4492
- catch {
4695
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4696
+ if (!(await this.sendDeliveryEnter(label)))
4697
+ return false;
4698
+ let proof = await this.confirmSubmitted(signature, baseline);
4699
+ if (proof === "unverifiable") {
4700
+ // No input row to read: this backend gets exactly what the old pasteText
4701
+ // path gave it, including the unconditional second Enter for queue-less
4702
+ // TUIs that swallow the first. Narrowing that to one Enter on the grounds
4703
+ // that "the text is visible" would be the very inference this change
4704
+ // exists to remove — visible text is also what a strand looks like.
4705
+ if (this.systemPasteOptions().retryEnter) {
4706
+ await new Promise(r => setTimeout(r, 1_000));
4707
+ await this.sendDeliveryEnter(`${label}-defensive-retry`);
4708
+ }
4709
+ return true; // best effort, exactly as before — nothing here is verified
4710
+ }
4711
+ if (proof !== "submitted") {
4712
+ // A bare Enter is a no-op at an empty prompt, so this is safe even if the
4713
+ // first one did land; when the text is still in the input row it is the
4714
+ // submit it never got. Re-pasting would append the text to itself.
4715
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4716
+ if (!(await this.sendDeliveryEnter(`${label}-retry`)))
4717
+ return false;
4718
+ proof = await this.confirmSubmitted(signature, baseline);
4719
+ }
4720
+ if (proof !== "submitted") {
4721
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
4493
4722
  return false;
4494
4723
  }
4724
+ return true;
4495
4725
  }
4496
4726
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4497
4727
  async recoverWindow() {
@@ -4973,7 +5203,16 @@ export class Daemon extends EventEmitter {
4973
5203
  try {
4974
5204
  // Messages can arrive during a restart and be queued on pasteLock before the
4975
5205
  // snapshot lands; both write to the pane, so both go through the same lock.
4976
- 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()));
5206
+ 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.`;
5207
+ const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
5208
+ if (!injected) {
5209
+ // rotation-state.json was deleted when the prompt was built, so there is
5210
+ // nothing left to retry from: the restore is gone either way. Say so —
5211
+ // reporting it as injected is how a lost context restore stays invisible.
5212
+ this.logger.error("Session snapshot could not be submitted — session continues without context");
5213
+ this.emit("snapshot_failed", this.name);
5214
+ return;
5215
+ }
4977
5216
  this.logger.info("Injected session snapshot as first message");
4978
5217
  this.emit("snapshot_injected", this.name);
4979
5218
  }
@@ -5043,6 +5282,69 @@ export class Daemon extends EventEmitter {
5043
5282
  }
5044
5283
  }
5045
5284
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
5285
+ /**
5286
+ * Positive proof from the CLI that there is no conversation left to resume.
5287
+ *
5288
+ * Only this may justify abandoning a session. A startup that merely ran out of
5289
+ * budget proves nothing: a healthy cold start on a loaded host looks exactly
5290
+ * the same, which is how 9 instances silently lost their history.
5291
+ *
5292
+ * Deliberately does NOT match a bare "--continue": the pane text can mention
5293
+ * the flag (a usage error, an echoed invocation) without the CLI ever saying
5294
+ * the conversation is gone.
5295
+ */
5296
+ paneSaysNoConversation(paneText) {
5297
+ if (!paneText)
5298
+ return false;
5299
+ return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
5300
+ .test(paneText);
5301
+ }
5302
+ /** How many consecutive startups have failed without proving the session is gone. */
5303
+ unprovenResumeFailures = 0;
5304
+ /** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
5305
+ static MAX_UNPROVEN_RESUME_FAILURES = 3;
5306
+ /**
5307
+ * Set the session aside instead of deleting it.
5308
+ *
5309
+ * The whole defect being fixed here is silent, irreversible loss of a user's
5310
+ * conversation. Renaming costs nothing and turns a wrong call into something
5311
+ * recoverable: the id is still on disk under a dated name.
5312
+ */
5313
+ setSessionAside(reason) {
5314
+ const sidFile = join(this.instanceDir, "session-id");
5315
+ try {
5316
+ if (!existsSync(sidFile))
5317
+ return;
5318
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
5319
+ renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
5320
+ this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
5321
+ this.pruneAbandonedSessions();
5322
+ }
5323
+ catch (err) {
5324
+ // Never let bookkeeping block a spawn; falling back to the old behaviour
5325
+ // is still better than not starting.
5326
+ this.logger.warn({ err: err.message }, "Could not set session aside");
5327
+ try {
5328
+ unlinkSync(sidFile);
5329
+ }
5330
+ catch { /* may not exist */ }
5331
+ }
5332
+ }
5333
+ /** Keep the newest few set-aside sessions; they are insurance, not an archive. */
5334
+ pruneAbandonedSessions(keep = 5) {
5335
+ try {
5336
+ const files = readdirSync(this.instanceDir)
5337
+ .filter((f) => f.startsWith("session-id.abandoned-"))
5338
+ .sort();
5339
+ for (const f of files.slice(0, Math.max(0, files.length - keep))) {
5340
+ try {
5341
+ unlinkSync(join(this.instanceDir, f));
5342
+ }
5343
+ catch { /* best effort */ }
5344
+ }
5345
+ }
5346
+ catch { /* best effort */ }
5347
+ }
5046
5348
  async spawnClaudeWindow() {
5047
5349
  this.beginSpawn();
5048
5350
  let resumedSuccessfully = false;
@@ -5066,7 +5368,7 @@ export class Daemon extends EventEmitter {
5066
5368
  // first miss is usually slowness, not a broken session.
5067
5369
  await this.noteStartupPaneForBackendOutage();
5068
5370
  await this.failStartupIfBackendUnreachable();
5069
- if (this.backend.retriesResumeOnStartupFailure?.() === true) {
5371
+ if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
5070
5372
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
5071
5373
  await this.killProcessTree();
5072
5374
  await this.tmux.killWindow();
@@ -5078,16 +5380,39 @@ export class Daemon extends EventEmitter {
5078
5380
  }
5079
5381
  }
5080
5382
  if (!alive) {
5081
- // Resume (or a fresh start) failed for a reason we do not recognise as an
5082
- // outage (stale --resume, crash, rate limit, etc.).
5083
- // Clean slate: clear session-id, skip resume, and retry once.
5084
- this.logger.warn("CLI startup failed — clearing session-id and retrying without resume");
5085
- const sidFile = join(this.instanceDir, "session-id");
5086
- try {
5087
- unlinkSync(sidFile);
5383
+ // The session may only be abandoned on positive proof that there is
5384
+ // nothing to resume. Running out of budget is not proof: on a loaded host
5385
+ // a healthy cold start misses the same deadline as a broken session, and
5386
+ // treating the two alike is what silently discarded users' conversations.
5387
+ if (attemptedResume) {
5388
+ let paneText;
5389
+ try {
5390
+ paneText = await this.tmux?.capturePaneWithHistory(50);
5391
+ }
5392
+ catch { /* pane may be gone */ }
5393
+ const proven = this.paneSaysNoConversation(paneText);
5394
+ if (!proven) {
5395
+ this.unprovenResumeFailures++;
5396
+ if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
5397
+ // Keep the session and fail this attempt; the fleet retries with
5398
+ // backoff, which is also how the backend-outage path behaves.
5399
+ await this.killProcessTree();
5400
+ await this.tmux.killWindow();
5401
+ throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
5402
+ + "— session kept, will retry");
5403
+ }
5404
+ // Escape hatch: a genuinely broken session must still recover. Say so
5405
+ // out loud — this start does NOT continue the previous conversation.
5406
+ this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
5407
+ + "The previous conversation is NOT continued; its context is lost to this session.");
5408
+ this.emit("context_lost", this.name, "resume_repeatedly_failed");
5409
+ }
5410
+ this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
5411
+ this.skipResume = true;
5088
5412
  }
5089
- catch { /* may not exist */ }
5090
- this.skipResume = true;
5413
+ // A fresh start that also failed retries once, as before. It never clears
5414
+ // a session: nothing about a failed fresh launch says the stored
5415
+ // conversation is unusable.
5091
5416
  await this.killProcessTree();
5092
5417
  await this.tmux.killWindow();
5093
5418
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
@@ -5309,9 +5634,16 @@ export class Daemon extends EventEmitter {
5309
5634
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
5310
5635
  // delivery is already in flight or queued. Without the lock the notice and
5311
5636
  // that delivery race into the same pane.
5312
- await this.paneWriteLock.run(async () => {
5313
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
5314
- });
5637
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
5638
+ if (!told) {
5639
+ // Recording the new instructions here would mark the agent as told about
5640
+ // a notice it never received, and every later restart would then skip
5641
+ // the reload. Leave the snapshot behind and hand it to the deferred
5642
+ // notice, which fires on the next real message.
5643
+ this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
5644
+ this.pendingInstructionsNotice = true;
5645
+ return;
5646
+ }
5315
5647
  // Record the value the agent has now been told about so the next
5316
5648
  // unchanged restart skips the reload.
5317
5649
  try {