@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.
@@ -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
- // adds `--session-id <uuid> --output-format json`, parses the one-line JSON
289
- // stdout into {session_id, result, is_error}, calls router.touch on success
290
- // and router.recordExit on close. Per b1 flag-verification report, NEVER
291
- // combine `--resume` with `--session-id` (not needed: pre-minting + reusing
292
- // the same UUID across spawns is the resume mechanism).
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
- * @param {{systemPrompt:string, userPrompt:string, model:string, sessionId?:string|null, env?:object, bin?:string, seat?:{fields:object}}} o
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 cliResult = await (deps.runCLI || runClaudeCLI)(systemPrompt, userContent, model, {
924
- sessionId,
925
- router: key ? router : null,
926
- routingKey: key,
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 was withheld. Throwing here is what
933
- // keeps it off the wire; the reason rides along for the log.
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
- if [ -n "$SESSION_LABEL" ] && ! session_reconciled; then REASON="session-reconcile-failed"; fi
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 the two
290
- // one-time acknowledgements are recorded up front. Fail-open: a seat
291
- // still launches when this cannot be done — the watchdog below then
292
- // reports the blocked session instead of nothing at all. The engine has
293
- // no first-run dialogs, so an engine-cohort seat's ~/.claude.json is left alone.
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 will report it`);
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 = 0;
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
- const rec = attentionRecord({ reason: silence.reason, mux, muxName, since: startedAt, runMs: silence.runMs });
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 += 1;
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 += 1;
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