@cohortapp/agent-sdk 2.18.4 → 2.18.5

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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * lib/collective/capture-slots.mjs — bound the capture workers a machine runs.
3
+ *
4
+ * Every Stop/PreCompact on the machine spawns a detached capture worker, and
5
+ * each worker runs one or two `claude --print` passes (hundreds of MB of RSS
6
+ * apiece). With no ceiling, a burst of session ends — or any loop that feeds
7
+ * the hook — becomes an unbounded fan-out of model processes. Two locks keep it
8
+ * bounded:
9
+ *
10
+ * - SLOT locks — `slot-<i>.lock` for i < max. A worker must hold one to run
11
+ * its passes, so at most `max` capture workers do model work machine-wide.
12
+ * - SESSION locks — `session-<id>.lock`. One session is captured by at most
13
+ * one worker at a time (a PreCompact and the Stop that follows it must not
14
+ * distil the same transcript twice in parallel).
15
+ *
16
+ * Both are O_EXCL files (`{flag:"wx"}` — the lock idiom registered in
17
+ * scripts/ci/check-durable-write-seam.mjs) under ONE machine-wide directory, so
18
+ * two agent installs on a machine share the ceiling. A lock records its holder's
19
+ * pid; a lock whose pid is dead (or which is older than `maxAgeMs`, covering
20
+ * pid reuse) is reclaimed. Reclaim re-reads the lock and deletes it only if it
21
+ * still holds the exact bytes judged stale, so a lock freshly re-taken by
22
+ * another reclaimer is not deleted; the residual window between that read and
23
+ * the unlink can at worst admit one extra worker, never lose the ceiling.
24
+ *
25
+ * Expected failure is a returned `{ok:false, error}`; nothing here throws.
26
+ *
27
+ * @module lib/collective/capture-slots
28
+ */
29
+
30
+ "use strict";
31
+
32
+ import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import { randomBytes } from "node:crypto";
36
+
37
+ /** Default machine-wide ceiling on capture workers doing model work. */
38
+ export const DEFAULT_MAX_CONCURRENT = 2;
39
+ /** Default time a worker waits for a slot before recording a skipped capture. */
40
+ export const DEFAULT_QUEUE_WAIT_MS = 30_000;
41
+ /** Poll interval while queued for a slot. */
42
+ export const DEFAULT_POLL_MS = 1_000;
43
+ /**
44
+ * A lock older than this is stale even if its pid is alive (the pid was
45
+ * reused). Well above a worker's worst case: distil (60s) + reflect (90s) +
46
+ * index + queue wait.
47
+ */
48
+ export const DEFAULT_LOCK_MAX_AGE_MS = 15 * 60_000;
49
+ /** A lock with unreadable content younger than this is a holder mid-write, not a corpse. */
50
+ const UNREADABLE_GRACE_MS = 10_000;
51
+
52
+ /**
53
+ * The machine-wide lock directory. `MAESTRO_CAPTURE_LOCK_DIR` overrides it
54
+ * (tests, and a machine that wants the locks elsewhere).
55
+ * @param {Record<string, string|undefined>} [env]
56
+ * @returns {string}
57
+ */
58
+ export function captureLockDir(env = process.env) {
59
+ const override = env && env.MAESTRO_CAPTURE_LOCK_DIR;
60
+ return override && String(override).trim() ? String(override) : join(homedir(), ".claude", "maestro-capture-locks");
61
+ }
62
+
63
+ /**
64
+ * The configured ceiling, clamped to a positive integer. Absent/invalid → default.
65
+ * @param {object} [cfg] collective config
66
+ * @returns {number}
67
+ */
68
+ export function maxConcurrentFrom(cfg) {
69
+ const n = Number(cfg && cfg.captureMaxConcurrent);
70
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_MAX_CONCURRENT;
71
+ }
72
+
73
+ /**
74
+ * The configured queue wait in ms, clamped to >= 0. Absent/invalid → default.
75
+ * @param {object} [cfg] collective config
76
+ * @returns {number}
77
+ */
78
+ export function queueWaitFrom(cfg) {
79
+ const n = Number(cfg && cfg.captureQueueWaitMs);
80
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_QUEUE_WAIT_MS;
81
+ }
82
+
83
+ /** Is `pid` a live process? EPERM means alive (someone else's process). */
84
+ export function pidAlive(pid) {
85
+ if (!Number.isInteger(pid) || pid <= 0) return false;
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch (err) {
90
+ return !!err && err.code === "EPERM";
91
+ }
92
+ }
93
+
94
+ function sanitize(id) {
95
+ const s = String(id || "").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128);
96
+ return s || "unknown";
97
+ }
98
+
99
+ /** Path of the session lock for `sessionId`. */
100
+ export function sessionLockPath(dir, sessionId) {
101
+ return join(dir, `session-${sanitize(sessionId)}.lock`);
102
+ }
103
+
104
+ /** Path of slot `i`. */
105
+ export function slotLockPath(dir, i) {
106
+ return join(dir, `slot-${i}.lock`);
107
+ }
108
+
109
+ /**
110
+ * Is the lock whose raw bytes are `raw` (mtime `mtimeMs`) stale at `nowMs`?
111
+ * @returns {boolean}
112
+ */
113
+ function isStale(raw, mtimeMs, nowMs, deps) {
114
+ let holder = null;
115
+ try { holder = JSON.parse(raw); } catch { holder = null; }
116
+ if (!holder || !Number.isInteger(holder.pid)) {
117
+ // Empty/corrupt: a holder between open(wx) and write, or a crashed one.
118
+ return nowMs - mtimeMs > UNREADABLE_GRACE_MS;
119
+ }
120
+ const acquired = Date.parse(holder.acquiredAt || "");
121
+ const ageMs = Number.isFinite(acquired) ? nowMs - acquired : nowMs - mtimeMs;
122
+ if (ageMs > deps.maxAgeMs) return true;
123
+ return !deps.isAlive(holder.pid);
124
+ }
125
+
126
+ /**
127
+ * Try once to take the O_EXCL lock at `lockPath`, reclaiming a stale one.
128
+ *
129
+ * @param {string} lockPath
130
+ * @param {{pid?:number, sessionId?:string}} [owner]
131
+ * @param {{now?:()=>number, isAlive?:(pid:number)=>boolean, maxAgeMs?:number}} [deps]
132
+ * @returns {{ok:true, lockPath:string, release:()=>boolean, reclaimed:boolean} |
133
+ * {ok:false, error:{code:"held"|"io", message:string, holder?:object}}}
134
+ */
135
+ export function tryAcquireLock(lockPath, owner = {}, deps = {}) {
136
+ const d = {
137
+ now: typeof deps.now === "function" ? deps.now : Date.now,
138
+ isAlive: typeof deps.isAlive === "function" ? deps.isAlive : pidAlive,
139
+ maxAgeMs: Number.isFinite(deps.maxAgeMs) ? deps.maxAgeMs : DEFAULT_LOCK_MAX_AGE_MS,
140
+ };
141
+ const pid = Number.isInteger(owner.pid) ? owner.pid : process.pid;
142
+ const token = randomBytes(8).toString("hex");
143
+ const dir = dirname(lockPath);
144
+ try { mkdirSync(dir, { recursive: true }); } catch (err) {
145
+ return { ok: false, error: { code: "io", message: `lock dir: ${err && err.message}` } };
146
+ }
147
+ let reclaimed = false;
148
+ // Two attempts: the first may find a stale lock and reclaim it; the second
149
+ // takes the freed name (or loses it to a concurrent reclaimer, which is "held").
150
+ for (let attempt = 0; attempt < 2; attempt++) {
151
+ try {
152
+ writeFileSync(lockPath, JSON.stringify({ pid, sessionId: owner.sessionId || "", token, acquiredAt: new Date(d.now()).toISOString() }), { flag: "wx" });
153
+ const release = () => {
154
+ try {
155
+ const cur = JSON.parse(readFileSync(lockPath, "utf8"));
156
+ if (!cur || cur.token !== token) return false; // reclaimed from us after we went stale — not ours to delete
157
+ unlinkSync(lockPath);
158
+ return true;
159
+ } catch {
160
+ return false; // already gone (reclaimed or cleaned) — nothing to release
161
+ }
162
+ };
163
+ return { ok: true, lockPath, release, reclaimed };
164
+ } catch (err) {
165
+ if (!err || err.code !== "EEXIST") {
166
+ return { ok: false, error: { code: "io", message: `lock ${lockPath}: ${err && err.message}` } };
167
+ }
168
+ }
169
+ let raw = "";
170
+ let mtimeMs = 0;
171
+ try {
172
+ raw = readFileSync(lockPath, "utf8");
173
+ mtimeMs = statSync(lockPath).mtimeMs;
174
+ } catch {
175
+ continue; // the holder released between our create and read — retry the create
176
+ }
177
+ if (!isStale(raw, mtimeMs, d.now(), d)) {
178
+ let holder = null;
179
+ try { holder = JSON.parse(raw); } catch { holder = null; }
180
+ return { ok: false, error: { code: "held", message: `lock ${lockPath} is held`, holder: holder || undefined } };
181
+ }
182
+ try {
183
+ // Delete only the exact stale bytes we judged; a lock re-taken since is left alone.
184
+ if (readFileSync(lockPath, "utf8") === raw) { unlinkSync(lockPath); reclaimed = true; }
185
+ } catch { /* someone else reclaimed it first — the retry below races for the name fairly */ }
186
+ }
187
+ return { ok: false, error: { code: "held", message: `lock ${lockPath} is held (lost the reclaim race)` } };
188
+ }
189
+
190
+ /**
191
+ * Take any free slot among `max`, once.
192
+ * @param {string} dir
193
+ * @param {number} max
194
+ * @param {object} [owner]
195
+ * @param {object} [deps] as {@link tryAcquireLock}
196
+ * @returns {{ok:true, slot:number, release:()=>boolean, reclaimed:boolean} |
197
+ * {ok:false, error:{code:"no-slot"|"io", message:string}}}
198
+ */
199
+ export function tryAcquireSlot(dir, max, owner = {}, deps = {}) {
200
+ let ioError = null;
201
+ for (let i = 0; i < max; i++) {
202
+ const r = tryAcquireLock(slotLockPath(dir, i), owner, deps);
203
+ if (r.ok) return { ok: true, slot: i, release: r.release, reclaimed: r.reclaimed };
204
+ if (r.error.code === "io") ioError = r.error;
205
+ }
206
+ if (ioError) return { ok: false, error: ioError };
207
+ return { ok: false, error: { code: "no-slot", message: `all ${max} capture slots are held` } };
208
+ }
209
+
210
+ /**
211
+ * Wait up to `waitMs` for a slot, polling. Resolves `{ok:false, error:{code:"no-slot"}}`
212
+ * when the wait runs out — the caller records the skipped capture.
213
+ * @param {string} dir
214
+ * @param {number} max
215
+ * @param {object} [owner]
216
+ * @param {{waitMs?:number, pollMs?:number, sleep?:(ms:number)=>Promise<void>, now?:()=>number}} [opts]
217
+ * plus the {@link tryAcquireLock} deps
218
+ * @returns {Promise<{ok:true, slot:number, release:()=>boolean, waitedMs:number} |
219
+ * {ok:false, error:{code:string, message:string}, waitedMs:number}>}
220
+ */
221
+ export async function acquireSlotQueued(dir, max, owner = {}, opts = {}) {
222
+ const now = typeof opts.now === "function" ? opts.now : Date.now;
223
+ const sleep = typeof opts.sleep === "function" ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
224
+ const waitMs = Number.isFinite(opts.waitMs) ? Math.max(0, opts.waitMs) : DEFAULT_QUEUE_WAIT_MS;
225
+ const pollMs = Number.isFinite(opts.pollMs) && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
226
+ const start = now();
227
+ for (;;) {
228
+ const r = tryAcquireSlot(dir, max, owner, opts);
229
+ if (r.ok) return { ...r, waitedMs: now() - start };
230
+ if (r.error.code !== "no-slot") return { ...r, waitedMs: now() - start };
231
+ if (now() - start >= waitMs) return { ...r, waitedMs: now() - start };
232
+ await sleep(pollMs);
233
+ }
234
+ }
@@ -27,6 +27,8 @@ export const DEFAULTS = Object.freeze({
27
27
  captureOn: ["Stop", "PreCompact"],
28
28
  minTurnsToCapture: 2,
29
29
  maxCardsPerSession: 12,
30
+ captureMaxConcurrent: 2,
31
+ captureQueueWaitMs: 30000,
30
32
  repoDenylist: [],
31
33
  digestBudgetTokens: 1500,
32
34
  recencyHalfLifeDays: 30,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * lib/collective/loop-guard.mjs — keep the collective layer from capturing itself.
3
+ *
4
+ * The global hooks (`maestro global-setup`) fire on EVERY Claude Code session on
5
+ * the machine, and the capture worker's distiller, the reflect reviewer and the
6
+ * skill curator are themselves `claude --print` sessions. Without an exemption
7
+ * each of those children's Stop hook captures ITS OWN transcript — whose content
8
+ * is the capture prompt — which spawns another worker, whose children stop and
9
+ * capture again. That is a fork-bomb: nothing in the chain terminates it.
10
+ *
11
+ * Two independent signals mark a session as the collective layer's own child:
12
+ *
13
+ * 1. ENV — every collective spawn carries {@link COLLECTIVE_CHILD_ENV}=1
14
+ * (set by the runtime adapter for the capture/reflect/curator lanes, and by
15
+ * the capture worker for its whole process tree). The hook classifies such
16
+ * a session as kind `collective` and does nothing for it.
17
+ * 2. CONTENT — if the env did not propagate (an older runtime, a wrapper that
18
+ * scrubs the environment), the transcript's FIRST user message is one of
19
+ * the collective prompts. Those prompts are this module's own text, so the
20
+ * opener is a reliable fingerprint; a human session does not open with it.
21
+ *
22
+ * Pure except {@link readTranscriptHead}, which reads a bounded prefix of a file.
23
+ *
24
+ * @module lib/collective/loop-guard
25
+ */
26
+
27
+ "use strict";
28
+
29
+ import { openSync, readSync, closeSync } from "node:fs";
30
+
31
+ /** The env marker every `claude` process spawned by the collective layer carries. */
32
+ export const COLLECTIVE_CHILD_ENV = "MAESTRO_COLLECTIVE_CHILD";
33
+
34
+ /**
35
+ * The opening line of each collective prompt. A drift test pins each one to its
36
+ * prompt builder (capture.buildDistillPrompt, reflect.buildReviewPrompt,
37
+ * curator.buildConsolidationPrompt), so a reworded prompt cannot silently disarm
38
+ * the content fallback.
39
+ */
40
+ export const COLLECTIVE_PROMPT_SENTINELS = Object.freeze([
41
+ "You distil a finished work session into durable MEMORY CARDS",
42
+ "You are the self-improvement reviewer for an autonomous AI agent",
43
+ "You are the skill CURATOR for an autonomous AI agent",
44
+ ]);
45
+
46
+ /**
47
+ * How much of a transcript the content fallback reads. The prompt is the first
48
+ * user input; SessionStart attachments (skill listings, digests) can precede it
49
+ * by tens of KB, so the window is generous — and one bounded read per stop.
50
+ */
51
+ export const TRANSCRIPT_HEAD_BYTES = 512 * 1024;
52
+
53
+ /** How far into the first user message a sentinel may start (tolerates a harness preamble). */
54
+ const SENTINEL_WINDOW_CHARS = 2000;
55
+
56
+ /**
57
+ * Does this environment mark a collective child?
58
+ * @param {Record<string, string|undefined>} [env]
59
+ * @returns {boolean}
60
+ */
61
+ export function isCollectiveChildEnv(env = process.env) {
62
+ return !!env && String(env[COLLECTIVE_CHILD_ENV] || "").trim() === "1";
63
+ }
64
+
65
+ /**
66
+ * The session kind the hooks act on. `collective` wins over `daemon`: a
67
+ * collective child spawned from a daemon session inherits MAESTRO_DAEMON=1 and
68
+ * must still be exempt from capture.
69
+ * @param {{source?:string}} [input] the hook's stdin payload
70
+ * @param {Record<string, string|undefined>} [env]
71
+ * @returns {"collective"|"daemon"|"interactive"}
72
+ */
73
+ export function classifyKind(input = {}, env = process.env) {
74
+ if (isCollectiveChildEnv(env)) return "collective";
75
+ const src = String((input && input.source) || "").toLowerCase();
76
+ if (src.includes("daemon") || (env && env.MAESTRO_DAEMON === "1")) return "daemon";
77
+ return "interactive";
78
+ }
79
+
80
+ function messageText(obj) {
81
+ const c = obj.content ?? (obj.message && obj.message.content);
82
+ if (typeof c === "string") return c;
83
+ if (Array.isArray(c)) {
84
+ return c.map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : "")).filter(Boolean).join(" ");
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /**
90
+ * The text of the first user message in a JSONL transcript prefix, or "".
91
+ * A truncated last line is simply skipped.
92
+ * @param {string} head
93
+ * @returns {string}
94
+ */
95
+ export function firstUserMessage(head) {
96
+ if (!head) return "";
97
+ for (const raw of String(head).split("\n")) {
98
+ const line = raw.trim();
99
+ if (!line.startsWith("{")) continue;
100
+ let obj;
101
+ try { obj = JSON.parse(line); } catch { continue; /* a truncated/partial line is not a message */ }
102
+ // A `--print` prompt is recorded twice: first as the queued input
103
+ // (`queue-operation`/`enqueue`, ahead of any attachment lines), then as the
104
+ // first `user` message. Either one is the session's first user input.
105
+ const queued = obj.type === "queue-operation" && obj.operation === "enqueue";
106
+ const role = (obj.message && obj.message.role) || obj.role || obj.type || "";
107
+ if (role !== "user" && !queued) continue;
108
+ const text = messageText(obj);
109
+ if (text) return text;
110
+ }
111
+ return "";
112
+ }
113
+
114
+ /**
115
+ * Is this transcript a collective child's (its first user turn is a collective prompt)?
116
+ * @param {string} head a prefix of the transcript JSONL
117
+ * @returns {boolean}
118
+ */
119
+ export function isCollectivePromptTranscript(head) {
120
+ const first = firstUserMessage(head).slice(0, SENTINEL_WINDOW_CHARS);
121
+ if (!first) return false;
122
+ return COLLECTIVE_PROMPT_SENTINELS.some((s) => first.includes(s));
123
+ }
124
+
125
+ /**
126
+ * Read at most `bytes` from the start of a file. "" when absent/unreadable.
127
+ * @param {string} path
128
+ * @param {number} [bytes]
129
+ * @returns {string}
130
+ */
131
+ export function readTranscriptHead(path, bytes = TRANSCRIPT_HEAD_BYTES) {
132
+ if (!path) return "";
133
+ let fd = null;
134
+ try {
135
+ fd = openSync(path, "r");
136
+ const buf = Buffer.alloc(bytes);
137
+ const n = readSync(fd, buf, 0, bytes, 0);
138
+ return buf.subarray(0, n).toString("utf8");
139
+ } catch {
140
+ return ""; // no transcript → the content fallback has nothing to say; the env signal still governs
141
+ } finally {
142
+ if (fd !== null) { try { closeSync(fd); } catch { /* the read already completed or failed */ } }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Why a stop/capture event must NOT spawn a capture worker, or null when it may.
148
+ * @param {{kind:string, transcriptHead?:string}} o
149
+ * @returns {null|"collective-child-env"|"collective-child-transcript"}
150
+ */
151
+ export function captureExemption({ kind, transcriptHead = "" } = {}) {
152
+ if (kind === "collective") return "collective-child-env";
153
+ if (isCollectivePromptTranscript(transcriptHead)) return "collective-child-transcript";
154
+ return null;
155
+ }
@@ -118,6 +118,7 @@ import { buildClaudeArgs } from "../session/launch-args.mjs";
118
118
  import { cohortTierFor } from "../model-router/catalog.mjs";
119
119
  import { DEFAULT_LLM_BASE_URL, apiKeyHelperCommand } from "../org/llm-token.mjs";
120
120
  import { COHORT_BACKEND } from "../rate-guard.mjs";
121
+ import { COLLECTIVE_CHILD_ENV } from "../collective/loop-guard.mjs";
121
122
 
122
123
  /** Engines a seat can name in `runtime.engine`. */
123
124
  export const ENGINES = Object.freeze(["claude", "cohort"]);
@@ -201,6 +202,10 @@ export function subscriptionAuth(env) {
201
202
  * fragments `x = {perm, mcp, knobs}`; `stdin` says whether the prompt is
202
203
  * written to the child's stdin instead of argv; `posture` names the env shape;
203
204
  * `retarget` says whether a router retarget is legal on the lane.
205
+ * `collectiveChild` marks the collective layer's own model passes: their env
206
+ * carries MAESTRO_COLLECTIVE_CHILD=1 so the global Stop/PreCompact hook does not
207
+ * capture the child's transcript (lib/collective/loop-guard.mjs) — without it,
208
+ * every capture spawns a session whose end spawns another capture.
204
209
  */
205
210
  const LANES = Object.freeze({
206
211
  dispatcher: {
@@ -232,15 +237,15 @@ const LANES = Object.freeze({
232
237
  argv: (i) => ["--print", "--model", i.model, "-p", i.prompt],
233
238
  },
234
239
  capture: {
235
- posture: "path", stdin: true, retarget: false,
240
+ posture: "path", stdin: true, retarget: false, collectiveChild: true,
236
241
  argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
237
242
  },
238
243
  reflect: {
239
- posture: "path", stdin: true, retarget: false,
244
+ posture: "path", stdin: true, retarget: false, collectiveChild: true,
240
245
  argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
241
246
  },
242
247
  curator: {
243
- posture: "path", stdin: true, retarget: false,
248
+ posture: "path", stdin: true, retarget: false, collectiveChild: true,
244
249
  argv: (i, x) => ["--print", "--model", i.model, ...x.mcp],
245
250
  },
246
251
  enrich: {
@@ -394,7 +399,12 @@ export function buildSpawn(i = {}, deps = {}) {
394
399
  daemonArgs: deps.daemonArgs || daemonClaudeArgs,
395
400
  augmentedPath: deps.augmentedPath || augmentedPath,
396
401
  };
397
- const input = { ...i, env: i.env === undefined ? process.env : i.env };
402
+ // A collective lane's marker rides extraEnv so both engines set it after
403
+ // every merge; a caller cannot drop it by passing its own extraEnv.
404
+ const extraEnv = spec.collectiveChild
405
+ ? { ...(i.extraEnv && typeof i.extraEnv === "object" ? i.extraEnv : {}), [COLLECTIVE_CHILD_ENV]: "1" }
406
+ : i.extraEnv;
407
+ const input = { ...i, env: i.env === undefined ? process.env : i.env, extraEnv };
398
408
 
399
409
  if (engine === "cohort") return buildCohortEngineSpawn(spec, input, d, deps, fail);
400
410
 
@@ -411,8 +421,8 @@ export function buildSpawn(i = {}, deps = {}) {
411
421
  const argv = spec.argv(input, x);
412
422
 
413
423
  const env = envResult.env;
414
- if (i.extraEnv && typeof i.extraEnv === "object") {
415
- for (const [k, v] of Object.entries(i.extraEnv)) if (v !== undefined && v !== null) env[k] = String(v);
424
+ if (input.extraEnv && typeof input.extraEnv === "object") {
425
+ for (const [k, v] of Object.entries(input.extraEnv)) if (v !== undefined && v !== null) env[k] = String(v);
416
426
  }
417
427
 
418
428
  return {
@@ -84,7 +84,8 @@
84
84
  * // Also on `machine` (open record), so hq's fleet view can show it next
85
85
  * // to sdkVersion without a schema change. `ok` is the attempt's verdict:
86
86
  * // true = healthy on `to`; false = install failed or rolled back to `from`.
87
- * upgrade?: { at: ISO8601, from: string, to: string, ok: boolean },
87
+ * upgrade?: { at: ISO8601, from: string, to: string, ok: boolean,
88
+ * reason?: string, healthy?: boolean }, // reason: WHY a failure failed
88
89
  * // THE BEATING DAEMON — which process is emitting this beat, what code
89
90
  * // it is actually running, and the last health-gate verdict on it.
90
91
  * // Also on `machine` (open record). `sdkVersion` ABOVE is what is
@@ -1367,7 +1368,25 @@ export function upgradeSummary(last) {
1367
1368
  const at = typeof last.at === "string" ? last.at : "";
1368
1369
  const to = typeof last.to === "string" ? last.to : "";
1369
1370
  if (!at || !to) return null;
1370
- return { at, from: typeof last.from === "string" ? last.from : "", to, ok: last.ok === true };
1371
+ // `reason` and `healthy` are the two fields autoupdate.sh has always written
1372
+ // to state/autoupdate/last.json and this projection used to drop. Without
1373
+ // them a failed attempt reaches the org as a bare `ok:false` — which is the
1374
+ // state three seats were in on 2026-09-24: provably behind, provably having
1375
+ // tried, and no way to learn WHY short of reading a file on a machine nobody
1376
+ // could reach. "install-failed" and "unhealthy-rolled-back" are different
1377
+ // problems with different fixes and they looked identical from here.
1378
+ //
1379
+ // Bounded on the way out: a reason is a short token plus at most a trimmed
1380
+ // error line, and the beat is not a log shipper.
1381
+ const reason = typeof last.reason === "string" ? last.reason.trim().slice(0, 300) : "";
1382
+ return {
1383
+ at,
1384
+ from: typeof last.from === "string" ? last.from : "",
1385
+ to,
1386
+ ok: last.ok === true,
1387
+ ...(reason ? { reason } : {}),
1388
+ ...(typeof last.healthy === "boolean" ? { healthy: last.healthy } : {}),
1389
+ };
1371
1390
  }
1372
1391
 
1373
1392
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.4",
3
+ "version": "2.18.5",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,13 @@ captureOn: [Stop, PreCompact]
19
19
  minTurnsToCapture: 2
20
20
  # Hard cap on cards distilled from a single session (anti-bloat + cost guard).
21
21
  maxCardsPerSession: 12
22
+ # Machine-wide ceiling on capture workers doing model work at once. Each worker
23
+ # runs one or two `claude --print` passes; the ceiling keeps a burst of session
24
+ # ends from fanning out into unbounded model processes.
25
+ captureMaxConcurrent: 2
26
+ # How long a worker waits for a free slot before it gives up and records a
27
+ # skipped capture (counter `collective.capture_skipped`, reason `no-slot`).
28
+ captureQueueWaitMs: 30000
22
29
  # Repos/paths whose sessions must NOT contribute to memory (substring or glob,
23
30
  # matched against the session cwd). Client work, secrets, anything ring-fenced.
24
31
  # Information-barrier domains are always excluded regardless of this list.
@@ -42,7 +42,7 @@
42
42
  * `eslint.config.mjs`/`authz-drift.test.ts` use in the Cohort repo.
43
43
  *
44
44
  * Set equality also makes this guard self-canarying: the register is non-empty,
45
- * so a regex broken by a refactor yields zero matches and fails LOUDLY as three
45
+ * so a regex broken by a refactor yields zero matches and fails LOUDLY as
46
46
  * stale entries, rather than passing vacuously.
47
47
  *
48
48
  * Pure, dependency-light: Node builtins only. ESM.
@@ -71,6 +71,8 @@ export const SANCTIONED_DIRECT_WRITES = {
71
71
  "acquireScheduleLock: reclaim of a lock already proven stale by mtime; must land in place under the same name.",
72
72
  "lib/cadence-bus.mjs:328":
73
73
  "acquireScheduleLock: second O_EXCL attempt after the stale holder vanished.",
74
+ "lib/collective/capture-slots.mjs:152":
75
+ "tryAcquireLock: O_EXCL create of a capture slot/session lock (the machine-wide capture ceiling). A lock published by rename is not a lock — two workers could both hold one slot.",
74
76
  };
75
77
 
76
78
  /** Argument spellings that are already safe and must never be flagged. */
@@ -14,6 +14,15 @@
14
14
  * this isn't an agent machine or the layer is disabled. Capture runs in a
15
15
  * DETACHED child so it never blocks session exit.
16
16
  *
17
+ * Loop guard: the capture worker's own model passes are Claude Code sessions,
18
+ * so their hooks land here too. A session of kind `collective` (env
19
+ * MAESTRO_COLLECTIVE_CHILD=1), or whose transcript opens with a collective
20
+ * prompt, is exempt from every event — no presence, no primer, no capture —
21
+ * so a capture can never spawn a capture (lib/collective/loop-guard.mjs).
22
+ * Workers are further bounded machine-wide by slot + per-session O_EXCL locks
23
+ * (lib/collective/capture-slots.mjs); a worker that cannot get a slot records a
24
+ * skipped capture rather than running.
25
+ *
17
26
  * The agent repo this feeds is the runner's own repo, overridable with
18
27
  * MAESTRO_COLLECTIVE_ROOT (used by tests + multi-repo setups).
19
28
  *
@@ -26,6 +35,7 @@ import { fileURLToPath } from "node:url";
26
35
  import { dirname, join, basename } from "node:path";
27
36
  import { existsSync, readFileSync } from "node:fs";
28
37
  import { spawn } from "node:child_process";
38
+ import { classifyKind, captureExemption, readTranscriptHead, COLLECTIVE_CHILD_ENV } from "../../lib/collective/loop-guard.mjs";
29
39
 
30
40
  const THIS_FILE = fileURLToPath(import.meta.url);
31
41
  const SELF_ROOT = process.env.MAESTRO_COLLECTIVE_ROOT || join(dirname(THIS_FILE), "..", "..");
@@ -60,6 +70,7 @@ async function main() {
60
70
  const cwd = input.cwd || process.cwd();
61
71
  const sessionId = input.session_id || input.sessionId || `sess-${process.pid}`;
62
72
  const transcriptPath = input.transcript_path || input.transcriptPath || "";
73
+ const kind = classifyKind(input, process.env);
63
74
 
64
75
  let config, presence, isEnabled, isRepoDenied;
65
76
  try {
@@ -75,8 +86,15 @@ async function main() {
75
86
  const denied = isRepoDenied(cwd, config);
76
87
  const repo = basename(cwd || "");
77
88
 
89
+ // The collective layer's own children: nothing on any event. A capture/
90
+ // reflect/curator `claude --print` must not register presence, be primed, or
91
+ // — above all — be captured (that is the recursion). Counted, never silent.
92
+ if (kind === "collective") {
93
+ if (event === "stop" || event === "capture") await recordExemption("collective-child-env", sessionId);
94
+ return;
95
+ }
96
+
78
97
  if (event === "prime") {
79
- const kind = classifyKind(input);
80
98
  try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind, ...ownerFields(presence) }); } catch { /* */ }
81
99
  if (denied) return; // never inject agent memory (or the persona) into a ring-fenced/client session
82
100
  const parts = [];
@@ -118,7 +136,7 @@ async function main() {
118
136
  let beaten = false;
119
137
  try { beaten = presence.heartbeat(SELF_ROOT, sessionId); } catch { beaten = false; }
120
138
  if (!beaten) {
121
- try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind: classifyKind(input), ...ownerFields(presence) }); } catch { /* */ }
139
+ try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind, ...ownerFields(presence) }); } catch { /* */ }
122
140
  }
123
141
  // WS3: nudge the self-learning loop's tool-use counter. Cheap, fail-open,
124
142
  // and only when the layer is enabled + the repo isn't denylisted.
@@ -133,14 +151,51 @@ async function main() {
133
151
 
134
152
  if (event === "stop") {
135
153
  try { presence.deregister(SELF_ROOT, sessionId); } catch { /* */ }
136
- if (!denied) spawnCaptureWorker({ transcriptPath, cwd, sessionId });
154
+ if (!denied) await maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId });
137
155
  return;
138
156
  }
139
157
 
140
158
  if (event === "capture") {
141
- if (!denied) spawnCaptureWorker({ transcriptPath, cwd, sessionId });
159
+ if (!denied) await maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId });
160
+ return;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Spawn a capture worker unless this session is the collective layer's own
166
+ * child. The env marker was handled before dispatch; this catches a child whose
167
+ * env did not propagate, by its transcript (the first user input is one of the
168
+ * collective prompts).
169
+ */
170
+ async function maybeSpawnCaptureWorker({ kind, transcriptPath, cwd, sessionId }) {
171
+ const exempt = captureExemption({ kind, transcriptHead: readTranscriptHead(transcriptPath) });
172
+ if (exempt) {
173
+ await recordExemption(exempt, sessionId);
142
174
  return;
143
175
  }
176
+ spawnCaptureWorker({ transcriptPath, cwd, sessionId });
177
+ }
178
+
179
+ /** Count a capture the loop guard refused to start. Never rejects. */
180
+ function recordExemption(reason, sessionId) {
181
+ return countCapture("collective.capture_exempt", reason, sessionId);
182
+ }
183
+
184
+ /** Count a capture a worker gave up on (lock contention). Never rejects. */
185
+ function recordSkip(reason, sessionId, extra = {}) {
186
+ return countCapture("collective.capture_skipped", reason, sessionId, extra);
187
+ }
188
+
189
+ /**
190
+ * One durable counter row per refused/skipped capture, in the agent's
191
+ * logs/diagnostics/counters stream (what doctor reads). Dynamic import so a repo
192
+ * that predates lib/diagnostics stays silent rather than failing the hook.
193
+ */
194
+ async function countCapture(name, reason, sessionId, extra = {}) {
195
+ try {
196
+ const counters = await import("../../lib/diagnostics/counters.mjs");
197
+ counters.bump(name, { reason, sessionId: sessionId || "", ...extra }, { agentRoot: SELF_ROOT });
198
+ } catch { /* a counter must never break a session exit */ }
144
199
  }
145
200
 
146
201
  /** The identity block + status line for `prime`. "" when there is nothing to say. */
@@ -178,13 +233,6 @@ function ownerFields(presence) {
178
233
  }
179
234
  }
180
235
 
181
- function classifyKind(input) {
182
- const src = String(input.source || "").toLowerCase();
183
- if (src.includes("daemon") || process.env.MAESTRO_DAEMON === "1") return "daemon";
184
- if (src.includes("resume")) return "interactive";
185
- return "interactive";
186
- }
187
-
188
236
  /** Spawn the detached capture pass and return immediately. */
189
237
  function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
190
238
  try {
@@ -197,6 +245,9 @@ function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
197
245
  MAESTRO_CAPTURE_TRANSCRIPT_PATH: transcriptPath || "",
198
246
  MAESTRO_CAPTURE_CWD: cwd || "",
199
247
  MAESTRO_CAPTURE_SESSION: sessionId || "",
248
+ // The worker's whole process tree is the collective layer: every
249
+ // `claude` it spawns inherits this, so their hooks exempt themselves.
250
+ [COLLECTIVE_CHILD_ENV]: "1",
200
251
  },
201
252
  });
202
253
  child.unref();
@@ -214,23 +265,68 @@ function spawnCaptureWorker({ transcriptPath, cwd, sessionId }) {
214
265
  *
215
266
  * Both passes are independently fail-open: a throw in either is swallowed, the
216
267
  * worker still exits 0, and the session was never blocked.
268
+ *
269
+ * Before either pass the worker takes two O_EXCL locks (lib/collective/
270
+ * capture-slots.mjs): the SESSION lock (one worker per session at a time — a
271
+ * PreCompact and the Stop after it never distil in parallel) and then one of
272
+ * `captureMaxConcurrent` machine-wide SLOTS, queueing up to
273
+ * `captureQueueWaitMs`. Either refusal ends the worker with a counted skip
274
+ * (`collective.capture_skipped`); an unwritable lock dir fails CLOSED the same
275
+ * way, because an unbounded capture is the failure this exists to prevent.
217
276
  */
218
277
  async function doCaptureWorker() {
278
+ const transcriptPath = process.env.MAESTRO_CAPTURE_TRANSCRIPT_PATH || "";
279
+ const cwd = process.env.MAESTRO_CAPTURE_CWD || "";
280
+ const sessionId = process.env.MAESTRO_CAPTURE_SESSION || "";
281
+ let cfg, cap, text;
219
282
  try {
220
- const transcriptPath = process.env.MAESTRO_CAPTURE_TRANSCRIPT_PATH || "";
221
- const cwd = process.env.MAESTRO_CAPTURE_CWD || "";
222
- const sessionId = process.env.MAESTRO_CAPTURE_SESSION || "";
283
+ const cfgMod = await import("../../lib/collective/config.mjs");
284
+ cfg = await cfgMod.loadConfig(SELF_ROOT);
285
+ if (!cfgMod.isEnabled(cfg)) return;
223
286
  let raw = "";
224
287
  if (transcriptPath && existsSync(transcriptPath)) {
225
288
  try { raw = readFileSync(transcriptPath, "utf8"); } catch { raw = ""; }
226
289
  }
227
- const cap = await import("../../lib/collective/capture.mjs");
228
- const text = cap.transcriptToText(raw);
229
- if (!text) return;
230
- const cfgMod = await import("../../lib/collective/config.mjs");
231
- const cfg = await cfgMod.loadConfig(SELF_ROOT);
232
- if (!cfgMod.isEnabled(cfg)) return;
290
+ cap = await import("../../lib/collective/capture.mjs");
291
+ text = cap.transcriptToText(raw);
292
+ if (!text) return; // nothing to distil → no lock, no slot
293
+ } catch {
294
+ return; // libs unavailable / not an agent repo → nothing to capture into
295
+ }
296
+ let slots;
297
+ try {
298
+ slots = await import("../../lib/collective/capture-slots.mjs");
299
+ } catch {
300
+ await recordSkip("lock-unavailable", sessionId);
301
+ return; // no lock module → no ceiling → do not run
302
+ }
303
+ const dir = slots.captureLockDir(process.env);
304
+ const owner = { pid: process.pid, sessionId };
305
+ const session = slots.tryAcquireLock(slots.sessionLockPath(dir, sessionId), owner);
306
+ if (!session.ok) {
307
+ await recordSkip(session.error.code === "held" ? "session-busy" : "lock-io", sessionId);
308
+ return;
309
+ }
310
+ try {
311
+ const max = slots.maxConcurrentFrom(cfg);
312
+ const slot = await slots.acquireSlotQueued(dir, max, owner, { waitMs: slots.queueWaitFrom(cfg) });
313
+ if (!slot.ok) {
314
+ await recordSkip(slot.error.code === "no-slot" ? "no-slot" : "lock-io", sessionId, { max, waitedMs: slot.waitedMs });
315
+ return;
316
+ }
317
+ try {
318
+ await runCapturePasses({ cfg, cap, text, transcriptPath, cwd, sessionId });
319
+ } finally {
320
+ slot.release();
321
+ }
322
+ } finally {
323
+ session.release();
324
+ }
325
+ }
233
326
 
327
+ /** Pass A + Pass B over one read of the transcript. Runs only under both locks. */
328
+ async function runCapturePasses({ cfg, cap, text, transcriptPath, cwd, sessionId }) {
329
+ try {
234
330
  // ── Pass A — collective memory cards (existing behaviour, unchanged). ──
235
331
  const res = await cap.captureSession({ agentRoot: SELF_ROOT, sessionId, cwd, transcriptText: text, cfg });
236
332
  if (res.written > 0) {
@@ -98,7 +98,7 @@
98
98
 
99
99
  "use strict";
100
100
 
101
- import { readFileSync } from "node:fs";
101
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
102
102
  import { spawnSync } from "node:child_process";
103
103
  import { dirname, join, resolve as resolvePath } from "node:path";
104
104
  import { fileURLToPath } from "node:url";
@@ -712,9 +712,19 @@ export function npmWhoami(deps) {
712
712
  return { ok: false, error: (err || `exit ${r.code}`).trim() };
713
713
  }
714
714
 
715
- /** `npm view <pkg> version` → {ok,version,error?}. Needs no auth. */
715
+ /**
716
+ * `npm view <pkg> version` → {ok,version,error?}. Needs no auth.
717
+ *
718
+ * `--prefer-online` is load-bearing, not tidiness. npm caches registry metadata
719
+ * and revalidates it lazily, so straight after a successful publish this read
720
+ * kept answering with the PREVIOUS version from the local cache: 2.18.4 went to
721
+ * the registry, and all ten read-lag retries reported 2.17.0 from ~/.npm while
722
+ * a fresh shell already saw 2.18.4. The run then declared the publish stage
723
+ * failed on a package it had just published correctly. The retry loop cannot
724
+ * out-wait a cache that is never re-fetched.
725
+ */
716
726
  export function npmLatest(pkg, deps) {
717
- const r = deps.exec("npm", ["view", pkg, "version"]);
727
+ const r = deps.exec("npm", ["view", "--prefer-online", pkg, "version"]);
718
728
  if (r.ok && r.stdout) return { ok: true, version: r.stdout.split("\n").pop().trim() };
719
729
  return { ok: false, version: null, error: ((r.stderr || r.stdout || `exit ${r.code}`).split("\n")[0] || "").trim() };
720
730
  }
@@ -748,16 +758,57 @@ export function gitTag(version, deps) {
748
758
  * @param {object} o - { agentRoot, client?, seatWindowMs? }
749
759
  * @returns {Promise<{ok:boolean, error?:string, seats:object[], nonSeats:object[]}>}
750
760
  */
761
+ /**
762
+ * Which enrolled agent directory this run reads the org through.
763
+ *
764
+ * This script lives in the SDK repo, which is NOT an agent seat: it has no
765
+ * config/org.yaml. Run from ~/maestro with nothing set, `loadOrgConfig(undefined)`
766
+ * threw `The "path" argument must be of type string`, the verify loop treated
767
+ * that as a transient read failure, and it retried ninety times over two hours
768
+ * before reporting nothing — about a fleet that had in fact taken the release.
769
+ *
770
+ * Order: an explicit --agent-root / AGENT_ROOT / AGENT_DIR, then the cwd if it
771
+ * is itself a seat, then the one enrolled directory under $HOME. Several
772
+ * candidates is ambiguity, not a default: it says so and asks for the flag.
773
+ *
774
+ * @returns {{ok:true, root:string, how:string} | {ok:false, error:string}}
775
+ */
776
+ export function resolveAgentRoot(explicit, deps = {}) {
777
+ const exists = deps.exists || ((p) => existsSync(p));
778
+ const home = deps.home || process.env.HOME || "";
779
+ const cwd = deps.cwd || process.cwd();
780
+ const isSeat = (dir) => Boolean(dir) && exists(join(dir, "config", "org.yaml"));
781
+
782
+ if (explicit) {
783
+ if (isSeat(explicit)) return { ok: true, root: explicit, how: "given" };
784
+ return { ok: false, error: `--agent-root ${explicit} has no config/org.yaml — that is not an enrolled agent directory.` };
785
+ }
786
+ if (isSeat(cwd)) return { ok: true, root: cwd, how: "cwd" };
787
+
788
+ const found = (deps.listHome || (() => { try { return readdirSync(home); } catch { return []; } }))()
789
+ .map((name) => join(home, name))
790
+ .filter(isSeat);
791
+ if (found.length === 1) return { ok: true, root: found[0], how: "discovered under $HOME" };
792
+ if (found.length === 0) {
793
+ return { ok: false, error: `no enrolled agent directory found (looked at ${cwd} and $HOME/*/config/org.yaml) — pass --agent-root <dir>.` };
794
+ }
795
+ return { ok: false, error: `several enrolled agent directories (${found.join(", ")}) — pass --agent-root <dir> to say which one reads the fleet.` };
796
+ }
797
+
751
798
  export async function readFleet(o = {}) {
752
799
  const client = o.client || (await import("../../lib/org/client.mjs"));
800
+ const root = resolveAgentRoot(o.agentRoot);
801
+ // `fatal` means polling cannot help: the fault is in how this run was invoked,
802
+ // not in a momentarily unreachable server. The loop stops on it.
803
+ if (!root.ok) return { ok: false, fatal: true, error: root.error, seats: [], nonSeats: [] };
753
804
  let cfg;
754
805
  try {
755
- cfg = client.loadOrgConfig(o.agentRoot);
806
+ cfg = client.loadOrgConfig(root.root);
756
807
  } catch (err) {
757
- return { ok: false, error: `org config unreadable (${err && err.message})`, seats: [], nonSeats: [] };
808
+ return { ok: false, fatal: true, error: `org config unreadable at ${root.root} (${err && err.message})`, seats: [], nonSeats: [] };
758
809
  }
759
810
  if (!client.isEnabled(cfg)) {
760
- return { ok: false, error: "org integration is not enabled for this seat — cannot read the fleet", seats: [], nonSeats: [] };
811
+ return { ok: false, fatal: true, error: `org integration is not enabled for ${root.root} — cannot read the fleet`, seats: [], nonSeats: [] };
761
812
  }
762
813
  let r;
763
814
  try {
@@ -899,6 +950,12 @@ export async function verifyPropagation(o) {
899
950
  client: o.client,
900
951
  seatWindowMs: o.seatWindowMs,
901
952
  });
953
+ if (!fleet.ok && fleet.fatal) {
954
+ // Not transient: retrying changes nothing and the deadline would only
955
+ // delay the answer. Say what to do and stop.
956
+ deps.log(`\nfleet read cannot succeed as invoked — ${fleet.error}`);
957
+ return { ok: false, summary: null, rounds: round, fatal: true, error: fleet.error };
958
+ }
902
959
  if (!fleet.ok) {
903
960
  deps.log(`round ${round}: fleet read failed — ${fleet.error}`);
904
961
  } else {
@@ -212,12 +212,17 @@ J=$(( RANDOM % (JITTER_MAX + 1) )); log "wake; jitter ${J}s"; [ "$J" -gt 0 ] &&
212
212
  # ── retry helper (npm view / install) ────────────────────────────────────────
213
213
  RETRIES=3
214
214
  RETRY_SLEEP="${MAESTRO_AUTOUPDATE_RETRY_SLEEP:-20}"
215
+ # The last error line a retried command printed, for the reason the org reads.
216
+ # Set on every failed attempt, cleared on success, and deliberately the LAST
217
+ # npm/git line rather than the whole log: the beat is not a log shipper.
218
+ LAST_ERROR=""
215
219
  retry(){ # $1 = label; rest = command (stdout+stderr → LOG)
216
220
  local label="$1"; shift
217
221
  local attempt=1 wait
218
222
  while :; do
219
- if "$@" >> "$LOG" 2>&1; then return 0; fi
220
- if [ "$attempt" -ge "$RETRIES" ]; then log "$label: failed after $attempt attempt(s)"; return 1; fi
223
+ if "$@" >> "$LOG" 2>&1; then LAST_ERROR=""; return 0; fi
224
+ LAST_ERROR="$(grep -aE "npm (error|ERR!)|^Error|EACCES|EEXIST|ENOTEMPTY|ENOSPC|ETARGET|E404|E401|ENOENT" "$LOG" 2>/dev/null | tail -1 | cut -c1-200)"
225
+ if [ "$attempt" -ge "$RETRIES" ]; then log "$label: failed after $attempt attempt(s)${LAST_ERROR:+ — $LAST_ERROR}"; return 1; fi
221
226
  wait=$(( RETRY_SLEEP * attempt ))
222
227
  log "$label: attempt $attempt failed; retrying in ${wait}s"
223
228
  sleep "$wait"; attempt=$(( attempt + 1 ))
@@ -598,7 +603,12 @@ failed_hold_reason(){ # prints "<reason> at <at>" when LATEST failed health with
598
603
  try {
599
604
  const j = JSON.parse(require("fs").readFileSync(file, "utf8"));
600
605
  const at = Date.parse(j.at);
601
- if (j.to === latest && j.ok === false && /unhealthy/.test(String(j.reason)) && Number.isFinite(at) && Date.now() - at < Number(holdS) * 1000) process.stdout.write(`${j.reason} at ${j.at}`);
606
+ // Match the reason TOKEN, not the whole string: since 2.18.5 a reason may
607
+ // carry a trailing ": <npm error line>", and an error line that happened
608
+ // to contain the word "unhealthy" must not put a seat into a day-long
609
+ // hold it did not earn.
610
+ const token = String(j.reason).split(": ")[0];
611
+ if (j.to === latest && j.ok === false && /unhealthy/.test(token) && Number.isFinite(at) && Date.now() - at < Number(holdS) * 1000) process.stdout.write(`${j.reason} at ${j.at}`);
602
612
  } catch {}
603
613
  ' "$LAST_JSON" "$LATEST" "$FAILED_HOLD_S" 2>/dev/null
604
614
  }
@@ -646,7 +656,11 @@ migrate_poller_plist(){ # audit F6 follow-through: an older install left SLACK_U
646
656
 
647
657
  if ! apply_version "$LATEST"; then
648
658
  log "npm install FAILED; aborting, staying on $CUR"
649
- write_last "$CUR" "$LATEST" false false "install-failed"
659
+ # Name WHAT failed, not merely that something did. Three seats sat on
660
+ # `ok:false` with no reason for hours on 2026-09-24 and the difference between
661
+ # a disk-full box, a permissions problem and an unreachable registry is the
662
+ # difference between three different fixes.
663
+ write_last "$CUR" "$LATEST" false false "install-failed${LAST_ERROR:+: $LAST_ERROR}"
650
664
  exit 1
651
665
  fi
652
666
  restart_daemon