@cohortapp/agent-sdk 2.18.7 → 2.18.10
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/docs/runbooks/fleet-rollout.md +30 -0
- package/lib/comms/send-gate.mjs +28 -4
- package/lib/daemon/reply-debt.mjs +105 -0
- package/lib/org/inbound/directedness.mjs +60 -0
- package/lib/org/inbound/facts.mjs +29 -4
- package/lib/org/inbound/project.mjs +38 -1
- package/lib/org/inbound/surfaces.mjs +68 -0
- package/lib/runtime/adapter.mjs +39 -4
- package/lib/session/first-run.mjs +213 -16
- package/lib/telemetry/alerts.mjs +159 -2
- package/lib/telemetry/collect.mjs +27 -1
- package/package.json +1 -1
- package/policies/ai-disclosure.yaml +47 -0
- package/scripts/daemon/agent-daemon.mjs +206 -13
- package/scripts/daemon/assurance.mjs +5 -0
- package/scripts/daemon/deliver.mjs +79 -11
- package/scripts/daemon/dispatcher.mjs +68 -26
- package/scripts/daemon/lib/session-router.mjs +21 -0
- package/scripts/daemon/responder.mjs +92 -17
- package/scripts/local-triggers/autoupdate.sh +56 -1
- package/scripts/session/supervisor.mjs +70 -13
|
@@ -34,7 +34,12 @@ import {
|
|
|
34
34
|
routerItemFromDaemonItem,
|
|
35
35
|
claimSession,
|
|
36
36
|
releaseSession,
|
|
37
|
+
isSessionIdCollision,
|
|
37
38
|
} from "./lib/session-router.mjs";
|
|
39
|
+
// The seat's count of replies it owed and did not send (lib/daemon/reply-debt).
|
|
40
|
+
// Every silent end of this path bumps one of these names so the number rides
|
|
41
|
+
// the presence beat instead of dying in a log line nobody tails.
|
|
42
|
+
import { REPLY_DEBT_COUNTERS } from "../../lib/daemon/reply-debt.mjs";
|
|
38
43
|
// Permission scoping (security CRITICAL / audit H1). sessionPermissionArgs()
|
|
39
44
|
// preserves the historical "--dangerously-skip-permissions" by default and only
|
|
40
45
|
// scopes tools when the operator opts in (MAESTRO_SCOPED_PERMISSIONS=1), so the
|
|
@@ -284,12 +289,24 @@ function recordResponderCost({ json, model, durationMs, exitCode, unmeasuredReas
|
|
|
284
289
|
// so the caller can surface the failure.
|
|
285
290
|
//
|
|
286
291
|
// Session-router wire-up (b2-b4, cycle 474): when the caller supplies a
|
|
287
|
-
// `sessionId` (pre-minted UUID) and a `router` + `routingKey`, the spawn
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
292
|
+
// `sessionId` (pre-minted UUID) and a `router` + `routingKey`, the spawn adds
|
|
293
|
+
// the session flag for what it HOLDS — `--session-id <uuid>` for an id the
|
|
294
|
+
// router just minted, `--resume <uuid>` for one it decided to continue — plus
|
|
295
|
+
// `--output-format json`, parses the one-line JSON stdout into
|
|
296
|
+
// {session_id, result, is_error}, calls router.touch on success and
|
|
297
|
+
// router.recordExit on close. The two flags are never combined, and
|
|
298
|
+
// `sessionArgs()` in lib/runtime/adapter.mjs is the one place that chooses.
|
|
299
|
+
//
|
|
300
|
+
// ~~"Per b1 flag-verification report, NEVER combine `--resume` with
|
|
301
|
+
// `--session-id` (not needed: pre-minting + reusing the same UUID across
|
|
302
|
+
// spawns is the resume mechanism)"~~ — STRUCK 2026-09-25. The parenthetical
|
|
303
|
+
// is the false half and it is the half that costs replies: reusing a UUID is
|
|
304
|
+
// NOT the resume mechanism, because `--session-id` is refused once that id has
|
|
305
|
+
// a transcript (`Error: Session ID <uuid> is already in use.`, exit 1, before
|
|
306
|
+
// any model call). The router's RESUME decision returns exactly such an id, so
|
|
307
|
+
// this lane dropped every continued reply — four measured on this seat,
|
|
308
|
+
// 09-22..24. Only the first half survives: the flags remain mutually
|
|
309
|
+
// exclusive, which is what `resumeSession` below expresses.
|
|
293
310
|
//
|
|
294
311
|
// @returns {Promise<{ text: string, jsonResult: object|null, exitCode: number }>}
|
|
295
312
|
/**
|
|
@@ -305,9 +322,13 @@ function recordResponderCost({ json, model, durationMs, exitCode, unmeasuredReas
|
|
|
305
322
|
* W4-E1 (CF-44): `seat` is the seat's engine resolved once for this spawn
|
|
306
323
|
* (lib/runtime/seat-engine.mjs). A claude seat adds no fields, so its spawn is
|
|
307
324
|
* unchanged; an engine-cohort seat runs `cli.mjs run`, continuing --session-id.
|
|
308
|
-
*
|
|
325
|
+
* `resumeSession` distinguishes CONTINUING a transcript (`--resume <id>`) from
|
|
326
|
+
* starting one under a chosen id (`--session-id <id>`). Passing a used id to
|
|
327
|
+
* `--session-id` is what the CLI refuses with "Session ID … is already in use",
|
|
328
|
+
* and it was the session router's RESUME decision doing exactly that.
|
|
329
|
+
* @param {{systemPrompt:string, userPrompt:string, model:string, sessionId?:string|null, resumeSession?:boolean, env?:object, bin?:string, seat?:{fields:object}}} o
|
|
309
330
|
*/
|
|
310
|
-
export function responderSpawn({ systemPrompt, userPrompt, model, sessionId = null, env = process.env, bin = CLAUDE_BIN, seat = { fields: {} } }) {
|
|
331
|
+
export function responderSpawn({ systemPrompt, userPrompt, model, sessionId = null, resumeSession = false, env = process.env, bin = CLAUDE_BIN, seat = { fields: {} } }) {
|
|
311
332
|
return buildSpawn({
|
|
312
333
|
lane: "responder",
|
|
313
334
|
bin,
|
|
@@ -315,6 +336,7 @@ export function responderSpawn({ systemPrompt, userPrompt, model, sessionId = nu
|
|
|
315
336
|
systemPrompt,
|
|
316
337
|
prompt: userPrompt,
|
|
317
338
|
sessionId,
|
|
339
|
+
resumeSession,
|
|
318
340
|
permissions: sessionPermissionArgs({ source: "responder" }),
|
|
319
341
|
mcp: { source: "responder" },
|
|
320
342
|
env,
|
|
@@ -328,11 +350,11 @@ function runClaudeCLI(systemPrompt, userPrompt, model, opts = {}) {
|
|
|
328
350
|
}
|
|
329
351
|
|
|
330
352
|
function runSeatCLI(seat, systemPrompt, userPrompt, model, opts = {}) {
|
|
331
|
-
const { sessionId = null, router = null, routingKey = null } = opts;
|
|
353
|
+
const { sessionId = null, resumeSession = false, router = null, routingKey = null } = opts;
|
|
332
354
|
const startedAt = Date.now();
|
|
333
355
|
|
|
334
356
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
335
|
-
const spawnSpec = responderSpawn({ systemPrompt, userPrompt, model, sessionId, seat });
|
|
357
|
+
const spawnSpec = responderSpawn({ systemPrompt, userPrompt, model, sessionId, resumeSession, seat });
|
|
336
358
|
if (!spawnSpec.ok) {
|
|
337
359
|
rejectPromise(new Error(`claude CLI unavailable: ${spawnSpec.error.message}`));
|
|
338
360
|
return;
|
|
@@ -884,6 +906,7 @@ ${MESSAGE_CRAFT}`;
|
|
|
884
906
|
const routerItem = deriveRouterItem(item);
|
|
885
907
|
let key = null;
|
|
886
908
|
let sessionId = null;
|
|
909
|
+
let resumeSession = false;
|
|
887
910
|
if (routerItem) {
|
|
888
911
|
try {
|
|
889
912
|
const candidateKey = deriveRoutingKey(routerItem);
|
|
@@ -896,6 +919,11 @@ ${MESSAGE_CRAFT}`;
|
|
|
896
919
|
key = candidateKey;
|
|
897
920
|
if (decision.decision === "RESUME" && decision.resumeId) {
|
|
898
921
|
sessionId = decision.resumeId;
|
|
922
|
+
// CONTINUING a transcript is `--resume`. Handing this id to
|
|
923
|
+
// `--session-id` is what the CLI refuses outright, and it refused
|
|
924
|
+
// EVERY resume this router ever decided — see sessionArgs() in
|
|
925
|
+
// lib/runtime/adapter.mjs.
|
|
926
|
+
resumeSession = true;
|
|
899
927
|
} else {
|
|
900
928
|
// EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
|
|
901
929
|
// the same key on next call (with a different sessionId) is fine;
|
|
@@ -920,17 +948,64 @@ ${MESSAGE_CRAFT}`;
|
|
|
920
948
|
// and released in a `finally`: a throw anywhere in between must not leave the
|
|
921
949
|
// room's key claimed forever, which would silently disable continuity for it.
|
|
922
950
|
try {
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
951
|
+
const runCLI = deps.runCLI || runClaudeCLI;
|
|
952
|
+
let cliResult;
|
|
953
|
+
try {
|
|
954
|
+
cliResult = await runCLI(systemPrompt, userContent, model, {
|
|
955
|
+
sessionId,
|
|
956
|
+
resumeSession,
|
|
957
|
+
router: key ? router : null,
|
|
958
|
+
routingKey: key,
|
|
959
|
+
});
|
|
960
|
+
} catch (err) {
|
|
961
|
+
// "Session ID <uuid> is already in use" is an ADDRESSING fault, not a
|
|
962
|
+
// model failure: the CLI exits 1 in ~0.1s having called nothing, and the
|
|
963
|
+
// same prompt with a fresh id succeeds. Before `sessionArgs` this was
|
|
964
|
+
// every RESUME; it can still happen for an id another process on this box
|
|
965
|
+
// claimed between route() and spawn. Either way, dropping a person's
|
|
966
|
+
// reply over a name collision is indefensible — mint a new id and go
|
|
967
|
+
// once more. ONE retry, and it never resumes: the second attempt is
|
|
968
|
+
// deliberately cold, because whatever owns that transcript is not us.
|
|
969
|
+
if (!isSessionIdCollision(err)) throw err;
|
|
970
|
+
counters.bump(REPLY_DEBT_COUNTERS.sessionCollision, { key: key || "none", model: String(model || "unknown") });
|
|
971
|
+
const freshId = randomUUID();
|
|
972
|
+
console.warn(`[responder] session id ${sessionId} already in use — retrying once cold as ${freshId}`);
|
|
973
|
+
cliResult = await runCLI(systemPrompt, userContent, model, {
|
|
974
|
+
sessionId: freshId,
|
|
975
|
+
resumeSession: false,
|
|
976
|
+
router: key ? router : null,
|
|
977
|
+
routingKey: key,
|
|
978
|
+
});
|
|
979
|
+
// Recovered: a person got their answer. Counted separately from the
|
|
980
|
+
// collision so `machine.replyDebt.withheld` does not accuse this seat of
|
|
981
|
+
// a silence it did not commit.
|
|
982
|
+
counters.bump(REPLY_DEBT_COUNTERS.sessionCollisionRecovered, { key: key || "none" });
|
|
983
|
+
sessionId = freshId;
|
|
984
|
+
}
|
|
928
985
|
|
|
929
986
|
const text = (cliResult.text || "").trim();
|
|
930
987
|
if (!text) {
|
|
988
|
+
// COUNT THE SILENCE BEFORE THROWING IT.
|
|
989
|
+
//
|
|
931
990
|
// Empty text is "nothing to send" — including the fail-closed non-JSON
|
|
932
|
-
// path, where the raw model turn
|
|
933
|
-
//
|
|
991
|
+
// path, where the raw model turn (tool chatter, thinking, an un-enveloped
|
|
992
|
+
// draft) was withheld because it has reached the wire before. Failing
|
|
993
|
+
// closed is right. But from anywhere except this log file, a withheld
|
|
994
|
+
// reply and a quiet hour are the same picture, and that is how a seat
|
|
995
|
+
// stops answering people without anything saying so.
|
|
996
|
+
//
|
|
997
|
+
// MEASURED FIRST, THEN SIZED. This path fired ZERO times in this seat's
|
|
998
|
+
// daemon logs between 2026-09-21 (when the fail-closed change landed) and
|
|
999
|
+
// 2026-09-24 — every observed quick-reply failure in that window was a
|
|
1000
|
+
// session-id collision instead. So the fix is a counter and a number on
|
|
1001
|
+
// the beat, not a retry machine for a failure nobody has seen yet.
|
|
1002
|
+
if (cliResult.withheldChars !== undefined || cliResult.error) {
|
|
1003
|
+
counters.bump(REPLY_DEBT_COUNTERS.unparseable, {
|
|
1004
|
+
model: String(model || "unknown"),
|
|
1005
|
+
chars: Number(cliResult.withheldChars) || 0,
|
|
1006
|
+
service: String((item && item.service) || "unknown"),
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
934
1009
|
throw new Error(`claude CLI returned empty result text in generateResponse${cliResult.error ? ` (${cliResult.error}; ${cliResult.withheldChars ?? 0} chars withheld)` : ""}`);
|
|
935
1010
|
}
|
|
936
1011
|
|
|
@@ -417,6 +417,46 @@ restart_daemon(){
|
|
|
417
417
|
if [ -z "$DAEMON_LABEL" ]; then log "no -daemon plist under ~/Library/LaunchAgents — nothing to kickstart (maestro upgrade installs it; doctor will say)"; return 0; fi
|
|
418
418
|
launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1 || log "WARN: kickstart $DAEMON_LABEL nonzero"
|
|
419
419
|
}
|
|
420
|
+
session_beating(){ # is the front door actually ANSWERING, not merely loaded?
|
|
421
|
+
# `session_reconciled` asks whether the job is loaded and a supervisor process
|
|
422
|
+
# is alive. Both were true on the seats that went silent: on 2026-09-24 three
|
|
423
|
+
# front doors had been wedged on an unanswered modal for days with their
|
|
424
|
+
# supervisors up and their jobs loaded. A process that exists is not a
|
|
425
|
+
# colleague who answers — the same substitution this gate's daemon half was
|
|
426
|
+
# rewritten to stop making.
|
|
427
|
+
local hb="$AGENT_DIR/state/session/heartbeat.json" age
|
|
428
|
+
[ -f "$hb" ] || return 1
|
|
429
|
+
age="$(node -e '
|
|
430
|
+
try {
|
|
431
|
+
const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
432
|
+
const t = Date.parse(j && j.ts);
|
|
433
|
+
process.stdout.write(Number.isFinite(t) ? String(Math.round((Date.now() - t) / 1000)) : "");
|
|
434
|
+
} catch {}
|
|
435
|
+
' "$hb" 2>/dev/null)"
|
|
436
|
+
[ -n "$age" ] || return 1
|
|
437
|
+
[ "$age" -le "$SESSION_STALE_S" ]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
revive_session(){ # a wedged front door cannot restart itself; restart it FOR it
|
|
441
|
+
# The upgrade notice asks the session to restart "at an idle moment". A
|
|
442
|
+
# session sitting on a modal never reaches an idle moment, so the polite ask
|
|
443
|
+
# is exactly the wrong instrument for the one case that needs it. A hard
|
|
444
|
+
# kickstart drops a wedged session and the new supervisor launches a fresh
|
|
445
|
+
# one — which clears the modal by construction and, from this release,
|
|
446
|
+
# watches the pane so it does not happen again.
|
|
447
|
+
[ -n "$SESSION_LABEL" ] || return 0
|
|
448
|
+
# An UNLOADED job is a different fault with a different remedy (`maestro
|
|
449
|
+
# session start`), and kickstarting a label launchd does not know just fails.
|
|
450
|
+
# Leave that one to session_reconciled, which already names it.
|
|
451
|
+
label_loaded "$SESSION_LABEL" || return 0
|
|
452
|
+
session_beating && return 0
|
|
453
|
+
log "front door $SESSION_LABEL is loaded but not beating (> ${SESSION_STALE_S}s) — kickstarting it rather than waiting for a session that cannot answer"
|
|
454
|
+
launchctl kickstart -k "gui/$(id -u)/$SESSION_LABEL" >> "$LOG" 2>&1 \
|
|
455
|
+
&& { log "front door $SESSION_LABEL restarted"; return 0; }
|
|
456
|
+
log "WARN: could not kickstart $SESSION_LABEL (maestro session restart --force)"
|
|
457
|
+
return 1
|
|
458
|
+
}
|
|
459
|
+
|
|
420
460
|
session_reconciled(){ # only asked when a -session plist was generated; never a rollback reason
|
|
421
461
|
label_loaded "$SESSION_LABEL" || { log "reconcile-failed: session job $SESSION_LABEL is not loaded (maestro session start)"; return 1; }
|
|
422
462
|
pgrep -f "$AGENT_DIR/scripts/session/supervisor" >/dev/null 2>&1 || { log "reconcile-failed: $SESSION_LABEL is loaded but no supervisor process is alive for $AGENT_DIR (maestro session status)"; return 1; }
|
|
@@ -544,6 +584,12 @@ if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V |
|
|
|
544
584
|
if is_healthy; then
|
|
545
585
|
log "healthy on $CUR"
|
|
546
586
|
sibling_job_audit
|
|
587
|
+
# The up-to-date path is NOT exempt from the front door, for the same
|
|
588
|
+
# reason it stopped being exempt from the daemon: the seats that went
|
|
589
|
+
# silent for days were all "up to date". A daemon that beats while the
|
|
590
|
+
# front door is wedged is a seat that looks perfect from here and answers
|
|
591
|
+
# nobody.
|
|
592
|
+
revive_session || true
|
|
547
593
|
if [ -n "$STALE_HOP" ]; then
|
|
548
594
|
# A real version hop: complete it the way an automatic one completes —
|
|
549
595
|
# the notice tells the session to restart itself onto the new code.
|
|
@@ -597,6 +643,11 @@ fi
|
|
|
597
643
|
# installed version, never a LATEST). Fail-open: an unreadable last.json holds
|
|
598
644
|
# nothing.
|
|
599
645
|
FAILED_HOLD_S="${MAESTRO_AUTOUPDATE_FAILED_HOLD_S:-86400}"
|
|
646
|
+
# How long a front door may go without beating before this run restarts it FOR
|
|
647
|
+
# it. Ten minutes: long enough that a busy session is never interrupted (the
|
|
648
|
+
# heartbeat rides every tool use), short enough that a wedged one is measured in
|
|
649
|
+
# minutes rather than the days it took to notice the last three.
|
|
650
|
+
SESSION_STALE_S="${MAESTRO_SESSION_STALE_S:-600}"
|
|
600
651
|
failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health within FAILED_HOLD_S
|
|
601
652
|
node -e '
|
|
602
653
|
const [file, latest, holdS] = process.argv.slice(1);
|
|
@@ -668,7 +719,11 @@ restart_daemon
|
|
|
668
719
|
|
|
669
720
|
if is_healthy; then
|
|
670
721
|
REASON=""
|
|
671
|
-
|
|
722
|
+
# Revive BEFORE reconciling: the new code is installed, and a front door that
|
|
723
|
+
# is not beating must be restarted onto it rather than left holding an inbox
|
|
724
|
+
# it cannot answer.
|
|
725
|
+
revive_session || REASON="session-revive-failed"
|
|
726
|
+
if [ -n "$SESSION_LABEL" ] && ! session_reconciled; then REASON="${REASON:-session-reconcile-failed}"; fi
|
|
672
727
|
log "OK: healthy on $LATEST${REASON:+ ($REASON)}"
|
|
673
728
|
sibling_job_audit
|
|
674
729
|
write_upgrade_notice "$CUR" "$LATEST"
|
|
@@ -52,7 +52,7 @@ import { pathToFileURL } from "node:url";
|
|
|
52
52
|
import { resolveAgentRoot } from "../../lib/agent-root.mjs";
|
|
53
53
|
import { writeJsonAtomic as fsWriteJsonAtomic, writeFileAtomic as fsWriteFileAtomic } from "../../lib/fs-atomic.mjs";
|
|
54
54
|
import { parseHeartbeat } from "../../lib/session/liveness.mjs";
|
|
55
|
-
import { ensureClaudeConfig, heartbeatSilence, attentionRecord, HEARTBEAT_GRACE_MS } from "../../lib/session/first-run.mjs";
|
|
55
|
+
import { ensureClaudeConfig, heartbeatSilence, attentionRecord, carriedRestarts, HEARTBEAT_GRACE_MS } from "../../lib/session/first-run.mjs";
|
|
56
56
|
import { paneTail, classifyPane, watchdogAction, captureCommand, sendKeyCommand } from "../../lib/session/pane.mjs";
|
|
57
57
|
import { acquireLock as singletonAcquireLock } from "../../lib/singleton.js";
|
|
58
58
|
import { buildSpawn } from "../../lib/runtime/adapter.mjs";
|
|
@@ -96,6 +96,25 @@ function defaultAfter(ms, fn) {
|
|
|
96
96
|
return () => clearTimeout(t);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The installed Claude Code version, for the version-gated first-run screens.
|
|
101
|
+
*
|
|
102
|
+
* Fail-open to `null`: a version we could not read is never GUESSED, and
|
|
103
|
+
* `seedClaudeConfig` leaves the version-gated keys alone when it gets none.
|
|
104
|
+
* Writing an invented version would re-open the very screen the seeding exists
|
|
105
|
+
* to close, which is strictly worse than leaving it to the watchdog.
|
|
106
|
+
*
|
|
107
|
+
* @returns {Promise<string|null>}
|
|
108
|
+
*/
|
|
109
|
+
async function readCliVersion(bin, d) {
|
|
110
|
+
try {
|
|
111
|
+
const r = await d.execFile(bin, ["--version"]);
|
|
112
|
+
if (!r || r.code !== 0) return null;
|
|
113
|
+
const m = String(r.stdout || "").trim().match(/(\d+\.\d+\.\d+)/);
|
|
114
|
+
return m ? m[1] : null;
|
|
115
|
+
} catch { return null; }
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
/**
|
|
100
119
|
* Real `execFile`: spawn without a shell, capture stdout, resolve with the
|
|
101
120
|
* exit code (never reject on a non-zero code — that is a result, not a defect).
|
|
@@ -162,6 +181,7 @@ export async function runSupervisor(deps = {}) {
|
|
|
162
181
|
homeDir: deps.homeDir === undefined ? homedir() : deps.homeDir,
|
|
163
182
|
after: deps.after || defaultAfter,
|
|
164
183
|
heartbeatGraceMs: deps.heartbeatGraceMs ?? HEARTBEAT_GRACE_MS,
|
|
184
|
+
cliVersion: deps.cliVersion === undefined ? undefined : deps.cliVersion,
|
|
165
185
|
mkdirSync: deps.mkdirSync || fsMkdirSync,
|
|
166
186
|
unlinkSync: deps.unlinkSync || fsUnlinkSync,
|
|
167
187
|
now: deps.now || Date.now,
|
|
@@ -286,20 +306,48 @@ export async function runSupervisor(deps = {}) {
|
|
|
286
306
|
const envFile = engineCohort ? join(paths.stateDir, `engine-env-${d.envFileId()}.env`) : null;
|
|
287
307
|
const cmds = buildMuxCommands({ mux, muxName, cwd: agentRoot, claudeBin: spec.bin, claudeArgs, exitFile: paths.lastExitFile, envFile });
|
|
288
308
|
|
|
289
|
-
// 4b. First-run dialogs. Nobody is attached to press a key, so
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
309
|
+
// 4b. First-run dialogs. Nobody is attached to press a key, so every one-time
|
|
310
|
+
// acknowledgement is recorded up front. Fail-open: a seat still launches
|
|
311
|
+
// when this cannot be done — the watchdog below then reads the screen and
|
|
312
|
+
// acts on it instead of nothing at all. The engine has no first-run
|
|
313
|
+
// dialogs, so an engine-cohort seat's ~/.claude.json is left alone.
|
|
314
|
+
//
|
|
315
|
+
// Two of those screens are VERSION-GATED (`lastOnboardingVersion`,
|
|
316
|
+
// `lastReleaseNotesSeen`): the CLI compares them against the version it is
|
|
317
|
+
// RUNNING, so an upgrade re-opens a screen a seeded seat had already
|
|
318
|
+
// passed. That is the difference between "the seat was set up wrong" and
|
|
319
|
+
// "the seat was fine until Tuesday" — and it was the second on the seats
|
|
320
|
+
// that went silent inside an upgrade window. So the seeder has to be told
|
|
321
|
+
// which version is installed; an unreadable one is `null` and those keys
|
|
322
|
+
// are left alone rather than guessed.
|
|
323
|
+
const cliVersion = d.cliVersion !== undefined
|
|
324
|
+
? d.cliVersion
|
|
325
|
+
: (engineCohort ? null : await readCliVersion(spec.bin, d));
|
|
294
326
|
if (!engineCohort) {
|
|
295
327
|
const seeded = ensureClaudeConfig({
|
|
296
328
|
homeDir: d.homeDir, agentRoot, bypass: permissionArgs.includes("--dangerously-skip-permissions"),
|
|
329
|
+
cliVersion,
|
|
297
330
|
readFileSync: d.readFileSync, writeFileAtomic: d.writeFileAtomic,
|
|
298
331
|
});
|
|
299
|
-
if (seeded.changed) d.log(`${seeded.path} seeded for an unattended launch: ${seeded.applied.join(", ")}`);
|
|
300
|
-
else if (!seeded.ok) d.log(`${seeded.path || "~/.claude.json"} not seeded (${seeded.error}) — a first-run dialog may block the session; the watchdog
|
|
332
|
+
if (seeded.changed) d.log(`${seeded.path} seeded for an unattended launch${cliVersion ? ` (cli ${cliVersion})` : " (cli version unreadable — the version-gated screens were left alone)"}: ${seeded.applied.join(", ")}`);
|
|
333
|
+
else if (!seeded.ok) d.log(`${seeded.path || "~/.claude.json"} not seeded (${seeded.error}) — a first-run dialog may block the session; the watchdog reads the screen and acts on it`);
|
|
301
334
|
}
|
|
302
335
|
|
|
336
|
+
// THE RESTART BUDGET IS CARRIED, NOT RESET — and it has to be read HERE,
|
|
337
|
+
// before the unlink below destroys the record that holds it. Every restart
|
|
338
|
+
// the watchdog performs ENDS this process and launchd starts a fresh one, so
|
|
339
|
+
// `restarts` held in memory is zero on every launch and `watchdogAction`'s
|
|
340
|
+
// bound never binds: a seat blocked on something a relaunch cannot fix would
|
|
341
|
+
// restart every two minutes forever. `carriedRestarts` resets it on evidence
|
|
342
|
+
// of work — a beat stamped after the record — so a seat that recovered once
|
|
343
|
+
// gets a full budget for its next fault.
|
|
344
|
+
let priorAttention = null;
|
|
345
|
+
try { priorAttention = JSON.parse(d.readFileSync(paths.attentionFile, "utf8")); } catch { priorAttention = null; }
|
|
346
|
+
let priorHeartbeat = null;
|
|
347
|
+
try { priorHeartbeat = parseHeartbeat(d.readFileSync(paths.heartbeatFile, "utf8")); } catch { priorHeartbeat = null; }
|
|
348
|
+
const restartsSoFar = carriedRestarts({ attention: priorAttention, heartbeat: priorHeartbeat });
|
|
349
|
+
if (restartsSoFar > 0) d.log(`this launch carries ${restartsSoFar} silent restart(s) from the previous run`);
|
|
350
|
+
|
|
303
351
|
try { d.unlinkSync(paths.lastExitFile); } catch { /* none from a previous run */ }
|
|
304
352
|
try { d.unlinkSync(paths.attentionFile); } catch { /* none outstanding */ }
|
|
305
353
|
// 4c. An upgrade notice asking for a restart onto the version THIS launch
|
|
@@ -350,7 +398,7 @@ export async function runSupervisor(deps = {}) {
|
|
|
350
398
|
// worse failure than the one it replaces. Every pass records what it saw, so
|
|
351
399
|
// the note stops being a guess and becomes evidence.
|
|
352
400
|
let clears = 0;
|
|
353
|
-
let restarts =
|
|
401
|
+
let restarts = restartsSoFar;
|
|
354
402
|
let watchdogStopped = false;
|
|
355
403
|
let cancelPass = () => {};
|
|
356
404
|
const paneFile = join(agentRoot, "state", "session", "pane-capture.txt");
|
|
@@ -388,20 +436,29 @@ export async function runSupervisor(deps = {}) {
|
|
|
388
436
|
const seen = classifyPane(pane);
|
|
389
437
|
const act = watchdogAction({ silent: true, kind: seen.kind, keys: seen.keys, clears, restarts });
|
|
390
438
|
|
|
391
|
-
|
|
439
|
+
// THE RECORD COUNTS THE ACTION IT IS ABOUT TO TAKE, not the ones before it.
|
|
440
|
+
// A restart ENDS this process, so this write is the last chance to tell the
|
|
441
|
+
// next launch what was spent; recording the pre-action count would hand
|
|
442
|
+
// every relaunch a budget one short of the truth and the bound would never
|
|
443
|
+
// reach its limit.
|
|
444
|
+
const nextClears = act.act === "clear" ? clears + 1 : clears;
|
|
445
|
+
const nextRestarts = act.act === "restart" ? restarts + 1 : restarts;
|
|
446
|
+
const rec = attentionRecord({ reason: silence.reason, mux, muxName, since: startedAt, runMs: silence.runMs, restarts: nextRestarts, action: act.act });
|
|
392
447
|
// The record now carries WHAT THE SCREEN SAYS and what was done about it.
|
|
393
448
|
// `hint` keeps the attach line for a human who is at the machine, but it is
|
|
394
449
|
// no longer the only remedy on offer.
|
|
395
|
-
const record = { ...rec, modal: seen.kind, modalWhy: seen.why, pane, action: act.act, actionReason: act.reason, clears, restarts };
|
|
450
|
+
const record = { ...rec, modal: seen.kind, modalWhy: seen.why, pane, action: act.act, actionReason: act.reason, clears: nextClears, restarts: nextRestarts };
|
|
451
|
+
// Written BEFORE the stop, because the stop ends this process's ability to
|
|
452
|
+
// write anything at all.
|
|
396
453
|
try { d.writeJsonAtomic(paths.attentionFile, record); } catch { /* the log line still says it */ }
|
|
397
454
|
d.log(`session ${muxName} silent ${Math.round(silence.runMs / 1000)} s — screen shows: ${seen.kind} (${seen.why}); ${act.reason}`);
|
|
398
455
|
|
|
399
456
|
if (act.act === "clear") {
|
|
400
|
-
clears
|
|
457
|
+
clears = nextClears;
|
|
401
458
|
d.log(`session ${muxName}: answering ${seen.kind} with ${JSON.stringify(seen.choice || seen.keys)}`);
|
|
402
459
|
await sendKeys(seen.keys);
|
|
403
460
|
} else if (act.act === "restart") {
|
|
404
|
-
restarts
|
|
461
|
+
restarts = nextRestarts;
|
|
405
462
|
d.log(`session ${muxName}: restarting (${restarts}) — the session is not answering and the screen cannot be cleared safely`);
|
|
406
463
|
try { await d.execFile(cmds.stop.file, cmds.stop.args, { cwd: agentRoot, env: d.env }); } catch { /* the relaunch loop handles a dead mux */ }
|
|
407
464
|
watchdogStopped = true; // the supervisor's own loop relaunches; do not fight it
|