@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/backend/codex.d.ts +23 -0
- package/dist/backend/codex.js +43 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +30 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +9 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +53 -4
- package/dist/daemon.js +307 -30
- package/dist/daemon.js.map +1 -1
- package/dist/deadline.d.ts +23 -0
- package/dist/deadline.js +29 -0
- package/dist/deadline.js.map +1 -0
- package/dist/fleet-manager.d.ts +23 -0
- package/dist/fleet-manager.js +75 -13
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +5 -0
- package/dist/instance-lifecycle.js +12 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +12 -2
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +31 -4
- package/dist/login-controller.js +52 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +29 -0
- package/dist/login-flows.js +48 -1
- package/dist/login-flows.js.map +1 -1
- package/dist/pane-input-residue.d.ts +19 -0
- package/dist/pane-input-residue.js +23 -0
- package/dist/pane-input-residue.js.map +1 -1
- package/dist/restart-progress.js +3 -21
- package/dist/restart-progress.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
4347
|
-
|
|
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.
|
|
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
|
-
*
|
|
4467
|
-
*
|
|
4468
|
-
*
|
|
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
|
|
4573
|
+
async confirmSubmitted(signature, baseline) {
|
|
4471
4574
|
if (!this.tmux)
|
|
4472
|
-
return
|
|
4575
|
+
return "unproven";
|
|
4576
|
+
let pane;
|
|
4473
4577
|
try {
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
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
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
5395
|
-
|
|
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 {
|