@songsid/agend 2.1.10 → 2.1.11-beta.2
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/antigravity.js +3 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +7 -0
- package/dist/backend/claude-code.js +107 -3
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.js +2 -10
- package/dist/backend/cli-env-probe-worker.js.map +1 -1
- package/dist/backend/cli-env-probe.d.ts +14 -0
- package/dist/backend/cli-env-probe.js +13 -0
- package/dist/backend/cli-env-probe.js.map +1 -0
- package/dist/backend/codex-metadata.d.ts +14 -0
- package/dist/backend/codex-metadata.js +70 -0
- package/dist/backend/codex-metadata.js.map +1 -0
- package/dist/backend/codex.d.ts +1 -2
- package/dist/backend/codex.js +112 -65
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +32 -0
- package/dist/backend/effort-metadata.js +21 -0
- package/dist/backend/effort-metadata.js.map +1 -0
- package/dist/backend/grok.js +3 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.d.ts +36 -0
- package/dist/backend/kiro-engine-ledger.js +113 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -0
- package/dist/backend/kiro-kas-store.d.ts +11 -0
- package/dist/backend/kiro-kas-store.js +67 -0
- package/dist/backend/kiro-kas-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +28 -0
- package/dist/backend/kiro-v3-identity.js +213 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -0
- package/dist/backend/kiro.d.ts +10 -0
- package/dist/backend/kiro.js +53 -5
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.js +3 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/opencode-permission.d.ts +49 -0
- package/dist/backend/opencode-permission.js +181 -0
- package/dist/backend/opencode-permission.js.map +1 -0
- package/dist/backend/opencode.d.ts +37 -4
- package/dist/backend/opencode.js +112 -25
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +29 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +35 -26
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +1 -0
- package/dist/channel/ipc-bridge.js +1 -1
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +48 -6
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +99 -0
- package/dist/command-table.js +125 -0
- package/dist/command-table.js.map +1 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +60 -0
- package/dist/daemon.js +274 -14
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +16 -0
- package/dist/delivery-outbox.js +55 -6
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +11 -0
- package/dist/doctor.js.map +1 -1
- package/dist/e2e-isolation.d.ts +122 -0
- package/dist/e2e-isolation.js +386 -0
- package/dist/e2e-isolation.js.map +1 -0
- package/dist/fleet-context.d.ts +2 -2
- package/dist/fleet-control-audit.d.ts +83 -0
- package/dist/fleet-control-audit.js +195 -0
- package/dist/fleet-control-audit.js.map +1 -0
- package/dist/fleet-manager.d.ts +69 -35
- package/dist/fleet-manager.js +596 -845
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.js +2 -1
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
- package/dist/host-memory.d.ts +16 -0
- package/dist/host-memory.js +32 -0
- package/dist/host-memory.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +18 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +71 -0
- package/dist/kiro-engine-status.js +68 -0
- package/dist/kiro-engine-status.js.map +1 -0
- package/dist/locale.js +72 -16
- package/dist/locale.js.map +1 -1
- package/dist/log-tail.d.ts +34 -0
- package/dist/log-tail.js +66 -0
- package/dist/log-tail.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +89 -1
- package/dist/logger.js.map +1 -1
- package/dist/login-controller.d.ts +1 -3
- package/dist/login-controller.js +2 -6
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +0 -8
- package/dist/login-flows.js +11 -11
- package/dist/login-flows.js.map +1 -1
- package/dist/login-manager.d.ts +5 -20
- package/dist/login-manager.js +0 -53
- package/dist/login-manager.js.map +1 -1
- package/dist/login-window-lock.d.ts +1 -1
- package/dist/login-window-lock.js +1 -1
- package/dist/memory-pressure.d.ts +41 -0
- package/dist/memory-pressure.js +117 -0
- package/dist/memory-pressure.js.map +1 -0
- package/dist/outbound-handlers.js +16 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +18 -5
- package/dist/paths.js.map +1 -1
- package/dist/probe-worker-pool.d.ts +28 -0
- package/dist/probe-worker-pool.js +129 -0
- package/dist/probe-worker-pool.js.map +1 -0
- package/dist/resource-report.d.ts +31 -0
- package/dist/resource-report.js +209 -0
- package/dist/resource-report.js.map +1 -0
- package/dist/rollout-index.d.ts +66 -0
- package/dist/rollout-index.js +124 -0
- package/dist/rollout-index.js.map +1 -0
- package/dist/slash-authz.d.ts +50 -0
- package/dist/slash-authz.js +19 -0
- package/dist/slash-authz.js.map +1 -0
- package/dist/spawn-gate.d.ts +13 -1
- package/dist/spawn-gate.js +108 -39
- package/dist/spawn-gate.js.map +1 -1
- package/dist/storm-window.d.ts +42 -0
- package/dist/storm-window.js +84 -1
- package/dist/storm-window.js.map +1 -1
- package/dist/tool-permissions.js +4 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +29 -2
- package/dist/topic-commands.js +77 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +21 -4
- package/dist/transcript-sources.js +55 -41
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/ui/settings.html +14 -13
- package/dist/ui/web-terminal/terminal-input.js +69 -0
- package/dist/ui/web-terminal/terminal.css +6 -0
- package/dist/ui/web-terminal/terminal.html +9 -1
- package/dist/ui/web-terminal/terminal.js +31 -2
- package/dist/web-terminal-http.js +1 -0
- package/dist/web-terminal-http.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { ensureInstanceDir } from "./private-dir.js";
|
|
5
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
|
-
import {
|
|
7
|
+
import { rotateLogIfNeededAsync } from "./logger.js";
|
|
8
8
|
import { coredumpFilterLaunchPrefix } from "./coredump-filter.js";
|
|
9
9
|
import { mcpServerState } from "./mcp-liveness.js";
|
|
10
10
|
import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
|
|
@@ -447,6 +447,18 @@ const STARTUP_DIALOG_POLL_MS = 500;
|
|
|
447
447
|
const STARTUP_DIALOG_BUDGET_MS = 30_000;
|
|
448
448
|
/** A blocking dialog still on screen this long after first being seen is reported for a human. */
|
|
449
449
|
const DIALOG_PARKED_NOTIFY_MS = 60_000;
|
|
450
|
+
/** After a runtime dialog's keys, how long the pane gets to repaint before it is read again to see whether they took. */
|
|
451
|
+
const DIALOG_ANSWER_SETTLE_MS = 300;
|
|
452
|
+
/** Consecutive answers one runtime dialog ignored (still on screen right after the keys) before it is reported for a human. */
|
|
453
|
+
const DIALOG_ANSWER_IGNORED_MAX = 3;
|
|
454
|
+
/**
|
|
455
|
+
* Once a dialog has been reported as ignoring the answer, how long the scanner waits between answers to the SAME request.
|
|
456
|
+
* Not a stop: requests that merely look alike (several queued permission requests for one directory) cannot be told
|
|
457
|
+
* apart from an ignored answer, and each still needs its answer — they get it, slowly — while a dialog that really
|
|
458
|
+
* ignores the keys is pressed far less. A different request, the dialog going away, or a new spawn restores the
|
|
459
|
+
* normal pace (and a fresh count).
|
|
460
|
+
*/
|
|
461
|
+
const DIALOG_ANSWER_BACKOFF_MS = 30_000;
|
|
450
462
|
/** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
|
|
451
463
|
const LATE_DIALOG_WRITE_ROUNDS = 3;
|
|
452
464
|
/** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
|
|
@@ -1024,6 +1036,12 @@ export class Daemon extends EventEmitter {
|
|
|
1024
1036
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
1025
1037
|
dialogParkedKey = null;
|
|
1026
1038
|
dialogParkedReported = false;
|
|
1039
|
+
/**
|
|
1040
|
+
* The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
|
|
1041
|
+
* still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
|
|
1042
|
+
* dialog on screen at every poll and ignores nothing.
|
|
1043
|
+
*/
|
|
1044
|
+
dialogAnswers = null;
|
|
1027
1045
|
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
1028
1046
|
inputBlockedDialogKey = null;
|
|
1029
1047
|
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
@@ -1234,6 +1252,9 @@ export class Daemon extends EventEmitter {
|
|
|
1234
1252
|
* an immediate restart would destroy in-flight work. Cleared when the server
|
|
1235
1253
|
* turns up alive again (operator restarted, CLI reconnected) before we fire.
|
|
1236
1254
|
*/
|
|
1255
|
+
/** Consecutive health ticks a failed `list-windows` may defer crash recovery before it counts as "gone" (#1127). */
|
|
1256
|
+
static WINDOW_QUERY_FAILURE_TICKS = 2;
|
|
1257
|
+
windowQueryFailureTicks = 0;
|
|
1237
1258
|
mcpRestartPending = false;
|
|
1238
1259
|
mcpRestartStaleTimer = null;
|
|
1239
1260
|
/** In-flight replacement grace; also the guard against three triggers stacking. */
|
|
@@ -1257,6 +1278,13 @@ export class Daemon extends EventEmitter {
|
|
|
1257
1278
|
mcpDeathDeferredForPid = null;
|
|
1258
1279
|
/** Prevent in-flight monitor callbacks from re-arming after a pause. */
|
|
1259
1280
|
runtimeMonitorsFrozen = false;
|
|
1281
|
+
/**
|
|
1282
|
+
* Bumped by every stop / pause / startup abort (they all freeze the monitors). A launch that has to
|
|
1283
|
+
* await something before it touches the pane (a backend's `prepareLaunch`) remembers the value it
|
|
1284
|
+
* started under and gives up if it moved: a stopped instance must not get a window, a config or a
|
|
1285
|
+
* token from a launch that was already in the air. A wake that STARTS after the freeze is unaffected.
|
|
1286
|
+
*/
|
|
1287
|
+
launchFenceEpoch = 0;
|
|
1260
1288
|
errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
|
|
1261
1289
|
errorDetectedAt = 0;
|
|
1262
1290
|
errorRecoveryDeadlineAt = 0;
|
|
@@ -1742,7 +1770,7 @@ export class Daemon extends EventEmitter {
|
|
|
1742
1770
|
if (savedId) {
|
|
1743
1771
|
const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
|
|
1744
1772
|
if (await oldTmux.isWindowAlive()) {
|
|
1745
|
-
this.
|
|
1773
|
+
await this.checkpointSessionId();
|
|
1746
1774
|
await oldTmux.killWindow();
|
|
1747
1775
|
this.logger.info({ savedId }, "Killed old tmux window for fresh start");
|
|
1748
1776
|
}
|
|
@@ -1771,7 +1799,7 @@ export class Daemon extends EventEmitter {
|
|
|
1771
1799
|
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
1772
1800
|
// keeps the writer attached after size resets.
|
|
1773
1801
|
const outputLog = join(this.instanceDir, "output.log");
|
|
1774
|
-
|
|
1802
|
+
await rotateLogIfNeededAsync(outputLog);
|
|
1775
1803
|
await this.tmux.pipeOutput(outputLog).catch(() => { });
|
|
1776
1804
|
// 4. Transcript monitor. claude-code is handled inside the monitor
|
|
1777
1805
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
@@ -2302,10 +2330,15 @@ export class Daemon extends EventEmitter {
|
|
|
2302
2330
|
return;
|
|
2303
2331
|
}
|
|
2304
2332
|
if (paneStatus?.alive) {
|
|
2333
|
+
this.windowQueryFailureTicks = 0;
|
|
2334
|
+
this.stormWindow?.noteWindowAlive(this.name);
|
|
2305
2335
|
// Instance output.log is fed by tmux pipe-pane and was previously never
|
|
2306
2336
|
// rotated (only fleet.log / daemon.log were). Cap growth every tick.
|
|
2337
|
+
// Fire and forget, on purpose: the size check is one stat, the copy of a
|
|
2338
|
+
// 10–100 MiB log runs off the event loop, and this tick must not wait for
|
|
2339
|
+
// it (#1161) — scheduleNext() below is not delayed by a rotation.
|
|
2307
2340
|
if (!this.config.lightweight) {
|
|
2308
|
-
|
|
2341
|
+
void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
|
|
2309
2342
|
}
|
|
2310
2343
|
scheduleNext();
|
|
2311
2344
|
return;
|
|
@@ -2320,6 +2353,8 @@ export class Daemon extends EventEmitter {
|
|
|
2320
2353
|
paneStatus = await this.tmux.getPaneStatus();
|
|
2321
2354
|
if (paneStatus?.alive) {
|
|
2322
2355
|
this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
|
|
2356
|
+
this.windowQueryFailureTicks = 0;
|
|
2357
|
+
this.stormWindow?.noteWindowAlive(this.name);
|
|
2323
2358
|
scheduleNext();
|
|
2324
2359
|
return;
|
|
2325
2360
|
}
|
|
@@ -2344,6 +2379,7 @@ export class Daemon extends EventEmitter {
|
|
|
2344
2379
|
// nullReason records *why* getPaneStatus returned null (for diagnosing
|
|
2345
2380
|
// whether this was a real window loss or a transient query failure).
|
|
2346
2381
|
let crashType = "window";
|
|
2382
|
+
let inWindowLossBurst = false;
|
|
2347
2383
|
let nullReason;
|
|
2348
2384
|
if (!paneStatus) {
|
|
2349
2385
|
const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
|
|
@@ -2377,6 +2413,7 @@ export class Daemon extends EventEmitter {
|
|
|
2377
2413
|
nullReason = "no_window";
|
|
2378
2414
|
try {
|
|
2379
2415
|
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
2416
|
+
this.windowQueryFailureTicks = 0;
|
|
2380
2417
|
const currentWindowId = this.tmux.getWindowId();
|
|
2381
2418
|
if (windows.some(w => w.id === currentWindowId)) {
|
|
2382
2419
|
// The exact window still exists, so `list-panes` was the query
|
|
@@ -2390,9 +2427,27 @@ export class Daemon extends EventEmitter {
|
|
|
2390
2427
|
nullReason = "same_name_other_window";
|
|
2391
2428
|
}
|
|
2392
2429
|
catch {
|
|
2430
|
+
// A failed window query proves nothing: it used to read as "the
|
|
2431
|
+
// window is gone" and respawned a live CLI. Look again next tick,
|
|
2432
|
+
// and give up waiting only after WINDOW_QUERY_FAILURE_TICKS in a
|
|
2433
|
+
// row (tmux that cannot list windows for that long is unhealthy
|
|
2434
|
+
// enough that recovery is the better bet).
|
|
2435
|
+
if (++this.windowQueryFailureTicks <= Daemon.WINDOW_QUERY_FAILURE_TICKS) {
|
|
2436
|
+
this.logger.warn({ failures: this.windowQueryFailureTicks }, `${cliLabel} window list unavailable — deferring crash recovery`);
|
|
2437
|
+
scheduleNext();
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2393
2440
|
nullReason = "query_error";
|
|
2394
2441
|
}
|
|
2442
|
+
this.windowQueryFailureTicks = 0;
|
|
2395
2443
|
this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
|
|
2444
|
+
// Several instances losing their windows together is ONE event on
|
|
2445
|
+
// the host or in tmux, not N crashes (#1127): the first ones to see
|
|
2446
|
+
// it respawn as usual; the one that makes it a burst opens the storm
|
|
2447
|
+
// window, which caps the recovery rate and folds the notices into
|
|
2448
|
+
// one. Later arrivals just join it.
|
|
2449
|
+
inWindowLossBurst = (this.stormWindow?.noteWindowLoss(this.name) ?? false)
|
|
2450
|
+
|| (this.stormWindow?.needsRecovery(this.name) ?? false);
|
|
2396
2451
|
}
|
|
2397
2452
|
}
|
|
2398
2453
|
else {
|
|
@@ -2466,7 +2521,7 @@ export class Daemon extends EventEmitter {
|
|
|
2466
2521
|
// independent CLI crash loops. StormWindow owns its escalation; do
|
|
2467
2522
|
// not let the same three server generations permanently trip every
|
|
2468
2523
|
// daemon's per-instance 3-in-5m breaker.
|
|
2469
|
-
if (crashType === "server") {
|
|
2524
|
+
if (crashType === "server" || inWindowLossBurst) {
|
|
2470
2525
|
this.crashTimestamps = [];
|
|
2471
2526
|
this.crashCount = 0;
|
|
2472
2527
|
}
|
|
@@ -2514,7 +2569,13 @@ export class Daemon extends EventEmitter {
|
|
|
2514
2569
|
this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
|
|
2515
2570
|
await new Promise(r => setTimeout(r, delay));
|
|
2516
2571
|
try {
|
|
2517
|
-
this.
|
|
2572
|
+
await this.checkpointSessionId();
|
|
2573
|
+
// stop() / pause() may have landed while this tick sat in the backoff delay or in the
|
|
2574
|
+
// (bounded) session lookup. They only stop LATER ticks; this continuation is already
|
|
2575
|
+
// past that check and must not clear the process/window or respawn an instance that
|
|
2576
|
+
// was just stopped or paused (#1160 review).
|
|
2577
|
+
if (this.runtimeMonitorsFrozen || this.healthCheckPaused)
|
|
2578
|
+
return;
|
|
2518
2579
|
this.transcriptMonitor?.resetOffset();
|
|
2519
2580
|
// Kill orphan MCP server from the crashed CLI session.
|
|
2520
2581
|
// MCP server writes its PID to channel.mcp.pid on startup.
|
|
@@ -2558,8 +2619,17 @@ export class Daemon extends EventEmitter {
|
|
|
2558
2619
|
this.emit("crash_respawn", this.name);
|
|
2559
2620
|
// Emit the per-instance audit event while the storm is still active
|
|
2560
2621
|
// so its chat notification is suppressed into the fleet summary.
|
|
2561
|
-
|
|
2622
|
+
// Also the instances that lost their window a moment BEFORE the burst
|
|
2623
|
+
// was recognised: they respawned as ordinary crashes but are part of
|
|
2624
|
+
// the incident now, and the window cannot close without them.
|
|
2625
|
+
if (crashType === "server" || inWindowLossBurst || this.stormWindow?.needsRecovery(this.name)) {
|
|
2562
2626
|
this.stormWindow?.markRecovered(this.name);
|
|
2627
|
+
}
|
|
2628
|
+
else {
|
|
2629
|
+
// Not (yet) part of a storm: keep the evidence, in case the next
|
|
2630
|
+
// window loss makes this one a member whose recovery is already done.
|
|
2631
|
+
this.stormWindow?.noteWindowRecovered(this.name);
|
|
2632
|
+
}
|
|
2563
2633
|
}
|
|
2564
2634
|
catch (err) {
|
|
2565
2635
|
if (this.stopOnUnsupportedCliRespawn(err)) {
|
|
@@ -2609,7 +2679,7 @@ export class Daemon extends EventEmitter {
|
|
|
2609
2679
|
return false;
|
|
2610
2680
|
this.healthCheckPaused = true;
|
|
2611
2681
|
this.logger.error({ reason: err.message }, "Not respawning: the installed CLI cannot run this instance as configured");
|
|
2612
|
-
this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.");
|
|
2682
|
+
this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.", undefined, "cli_unsupported");
|
|
2613
2683
|
return true;
|
|
2614
2684
|
}
|
|
2615
2685
|
/**
|
|
@@ -2625,12 +2695,13 @@ export class Daemon extends EventEmitter {
|
|
|
2625
2695
|
* `crash_loop` already had this treatment; this is the same bridge for the other
|
|
2626
2696
|
* four, carrying a human-readable cause and the operator's next step.
|
|
2627
2697
|
*/
|
|
2628
|
-
emitSupervisionEnded(reason, remedy, exitCode) {
|
|
2698
|
+
emitSupervisionEnded(reason, remedy, exitCode, cause) {
|
|
2629
2699
|
this.emit("supervision_ended", {
|
|
2630
2700
|
name: this.name,
|
|
2631
2701
|
reason,
|
|
2632
2702
|
remedy,
|
|
2633
2703
|
...(exitCode !== undefined ? { exitCode } : {}),
|
|
2704
|
+
...(cause ? { cause } : {}),
|
|
2634
2705
|
});
|
|
2635
2706
|
}
|
|
2636
2707
|
setProcessStatus(status) {
|
|
@@ -2680,11 +2751,18 @@ export class Daemon extends EventEmitter {
|
|
|
2680
2751
|
this.errorMonitorTimer = setInterval(async () => {
|
|
2681
2752
|
if (!this.tmux || this.spawning)
|
|
2682
2753
|
return;
|
|
2754
|
+
// This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
|
|
2755
|
+
// a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
|
|
2756
|
+
const pollSpawn = this.spawnGeneration;
|
|
2757
|
+
const pollFence = this.launchFenceEpoch;
|
|
2758
|
+
const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch;
|
|
2683
2759
|
try {
|
|
2684
2760
|
const alive = await this.tmux.isWindowAlive();
|
|
2685
2761
|
if (!alive)
|
|
2686
2762
|
return;
|
|
2687
2763
|
const pane = await this.tmux.capturePane();
|
|
2764
|
+
if (stale())
|
|
2765
|
+
return;
|
|
2688
2766
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2689
2767
|
// A sign-in screen that appears AFTER the startup scan ended (first run:
|
|
2690
2768
|
// theme → login can take longer than its budget) is the same auth
|
|
@@ -2724,6 +2802,7 @@ export class Daemon extends EventEmitter {
|
|
|
2724
2802
|
// on). Runtime table only: startup patterns are loose on purpose and
|
|
2725
2803
|
// must never send keys into an ordinary transcript.
|
|
2726
2804
|
let blockingSeen = null;
|
|
2805
|
+
let answersBackedOff = false;
|
|
2727
2806
|
for (const dialog of dialogs) {
|
|
2728
2807
|
if (!Daemon.dialogMatches(dialog, pane))
|
|
2729
2808
|
continue;
|
|
@@ -2732,6 +2811,20 @@ export class Daemon extends EventEmitter {
|
|
|
2732
2811
|
blockingSeen = dialog;
|
|
2733
2812
|
if (dialog.holdOnly)
|
|
2734
2813
|
break; // recognised, deliberately not answered; trackDialogParked reports it
|
|
2814
|
+
// Reported as ignoring the daemon: the SAME request (the identity the count was made on — verbatim, the
|
|
2815
|
+
// backend's own when it names one) is answered only once per DIALOG_ANSWER_BACKOFF_MS. Anything else — another
|
|
2816
|
+
// request, an unexplained difference, the dialog gone — is not held back here; the answer path below ends or
|
|
2817
|
+
// keeps the episode by its own rules.
|
|
2818
|
+
const episode = this.dialogAnswers;
|
|
2819
|
+
// A wall clock that moved BACKWARDS since the last answer (NTP step, VM resume) gives a negative elapsed time:
|
|
2820
|
+
// the backoff has expired, not "not started" — held to it, the answers would stop until the clock caught up.
|
|
2821
|
+
const sinceLastAnswer = episode ? Date.now() - episode.lastAnswerAt : 0;
|
|
2822
|
+
if (episode?.reported && episode.key === Daemon.answerKey(dialog)
|
|
2823
|
+
&& episode.screen === Daemon.screenOf(dialog, pane)
|
|
2824
|
+
&& sinceLastAnswer >= 0 && sinceLastAnswer < DIALOG_ANSWER_BACKOFF_MS) {
|
|
2825
|
+
answersBackedOff = true;
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2735
2828
|
const autoKey = dialog.autoResolutionKey;
|
|
2736
2829
|
if (dialog.verifyAfterKeys && autoKey
|
|
2737
2830
|
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
@@ -2751,10 +2844,23 @@ export class Daemon extends EventEmitter {
|
|
|
2751
2844
|
// while a delivery was finishing. A stale danger menu must never
|
|
2752
2845
|
// receive a blind key sequence.
|
|
2753
2846
|
const currentPane = await this.tmux.capturePane();
|
|
2847
|
+
// Every await below can outlive the spawn / monitors this poll started under: after each one, before
|
|
2848
|
+
// any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
|
|
2849
|
+
if (stale())
|
|
2850
|
+
return;
|
|
2754
2851
|
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
2755
2852
|
this.updateInputBlockedState(currentPane, dialogs);
|
|
2853
|
+
this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
|
|
2756
2854
|
return;
|
|
2757
2855
|
}
|
|
2856
|
+
// What is being answered: the request's identity when the backend can name it (verbatim, without whatever
|
|
2857
|
+
// ticks around it), else the whole screen.
|
|
2858
|
+
const before = Daemon.screenOf(dialog, currentPane);
|
|
2859
|
+
// Another request since the last answer: the count and the report belong to that one. Its parked clock goes
|
|
2860
|
+
// too only when the backend vouches for the identity — an unrecognised difference (a timer, a spinner) is
|
|
2861
|
+
// not evidence that the old request was answered, and the one-minute fallback must keep running.
|
|
2862
|
+
if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
|
|
2863
|
+
this.endDialogEpisode(dialog.requestIdentity !== undefined);
|
|
2758
2864
|
if (dialog.verifyAfterKeys && autoKey
|
|
2759
2865
|
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2760
2866
|
&& this.autoResolvedDialogKey === autoKey)
|
|
@@ -2773,17 +2879,24 @@ export class Daemon extends EventEmitter {
|
|
|
2773
2879
|
else {
|
|
2774
2880
|
sent = await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2775
2881
|
}
|
|
2882
|
+
if (stale())
|
|
2883
|
+
return;
|
|
2776
2884
|
if (!sent) {
|
|
2777
2885
|
if (dialog.verifyAfterKeys && autoKey) {
|
|
2778
2886
|
this.autoResolvedDialogGeneration = 0;
|
|
2779
2887
|
this.autoResolvedDialogKey = null;
|
|
2780
2888
|
}
|
|
2889
|
+
this.breakAnswerStreak(dialog);
|
|
2781
2890
|
return;
|
|
2782
2891
|
}
|
|
2783
2892
|
await new Promise(r => setTimeout(r, 200));
|
|
2893
|
+
if (stale())
|
|
2894
|
+
return;
|
|
2784
2895
|
}
|
|
2785
2896
|
if (dialog.verifyAfterKeys) {
|
|
2786
2897
|
const afterKeysPane = await this.tmux.capturePane();
|
|
2898
|
+
if (stale())
|
|
2899
|
+
return;
|
|
2787
2900
|
const dialogStillActive = dialog.inputBlocked
|
|
2788
2901
|
? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
|
|
2789
2902
|
: Daemon.dialogMatches(dialog, afterKeysPane);
|
|
@@ -2799,8 +2912,27 @@ export class Daemon extends EventEmitter {
|
|
|
2799
2912
|
resolved = true;
|
|
2800
2913
|
}
|
|
2801
2914
|
}
|
|
2915
|
+
else if (dialog.keys.length > 0) {
|
|
2916
|
+
// Did the keys take? Read the pane again. The same request still there is an answer that did nothing. A request
|
|
2917
|
+
// the backend names as DIFFERENT (the next queued one), or no dialog at all, means it took. Without a
|
|
2918
|
+
// backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
|
|
2919
|
+
// is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
|
|
2920
|
+
await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
|
|
2921
|
+
const afterPane = await this.tmux.capturePane();
|
|
2922
|
+
if (stale())
|
|
2923
|
+
return; // (nothing was touched since the last check: only a read)
|
|
2924
|
+
const stillThere = Daemon.dialogMatches(dialog, afterPane);
|
|
2925
|
+
const same = stillThere && Daemon.screenOf(dialog, afterPane) === before;
|
|
2926
|
+
this.noteDialogAnswer(dialog, same ? "ignored" : (!stillThere || dialog.requestIdentity) ? "took" : "unproven", before);
|
|
2927
|
+
}
|
|
2928
|
+
else {
|
|
2929
|
+
this.breakAnswerStreak(dialog);
|
|
2930
|
+
}
|
|
2802
2931
|
});
|
|
2932
|
+
if (stale())
|
|
2933
|
+
return; // the answer-and-look outlived its spawn / monitors: it commits nothing
|
|
2803
2934
|
if (!dismissed) {
|
|
2935
|
+
this.breakAnswerStreak(dialog);
|
|
2804
2936
|
this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
2805
2937
|
}
|
|
2806
2938
|
else if (dialog.verifyAfterKeys && !resolved) {
|
|
@@ -2809,6 +2941,11 @@ export class Daemon extends EventEmitter {
|
|
|
2809
2941
|
this.trackDialogParked(blockingSeen);
|
|
2810
2942
|
return; // Dialog handled (or deliberately deferred): skip error checks this cycle
|
|
2811
2943
|
}
|
|
2944
|
+
// Reached only when no dialog was answered this poll (none on screen, a fenced one, or one waiting out its
|
|
2945
|
+
// backoff): unless it is the one backing off, the screen that was being answered is gone, and the next dialog is
|
|
2946
|
+
// a new one.
|
|
2947
|
+
if (!answersBackedOff)
|
|
2948
|
+
this.dialogAnswers = null;
|
|
2812
2949
|
this.trackDialogParked(blockingSeen);
|
|
2813
2950
|
if (blockingSeen)
|
|
2814
2951
|
return; // held dialog: skip error checks this cycle
|
|
@@ -3402,8 +3539,9 @@ export class Daemon extends EventEmitter {
|
|
|
3402
3539
|
// Quit CLI FIRST — this kills MCP server child processes cleanly.
|
|
3403
3540
|
// IPC must stay open during quit so MCP servers receive the shutdown message.
|
|
3404
3541
|
if (this.tmux) {
|
|
3405
|
-
|
|
3542
|
+
// Paused before the (bounded) checkpoint wait, so a health tick cannot run in that gap.
|
|
3406
3543
|
this.healthCheckPaused = true;
|
|
3544
|
+
await this.checkpointSessionId();
|
|
3407
3545
|
let killed = false;
|
|
3408
3546
|
const windowId = this.tmux.getWindowId();
|
|
3409
3547
|
const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
|
|
@@ -3581,7 +3719,7 @@ export class Daemon extends EventEmitter {
|
|
|
3581
3719
|
this.freezeRuntimeMonitors();
|
|
3582
3720
|
const transition = (async () => {
|
|
3583
3721
|
try {
|
|
3584
|
-
this.
|
|
3722
|
+
await this.checkpointSessionId();
|
|
3585
3723
|
await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
|
|
3586
3724
|
let exited = false;
|
|
3587
3725
|
for (let i = 0; i < 15; i++) {
|
|
@@ -3759,8 +3897,10 @@ export class Daemon extends EventEmitter {
|
|
|
3759
3897
|
// fleet is SIGKILLed or the host reboots.
|
|
3760
3898
|
// Idle observations are safe checkpoints; the 60s safety sweep also gives
|
|
3761
3899
|
// us a bounded retry if session creation produced no visible state edge.
|
|
3900
|
+
// The discovery behind it is a CLI listing: it runs in the background (single-flight, at most
|
|
3901
|
+
// once per SESSION_CHECKPOINT_IDLE_THROTTLE_MS), never on this synchronous path (#1160).
|
|
3762
3902
|
if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
|
|
3763
|
-
this.
|
|
3903
|
+
this.checkpointSessionIdInBackground();
|
|
3764
3904
|
}
|
|
3765
3905
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
3766
3906
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
@@ -4601,6 +4741,7 @@ export class Daemon extends EventEmitter {
|
|
|
4601
4741
|
/** Stop every runtime poller/watcher while preserving IPC and daemon state. */
|
|
4602
4742
|
freezeRuntimeMonitors() {
|
|
4603
4743
|
this.runtimeMonitorsFrozen = true;
|
|
4744
|
+
this.launchFenceEpoch++;
|
|
4604
4745
|
if (this.healthCheckTimer) {
|
|
4605
4746
|
clearTimeout(this.healthCheckTimer);
|
|
4606
4747
|
this.healthCheckTimer = null;
|
|
@@ -5668,6 +5809,64 @@ export class Daemon extends EventEmitter {
|
|
|
5668
5809
|
this.trackDialogParked(null);
|
|
5669
5810
|
return { state: "clear" };
|
|
5670
5811
|
}
|
|
5812
|
+
/** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
|
|
5813
|
+
static screenOf(dialog, pane) {
|
|
5814
|
+
return dialog.requestIdentity?.(pane) ?? pane;
|
|
5815
|
+
}
|
|
5816
|
+
static answerKey(dialog) {
|
|
5817
|
+
return `${Daemon.dialogKey(dialog)}\0${dialog.description}`;
|
|
5818
|
+
}
|
|
5819
|
+
/**
|
|
5820
|
+
* The episode being counted is over: its count and its report. Its parked clock ends too (`clearParked`) only when the
|
|
5821
|
+
* dialog is gone or a different request is positively identified — not on a difference nobody can explain.
|
|
5822
|
+
*/
|
|
5823
|
+
endDialogEpisode(clearParked = true) {
|
|
5824
|
+
this.dialogAnswers = null;
|
|
5825
|
+
if (clearParked)
|
|
5826
|
+
this.trackDialogParked(null);
|
|
5827
|
+
}
|
|
5828
|
+
/**
|
|
5829
|
+
* A poll that did not complete an answer-and-look (lock busy, keys not sent, nothing to press) breaks "in a row" for
|
|
5830
|
+
* this dialog; if the dialog on screen is a DIFFERENT one, the episode being counted belongs to a screen that is gone.
|
|
5831
|
+
*/
|
|
5832
|
+
breakAnswerStreak(dialog) {
|
|
5833
|
+
if (!this.dialogAnswers)
|
|
5834
|
+
return;
|
|
5835
|
+
if (this.dialogAnswers.key === Daemon.answerKey(dialog))
|
|
5836
|
+
this.dialogAnswers.ignored = 0;
|
|
5837
|
+
else
|
|
5838
|
+
this.endDialogEpisode();
|
|
5839
|
+
}
|
|
5840
|
+
/**
|
|
5841
|
+
* One answer of the runtime scanner to a dialog, and whether the SAME dialog (same kind, same content) was still
|
|
5842
|
+
* on screen right after it.
|
|
5843
|
+
* An answer that took ends the episode (what shows next is a new dialog, so the parked clock restarts too);
|
|
5844
|
+
* DIALOG_ANSWER_IGNORED_MAX in a row that did not is reported ONCE per episode, for ANY backend's dialog —
|
|
5845
|
+
* not only the ones that hold deliveries — so a human is told in about a quarter of a minute instead of the
|
|
5846
|
+
* instance sitting there until a hang detector (and only with pending work) notices. The event carries the
|
|
5847
|
+
* dialog's static description and a count, never the pane: a pane can hold anything the agent printed.
|
|
5848
|
+
*/
|
|
5849
|
+
noteDialogAnswer(dialog, outcome, screen) {
|
|
5850
|
+
const key = Daemon.answerKey(dialog);
|
|
5851
|
+
if (!this.dialogAnswers || this.dialogAnswers.key !== key)
|
|
5852
|
+
this.dialogAnswers = { key, ignored: 0, reported: false, screen, lastAnswerAt: 0 }; // the exact screen being counted: every answer in the episode saw this same one
|
|
5853
|
+
const episode = this.dialogAnswers;
|
|
5854
|
+
if (outcome !== "ignored") {
|
|
5855
|
+
this.endDialogEpisode(outcome === "took");
|
|
5856
|
+
return;
|
|
5857
|
+
}
|
|
5858
|
+
episode.ignored++;
|
|
5859
|
+
episode.lastAnswerAt = Date.now();
|
|
5860
|
+
if (episode.ignored < DIALOG_ANSWER_IGNORED_MAX || episode.reported)
|
|
5861
|
+
return;
|
|
5862
|
+
episode.reported = true;
|
|
5863
|
+
// The time-based "parked" report would say the same thing again a minute later.
|
|
5864
|
+
if (this.dialogParkedKey === Daemon.dialogKey(dialog))
|
|
5865
|
+
this.dialogParkedReported = true;
|
|
5866
|
+
const holdsDeliveries = dialog.blocksDelivery === true || dialog.holdOnly === true;
|
|
5867
|
+
this.logger.warn({ dialog: dialog.description, attempts: episode.ignored, holdsDeliveries }, "Runtime dialog is ignoring AgEnD's answer — reporting for a human");
|
|
5868
|
+
this.emit("dialog_answer_ignored", { name: this.name, description: dialog.description, attempts: episode.ignored, holdsDeliveries });
|
|
5869
|
+
}
|
|
5671
5870
|
/**
|
|
5672
5871
|
* Remember how long a blocking dialog has been on screen. Once it has
|
|
5673
5872
|
* outlived DIALOG_PARKED_NOTIFY_MS — the auto-dismiss did not take, or the
|
|
@@ -7548,6 +7747,7 @@ export class Daemon extends EventEmitter {
|
|
|
7548
7747
|
* into exactly the window this exists to close.
|
|
7549
7748
|
*/
|
|
7550
7749
|
beginSpawn() {
|
|
7750
|
+
this.dialogAnswers = null;
|
|
7551
7751
|
// A relaunch settles any outstanding quit watch: the CLI came back.
|
|
7552
7752
|
this.clearQuitRelaunchWatch();
|
|
7553
7753
|
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
@@ -8032,7 +8232,7 @@ export class Daemon extends EventEmitter {
|
|
|
8032
8232
|
if (!claimed)
|
|
8033
8233
|
return;
|
|
8034
8234
|
try {
|
|
8035
|
-
this.saveSessionId();
|
|
8235
|
+
this.saveSessionId(); // the muse switch: a backend with no CLI lookup, nothing to wait for
|
|
8036
8236
|
const ready = await this.trySpawn(true, this.wakeBudgetMs(30_000));
|
|
8037
8237
|
this.transcriptMonitor?.resetOffset();
|
|
8038
8238
|
if (ready)
|
|
@@ -8123,6 +8323,7 @@ export class Daemon extends EventEmitter {
|
|
|
8123
8323
|
catch { /* non-fatal */ }
|
|
8124
8324
|
}
|
|
8125
8325
|
async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
|
|
8326
|
+
const launchFence = this.launchFenceEpoch;
|
|
8126
8327
|
const backendConfig = this.buildBackendConfig();
|
|
8127
8328
|
// Compare freshly-built instructions against the last value the agent was
|
|
8128
8329
|
// told about. Computed for ALL backends (not gated by
|
|
@@ -8158,6 +8359,16 @@ export class Daemon extends EventEmitter {
|
|
|
8158
8359
|
this.pendingInstructionsUpdate = backendConfig.instructions;
|
|
8159
8360
|
}
|
|
8160
8361
|
}
|
|
8362
|
+
// A backend that has to ask its CLI something before the launch command exists (OpenCode: does
|
|
8363
|
+
// this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
|
|
8364
|
+
try {
|
|
8365
|
+
await this.backend.prepareLaunch?.();
|
|
8366
|
+
}
|
|
8367
|
+
catch { /* unknown capability → the backend's conservative form */ }
|
|
8368
|
+
// Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
|
|
8369
|
+
// into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
|
|
8370
|
+
if (launchFence !== this.launchFenceEpoch)
|
|
8371
|
+
throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
|
|
8161
8372
|
this.backend.writeConfig(backendConfig);
|
|
8162
8373
|
this.backend.preTrust?.(this.config.working_directory);
|
|
8163
8374
|
// Resolve working directory (e.g. symlink for hidden paths)
|
|
@@ -8251,7 +8462,7 @@ export class Daemon extends EventEmitter {
|
|
|
8251
8462
|
});
|
|
8252
8463
|
if (reuseWindow && !this.config.lightweight) {
|
|
8253
8464
|
const outputLog = join(this.instanceDir, "output.log");
|
|
8254
|
-
|
|
8465
|
+
await rotateLogIfNeededAsync(outputLog);
|
|
8255
8466
|
await this.tmux.pipeOutput(outputLog).catch(err => {
|
|
8256
8467
|
this.logger.warn({ err }, "Failed to restore pipe-pane after wake");
|
|
8257
8468
|
});
|
|
@@ -8592,6 +8803,55 @@ export class Daemon extends EventEmitter {
|
|
|
8592
8803
|
}
|
|
8593
8804
|
catch { /* best effort */ }
|
|
8594
8805
|
}
|
|
8806
|
+
/**
|
|
8807
|
+
* How long a stop / pause / crash respawn / wake waits for a backend's session lookup before it
|
|
8808
|
+
* goes on with what it has (the cache or the persisted id). A missed checkpoint is retried at the
|
|
8809
|
+
* next idle observation — the same as when the lookup used to fail — and a clean stop must never
|
|
8810
|
+
* wait on a CLI listing for its whole 15 s timeout.
|
|
8811
|
+
*/
|
|
8812
|
+
static SESSION_CHECKPOINT_BUDGET_MS = 5_000;
|
|
8813
|
+
/** Idle observations ask a backend that needs a CLI lookup at most this often. */
|
|
8814
|
+
static SESSION_CHECKPOINT_IDLE_THROTTLE_MS = 30_000;
|
|
8815
|
+
lastIdleSessionRefreshAt = Number.NEGATIVE_INFINITY;
|
|
8816
|
+
/**
|
|
8817
|
+
* Persist the session id for a lifecycle transition. A backend that answers from a file (or not at
|
|
8818
|
+
* all) is saved at once, exactly as before. One that must ask a CLI (`refreshSessionId`, OpenCode)
|
|
8819
|
+
* is awaited — bounded — first, so the checkpoint is the CURRENT session, not a stale cache. The
|
|
8820
|
+
* lookup is the backend's own single-flight one: an idle observation already running it is joined,
|
|
8821
|
+
* not repeated. Never rejects.
|
|
8822
|
+
*/
|
|
8823
|
+
checkpointSessionId() {
|
|
8824
|
+
const refresh = this.backend?.refreshSessionId;
|
|
8825
|
+
if (this.skipResume || !refresh) {
|
|
8826
|
+
this.saveSessionId();
|
|
8827
|
+
return;
|
|
8828
|
+
}
|
|
8829
|
+
return this.refreshThenSave(refresh.call(this.backend), Daemon.SESSION_CHECKPOINT_BUDGET_MS);
|
|
8830
|
+
}
|
|
8831
|
+
async refreshThenSave(lookup, budgetMs) {
|
|
8832
|
+
let timer;
|
|
8833
|
+
const budget = new Promise(resolve => { timer = setTimeout(resolve, budgetMs); timer.unref?.(); });
|
|
8834
|
+
try {
|
|
8835
|
+
await Promise.race([lookup.then(() => undefined, () => undefined), budget]);
|
|
8836
|
+
}
|
|
8837
|
+
finally {
|
|
8838
|
+
if (timer)
|
|
8839
|
+
clearTimeout(timer);
|
|
8840
|
+
}
|
|
8841
|
+
this.saveSessionId();
|
|
8842
|
+
}
|
|
8843
|
+
/** The idle-observation checkpoint: persist what is known now, look again in the background at most every 30 s. */
|
|
8844
|
+
checkpointSessionIdInBackground() {
|
|
8845
|
+
this.saveSessionId();
|
|
8846
|
+
const refresh = this.backend?.refreshSessionId;
|
|
8847
|
+
if (this.skipResume || !refresh)
|
|
8848
|
+
return;
|
|
8849
|
+
const now = Date.now();
|
|
8850
|
+
if (now - this.lastIdleSessionRefreshAt < Daemon.SESSION_CHECKPOINT_IDLE_THROTTLE_MS)
|
|
8851
|
+
return;
|
|
8852
|
+
this.lastIdleSessionRefreshAt = now;
|
|
8853
|
+
void refresh.call(this.backend).then(() => this.saveSessionId(), () => { });
|
|
8854
|
+
}
|
|
8595
8855
|
saveSessionId() {
|
|
8596
8856
|
// When a resume failure has forced a fresh start, don't persist the stale id
|
|
8597
8857
|
// back from statusline.json — that would re-arm --continue and re-loop.
|