@songsid/agend 2.1.12-beta.2 → 2.1.12-beta.3
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 +20 -0
- package/dist/backend/claude-code.js +84 -25
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +20 -0
- package/dist/backend/codex.js +55 -5
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +8 -0
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +14 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +71 -2
- package/dist/daemon.js +308 -20
- package/dist/daemon.js.map +1 -1
- package/dist/locale.js +4 -4
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +11 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +8 -0
- package/dist/transcript-jsonl.js +27 -0
- package/dist/transcript-jsonl.js.map +1 -0
- package/dist/transcript-monitor.js +13 -24
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.js +2 -17
- package/dist/transcript-sources.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -1
- package/dist/turn-reply-guard.js +36 -1
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/ui/view.html +17 -10
- package/dist/ui/web-terminal/terminal.css +1 -1
- package/dist/ui/web-terminal/terminal.js +5 -1
- package/dist/usage/format-rich.js +3 -7
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/i18n.d.ts +5 -2
- package/dist/usage/i18n.js +9 -5
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +11 -5
- package/dist/usage/providers.js +55 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +0 -9
- package/dist/usage/usage-api.js +7 -32
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.d.ts
CHANGED
|
@@ -219,6 +219,8 @@ export declare class PendingWorkTracker {
|
|
|
219
219
|
private lastIdleOrder;
|
|
220
220
|
constructor(now?: number);
|
|
221
221
|
recordInbound(now?: number): void;
|
|
222
|
+
/** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
|
|
223
|
+
lastInboundTimestamp(): number;
|
|
222
224
|
recordIdle(now?: number): boolean;
|
|
223
225
|
hasPendingWork(): boolean;
|
|
224
226
|
}
|
|
@@ -561,6 +563,11 @@ export declare class Daemon extends EventEmitter {
|
|
|
561
563
|
private instanceStateIdleDebounceMs;
|
|
562
564
|
private instanceStateStuckTimeoutMs;
|
|
563
565
|
private instanceStateReadyPattern;
|
|
566
|
+
/**
|
|
567
|
+
* #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
|
|
568
|
+
* When the block lifts, the silence since counts as a fresh interval.
|
|
569
|
+
*/
|
|
570
|
+
private sawBlockedInput;
|
|
564
571
|
private instanceStateBusyPattern;
|
|
565
572
|
private instanceStateMonitorActive;
|
|
566
573
|
private sessionCheckpointWarningEmitted;
|
|
@@ -570,6 +577,35 @@ export declare class Daemon extends EventEmitter {
|
|
|
570
577
|
private lastMalformedToolCallSignature;
|
|
571
578
|
private proxyReplySeq;
|
|
572
579
|
private lastReplyDropWarningAt;
|
|
580
|
+
/**
|
|
581
|
+
* #1241: an idle edge that has not yet proven the turn ended. Armed instead
|
|
582
|
+
* of recovering immediately; a steady idle snapshot past confirmAt promotes
|
|
583
|
+
* it to a real recovery, while a delivered reply, cancel, or newly observed
|
|
584
|
+
* work dissolves it. Scoped to one guard generation. Split clock domains
|
|
585
|
+
* (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
|
|
586
|
+
* event ordering, never a deadline), while confirmAt is monotonic
|
|
587
|
+
* (performance.now, same domain as the setTimeout firing the deadline), so
|
|
588
|
+
* a wall jump can neither shorten nor stretch the window.
|
|
589
|
+
*/
|
|
590
|
+
private replyGuardIdleConfirm;
|
|
591
|
+
/**
|
|
592
|
+
* #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
|
|
593
|
+
* to 60 s apart, so without this the real delay could approach two windows.
|
|
594
|
+
* At the deadline a fresh, fenced capture re-observes the pane instead.
|
|
595
|
+
*/
|
|
596
|
+
private replyGuardConfirmTimer;
|
|
597
|
+
/** #1241: dissolve a pending confirmation and its deadline, if any. */
|
|
598
|
+
private clearReplyGuardConfirm;
|
|
599
|
+
/** #1241: arm a confirmation window with its own deadline capture. */
|
|
600
|
+
private armReplyGuardConfirm;
|
|
601
|
+
/**
|
|
602
|
+
* #1241 P3: the window elapsed with no verdict from monitor ticks — drive
|
|
603
|
+
* one fresh observation. Generation-fenced twice: a stale record returns
|
|
604
|
+
* before capturing, and the resulting snapshot still flows through the
|
|
605
|
+
* normal confirmed evaluation. When the capture cannot run (paused, frozen,
|
|
606
|
+
* spawning), the record stays and the next monitor tick promotes instead.
|
|
607
|
+
*/
|
|
608
|
+
private fireReplyGuardConfirm;
|
|
573
609
|
private autoPauseController;
|
|
574
610
|
private pauseRequested;
|
|
575
611
|
/**
|
|
@@ -1122,6 +1158,15 @@ export declare class Daemon extends EventEmitter {
|
|
|
1122
1158
|
* bounded SIGTERM -> SIGKILL fallback instead of typing into an unknown pane.
|
|
1123
1159
|
*/
|
|
1124
1160
|
private drainBusyKiroForStop;
|
|
1161
|
+
/**
|
|
1162
|
+
* #1217: the CLI answered its quit command with a confirmation that a stray
|
|
1163
|
+
* key would answer for us (Claude's "Background work is running": Enter
|
|
1164
|
+
* picks "Exit and stop tasks"). Never choose an option: cancel the prompt
|
|
1165
|
+
* with Escape and let the caller stop the process itself (SIGTERM), which is
|
|
1166
|
+
* what the bounded grace would have come to anyway — just without waiting
|
|
1167
|
+
* it out. True when that prompt was on screen.
|
|
1168
|
+
*/
|
|
1169
|
+
private cancelQuitConfirmation;
|
|
1125
1170
|
stop(): Promise<void>;
|
|
1126
1171
|
getHangDetector(): HangDetector | null;
|
|
1127
1172
|
getInstanceState(): InstanceState | "paused";
|
|
@@ -1197,6 +1242,12 @@ export declare class Daemon extends EventEmitter {
|
|
|
1197
1242
|
* manager does not pass through the dead MCP server. Consuming the turn state
|
|
1198
1243
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
1199
1244
|
*/
|
|
1245
|
+
/**
|
|
1246
|
+
* #1241: promote a pending idle-edge confirmation once the window has
|
|
1247
|
+
* elapsed. Steady idle with no reply and no work since the edge is proven
|
|
1248
|
+
* idle; anything else dissolves the confirmation and leaves the turn armed.
|
|
1249
|
+
*/
|
|
1250
|
+
private maybeConfirmReplyGuardIdle;
|
|
1200
1251
|
private maybeProxyReplyOnTurnEnd;
|
|
1201
1252
|
private isClaudeCodeBackend;
|
|
1202
1253
|
private replyCompletionGuardEnabled;
|
|
@@ -1810,10 +1861,28 @@ export declare class Daemon extends EventEmitter {
|
|
|
1810
1861
|
* the conversation is gone.
|
|
1811
1862
|
*/
|
|
1812
1863
|
private paneSaysNoConversation;
|
|
1813
|
-
/** How many consecutive startups have failed without proving the session is gone. */
|
|
1814
|
-
private unprovenResumeFailures;
|
|
1815
1864
|
/** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
|
|
1816
1865
|
private static readonly MAX_UNPROVEN_RESUME_FAILURES;
|
|
1866
|
+
/**
|
|
1867
|
+
* #1217: the unproven-failure count lives in the instance directory, keyed by
|
|
1868
|
+
* the session id. Each start builds a new Daemon, so a count kept on the
|
|
1869
|
+
* object was always "attempt 1/3": the escape hatch could never fire, and an
|
|
1870
|
+
* unresumable session failed every start forever. A different session id (or
|
|
1871
|
+
* none) starts the count again; a successful resume or a set-aside ends it.
|
|
1872
|
+
*/
|
|
1873
|
+
private bumpUnprovenResumeFailures;
|
|
1874
|
+
private clearUnprovenResumeFailures;
|
|
1875
|
+
/**
|
|
1876
|
+
* #1217: a session id belongs to the backend that made it. After a backend
|
|
1877
|
+
* switch the old id names nothing the new CLI knows (a Claude id handed to
|
|
1878
|
+
* muse is "retained session not found"), so resuming it can only fail. The
|
|
1879
|
+
* owner is recorded on every successful start; an id recorded for another
|
|
1880
|
+
* backend is set aside (kept on disk, as always) and this start is fresh.
|
|
1881
|
+
* An id with no recorded owner (from before this was recorded) is left to
|
|
1882
|
+
* the resume itself — its failure is proven or counted like any other.
|
|
1883
|
+
*/
|
|
1884
|
+
private setAsideForeignSession;
|
|
1885
|
+
private recordSessionOwner;
|
|
1817
1886
|
/**
|
|
1818
1887
|
* Set the session aside instead of deleting it.
|
|
1819
1888
|
*
|
package/dist/daemon.js
CHANGED
|
@@ -36,6 +36,7 @@ import { deliveryContentDigest } from "./delivery-outbox.js";
|
|
|
36
36
|
import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
|
|
37
37
|
import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
|
|
38
38
|
import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
|
|
39
|
+
import { codexLiveRowMatches } from "./backend/codex.js";
|
|
39
40
|
import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
|
|
40
41
|
const __filename = fileURLToPath(import.meta.url);
|
|
41
42
|
const __dirname = dirname(__filename);
|
|
@@ -54,7 +55,16 @@ const TURN_OUTBOUND_TOOLS = new Set([
|
|
|
54
55
|
// a deliberate reaction or edit is a valid response even without new text.
|
|
55
56
|
const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
|
|
56
57
|
const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
57
|
-
|
|
58
|
+
/**
|
|
59
|
+
* #1241: how long an idle edge must persist — with no reply and no further
|
|
60
|
+
* observed work — before the reply guard declares the turn a miss. A claude
|
|
61
|
+
* turn can repaint idle mid-turn (long thinking, subagent delegation) while
|
|
62
|
+
* the first reply is still minutes out; recovering on the first edge injects
|
|
63
|
+
* a prompt the agent later answers twice. A genuine miss stays idle, so the
|
|
64
|
+
* backstop is delayed by at most this window, never disabled.
|
|
65
|
+
*/
|
|
66
|
+
const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
|
|
67
|
+
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
|
|
58
68
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
59
69
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
60
70
|
const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
@@ -424,6 +434,10 @@ export class PendingWorkTracker {
|
|
|
424
434
|
this.lastInboundAt = now;
|
|
425
435
|
this.lastInboundOrder = ++this.sequence;
|
|
426
436
|
}
|
|
437
|
+
/** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
|
|
438
|
+
lastInboundTimestamp() {
|
|
439
|
+
return this.lastInboundAt;
|
|
440
|
+
}
|
|
427
441
|
recordIdle(now = Date.now()) {
|
|
428
442
|
// An async pane poll can finish after a newer inbound. Do not let its stale
|
|
429
443
|
// observation clear work which had not arrived when the pane was captured.
|
|
@@ -565,6 +579,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
|
565
579
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
566
580
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
567
581
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
582
|
+
/** The Claude phrasing every backend was judged by before #1217; still the default for a backend that names none. */
|
|
583
|
+
const LEGACY_NO_CONVERSATION = /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i;
|
|
584
|
+
/** #1217: unproven resume failures, kept across Daemon instances, keyed by session id. */
|
|
585
|
+
const RESUME_FAILURES_FILE = "resume-failures.json";
|
|
586
|
+
/** #1217: the backend that owns the stored session id. */
|
|
587
|
+
const SESSION_OWNER_FILE = "session-id.backend";
|
|
568
588
|
/** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
|
|
569
589
|
const INPUT_RESIDUE_MAX_ROUNDS = 60;
|
|
570
590
|
const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
|
|
@@ -1157,6 +1177,11 @@ export class Daemon extends EventEmitter {
|
|
|
1157
1177
|
instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
1158
1178
|
instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
|
|
1159
1179
|
instanceStateReadyPattern = null;
|
|
1180
|
+
/**
|
|
1181
|
+
* #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
|
|
1182
|
+
* When the block lifts, the silence since counts as a fresh interval.
|
|
1183
|
+
*/
|
|
1184
|
+
sawBlockedInput = false;
|
|
1160
1185
|
instanceStateBusyPattern = null;
|
|
1161
1186
|
instanceStateMonitorActive = false;
|
|
1162
1187
|
sessionCheckpointWarningEmitted = false;
|
|
@@ -1169,6 +1194,62 @@ export class Daemon extends EventEmitter {
|
|
|
1169
1194
|
lastMalformedToolCallSignature;
|
|
1170
1195
|
proxyReplySeq = 0;
|
|
1171
1196
|
lastReplyDropWarningAt = 0;
|
|
1197
|
+
/**
|
|
1198
|
+
* #1241: an idle edge that has not yet proven the turn ended. Armed instead
|
|
1199
|
+
* of recovering immediately; a steady idle snapshot past confirmAt promotes
|
|
1200
|
+
* it to a real recovery, while a delivered reply, cancel, or newly observed
|
|
1201
|
+
* work dissolves it. Scoped to one guard generation. Split clock domains
|
|
1202
|
+
* (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
|
|
1203
|
+
* event ordering, never a deadline), while confirmAt is monotonic
|
|
1204
|
+
* (performance.now, same domain as the setTimeout firing the deadline), so
|
|
1205
|
+
* a wall jump can neither shorten nor stretch the window.
|
|
1206
|
+
*/
|
|
1207
|
+
replyGuardIdleConfirm = null;
|
|
1208
|
+
/**
|
|
1209
|
+
* #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
|
|
1210
|
+
* to 60 s apart, so without this the real delay could approach two windows.
|
|
1211
|
+
* At the deadline a fresh, fenced capture re-observes the pane instead.
|
|
1212
|
+
*/
|
|
1213
|
+
replyGuardConfirmTimer = null;
|
|
1214
|
+
/** #1241: dissolve a pending confirmation and its deadline, if any. */
|
|
1215
|
+
clearReplyGuardConfirm() {
|
|
1216
|
+
this.replyGuardIdleConfirm = null;
|
|
1217
|
+
if (this.replyGuardConfirmTimer) {
|
|
1218
|
+
clearTimeout(this.replyGuardConfirmTimer);
|
|
1219
|
+
this.replyGuardConfirmTimer = null;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** #1241: arm a confirmation window with its own deadline capture. */
|
|
1223
|
+
armReplyGuardConfirm(generation) {
|
|
1224
|
+
this.clearReplyGuardConfirm();
|
|
1225
|
+
this.replyGuardIdleConfirm = {
|
|
1226
|
+
generation,
|
|
1227
|
+
edgeAt: Date.now(),
|
|
1228
|
+
confirmAt: performance.now() + REPLY_GUARD_IDLE_CONFIRM_MS,
|
|
1229
|
+
};
|
|
1230
|
+
this.replyGuardConfirmTimer = setTimeout(() => {
|
|
1231
|
+
this.replyGuardConfirmTimer = null;
|
|
1232
|
+
this.fireReplyGuardConfirm();
|
|
1233
|
+
}, REPLY_GUARD_IDLE_CONFIRM_MS);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* #1241 P3: the window elapsed with no verdict from monitor ticks — drive
|
|
1237
|
+
* one fresh observation. Generation-fenced twice: a stale record returns
|
|
1238
|
+
* before capturing, and the resulting snapshot still flows through the
|
|
1239
|
+
* normal confirmed evaluation. When the capture cannot run (paused, frozen,
|
|
1240
|
+
* spawning), the record stays and the next monitor tick promotes instead.
|
|
1241
|
+
*/
|
|
1242
|
+
fireReplyGuardConfirm() {
|
|
1243
|
+
const pending = this.replyGuardIdleConfirm;
|
|
1244
|
+
if (!pending)
|
|
1245
|
+
return;
|
|
1246
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
1247
|
+
if (!turn || turn.generation !== pending.generation || turn.phase !== "awaiting") {
|
|
1248
|
+
this.clearReplyGuardConfirm();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
void this.captureAndEvaluateInstanceState("reply_guard_confirm", this.instanceStateLastOutputAt);
|
|
1252
|
+
}
|
|
1172
1253
|
autoPauseController;
|
|
1173
1254
|
pauseRequested = false;
|
|
1174
1255
|
/**
|
|
@@ -3179,12 +3260,9 @@ export class Daemon extends EventEmitter {
|
|
|
3179
3260
|
/** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
|
|
3180
3261
|
evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
|
|
3181
3262
|
// Count occurrences across the WHOLE pane (not just text after the last
|
|
3182
|
-
// ready prompt).
|
|
3183
|
-
//
|
|
3184
|
-
const countMatches = (pattern) =>
|
|
3185
|
-
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
3186
|
-
return (pane.match(new RegExp(pattern.source, flags)) || []).length;
|
|
3187
|
-
};
|
|
3263
|
+
// ready prompt). One shared counter (the static below): arm requires
|
|
3264
|
+
// count > baseline, so a drifted clone here would silently gate nudges.
|
|
3265
|
+
const countMatches = (pattern) => Daemon.countOccurrences(pattern, pane);
|
|
3188
3266
|
// Same veto as the state machine: a backend whose ready marker is permanently
|
|
3189
3267
|
// on screen would otherwise "recover" on the very first tick after the error,
|
|
3190
3268
|
// rebaselining the occurrence count while the error is still displayed — one
|
|
@@ -3413,6 +3491,9 @@ export class Daemon extends EventEmitter {
|
|
|
3413
3491
|
// FleetManager calls this synchronously before sendEscape. Cancel only the
|
|
3414
3492
|
// current human turn; leave already-forwarded tool requests to settle.
|
|
3415
3493
|
this.turnReplyGuard.cancelByUser();
|
|
3494
|
+
// #1241: a cancelled turn never confirms — its next edge completes it as
|
|
3495
|
+
// intentional. Drop a pending confirmation so it cannot promote afterwards.
|
|
3496
|
+
this.clearReplyGuardConfirm();
|
|
3416
3497
|
// #1209: a cancelled turn must never resume (#1199) — drop its marker.
|
|
3417
3498
|
// (Completion paths clear via the guard's onComplete; a cancel with no
|
|
3418
3499
|
// later idle edge would otherwise leave the marker behind.)
|
|
@@ -3743,11 +3824,38 @@ export class Daemon extends EventEmitter {
|
|
|
3743
3824
|
return false;
|
|
3744
3825
|
return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
|
|
3745
3826
|
}
|
|
3827
|
+
/**
|
|
3828
|
+
* #1217: the CLI answered its quit command with a confirmation that a stray
|
|
3829
|
+
* key would answer for us (Claude's "Background work is running": Enter
|
|
3830
|
+
* picks "Exit and stop tasks"). Never choose an option: cancel the prompt
|
|
3831
|
+
* with Escape and let the caller stop the process itself (SIGTERM), which is
|
|
3832
|
+
* what the bounded grace would have come to anyway — just without waiting
|
|
3833
|
+
* it out. True when that prompt was on screen.
|
|
3834
|
+
*/
|
|
3835
|
+
async cancelQuitConfirmation(reason) {
|
|
3836
|
+
const backend = this.backend;
|
|
3837
|
+
if (!backend?.quitBlockedByDialog || !this.tmux)
|
|
3838
|
+
return false;
|
|
3839
|
+
let pane;
|
|
3840
|
+
try {
|
|
3841
|
+
pane = await this.tmux.capturePane();
|
|
3842
|
+
}
|
|
3843
|
+
catch {
|
|
3844
|
+
return false;
|
|
3845
|
+
}
|
|
3846
|
+
if (!backend.quitBlockedByDialog(pane))
|
|
3847
|
+
return false;
|
|
3848
|
+
this.logger.warn({ reason }, "The CLI asked to confirm its exit (background work is running) — cancelling that prompt and stopping the process, not answering it");
|
|
3849
|
+
await this.tmux.sendSpecialKey("Escape").catch(() => false);
|
|
3850
|
+
return true;
|
|
3851
|
+
}
|
|
3746
3852
|
async stop() {
|
|
3747
3853
|
this.logger.info("Stopping daemon instance");
|
|
3748
3854
|
this.clearQuitRelaunchWatch();
|
|
3749
3855
|
this.fenceDeliveryWritesForStop();
|
|
3750
3856
|
this.turnReplyGuard.reset();
|
|
3857
|
+
this.clearReplyGuardConfirm();
|
|
3858
|
+
this.sawBlockedInput = false;
|
|
3751
3859
|
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
3752
3860
|
// stopped. It must fail closed, not press Enter in a replacement pane.
|
|
3753
3861
|
this.inputTransientGuardGeneration = null;
|
|
@@ -3789,6 +3897,8 @@ export class Daemon extends EventEmitter {
|
|
|
3789
3897
|
killed = true;
|
|
3790
3898
|
break;
|
|
3791
3899
|
}
|
|
3900
|
+
if (await this.cancelQuitConfirmation("stop"))
|
|
3901
|
+
break;
|
|
3792
3902
|
}
|
|
3793
3903
|
}
|
|
3794
3904
|
if (!killed) {
|
|
@@ -4052,6 +4162,8 @@ export class Daemon extends EventEmitter {
|
|
|
4052
4162
|
exited = true;
|
|
4053
4163
|
break;
|
|
4054
4164
|
}
|
|
4165
|
+
if (await this.cancelQuitConfirmation(pauseReason))
|
|
4166
|
+
break;
|
|
4055
4167
|
}
|
|
4056
4168
|
if (!exited) {
|
|
4057
4169
|
await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
|
|
@@ -4212,7 +4324,24 @@ export class Daemon extends EventEmitter {
|
|
|
4212
4324
|
// dedicated input_blocked event carries the reason to observers.
|
|
4213
4325
|
if (this.inputBlockedDialogKey !== null) {
|
|
4214
4326
|
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
|
|
4215
|
-
|
|
4327
|
+
// #1241 P2-2: stdin-owned work is not idle evidence. A pending window
|
|
4328
|
+
// measured through a dialog would recover on pre-dialog idle time, so
|
|
4329
|
+
// the block dissolves it; the silence after the dialog clears starts a
|
|
4330
|
+
// fresh interval below.
|
|
4331
|
+
this.sawBlockedInput = true;
|
|
4332
|
+
this.clearReplyGuardConfirm();
|
|
4333
|
+
return;
|
|
4334
|
+
}
|
|
4335
|
+
// #1241 P2-2: the block just lifted. With the pane idle, evaluate like an
|
|
4336
|
+
// edge so a genuine miss re-accumulates a full window instead of inheriting
|
|
4337
|
+
// pre-dialog idle time (or never re-arming at all when the daemon froze at
|
|
4338
|
+
// idle). Only arming runs here: recovery-phase turns keep their lifecycle.
|
|
4339
|
+
const justUnblocked = this.sawBlockedInput;
|
|
4340
|
+
this.sawBlockedInput = false;
|
|
4341
|
+
if (justUnblocked && snapshot.state === "idle") {
|
|
4342
|
+
const unblockedTurn = this.turnReplyGuard.snapshot();
|
|
4343
|
+
if (unblockedTurn && unblockedTurn.phase === "awaiting")
|
|
4344
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
4216
4345
|
}
|
|
4217
4346
|
this.instanceState = snapshot.state;
|
|
4218
4347
|
this.acceptedInstanceSnapshot = { ...snapshot };
|
|
@@ -4226,6 +4355,18 @@ export class Daemon extends EventEmitter {
|
|
|
4226
4355
|
if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
|
|
4227
4356
|
this.checkpointSessionIdInBackground();
|
|
4228
4357
|
}
|
|
4358
|
+
// #1241: the turn demonstrably continued — work observed after an idle
|
|
4359
|
+
// edge dissolves a pending guard confirmation. A later genuine end
|
|
4360
|
+
// re-arms it with a fresh edge.
|
|
4361
|
+
if (snapshot.state !== "idle") {
|
|
4362
|
+
this.turnReplyGuard.noteTurnActivity();
|
|
4363
|
+
const pending = this.replyGuardIdleConfirm;
|
|
4364
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
4365
|
+
if (pending && turn && turn.generation === pending.generation && turn.phase === "awaiting") {
|
|
4366
|
+
this.clearReplyGuardConfirm();
|
|
4367
|
+
this.logger.debug({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4229
4370
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
4230
4371
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
4231
4372
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
@@ -4240,6 +4381,12 @@ export class Daemon extends EventEmitter {
|
|
|
4240
4381
|
if (acceptedIdle)
|
|
4241
4382
|
this.maybeProxyReplyOnTurnEnd(pane);
|
|
4242
4383
|
}
|
|
4384
|
+
else if (snapshot.state === "idle") {
|
|
4385
|
+
// #1241: steady idle between edges. A pending confirmation whose window
|
|
4386
|
+
// has elapsed is promoted to a real evaluation here — proven idle,
|
|
4387
|
+
// not the first output gap.
|
|
4388
|
+
this.maybeConfirmReplyGuardIdle(pane);
|
|
4389
|
+
}
|
|
4243
4390
|
if (snapshot.state !== previous) {
|
|
4244
4391
|
this.logStateEdge(previous, snapshot);
|
|
4245
4392
|
this.emit("instance_state", { name: this.name, ...snapshot });
|
|
@@ -4309,6 +4456,8 @@ export class Daemon extends EventEmitter {
|
|
|
4309
4456
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
4310
4457
|
// is the agent's own output.
|
|
4311
4458
|
const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
|
|
4459
|
+
// #1241 P2-1: carry pre-arm work evidence over the arm, bounded by this
|
|
4460
|
+
// delivery's ingress — output the paste produced before it confirmed.
|
|
4312
4461
|
this.turnReplyGuard.arm({
|
|
4313
4462
|
adapterId: meta.adapter_id || undefined,
|
|
4314
4463
|
chatId: meta.chat_id,
|
|
@@ -4316,7 +4465,7 @@ export class Daemon extends EventEmitter {
|
|
|
4316
4465
|
messageId: meta.message_id || undefined,
|
|
4317
4466
|
correlationId: meta.correlation_id || undefined,
|
|
4318
4467
|
inboundMarker,
|
|
4319
|
-
});
|
|
4468
|
+
}, this.pendingWork.lastInboundTimestamp());
|
|
4320
4469
|
// #1209: persist the in-flight turn with a seam checkpoint. A marker
|
|
4321
4470
|
// that survives a restart is an interrupted turn by construction —
|
|
4322
4471
|
// completion (via the guard's onComplete) and cancel paths delete it.
|
|
@@ -4444,7 +4593,18 @@ export class Daemon extends EventEmitter {
|
|
|
4444
4593
|
* manager does not pass through the dead MCP server. Consuming the turn state
|
|
4445
4594
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
4446
4595
|
*/
|
|
4447
|
-
|
|
4596
|
+
/**
|
|
4597
|
+
* #1241: promote a pending idle-edge confirmation once the window has
|
|
4598
|
+
* elapsed. Steady idle with no reply and no work since the edge is proven
|
|
4599
|
+
* idle; anything else dissolves the confirmation and leaves the turn armed.
|
|
4600
|
+
*/
|
|
4601
|
+
maybeConfirmReplyGuardIdle(pane) {
|
|
4602
|
+
const pending = this.replyGuardIdleConfirm;
|
|
4603
|
+
if (!pending || this.isPaused || performance.now() < pending.confirmAt)
|
|
4604
|
+
return;
|
|
4605
|
+
this.maybeProxyReplyOnTurnEnd(pane, true);
|
|
4606
|
+
}
|
|
4607
|
+
maybeProxyReplyOnTurnEnd(pane, confirmed = false) {
|
|
4448
4608
|
const turn = this.turnReplyGuard.snapshot();
|
|
4449
4609
|
if (!turn || this.isPaused)
|
|
4450
4610
|
return;
|
|
@@ -4456,18 +4616,21 @@ export class Daemon extends EventEmitter {
|
|
|
4456
4616
|
generation: turn.generation,
|
|
4457
4617
|
});
|
|
4458
4618
|
}
|
|
4619
|
+
this.clearReplyGuardConfirm();
|
|
4459
4620
|
this.turnReplyGuard.complete(turn.generation);
|
|
4460
4621
|
return;
|
|
4461
4622
|
}
|
|
4462
4623
|
// An intentional stop is not a dropped reply. Successful in-flight replies
|
|
4463
4624
|
// still settle above, but cancellation cannot start another model turn.
|
|
4464
4625
|
if (turn.cancelledByUser) {
|
|
4626
|
+
this.clearReplyGuardConfirm();
|
|
4465
4627
|
this.turnReplyGuard.complete(turn.generation);
|
|
4466
4628
|
return;
|
|
4467
4629
|
}
|
|
4468
4630
|
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
4469
4631
|
// third turn or guess at terminal text; make the failure visible instead.
|
|
4470
4632
|
if (turn.phase === "recovering") {
|
|
4633
|
+
this.clearReplyGuardConfirm();
|
|
4471
4634
|
this.turnReplyGuard.complete(turn.generation);
|
|
4472
4635
|
this.reportUnrecoveredReplyDrop(turn);
|
|
4473
4636
|
return;
|
|
@@ -4528,10 +4691,50 @@ export class Daemon extends EventEmitter {
|
|
|
4528
4691
|
if (turn.replyAttempted) {
|
|
4529
4692
|
// A provider timeout can be "applied, then timed out". Retrying it would
|
|
4530
4693
|
// risk a duplicate; report the unknown result and stop here.
|
|
4694
|
+
this.clearReplyGuardConfirm();
|
|
4531
4695
|
this.turnReplyGuard.complete(turn.generation);
|
|
4532
4696
|
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4533
4697
|
return;
|
|
4534
4698
|
}
|
|
4699
|
+
// #1241: an idle edge is not proof the turn ended. Two gates before the
|
|
4700
|
+
// recovery prompt goes anywhere near the CLI's input:
|
|
4701
|
+
// 1. work must have been observed after this generation armed — otherwise
|
|
4702
|
+
// the "edge" is a pane flicker (or a stale working state) mid-turn,
|
|
4703
|
+
// and the turn stays armed for its real end;
|
|
4704
|
+
// 2. the edge must persist: the first qualifying edge only arms a
|
|
4705
|
+
// confirmation window, and recovery starts only when steady idle past
|
|
4706
|
+
// the window still shows no reply and no further work.
|
|
4707
|
+
if (!turn.busyObserved) {
|
|
4708
|
+
this.logger.info({
|
|
4709
|
+
correlationId: turn.target.correlationId,
|
|
4710
|
+
generation: turn.generation,
|
|
4711
|
+
reason: "no_busy_since_arm",
|
|
4712
|
+
}, "Reply guard holding — idle edge with no work observed since the turn armed, not a turn end");
|
|
4713
|
+
return;
|
|
4714
|
+
}
|
|
4715
|
+
if (!confirmed) {
|
|
4716
|
+
if (this.replyGuardIdleConfirm?.generation === turn.generation)
|
|
4717
|
+
return;
|
|
4718
|
+
this.armReplyGuardConfirm(turn.generation);
|
|
4719
|
+
this.logger.info({
|
|
4720
|
+
correlationId: turn.target.correlationId,
|
|
4721
|
+
generation: turn.generation,
|
|
4722
|
+
reason: "idle_edge_pending_confirm",
|
|
4723
|
+
confirmMs: REPLY_GUARD_IDLE_CONFIRM_MS,
|
|
4724
|
+
}, "Reply guard idle edge pending confirmation — holding recovery until idle persists");
|
|
4725
|
+
return;
|
|
4726
|
+
}
|
|
4727
|
+
if (this.replyGuardIdleConfirm && turn.lastBusyAt > this.replyGuardIdleConfirm.edgeAt) {
|
|
4728
|
+
this.clearReplyGuardConfirm();
|
|
4729
|
+
this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
// A stale confirmation for another generation proves nothing about this one.
|
|
4733
|
+
if (this.replyGuardIdleConfirm && this.replyGuardIdleConfirm.generation !== turn.generation) {
|
|
4734
|
+
this.clearReplyGuardConfirm();
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
this.clearReplyGuardConfirm();
|
|
4535
4738
|
this.startReplyRecovery(turn, "no_valid_call");
|
|
4536
4739
|
}
|
|
4537
4740
|
isClaudeCodeBackend() {
|
|
@@ -4544,6 +4747,9 @@ export class Daemon extends EventEmitter {
|
|
|
4544
4747
|
&& this.backend?.replyCompletionGuard === true;
|
|
4545
4748
|
}
|
|
4546
4749
|
startReplyRecovery(turn, reason) {
|
|
4750
|
+
// The confirmation served its purpose: recovery has its own lifecycle now
|
|
4751
|
+
// (a second edge ends it), so a pending window must not linger past this.
|
|
4752
|
+
this.clearReplyGuardConfirm();
|
|
4547
4753
|
const current = this.turnReplyGuard.snapshot();
|
|
4548
4754
|
if (current?.generation === turn.generation && current.cancelledByUser) {
|
|
4549
4755
|
this.turnReplyGuard.complete(turn.generation);
|
|
@@ -6359,8 +6565,12 @@ export class Daemon extends EventEmitter {
|
|
|
6359
6565
|
const baseline = this.nudgeBaselines?.get(episode.key);
|
|
6360
6566
|
if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
|
|
6361
6567
|
return false;
|
|
6568
|
+
// And only when the row is the live item, not a quotation or scrollback above a newer turn. (Never a regex
|
|
6569
|
+
// lookahead on the counting pattern: whole-pane dedup would max at 1 and miss later hits. See #1215.)
|
|
6570
|
+
if (!codexLiveRowMatches(episode.pane, pattern))
|
|
6571
|
+
return false;
|
|
6362
6572
|
this.capacityNudge = {
|
|
6363
|
-
pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
|
|
6573
|
+
pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
|
|
6364
6574
|
epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
|
|
6365
6575
|
};
|
|
6366
6576
|
this.logger.info({ delayMs }, "Model-capacity nudge armed");
|
|
@@ -6434,7 +6644,8 @@ export class Daemon extends EventEmitter {
|
|
|
6434
6644
|
// vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
|
|
6435
6645
|
const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
|
|
6436
6646
|
current,
|
|
6437
|
-
accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
|
|
6647
|
+
accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
|
|
6648
|
+
&& codexLiveRowMatches(pane, nudge.pattern),
|
|
6438
6649
|
});
|
|
6439
6650
|
this.logger.info({ sent }, "Model-capacity nudge attempted");
|
|
6440
6651
|
});
|
|
@@ -8605,6 +8816,8 @@ export class Daemon extends EventEmitter {
|
|
|
8605
8816
|
// warning is safer than treating the replacement's startup idle as the old
|
|
8606
8817
|
// turn ending and injecting a stale recovery prompt.
|
|
8607
8818
|
this.turnReplyGuard.reset();
|
|
8819
|
+
this.clearReplyGuardConfirm();
|
|
8820
|
+
this.sawBlockedInput = false;
|
|
8608
8821
|
if (this.spawnDepth === 0) {
|
|
8609
8822
|
this.spawnGeneration++;
|
|
8610
8823
|
this.interactionObservation.reset(this.interactionOwner());
|
|
@@ -8688,13 +8901,80 @@ export class Daemon extends EventEmitter {
|
|
|
8688
8901
|
paneSaysNoConversation(paneText) {
|
|
8689
8902
|
if (!paneText)
|
|
8690
8903
|
return false;
|
|
8691
|
-
|
|
8692
|
-
|
|
8904
|
+
// Each CLI says it in its own words (#1217): muse's "retained session not
|
|
8905
|
+
// found" never matched the Claude phrasing, so a muse session that was not
|
|
8906
|
+
// there could never be proven gone. Backends that name no pattern keep the
|
|
8907
|
+
// phrasing this always used.
|
|
8908
|
+
const pattern = this.backend?.resumeMissingPattern?.() ?? LEGACY_NO_CONVERSATION;
|
|
8909
|
+
return pattern.test(paneText);
|
|
8693
8910
|
}
|
|
8694
|
-
/** How many consecutive startups have failed without proving the session is gone. */
|
|
8695
|
-
unprovenResumeFailures = 0;
|
|
8696
8911
|
/** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
|
|
8697
8912
|
static MAX_UNPROVEN_RESUME_FAILURES = 3;
|
|
8913
|
+
/**
|
|
8914
|
+
* #1217: the unproven-failure count lives in the instance directory, keyed by
|
|
8915
|
+
* the session id. Each start builds a new Daemon, so a count kept on the
|
|
8916
|
+
* object was always "attempt 1/3": the escape hatch could never fire, and an
|
|
8917
|
+
* unresumable session failed every start forever. A different session id (or
|
|
8918
|
+
* none) starts the count again; a successful resume or a set-aside ends it.
|
|
8919
|
+
*/
|
|
8920
|
+
bumpUnprovenResumeFailures() {
|
|
8921
|
+
const path = join(this.instanceDir, RESUME_FAILURES_FILE);
|
|
8922
|
+
let sessionId = "";
|
|
8923
|
+
try {
|
|
8924
|
+
sessionId = readFileSync(join(this.instanceDir, "session-id"), "utf-8").trim();
|
|
8925
|
+
}
|
|
8926
|
+
catch { /* none */ }
|
|
8927
|
+
let previous = null;
|
|
8928
|
+
try {
|
|
8929
|
+
previous = JSON.parse(readFileSync(path, "utf-8"));
|
|
8930
|
+
}
|
|
8931
|
+
catch { /* first failure, or unreadable */ }
|
|
8932
|
+
const count = previous && previous.sessionId === sessionId && Number.isInteger(previous.count) && previous.count > 0
|
|
8933
|
+
? previous.count + 1
|
|
8934
|
+
: 1;
|
|
8935
|
+
try {
|
|
8936
|
+
writeFileSync(path, JSON.stringify({ sessionId, count }));
|
|
8937
|
+
}
|
|
8938
|
+
catch { /* best effort: counts as before */ }
|
|
8939
|
+
return count;
|
|
8940
|
+
}
|
|
8941
|
+
clearUnprovenResumeFailures() {
|
|
8942
|
+
try {
|
|
8943
|
+
unlinkSync(join(this.instanceDir, RESUME_FAILURES_FILE));
|
|
8944
|
+
}
|
|
8945
|
+
catch { /* none */ }
|
|
8946
|
+
}
|
|
8947
|
+
/**
|
|
8948
|
+
* #1217: a session id belongs to the backend that made it. After a backend
|
|
8949
|
+
* switch the old id names nothing the new CLI knows (a Claude id handed to
|
|
8950
|
+
* muse is "retained session not found"), so resuming it can only fail. The
|
|
8951
|
+
* owner is recorded on every successful start; an id recorded for another
|
|
8952
|
+
* backend is set aside (kept on disk, as always) and this start is fresh.
|
|
8953
|
+
* An id with no recorded owner (from before this was recorded) is left to
|
|
8954
|
+
* the resume itself — its failure is proven or counted like any other.
|
|
8955
|
+
*/
|
|
8956
|
+
setAsideForeignSession() {
|
|
8957
|
+
if (!existsSync(join(this.instanceDir, "session-id")))
|
|
8958
|
+
return;
|
|
8959
|
+
let owner = "";
|
|
8960
|
+
try {
|
|
8961
|
+
owner = readFileSync(join(this.instanceDir, SESSION_OWNER_FILE), "utf-8").trim();
|
|
8962
|
+
}
|
|
8963
|
+
catch {
|
|
8964
|
+
return;
|
|
8965
|
+
}
|
|
8966
|
+
const current = this.config.backend ?? "claude-code";
|
|
8967
|
+
if (!owner || owner === current)
|
|
8968
|
+
return;
|
|
8969
|
+
this.logger.warn({ from: owner, to: current }, "The stored session belongs to another backend — not resuming it");
|
|
8970
|
+
this.setSessionAside(`backend_switched:${owner}->${current}`);
|
|
8971
|
+
}
|
|
8972
|
+
recordSessionOwner() {
|
|
8973
|
+
try {
|
|
8974
|
+
writeFileSync(join(this.instanceDir, SESSION_OWNER_FILE), this.config.backend ?? "claude-code");
|
|
8975
|
+
}
|
|
8976
|
+
catch { /* best effort */ }
|
|
8977
|
+
}
|
|
8698
8978
|
/**
|
|
8699
8979
|
* Set the session aside instead of deleting it.
|
|
8700
8980
|
*
|
|
@@ -8710,6 +8990,7 @@ export class Daemon extends EventEmitter {
|
|
|
8710
8990
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
8711
8991
|
renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
|
|
8712
8992
|
this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
|
|
8993
|
+
this.clearUnprovenResumeFailures();
|
|
8713
8994
|
this.pruneAbandonedSessions();
|
|
8714
8995
|
}
|
|
8715
8996
|
catch (err) {
|
|
@@ -8744,6 +9025,10 @@ export class Daemon extends EventEmitter {
|
|
|
8744
9025
|
if (!this.backend) {
|
|
8745
9026
|
throw new Error("No backend configured — cannot spawn CLI window");
|
|
8746
9027
|
}
|
|
9028
|
+
// Fresh start or not: the owner is recorded below once this CLI is up, so an
|
|
9029
|
+
// id it does not own must be out of the way first, or it would be recorded
|
|
9030
|
+
// as this backend's and resumed by the next start.
|
|
9031
|
+
this.setAsideForeignSession();
|
|
8747
9032
|
const attemptedResume = !this.skipResume;
|
|
8748
9033
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
8749
9034
|
// conversation must come back from the backend before anything paints).
|
|
@@ -8784,18 +9069,18 @@ export class Daemon extends EventEmitter {
|
|
|
8784
9069
|
catch { /* pane may be gone */ }
|
|
8785
9070
|
const proven = this.paneSaysNoConversation(paneText);
|
|
8786
9071
|
if (!proven) {
|
|
8787
|
-
this.
|
|
8788
|
-
if (
|
|
9072
|
+
const failures = this.bumpUnprovenResumeFailures();
|
|
9073
|
+
if (failures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
|
|
8789
9074
|
// Keep the session and fail this attempt; the fleet retries with
|
|
8790
9075
|
// backoff, which is also how the backend-outage path behaves.
|
|
8791
9076
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
8792
9077
|
await this.tmux.killWindow();
|
|
8793
|
-
throw new Error(`CLI startup failed with a session to resume (attempt ${
|
|
9078
|
+
throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
8794
9079
|
+ "— session kept, will retry");
|
|
8795
9080
|
}
|
|
8796
9081
|
// Escape hatch: a genuinely broken session must still recover. Say so
|
|
8797
9082
|
// out loud — this start does NOT continue the previous conversation.
|
|
8798
|
-
this.logger.error({ attempts:
|
|
9083
|
+
this.logger.error({ attempts: failures }, "Giving up on resuming after repeated startup failures — starting fresh. "
|
|
8799
9084
|
+ "The previous conversation is NOT continued; its context is lost to this session.");
|
|
8800
9085
|
this.emit("context_lost", this.name, "resume_repeatedly_failed");
|
|
8801
9086
|
}
|
|
@@ -8816,11 +9101,14 @@ export class Daemon extends EventEmitter {
|
|
|
8816
9101
|
}
|
|
8817
9102
|
else if (attemptedResume) {
|
|
8818
9103
|
resumedSuccessfully = true;
|
|
9104
|
+
this.clearUnprovenResumeFailures();
|
|
8819
9105
|
// A resume needs the backend: its success is positive proof the backend
|
|
8820
9106
|
// is reachable again (a fresh prompt is local and proves nothing).
|
|
8821
9107
|
this.backendOutage?.clear(this.backendKey());
|
|
8822
9108
|
}
|
|
8823
9109
|
this.lastSpawnAt = Date.now();
|
|
9110
|
+
// #1217: whatever session this CLI writes from here on is this backend's.
|
|
9111
|
+
this.recordSessionOwner();
|
|
8824
9112
|
this.skipResume = false; // CLI started successfully — reset for next spawn
|
|
8825
9113
|
this.backgroundSessionRecoveryAttempted = false;
|
|
8826
9114
|
}
|