@songsid/agend 2.1.5-beta.12 → 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.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). */
@@ -3539,7 +3551,7 @@ export class Daemon extends EventEmitter {
3539
3551
  await this.wake();
3540
3552
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3541
3553
  return;
3542
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3554
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3543
3555
  this.markTurnStarted(meta, formatted);
3544
3556
  }
3545
3557
  else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3577,7 +3589,7 @@ export class Daemon extends EventEmitter {
3577
3589
  await this.wake();
3578
3590
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3579
3591
  return;
3580
- if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
3592
+ if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
3581
3593
  this.markTurnStarted(meta, formatted);
3582
3594
  }
3583
3595
  }).catch(err => {
@@ -3673,7 +3685,7 @@ export class Daemon extends EventEmitter {
3673
3685
  // A fresh delivery begins a fresh turn — its bubble must not inherit
3674
3686
  // the previous turn's tool list.
3675
3687
  this.resetToolProgress();
3676
- if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
3688
+ if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
3677
3689
  this.markTurnStarted(meta, formatted);
3678
3690
  }
3679
3691
  else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
@@ -3841,7 +3853,7 @@ export class Daemon extends EventEmitter {
3841
3853
  if (probe.state !== "clear")
3842
3854
  return "dialog";
3843
3855
  }
3844
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3856
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
3845
3857
  });
3846
3858
  if (outcome !== "dialog")
3847
3859
  return outcome;
@@ -4250,13 +4262,17 @@ export class Daemon extends EventEmitter {
4250
4262
  }
4251
4263
  return sent;
4252
4264
  }
4253
- async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
4265
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
4266
+ const signature = this.submissionSignature(formatted, submissionId);
4254
4267
  let windowId = initialWindowId;
4255
4268
  // Bug A: paste with backoff. Transient failures are usually a stale window id
4256
4269
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
4257
4270
  const maxAttempts = 3;
4258
4271
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4259
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);
4260
4276
  const pasted = await this.tmux.pasteBuffer(formatted);
4261
4277
  if (!pasted) {
4262
4278
  const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
@@ -4338,16 +4354,70 @@ export class Daemon extends EventEmitter {
4338
4354
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
4339
4355
  if (handingOffToNativeQueue) {
4340
4356
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
4341
- if (await this.nativeQueuePasteVisible(formatted)) {
4357
+ const proof = await this.confirmSubmitted(signature, pasteBaseline);
4358
+ if (proof === "submitted") {
4342
4359
  if (status)
4343
4360
  this.emit("message_confirmed", status); // ✅ native queue accepted
4344
4361
  return true;
4345
4362
  }
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");
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");
4348
4379
  if (windowId && this.controlClient) {
4349
4380
  await this.controlClient.waitUntilIdle(windowId);
4350
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.
4351
4421
  const repasted = await this.tmux.pasteBuffer(formatted);
4352
4422
  if (!repasted) {
4353
4423
  this.logger.error({
@@ -4382,7 +4452,7 @@ export class Daemon extends EventEmitter {
4382
4452
  return true;
4383
4453
  }
4384
4454
  }
4385
- else if (await this.nativeQueuePasteVisible(formatted)) {
4455
+ else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
4386
4456
  if (status)
4387
4457
  this.emit("message_confirmed", status); // ✅
4388
4458
  return true;
@@ -4463,31 +4533,195 @@ export class Daemon extends EventEmitter {
4463
4533
  return false;
4464
4534
  }
4465
4535
  /**
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.
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.
4469
4545
  */
4470
- async nativeQueuePasteVisible(formatted) {
4546
+ async confirmSubmitted(signature, baseline) {
4471
4547
  if (!this.tmux)
4472
- return false;
4548
+ return "unproven";
4549
+ let pane;
4473
4550
  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;
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;
4481
4635
  }
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;
4636
+ catch {
4637
+ if (attempt < BASELINE_CAPTURE_ATTEMPTS)
4638
+ await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
4485
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");
4486
4693
  return false;
4487
4694
  }
4488
- catch {
4695
+ await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
4696
+ if (!(await this.sendDeliveryEnter(label)))
4489
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);
4490
4719
  }
4720
+ if (proof !== "submitted") {
4721
+ this.logger.warn({ label, proof }, "System paste may not have been submitted");
4722
+ return false;
4723
+ }
4724
+ return true;
4491
4725
  }
4492
4726
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
4493
4727
  async recoverWindow() {
@@ -4969,7 +5203,16 @@ export class Daemon extends EventEmitter {
4969
5203
  try {
4970
5204
  // Messages can arrive during a restart and be queued on pasteLock before the
4971
5205
  // 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()));
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
+ }
4973
5216
  this.logger.info("Injected session snapshot as first message");
4974
5217
  this.emit("snapshot_injected", this.name);
4975
5218
  }
@@ -5391,9 +5634,16 @@ export class Daemon extends EventEmitter {
5391
5634
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
5392
5635
  // delivery is already in flight or queued. Without the lock the notice and
5393
5636
  // 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
- });
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
+ }
5397
5647
  // Record the value the agent has now been told about so the next
5398
5648
  // unchanged restart skips the reload.
5399
5649
  try {