@songsid/agend 2.1.12-beta.2 → 2.1.12-beta.4
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/README.md +54 -8
- package/README.zh-TW.md +104 -11
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +38 -29
- package/dist/agent-endpoint.js.map +1 -1
- 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/factory.js +5 -5
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/index.d.ts +0 -1
- package/dist/backend/index.js +0 -1
- package/dist/backend/index.js.map +1 -1
- package/dist/backend/kiro.d.ts +10 -1
- package/dist/backend/kiro.js +13 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/mock.d.ts +1 -1
- package/dist/backend/mock.js +1 -1
- package/dist/backend/muse.d.ts +20 -0
- package/dist/backend/muse.js +68 -11
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/removed.d.ts +12 -0
- package/dist/backend/removed.js +18 -0
- package/dist/backend/removed.js.map +1 -0
- package/dist/backend/session-signals.js +5 -10
- package/dist/backend/session-signals.js.map +1 -1
- package/dist/backend/types.d.ts +15 -1
- package/dist/backend/types.js +0 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +9 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +14 -8
- package/dist/channel/adapters/telegram.js +80 -26
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/classic-channel-manager.js +2 -2
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +27 -53
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +4 -2
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +48 -10
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +7 -5
- package/dist/config.js.map +1 -1
- package/dist/context-guardian.d.ts +1 -1
- package/dist/context-guardian.js +1 -1
- package/dist/cross-instance-notice.d.ts +39 -0
- package/dist/cross-instance-notice.js +84 -0
- package/dist/cross-instance-notice.js.map +1 -0
- package/dist/daemon.d.ts +73 -2
- package/dist/daemon.js +333 -31
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.js +0 -3
- package/dist/daily-summary.js.map +1 -1
- package/dist/fleet-manager.d.ts +16 -3
- package/dist/fleet-manager.js +110 -53
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
- package/dist/instance-config-impact.js +3 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +9 -1
- package/dist/instance-lifecycle.js +62 -7
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +30 -6
- package/dist/locale.js.map +1 -1
- package/dist/memory-pressure.d.ts +8 -2
- package/dist/memory-pressure.js +10 -4
- package/dist/memory-pressure.js.map +1 -1
- package/dist/outbound-handlers.js +47 -25
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +0 -1
- package/dist/outbound-schemas.js +3 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/reply-dedup.d.ts +5 -0
- package/dist/reply-dedup.js +9 -0
- package/dist/reply-dedup.js.map +1 -1
- package/dist/settings-api.js +5 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +0 -4
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.js +4 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/target-queue-worker.d.ts +1 -1
- package/dist/topic-commands.d.ts +64 -5
- package/dist/topic-commands.js +172 -14
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +20 -0
- package/dist/transcript-jsonl.js +64 -0
- package/dist/transcript-jsonl.js.map +1 -0
- package/dist/transcript-monitor.js +21 -29
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +1 -1
- package/dist/transcript-sources.js +29 -26
- 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/types.d.ts +13 -4
- package/dist/ui/dashboard.html +3 -3
- package/dist/ui/settings.html +58 -16
- 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/update-check.d.ts +15 -6
- package/dist/update-check.js +20 -7
- package/dist/update-check.js.map +1 -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/dist/wake-coordinator.js +1 -1
- package/dist/web-api.js +1 -2
- package/dist/web-api.js.map +1 -1
- package/package.json +1 -1
- package/dist/backend/gemini-cli.d.ts +0 -20
- package/dist/backend/gemini-cli.js +0 -169
- package/dist/backend/gemini-cli.js.map +0 -1
package/dist/daemon.js
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveToolSet } from "./tool-permissions.js";
|
|
|
16
16
|
import { getAgendHome } from "./paths.js";
|
|
17
17
|
import { LOGIN_FLOWS } from "./login-flows.js";
|
|
18
18
|
import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
|
|
19
|
+
import { isCrossInstanceVisibility } from "./cross-instance-notice.js";
|
|
19
20
|
import { ContextGuardian } from "./context-guardian.js";
|
|
20
21
|
import { IpcServer } from "./channel/ipc-bridge.js";
|
|
21
22
|
import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
|
|
@@ -36,6 +37,7 @@ import { deliveryContentDigest } from "./delivery-outbox.js";
|
|
|
36
37
|
import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
|
|
37
38
|
import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
|
|
38
39
|
import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
|
|
40
|
+
import { codexLiveRowMatches } from "./backend/codex.js";
|
|
39
41
|
import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
|
|
40
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
41
43
|
const __dirname = dirname(__filename);
|
|
@@ -54,7 +56,16 @@ const TURN_OUTBOUND_TOOLS = new Set([
|
|
|
54
56
|
// a deliberate reaction or edit is a valid response even without new text.
|
|
55
57
|
const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
|
|
56
58
|
const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
57
|
-
|
|
59
|
+
/**
|
|
60
|
+
* #1241: how long an idle edge must persist — with no reply and no further
|
|
61
|
+
* observed work — before the reply guard declares the turn a miss. A claude
|
|
62
|
+
* turn can repaint idle mid-turn (long thinking, subagent delegation) while
|
|
63
|
+
* the first reply is still minutes out; recovering on the first edge injects
|
|
64
|
+
* a prompt the agent later answers twice. A genuine miss stays idle, so the
|
|
65
|
+
* backstop is delayed by at most this window, never disabled.
|
|
66
|
+
*/
|
|
67
|
+
const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
|
|
68
|
+
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
69
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
59
70
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
60
71
|
const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
@@ -63,9 +74,7 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
|
|
|
63
74
|
? `.kiro/steering/agend-${instanceName}.md`
|
|
64
75
|
: binaryName === "agy"
|
|
65
76
|
? ".agents/agents.md"
|
|
66
|
-
:
|
|
67
|
-
? "GEMINI.md"
|
|
68
|
-
: join(instanceDir, "fleet-instructions.md");
|
|
77
|
+
: join(instanceDir, "fleet-instructions.md");
|
|
69
78
|
return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
@@ -424,6 +433,10 @@ export class PendingWorkTracker {
|
|
|
424
433
|
this.lastInboundAt = now;
|
|
425
434
|
this.lastInboundOrder = ++this.sequence;
|
|
426
435
|
}
|
|
436
|
+
/** #1241 P2-1: when the latest delivery entered, bounding pre-arm activity. */
|
|
437
|
+
lastInboundTimestamp() {
|
|
438
|
+
return this.lastInboundAt;
|
|
439
|
+
}
|
|
427
440
|
recordIdle(now = Date.now()) {
|
|
428
441
|
// An async pane poll can finish after a newer inbound. Do not let its stale
|
|
429
442
|
// observation clear work which had not arrived when the pane was captured.
|
|
@@ -565,6 +578,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
|
565
578
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
566
579
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
567
580
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
581
|
+
/** The Claude phrasing every backend was judged by before #1217; still the default for a backend that names none. */
|
|
582
|
+
const LEGACY_NO_CONVERSATION = /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)/i;
|
|
583
|
+
/** #1217: unproven resume failures, kept across Daemon instances, keyed by session id. */
|
|
584
|
+
const RESUME_FAILURES_FILE = "resume-failures.json";
|
|
585
|
+
/** #1217: the backend that owns the stored session id. */
|
|
586
|
+
const SESSION_OWNER_FILE = "session-id.backend";
|
|
568
587
|
/** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
|
|
569
588
|
const INPUT_RESIDUE_MAX_ROUNDS = 60;
|
|
570
589
|
const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
|
|
@@ -1157,6 +1176,11 @@ export class Daemon extends EventEmitter {
|
|
|
1157
1176
|
instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
1158
1177
|
instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
|
|
1159
1178
|
instanceStateReadyPattern = null;
|
|
1179
|
+
/**
|
|
1180
|
+
* #1241 P2-2: an execution snapshot was suppressed by a blocking dialog.
|
|
1181
|
+
* When the block lifts, the silence since counts as a fresh interval.
|
|
1182
|
+
*/
|
|
1183
|
+
sawBlockedInput = false;
|
|
1160
1184
|
instanceStateBusyPattern = null;
|
|
1161
1185
|
instanceStateMonitorActive = false;
|
|
1162
1186
|
sessionCheckpointWarningEmitted = false;
|
|
@@ -1169,6 +1193,62 @@ export class Daemon extends EventEmitter {
|
|
|
1169
1193
|
lastMalformedToolCallSignature;
|
|
1170
1194
|
proxyReplySeq = 0;
|
|
1171
1195
|
lastReplyDropWarningAt = 0;
|
|
1196
|
+
/**
|
|
1197
|
+
* #1241: an idle edge that has not yet proven the turn ended. Armed instead
|
|
1198
|
+
* of recovering immediately; a steady idle snapshot past confirmAt promotes
|
|
1199
|
+
* it to a real recovery, while a delivered reply, cancel, or newly observed
|
|
1200
|
+
* work dissolves it. Scoped to one guard generation. Split clock domains
|
|
1201
|
+
* (R3): edgeAt is wall-clock (compared only against lastBusyAt, also wall —
|
|
1202
|
+
* event ordering, never a deadline), while confirmAt is monotonic
|
|
1203
|
+
* (performance.now, same domain as the setTimeout firing the deadline), so
|
|
1204
|
+
* a wall jump can neither shorten nor stretch the window.
|
|
1205
|
+
*/
|
|
1206
|
+
replyGuardIdleConfirm = null;
|
|
1207
|
+
/**
|
|
1208
|
+
* #1241 P3: the confirmation window's own wake-up. Monitor ticks may be up
|
|
1209
|
+
* to 60 s apart, so without this the real delay could approach two windows.
|
|
1210
|
+
* At the deadline a fresh, fenced capture re-observes the pane instead.
|
|
1211
|
+
*/
|
|
1212
|
+
replyGuardConfirmTimer = null;
|
|
1213
|
+
/** #1241: dissolve a pending confirmation and its deadline, if any. */
|
|
1214
|
+
clearReplyGuardConfirm() {
|
|
1215
|
+
this.replyGuardIdleConfirm = null;
|
|
1216
|
+
if (this.replyGuardConfirmTimer) {
|
|
1217
|
+
clearTimeout(this.replyGuardConfirmTimer);
|
|
1218
|
+
this.replyGuardConfirmTimer = null;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/** #1241: arm a confirmation window with its own deadline capture. */
|
|
1222
|
+
armReplyGuardConfirm(generation) {
|
|
1223
|
+
this.clearReplyGuardConfirm();
|
|
1224
|
+
this.replyGuardIdleConfirm = {
|
|
1225
|
+
generation,
|
|
1226
|
+
edgeAt: Date.now(),
|
|
1227
|
+
confirmAt: performance.now() + REPLY_GUARD_IDLE_CONFIRM_MS,
|
|
1228
|
+
};
|
|
1229
|
+
this.replyGuardConfirmTimer = setTimeout(() => {
|
|
1230
|
+
this.replyGuardConfirmTimer = null;
|
|
1231
|
+
this.fireReplyGuardConfirm();
|
|
1232
|
+
}, REPLY_GUARD_IDLE_CONFIRM_MS);
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* #1241 P3: the window elapsed with no verdict from monitor ticks — drive
|
|
1236
|
+
* one fresh observation. Generation-fenced twice: a stale record returns
|
|
1237
|
+
* before capturing, and the resulting snapshot still flows through the
|
|
1238
|
+
* normal confirmed evaluation. When the capture cannot run (paused, frozen,
|
|
1239
|
+
* spawning), the record stays and the next monitor tick promotes instead.
|
|
1240
|
+
*/
|
|
1241
|
+
fireReplyGuardConfirm() {
|
|
1242
|
+
const pending = this.replyGuardIdleConfirm;
|
|
1243
|
+
if (!pending)
|
|
1244
|
+
return;
|
|
1245
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
1246
|
+
if (!turn || turn.generation !== pending.generation || turn.phase !== "awaiting") {
|
|
1247
|
+
this.clearReplyGuardConfirm();
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
void this.captureAndEvaluateInstanceState("reply_guard_confirm", this.instanceStateLastOutputAt);
|
|
1251
|
+
}
|
|
1172
1252
|
autoPauseController;
|
|
1173
1253
|
pauseRequested = false;
|
|
1174
1254
|
/**
|
|
@@ -2031,7 +2111,7 @@ export class Daemon extends EventEmitter {
|
|
|
2031
2111
|
}
|
|
2032
2112
|
// 8. Context guardian
|
|
2033
2113
|
const statusFile = join(this.instanceDir, "statusline.json");
|
|
2034
|
-
this.guardian = new ContextGuardian(this.config.context_guardian, this.logger, statusFile);
|
|
2114
|
+
this.guardian = new ContextGuardian(this.config.context_guardian ?? {}, this.logger, statusFile);
|
|
2035
2115
|
this.guardian.startWatching();
|
|
2036
2116
|
this.guardian.on("status_update", () => {
|
|
2037
2117
|
this.saveSessionId();
|
|
@@ -3179,12 +3259,9 @@ export class Daemon extends EventEmitter {
|
|
|
3179
3259
|
/** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
|
|
3180
3260
|
evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
|
|
3181
3261
|
// 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
|
-
};
|
|
3262
|
+
// ready prompt). One shared counter (the static below): arm requires
|
|
3263
|
+
// count > baseline, so a drifted clone here would silently gate nudges.
|
|
3264
|
+
const countMatches = (pattern) => Daemon.countOccurrences(pattern, pane);
|
|
3188
3265
|
// Same veto as the state machine: a backend whose ready marker is permanently
|
|
3189
3266
|
// on screen would otherwise "recover" on the very first tick after the error,
|
|
3190
3267
|
// rebaselining the occurrence count while the error is still displayed — one
|
|
@@ -3413,6 +3490,9 @@ export class Daemon extends EventEmitter {
|
|
|
3413
3490
|
// FleetManager calls this synchronously before sendEscape. Cancel only the
|
|
3414
3491
|
// current human turn; leave already-forwarded tool requests to settle.
|
|
3415
3492
|
this.turnReplyGuard.cancelByUser();
|
|
3493
|
+
// #1241: a cancelled turn never confirms — its next edge completes it as
|
|
3494
|
+
// intentional. Drop a pending confirmation so it cannot promote afterwards.
|
|
3495
|
+
this.clearReplyGuardConfirm();
|
|
3416
3496
|
// #1209: a cancelled turn must never resume (#1199) — drop its marker.
|
|
3417
3497
|
// (Completion paths clear via the guard's onComplete; a cancel with no
|
|
3418
3498
|
// later idle edge would otherwise leave the marker behind.)
|
|
@@ -3743,11 +3823,38 @@ export class Daemon extends EventEmitter {
|
|
|
3743
3823
|
return false;
|
|
3744
3824
|
return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
|
|
3745
3825
|
}
|
|
3826
|
+
/**
|
|
3827
|
+
* #1217: the CLI answered its quit command with a confirmation that a stray
|
|
3828
|
+
* key would answer for us (Claude's "Background work is running": Enter
|
|
3829
|
+
* picks "Exit and stop tasks"). Never choose an option: cancel the prompt
|
|
3830
|
+
* with Escape and let the caller stop the process itself (SIGTERM), which is
|
|
3831
|
+
* what the bounded grace would have come to anyway — just without waiting
|
|
3832
|
+
* it out. True when that prompt was on screen.
|
|
3833
|
+
*/
|
|
3834
|
+
async cancelQuitConfirmation(reason) {
|
|
3835
|
+
const backend = this.backend;
|
|
3836
|
+
if (!backend?.quitBlockedByDialog || !this.tmux)
|
|
3837
|
+
return false;
|
|
3838
|
+
let pane;
|
|
3839
|
+
try {
|
|
3840
|
+
pane = await this.tmux.capturePane();
|
|
3841
|
+
}
|
|
3842
|
+
catch {
|
|
3843
|
+
return false;
|
|
3844
|
+
}
|
|
3845
|
+
if (!backend.quitBlockedByDialog(pane))
|
|
3846
|
+
return false;
|
|
3847
|
+
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");
|
|
3848
|
+
await this.tmux.sendSpecialKey("Escape").catch(() => false);
|
|
3849
|
+
return true;
|
|
3850
|
+
}
|
|
3746
3851
|
async stop() {
|
|
3747
3852
|
this.logger.info("Stopping daemon instance");
|
|
3748
3853
|
this.clearQuitRelaunchWatch();
|
|
3749
3854
|
this.fenceDeliveryWritesForStop();
|
|
3750
3855
|
this.turnReplyGuard.reset();
|
|
3856
|
+
this.clearReplyGuardConfirm();
|
|
3857
|
+
this.sawBlockedInput = false;
|
|
3751
3858
|
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
3752
3859
|
// stopped. It must fail closed, not press Enter in a replacement pane.
|
|
3753
3860
|
this.inputTransientGuardGeneration = null;
|
|
@@ -3789,6 +3896,8 @@ export class Daemon extends EventEmitter {
|
|
|
3789
3896
|
killed = true;
|
|
3790
3897
|
break;
|
|
3791
3898
|
}
|
|
3899
|
+
if (await this.cancelQuitConfirmation("stop"))
|
|
3900
|
+
break;
|
|
3792
3901
|
}
|
|
3793
3902
|
}
|
|
3794
3903
|
if (!killed) {
|
|
@@ -3871,8 +3980,20 @@ export class Daemon extends EventEmitter {
|
|
|
3871
3980
|
getInteractionSnapshot() {
|
|
3872
3981
|
return this.interactionObservation.snapshot(this.interactionOwner());
|
|
3873
3982
|
}
|
|
3983
|
+
/** Key of the last interaction snapshot actually emitted + broadcast (#1219). */
|
|
3984
|
+
lastPublishedInteractionKey = null;
|
|
3874
3985
|
publishInteraction() {
|
|
3875
3986
|
const interaction = this.getInteractionSnapshot();
|
|
3987
|
+
// The runtime monitor and every state capture observe the same unchanged
|
|
3988
|
+
// dialog for minutes; re-emitting an identical snapshot each time is
|
|
3989
|
+
// needless IPC traffic. Skip only exact duplicates — any change in the
|
|
3990
|
+
// presentation-relevant fields publishes, so the final state is never
|
|
3991
|
+
// lost (clocks like ageMs are excluded on purpose: they always change).
|
|
3992
|
+
const key = JSON.stringify([interaction.phase, interaction.kind, interaction.reason,
|
|
3993
|
+
interaction.episode, interaction.stale, interaction.suspected]);
|
|
3994
|
+
if (key === this.lastPublishedInteractionKey)
|
|
3995
|
+
return;
|
|
3996
|
+
this.lastPublishedInteractionKey = key;
|
|
3876
3997
|
// Presentation telemetry must not interrupt the execution-state/hold path.
|
|
3877
3998
|
try {
|
|
3878
3999
|
this.emit("instance_interaction", { name: this.name, interaction });
|
|
@@ -4052,6 +4173,8 @@ export class Daemon extends EventEmitter {
|
|
|
4052
4173
|
exited = true;
|
|
4053
4174
|
break;
|
|
4054
4175
|
}
|
|
4176
|
+
if (await this.cancelQuitConfirmation(pauseReason))
|
|
4177
|
+
break;
|
|
4055
4178
|
}
|
|
4056
4179
|
if (!exited) {
|
|
4057
4180
|
await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
|
|
@@ -4212,7 +4335,24 @@ export class Daemon extends EventEmitter {
|
|
|
4212
4335
|
// dedicated input_blocked event carries the reason to observers.
|
|
4213
4336
|
if (this.inputBlockedDialogKey !== null) {
|
|
4214
4337
|
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
|
|
4215
|
-
|
|
4338
|
+
// #1241 P2-2: stdin-owned work is not idle evidence. A pending window
|
|
4339
|
+
// measured through a dialog would recover on pre-dialog idle time, so
|
|
4340
|
+
// the block dissolves it; the silence after the dialog clears starts a
|
|
4341
|
+
// fresh interval below.
|
|
4342
|
+
this.sawBlockedInput = true;
|
|
4343
|
+
this.clearReplyGuardConfirm();
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
// #1241 P2-2: the block just lifted. With the pane idle, evaluate like an
|
|
4347
|
+
// edge so a genuine miss re-accumulates a full window instead of inheriting
|
|
4348
|
+
// pre-dialog idle time (or never re-arming at all when the daemon froze at
|
|
4349
|
+
// idle). Only arming runs here: recovery-phase turns keep their lifecycle.
|
|
4350
|
+
const justUnblocked = this.sawBlockedInput;
|
|
4351
|
+
this.sawBlockedInput = false;
|
|
4352
|
+
if (justUnblocked && snapshot.state === "idle") {
|
|
4353
|
+
const unblockedTurn = this.turnReplyGuard.snapshot();
|
|
4354
|
+
if (unblockedTurn && unblockedTurn.phase === "awaiting")
|
|
4355
|
+
this.maybeProxyReplyOnTurnEnd(pane);
|
|
4216
4356
|
}
|
|
4217
4357
|
this.instanceState = snapshot.state;
|
|
4218
4358
|
this.acceptedInstanceSnapshot = { ...snapshot };
|
|
@@ -4226,6 +4366,18 @@ export class Daemon extends EventEmitter {
|
|
|
4226
4366
|
if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
|
|
4227
4367
|
this.checkpointSessionIdInBackground();
|
|
4228
4368
|
}
|
|
4369
|
+
// #1241: the turn demonstrably continued — work observed after an idle
|
|
4370
|
+
// edge dissolves a pending guard confirmation. A later genuine end
|
|
4371
|
+
// re-arms it with a fresh edge.
|
|
4372
|
+
if (snapshot.state !== "idle") {
|
|
4373
|
+
this.turnReplyGuard.noteTurnActivity();
|
|
4374
|
+
const pending = this.replyGuardIdleConfirm;
|
|
4375
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
4376
|
+
if (pending && turn && turn.generation === pending.generation && turn.phase === "awaiting") {
|
|
4377
|
+
this.clearReplyGuardConfirm();
|
|
4378
|
+
this.logger.debug({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4229
4381
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
4230
4382
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
4231
4383
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
@@ -4240,6 +4392,12 @@ export class Daemon extends EventEmitter {
|
|
|
4240
4392
|
if (acceptedIdle)
|
|
4241
4393
|
this.maybeProxyReplyOnTurnEnd(pane);
|
|
4242
4394
|
}
|
|
4395
|
+
else if (snapshot.state === "idle") {
|
|
4396
|
+
// #1241: steady idle between edges. A pending confirmation whose window
|
|
4397
|
+
// has elapsed is promoted to a real evaluation here — proven idle,
|
|
4398
|
+
// not the first output gap.
|
|
4399
|
+
this.maybeConfirmReplyGuardIdle(pane);
|
|
4400
|
+
}
|
|
4243
4401
|
if (snapshot.state !== previous) {
|
|
4244
4402
|
this.logStateEdge(previous, snapshot);
|
|
4245
4403
|
this.emit("instance_state", { name: this.name, ...snapshot });
|
|
@@ -4309,6 +4467,8 @@ export class Daemon extends EventEmitter {
|
|
|
4309
4467
|
// The last non-empty line of what we pasted: everything on screen after it
|
|
4310
4468
|
// is the agent's own output.
|
|
4311
4469
|
const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
|
|
4470
|
+
// #1241 P2-1: carry pre-arm work evidence over the arm, bounded by this
|
|
4471
|
+
// delivery's ingress — output the paste produced before it confirmed.
|
|
4312
4472
|
this.turnReplyGuard.arm({
|
|
4313
4473
|
adapterId: meta.adapter_id || undefined,
|
|
4314
4474
|
chatId: meta.chat_id,
|
|
@@ -4316,7 +4476,7 @@ export class Daemon extends EventEmitter {
|
|
|
4316
4476
|
messageId: meta.message_id || undefined,
|
|
4317
4477
|
correlationId: meta.correlation_id || undefined,
|
|
4318
4478
|
inboundMarker,
|
|
4319
|
-
});
|
|
4479
|
+
}, this.pendingWork.lastInboundTimestamp());
|
|
4320
4480
|
// #1209: persist the in-flight turn with a seam checkpoint. A marker
|
|
4321
4481
|
// that survives a restart is an interrupted turn by construction —
|
|
4322
4482
|
// completion (via the guard's onComplete) and cancel paths delete it.
|
|
@@ -4444,7 +4604,18 @@ export class Daemon extends EventEmitter {
|
|
|
4444
4604
|
* manager does not pass through the dead MCP server. Consuming the turn state
|
|
4445
4605
|
* here (edge-triggered, then reset) is what makes it at most once per turn.
|
|
4446
4606
|
*/
|
|
4447
|
-
|
|
4607
|
+
/**
|
|
4608
|
+
* #1241: promote a pending idle-edge confirmation once the window has
|
|
4609
|
+
* elapsed. Steady idle with no reply and no work since the edge is proven
|
|
4610
|
+
* idle; anything else dissolves the confirmation and leaves the turn armed.
|
|
4611
|
+
*/
|
|
4612
|
+
maybeConfirmReplyGuardIdle(pane) {
|
|
4613
|
+
const pending = this.replyGuardIdleConfirm;
|
|
4614
|
+
if (!pending || this.isPaused || performance.now() < pending.confirmAt)
|
|
4615
|
+
return;
|
|
4616
|
+
this.maybeProxyReplyOnTurnEnd(pane, true);
|
|
4617
|
+
}
|
|
4618
|
+
maybeProxyReplyOnTurnEnd(pane, confirmed = false) {
|
|
4448
4619
|
const turn = this.turnReplyGuard.snapshot();
|
|
4449
4620
|
if (!turn || this.isPaused)
|
|
4450
4621
|
return;
|
|
@@ -4456,18 +4627,21 @@ export class Daemon extends EventEmitter {
|
|
|
4456
4627
|
generation: turn.generation,
|
|
4457
4628
|
});
|
|
4458
4629
|
}
|
|
4630
|
+
this.clearReplyGuardConfirm();
|
|
4459
4631
|
this.turnReplyGuard.complete(turn.generation);
|
|
4460
4632
|
return;
|
|
4461
4633
|
}
|
|
4462
4634
|
// An intentional stop is not a dropped reply. Successful in-flight replies
|
|
4463
4635
|
// still settle above, but cancellation cannot start another model turn.
|
|
4464
4636
|
if (turn.cancelledByUser) {
|
|
4637
|
+
this.clearReplyGuardConfirm();
|
|
4465
4638
|
this.turnReplyGuard.complete(turn.generation);
|
|
4466
4639
|
return;
|
|
4467
4640
|
}
|
|
4468
4641
|
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
4469
4642
|
// third turn or guess at terminal text; make the failure visible instead.
|
|
4470
4643
|
if (turn.phase === "recovering") {
|
|
4644
|
+
this.clearReplyGuardConfirm();
|
|
4471
4645
|
this.turnReplyGuard.complete(turn.generation);
|
|
4472
4646
|
this.reportUnrecoveredReplyDrop(turn);
|
|
4473
4647
|
return;
|
|
@@ -4528,10 +4702,50 @@ export class Daemon extends EventEmitter {
|
|
|
4528
4702
|
if (turn.replyAttempted) {
|
|
4529
4703
|
// A provider timeout can be "applied, then timed out". Retrying it would
|
|
4530
4704
|
// risk a duplicate; report the unknown result and stop here.
|
|
4705
|
+
this.clearReplyGuardConfirm();
|
|
4531
4706
|
this.turnReplyGuard.complete(turn.generation);
|
|
4532
4707
|
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4533
4708
|
return;
|
|
4534
4709
|
}
|
|
4710
|
+
// #1241: an idle edge is not proof the turn ended. Two gates before the
|
|
4711
|
+
// recovery prompt goes anywhere near the CLI's input:
|
|
4712
|
+
// 1. work must have been observed after this generation armed — otherwise
|
|
4713
|
+
// the "edge" is a pane flicker (or a stale working state) mid-turn,
|
|
4714
|
+
// and the turn stays armed for its real end;
|
|
4715
|
+
// 2. the edge must persist: the first qualifying edge only arms a
|
|
4716
|
+
// confirmation window, and recovery starts only when steady idle past
|
|
4717
|
+
// the window still shows no reply and no further work.
|
|
4718
|
+
if (!turn.busyObserved) {
|
|
4719
|
+
this.logger.info({
|
|
4720
|
+
correlationId: turn.target.correlationId,
|
|
4721
|
+
generation: turn.generation,
|
|
4722
|
+
reason: "no_busy_since_arm",
|
|
4723
|
+
}, "Reply guard holding — idle edge with no work observed since the turn armed, not a turn end");
|
|
4724
|
+
return;
|
|
4725
|
+
}
|
|
4726
|
+
if (!confirmed) {
|
|
4727
|
+
if (this.replyGuardIdleConfirm?.generation === turn.generation)
|
|
4728
|
+
return;
|
|
4729
|
+
this.armReplyGuardConfirm(turn.generation);
|
|
4730
|
+
this.logger.info({
|
|
4731
|
+
correlationId: turn.target.correlationId,
|
|
4732
|
+
generation: turn.generation,
|
|
4733
|
+
reason: "idle_edge_pending_confirm",
|
|
4734
|
+
confirmMs: REPLY_GUARD_IDLE_CONFIRM_MS,
|
|
4735
|
+
}, "Reply guard idle edge pending confirmation — holding recovery until idle persists");
|
|
4736
|
+
return;
|
|
4737
|
+
}
|
|
4738
|
+
if (this.replyGuardIdleConfirm && turn.lastBusyAt > this.replyGuardIdleConfirm.edgeAt) {
|
|
4739
|
+
this.clearReplyGuardConfirm();
|
|
4740
|
+
this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation }, "Reply guard confirmation dropped — work observed after the idle edge, the turn continued");
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
// A stale confirmation for another generation proves nothing about this one.
|
|
4744
|
+
if (this.replyGuardIdleConfirm && this.replyGuardIdleConfirm.generation !== turn.generation) {
|
|
4745
|
+
this.clearReplyGuardConfirm();
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
this.clearReplyGuardConfirm();
|
|
4535
4749
|
this.startReplyRecovery(turn, "no_valid_call");
|
|
4536
4750
|
}
|
|
4537
4751
|
isClaudeCodeBackend() {
|
|
@@ -4544,6 +4758,9 @@ export class Daemon extends EventEmitter {
|
|
|
4544
4758
|
&& this.backend?.replyCompletionGuard === true;
|
|
4545
4759
|
}
|
|
4546
4760
|
startReplyRecovery(turn, reason) {
|
|
4761
|
+
// The confirmation served its purpose: recovery has its own lifecycle now
|
|
4762
|
+
// (a second edge ends it), so a pending window must not linger past this.
|
|
4763
|
+
this.clearReplyGuardConfirm();
|
|
4547
4764
|
const current = this.turnReplyGuard.snapshot();
|
|
4548
4765
|
if (current?.generation === turn.generation && current.cancelledByUser) {
|
|
4549
4766
|
this.turnReplyGuard.complete(turn.generation);
|
|
@@ -4873,15 +5090,14 @@ export class Daemon extends EventEmitter {
|
|
|
4873
5090
|
? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
|
|
4874
5091
|
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4875
5092
|
return;
|
|
4876
|
-
// An old capture must not retire a new launch's transient guard either.
|
|
4877
|
-
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4878
|
-
this.resetFooterFallback();
|
|
4879
|
-
return;
|
|
4880
|
-
}
|
|
4881
5093
|
// Delivery's unknown-footer proof also awaits the TTY mode. Validate
|
|
4882
5094
|
// output and launch freshness AFTER both awaits, before accepting it.
|
|
4883
5095
|
const deliveryCandidate = reason === "delivery_idle_gate"
|
|
4884
5096
|
? await this.probeDeliveryIdleFallback(pane) : null;
|
|
5097
|
+
// An old capture must not retire a new launch's transient guard either.
|
|
5098
|
+
// This check is only live here, after the probe's await: the identical
|
|
5099
|
+
// check before the probe was dead (no await in between, so the early
|
|
5100
|
+
// return above had already settled it) and is gone (#1219).
|
|
4885
5101
|
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4886
5102
|
this.resetFooterFallback();
|
|
4887
5103
|
return;
|
|
@@ -5377,6 +5593,10 @@ export class Daemon extends EventEmitter {
|
|
|
5377
5593
|
this.config.tags = [...update.tags];
|
|
5378
5594
|
else if (update.tags === null)
|
|
5379
5595
|
delete this.config.tags;
|
|
5596
|
+
if (isCrossInstanceVisibility(update.cross_instance_visibility))
|
|
5597
|
+
this.config.cross_instance_visibility = update.cross_instance_visibility;
|
|
5598
|
+
else if (update.cross_instance_visibility === null)
|
|
5599
|
+
delete this.config.cross_instance_visibility;
|
|
5380
5600
|
if (["trace", "debug", "info", "warn", "error"].includes(String(update.log_level))) {
|
|
5381
5601
|
const level = update.log_level;
|
|
5382
5602
|
if (this.config.log_level !== level) {
|
|
@@ -6359,8 +6579,12 @@ export class Daemon extends EventEmitter {
|
|
|
6359
6579
|
const baseline = this.nudgeBaselines?.get(episode.key);
|
|
6360
6580
|
if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
|
|
6361
6581
|
return false;
|
|
6582
|
+
// And only when the row is the live item, not a quotation or scrollback above a newer turn. (Never a regex
|
|
6583
|
+
// lookahead on the counting pattern: whole-pane dedup would max at 1 and miss later hits. See #1215.)
|
|
6584
|
+
if (!codexLiveRowMatches(episode.pane, pattern))
|
|
6585
|
+
return false;
|
|
6362
6586
|
this.capacityNudge = {
|
|
6363
|
-
pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
|
|
6587
|
+
pane: episode.pane, pattern, spawn: episode.spawn, fence: episode.fence,
|
|
6364
6588
|
epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
|
|
6365
6589
|
};
|
|
6366
6590
|
this.logger.info({ delayMs }, "Model-capacity nudge armed");
|
|
@@ -6434,7 +6658,8 @@ export class Daemon extends EventEmitter {
|
|
|
6434
6658
|
// vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
|
|
6435
6659
|
const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
|
|
6436
6660
|
current,
|
|
6437
|
-
accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
|
|
6661
|
+
accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane)
|
|
6662
|
+
&& codexLiveRowMatches(pane, nudge.pattern),
|
|
6438
6663
|
});
|
|
6439
6664
|
this.logger.info({ sent }, "Model-capacity nudge attempted");
|
|
6440
6665
|
});
|
|
@@ -8605,6 +8830,8 @@ export class Daemon extends EventEmitter {
|
|
|
8605
8830
|
// warning is safer than treating the replacement's startup idle as the old
|
|
8606
8831
|
// turn ending and injecting a stale recovery prompt.
|
|
8607
8832
|
this.turnReplyGuard.reset();
|
|
8833
|
+
this.clearReplyGuardConfirm();
|
|
8834
|
+
this.sawBlockedInput = false;
|
|
8608
8835
|
if (this.spawnDepth === 0) {
|
|
8609
8836
|
this.spawnGeneration++;
|
|
8610
8837
|
this.interactionObservation.reset(this.interactionOwner());
|
|
@@ -8688,13 +8915,80 @@ export class Daemon extends EventEmitter {
|
|
|
8688
8915
|
paneSaysNoConversation(paneText) {
|
|
8689
8916
|
if (!paneText)
|
|
8690
8917
|
return false;
|
|
8691
|
-
|
|
8692
|
-
|
|
8918
|
+
// Each CLI says it in its own words (#1217): muse's "retained session not
|
|
8919
|
+
// found" never matched the Claude phrasing, so a muse session that was not
|
|
8920
|
+
// there could never be proven gone. Backends that name no pattern keep the
|
|
8921
|
+
// phrasing this always used.
|
|
8922
|
+
const pattern = this.backend?.resumeMissingPattern?.() ?? LEGACY_NO_CONVERSATION;
|
|
8923
|
+
return pattern.test(paneText);
|
|
8693
8924
|
}
|
|
8694
|
-
/** How many consecutive startups have failed without proving the session is gone. */
|
|
8695
|
-
unprovenResumeFailures = 0;
|
|
8696
8925
|
/** After this many, start fresh anyway — loudly — so a truly broken session still recovers. */
|
|
8697
8926
|
static MAX_UNPROVEN_RESUME_FAILURES = 3;
|
|
8927
|
+
/**
|
|
8928
|
+
* #1217: the unproven-failure count lives in the instance directory, keyed by
|
|
8929
|
+
* the session id. Each start builds a new Daemon, so a count kept on the
|
|
8930
|
+
* object was always "attempt 1/3": the escape hatch could never fire, and an
|
|
8931
|
+
* unresumable session failed every start forever. A different session id (or
|
|
8932
|
+
* none) starts the count again; a successful resume or a set-aside ends it.
|
|
8933
|
+
*/
|
|
8934
|
+
bumpUnprovenResumeFailures() {
|
|
8935
|
+
const path = join(this.instanceDir, RESUME_FAILURES_FILE);
|
|
8936
|
+
let sessionId = "";
|
|
8937
|
+
try {
|
|
8938
|
+
sessionId = readFileSync(join(this.instanceDir, "session-id"), "utf-8").trim();
|
|
8939
|
+
}
|
|
8940
|
+
catch { /* none */ }
|
|
8941
|
+
let previous = null;
|
|
8942
|
+
try {
|
|
8943
|
+
previous = JSON.parse(readFileSync(path, "utf-8"));
|
|
8944
|
+
}
|
|
8945
|
+
catch { /* first failure, or unreadable */ }
|
|
8946
|
+
const count = previous && previous.sessionId === sessionId && Number.isInteger(previous.count) && previous.count > 0
|
|
8947
|
+
? previous.count + 1
|
|
8948
|
+
: 1;
|
|
8949
|
+
try {
|
|
8950
|
+
writeFileSync(path, JSON.stringify({ sessionId, count }));
|
|
8951
|
+
}
|
|
8952
|
+
catch { /* best effort: counts as before */ }
|
|
8953
|
+
return count;
|
|
8954
|
+
}
|
|
8955
|
+
clearUnprovenResumeFailures() {
|
|
8956
|
+
try {
|
|
8957
|
+
unlinkSync(join(this.instanceDir, RESUME_FAILURES_FILE));
|
|
8958
|
+
}
|
|
8959
|
+
catch { /* none */ }
|
|
8960
|
+
}
|
|
8961
|
+
/**
|
|
8962
|
+
* #1217: a session id belongs to the backend that made it. After a backend
|
|
8963
|
+
* switch the old id names nothing the new CLI knows (a Claude id handed to
|
|
8964
|
+
* muse is "retained session not found"), so resuming it can only fail. The
|
|
8965
|
+
* owner is recorded on every successful start; an id recorded for another
|
|
8966
|
+
* backend is set aside (kept on disk, as always) and this start is fresh.
|
|
8967
|
+
* An id with no recorded owner (from before this was recorded) is left to
|
|
8968
|
+
* the resume itself — its failure is proven or counted like any other.
|
|
8969
|
+
*/
|
|
8970
|
+
setAsideForeignSession() {
|
|
8971
|
+
if (!existsSync(join(this.instanceDir, "session-id")))
|
|
8972
|
+
return;
|
|
8973
|
+
let owner = "";
|
|
8974
|
+
try {
|
|
8975
|
+
owner = readFileSync(join(this.instanceDir, SESSION_OWNER_FILE), "utf-8").trim();
|
|
8976
|
+
}
|
|
8977
|
+
catch {
|
|
8978
|
+
return;
|
|
8979
|
+
}
|
|
8980
|
+
const current = this.config.backend ?? "claude-code";
|
|
8981
|
+
if (!owner || owner === current)
|
|
8982
|
+
return;
|
|
8983
|
+
this.logger.warn({ from: owner, to: current }, "The stored session belongs to another backend — not resuming it");
|
|
8984
|
+
this.setSessionAside(`backend_switched:${owner}->${current}`);
|
|
8985
|
+
}
|
|
8986
|
+
recordSessionOwner() {
|
|
8987
|
+
try {
|
|
8988
|
+
writeFileSync(join(this.instanceDir, SESSION_OWNER_FILE), this.config.backend ?? "claude-code");
|
|
8989
|
+
}
|
|
8990
|
+
catch { /* best effort */ }
|
|
8991
|
+
}
|
|
8698
8992
|
/**
|
|
8699
8993
|
* Set the session aside instead of deleting it.
|
|
8700
8994
|
*
|
|
@@ -8710,6 +9004,7 @@ export class Daemon extends EventEmitter {
|
|
|
8710
9004
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
8711
9005
|
renameSync(sidFile, join(this.instanceDir, `session-id.abandoned-${stamp}`));
|
|
8712
9006
|
this.logger.warn({ reason }, "Session set aside (kept on disk as session-id.abandoned-*), starting fresh");
|
|
9007
|
+
this.clearUnprovenResumeFailures();
|
|
8713
9008
|
this.pruneAbandonedSessions();
|
|
8714
9009
|
}
|
|
8715
9010
|
catch (err) {
|
|
@@ -8744,6 +9039,10 @@ export class Daemon extends EventEmitter {
|
|
|
8744
9039
|
if (!this.backend) {
|
|
8745
9040
|
throw new Error("No backend configured — cannot spawn CLI window");
|
|
8746
9041
|
}
|
|
9042
|
+
// Fresh start or not: the owner is recorded below once this CLI is up, so an
|
|
9043
|
+
// id it does not own must be out of the way first, or it would be recorded
|
|
9044
|
+
// as this backend's and resumed by the next start.
|
|
9045
|
+
this.setAsideForeignSession();
|
|
8747
9046
|
const attemptedResume = !this.skipResume;
|
|
8748
9047
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
8749
9048
|
// conversation must come back from the backend before anything paints).
|
|
@@ -8784,18 +9083,18 @@ export class Daemon extends EventEmitter {
|
|
|
8784
9083
|
catch { /* pane may be gone */ }
|
|
8785
9084
|
const proven = this.paneSaysNoConversation(paneText);
|
|
8786
9085
|
if (!proven) {
|
|
8787
|
-
this.
|
|
8788
|
-
if (
|
|
9086
|
+
const failures = this.bumpUnprovenResumeFailures();
|
|
9087
|
+
if (failures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
|
|
8789
9088
|
// Keep the session and fail this attempt; the fleet retries with
|
|
8790
9089
|
// backoff, which is also how the backend-outage path behaves.
|
|
8791
9090
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
8792
9091
|
await this.tmux.killWindow();
|
|
8793
|
-
throw new Error(`CLI startup failed with a session to resume (attempt ${
|
|
9092
|
+
throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
8794
9093
|
+ "— session kept, will retry");
|
|
8795
9094
|
}
|
|
8796
9095
|
// Escape hatch: a genuinely broken session must still recover. Say so
|
|
8797
9096
|
// out loud — this start does NOT continue the previous conversation.
|
|
8798
|
-
this.logger.error({ attempts:
|
|
9097
|
+
this.logger.error({ attempts: failures }, "Giving up on resuming after repeated startup failures — starting fresh. "
|
|
8799
9098
|
+ "The previous conversation is NOT continued; its context is lost to this session.");
|
|
8800
9099
|
this.emit("context_lost", this.name, "resume_repeatedly_failed");
|
|
8801
9100
|
}
|
|
@@ -8816,11 +9115,14 @@ export class Daemon extends EventEmitter {
|
|
|
8816
9115
|
}
|
|
8817
9116
|
else if (attemptedResume) {
|
|
8818
9117
|
resumedSuccessfully = true;
|
|
9118
|
+
this.clearUnprovenResumeFailures();
|
|
8819
9119
|
// A resume needs the backend: its success is positive proof the backend
|
|
8820
9120
|
// is reachable again (a fresh prompt is local and proves nothing).
|
|
8821
9121
|
this.backendOutage?.clear(this.backendKey());
|
|
8822
9122
|
}
|
|
8823
9123
|
this.lastSpawnAt = Date.now();
|
|
9124
|
+
// #1217: whatever session this CLI writes from here on is this backend's.
|
|
9125
|
+
this.recordSessionOwner();
|
|
8824
9126
|
this.skipResume = false; // CLI started successfully — reset for next spawn
|
|
8825
9127
|
this.backgroundSessionRecoveryAttempted = false;
|
|
8826
9128
|
}
|
|
@@ -9206,8 +9508,8 @@ export class Daemon extends EventEmitter {
|
|
|
9206
9508
|
else {
|
|
9207
9509
|
this.warmupNeeded = !!backendConfig.instructions && prev !== backendConfig.instructions;
|
|
9208
9510
|
}
|
|
9209
|
-
// For backends that don't re-read instructions on resume (kiro/codex
|
|
9210
|
-
//
|
|
9511
|
+
// For backends that don't re-read instructions on resume (kiro/codex),
|
|
9512
|
+
// also notify the agent on next message instead of forcing a new
|
|
9211
9513
|
// session. Resume is preserved so context isn't lost.
|
|
9212
9514
|
if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
|
|
9213
9515
|
if (prev) {
|