@songsid/agend 2.1.5-beta.8 → 2.1.5
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 +12 -0
- 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 +48 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -2
- package/dist/channel/adapters/discord.js +64 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +41 -2
- package/dist/channel/adapters/telegram.js +116 -20
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/factory.js +6 -1
- package/dist/channel/factory.js.map +1 -1
- package/dist/channel/types.d.ts +33 -1
- package/dist/classic-channel-manager.d.ts +23 -3
- package/dist/classic-channel-manager.js +35 -0
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/config-validator.js +23 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +143 -21
- package/dist/daemon.js +969 -185
- 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 +15 -1
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +185 -3
- package/dist/fleet-manager.js +782 -127
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +26 -2
- 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 +28 -2
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +31 -4
- package/dist/login-controller.js +52 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +29 -0
- package/dist/login-flows.js +48 -1
- package/dist/login-flows.js.map +1 -1
- package/dist/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.js +3 -21
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.d.ts +1 -1
- package/dist/settings-api.js +38 -6
- package/dist/settings-api.js.map +1 -1
- package/dist/topic-commands.d.ts +27 -2
- package/dist/topic-commands.js +37 -6
- 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/types.d.ts +7 -0
- package/dist/ui/dashboard.html +38 -10
- package/dist/ui/settings.html +93 -15
- package/dist/ui/view.html +35 -20
- 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/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"
|
|
@@ -377,15 +381,31 @@ export class PendingWorkTracker {
|
|
|
377
381
|
// An async pane poll can finish after a newer inbound. Do not let its stale
|
|
378
382
|
// observation clear work which had not arrived when the pane was captured.
|
|
379
383
|
if (now < this.lastInboundAt)
|
|
380
|
-
return;
|
|
384
|
+
return false;
|
|
381
385
|
this.lastIdleAt = now;
|
|
382
386
|
this.lastIdleOrder = ++this.sequence;
|
|
387
|
+
return true;
|
|
383
388
|
}
|
|
384
389
|
hasPendingWork() {
|
|
385
390
|
return this.lastInboundOrder > this.lastIdleOrder;
|
|
386
391
|
}
|
|
387
392
|
}
|
|
388
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
|
+
}
|
|
389
409
|
/** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
|
|
390
410
|
const BOTTOM_READY_POLL_MS = 250;
|
|
391
411
|
/** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
|
|
@@ -411,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
411
431
|
}
|
|
412
432
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
413
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;
|
|
414
437
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
415
438
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
416
439
|
const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
@@ -837,6 +860,14 @@ export class Daemon extends EventEmitter {
|
|
|
837
860
|
spawnSettled = null;
|
|
838
861
|
resolveSpawnSettled = null;
|
|
839
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;
|
|
840
871
|
skipResume = false;
|
|
841
872
|
startupAborted = false;
|
|
842
873
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
@@ -870,19 +901,14 @@ export class Daemon extends EventEmitter {
|
|
|
870
901
|
instanceStateMonitorActive = false;
|
|
871
902
|
sessionCheckpointWarningEmitted = false;
|
|
872
903
|
statePollInFlight = false;
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
turnHadInbound = false;
|
|
878
|
-
turnOutboundDelivered = false;
|
|
879
|
-
/** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
|
|
880
|
-
turnReplyDelivered = false;
|
|
881
|
-
turnCorrelationId;
|
|
882
|
-
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();
|
|
883
908
|
/** Prevent a visible stale XML fragment from being recovered on later turns. */
|
|
884
909
|
lastMalformedToolCallSignature;
|
|
885
910
|
proxyReplySeq = 0;
|
|
911
|
+
lastReplyDropWarningAt = 0;
|
|
886
912
|
autoPauseController;
|
|
887
913
|
pauseRequested = false;
|
|
888
914
|
/**
|
|
@@ -1059,6 +1085,33 @@ export class Daemon extends EventEmitter {
|
|
|
1059
1085
|
static errorPatternKey(ep) {
|
|
1060
1086
|
return `${ep.type}:${ep.pattern.source}`;
|
|
1061
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
|
+
}
|
|
1062
1115
|
clearErrorRecoveryGate() {
|
|
1063
1116
|
this.errorWaitingForRecovery = false;
|
|
1064
1117
|
this.errorDetectedAt = 0;
|
|
@@ -1899,12 +1952,8 @@ export class Daemon extends EventEmitter {
|
|
|
1899
1952
|
// --continue again and crash in the same way → loop. Clear the session id
|
|
1900
1953
|
// and skip resume so the next spawn starts fresh. (skipResume also stops
|
|
1901
1954
|
// saveSessionId below from resurrecting the id from statusline.json.)
|
|
1902
|
-
if (
|
|
1903
|
-
this.
|
|
1904
|
-
try {
|
|
1905
|
-
unlinkSync(join(this.instanceDir, "session-id"));
|
|
1906
|
-
}
|
|
1907
|
-
catch { /* may not exist */ }
|
|
1955
|
+
if (this.paneSaysNoConversation(lastOutput)) {
|
|
1956
|
+
this.setSessionAside("cli_reported_no_conversation");
|
|
1908
1957
|
this.skipResume = true;
|
|
1909
1958
|
}
|
|
1910
1959
|
// Append to crash history
|
|
@@ -2484,6 +2533,10 @@ export class Daemon extends EventEmitter {
|
|
|
2484
2533
|
}
|
|
2485
2534
|
async stop() {
|
|
2486
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;
|
|
2487
2540
|
this.freezeRuntimeMonitors();
|
|
2488
2541
|
this.pendingIpcRequests.clear();
|
|
2489
2542
|
if (this.adapter)
|
|
@@ -2777,7 +2830,7 @@ export class Daemon extends EventEmitter {
|
|
|
2777
2830
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
2778
2831
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
2779
2832
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
2780
|
-
this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2833
|
+
const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
|
|
2781
2834
|
// The turn is over. A transcript can end on a tool_use with no matching
|
|
2782
2835
|
// tool_result (interrupted, crashed, cancelled), which would otherwise leave
|
|
2783
2836
|
// the last tool pinned to the progress line for the rest of the session.
|
|
@@ -2785,7 +2838,8 @@ export class Daemon extends EventEmitter {
|
|
|
2785
2838
|
this.resetToolProgress();
|
|
2786
2839
|
// Must run before the mcpRestartPending branch below: the pane text is the
|
|
2787
2840
|
// only copy of the answer, and the revival restart is about to clear it.
|
|
2788
|
-
|
|
2841
|
+
if (acceptedIdle)
|
|
2842
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
2789
2843
|
}
|
|
2790
2844
|
if (snapshot.state !== previous) {
|
|
2791
2845
|
this.logger.info({
|
|
@@ -2836,13 +2890,17 @@ export class Daemon extends EventEmitter {
|
|
|
2836
2890
|
// a task result, and a stale one (sol's review of #515).
|
|
2837
2891
|
if (meta.from_instance || !meta.chat_id)
|
|
2838
2892
|
return;
|
|
2839
|
-
this.turnHadInbound = true;
|
|
2840
|
-
this.turnOutboundDelivered = false;
|
|
2841
|
-
this.turnReplyDelivered = false;
|
|
2842
|
-
this.turnCorrelationId = meta.correlation_id || undefined;
|
|
2843
2893
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
2844
2894
|
// is the agent's own output.
|
|
2845
|
-
|
|
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
|
+
});
|
|
2846
2904
|
}
|
|
2847
2905
|
/**
|
|
2848
2906
|
* Turn ended (busy→idle edge): if the MCP server is dead and none of the
|
|
@@ -2852,56 +2910,198 @@ export class Daemon extends EventEmitter {
|
|
|
2852
2910
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
2853
2911
|
*/
|
|
2854
2912
|
maybeProxyReplyOnTurnEnd(pane) {
|
|
2855
|
-
const
|
|
2856
|
-
|
|
2857
|
-
const replyDelivered = this.turnReplyDelivered;
|
|
2858
|
-
const correlationId = this.turnCorrelationId;
|
|
2859
|
-
const inboundMarker = this.turnInboundMarker;
|
|
2860
|
-
this.turnHadInbound = false;
|
|
2861
|
-
this.turnOutboundDelivered = false;
|
|
2862
|
-
this.turnReplyDelivered = false;
|
|
2863
|
-
this.turnCorrelationId = undefined;
|
|
2864
|
-
this.turnInboundMarker = undefined;
|
|
2865
|
-
if (!hadInbound || this.isPaused)
|
|
2913
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
2914
|
+
if (!turn || this.isPaused)
|
|
2866
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);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2867
2934
|
// Claude sometimes prints a broken XML tool call as plain text and returns
|
|
2868
2935
|
// idle without ever invoking `reply`. This is independent of MCP liveness:
|
|
2869
2936
|
// the model malformed the call before the server could receive it.
|
|
2870
|
-
if (
|
|
2871
|
-
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
|
|
2937
|
+
if (this.isClaudeCodeBackend() && pane) {
|
|
2938
|
+
const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
|
|
2872
2939
|
if (malformed) {
|
|
2873
2940
|
const stale = malformed.signature === this.lastMalformedToolCallSignature;
|
|
2874
2941
|
this.lastMalformedToolCallSignature = malformed.signature;
|
|
2875
2942
|
if (!stale) {
|
|
2876
|
-
|
|
2877
|
-
this.logger.warn({ correlationId, recovered }, recovered
|
|
2943
|
+
this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
|
|
2878
2944
|
? "Malformed Claude tool call detected — attempting to recover reply text"
|
|
2879
2945
|
: "Malformed Claude tool call detected — reply text could not be extracted");
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
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);
|
|
2883
2969
|
}
|
|
2884
2970
|
// Never let the broader dead-MCP fallback relay the XML/chrome too.
|
|
2885
2971
|
return;
|
|
2886
2972
|
}
|
|
2887
2973
|
}
|
|
2888
|
-
|
|
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);
|
|
2889
2981
|
return;
|
|
2890
|
-
|
|
2891
|
-
if (this.
|
|
2982
|
+
}
|
|
2983
|
+
if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
|
|
2984
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
2892
2985
|
return;
|
|
2893
|
-
|
|
2894
|
-
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");
|
|
2895
2992
|
return;
|
|
2896
|
-
|
|
2993
|
+
}
|
|
2994
|
+
this.startReplyRecovery(turn, "no_valid_call");
|
|
2897
2995
|
}
|
|
2898
2996
|
isClaudeCodeBackend() {
|
|
2899
2997
|
return this.runtimeIdentity?.backend === "claude-code"
|
|
2900
2998
|
|| this.config.backend === "claude-code"
|
|
2901
2999
|
|| this.backend?.binaryName === "claude";
|
|
2902
3000
|
}
|
|
3001
|
+
replyCompletionGuardEnabled() {
|
|
3002
|
+
return this.config.reply_completion_guard !== false
|
|
3003
|
+
&& this.backend?.replyCompletionGuard === true;
|
|
3004
|
+
}
|
|
3005
|
+
startReplyRecovery(turn, reason) {
|
|
3006
|
+
if (!this.turnReplyGuard.beginRecovery(turn.generation))
|
|
3007
|
+
return;
|
|
3008
|
+
this.emit("reply_drop_detected", {
|
|
3009
|
+
name: this.name,
|
|
3010
|
+
correlationId: turn.target.correlationId,
|
|
3011
|
+
generation: turn.generation,
|
|
3012
|
+
reason,
|
|
3013
|
+
recoveryStarted: true,
|
|
3014
|
+
});
|
|
3015
|
+
void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
|
|
3016
|
+
this.queueReplyRecoveryPrompt(turn);
|
|
3017
|
+
}
|
|
3018
|
+
reportReplyDropWithoutRetry(turn, reason) {
|
|
3019
|
+
this.emit("reply_drop_detected", {
|
|
3020
|
+
name: this.name,
|
|
3021
|
+
correlationId: turn.target.correlationId,
|
|
3022
|
+
generation: turn.generation,
|
|
3023
|
+
reason,
|
|
3024
|
+
recoveryStarted: false,
|
|
3025
|
+
});
|
|
3026
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
|
|
3027
|
+
}
|
|
3028
|
+
queueReplyRecoveryPrompt(turn) {
|
|
3029
|
+
const deliveryEpoch = this.deliveryEpoch;
|
|
3030
|
+
this.pasteQueueDepth++;
|
|
3031
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3032
|
+
try {
|
|
3033
|
+
const current = this.turnReplyGuard.snapshot();
|
|
3034
|
+
if (!current || current.generation !== turn.generation || current.phase !== "recovering")
|
|
3035
|
+
return;
|
|
3036
|
+
if (current.replyDelivered) {
|
|
3037
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3038
|
+
this.emit("reply_drop_recovered", {
|
|
3039
|
+
name: this.name,
|
|
3040
|
+
correlationId: current.target.correlationId,
|
|
3041
|
+
generation: current.generation,
|
|
3042
|
+
});
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
|
|
3046
|
+
deliveryEpoch,
|
|
3047
|
+
});
|
|
3048
|
+
if (!delivered) {
|
|
3049
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3050
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
finally {
|
|
3054
|
+
this.pasteQueueDepth--;
|
|
3055
|
+
}
|
|
3056
|
+
}).catch(err => {
|
|
3057
|
+
this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
|
|
3058
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3059
|
+
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
queueMalformedReplyRecovery(turn, text) {
|
|
3063
|
+
// Serialize the delivery decision ahead of any new pane input. If direct
|
|
3064
|
+
// delivery is positively acknowledged, there is no recovery turn. If its
|
|
3065
|
+
// outcome is unknown, do not ask the model to duplicate it.
|
|
3066
|
+
const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
|
|
3067
|
+
this.pasteQueueDepth++;
|
|
3068
|
+
this.pasteLock = this.pasteLock.then(async () => {
|
|
3069
|
+
try {
|
|
3070
|
+
const delivered = await delivery;
|
|
3071
|
+
this.emit("malformed_tool_call", {
|
|
3072
|
+
name: this.name,
|
|
3073
|
+
correlationId: turn.target.correlationId,
|
|
3074
|
+
recovered: delivered,
|
|
3075
|
+
});
|
|
3076
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3077
|
+
if (!delivered)
|
|
3078
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3079
|
+
}
|
|
3080
|
+
finally {
|
|
3081
|
+
this.pasteQueueDepth--;
|
|
3082
|
+
}
|
|
3083
|
+
}).catch(err => {
|
|
3084
|
+
this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
|
|
3085
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
3086
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
|
|
3090
|
+
this.emit("reply_drop_unrecovered", {
|
|
3091
|
+
name: this.name,
|
|
3092
|
+
correlationId: turn.target.correlationId,
|
|
3093
|
+
generation: turn.generation,
|
|
3094
|
+
reason,
|
|
3095
|
+
});
|
|
3096
|
+
const now = Date.now();
|
|
3097
|
+
if (this.lastReplyDropWarningAt !== 0
|
|
3098
|
+
&& now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
|
|
3099
|
+
return;
|
|
3100
|
+
this.lastReplyDropWarningAt = now;
|
|
3101
|
+
void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
|
|
3102
|
+
}
|
|
2903
3103
|
/** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
|
|
2904
|
-
async sendProxyReply(pane,
|
|
3104
|
+
async sendProxyReply(pane, target) {
|
|
2905
3105
|
try {
|
|
2906
3106
|
// The idle capture normally hands its pane in; fall back only when the
|
|
2907
3107
|
// edge came from a path without one.
|
|
@@ -2909,74 +3109,110 @@ export class Daemon extends EventEmitter {
|
|
|
2909
3109
|
pane = await this.tmux?.capturePane();
|
|
2910
3110
|
if (!pane)
|
|
2911
3111
|
return;
|
|
2912
|
-
const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
3112
|
+
const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
|
|
2913
3113
|
if (!text) {
|
|
2914
3114
|
this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
|
|
2915
3115
|
return;
|
|
2916
3116
|
}
|
|
2917
3117
|
let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
|
|
2918
|
-
if (correlationId)
|
|
2919
|
-
body += `\n\n(correlation_id: ${correlationId})`;
|
|
2920
|
-
this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply —
|
|
2921
|
-
this.
|
|
2922
|
-
|
|
3118
|
+
if (target.correlationId)
|
|
3119
|
+
body += `\n\n(correlation_id: ${target.correlationId})`;
|
|
3120
|
+
this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
|
|
3121
|
+
const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
|
|
3122
|
+
if (delivered)
|
|
3123
|
+
this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
|
|
2923
3124
|
}
|
|
2924
3125
|
catch (err) {
|
|
2925
3126
|
this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
|
|
2926
3127
|
}
|
|
2927
3128
|
}
|
|
2928
|
-
/** Send a narrowly extracted #648 reply; operator notification is a separate event. */
|
|
2929
|
-
sendRecoveredMalformedReply(text, correlationId) {
|
|
2930
|
-
try {
|
|
2931
|
-
this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
|
|
2932
|
-
}
|
|
2933
|
-
catch (err) {
|
|
2934
|
-
this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
3129
|
/** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
|
|
2938
|
-
deliverDaemonReply(body, requestPrefix, logLabel) {
|
|
3130
|
+
deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
|
|
2939
3131
|
const args = { text: body };
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
3132
|
+
const chatId = target?.chatId ?? this.lastChatId;
|
|
3133
|
+
const threadId = target?.threadId ?? this.lastThreadId;
|
|
3134
|
+
const adapterId = target?.adapterId ?? this.lastAdapterId;
|
|
3135
|
+
if (chatId) {
|
|
3136
|
+
args.chat_id = chatId;
|
|
3137
|
+
if (threadId)
|
|
3138
|
+
args.thread_id = threadId;
|
|
2944
3139
|
}
|
|
2945
3140
|
const adapters = this.messageBus.getAllAdapters();
|
|
2946
3141
|
if (adapters.length > 0) {
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
3142
|
+
// With one adapter there is nothing to choose and adapters[0] is exact.
|
|
3143
|
+
// With several, "no adapter named" is a routing failure, not a default:
|
|
3144
|
+
// picking the first sends one world's chat id through another world's bot.
|
|
3145
|
+
const adapter = adapterId
|
|
3146
|
+
? this.messageBus.getAdapter(adapterId)
|
|
3147
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
3148
|
+
if (!adapter) {
|
|
3149
|
+
this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
|
|
3150
|
+
return Promise.resolve(false);
|
|
3151
|
+
}
|
|
3152
|
+
return new Promise(resolve => {
|
|
3153
|
+
let settled = false;
|
|
3154
|
+
const finish = (delivered, error) => {
|
|
3155
|
+
if (settled)
|
|
3156
|
+
return;
|
|
3157
|
+
settled = true;
|
|
3158
|
+
clearTimeout(timeout);
|
|
3159
|
+
if (delivered)
|
|
3160
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3161
|
+
else
|
|
3162
|
+
this.logger.error({ error }, `${logLabel} failed`);
|
|
3163
|
+
resolve(delivered);
|
|
3164
|
+
};
|
|
3165
|
+
const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
|
|
3166
|
+
timeout.unref?.();
|
|
3167
|
+
try {
|
|
3168
|
+
if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
|
|
3169
|
+
finish(false, "reply route unavailable");
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
catch {
|
|
3173
|
+
finish(false, "adapter reply threw before returning a promise");
|
|
3174
|
+
}
|
|
2952
3175
|
});
|
|
2953
|
-
return;
|
|
2954
3176
|
}
|
|
2955
3177
|
if (!this.ipcServer) {
|
|
2956
3178
|
this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
|
|
2957
|
-
return;
|
|
3179
|
+
return Promise.resolve(false);
|
|
2958
3180
|
}
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3181
|
+
return new Promise(resolve => {
|
|
3182
|
+
const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
|
|
3183
|
+
const timeout = setTimeout(() => {
|
|
3184
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3185
|
+
this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
|
|
3186
|
+
resolve(false);
|
|
3187
|
+
}, daemonBudgetMs("reply"));
|
|
3188
|
+
timeout.unref?.();
|
|
3189
|
+
// Register before broadcast: an in-process/fast adapter can answer in the
|
|
3190
|
+
// same tick, and a waiter installed afterwards would miss that response.
|
|
3191
|
+
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
3192
|
+
clearTimeout(timeout);
|
|
3193
|
+
const delivered = !respMsg.error && respMsg.result != null;
|
|
3194
|
+
if (!delivered)
|
|
3195
|
+
this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
|
|
3196
|
+
else
|
|
3197
|
+
this.logger.info(`${logLabel} delivered`);
|
|
3198
|
+
resolve(delivered);
|
|
3199
|
+
});
|
|
3200
|
+
try {
|
|
3201
|
+
this.ipcServer.broadcast({
|
|
3202
|
+
type: "fleet_outbound",
|
|
3203
|
+
tool: "reply",
|
|
3204
|
+
args,
|
|
3205
|
+
fleetRequestId: fleetReqId,
|
|
3206
|
+
adapterId,
|
|
3207
|
+
statusOnly,
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
catch {
|
|
3211
|
+
clearTimeout(timeout);
|
|
3212
|
+
this.pendingIpcRequests.delete(fleetReqId);
|
|
3213
|
+
this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
|
|
3214
|
+
resolve(false);
|
|
3215
|
+
}
|
|
2980
3216
|
});
|
|
2981
3217
|
}
|
|
2982
3218
|
clearInstanceStateIdleTimer() {
|
|
@@ -3323,6 +3559,12 @@ export class Daemon extends EventEmitter {
|
|
|
3323
3559
|
if (update.tool_progress === null || ["off", "standard", "verbose"].includes(String(update.tool_progress))) {
|
|
3324
3560
|
this.updateToolProgress(update.tool_progress === null ? undefined : update.tool_progress);
|
|
3325
3561
|
}
|
|
3562
|
+
if (typeof update.reply_completion_guard === "boolean") {
|
|
3563
|
+
this.config.reply_completion_guard = update.reply_completion_guard;
|
|
3564
|
+
}
|
|
3565
|
+
else if (update.reply_completion_guard === null) {
|
|
3566
|
+
delete this.config.reply_completion_guard;
|
|
3567
|
+
}
|
|
3326
3568
|
if (typeof update.mcp_proxy_reply === "boolean")
|
|
3327
3569
|
this.config.mcp_proxy_reply = update.mcp_proxy_reply;
|
|
3328
3570
|
else if (update.mcp_proxy_reply === null)
|
|
@@ -3543,7 +3785,7 @@ export class Daemon extends EventEmitter {
|
|
|
3543
3785
|
await this.wake();
|
|
3544
3786
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3545
3787
|
return;
|
|
3546
|
-
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
|
|
3788
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
3547
3789
|
this.markTurnStarted(meta, formatted);
|
|
3548
3790
|
}
|
|
3549
3791
|
else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
@@ -3581,7 +3823,7 @@ export class Daemon extends EventEmitter {
|
|
|
3581
3823
|
await this.wake();
|
|
3582
3824
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3583
3825
|
return;
|
|
3584
|
-
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch })) {
|
|
3826
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
3585
3827
|
this.markTurnStarted(meta, formatted);
|
|
3586
3828
|
}
|
|
3587
3829
|
}).catch(err => {
|
|
@@ -3633,8 +3875,7 @@ export class Daemon extends EventEmitter {
|
|
|
3633
3875
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
3634
3876
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3635
3877
|
return;
|
|
3636
|
-
|
|
3637
|
-
this.markTurnStarted(meta, rawText);
|
|
3878
|
+
await this.deliverMessage(rawText, undefined, { deliveryEpoch });
|
|
3638
3879
|
}).catch(err => {
|
|
3639
3880
|
this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
|
|
3640
3881
|
});
|
|
@@ -3677,7 +3918,7 @@ export class Daemon extends EventEmitter {
|
|
|
3677
3918
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
3678
3919
|
// the previous turn's tool list.
|
|
3679
3920
|
this.resetToolProgress();
|
|
3680
|
-
if (await this.deliverMessage(formatted, status, { deliveryEpoch })) {
|
|
3921
|
+
if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id })) {
|
|
3681
3922
|
this.markTurnStarted(meta, formatted);
|
|
3682
3923
|
}
|
|
3683
3924
|
else if (meta.from_instance && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
@@ -3826,16 +4067,24 @@ export class Daemon extends EventEmitter {
|
|
|
3826
4067
|
}
|
|
3827
4068
|
}
|
|
3828
4069
|
}
|
|
3829
|
-
// Everything above is *waiting*; everything below
|
|
3830
|
-
//
|
|
3831
|
-
//
|
|
3832
|
-
//
|
|
4070
|
+
// Everything above is *waiting*; everything below owns the write lock.
|
|
4071
|
+
// Long idle/dialog waits stay outside because the runtime dismisser may be
|
|
4072
|
+
// what makes progress. The one under-lock wait below is explicitly passive:
|
|
4073
|
+
// it clears by itself and closes a repaint race immediately before paste.
|
|
3833
4074
|
for (let round = 0;; round++) {
|
|
3834
4075
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
3835
4076
|
if (cancelled())
|
|
3836
4077
|
return false;
|
|
3837
4078
|
if (this.refuseFatalStartupDelivery(status))
|
|
3838
4079
|
return false;
|
|
4080
|
+
// A passive startup phase may paint after the outer readiness probe.
|
|
4081
|
+
// It clears without input, so waiting under the pane lock cannot starve
|
|
4082
|
+
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4083
|
+
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4084
|
+
if (status)
|
|
4085
|
+
this.emit("message_failed", status); // ❌
|
|
4086
|
+
return false;
|
|
4087
|
+
}
|
|
3839
4088
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
3840
4089
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
3841
4090
|
// this critical section. Re-probe here, right before the write; a
|
|
@@ -3845,7 +4094,7 @@ export class Daemon extends EventEmitter {
|
|
|
3845
4094
|
if (probe.state !== "clear")
|
|
3846
4095
|
return "dialog";
|
|
3847
4096
|
}
|
|
3848
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
|
|
4097
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
|
|
3849
4098
|
});
|
|
3850
4099
|
if (outcome !== "dialog")
|
|
3851
4100
|
return outcome;
|
|
@@ -3879,6 +4128,75 @@ export class Daemon extends EventEmitter {
|
|
|
3879
4128
|
deliveryBlockingDialogs() {
|
|
3880
4129
|
return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
|
|
3881
4130
|
}
|
|
4131
|
+
/** Passive input-unavailable phases relevant to the current spawn only. */
|
|
4132
|
+
guardedInputTransients() {
|
|
4133
|
+
if (this.inputTransientGuardGeneration === null
|
|
4134
|
+
|| this.inputTransientGuardGeneration !== this.spawnGeneration)
|
|
4135
|
+
return [];
|
|
4136
|
+
return this.backend?.getInputUnavailableTransients?.() ?? [];
|
|
4137
|
+
}
|
|
4138
|
+
/** Match a passive transient against the current interactive screen. */
|
|
4139
|
+
inputTransientInPane(pane) {
|
|
4140
|
+
for (const transient of this.guardedInputTransients()) {
|
|
4141
|
+
// `pattern` is only a cheap pre-filter. `isActive` is deliberately the
|
|
4142
|
+
// authority because users and agents routinely quote diagnostic text.
|
|
4143
|
+
transient.pattern.lastIndex = 0;
|
|
4144
|
+
if (!transient.pattern.test(pane))
|
|
4145
|
+
continue;
|
|
4146
|
+
if (transient.isActive(pane))
|
|
4147
|
+
return transient;
|
|
4148
|
+
}
|
|
4149
|
+
return null;
|
|
4150
|
+
}
|
|
4151
|
+
/** Capture failure is unknown, never evidence that input is available. */
|
|
4152
|
+
async probeInputTransient() {
|
|
4153
|
+
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4154
|
+
return { state: "clear" };
|
|
4155
|
+
try {
|
|
4156
|
+
const pane = await this.tmux.capturePane();
|
|
4157
|
+
const transient = this.inputTransientInPane(pane);
|
|
4158
|
+
return transient ? { state: "active", transient } : { state: "clear" };
|
|
4159
|
+
}
|
|
4160
|
+
catch (err) {
|
|
4161
|
+
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
4162
|
+
return { state: "unknown" };
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
/**
|
|
4166
|
+
* Wait under the pane lock for a passive transient to disappear. Unlike a
|
|
4167
|
+
* dialog, this state clears by itself, so the wait cannot starve a keypress
|
|
4168
|
+
* dismisser. Stop or a replacement spawn invalidates the captured identity.
|
|
4169
|
+
*/
|
|
4170
|
+
async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
|
|
4171
|
+
const generation = this.inputTransientGuardGeneration;
|
|
4172
|
+
if (generation === null || generation !== this.spawnGeneration)
|
|
4173
|
+
return true;
|
|
4174
|
+
const deadline = Date.now() + timeoutMs;
|
|
4175
|
+
let observedDescription = null;
|
|
4176
|
+
for (;;) {
|
|
4177
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4178
|
+
this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
|
|
4179
|
+
return false;
|
|
4180
|
+
}
|
|
4181
|
+
const probe = await this.probeInputTransient();
|
|
4182
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4183
|
+
this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
|
|
4184
|
+
return false;
|
|
4185
|
+
}
|
|
4186
|
+
if (probe.state === "clear")
|
|
4187
|
+
return true;
|
|
4188
|
+
if (probe.state === "active" && observedDescription !== probe.transient.description) {
|
|
4189
|
+
observedDescription = probe.transient.description;
|
|
4190
|
+
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4191
|
+
}
|
|
4192
|
+
const remaining = deadline - Date.now();
|
|
4193
|
+
if (remaining <= 0) {
|
|
4194
|
+
this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
|
|
4195
|
+
return false;
|
|
4196
|
+
}
|
|
4197
|
+
await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
3882
4200
|
static dialogKey(dialog) {
|
|
3883
4201
|
return `${dialog.pattern.source}/${dialog.pattern.flags}`;
|
|
3884
4202
|
}
|
|
@@ -3989,10 +4307,10 @@ export class Daemon extends EventEmitter {
|
|
|
3989
4307
|
}
|
|
3990
4308
|
/**
|
|
3991
4309
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
3992
|
-
* input queue or steered (after its own dialog probe); "dialog"
|
|
3993
|
-
* must never be — a paste + Enter into a dialog answers it
|
|
3994
|
-
*
|
|
3995
|
-
*
|
|
4310
|
+
* input queue or steered (after its own dialog probe); "dialog", "transient"
|
|
4311
|
+
* and "unknown" must never be — a paste + Enter into a dialog answers it
|
|
4312
|
+
* with its default, while a startup transient swallows it and an unreadable
|
|
4313
|
+
* pane proves nothing. The silence gate comes first and costs no capture.
|
|
3996
4314
|
*/
|
|
3997
4315
|
async paneReadinessForDelivery(windowId) {
|
|
3998
4316
|
if (!this.isPaneIdleForDelivery(windowId))
|
|
@@ -4000,6 +4318,11 @@ export class Daemon extends EventEmitter {
|
|
|
4000
4318
|
const probe = await this.probeBlockingDialog();
|
|
4001
4319
|
if (probe.state !== "clear")
|
|
4002
4320
|
return probe.state;
|
|
4321
|
+
const transient = await this.probeInputTransient();
|
|
4322
|
+
if (transient.state === "active")
|
|
4323
|
+
return "transient";
|
|
4324
|
+
if (transient.state === "unknown")
|
|
4325
|
+
return "unknown";
|
|
4003
4326
|
if (this.backend?.dropsEnterWhileBusy?.() !== true)
|
|
4004
4327
|
return "ready";
|
|
4005
4328
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
@@ -4046,6 +4369,7 @@ export class Daemon extends EventEmitter {
|
|
|
4046
4369
|
const deadline = Date.now() + timeoutMs;
|
|
4047
4370
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4048
4371
|
let unknownStreak = 0;
|
|
4372
|
+
let transientDeadline = 0;
|
|
4049
4373
|
for (;;) {
|
|
4050
4374
|
const remaining = deadline - Date.now();
|
|
4051
4375
|
if (remaining <= 0)
|
|
@@ -4055,6 +4379,26 @@ export class Daemon extends EventEmitter {
|
|
|
4055
4379
|
return false;
|
|
4056
4380
|
// The idle wait just resolved, so re-reading the silence gate would only
|
|
4057
4381
|
// add a stale-cache race; ask the pane the remaining questions directly.
|
|
4382
|
+
const transient = await this.probeInputTransient();
|
|
4383
|
+
if (transient.state === "active") {
|
|
4384
|
+
unknownStreak = 0;
|
|
4385
|
+
transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4386
|
+
if (Date.now() >= transientDeadline) {
|
|
4387
|
+
this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4388
|
+
return false;
|
|
4389
|
+
}
|
|
4390
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4391
|
+
continue;
|
|
4392
|
+
}
|
|
4393
|
+
transientDeadline = 0;
|
|
4394
|
+
if (transient.state === "unknown") {
|
|
4395
|
+
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4396
|
+
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
4397
|
+
return false;
|
|
4398
|
+
}
|
|
4399
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4400
|
+
continue;
|
|
4401
|
+
}
|
|
4058
4402
|
if (bottomGated) {
|
|
4059
4403
|
if (await this.isPaneReadyForDelivery(windowId))
|
|
4060
4404
|
return true;
|
|
@@ -4090,15 +4434,16 @@ export class Daemon extends EventEmitter {
|
|
|
4090
4434
|
* must fail rather than paste on top of the stranded text).
|
|
4091
4435
|
*/
|
|
4092
4436
|
async submitStrandedInputIfAny(windowId) {
|
|
4093
|
-
if (this.
|
|
4437
|
+
if (!this.tmux || !this.backend)
|
|
4094
4438
|
return "idle";
|
|
4095
4439
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
4096
4440
|
if (!prompt)
|
|
4097
4441
|
return "idle";
|
|
4442
|
+
const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
|
|
4098
4443
|
// Pre-check outside the lock so the common case (verified clear) never
|
|
4099
4444
|
// contends with the dialog dismisser. Only a POSITIVE "clear" skips the
|
|
4100
4445
|
// lock; an unreadable pane is re-examined under it, not waved through.
|
|
4101
|
-
const pre = await this.strandedInputState(prompt);
|
|
4446
|
+
const pre = await this.strandedInputState(prompt, requireBottomReady);
|
|
4102
4447
|
if (pre === "clear")
|
|
4103
4448
|
return "idle";
|
|
4104
4449
|
if (pre === "busy")
|
|
@@ -4114,7 +4459,7 @@ export class Daemon extends EventEmitter {
|
|
|
4114
4459
|
// no-op at an empty prompt, but into a modal it is a confirmation.
|
|
4115
4460
|
if (this.fatalStartupBlocked)
|
|
4116
4461
|
return "busy";
|
|
4117
|
-
const state = await this.strandedInputState(prompt);
|
|
4462
|
+
const state = await this.strandedInputState(prompt, requireBottomReady);
|
|
4118
4463
|
if (state === "clear")
|
|
4119
4464
|
return "idle";
|
|
4120
4465
|
if (state !== "stranded")
|
|
@@ -4124,12 +4469,12 @@ export class Daemon extends EventEmitter {
|
|
|
4124
4469
|
});
|
|
4125
4470
|
}
|
|
4126
4471
|
/**
|
|
4127
|
-
* "stranded": an AgEnD message sits in the input row; "clear":
|
|
4128
|
-
*
|
|
4129
|
-
*
|
|
4130
|
-
* read. Only "clear"
|
|
4472
|
+
* "stranded": an AgEnD message sits in the input row; "clear": no strand is
|
|
4473
|
+
* visible under the backend's readiness policy; "busy": generating, or (for
|
|
4474
|
+
* a bottom-gated backend) the prompt is not at the bottom; "unknown": the
|
|
4475
|
+
* pane could not be read. Only "clear" permits the next paste.
|
|
4131
4476
|
*/
|
|
4132
|
-
async strandedInputState(prompt) {
|
|
4477
|
+
async strandedInputState(prompt, requireBottomReady) {
|
|
4133
4478
|
let pane;
|
|
4134
4479
|
try {
|
|
4135
4480
|
pane = await this.tmux.capturePane();
|
|
@@ -4137,11 +4482,16 @@ export class Daemon extends EventEmitter {
|
|
|
4137
4482
|
catch {
|
|
4138
4483
|
return "unknown";
|
|
4139
4484
|
}
|
|
4485
|
+
if (this.inputTransientInPane(pane))
|
|
4486
|
+
return "busy";
|
|
4140
4487
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
4141
4488
|
return "busy";
|
|
4142
4489
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
4143
4490
|
return "stranded";
|
|
4144
|
-
|
|
4491
|
+
// Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
|
|
4492
|
+
// does not: this preflight runs only after its ordinary silence gate and is
|
|
4493
|
+
// enabled solely to recover a positively identified old AgEnD strand.
|
|
4494
|
+
return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
|
|
4145
4495
|
}
|
|
4146
4496
|
/**
|
|
4147
4497
|
* F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
|
|
@@ -4245,6 +4595,8 @@ export class Daemon extends EventEmitter {
|
|
|
4245
4595
|
* an invariant maintained by comments.
|
|
4246
4596
|
*/
|
|
4247
4597
|
async sendDeliveryEnter(phase) {
|
|
4598
|
+
if (!(await this.waitForInputTransientToClear(phase)))
|
|
4599
|
+
return false;
|
|
4248
4600
|
const sent = await this.tmux.sendSpecialKey("Enter");
|
|
4249
4601
|
if (!sent) {
|
|
4250
4602
|
this.logger.error({
|
|
@@ -4254,13 +4606,17 @@ export class Daemon extends EventEmitter {
|
|
|
4254
4606
|
}
|
|
4255
4607
|
return sent;
|
|
4256
4608
|
}
|
|
4257
|
-
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
|
|
4609
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId) {
|
|
4610
|
+
const signature = this.submissionSignature(formatted, submissionId);
|
|
4258
4611
|
let windowId = initialWindowId;
|
|
4259
4612
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
4260
4613
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
4261
4614
|
const maxAttempts = 3;
|
|
4262
4615
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
4263
4616
|
const pasteStartedAt = Date.now();
|
|
4617
|
+
// Read the pane BEFORE writing to it, so the submission check can require
|
|
4618
|
+
// evidence this paste ADDED rather than evidence that was already there.
|
|
4619
|
+
const pasteBaseline = await this.capturePaneEvidence(signature);
|
|
4264
4620
|
const pasted = await this.tmux.pasteBuffer(formatted);
|
|
4265
4621
|
if (!pasted) {
|
|
4266
4622
|
const tmuxError = this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure";
|
|
@@ -4342,16 +4698,73 @@ export class Daemon extends EventEmitter {
|
|
|
4342
4698
|
// can silently swallow it). Idle submissions keep the swallowed-Enter path.
|
|
4343
4699
|
if (handingOffToNativeQueue) {
|
|
4344
4700
|
await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
|
|
4345
|
-
|
|
4701
|
+
const proof = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4702
|
+
if (proof === "submitted") {
|
|
4346
4703
|
if (status)
|
|
4347
4704
|
this.emit("message_confirmed", status); // ✅ native queue accepted
|
|
4348
4705
|
return true;
|
|
4349
4706
|
}
|
|
4350
|
-
|
|
4351
|
-
|
|
4707
|
+
if (proof === "unverifiable") {
|
|
4708
|
+
// Our text is on the pane but cannot be shown to have left the input
|
|
4709
|
+
// row — either the backend exposes no input row (only /steer reaches
|
|
4710
|
+
// this path on such a backend) or the pre-paste pane was unreadable.
|
|
4711
|
+
// Re-pasting on that would deliver the message twice, so accept as
|
|
4712
|
+
// this path always has, and record that it is unproven.
|
|
4713
|
+
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
4714
|
+
if (status)
|
|
4715
|
+
this.emit("message_confirmed", status); // ✅ (best-effort)
|
|
4716
|
+
return true;
|
|
4717
|
+
}
|
|
4718
|
+
// Not submitted (or not provably submitted): fall back once to the
|
|
4719
|
+
// normal idle-gated path, which is the only one with a full
|
|
4720
|
+
// confirmation ladder. "stranded" is the case that used to be a silent
|
|
4721
|
+
// ✅ — the text was visible, so the old check called it delivered.
|
|
4722
|
+
this.logger.warn({ proof }, "Native-queue paste not confirmed as submitted — retrying via idle-gated delivery");
|
|
4352
4723
|
if (windowId && this.controlClient) {
|
|
4353
4724
|
await this.controlClient.waitUntilIdle(windowId);
|
|
4354
4725
|
}
|
|
4726
|
+
// Re-read before doing anything: the CLI had a whole turn to flush its
|
|
4727
|
+
// queue while we waited, and the right recovery differs per outcome.
|
|
4728
|
+
// Re-pasting a payload that is STILL in the input row appends it to
|
|
4729
|
+
// itself — paste-buffer writes at the cursor — and the next Enter then
|
|
4730
|
+
// submits the message twice over. That is the trap this fix would walk
|
|
4731
|
+
// into if it treated "not confirmed" as one state.
|
|
4732
|
+
const settled = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4733
|
+
if (settled === "submitted") {
|
|
4734
|
+
this.logger.info("Native-queue message submitted itself while we waited for idle");
|
|
4735
|
+
if (status)
|
|
4736
|
+
this.emit("message_confirmed", status); // ✅
|
|
4737
|
+
return true;
|
|
4738
|
+
}
|
|
4739
|
+
if (settled === "stranded") {
|
|
4740
|
+
// The text is already there in full: it needs submitting, not sending
|
|
4741
|
+
// again. The prompt is back now, so an Enter can land.
|
|
4742
|
+
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
4743
|
+
const strandedAt = Date.now();
|
|
4744
|
+
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
4745
|
+
if (status)
|
|
4746
|
+
this.emit("message_failed", status); // ❌
|
|
4747
|
+
return false;
|
|
4748
|
+
}
|
|
4749
|
+
const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4750
|
+
if (afterEnter === "submitted") {
|
|
4751
|
+
if (status)
|
|
4752
|
+
this.emit("message_confirmed", status); // ✅
|
|
4753
|
+
return true;
|
|
4754
|
+
}
|
|
4755
|
+
// Deliberately NOT "or the pane went busy". This branch runs because
|
|
4756
|
+
// the text was still in the input row; if the recovery Enter was
|
|
4757
|
+
// swallowed too, the pane is busy with something else — a late redraw,
|
|
4758
|
+
// another turn's output — and treating that as proof re-confirms a
|
|
4759
|
+
// message nobody submitted. Output is corroboration; text sitting in
|
|
4760
|
+
// the input row is disqualifying, and disqualifying evidence wins.
|
|
4761
|
+
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
4762
|
+
if (status)
|
|
4763
|
+
this.emit("message_failed", status); // ❌
|
|
4764
|
+
return false;
|
|
4765
|
+
}
|
|
4766
|
+
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
4767
|
+
// so pasting it again cannot duplicate anything.
|
|
4355
4768
|
const repasted = await this.tmux.pasteBuffer(formatted);
|
|
4356
4769
|
if (!repasted) {
|
|
4357
4770
|
this.logger.error({
|
|
@@ -4369,24 +4782,13 @@ export class Daemon extends EventEmitter {
|
|
|
4369
4782
|
return false;
|
|
4370
4783
|
}
|
|
4371
4784
|
if (windowId && this.controlClient) {
|
|
4372
|
-
|
|
4373
|
-
if (!becameBusy) {
|
|
4374
|
-
this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
|
|
4375
|
-
const retry2At = Date.now();
|
|
4376
|
-
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
|
|
4377
|
-
if (status)
|
|
4378
|
-
this.emit("message_failed", status); // ❌
|
|
4379
|
-
return false;
|
|
4380
|
-
}
|
|
4381
|
-
becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
|
|
4382
|
-
}
|
|
4383
|
-
if (becameBusy) {
|
|
4785
|
+
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
4384
4786
|
if (status)
|
|
4385
4787
|
this.emit("message_confirmed", status); // ✅
|
|
4386
4788
|
return true;
|
|
4387
4789
|
}
|
|
4388
4790
|
}
|
|
4389
|
-
else if (await this.
|
|
4791
|
+
else if (await this.confirmSubmitted(signature, pasteBaseline) === "submitted") {
|
|
4390
4792
|
if (status)
|
|
4391
4793
|
this.emit("message_confirmed", status); // ✅
|
|
4392
4794
|
return true;
|
|
@@ -4426,17 +4828,17 @@ export class Daemon extends EventEmitter {
|
|
|
4426
4828
|
}
|
|
4427
4829
|
}
|
|
4428
4830
|
else if (windowId && this.controlClient) {
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4831
|
+
// An idle delivery used to be judged by output alone: anything the pane
|
|
4832
|
+
// printed after Enter counted as "submitted". Waking a paused instance
|
|
4833
|
+
// breaks that — the CLI's final redraw arrives right as we press Enter,
|
|
4834
|
+
// so a swallowed Enter is confirmed by the redraw it was lost to, and
|
|
4835
|
+
// the message is never in a turn (#745 left this path on the old rule).
|
|
4836
|
+
//
|
|
4837
|
+
// Where the input row can be read, it decides: text still sitting there
|
|
4838
|
+
// is disqualifying and no amount of output may override it. Where it
|
|
4839
|
+
// cannot (a backend with no prompt pattern), the output signal remains
|
|
4840
|
+
// exactly as before — this must not regress backends we cannot read.
|
|
4841
|
+
const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
|
|
4440
4842
|
if (becameBusy) {
|
|
4441
4843
|
if (status)
|
|
4442
4844
|
this.emit("message_confirmed", status); // ✅
|
|
@@ -4467,31 +4869,260 @@ export class Daemon extends EventEmitter {
|
|
|
4467
4869
|
return false;
|
|
4468
4870
|
}
|
|
4469
4871
|
/**
|
|
4470
|
-
*
|
|
4471
|
-
*
|
|
4472
|
-
*
|
|
4872
|
+
* The single place a pasted message is judged submitted, shared by the
|
|
4873
|
+
* native-queue handoff, the idle-gated redelivery and the system pastes.
|
|
4874
|
+
*
|
|
4875
|
+
* It replaces three divergent weak checks, the weakest of which accepted "the
|
|
4876
|
+
* pasted text is visible somewhere in the pane". That one was satisfied BY
|
|
4877
|
+
* the failure it was meant to catch: text stranded in the input row is on
|
|
4878
|
+
* screen, so a message nobody submitted was confirmed ✅ with no warning
|
|
4879
|
+
* anywhere. Evidence is weighed in order of what it can prove, and the
|
|
4880
|
+
* disqualifying evidence is checked first.
|
|
4473
4881
|
*/
|
|
4474
|
-
|
|
4475
|
-
|
|
4882
|
+
/**
|
|
4883
|
+
* Whether this backend exposes an input row we can actually read, i.e. whether
|
|
4884
|
+
* confirmSubmitted can ever return a verdict stronger than a guess.
|
|
4885
|
+
*
|
|
4886
|
+
* This is a property of the BACKEND, not of one delivery's verdict. Keying the
|
|
4887
|
+
* output-signal fallback off the verdict instead let codex — which can be read
|
|
4888
|
+
* — fall back to "the pane printed something" whenever a paste left no trace,
|
|
4889
|
+
* which is the same false confirmation one layer down.
|
|
4890
|
+
*/
|
|
4891
|
+
canProveSubmission() {
|
|
4892
|
+
return !!this.backend?.getBottomReadyPattern?.();
|
|
4893
|
+
}
|
|
4894
|
+
/** Positive submission retires the startup transient guard for this spawn. */
|
|
4895
|
+
submittedProof() {
|
|
4896
|
+
if (this.inputTransientGuardGeneration === this.spawnGeneration) {
|
|
4897
|
+
this.inputTransientGuardGeneration = null;
|
|
4898
|
+
}
|
|
4899
|
+
return "submitted";
|
|
4900
|
+
}
|
|
4901
|
+
/**
|
|
4902
|
+
* Decide a submission after an Enter, with one bounded retry.
|
|
4903
|
+
*
|
|
4904
|
+
* Backends we can read must SHOW the text left the input row: neither a
|
|
4905
|
+
* strand nor a vanished paste may be upgraded to success by output, because
|
|
4906
|
+
* the output that would do the upgrading is usually the very redraw the Enter
|
|
4907
|
+
* was lost to. Backends we cannot read keep the legacy output signal — there
|
|
4908
|
+
* is nothing better available for them, and taking it away would fail every
|
|
4909
|
+
* one of their deliveries.
|
|
4910
|
+
*
|
|
4911
|
+
* One ladder, used by every post-Enter confirmation, so an escape hatch
|
|
4912
|
+
* cannot reappear in just one of them (it did: three copies, two of which
|
|
4913
|
+
* still confirmed on output alone).
|
|
4914
|
+
*/
|
|
4915
|
+
async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
|
|
4916
|
+
if (!this.canProveSubmission()) {
|
|
4917
|
+
let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
4918
|
+
if (!busy) {
|
|
4919
|
+
this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
|
|
4920
|
+
const retryAt = Date.now();
|
|
4921
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4922
|
+
return false;
|
|
4923
|
+
busy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
4924
|
+
}
|
|
4925
|
+
return busy;
|
|
4926
|
+
}
|
|
4927
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4928
|
+
return true;
|
|
4929
|
+
// Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
|
|
4930
|
+
// how we got here. Then poll briefly rather than judging on one capture:
|
|
4931
|
+
// the key takes a moment to be digested, and a single early look would
|
|
4932
|
+
// report a failure for a message that did go out.
|
|
4933
|
+
this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
|
|
4934
|
+
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
4476
4935
|
return false;
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4936
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4937
|
+
return false;
|
|
4938
|
+
const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
4939
|
+
for (;;) {
|
|
4940
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4480
4941
|
return true;
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4942
|
+
if (Date.now() >= deadline)
|
|
4943
|
+
return false;
|
|
4944
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
4945
|
+
}
|
|
4946
|
+
}
|
|
4947
|
+
async confirmSubmitted(signature, baseline) {
|
|
4948
|
+
if (!this.tmux)
|
|
4949
|
+
return "unproven";
|
|
4950
|
+
let pane;
|
|
4951
|
+
try {
|
|
4952
|
+
pane = await this.tmux.capturePane();
|
|
4953
|
+
}
|
|
4954
|
+
catch {
|
|
4955
|
+
return "unproven";
|
|
4956
|
+
}
|
|
4957
|
+
// Without a way to tell the input row from the transcript, "the text is on
|
|
4958
|
+
// screen" cannot distinguish submitted from stranded — that ambiguity IS
|
|
4959
|
+
// the bug. Such a backend gets an explicit verdict rather than a guess:
|
|
4960
|
+
// "unverifiable" (something of ours appeared, but not provably out of the
|
|
4961
|
+
// input row) or "unproven" (nothing of ours appeared, so the paste itself
|
|
4962
|
+
// was lost). The caller keeps that backend's existing best-effort handling
|
|
4963
|
+
// for the first and recovers on the second, which is what it did before —
|
|
4964
|
+
// the fix here is for backends that CAN be read, not a new guess for those
|
|
4965
|
+
// that cannot.
|
|
4966
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
4967
|
+
if (!prompt) {
|
|
4968
|
+
const seen = this.paneEvidence(pane, signature);
|
|
4969
|
+
if (signature.unique && seen.payload > 0)
|
|
4970
|
+
return "unverifiable";
|
|
4971
|
+
return seen.payload > (baseline?.payload ?? Infinity) || seen.queued > (baseline?.queued ?? Infinity)
|
|
4972
|
+
? "unverifiable"
|
|
4973
|
+
: "unproven";
|
|
4974
|
+
}
|
|
4975
|
+
const after = this.paneEvidence(pane, signature);
|
|
4976
|
+
// 1. Disqualifying evidence, checked FIRST and never overridden by the
|
|
4977
|
+
// corroborating evidence below: our text is sitting in the input row, so
|
|
4978
|
+
// it was not submitted — whatever else is on screen.
|
|
4979
|
+
//
|
|
4980
|
+
// It must be OUR text. A unique signature settles that on its own; a
|
|
4981
|
+
// body-derived one cannot, so it is only attributed to this delivery
|
|
4982
|
+
// when the input row did not already show it before we pasted.
|
|
4983
|
+
// Otherwise an older stranded message with the same opening would be
|
|
4984
|
+
// read as ours, we would press Enter to "recover" it, and the turn IT
|
|
4985
|
+
// starts would confirm a message that never reached the pane.
|
|
4986
|
+
if (after.strandedInput && (signature.unique || baseline?.strandedInput === false))
|
|
4987
|
+
return "stranded";
|
|
4988
|
+
// 2. Positive evidence. A unique signature needs no baseline: no earlier
|
|
4989
|
+
// message can carry this delivery's message_id, so finding it outside
|
|
4990
|
+
// the input row is proof in itself — which also means a momentary
|
|
4991
|
+
// failure to read the pane BEFORE pasting cannot turn a delivered
|
|
4992
|
+
// message into a re-paste.
|
|
4993
|
+
if (signature.unique && after.payload > 0)
|
|
4994
|
+
return this.submittedProof();
|
|
4995
|
+
// 3. Otherwise the evidence must be NEW relative to the pane as it was
|
|
4996
|
+
// before we pasted: a queue marker left by an earlier message, or an
|
|
4997
|
+
// older transcript entry that opens the same way, are on screen either
|
|
4998
|
+
// way and would otherwise confirm a paste that never landed.
|
|
4999
|
+
if (baseline) {
|
|
5000
|
+
if (after.queued > baseline.queued)
|
|
5001
|
+
return this.submittedProof();
|
|
5002
|
+
if (after.payload > baseline.payload)
|
|
5003
|
+
return this.submittedProof();
|
|
5004
|
+
// 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
|
|
5005
|
+
return "unproven";
|
|
5006
|
+
}
|
|
5007
|
+
// No baseline and nothing that identifies this delivery on its own. "The
|
|
5008
|
+
// paste was lost" is a guess, not a finding, and acting on it re-pastes a
|
|
5009
|
+
// message that may well have been submitted — so say the evidence is
|
|
5010
|
+
// missing instead of inventing a verdict.
|
|
5011
|
+
this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
|
|
5012
|
+
return "unverifiable";
|
|
5013
|
+
}
|
|
5014
|
+
/**
|
|
5015
|
+
* What the pane currently shows of a given message. Taken once before the
|
|
5016
|
+
* paste and once after, so confirmSubmitted can require a NEW marker or a NEW
|
|
5017
|
+
* echo rather than accepting whatever was already there.
|
|
5018
|
+
*/
|
|
5019
|
+
async capturePaneEvidence(signature) {
|
|
5020
|
+
if (!this.tmux)
|
|
5021
|
+
return null;
|
|
5022
|
+
// Bounded re-probe: a capture that fails here costs the delivery its only
|
|
5023
|
+
// "before" picture, and a momentary failure should not decide anything.
|
|
5024
|
+
for (let attempt = 1; attempt <= BASELINE_CAPTURE_ATTEMPTS; attempt++) {
|
|
5025
|
+
try {
|
|
5026
|
+
const pane = await this.tmux.capturePane();
|
|
5027
|
+
const evidence = this.paneEvidence(pane, signature);
|
|
5028
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5029
|
+
if (prompt && strandedAgendMessageInInput(pane, prompt)) {
|
|
5030
|
+
// Whatever we paste now lands after it, and one Enter submits both as
|
|
5031
|
+
// a single message. Nothing here can undo that; saying so beats
|
|
5032
|
+
// letting two messages silently merge.
|
|
5033
|
+
this.logger.warn("An unsubmitted message is already in the input row — this delivery will be appended to it");
|
|
5034
|
+
}
|
|
5035
|
+
return evidence;
|
|
4485
5036
|
}
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
5037
|
+
catch {
|
|
5038
|
+
if (attempt < BASELINE_CAPTURE_ATTEMPTS)
|
|
5039
|
+
await new Promise(r => setTimeout(r, BASELINE_CAPTURE_RETRY_MS));
|
|
4489
5040
|
}
|
|
5041
|
+
}
|
|
5042
|
+
// Still unreadable: confirmSubmitted falls back to evidence that stands on
|
|
5043
|
+
// its own (a unique message_id) and refuses to guess without it.
|
|
5044
|
+
this.logger.warn("Could not read the pane before pasting — no baseline for this delivery");
|
|
5045
|
+
return null;
|
|
5046
|
+
}
|
|
5047
|
+
paneEvidence(pane, signature) {
|
|
5048
|
+
const marker = this.backend?.getQueuedInputMarker?.();
|
|
5049
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5050
|
+
const input = prompt ? inputAreaText(pane, prompt) : null;
|
|
5051
|
+
return {
|
|
5052
|
+
queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
|
|
5053
|
+
payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
|
|
5054
|
+
strandedInput: input != null && inputShowsPastedText(input, signature.value),
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
/**
|
|
5058
|
+
* What identifies THIS message on screen. The routing envelope carries a
|
|
5059
|
+
* message_id that no earlier transcript entry can share, so prefer it: the
|
|
5060
|
+
* body alone is not distinctive enough — short repeated instructions ("carry
|
|
5061
|
+
* on", a status ping) recur verbatim, and an older copy would vouch for a
|
|
5062
|
+
* paste that never landed. System pastes carry no envelope and fall back to
|
|
5063
|
+
* the body signature, which the baseline comparison still ties to this paste.
|
|
5064
|
+
*/
|
|
5065
|
+
submissionSignature(formatted, trustedId) {
|
|
5066
|
+
// The id must come from the message's own metadata, never from scanning the
|
|
5067
|
+
// rendered text: agents and users discuss message ids in the body all the
|
|
5068
|
+
// time ("check message_id: abc"), and a body-scanned value would match an
|
|
5069
|
+
// older transcript entry quoting the same thing — then be trusted as unique
|
|
5070
|
+
// and confirm a paste that never landed. Only the value AgEnD itself put in
|
|
5071
|
+
// the handoff metadata identifies THIS delivery.
|
|
5072
|
+
const id = trustedId?.trim();
|
|
5073
|
+
if (id)
|
|
5074
|
+
return { value: `message_id:${id}`, unique: true };
|
|
5075
|
+
return { value: pastedTextSignature(formatted), unique: false };
|
|
5076
|
+
}
|
|
5077
|
+
/**
|
|
5078
|
+
* Paste + submit + confirm for the SYSTEM messages that used to go out via
|
|
5079
|
+
* tmux.pasteText: one Enter (two for queue-less backends) and no verification
|
|
5080
|
+
* at all, so a swallowed Enter left the notice sitting in the input row where
|
|
5081
|
+
* the next delivery submitted both as one message. Shares confirmSubmitted
|
|
5082
|
+
* with the delivery path — having one primitive is the point.
|
|
5083
|
+
*
|
|
5084
|
+
* Best effort by design: these notices must never fail a delivery or throw.
|
|
5085
|
+
* The caller already holds paneWriteLock.
|
|
5086
|
+
*/
|
|
5087
|
+
async submitSystemPaste(text, label) {
|
|
5088
|
+
if (!this.tmux)
|
|
5089
|
+
return false;
|
|
5090
|
+
const signature = this.submissionSignature(text);
|
|
5091
|
+
const baseline = await this.capturePaneEvidence(signature);
|
|
5092
|
+
if (!(await this.tmux.pasteBuffer(text))) {
|
|
5093
|
+
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
4490
5094
|
return false;
|
|
4491
5095
|
}
|
|
4492
|
-
|
|
5096
|
+
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
5097
|
+
if (!(await this.sendDeliveryEnter(label)))
|
|
5098
|
+
return false;
|
|
5099
|
+
let proof = await this.confirmSubmitted(signature, baseline);
|
|
5100
|
+
if (proof === "unverifiable") {
|
|
5101
|
+
// No input row to read: this backend gets exactly what the old pasteText
|
|
5102
|
+
// path gave it, including the unconditional second Enter for queue-less
|
|
5103
|
+
// TUIs that swallow the first. Narrowing that to one Enter on the grounds
|
|
5104
|
+
// that "the text is visible" would be the very inference this change
|
|
5105
|
+
// exists to remove — visible text is also what a strand looks like.
|
|
5106
|
+
if (this.systemPasteOptions().retryEnter) {
|
|
5107
|
+
await new Promise(r => setTimeout(r, 1_000));
|
|
5108
|
+
await this.sendDeliveryEnter(`${label}-defensive-retry`);
|
|
5109
|
+
}
|
|
5110
|
+
return true; // best effort, exactly as before — nothing here is verified
|
|
5111
|
+
}
|
|
5112
|
+
if (proof !== "submitted") {
|
|
5113
|
+
// A bare Enter is a no-op at an empty prompt, so this is safe even if the
|
|
5114
|
+
// first one did land; when the text is still in the input row it is the
|
|
5115
|
+
// submit it never got. Re-pasting would append the text to itself.
|
|
5116
|
+
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
5117
|
+
if (!(await this.sendDeliveryEnter(`${label}-retry`)))
|
|
5118
|
+
return false;
|
|
5119
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5120
|
+
}
|
|
5121
|
+
if (proof !== "submitted") {
|
|
5122
|
+
this.logger.warn({ label, proof }, "System paste may not have been submitted");
|
|
4493
5123
|
return false;
|
|
4494
5124
|
}
|
|
5125
|
+
return true;
|
|
4495
5126
|
}
|
|
4496
5127
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
4497
5128
|
async recoverWindow() {
|
|
@@ -4584,14 +5215,21 @@ export class Daemon extends EventEmitter {
|
|
|
4584
5215
|
if (this.socketSessionNames.get(socket) === this.name) {
|
|
4585
5216
|
this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
|
|
4586
5217
|
}
|
|
5218
|
+
// A tool invocation is not delivery evidence. Capture which obligation it
|
|
5219
|
+
// belongs to now, then settle it only after the adapter/fleet responds.
|
|
5220
|
+
// Explicitly mapped sibling sessions must not satisfy this instance's turn;
|
|
5221
|
+
// an unmapped socket is retained for legacy/tests where mcp_ready was not
|
|
5222
|
+
// observed before the first tool call.
|
|
5223
|
+
const sourceSession = this.socketSessionNames.get(socket);
|
|
5224
|
+
const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
|
|
5225
|
+
? this.turnReplyGuard.beginToolAttempt(tool === "reply")
|
|
5226
|
+
: null;
|
|
4587
5227
|
// For now, log and respond. Full adapter routing will be wired in fleet manager.
|
|
4588
5228
|
const respond = (result, error) => {
|
|
4589
5229
|
// A message that verifiably went out stands down the dead-MCP proxy reply
|
|
4590
5230
|
// for this turn: the agent proved it can still speak for itself.
|
|
4591
5231
|
if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
|
|
4592
|
-
this.
|
|
4593
|
-
if (tool === "reply")
|
|
4594
|
-
this.turnReplyDelivered = true;
|
|
5232
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
4595
5233
|
}
|
|
4596
5234
|
const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
|
|
4597
5235
|
if (!sent) {
|
|
@@ -4782,7 +5420,15 @@ export class Daemon extends EventEmitter {
|
|
|
4782
5420
|
});
|
|
4783
5421
|
return;
|
|
4784
5422
|
}
|
|
4785
|
-
|
|
5423
|
+
// Same rule as deliverDaemonReply: one adapter is unambiguous, several with
|
|
5424
|
+
// no bound world is a routing failure rather than a first-wins guess.
|
|
5425
|
+
const adapter = this.lastAdapterId
|
|
5426
|
+
? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
|
|
5427
|
+
: (adapters.length === 1 ? adapters[0] : undefined);
|
|
5428
|
+
if (!adapter) {
|
|
5429
|
+
respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
|
|
5430
|
+
return;
|
|
5431
|
+
}
|
|
4786
5432
|
if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
|
|
4787
5433
|
respond(null, `Unknown tool: ${tool}`);
|
|
4788
5434
|
}
|
|
@@ -4973,7 +5619,16 @@ export class Daemon extends EventEmitter {
|
|
|
4973
5619
|
try {
|
|
4974
5620
|
// Messages can arrive during a restart and be queued on pasteLock before the
|
|
4975
5621
|
// snapshot lands; both write to the pane, so both go through the same lock.
|
|
4976
|
-
|
|
5622
|
+
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.`;
|
|
5623
|
+
const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
|
|
5624
|
+
if (!injected) {
|
|
5625
|
+
// rotation-state.json was deleted when the prompt was built, so there is
|
|
5626
|
+
// nothing left to retry from: the restore is gone either way. Say so —
|
|
5627
|
+
// reporting it as injected is how a lost context restore stays invisible.
|
|
5628
|
+
this.logger.error("Session snapshot could not be submitted — session continues without context");
|
|
5629
|
+
this.emit("snapshot_failed", this.name);
|
|
5630
|
+
return;
|
|
5631
|
+
}
|
|
4977
5632
|
this.logger.info("Injected session snapshot as first message");
|
|
4978
5633
|
this.emit("snapshot_injected", this.name);
|
|
4979
5634
|
}
|
|
@@ -4989,6 +5644,17 @@ export class Daemon extends EventEmitter {
|
|
|
4989
5644
|
* into exactly the window this exists to close.
|
|
4990
5645
|
*/
|
|
4991
5646
|
beginSpawn() {
|
|
5647
|
+
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
5648
|
+
// v1 deliberately does not persist obligations across restarts: missing one
|
|
5649
|
+
// warning is safer than treating the replacement's startup idle as the old
|
|
5650
|
+
// turn ending and injecting a stale recovery prompt.
|
|
5651
|
+
this.turnReplyGuard.reset();
|
|
5652
|
+
if (this.spawnDepth === 0) {
|
|
5653
|
+
this.spawnGeneration++;
|
|
5654
|
+
this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
|
|
5655
|
+
? this.spawnGeneration
|
|
5656
|
+
: null;
|
|
5657
|
+
}
|
|
4992
5658
|
this.spawnDepth++;
|
|
4993
5659
|
this.spawning = true;
|
|
4994
5660
|
if (!this.spawnSettled) {
|
|
@@ -5043,6 +5709,69 @@ export class Daemon extends EventEmitter {
|
|
|
5043
5709
|
}
|
|
5044
5710
|
}
|
|
5045
5711
|
/** Spawn a CLI window. Returns true if --resume was used successfully. */
|
|
5712
|
+
/**
|
|
5713
|
+
* Positive proof from the CLI that there is no conversation left to resume.
|
|
5714
|
+
*
|
|
5715
|
+
* Only this may justify abandoning a session. A startup that merely ran out of
|
|
5716
|
+
* budget proves nothing: a healthy cold start on a loaded host looks exactly
|
|
5717
|
+
* the same, which is how 9 instances silently lost their history.
|
|
5718
|
+
*
|
|
5719
|
+
* Deliberately does NOT match a bare "--continue": the pane text can mention
|
|
5720
|
+
* the flag (a usage error, an echoed invocation) without the CLI ever saying
|
|
5721
|
+
* the conversation is gone.
|
|
5722
|
+
*/
|
|
5723
|
+
paneSaysNoConversation(paneText) {
|
|
5724
|
+
if (!paneText)
|
|
5725
|
+
return false;
|
|
5726
|
+
return /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i
|
|
5727
|
+
.test(paneText);
|
|
5728
|
+
}
|
|
5729
|
+
/** How many consecutive startups have failed without proving the session is gone. */
|
|
5730
|
+
unprovenResumeFailures = 0;
|
|
5731
|
+
/** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
|
|
5732
|
+
static MAX_UNPROVEN_RESUME_FAILURES = 3;
|
|
5733
|
+
/**
|
|
5734
|
+
* Set the session aside instead of deleting it.
|
|
5735
|
+
*
|
|
5736
|
+
* The whole defect being fixed here is silent, irreversible loss of a user's
|
|
5737
|
+
* conversation. Renaming costs nothing and turns a wrong call into something
|
|
5738
|
+
* recoverable: the id is still on disk under a dated name.
|
|
5739
|
+
*/
|
|
5740
|
+
setSessionAside(reason) {
|
|
5741
|
+
const sidFile = join(this.instanceDir, "session-id");
|
|
5742
|
+
try {
|
|
5743
|
+
if (!existsSync(sidFile))
|
|
5744
|
+
return;
|
|
5745
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
5746
|
+
renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
|
|
5747
|
+
this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
|
|
5748
|
+
this.pruneAbandonedSessions();
|
|
5749
|
+
}
|
|
5750
|
+
catch (err) {
|
|
5751
|
+
// Never let bookkeeping block a spawn; falling back to the old behaviour
|
|
5752
|
+
// is still better than not starting.
|
|
5753
|
+
this.logger.warn({ err: err.message }, "Could not set session aside");
|
|
5754
|
+
try {
|
|
5755
|
+
unlinkSync(sidFile);
|
|
5756
|
+
}
|
|
5757
|
+
catch { /* may not exist */ }
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5760
|
+
/** Keep the newest few set-aside sessions; they are insurance, not an archive. */
|
|
5761
|
+
pruneAbandonedSessions(keep = 5) {
|
|
5762
|
+
try {
|
|
5763
|
+
const files = readdirSync(this.instanceDir)
|
|
5764
|
+
.filter((f) => f.startsWith("session-id.abandoned-"))
|
|
5765
|
+
.sort();
|
|
5766
|
+
for (const f of files.slice(0, Math.max(0, files.length - keep))) {
|
|
5767
|
+
try {
|
|
5768
|
+
unlinkSync(join(this.instanceDir, f));
|
|
5769
|
+
}
|
|
5770
|
+
catch { /* best effort */ }
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
catch { /* best effort */ }
|
|
5774
|
+
}
|
|
5046
5775
|
async spawnClaudeWindow() {
|
|
5047
5776
|
this.beginSpawn();
|
|
5048
5777
|
let resumedSuccessfully = false;
|
|
@@ -5066,7 +5795,7 @@ export class Daemon extends EventEmitter {
|
|
|
5066
5795
|
// first miss is usually slowness, not a broken session.
|
|
5067
5796
|
await this.noteStartupPaneForBackendOutage();
|
|
5068
5797
|
await this.failStartupIfBackendUnreachable();
|
|
5069
|
-
if (this.backend.retriesResumeOnStartupFailure?.()
|
|
5798
|
+
if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
|
|
5070
5799
|
this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
|
|
5071
5800
|
await this.killProcessTree();
|
|
5072
5801
|
await this.tmux.killWindow();
|
|
@@ -5078,16 +5807,39 @@ export class Daemon extends EventEmitter {
|
|
|
5078
5807
|
}
|
|
5079
5808
|
}
|
|
5080
5809
|
if (!alive) {
|
|
5081
|
-
//
|
|
5082
|
-
//
|
|
5083
|
-
//
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5810
|
+
// The session may only be abandoned on positive proof that there is
|
|
5811
|
+
// nothing to resume. Running out of budget is not proof: on a loaded host
|
|
5812
|
+
// a healthy cold start misses the same deadline as a broken session, and
|
|
5813
|
+
// treating the two alike is what silently discarded users' conversations.
|
|
5814
|
+
if (attemptedResume) {
|
|
5815
|
+
let paneText;
|
|
5816
|
+
try {
|
|
5817
|
+
paneText = await this.tmux?.capturePaneWithHistory(50);
|
|
5818
|
+
}
|
|
5819
|
+
catch { /* pane may be gone */ }
|
|
5820
|
+
const proven = this.paneSaysNoConversation(paneText);
|
|
5821
|
+
if (!proven) {
|
|
5822
|
+
this.unprovenResumeFailures++;
|
|
5823
|
+
if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
|
|
5824
|
+
// Keep the session and fail this attempt; the fleet retries with
|
|
5825
|
+
// backoff, which is also how the backend-outage path behaves.
|
|
5826
|
+
await this.killProcessTree();
|
|
5827
|
+
await this.tmux.killWindow();
|
|
5828
|
+
throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
5829
|
+
+ "— session kept, will retry");
|
|
5830
|
+
}
|
|
5831
|
+
// Escape hatch: a genuinely broken session must still recover. Say so
|
|
5832
|
+
// out loud — this start does NOT continue the previous conversation.
|
|
5833
|
+
this.logger.error({ attempts: this.unprovenResumeFailures }, "Giving up on resuming after repeated startup failures — starting fresh. "
|
|
5834
|
+
+ "The previous conversation is NOT continued; its context is lost to this session.");
|
|
5835
|
+
this.emit("context_lost", this.name, "resume_repeatedly_failed");
|
|
5836
|
+
}
|
|
5837
|
+
this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
|
|
5838
|
+
this.skipResume = true;
|
|
5088
5839
|
}
|
|
5089
|
-
|
|
5090
|
-
|
|
5840
|
+
// A fresh start that also failed retries once, as before. It never clears
|
|
5841
|
+
// a session: nothing about a failed fresh launch says the stored
|
|
5842
|
+
// conversation is unusable.
|
|
5091
5843
|
await this.killProcessTree();
|
|
5092
5844
|
await this.tmux.killWindow();
|
|
5093
5845
|
const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
|
|
@@ -5309,9 +6061,16 @@ export class Daemon extends EventEmitter {
|
|
|
5309
6061
|
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
5310
6062
|
// delivery is already in flight or queued. Without the lock the notice and
|
|
5311
6063
|
// that delivery race into the same pane.
|
|
5312
|
-
await this.paneWriteLock.run(
|
|
5313
|
-
|
|
5314
|
-
|
|
6064
|
+
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
|
|
6065
|
+
if (!told) {
|
|
6066
|
+
// Recording the new instructions here would mark the agent as told about
|
|
6067
|
+
// a notice it never received, and every later restart would then skip
|
|
6068
|
+
// the reload. Leave the snapshot behind and hand it to the deferred
|
|
6069
|
+
// notice, which fires on the next real message.
|
|
6070
|
+
this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
|
|
6071
|
+
this.pendingInstructionsNotice = true;
|
|
6072
|
+
return;
|
|
6073
|
+
}
|
|
5315
6074
|
// Record the value the agent has now been told about so the next
|
|
5316
6075
|
// unchanged restart skips the reload.
|
|
5317
6076
|
try {
|
|
@@ -5495,6 +6254,7 @@ export class Daemon extends EventEmitter {
|
|
|
5495
6254
|
await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
|
|
5496
6255
|
let cleanReadyPolls = 0;
|
|
5497
6256
|
let lastDialog = null;
|
|
6257
|
+
let lastTransient = null;
|
|
5498
6258
|
let captureFailures = 0;
|
|
5499
6259
|
let attempts = 0;
|
|
5500
6260
|
do {
|
|
@@ -5516,6 +6276,18 @@ export class Daemon extends EventEmitter {
|
|
|
5516
6276
|
continue;
|
|
5517
6277
|
}
|
|
5518
6278
|
try {
|
|
6279
|
+
// Some TUIs expose a real-looking input row before they accept Enter.
|
|
6280
|
+
// This phase clears by itself: never send a key, and let its structural
|
|
6281
|
+
// current-screen matcher outrank the otherwise-valid ready pattern.
|
|
6282
|
+
const transient = this.inputTransientInPane(pane);
|
|
6283
|
+
if (transient) {
|
|
6284
|
+
cleanReadyPolls = 0;
|
|
6285
|
+
lastDialog = null;
|
|
6286
|
+
lastTransient = transient;
|
|
6287
|
+
await sleep();
|
|
6288
|
+
continue;
|
|
6289
|
+
}
|
|
6290
|
+
lastTransient = null;
|
|
5519
6291
|
// Try each startup dialog pattern before checking ready state
|
|
5520
6292
|
let matched = false;
|
|
5521
6293
|
lastDialog = null;
|
|
@@ -5643,7 +6415,10 @@ export class Daemon extends EventEmitter {
|
|
|
5643
6415
|
await sleep();
|
|
5644
6416
|
} while (remaining() > 0);
|
|
5645
6417
|
// Budget exhausted. Never silently: say which way it went.
|
|
5646
|
-
if (
|
|
6418
|
+
if (lastTransient) {
|
|
6419
|
+
this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
|
|
6420
|
+
}
|
|
6421
|
+
else if (lastDialog) {
|
|
5647
6422
|
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");
|
|
5648
6423
|
}
|
|
5649
6424
|
else {
|
|
@@ -5659,12 +6434,21 @@ export class Daemon extends EventEmitter {
|
|
|
5659
6434
|
updateLastChat(chatId, threadId, adapterId) {
|
|
5660
6435
|
if (!chatId)
|
|
5661
6436
|
return;
|
|
6437
|
+
const chatChanged = this.lastChatId !== chatId;
|
|
5662
6438
|
this.lastChatId = chatId;
|
|
5663
6439
|
// An unthreaded inbound must clear a previous topic rather than leaking it
|
|
5664
6440
|
// into the next reply target.
|
|
5665
6441
|
this.lastThreadId = threadId || undefined;
|
|
5666
6442
|
if (adapterId)
|
|
5667
6443
|
this.lastAdapterId = adapterId;
|
|
6444
|
+
else if (chatChanged) {
|
|
6445
|
+
// A NEW chat with no adapter named alongside it: the old adapter belonged
|
|
6446
|
+
// to the old chat, and keeping it asserts a pairing nobody supplied. That
|
|
6447
|
+
// is how one platform's chat id ends up being sent through another
|
|
6448
|
+
// platform's bot. Unknown is recoverable (a single-adapter fleet is
|
|
6449
|
+
// unambiguous anyway); a confidently wrong world is not.
|
|
6450
|
+
this.lastAdapterId = undefined;
|
|
6451
|
+
}
|
|
5668
6452
|
try {
|
|
5669
6453
|
writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
|
|
5670
6454
|
}
|