@songsid/agend 2.1.5-beta.2 → 2.1.5-beta.21
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/claude-code.d.ts +12 -0
- package/dist/backend/claude-code.js +14 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +25 -1
- package/dist/backend/codex.js +76 -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 +70 -0
- package/dist/backend/types.js +23 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -2
- package/dist/channel/adapters/discord.js +82 -8
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +2 -2
- package/dist/channel/adapters/telegram.js +8 -8
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +22 -1
- package/dist/cli.js +37 -37
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +181 -21
- package/dist/daemon.js +1054 -225
- 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-context.d.ts +17 -1
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +189 -2
- package/dist/fleet-manager.js +843 -124
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.d.ts +20 -0
- package/dist/full-restart.js +39 -0
- package/dist/full-restart.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +8 -2
- package/dist/instance-lifecycle.js +47 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instance-removal.d.ts +13 -0
- package/dist/instance-removal.js +14 -0
- package/dist/instance-removal.js.map +1 -0
- package/dist/instructions.js +2 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +54 -4
- 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/outbound-handlers.d.ts +1 -0
- package/dist/outbound-handlers.js +118 -29
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +25 -2
- package/dist/pane-input-residue.js +31 -2
- package/dist/pane-input-residue.js.map +1 -1
- package/dist/restart-progress.d.ts +35 -5
- package/dist/restart-progress.js +177 -34
- package/dist/restart-progress.js.map +1 -1
- package/dist/service-restart-selection.d.ts +27 -0
- package/dist/service-restart-selection.js +25 -0
- package/dist/service-restart-selection.js.map +1 -0
- package/dist/topic-commands.d.ts +27 -2
- package/dist/topic-commands.js +59 -13
- package/dist/topic-commands.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +41 -0
- package/dist/turn-reply-guard.js +77 -0
- package/dist/turn-reply-guard.js.map +1 -0
- package/dist/ui/dashboard.html +34 -8
- package/dist/ui/view.html +32 -18
- package/dist/ui/web-terminal/terminal.html +1 -0
- package/dist/update-marker.d.ts +11 -1
- package/dist/update-marker.js +19 -4
- package/dist/update-marker.js.map +1 -1
- package/dist/update-progress.d.ts +1 -1
- package/dist/update-progress.js +12 -0
- package/dist/update-progress.js.map +1 -1
- package/dist/usage/providers.d.ts +39 -0
- package/dist/usage/providers.js +69 -3
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.d.ts +1 -0
- package/dist/view-api.js +12 -2
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +22 -1
- package/dist/web-api.js +25 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal-http.d.ts +8 -0
- package/dist/web-terminal-http.js +29 -3
- package/dist/web-terminal-http.js.map +1 -1
- package/package.json +1 -1
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,9 @@ 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
|
+
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
28
|
+
import { t } from "./locale.js";
|
|
27
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
30
|
const __dirname = dirname(__filename);
|
|
29
31
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -34,6 +36,8 @@ const TASK_TOOL = "task";
|
|
|
34
36
|
// Tools whose success proves the agent got a message out this turn. While any
|
|
35
37
|
// of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
|
|
36
38
|
const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
|
|
39
|
+
const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
40
|
+
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
|
|
37
41
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
38
42
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
39
43
|
const source = binaryName === "codex" || binaryName === "grok"
|
|
@@ -47,6 +51,33 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
|
|
|
47
51
|
: join(instanceDir, "fleet-instructions.md");
|
|
48
52
|
return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
|
|
49
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether this backend has to be told in its pane that its instructions moved.
|
|
56
|
+
*
|
|
57
|
+
* A backend that re-reads them on resume already received the new ones from the
|
|
58
|
+
* resume itself, so the notice is pure interruption. Both places that can send
|
|
59
|
+
* it — the warmup paste and the deferred pendingInstructionsNotice — ask through
|
|
60
|
+
* here, so the two cannot drift apart again: the warmup path used to omit this
|
|
61
|
+
* check entirely and told claude-code to reload instructions it had just read.
|
|
62
|
+
*/
|
|
63
|
+
export function backendNeedsPaneReloadNotice(backend) {
|
|
64
|
+
return !backend?.instructionsReloadedOnResume;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What the warmup should do about changed instructions.
|
|
68
|
+
*
|
|
69
|
+
* `skip` — nothing to say, or this backend learns of the change by itself.
|
|
70
|
+
* `defer` — worth saying, but nobody is talking to the instance; say it when a
|
|
71
|
+
* real message next arrives rather than provoking an unsolicited reply.
|
|
72
|
+
* `paste` — say it now, into a pane that already has a delivery in flight.
|
|
73
|
+
*/
|
|
74
|
+
export function warmupNoticeAction(opts) {
|
|
75
|
+
if (!opts.warmupNeeded)
|
|
76
|
+
return "skip";
|
|
77
|
+
if (!backendNeedsPaneReloadNotice(opts.backend))
|
|
78
|
+
return "skip";
|
|
79
|
+
return opts.pasteQueueDepth === 0 ? "defer" : "paste";
|
|
80
|
+
}
|
|
50
81
|
export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
|
|
51
82
|
export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
|
|
52
83
|
export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
@@ -350,15 +381,31 @@ export class PendingWorkTracker {
|
|
|
350
381
|
// An async pane poll can finish after a newer inbound. Do not let its stale
|
|
351
382
|
// observation clear work which had not arrived when the pane was captured.
|
|
352
383
|
if (now < this.lastInboundAt)
|
|
353
|
-
return;
|
|
384
|
+
return false;
|
|
354
385
|
this.lastIdleAt = now;
|
|
355
386
|
this.lastIdleOrder = ++this.sequence;
|
|
387
|
+
return true;
|
|
356
388
|
}
|
|
357
389
|
hasPendingWork() {
|
|
358
390
|
return this.lastInboundOrder > this.lastIdleOrder;
|
|
359
391
|
}
|
|
360
392
|
}
|
|
361
393
|
const NORMAL_ENTER_SETTLE_MS = 500;
|
|
394
|
+
/** How long to keep looking for proof after a retry Enter before failing it. */
|
|
395
|
+
const POST_ENTER_PROOF_WINDOW_MS = 3_000;
|
|
396
|
+
const POST_ENTER_PROOF_POLL_MS = 250;
|
|
397
|
+
/** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
|
|
398
|
+
const BASELINE_CAPTURE_ATTEMPTS = 3;
|
|
399
|
+
const BASELINE_CAPTURE_RETRY_MS = 150;
|
|
400
|
+
/** Occurrences of `needle` in `haystack` (plain text, no regex semantics). */
|
|
401
|
+
function countOccurrences(haystack, needle) {
|
|
402
|
+
if (!needle)
|
|
403
|
+
return 0;
|
|
404
|
+
let n = 0;
|
|
405
|
+
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length))
|
|
406
|
+
n++;
|
|
407
|
+
return n;
|
|
408
|
+
}
|
|
362
409
|
/** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
|
|
363
410
|
const BOTTOM_READY_POLL_MS = 250;
|
|
364
411
|
/** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
|
|
@@ -384,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
384
431
|
}
|
|
385
432
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
386
433
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
434
|
+
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
435
|
+
const INPUT_TRANSIENT_WAIT_MS = 30_000;
|
|
436
|
+
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
387
437
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
388
438
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
389
439
|
const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
@@ -810,6 +860,14 @@ export class Daemon extends EventEmitter {
|
|
|
810
860
|
spawnSettled = null;
|
|
811
861
|
resolveSpawnSettled = null;
|
|
812
862
|
spawnDepth = 0;
|
|
863
|
+
/** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
|
|
864
|
+
spawnGeneration = 0;
|
|
865
|
+
/**
|
|
866
|
+
* Keeps passive startup-transient checks alive through the first positively
|
|
867
|
+
* submitted post-spawn message. Without this tail, a transient painted just
|
|
868
|
+
* after the startup scan can still swallow that message's Enter.
|
|
869
|
+
*/
|
|
870
|
+
inputTransientGuardGeneration = null;
|
|
813
871
|
skipResume = false;
|
|
814
872
|
startupAborted = false;
|
|
815
873
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
@@ -843,19 +901,14 @@ export class Daemon extends EventEmitter {
|
|
|
843
901
|
instanceStateMonitorActive = false;
|
|
844
902
|
sessionCheckpointWarningEmitted = false;
|
|
845
903
|
statePollInFlight = false;
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
turnHadInbound = false;
|
|
851
|
-
turnOutboundDelivered = false;
|
|
852
|
-
/** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
|
|
853
|
-
turnReplyDelivered = false;
|
|
854
|
-
turnCorrelationId;
|
|
855
|
-
turnInboundMarker;
|
|
904
|
+
// One generation-scoped source of truth for human-turn reply completion.
|
|
905
|
+
// It also supplies the older dead-MCP/malformed-call recovery paths, so those
|
|
906
|
+
// paths cannot disagree about whether the turn already spoke to the channel.
|
|
907
|
+
turnReplyGuard = new TurnReplyGuard();
|
|
856
908
|
/** Prevent a visible stale XML fragment from being recovered on later turns. */
|
|
857
909
|
lastMalformedToolCallSignature;
|
|
858
910
|
proxyReplySeq = 0;
|
|
911
|
+
lastReplyDropWarningAt = 0;
|
|
859
912
|
autoPauseController;
|
|
860
913
|
pauseRequested = false;
|
|
861
914
|
/**
|
|
@@ -1032,6 +1085,33 @@ export class Daemon extends EventEmitter {
|
|
|
1032
1085
|
static errorPatternKey(ep) {
|
|
1033
1086
|
return `${ep.type}:${ep.pattern.source}`;
|
|
1034
1087
|
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Undo the auth suspicion a pattern match armed, once the backend's token-free
|
|
1090
|
+
* probe has said the credentials are fine.
|
|
1091
|
+
*
|
|
1092
|
+
* emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
|
|
1093
|
+
* the lifecycle's "valid" verdict used to just drop the incident — leaving the
|
|
1094
|
+
* daemon permanently suspicious of an auth failure that never existed. That
|
|
1095
|
+
* state is not inert: authFailureUnresolved suppresses the stuck/hang
|
|
1096
|
+
* notification and holds MCP auto-restart (a later real MCP death is then read
|
|
1097
|
+
* as already-confirmed auth trouble rather than re-verified), and the recovery
|
|
1098
|
+
* gate suppresses further error detection until a ready pattern shows up.
|
|
1099
|
+
*
|
|
1100
|
+
* Only what an auth match armed is rolled back. If some other pattern has
|
|
1101
|
+
* since armed the recovery gate, that one is still live and stays.
|
|
1102
|
+
*/
|
|
1103
|
+
clearSuspectedAuthFailure() {
|
|
1104
|
+
if (!this.authFailureUnresolved && !this.loginScreenReported)
|
|
1105
|
+
return false;
|
|
1106
|
+
this.authFailureUnresolved = false;
|
|
1107
|
+
this.loginScreenReported = false;
|
|
1108
|
+
if (this.lastDetectedErrorType === "auth_error") {
|
|
1109
|
+
this.clearErrorRecoveryGate();
|
|
1110
|
+
this.lastDetectedErrorType = null;
|
|
1111
|
+
}
|
|
1112
|
+
this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1035
1115
|
clearErrorRecoveryGate() {
|
|
1036
1116
|
this.errorWaitingForRecovery = false;
|
|
1037
1117
|
this.errorDetectedAt = 0;
|
|
@@ -1230,48 +1310,7 @@ export class Daemon extends EventEmitter {
|
|
|
1230
1310
|
// the instructions actually changed since the agent last saw them.
|
|
1231
1311
|
// Skipping the no-op reload saves 10-30s of agent time on every restart
|
|
1232
1312
|
// where instructions are unchanged.
|
|
1233
|
-
|
|
1234
|
-
try {
|
|
1235
|
-
if (!this.warmupNeeded) {
|
|
1236
|
-
this.logger.debug("Warmup skipped — instructions unchanged");
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
// Skip warmup if no one is talking to this instance (avoid triggering
|
|
1240
|
-
// unsolicited agent replies on idle instances after fleet restart).
|
|
1241
|
-
if (this.pasteQueueDepth === 0) {
|
|
1242
|
-
this.logger.debug("Warmup deferred — no pending inbound messages");
|
|
1243
|
-
// Convert to pendingInstructionsNotice so it fires on next real message.
|
|
1244
|
-
this.pendingInstructionsNotice = true;
|
|
1245
|
-
try {
|
|
1246
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
1247
|
-
}
|
|
1248
|
-
catch { }
|
|
1249
|
-
return;
|
|
1250
|
-
}
|
|
1251
|
-
const wid = existsSync(join(this.instanceDir, "window-id"))
|
|
1252
|
-
? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
|
|
1253
|
-
if (wid && this.controlClient) {
|
|
1254
|
-
await this.controlClient.waitForIdle(wid, 120_000);
|
|
1255
|
-
}
|
|
1256
|
-
else {
|
|
1257
|
-
await new Promise(r => setTimeout(r, 5000));
|
|
1258
|
-
}
|
|
1259
|
-
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
1260
|
-
// delivery is already in flight or queued. Without the lock the notice and
|
|
1261
|
-
// that delivery race into the same pane.
|
|
1262
|
-
await this.paneWriteLock.run(async () => {
|
|
1263
|
-
await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
|
|
1264
|
-
});
|
|
1265
|
-
// Record the value the agent has now been told about so the next
|
|
1266
|
-
// unchanged restart skips the reload.
|
|
1267
|
-
try {
|
|
1268
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
1269
|
-
}
|
|
1270
|
-
catch { /* best effort */ }
|
|
1271
|
-
this.logger.debug("Warmup sent after idle");
|
|
1272
|
-
}
|
|
1273
|
-
catch { /* non-fatal */ }
|
|
1274
|
-
})();
|
|
1313
|
+
void this.runWarmupInstructionNotice();
|
|
1275
1314
|
if (!this.config.lightweight) {
|
|
1276
1315
|
// 3. Pipe-pane for prompt detection. Rotate first so a ballooned log from a
|
|
1277
1316
|
// previous stuck splash (hundreds of MB of ANSI frames) is truncated before
|
|
@@ -1913,12 +1952,8 @@ export class Daemon extends EventEmitter {
|
|
|
1913
1952
|
// --continue again and crash in the same way → loop. Clear the session id
|
|
1914
1953
|
// and skip resume so the next spawn starts fresh. (skipResume also stops
|
|
1915
1954
|
// saveSessionId below from resurrecting the id from statusline.json.)
|
|
1916
|
-
if (
|
|
1917
|
-
this.
|
|
1918
|
-
try {
|
|
1919
|
-
unlinkSync(join(this.instanceDir, "session-id"));
|
|
1920
|
-
}
|
|
1921
|
-
catch { /* may not exist */ }
|
|
1955
|
+
if (this.paneSaysNoConversation(lastOutput)) {
|
|
1956
|
+
this.setSessionAside("cli_reported_no_conversation");
|
|
1922
1957
|
this.skipResume = true;
|
|
1923
1958
|
}
|
|
1924
1959
|
// Append to crash history
|
|
@@ -2498,6 +2533,10 @@ export class Daemon extends EventEmitter {
|
|
|
2498
2533
|
}
|
|
2499
2534
|
async stop() {
|
|
2500
2535
|
this.logger.info("Stopping daemon instance");
|
|
2536
|
+
this.turnReplyGuard.reset();
|
|
2537
|
+
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
2538
|
+
// stopped. It must fail closed, not press Enter in a replacement pane.
|
|
2539
|
+
this.inputTransientGuardGeneration = null;
|
|
2501
2540
|
this.freezeRuntimeMonitors();
|
|
2502
2541
|
this.pendingIpcRequests.clear();
|
|
2503
2542
|
if (this.adapter)
|
|
@@ -2791,7 +2830,7 @@ export class Daemon extends EventEmitter {
|
|
|
2791
2830
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
2792
2831
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
2793
2832
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
2794
|
-
this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2833
|
+
const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2795
2834
|
// The turn is over. A transcript can end on a tool_use with no matching
|
|
2796
2835
|
// tool_result (interrupted, crashed, cancelled), which would otherwise leave
|
|
2797
2836
|
// the last tool pinned to the progress line for the rest of the session.
|
|
@@ -2799,7 +2838,8 @@ export class Daemon extends EventEmitter {
|
|
|
2799
2838
|
this.resetToolProgress();
|
|
2800
2839
|
// Must run before the mcpRestartPending branch below: the pane text is the
|
|
2801
2840
|
// only copy of the answer, and the revival restart is about to clear it.
|
|
2802
|
-
|
|
2841
|
+
if (acceptedIdle)
|
|
2842
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
2803
2843
|
}
|
|
2804
2844
|
if (snapshot.state !== previous) {
|
|
2805
2845
|
this.logger.info({
|
|
@@ -2850,13 +2890,17 @@ export class Daemon extends EventEmitter {
|
|
|
2850
2890
|
// a task result, and a stale one (sol's review of #515).
|
|
2851
2891
|
if (meta.from_instance || !meta.chat_id)
|
|
2852
2892
|
return;
|
|
2853
|
-
this.turnHadInbound = true;
|
|
2854
|
-
this.turnOutboundDelivered = false;
|
|
2855
|
-
this.turnReplyDelivered = false;
|
|
2856
|
-
this.turnCorrelationId = meta.correlation_id || undefined;
|
|
2857
2893
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
2858
2894
|
// is the agent's own output.
|
|
2859
|
-
|
|
2895
|
+
const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
|
|
2896
|
+
this.turnReplyGuard.arm({
|
|
2897
|
+
adapterId: meta.adapter_id || undefined,
|
|
2898
|
+
chatId: meta.chat_id,
|
|
2899
|
+
threadId: meta.thread_id || undefined,
|
|
2900
|
+
messageId: meta.message_id || undefined,
|
|
2901
|
+
correlationId: meta.correlation_id || undefined,
|
|
2902
|
+
inboundMarker,
|
|
2903
|
+
});
|
|
2860
2904
|
}
|
|
2861
2905
|
/**
|
|
2862
2906
|
* Turn ended (busy→idle edge): if the MCP server is dead and none of the
|
|
@@ -2866,56 +2910,197 @@ export class Daemon extends EventEmitter {
|
|
|
2866
2910
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
2867
2911
|
*/
|
|
2868
2912
|
maybeProxyReplyOnTurnEnd(pane) {
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2913
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
2914
|
+
if (!turn || this.isPaused)
|
|
2915
|
+
return;
|
|
2916
|
+
if (turn.replyDelivered) {
|
|
2917
|
+
if (turn.phase === "recovering") {
|
|
2918
|
+
this.emit("reply_drop_recovered", {
|
|
2919
|
+
name: this.name,
|
|
2920
|
+
correlationId: turn.target.correlationId,
|
|
2921
|
+
generation: turn.generation,
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
2928
|
+
// third turn or guess at terminal text; make the failure visible instead.
|
|
2929
|
+
if (turn.phase === "recovering") {
|
|
2930
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2931
|
+
this.reportUnrecoveredReplyDrop(turn);
|
|
2880
2932
|
return;
|
|
2933
|
+
}
|
|
2881
2934
|
// Claude sometimes prints a broken XML tool call as plain text and returns
|
|
2882
2935
|
// idle without ever invoking `reply`. This is independent of MCP liveness:
|
|
2883
2936
|
// the model malformed the call before the server could receive it.
|
|
2884
|
-
if (
|
|
2885
|
-
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
|
|
2937
|
+
if (this.isClaudeCodeBackend() && pane) {
|
|
2938
|
+
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
|
|
2886
2939
|
if (malformed) {
|
|
2887
2940
|
const stale = malformed.signature === this.lastMalformedToolCallSignature;
|
|
2888
2941
|
this.lastMalformedToolCallSignature = malformed.signature;
|
|
2889
2942
|
if (!stale) {
|
|
2890
|
-
|
|
2891
|
-
this.logger.warn({ correlationId, recovered }, recovered
|
|
2943
|
+
this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
|
|
2892
2944
|
? "Malformed Claude tool call detected — attempting to recover reply text"
|
|
2893
2945
|
: "Malformed Claude tool call detected — reply text could not be extracted");
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2946
|
+
if (malformed.text) {
|
|
2947
|
+
this.queueMalformedReplyRecovery(turn, malformed.text);
|
|
2948
|
+
}
|
|
2949
|
+
else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
|
|
2950
|
+
this.emit("malformed_tool_call", {
|
|
2951
|
+
name: this.name,
|
|
2952
|
+
correlationId: turn.target.correlationId,
|
|
2953
|
+
recovered: false,
|
|
2954
|
+
recoveryStarted: true,
|
|
2955
|
+
});
|
|
2956
|
+
this.startReplyRecovery(turn, "malformed_call");
|
|
2957
|
+
}
|
|
2958
|
+
else {
|
|
2959
|
+
this.emit("malformed_tool_call", {
|
|
2960
|
+
name: this.name,
|
|
2961
|
+
correlationId: turn.target.correlationId,
|
|
2962
|
+
recovered: false,
|
|
2963
|
+
});
|
|
2964
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
else {
|
|
2968
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2897
2969
|
}
|
|
2898
2970
|
// Never let the broader dead-MCP fallback relay the XML/chrome too.
|
|
2899
2971
|
return;
|
|
2900
2972
|
}
|
|
2901
2973
|
}
|
|
2902
|
-
|
|
2974
|
+
// Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
|
|
2975
|
+
// tools still suppress that proxy, but only a delivered `reply` satisfies
|
|
2976
|
+
// the human-facing completion guard below.
|
|
2977
|
+
if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
|
|
2978
|
+
&& mcpServerState(this.instanceDir).state === "dead") {
|
|
2979
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2980
|
+
void this.sendProxyReply(pane, turn.target);
|
|
2903
2981
|
return;
|
|
2904
|
-
|
|
2905
|
-
if (this.
|
|
2982
|
+
}
|
|
2983
|
+
if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
|
|
2984
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2906
2985
|
return;
|
|
2907
|
-
|
|
2908
|
-
if (
|
|
2986
|
+
}
|
|
2987
|
+
if (turn.replyAttempted) {
|
|
2988
|
+
// A provider timeout can be "applied, then timed out". Retrying it would
|
|
2989
|
+
// risk a duplicate; report the unknown result and stop here.
|
|
2990
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2991
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
2909
2992
|
return;
|
|
2910
|
-
|
|
2993
|
+
}
|
|
2994
|
+
this.startReplyRecovery(turn, "no_valid_call");
|
|
2911
2995
|
}
|
|
2912
2996
|
isClaudeCodeBackend() {
|
|
2913
2997
|
return this.runtimeIdentity?.backend === "claude-code"
|
|
2914
2998
|
|| this.config.backend === "claude-code"
|
|
2915
2999
|
|| this.backend?.binaryName === "claude";
|
|
2916
3000
|
}
|
|
3001
|
+
replyCompletionGuardEnabled() {
|
|
3002
|
+
return this.backend?.replyCompletionGuard === true;
|
|
3003
|
+
}
|
|
3004
|
+
startReplyRecovery(turn, reason) {
|
|
3005
|
+
if (!this.turnReplyGuard.beginRecovery(turn.generation))
|
|
3006
|
+
return;
|
|
3007
|
+
this.emit("reply_drop_detected", {
|
|
3008
|
+
name: this.name,
|
|
3009
|
+
correlationId: turn.target.correlationId,
|
|
3010
|
+
generation: turn.generation,
|
|
3011
|
+
reason,
|
|
3012
|
+
recoveryStarted: true,
|
|
3013
|
+
});
|
|
3014
|
+
void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
|
|
3015
|
+
this.queueReplyRecoveryPrompt(turn);
|
|
3016
|
+
}
|
|
3017
|
+
reportReplyDropWithoutRetry(turn, reason) {
|
|
3018
|
+
this.emit("reply_drop_detected", {
|
|
3019
|
+
name: this.name,
|
|
3020
|
+
correlationId: turn.target.correlationId,
|
|
3021
|
+
generation: turn.generation,
|
|
3022
|
+
reason,
|
|
3023
|
+
recoveryStarted: false,
|
|
3024
|
+
});
|
|
3025
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
|
|
3026
|
+
}
|
|
3027
|
+
queueReplyRecoveryPrompt(turn) {
|
|
3028
|
+
const deliveryEpoch = this.deliveryEpoch;
|
|
3029
|
+
this.pasteQueueDepth++;
|
|
3030
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3031
|
+
try {
|
|
3032
|
+
const current = this.turnReplyGuard.snapshot();
|
|
3033
|
+
if (!current || current.generation !== turn.generation || current.phase !== "recovering")
|
|
3034
|
+
return;
|
|
3035
|
+
if (current.replyDelivered) {
|
|
3036
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3037
|
+
this.emit("reply_drop_recovered", {
|
|
3038
|
+
name: this.name,
|
|
3039
|
+
correlationId: current.target.correlationId,
|
|
3040
|
+
generation: current.generation,
|
|
3041
|
+
});
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
|
|
3045
|
+
deliveryEpoch,
|
|
3046
|
+
});
|
|
3047
|
+
if (!delivered) {
|
|
3048
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3049
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
finally {
|
|
3053
|
+
this.pasteQueueDepth--;
|
|
3054
|
+
}
|
|
3055
|
+
}).catch(err => {
|
|
3056
|
+
this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
|
|
3057
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3058
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
queueMalformedReplyRecovery(turn, text) {
|
|
3062
|
+
// Serialize the delivery decision ahead of any new pane input. If direct
|
|
3063
|
+
// delivery is positively acknowledged, there is no recovery turn. If its
|
|
3064
|
+
// outcome is unknown, do not ask the model to duplicate it.
|
|
3065
|
+
const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
|
|
3066
|
+
this.pasteQueueDepth++;
|
|
3067
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3068
|
+
try {
|
|
3069
|
+
const delivered = await delivery;
|
|
3070
|
+
this.emit("malformed_tool_call", {
|
|
3071
|
+
name: this.name,
|
|
3072
|
+
correlationId: turn.target.correlationId,
|
|
3073
|
+
recovered: delivered,
|
|
3074
|
+
});
|
|
3075
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3076
|
+
if (!delivered)
|
|
3077
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3078
|
+
}
|
|
3079
|
+
finally {
|
|
3080
|
+
this.pasteQueueDepth--;
|
|
3081
|
+
}
|
|
3082
|
+
}).catch(err => {
|
|
3083
|
+
this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
|
|
3084
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3085
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
|
|
3089
|
+
this.emit("reply_drop_unrecovered", {
|
|
3090
|
+
name: this.name,
|
|
3091
|
+
correlationId: turn.target.correlationId,
|
|
3092
|
+
generation: turn.generation,
|
|
3093
|
+
reason,
|
|
3094
|
+
});
|
|
3095
|
+
const now = Date.now();
|
|
3096
|
+
if (this.lastReplyDropWarningAt !== 0
|
|
3097
|
+
&& now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
|
|
3098
|
+
return;
|
|
3099
|
+
this.lastReplyDropWarningAt = now;
|
|
3100
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
|
|
3101
|
+
}
|
|
2917
3102
|
/** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
|
|
2918
|
-
async sendProxyReply(pane,
|
|
3103
|
+
async sendProxyReply(pane, target) {
|
|
2919
3104
|
try {
|
|
2920
3105
|
// The idle capture normally hands its pane in; fall back only when the
|
|
2921
3106
|
// edge came from a path without one.
|
|
@@ -2923,74 +3108,110 @@ export class Daemon extends EventEmitter {
|
|
|
2923
3108
|
pane = await this.tmux?.capturePane();
|
|
2924
3109
|
if (!pane)
|
|
2925
3110
|
return;
|
|
2926
|
-
const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
3111
|
+
const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
2927
3112
|
if (!text) {
|
|
2928
3113
|
this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
|
|
2929
3114
|
return;
|
|
2930
3115
|
}
|
|
2931
3116
|
let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
|
|
2932
|
-
if (correlationId)
|
|
2933
|
-
body += `\n\n(correlation_id: ${correlationId})`;
|
|
2934
|
-
this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply —
|
|
2935
|
-
this.
|
|
2936
|
-
|
|
3117
|
+
if (target.correlationId)
|
|
3118
|
+
body += `\n\n(correlation_id: ${target.correlationId})`;
|
|
3119
|
+
this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
|
|
3120
|
+
const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
|
|
3121
|
+
if (delivered)
|
|
3122
|
+
this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
|
|
2937
3123
|
}
|
|
2938
3124
|
catch (err) {
|
|
2939
3125
|
this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
|
|
2940
3126
|
}
|
|
2941
3127
|
}
|
|
2942
|
-
/** Send a narrowly extracted #648 reply; operator notification is a separate event. */
|
|
2943
|
-
sendRecoveredMalformedReply(text, correlationId) {
|
|
2944
|
-
try {
|
|
2945
|
-
this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
|
|
2946
|
-
}
|
|
2947
|
-
catch (err) {
|
|
2948
|
-
this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
3128
|
/** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
|
|
2952
|
-
deliverDaemonReply(body, requestPrefix, logLabel) {
|
|
3129
|
+
deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
|
|
2953
3130
|
const args = { text: body };
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
3131
|
+
const chatId = target?.chatId ?? this.lastChatId;
|
|
3132
|
+
const threadId = target?.threadId ?? this.lastThreadId;
|
|
3133
|
+
const adapterId = target?.adapterId ?? this.lastAdapterId;
|
|
3134
|
+
if (chatId) {
|
|
3135
|
+
args.chat_id = chatId;
|
|
3136
|
+
if (threadId)
|
|
3137
|
+
args.thread_id = threadId;
|
|
2958
3138
|
}
|
|
2959
3139
|
const adapters = this.messageBus.getAllAdapters();
|
|
2960
3140
|
if (adapters.length > 0) {
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
3141
|
+
// With one adapter there is nothing to choose and adapters[0] is exact.
|
|
3142
|
+
// With several, "no adapter named" is a routing failure, not a default:
|
|
3143
|
+
// picking the first sends one world's chat id through another world's bot.
|
|
3144
|
+
const adapter = adapterId
|
|
3145
|
+
? this.messageBus.getAdapter(adapterId)
|
|
3146
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
3147
|
+
if (!adapter) {
|
|
3148
|
+
this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
|
|
3149
|
+
return Promise.resolve(false);
|
|
3150
|
+
}
|
|
3151
|
+
return new Promise(resolve => {
|
|
3152
|
+
let settled = false;
|
|
3153
|
+
const finish = (delivered, error) => {
|
|
3154
|
+
if (settled)
|
|
3155
|
+
return;
|
|
3156
|
+
settled = true;
|
|
3157
|
+
clearTimeout(timeout);
|
|
3158
|
+
if (delivered)
|
|
3159
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3160
|
+
else
|
|
3161
|
+
this.logger.error({ error }, `${logLabel} failed`);
|
|
3162
|
+
resolve(delivered);
|
|
3163
|
+
};
|
|
3164
|
+
const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
|
|
3165
|
+
timeout.unref?.();
|
|
3166
|
+
try {
|
|
3167
|
+
if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
|
|
3168
|
+
finish(false, "reply route unavailable");
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
catch {
|
|
3172
|
+
finish(false, "adapter reply threw before returning a promise");
|
|
3173
|
+
}
|
|
2966
3174
|
});
|
|
2967
|
-
return;
|
|
2968
3175
|
}
|
|
2969
3176
|
if (!this.ipcServer) {
|
|
2970
3177
|
this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
|
|
2971
|
-
return;
|
|
3178
|
+
return Promise.resolve(false);
|
|
2972
3179
|
}
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3180
|
+
return new Promise(resolve => {
|
|
3181
|
+
const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
|
|
3182
|
+
const timeout = setTimeout(() => {
|
|
3183
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3184
|
+
this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
|
|
3185
|
+
resolve(false);
|
|
3186
|
+
}, daemonBudgetMs("reply"));
|
|
3187
|
+
timeout.unref?.();
|
|
3188
|
+
// Register before broadcast: an in-process/fast adapter can answer in the
|
|
3189
|
+
// same tick, and a waiter installed afterwards would miss that response.
|
|
3190
|
+
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
3191
|
+
clearTimeout(timeout);
|
|
3192
|
+
const delivered = !respMsg.error && respMsg.result != null;
|
|
3193
|
+
if (!delivered)
|
|
3194
|
+
this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
|
|
3195
|
+
else
|
|
3196
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3197
|
+
resolve(delivered);
|
|
3198
|
+
});
|
|
3199
|
+
try {
|
|
3200
|
+
this.ipcServer.broadcast({
|
|
3201
|
+
type: "fleet_outbound",
|
|
3202
|
+
tool: "reply",
|
|
3203
|
+
args,
|
|
3204
|
+
fleetRequestId: fleetReqId,
|
|
3205
|
+
adapterId,
|
|
3206
|
+
statusOnly,
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
catch {
|
|
3210
|
+
clearTimeout(timeout);
|
|
3211
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3212
|
+
this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
|
|
3213
|
+
resolve(false);
|
|
3214
|
+
}
|
|
2994
3215
|
});
|
|
2995
3216
|
}
|
|
2996
3217
|
clearInstanceStateIdleTimer() {
|
|
@@ -3557,7 +3778,7 @@ export class Daemon extends EventEmitter {
|
|
|
3557
3778
|
await this.wake();
|
|
3558
3779
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3559
3780
|
return;
|
|
3560
|
-
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
|
|
3781
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
3561
3782
|
this.markTurnStarted(meta, formatted);
|
|
3562
3783
|
}
|
|
3563
3784
|
else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
@@ -3595,7 +3816,7 @@ export class Daemon extends EventEmitter {
|
|
|
3595
3816
|
await this.wake();
|
|
3596
3817
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3597
3818
|
return;
|
|
3598
|
-
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
|
|
3819
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
3599
3820
|
this.markTurnStarted(meta, formatted);
|
|
3600
3821
|
}
|
|
3601
3822
|
}).catch(err => {
|
|
@@ -3647,8 +3868,7 @@ export class Daemon extends EventEmitter {
|
|
|
3647
3868
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
3648
3869
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3649
3870
|
return;
|
|
3650
|
-
|
|
3651
|
-
this.markTurnStarted(meta, rawText);
|
|
3871
|
+
await this.deliverMessage(rawText, undefined, { deliveryEpoch });
|
|
3652
3872
|
}).catch(err => {
|
|
3653
3873
|
this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
|
|
3654
3874
|
});
|
|
@@ -3691,7 +3911,7 @@ export class Daemon extends EventEmitter {
|
|
|
3691
3911
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
3692
3912
|
// the previous turn's tool list.
|
|
3693
3913
|
this.resetToolProgress();
|
|
3694
|
-
if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
|
|
3914
|
+
if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
|
|
3695
3915
|
this.markTurnStarted(meta, formatted);
|
|
3696
3916
|
}
|
|
3697
3917
|
else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
@@ -3840,16 +4060,24 @@ export class Daemon extends EventEmitter {
|
|
|
3840
4060
|
}
|
|
3841
4061
|
}
|
|
3842
4062
|
}
|
|
3843
|
-
// Everything above is *waiting*; everything below
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
//
|
|
4063
|
+
// Everything above is *waiting*; everything below owns the write lock.
|
|
4064
|
+
// Long idle/dialog waits stay outside because the runtime dismisser may be
|
|
4065
|
+
// what makes progress. The one under-lock wait below is explicitly passive:
|
|
4066
|
+
// it clears by itself and closes a repaint race immediately before paste.
|
|
3847
4067
|
for (let round = 0;; round++) {
|
|
3848
4068
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
3849
4069
|
if (cancelled())
|
|
3850
4070
|
return false;
|
|
3851
4071
|
if (this.refuseFatalStartupDelivery(status))
|
|
3852
4072
|
return false;
|
|
4073
|
+
// A passive startup phase may paint after the outer readiness probe.
|
|
4074
|
+
// It clears without input, so waiting under the pane lock cannot starve
|
|
4075
|
+
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4076
|
+
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4077
|
+
if (status)
|
|
4078
|
+
this.emit("message_failed", status); // ❌
|
|
4079
|
+
return false;
|
|
4080
|
+
}
|
|
3853
4081
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
3854
4082
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
3855
4083
|
// this critical section. Re-probe here, right before the write; a
|
|
@@ -3859,7 +4087,7 @@ export class Daemon extends EventEmitter {
|
|
|
3859
4087
|
if (probe.state !== "clear")
|
|
3860
4088
|
return "dialog";
|
|
3861
4089
|
}
|
|
3862
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
|
|
4090
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
|
|
3863
4091
|
});
|
|
3864
4092
|
if (outcome !== "dialog")
|
|
3865
4093
|
return outcome;
|
|
@@ -3893,6 +4121,75 @@ export class Daemon extends EventEmitter {
|
|
|
3893
4121
|
deliveryBlockingDialogs() {
|
|
3894
4122
|
return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
|
|
3895
4123
|
}
|
|
4124
|
+
/** Passive input-unavailable phases relevant to the current spawn only. */
|
|
4125
|
+
guardedInputTransients() {
|
|
4126
|
+
if (this.inputTransientGuardGeneration === null
|
|
4127
|
+
|| this.inputTransientGuardGeneration !== this.spawnGeneration)
|
|
4128
|
+
return [];
|
|
4129
|
+
return this.backend?.getInputUnavailableTransients?.() ?? [];
|
|
4130
|
+
}
|
|
4131
|
+
/** Match a passive transient against the current interactive screen. */
|
|
4132
|
+
inputTransientInPane(pane) {
|
|
4133
|
+
for (const transient of this.guardedInputTransients()) {
|
|
4134
|
+
// `pattern` is only a cheap pre-filter. `isActive` is deliberately the
|
|
4135
|
+
// authority because users and agents routinely quote diagnostic text.
|
|
4136
|
+
transient.pattern.lastIndex = 0;
|
|
4137
|
+
if (!transient.pattern.test(pane))
|
|
4138
|
+
continue;
|
|
4139
|
+
if (transient.isActive(pane))
|
|
4140
|
+
return transient;
|
|
4141
|
+
}
|
|
4142
|
+
return null;
|
|
4143
|
+
}
|
|
4144
|
+
/** Capture failure is unknown, never evidence that input is available. */
|
|
4145
|
+
async probeInputTransient() {
|
|
4146
|
+
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4147
|
+
return { state: "clear" };
|
|
4148
|
+
try {
|
|
4149
|
+
const pane = await this.tmux.capturePane();
|
|
4150
|
+
const transient = this.inputTransientInPane(pane);
|
|
4151
|
+
return transient ? { state: "active", transient } : { state: "clear" };
|
|
4152
|
+
}
|
|
4153
|
+
catch (err) {
|
|
4154
|
+
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
4155
|
+
return { state: "unknown" };
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Wait under the pane lock for a passive transient to disappear. Unlike a
|
|
4160
|
+
* dialog, this state clears by itself, so the wait cannot starve a keypress
|
|
4161
|
+
* dismisser. Stop or a replacement spawn invalidates the captured identity.
|
|
4162
|
+
*/
|
|
4163
|
+
async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
|
|
4164
|
+
const generation = this.inputTransientGuardGeneration;
|
|
4165
|
+
if (generation === null || generation !== this.spawnGeneration)
|
|
4166
|
+
return true;
|
|
4167
|
+
const deadline = Date.now() + timeoutMs;
|
|
4168
|
+
let observedDescription = null;
|
|
4169
|
+
for (;;) {
|
|
4170
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4171
|
+
this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
|
|
4172
|
+
return false;
|
|
4173
|
+
}
|
|
4174
|
+
const probe = await this.probeInputTransient();
|
|
4175
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4176
|
+
this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
|
|
4177
|
+
return false;
|
|
4178
|
+
}
|
|
4179
|
+
if (probe.state === "clear")
|
|
4180
|
+
return true;
|
|
4181
|
+
if (probe.state === "active" && observedDescription !== probe.transient.description) {
|
|
4182
|
+
observedDescription = probe.transient.description;
|
|
4183
|
+
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4184
|
+
}
|
|
4185
|
+
const remaining = deadline - Date.now();
|
|
4186
|
+
if (remaining <= 0) {
|
|
4187
|
+
this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
|
|
4188
|
+
return false;
|
|
4189
|
+
}
|
|
4190
|
+
await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
3896
4193
|
static dialogKey(dialog) {
|
|
3897
4194
|
return `${dialog.pattern.source}/${dialog.pattern.flags}`;
|
|
3898
4195
|
}
|
|
@@ -4003,10 +4300,10 @@ export class Daemon extends EventEmitter {
|
|
|
4003
4300
|
}
|
|
4004
4301
|
/**
|
|
4005
4302
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
4006
|
-
* input queue or steered (after its own dialog probe); "dialog"
|
|
4007
|
-
* must never be — a paste + Enter into a dialog answers it
|
|
4008
|
-
*
|
|
4009
|
-
*
|
|
4303
|
+
* input queue or steered (after its own dialog probe); "dialog", "transient"
|
|
4304
|
+
* and "unknown" must never be — a paste + Enter into a dialog answers it
|
|
4305
|
+
* with its default, while a startup transient swallows it and an unreadable
|
|
4306
|
+
* pane proves nothing. The silence gate comes first and costs no capture.
|
|
4010
4307
|
*/
|
|
4011
4308
|
async paneReadinessForDelivery(windowId) {
|
|
4012
4309
|
if (!this.isPaneIdleForDelivery(windowId))
|
|
@@ -4014,6 +4311,11 @@ export class Daemon extends EventEmitter {
|
|
|
4014
4311
|
const probe = await this.probeBlockingDialog();
|
|
4015
4312
|
if (probe.state !== "clear")
|
|
4016
4313
|
return probe.state;
|
|
4314
|
+
const transient = await this.probeInputTransient();
|
|
4315
|
+
if (transient.state === "active")
|
|
4316
|
+
return "transient";
|
|
4317
|
+
if (transient.state === "unknown")
|
|
4318
|
+
return "unknown";
|
|
4017
4319
|
if (this.backend?.dropsEnterWhileBusy?.() !== true)
|
|
4018
4320
|
return "ready";
|
|
4019
4321
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
@@ -4060,6 +4362,7 @@ export class Daemon extends EventEmitter {
|
|
|
4060
4362
|
const deadline = Date.now() + timeoutMs;
|
|
4061
4363
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4062
4364
|
let unknownStreak = 0;
|
|
4365
|
+
let transientDeadline = 0;
|
|
4063
4366
|
for (;;) {
|
|
4064
4367
|
const remaining = deadline - Date.now();
|
|
4065
4368
|
if (remaining <= 0)
|
|
@@ -4069,6 +4372,26 @@ export class Daemon extends EventEmitter {
|
|
|
4069
4372
|
return false;
|
|
4070
4373
|
// The idle wait just resolved, so re-reading the silence gate would only
|
|
4071
4374
|
// add a stale-cache race; ask the pane the remaining questions directly.
|
|
4375
|
+
const transient = await this.probeInputTransient();
|
|
4376
|
+
if (transient.state === "active") {
|
|
4377
|
+
unknownStreak = 0;
|
|
4378
|
+
transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4379
|
+
if (Date.now() >= transientDeadline) {
|
|
4380
|
+
this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4381
|
+
return false;
|
|
4382
|
+
}
|
|
4383
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4384
|
+
continue;
|
|
4385
|
+
}
|
|
4386
|
+
transientDeadline = 0;
|
|
4387
|
+
if (transient.state === "unknown") {
|
|
4388
|
+
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4389
|
+
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
4390
|
+
return false;
|
|
4391
|
+
}
|
|
4392
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4393
|
+
continue;
|
|
4394
|
+
}
|
|
4072
4395
|
if (bottomGated) {
|
|
4073
4396
|
if (await this.isPaneReadyForDelivery(windowId))
|
|
4074
4397
|
return true;
|
|
@@ -4104,15 +4427,16 @@ export class Daemon extends EventEmitter {
|
|
|
4104
4427
|
* must fail rather than paste on top of the stranded text).
|
|
4105
4428
|
*/
|
|
4106
4429
|
async submitStrandedInputIfAny(windowId) {
|
|
4107
|
-
if (this.
|
|
4430
|
+
if (!this.tmux || !this.backend)
|
|
4108
4431
|
return "idle";
|
|
4109
4432
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
4110
4433
|
if (!prompt)
|
|
4111
4434
|
return "idle";
|
|
4435
|
+
const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
|
|
4112
4436
|
// Pre-check outside the lock so the common case (verified clear) never
|
|
4113
4437
|
// contends with the dialog dismisser. Only a POSITIVE "clear" skips the
|
|
4114
4438
|
// lock; an unreadable pane is re-examined under it, not waved through.
|
|
4115
|
-
const pre = await this.strandedInputState(prompt);
|
|
4439
|
+
const pre = await this.strandedInputState(prompt, requireBottomReady);
|
|
4116
4440
|
if (pre === "clear")
|
|
4117
4441
|
return "idle";
|
|
4118
4442
|
if (pre === "busy")
|
|
@@ -4128,7 +4452,7 @@ export class Daemon extends EventEmitter {
|
|
|
4128
4452
|
// no-op at an empty prompt, but into a modal it is a confirmation.
|
|
4129
4453
|
if (this.fatalStartupBlocked)
|
|
4130
4454
|
return "busy";
|
|
4131
|
-
const state = await this.strandedInputState(prompt);
|
|
4455
|
+
const state = await this.strandedInputState(prompt, requireBottomReady);
|
|
4132
4456
|
if (state === "clear")
|
|
4133
4457
|
return "idle";
|
|
4134
4458
|
if (state !== "stranded")
|
|
@@ -4138,12 +4462,12 @@ export class Daemon extends EventEmitter {
|
|
|
4138
4462
|
});
|
|
4139
4463
|
}
|
|
4140
4464
|
/**
|
|
4141
|
-
* "stranded": an AgEnD message sits in the input row; "clear":
|
|
4142
|
-
*
|
|
4143
|
-
*
|
|
4144
|
-
* read. Only "clear"
|
|
4465
|
+
* "stranded": an AgEnD message sits in the input row; "clear": no strand is
|
|
4466
|
+
* visible under the backend's readiness policy; "busy": generating, or (for
|
|
4467
|
+
* a bottom-gated backend) the prompt is not at the bottom; "unknown": the
|
|
4468
|
+
* pane could not be read. Only "clear" permits the next paste.
|
|
4145
4469
|
*/
|
|
4146
|
-
async strandedInputState(prompt) {
|
|
4470
|
+
async strandedInputState(prompt, requireBottomReady) {
|
|
4147
4471
|
let pane;
|
|
4148
4472
|
try {
|
|
4149
4473
|
pane = await this.tmux.capturePane();
|
|
@@ -4151,11 +4475,16 @@ export class Daemon extends EventEmitter {
|
|
|
4151
4475
|
catch {
|
|
4152
4476
|
return "unknown";
|
|
4153
4477
|
}
|
|
4478
|
+
if (this.inputTransientInPane(pane))
|
|
4479
|
+
return "busy";
|
|
4154
4480
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
4155
4481
|
return "busy";
|
|
4156
4482
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
4157
4483
|
return "stranded";
|
|
4158
|
-
|
|
4484
|
+
// Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
|
|
4485
|
+
// does not: this preflight runs only after its ordinary silence gate and is
|
|
4486
|
+
// enabled solely to recover a positively identified old AgEnD strand.
|
|
4487
|
+
return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
|
|
4159
4488
|
}
|
|
4160
4489
|
/**
|
|
4161
4490
|
* F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
|
|
@@ -4259,6 +4588,8 @@ export class Daemon extends EventEmitter {
|
|
|
4259
4588
|
* an invariant maintained by comments.
|
|
4260
4589
|
*/
|
|
4261
4590
|
async sendDeliveryEnter(phase) {
|
|
4591
|
+
if (!(await this.waitForInputTransientToClear(phase)))
|
|
4592
|
+
return false;
|
|
4262
4593
|
const sent = await this.tmux.sendSpecialKey("Enter");
|
|
4263
4594
|
if (!sent) {
|
|
4264
4595
|
this.logger.error({
|
|
@@ -4268,13 +4599,17 @@ export class Daemon extends EventEmitter {
|
|
|
4268
4599
|
}
|
|
4269
4600
|
return sent;
|
|
4270
4601
|
}
|
|
4271
|
-
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
|
|
4602
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
|
|
4603
|
+
const signature = this.submissionSignature(formatted, submissionId);
|
|
4272
4604
|
let windowId = initialWindowId;
|
|
4273
4605
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
4274
4606
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
4275
4607
|
const maxAttempts = 3;
|
|
4276
4608
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
4277
4609
|
const pasteStartedAt = Date.now();
|
|
4610
|
+
// Read the pane BEFORE writing to it, so the submission check can require
|
|
4611
|
+
// evidence this paste ADDED rather than evidence that was already there.
|
|
4612
|
+
const pasteBaseline = await this.capturePaneEvidence(signature);
|
|
4278
4613
|
const pasted = await this.tmux.pasteBuffer(formatted);
|
|
4279
4614
|
if (!pasted) {
|
|
4280
4615
|
const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
|
|
@@ -4356,16 +4691,73 @@ export class Daemon extends EventEmitter {
|
|
|
4356
4691
|
// can silently swallow it). Idle submissions keep the swallowed-Enter path.
|
|
4357
4692
|
if (handingOffToNativeQueue) {
|
|
4358
4693
|
await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
|
|
4359
|
-
|
|
4694
|
+
const proof = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4695
|
+
if (proof === "submitted") {
|
|
4360
4696
|
if (status)
|
|
4361
4697
|
this.emit("message_confirmed", status); // ✅ native queue accepted
|
|
4362
4698
|
return true;
|
|
4363
4699
|
}
|
|
4364
|
-
|
|
4365
|
-
|
|
4700
|
+
if (proof === "unverifiable") {
|
|
4701
|
+
// Our text is on the pane but cannot be shown to have left the input
|
|
4702
|
+
// row — either the backend exposes no input row (only /steer reaches
|
|
4703
|
+
// this path on such a backend) or the pre-paste pane was unreadable.
|
|
4704
|
+
// Re-pasting on that would deliver the message twice, so accept as
|
|
4705
|
+
// this path always has, and record that it is unproven.
|
|
4706
|
+
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
4707
|
+
if (status)
|
|
4708
|
+
this.emit("message_confirmed", status); // ✅ (best-effort)
|
|
4709
|
+
return true;
|
|
4710
|
+
}
|
|
4711
|
+
// Not submitted (or not provably submitted): fall back once to the
|
|
4712
|
+
// normal idle-gated path, which is the only one with a full
|
|
4713
|
+
// confirmation ladder. "stranded" is the case that used to be a silent
|
|
4714
|
+
// ✅ — the text was visible, so the old check called it delivered.
|
|
4715
|
+
this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
|
|
4366
4716
|
if (windowId && this.controlClient) {
|
|
4367
4717
|
await this.controlClient.waitUntilIdle(windowId);
|
|
4368
4718
|
}
|
|
4719
|
+
// Re-read before doing anything: the CLI had a whole turn to flush its
|
|
4720
|
+
// queue while we waited, and the right recovery differs per outcome.
|
|
4721
|
+
// Re-pasting a payload that is STILL in the input row appends it to
|
|
4722
|
+
// itself — paste-buffer writes at the cursor — and the next Enter then
|
|
4723
|
+
// submits the message twice over. That is the trap this fix would walk
|
|
4724
|
+
// into if it treated "not confirmed" as one state.
|
|
4725
|
+
const settled = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4726
|
+
if (settled === "submitted") {
|
|
4727
|
+
this.logger.info("Native-queue message submitted itself while we waited for idle");
|
|
4728
|
+
if (status)
|
|
4729
|
+
this.emit("message_confirmed", status); // ✅
|
|
4730
|
+
return true;
|
|
4731
|
+
}
|
|
4732
|
+
if (settled === "stranded") {
|
|
4733
|
+
// The text is already there in full: it needs submitting, not sending
|
|
4734
|
+
// again. The prompt is back now, so an Enter can land.
|
|
4735
|
+
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
4736
|
+
const strandedAt = Date.now();
|
|
4737
|
+
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
4738
|
+
if (status)
|
|
4739
|
+
this.emit("message_failed", status); // ❌
|
|
4740
|
+
return false;
|
|
4741
|
+
}
|
|
4742
|
+
const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4743
|
+
if (afterEnter === "submitted") {
|
|
4744
|
+
if (status)
|
|
4745
|
+
this.emit("message_confirmed", status); // ✅
|
|
4746
|
+
return true;
|
|
4747
|
+
}
|
|
4748
|
+
// Deliberately NOT "or the pane went busy". This branch runs because
|
|
4749
|
+
// the text was still in the input row; if the recovery Enter was
|
|
4750
|
+
// swallowed too, the pane is busy with something else — a late redraw,
|
|
4751
|
+
// another turn's output — and treating that as proof re-confirms a
|
|
4752
|
+
// message nobody submitted. Output is corroboration; text sitting in
|
|
4753
|
+
// the input row is disqualifying, and disqualifying evidence wins.
|
|
4754
|
+
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
4755
|
+
if (status)
|
|
4756
|
+
this.emit("message_failed", status); // ❌
|
|
4757
|
+
return false;
|
|
4758
|
+
}
|
|
4759
|
+
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
4760
|
+
// so pasting it again cannot duplicate anything.
|
|
4369
4761
|
const repasted = await this.tmux.pasteBuffer(formatted);
|
|
4370
4762
|
if (!repasted) {
|
|
4371
4763
|
this.logger.error({
|
|
@@ -4383,24 +4775,13 @@ export class Daemon extends EventEmitter {
|
|
|
4383
4775
|
return false;
|
|
4384
4776
|
}
|
|
4385
4777
|
if (windowId && this.controlClient) {
|
|
4386
|
-
|
|
4387
|
-
if (!becameBusy) {
|
|
4388
|
-
this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
|
|
4389
|
-
const retry2At = Date.now();
|
|
4390
|
-
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
|
|
4391
|
-
if (status)
|
|
4392
|
-
this.emit("message_failed", status); // ❌
|
|
4393
|
-
return false;
|
|
4394
|
-
}
|
|
4395
|
-
becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
|
|
4396
|
-
}
|
|
4397
|
-
if (becameBusy) {
|
|
4778
|
+
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
4398
4779
|
if (status)
|
|
4399
4780
|
this.emit("message_confirmed", status); // ✅
|
|
4400
4781
|
return true;
|
|
4401
4782
|
}
|
|
4402
4783
|
}
|
|
4403
|
-
else if (await this.
|
|
4784
|
+
else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
|
|
4404
4785
|
if (status)
|
|
4405
4786
|
this.emit("message_confirmed", status); // ✅
|
|
4406
4787
|
return true;
|
|
@@ -4440,17 +4821,17 @@ export class Daemon extends EventEmitter {
|
|
|
4440
4821
|
}
|
|
4441
4822
|
}
|
|
4442
4823
|
else if (windowId && this.controlClient) {
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4824
|
+
// An idle delivery used to be judged by output alone: anything the pane
|
|
4825
|
+
// printed after Enter counted as "submitted". Waking a paused instance
|
|
4826
|
+
// breaks that — the CLI's final redraw arrives right as we press Enter,
|
|
4827
|
+
// so a swallowed Enter is confirmed by the redraw it was lost to, and
|
|
4828
|
+
// the message is never in a turn (#745 left this path on the old rule).
|
|
4829
|
+
//
|
|
4830
|
+
// Where the input row can be read, it decides: text still sitting there
|
|
4831
|
+
// is disqualifying and no amount of output may override it. Where it
|
|
4832
|
+
// cannot (a backend with no prompt pattern), the output signal remains
|
|
4833
|
+
// exactly as before — this must not regress backends we cannot read.
|
|
4834
|
+
const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
|
|
4454
4835
|
if (becameBusy) {
|
|
4455
4836
|
if (status)
|
|
4456
4837
|
this.emit("message_confirmed", status); // ✅
|
|
@@ -4481,31 +4862,260 @@ export class Daemon extends EventEmitter {
|
|
|
4481
4862
|
return false;
|
|
4482
4863
|
}
|
|
4483
4864
|
/**
|
|
4484
|
-
*
|
|
4485
|
-
*
|
|
4486
|
-
*
|
|
4865
|
+
* The single place a pasted message is judged submitted, shared by the
|
|
4866
|
+
* native-queue handoff, the idle-gated redelivery and the system pastes.
|
|
4867
|
+
*
|
|
4868
|
+
* It replaces three divergent weak checks, the weakest of which accepted "the
|
|
4869
|
+
* pasted text is visible somewhere in the pane". That one was satisfied BY
|
|
4870
|
+
* the failure it was meant to catch: text stranded in the input row is on
|
|
4871
|
+
* screen, so a message nobody submitted was confirmed ✅ with no warning
|
|
4872
|
+
* anywhere. Evidence is weighed in order of what it can prove, and the
|
|
4873
|
+
* disqualifying evidence is checked first.
|
|
4487
4874
|
*/
|
|
4488
|
-
|
|
4489
|
-
|
|
4875
|
+
/**
|
|
4876
|
+
* Whether this backend exposes an input row we can actually read, i.e. whether
|
|
4877
|
+
* confirmSubmitted can ever return a verdict stronger than a guess.
|
|
4878
|
+
*
|
|
4879
|
+
* This is a property of the BACKEND, not of one delivery's verdict. Keying the
|
|
4880
|
+
* output-signal fallback off the verdict instead let codex — which can be read
|
|
4881
|
+
* — fall back to "the pane printed something" whenever a paste left no trace,
|
|
4882
|
+
* which is the same false confirmation one layer down.
|
|
4883
|
+
*/
|
|
4884
|
+
canProveSubmission() {
|
|
4885
|
+
return !!this.backend?.getBottomReadyPattern?.();
|
|
4886
|
+
}
|
|
4887
|
+
/** Positive submission retires the startup transient guard for this spawn. */
|
|
4888
|
+
submittedProof() {
|
|
4889
|
+
if (this.inputTransientGuardGeneration === this.spawnGeneration) {
|
|
4890
|
+
this.inputTransientGuardGeneration = null;
|
|
4891
|
+
}
|
|
4892
|
+
return "submitted";
|
|
4893
|
+
}
|
|
4894
|
+
/**
|
|
4895
|
+
* Decide a submission after an Enter, with one bounded retry.
|
|
4896
|
+
*
|
|
4897
|
+
* Backends we can read must SHOW the text left the input row: neither a
|
|
4898
|
+
* strand nor a vanished paste may be upgraded to success by output, because
|
|
4899
|
+
* the output that would do the upgrading is usually the very redraw the Enter
|
|
4900
|
+
* was lost to. Backends we cannot read keep the legacy output signal — there
|
|
4901
|
+
* is nothing better available for them, and taking it away would fail every
|
|
4902
|
+
* one of their deliveries.
|
|
4903
|
+
*
|
|
4904
|
+
* One ladder, used by every post-Enter confirmation, so an escape hatch
|
|
4905
|
+
* cannot reappear in just one of them (it did: three copies, two of which
|
|
4906
|
+
* still confirmed on output alone).
|
|
4907
|
+
*/
|
|
4908
|
+
async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
|
|
4909
|
+
if (!this.canProveSubmission()) {
|
|
4910
|
+
let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
4911
|
+
if (!busy) {
|
|
4912
|
+
this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
|
|
4913
|
+
const retryAt = Date.now();
|
|
4914
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4915
|
+
return false;
|
|
4916
|
+
busy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
4917
|
+
}
|
|
4918
|
+
return busy;
|
|
4919
|
+
}
|
|
4920
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4921
|
+
return true;
|
|
4922
|
+
// Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
|
|
4923
|
+
// how we got here. Then poll briefly rather than judging on one capture:
|
|
4924
|
+
// the key takes a moment to be digested, and a single early look would
|
|
4925
|
+
// report a failure for a message that did go out.
|
|
4926
|
+
this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
|
|
4927
|
+
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
4490
4928
|
return false;
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4929
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4930
|
+
return false;
|
|
4931
|
+
const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
4932
|
+
for (;;) {
|
|
4933
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4494
4934
|
return true;
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4935
|
+
if (Date.now() >= deadline)
|
|
4936
|
+
return false;
|
|
4937
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
async confirmSubmitted(signature, baseline) {
|
|
4941
|
+
if (!this.tmux)
|
|
4942
|
+
return "unproven";
|
|
4943
|
+
let pane;
|
|
4944
|
+
try {
|
|
4945
|
+
pane = await this.tmux.capturePane();
|
|
4946
|
+
}
|
|
4947
|
+
catch {
|
|
4948
|
+
return "unproven";
|
|
4949
|
+
}
|
|
4950
|
+
// Without a way to tell the input row from the transcript, "the text is on
|
|
4951
|
+
// screen" cannot distinguish submitted from stranded — that ambiguity IS
|
|
4952
|
+
// the bug. Such a backend gets an explicit verdict rather than a guess:
|
|
4953
|
+
// "unverifiable" (something of ours appeared, but not provably out of the
|
|
4954
|
+
// input row) or "unproven" (nothing of ours appeared, so the paste itself
|
|
4955
|
+
// was lost). The caller keeps that backend's existing best-effort handling
|
|
4956
|
+
// for the first and recovers on the second, which is what it did before —
|
|
4957
|
+
// the fix here is for backends that CAN be read, not a new guess for those
|
|
4958
|
+
// that cannot.
|
|
4959
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
4960
|
+
if (!prompt) {
|
|
4961
|
+
const seen = this.paneEvidence(pane, signature);
|
|
4962
|
+
if (signature.unique && seen.payload > 0)
|
|
4963
|
+
return "unverifiable";
|
|
4964
|
+
return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
|
|
4965
|
+
? "unverifiable"
|
|
4966
|
+
: "unproven";
|
|
4967
|
+
}
|
|
4968
|
+
const after = this.paneEvidence(pane, signature);
|
|
4969
|
+
// 1. Disqualifying evidence, checked FIRST and never overridden by the
|
|
4970
|
+
// corroborating evidence below: our text is sitting in the input row, so
|
|
4971
|
+
// it was not submitted — whatever else is on screen.
|
|
4972
|
+
//
|
|
4973
|
+
// It must be OUR text. A unique signature settles that on its own; a
|
|
4974
|
+
// body-derived one cannot, so it is only attributed to this delivery
|
|
4975
|
+
// when the input row did not already show it before we pasted.
|
|
4976
|
+
// Otherwise an older stranded message with the same opening would be
|
|
4977
|
+
// read as ours, we would press Enter to "recover" it, and the turn IT
|
|
4978
|
+
// starts would confirm a message that never reached the pane.
|
|
4979
|
+
if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
|
|
4980
|
+
return "stranded";
|
|
4981
|
+
// 2. Positive evidence. A unique signature needs no baseline: no earlier
|
|
4982
|
+
// message can carry this delivery's message_id, so finding it outside
|
|
4983
|
+
// the input row is proof in itself — which also means a momentary
|
|
4984
|
+
// failure to read the pane BEFORE pasting cannot turn a delivered
|
|
4985
|
+
// message into a re-paste.
|
|
4986
|
+
if (signature.unique && after.payload > 0)
|
|
4987
|
+
return this.submittedProof();
|
|
4988
|
+
// 3. Otherwise the evidence must be NEW relative to the pane as it was
|
|
4989
|
+
// before we pasted: a queue marker left by an earlier message, or an
|
|
4990
|
+
// older transcript entry that opens the same way, are on screen either
|
|
4991
|
+
// way and would otherwise confirm a paste that never landed.
|
|
4992
|
+
if (baseline) {
|
|
4993
|
+
if (after.queued > baseline.queued)
|
|
4994
|
+
return this.submittedProof();
|
|
4995
|
+
if (after.payload > baseline.payload)
|
|
4996
|
+
return this.submittedProof();
|
|
4997
|
+
// 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
|
|
4998
|
+
return "unproven";
|
|
4999
|
+
}
|
|
5000
|
+
// No baseline and nothing that identifies this delivery on its own. "The
|
|
5001
|
+
// paste was lost" is a guess, not a finding, and acting on it re-pastes a
|
|
5002
|
+
// message that may well have been submitted — so say the evidence is
|
|
5003
|
+
// missing instead of inventing a verdict.
|
|
5004
|
+
this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
|
|
5005
|
+
return "unverifiable";
|
|
5006
|
+
}
|
|
5007
|
+
/**
|
|
5008
|
+
* What the pane currently shows of a given message. Taken once before the
|
|
5009
|
+
* paste and once after, so confirmSubmitted can require a NEW marker or a NEW
|
|
5010
|
+
* echo rather than accepting whatever was already there.
|
|
5011
|
+
*/
|
|
5012
|
+
async capturePaneEvidence(signature) {
|
|
5013
|
+
if (!this.tmux)
|
|
5014
|
+
return null;
|
|
5015
|
+
// Bounded re-probe: a capture that fails here costs the delivery its only
|
|
5016
|
+
// "before" picture, and a momentary failure should not decide anything.
|
|
5017
|
+
for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
|
|
5018
|
+
try {
|
|
5019
|
+
const pane = await this.tmux.capturePane();
|
|
5020
|
+
const evidence = this.paneEvidence(pane, signature);
|
|
5021
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5022
|
+
if (prompt && strandedAgendMessageInInput(pane, prompt)) {
|
|
5023
|
+
// Whatever we paste now lands after it, and one Enter submits both as
|
|
5024
|
+
// a single message. Nothing here can undo that; saying so beats
|
|
5025
|
+
// letting two messages silently merge.
|
|
5026
|
+
this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
|
|
5027
|
+
}
|
|
5028
|
+
return evidence;
|
|
4499
5029
|
}
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
5030
|
+
catch {
|
|
5031
|
+
if (attempt < BASELINE_CAPTURE_ATTEMPTS)
|
|
5032
|
+
await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
|
|
4503
5033
|
}
|
|
5034
|
+
}
|
|
5035
|
+
// Still unreadable: confirmSubmitted falls back to evidence that stands on
|
|
5036
|
+
// its own (a unique message_id) and refuses to guess without it.
|
|
5037
|
+
this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
|
|
5038
|
+
return null;
|
|
5039
|
+
}
|
|
5040
|
+
paneEvidence(pane, signature) {
|
|
5041
|
+
const marker = this.backend?.getQueuedInputMarker?.();
|
|
5042
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5043
|
+
const input = prompt ? inputAreaText(pane, prompt) : null;
|
|
5044
|
+
return {
|
|
5045
|
+
queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
|
|
5046
|
+
payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
|
|
5047
|
+
strandedInput: input != null && inputShowsPastedText(input, signature.value),
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
/**
|
|
5051
|
+
* What identifies THIS message on screen. The routing envelope carries a
|
|
5052
|
+
* message_id that no earlier transcript entry can share, so prefer it: the
|
|
5053
|
+
* body alone is not distinctive enough — short repeated instructions ("carry
|
|
5054
|
+
* on", a status ping) recur verbatim, and an older copy would vouch for a
|
|
5055
|
+
* paste that never landed. System pastes carry no envelope and fall back to
|
|
5056
|
+
* the body signature, which the baseline comparison still ties to this paste.
|
|
5057
|
+
*/
|
|
5058
|
+
submissionSignature(formatted, trustedId) {
|
|
5059
|
+
// The id must come from the message's own metadata, never from scanning the
|
|
5060
|
+
// rendered text: agents and users discuss message ids in the body all the
|
|
5061
|
+
// time ("check message_id: abc"), and a body-scanned value would match an
|
|
5062
|
+
// older transcript entry quoting the same thing — then be trusted as unique
|
|
5063
|
+
// and confirm a paste that never landed. Only the value AgEnD itself put in
|
|
5064
|
+
// the handoff metadata identifies THIS delivery.
|
|
5065
|
+
const id = trustedId?.trim();
|
|
5066
|
+
if (id)
|
|
5067
|
+
return { value: `message_id:${id}`, unique: true };
|
|
5068
|
+
return { value: pastedTextSignature(formatted), unique: false };
|
|
5069
|
+
}
|
|
5070
|
+
/**
|
|
5071
|
+
* Paste + submit + confirm for the SYSTEM messages that used to go out via
|
|
5072
|
+
* tmux.pasteText: one Enter (two for queue-less backends) and no verification
|
|
5073
|
+
* at all, so a swallowed Enter left the notice sitting in the input row where
|
|
5074
|
+
* the next delivery submitted both as one message. Shares confirmSubmitted
|
|
5075
|
+
* with the delivery path — having one primitive is the point.
|
|
5076
|
+
*
|
|
5077
|
+
* Best effort by design: these notices must never fail a delivery or throw.
|
|
5078
|
+
* The caller already holds paneWriteLock.
|
|
5079
|
+
*/
|
|
5080
|
+
async submitSystemPaste(text, label) {
|
|
5081
|
+
if (!this.tmux)
|
|
5082
|
+
return false;
|
|
5083
|
+
const signature = this.submissionSignature(text);
|
|
5084
|
+
const baseline = await this.capturePaneEvidence(signature);
|
|
5085
|
+
if (!(await this.tmux.pasteBuffer(text))) {
|
|
5086
|
+
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
4504
5087
|
return false;
|
|
4505
5088
|
}
|
|
4506
|
-
|
|
5089
|
+
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
5090
|
+
if (!(await this.sendDeliveryEnter(label)))
|
|
5091
|
+
return false;
|
|
5092
|
+
let proof = await this.confirmSubmitted(signature, baseline);
|
|
5093
|
+
if (proof === "unverifiable") {
|
|
5094
|
+
// No input row to read: this backend gets exactly what the old pasteText
|
|
5095
|
+
// path gave it, including the unconditional second Enter for queue-less
|
|
5096
|
+
// TUIs that swallow the first. Narrowing that to one Enter on the grounds
|
|
5097
|
+
// that "the text is visible" would be the very inference this change
|
|
5098
|
+
// exists to remove — visible text is also what a strand looks like.
|
|
5099
|
+
if (this.systemPasteOptions().retryEnter) {
|
|
5100
|
+
await new Promise(r => setTimeout(r, 1_000));
|
|
5101
|
+
await this.sendDeliveryEnter(`${label}-defensive-retry`);
|
|
5102
|
+
}
|
|
5103
|
+
return true; // best effort, exactly as before — nothing here is verified
|
|
5104
|
+
}
|
|
5105
|
+
if (proof !== "submitted") {
|
|
5106
|
+
// A bare Enter is a no-op at an empty prompt, so this is safe even if the
|
|
5107
|
+
// first one did land; when the text is still in the input row it is the
|
|
5108
|
+
// submit it never got. Re-pasting would append the text to itself.
|
|
5109
|
+
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
5110
|
+
if (!(await this.sendDeliveryEnter(`${label}-retry`)))
|
|
5111
|
+
return false;
|
|
5112
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5113
|
+
}
|
|
5114
|
+
if (proof !== "submitted") {
|
|
5115
|
+
this.logger.warn({ label, proof }, "System paste may not have been submitted");
|
|
4507
5116
|
return false;
|
|
4508
5117
|
}
|
|
5118
|
+
return true;
|
|
4509
5119
|
}
|
|
4510
5120
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
4511
5121
|
async recoverWindow() {
|
|
@@ -4598,14 +5208,21 @@ export class Daemon extends EventEmitter {
|
|
|
4598
5208
|
if (this.socketSessionNames.get(socket) === this.name) {
|
|
4599
5209
|
this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
|
|
4600
5210
|
}
|
|
5211
|
+
// A tool invocation is not delivery evidence. Capture which obligation it
|
|
5212
|
+
// belongs to now, then settle it only after the adapter/fleet responds.
|
|
5213
|
+
// Explicitly mapped sibling sessions must not satisfy this instance's turn;
|
|
5214
|
+
// an unmapped socket is retained for legacy/tests where mcp_ready was not
|
|
5215
|
+
// observed before the first tool call.
|
|
5216
|
+
const sourceSession = this.socketSessionNames.get(socket);
|
|
5217
|
+
const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
|
|
5218
|
+
? this.turnReplyGuard.beginToolAttempt(tool === "reply")
|
|
5219
|
+
: null;
|
|
4601
5220
|
// For now, log and respond. Full adapter routing will be wired in fleet manager.
|
|
4602
5221
|
const respond = (result, error) => {
|
|
4603
5222
|
// A message that verifiably went out stands down the dead-MCP proxy reply
|
|
4604
5223
|
// for this turn: the agent proved it can still speak for itself.
|
|
4605
5224
|
if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
|
|
4606
|
-
this.
|
|
4607
|
-
if (tool === "reply")
|
|
4608
|
-
this.turnReplyDelivered = true;
|
|
5225
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
4609
5226
|
}
|
|
4610
5227
|
const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
|
|
4611
5228
|
if (!sent) {
|
|
@@ -4796,7 +5413,15 @@ export class Daemon extends EventEmitter {
|
|
|
4796
5413
|
});
|
|
4797
5414
|
return;
|
|
4798
5415
|
}
|
|
4799
|
-
|
|
5416
|
+
// Same rule as deliverDaemonReply: one adapter is unambiguous, several with
|
|
5417
|
+
// no bound world is a routing failure rather than a first-wins guess.
|
|
5418
|
+
const adapter = this.lastAdapterId
|
|
5419
|
+
? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
|
|
5420
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
5421
|
+
if (!adapter) {
|
|
5422
|
+
respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
|
|
5423
|
+
return;
|
|
5424
|
+
}
|
|
4800
5425
|
if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
|
|
4801
5426
|
respond(null, `Unknown tool: ${tool}`);
|
|
4802
5427
|
}
|
|
@@ -4987,7 +5612,16 @@ export class Daemon extends EventEmitter {
|
|
|
4987
5612
|
try {
|
|
4988
5613
|
// Messages can arrive during a restart and be queued on pasteLock before the
|
|
4989
5614
|
// snapshot lands; both write to the pane, so both go through the same lock.
|
|
4990
|
-
|
|
5615
|
+
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.`;
|
|
5616
|
+
const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
|
|
5617
|
+
if (!injected) {
|
|
5618
|
+
// rotation-state.json was deleted when the prompt was built, so there is
|
|
5619
|
+
// nothing left to retry from: the restore is gone either way. Say so —
|
|
5620
|
+
// reporting it as injected is how a lost context restore stays invisible.
|
|
5621
|
+
this.logger.error("Session snapshot could not be submitted — session continues without context");
|
|
5622
|
+
this.emit("snapshot_failed", this.name);
|
|
5623
|
+
return;
|
|
5624
|
+
}
|
|
4991
5625
|
this.logger.info("Injected session snapshot as first message");
|
|
4992
5626
|
this.emit("snapshot_injected", this.name);
|
|
4993
5627
|
}
|
|
@@ -5003,6 +5637,17 @@ export class Daemon extends EventEmitter {
|
|
|
5003
5637
|
* into exactly the window this exists to close.
|
|
5004
5638
|
*/
|
|
5005
5639
|
beginSpawn() {
|
|
5640
|
+
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
5641
|
+
// v1 deliberately does not persist obligations across restarts: missing one
|
|
5642
|
+
// warning is safer than treating the replacement's startup idle as the old
|
|
5643
|
+
// turn ending and injecting a stale recovery prompt.
|
|
5644
|
+
this.turnReplyGuard.reset();
|
|
5645
|
+
if (this.spawnDepth === 0) {
|
|
5646
|
+
this.spawnGeneration++;
|
|
5647
|
+
this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
|
|
5648
|
+
? this.spawnGeneration
|
|
5649
|
+
: null;
|
|
5650
|
+
}
|
|
5006
5651
|
this.spawnDepth++;
|
|
5007
5652
|
this.spawning = true;
|
|
5008
5653
|
if (!this.spawnSettled) {
|
|
@@ -5057,6 +5702,69 @@ export class Daemon extends EventEmitter {
|
|
|
5057
5702
|
}
|
|
5058
5703
|
}
|
|
5059
5704
|
/** Spawn a CLI window. Returns true if --resume was used successfully. */
|
|
5705
|
+
/**
|
|
5706
|
+
* Positive proof from the CLI that there is no conversation left to resume.
|
|
5707
|
+
*
|
|
5708
|
+
* Only this may justify abandoning a session. A startup that merely ran out of
|
|
5709
|
+
* budget proves nothing: a healthy cold start on a loaded host looks exactly
|
|
5710
|
+
* the same, which is how 9 instances silently lost their history.
|
|
5711
|
+
*
|
|
5712
|
+
* Deliberately does NOT match a bare "--continue": the pane text can mention
|
|
5713
|
+
* the flag (a usage error, an echoed invocation) without the CLI ever saying
|
|
5714
|
+
* the conversation is gone.
|
|
5715
|
+
*/
|
|
5716
|
+
paneSaysNoConversation(paneText) {
|
|
5717
|
+
if (!paneText)
|
|
5718
|
+
return false;
|
|
5719
|
+
return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
|
|
5720
|
+
.test(paneText);
|
|
5721
|
+
}
|
|
5722
|
+
/** How many consecutive startups have failed without proving the session is gone. */
|
|
5723
|
+
unprovenResumeFailures = 0;
|
|
5724
|
+
/** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
|
|
5725
|
+
static MAX_UNPROVEN_RESUME_FAILURES = 3;
|
|
5726
|
+
/**
|
|
5727
|
+
* Set the session aside instead of deleting it.
|
|
5728
|
+
*
|
|
5729
|
+
* The whole defect being fixed here is silent, irreversible loss of a user's
|
|
5730
|
+
* conversation. Renaming costs nothing and turns a wrong call into something
|
|
5731
|
+
* recoverable: the id is still on disk under a dated name.
|
|
5732
|
+
*/
|
|
5733
|
+
setSessionAside(reason) {
|
|
5734
|
+
const sidFile = join(this.instanceDir, "session-id");
|
|
5735
|
+
try {
|
|
5736
|
+
if (!existsSync(sidFile))
|
|
5737
|
+
return;
|
|
5738
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
5739
|
+
renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
|
|
5740
|
+
this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
|
|
5741
|
+
this.pruneAbandonedSessions();
|
|
5742
|
+
}
|
|
5743
|
+
catch (err) {
|
|
5744
|
+
// Never let bookkeeping block a spawn; falling back to the old behaviour
|
|
5745
|
+
// is still better than not starting.
|
|
5746
|
+
this.logger.warn({ err: err.message }, "Could not set session aside");
|
|
5747
|
+
try {
|
|
5748
|
+
unlinkSync(sidFile);
|
|
5749
|
+
}
|
|
5750
|
+
catch { /* may not exist */ }
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
/** Keep the newest few set-aside sessions; they are insurance, not an archive. */
|
|
5754
|
+
pruneAbandonedSessions(keep = 5) {
|
|
5755
|
+
try {
|
|
5756
|
+
const files = readdirSync(this.instanceDir)
|
|
5757
|
+
.filter((f) => f.startsWith("session-id.abandoned-"))
|
|
5758
|
+
.sort();
|
|
5759
|
+
for (const f of files.slice(0, Math.max(0, files.length - keep))) {
|
|
5760
|
+
try {
|
|
5761
|
+
unlinkSync(join(this.instanceDir, f));
|
|
5762
|
+
}
|
|
5763
|
+
catch { /* best effort */ }
|
|
5764
|
+
}
|
|
5765
|
+
}
|
|
5766
|
+
catch { /* best effort */ }
|
|
5767
|
+
}
|
|
5060
5768
|
async spawnClaudeWindow() {
|
|
5061
5769
|
this.beginSpawn();
|
|
5062
5770
|
let resumedSuccessfully = false;
|
|
@@ -5080,7 +5788,7 @@ export class Daemon extends EventEmitter {
|
|
|
5080
5788
|
// first miss is usually slowness, not a broken session.
|
|
5081
5789
|
await this.noteStartupPaneForBackendOutage();
|
|
5082
5790
|
await this.failStartupIfBackendUnreachable();
|
|
5083
|
-
if (this.backend.retriesResumeOnStartupFailure?.()
|
|
5791
|
+
if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
|
|
5084
5792
|
this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
|
|
5085
5793
|
await this.killProcessTree();
|
|
5086
5794
|
await this.tmux.killWindow();
|
|
@@ -5092,16 +5800,39 @@ export class Daemon extends EventEmitter {
|
|
|
5092
5800
|
}
|
|
5093
5801
|
}
|
|
5094
5802
|
if (!alive) {
|
|
5095
|
-
//
|
|
5096
|
-
//
|
|
5097
|
-
//
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5803
|
+
// The session may only be abandoned on positive proof that there is
|
|
5804
|
+
// nothing to resume. Running out of budget is not proof: on a loaded host
|
|
5805
|
+
// a healthy cold start misses the same deadline as a broken session, and
|
|
5806
|
+
// treating the two alike is what silently discarded users' conversations.
|
|
5807
|
+
if (attemptedResume) {
|
|
5808
|
+
let paneText;
|
|
5809
|
+
try {
|
|
5810
|
+
paneText = await this.tmux?.capturePaneWithHistory(50);
|
|
5811
|
+
}
|
|
5812
|
+
catch { /* pane may be gone */ }
|
|
5813
|
+
const proven = this.paneSaysNoConversation(paneText);
|
|
5814
|
+
if (!proven) {
|
|
5815
|
+
this.unprovenResumeFailures++;
|
|
5816
|
+
if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
|
|
5817
|
+
// Keep the session and fail this attempt; the fleet retries with
|
|
5818
|
+
// backoff, which is also how the backend-outage path behaves.
|
|
5819
|
+
await this.killProcessTree();
|
|
5820
|
+
await this.tmux.killWindow();
|
|
5821
|
+
throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
5822
|
+
+ "— session kept, will retry");
|
|
5823
|
+
}
|
|
5824
|
+
// Escape hatch: a genuinely broken session must still recover. Say so
|
|
5825
|
+
// out loud — this start does NOT continue the previous conversation.
|
|
5826
|
+
this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
|
|
5827
|
+
+ "The previous conversation is NOT continued; its context is lost to this session.");
|
|
5828
|
+
this.emit("context_lost", this.name, "resume_repeatedly_failed");
|
|
5829
|
+
}
|
|
5830
|
+
this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
|
|
5831
|
+
this.skipResume = true;
|
|
5102
5832
|
}
|
|
5103
|
-
|
|
5104
|
-
|
|
5833
|
+
// A fresh start that also failed retries once, as before. It never clears
|
|
5834
|
+
// a session: nothing about a failed fresh launch says the stored
|
|
5835
|
+
// conversation is unusable.
|
|
5105
5836
|
await this.killProcessTree();
|
|
5106
5837
|
await this.tmux.killWindow();
|
|
5107
5838
|
const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
|
|
@@ -5270,6 +6001,79 @@ export class Daemon extends EventEmitter {
|
|
|
5270
6001
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
5271
6002
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
5272
6003
|
}
|
|
6004
|
+
/**
|
|
6005
|
+
* Tell the agent its instructions moved, once the CLI is idle.
|
|
6006
|
+
*
|
|
6007
|
+
* A named method rather than an inline closure so the wiring itself is
|
|
6008
|
+
* testable: warmupNoticeAction's own tests pass even if this call site stops
|
|
6009
|
+
* consulting it, which is exactly the bug this path had — the decision was
|
|
6010
|
+
* made inline and never asked whether the backend needed telling at all.
|
|
6011
|
+
*
|
|
6012
|
+
* Never throws: a missed reload notice must not fail a spawn.
|
|
6013
|
+
*/
|
|
6014
|
+
async runWarmupInstructionNotice() {
|
|
6015
|
+
try {
|
|
6016
|
+
const action = warmupNoticeAction({
|
|
6017
|
+
warmupNeeded: this.warmupNeeded,
|
|
6018
|
+
backend: this.backend ?? undefined,
|
|
6019
|
+
pasteQueueDepth: this.pasteQueueDepth,
|
|
6020
|
+
});
|
|
6021
|
+
if (action === "skip") {
|
|
6022
|
+
this.logger.debug(this.warmupNeeded
|
|
6023
|
+
? "Warmup skipped — this backend reloads instructions on resume"
|
|
6024
|
+
: "Warmup skipped — instructions unchanged");
|
|
6025
|
+
// When the backend learns of the change on its own, record what it now
|
|
6026
|
+
// holds: leaving prev-instructions behind would keep every later
|
|
6027
|
+
// restart comparing against a value the agent has long since replaced.
|
|
6028
|
+
if (this.warmupNeeded) {
|
|
6029
|
+
try {
|
|
6030
|
+
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
6031
|
+
}
|
|
6032
|
+
catch { /* best effort */ }
|
|
6033
|
+
}
|
|
6034
|
+
return;
|
|
6035
|
+
}
|
|
6036
|
+
if (action === "defer") {
|
|
6037
|
+
this.logger.debug("Warmup deferred — no pending inbound messages");
|
|
6038
|
+
// Convert to pendingInstructionsNotice so it fires on next real message.
|
|
6039
|
+
this.pendingInstructionsNotice = true;
|
|
6040
|
+
try {
|
|
6041
|
+
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
6042
|
+
}
|
|
6043
|
+
catch { }
|
|
6044
|
+
return;
|
|
6045
|
+
}
|
|
6046
|
+
const wid = existsSync(join(this.instanceDir, "window-id"))
|
|
6047
|
+
? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
|
|
6048
|
+
if (wid && this.controlClient) {
|
|
6049
|
+
await this.controlClient.waitForIdle(wid, 120_000);
|
|
6050
|
+
}
|
|
6051
|
+
else {
|
|
6052
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
6053
|
+
}
|
|
6054
|
+
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
6055
|
+
// delivery is already in flight or queued. Without the lock the notice and
|
|
6056
|
+
// that delivery race into the same pane.
|
|
6057
|
+
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
|
|
6058
|
+
if (!told) {
|
|
6059
|
+
// Recording the new instructions here would mark the agent as told about
|
|
6060
|
+
// a notice it never received, and every later restart would then skip
|
|
6061
|
+
// the reload. Leave the snapshot behind and hand it to the deferred
|
|
6062
|
+
// notice, which fires on the next real message.
|
|
6063
|
+
this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
|
|
6064
|
+
this.pendingInstructionsNotice = true;
|
|
6065
|
+
return;
|
|
6066
|
+
}
|
|
6067
|
+
// Record the value the agent has now been told about so the next
|
|
6068
|
+
// unchanged restart skips the reload.
|
|
6069
|
+
try {
|
|
6070
|
+
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
6071
|
+
}
|
|
6072
|
+
catch { /* best effort */ }
|
|
6073
|
+
this.logger.debug("Warmup sent after idle");
|
|
6074
|
+
}
|
|
6075
|
+
catch { /* non-fatal */ }
|
|
6076
|
+
}
|
|
5273
6077
|
async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
|
|
5274
6078
|
const backendConfig = this.buildBackendConfig();
|
|
5275
6079
|
// Compare freshly-built instructions against the last value the agent was
|
|
@@ -5298,7 +6102,7 @@ export class Daemon extends EventEmitter {
|
|
|
5298
6102
|
// For backends that don't re-read instructions on resume (kiro/codex/
|
|
5299
6103
|
// gemini), also notify the agent on next message instead of forcing a new
|
|
5300
6104
|
// session. Resume is preserved so context isn't lost.
|
|
5301
|
-
if (!backendConfig.skipResume &&
|
|
6105
|
+
if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
|
|
5302
6106
|
if (prev) {
|
|
5303
6107
|
this.logger.info("Instructions changed — will notify agent on next message");
|
|
5304
6108
|
this.pendingInstructionsNotice = true;
|
|
@@ -5443,6 +6247,7 @@ export class Daemon extends EventEmitter {
|
|
|
5443
6247
|
await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
|
|
5444
6248
|
let cleanReadyPolls = 0;
|
|
5445
6249
|
let lastDialog = null;
|
|
6250
|
+
let lastTransient = null;
|
|
5446
6251
|
let captureFailures = 0;
|
|
5447
6252
|
let attempts = 0;
|
|
5448
6253
|
do {
|
|
@@ -5464,6 +6269,18 @@ export class Daemon extends EventEmitter {
|
|
|
5464
6269
|
continue;
|
|
5465
6270
|
}
|
|
5466
6271
|
try {
|
|
6272
|
+
// Some TUIs expose a real-looking input row before they accept Enter.
|
|
6273
|
+
// This phase clears by itself: never send a key, and let its structural
|
|
6274
|
+
// current-screen matcher outrank the otherwise-valid ready pattern.
|
|
6275
|
+
const transient = this.inputTransientInPane(pane);
|
|
6276
|
+
if (transient) {
|
|
6277
|
+
cleanReadyPolls = 0;
|
|
6278
|
+
lastDialog = null;
|
|
6279
|
+
lastTransient = transient;
|
|
6280
|
+
await sleep();
|
|
6281
|
+
continue;
|
|
6282
|
+
}
|
|
6283
|
+
lastTransient = null;
|
|
5467
6284
|
// Try each startup dialog pattern before checking ready state
|
|
5468
6285
|
let matched = false;
|
|
5469
6286
|
lastDialog = null;
|
|
@@ -5591,7 +6408,10 @@ export class Daemon extends EventEmitter {
|
|
|
5591
6408
|
await sleep();
|
|
5592
6409
|
} while (remaining() > 0);
|
|
5593
6410
|
// Budget exhausted. Never silently: say which way it went.
|
|
5594
|
-
if (
|
|
6411
|
+
if (lastTransient) {
|
|
6412
|
+
this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
|
|
6413
|
+
}
|
|
6414
|
+
else if (lastDialog) {
|
|
5595
6415
|
this.logger.warn({ description: lastDialog.description, attempts, budgetMs }, "Startup scan exhausted with a dialog still on screen — reporting the CLI alive; deliveries stay blocked until it is answered");
|
|
5596
6416
|
}
|
|
5597
6417
|
else {
|
|
@@ -5607,12 +6427,21 @@ export class Daemon extends EventEmitter {
|
|
|
5607
6427
|
updateLastChat(chatId, threadId, adapterId) {
|
|
5608
6428
|
if (!chatId)
|
|
5609
6429
|
return;
|
|
6430
|
+
const chatChanged = this.lastChatId !== chatId;
|
|
5610
6431
|
this.lastChatId = chatId;
|
|
5611
6432
|
// An unthreaded inbound must clear a previous topic rather than leaking it
|
|
5612
6433
|
// into the next reply target.
|
|
5613
6434
|
this.lastThreadId = threadId || undefined;
|
|
5614
6435
|
if (adapterId)
|
|
5615
6436
|
this.lastAdapterId = adapterId;
|
|
6437
|
+
else if (chatChanged) {
|
|
6438
|
+
// A NEW chat with no adapter named alongside it: the old adapter belonged
|
|
6439
|
+
// to the old chat, and keeping it asserts a pairing nobody supplied. That
|
|
6440
|
+
// is how one platform's chat id ends up being sent through another
|
|
6441
|
+
// platform's bot. Unknown is recoverable (a single-adapter fleet is
|
|
6442
|
+
// unambiguous anyway); a confidently wrong world is not.
|
|
6443
|
+
this.lastAdapterId = undefined;
|
|
6444
|
+
}
|
|
5616
6445
|
try {
|
|
5617
6446
|
writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
|
|
5618
6447
|
}
|