@cohortapp/agent-sdk 2.18.8 → 2.18.11

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.
@@ -2,16 +2,36 @@
2
2
  * lib/session/first-run.mjs — the dialogs an unattended session cannot answer.
3
3
  *
4
4
  * The supervisor launches an INTERACTIVE `claude` inside a multiplexer with
5
- * nobody attached. On a fresh seat that first launch shows two one-time
6
- * dialogs and waits for a keypress that never comes:
5
+ * nobody attached. That launch can stop on a one-time screen and wait for a
6
+ * keypress that never comes. Each such screen is recorded in `~/.claude.json`
7
+ * once it is answered, and the supervisor SEEDS those records before launch:
7
8
  *
8
9
  * - the bypass-permissions acknowledgement (`--dangerously-skip-permissions`),
9
- * recorded in `~/.claude.json` as `bypassPermissionsModeAccepted: true`;
10
+ * recorded as `bypassPermissionsModeAccepted: true`;
10
11
  * - the per-directory trust dialog, recorded as
11
- * `projects[<cwd>].hasTrustDialogAccepted: true`.
12
+ * `projects[<cwd>].hasTrustDialogAccepted: true`;
13
+ * - onboarding, recorded as `hasCompletedOnboarding: true` AND
14
+ * `lastOnboardingVersion: "<cli version>"`;
15
+ * - the release notes screen, recorded as `lastReleaseNotesSeen: "<cli version>"`.
12
16
  *
13
- * Neither is a secret and both are exactly what the operator answered when
14
- * they set the seat up by hand, so the supervisor SEEDS them before launch:
17
+ * ── WHY THE LAST TWO ARE NOT OPTIONAL: A SEEDED SEAT CAN GO BACK TO BLOCKED ──
18
+ * The first two are answered ONCE per seat and stay answered. The last two are
19
+ * VERSION-GATED: they are compared against the CLI version that is running, so
20
+ * an UPGRADE re-opens a screen a seeded seat had already passed. That is the
21
+ * difference between "the seat was set up wrong" and "the seat was fine until
22
+ * Tuesday", and it is the second that produced three seats which had been
23
+ * answering for weeks and then stopped inside an upgrade window.
24
+ *
25
+ * A seeder that hardcodes the screens it knows about is the autoupdater's
26
+ * lesson a second time: the check passed because it only ever asked the
27
+ * questions it already knew the answers to. {@link seedClaudeConfig} is
28
+ * therefore version-aware, and the supervisor's watchdog
29
+ * (`lib/session/pane#watchdogAction`) exists precisely because it CANNOT be
30
+ * complete — a screen this build has never heard of must still end in a running
31
+ * session, not in a three-day silence.
32
+ *
33
+ * Nothing here is a secret and every value is exactly what the operator
34
+ * answered when they set the seat up by hand:
15
35
  * {@link seedClaudeConfig} is the pure merge (adds only the missing flags,
16
36
  * preserves every other key, refuses to touch a malformed file), and
17
37
  * {@link ensureClaudeConfig} is its I/O shell (writes only when something
@@ -25,6 +45,22 @@
25
45
  * it and writes `state/session/attention.json` ({@link attentionRecord}) so
26
46
  * `doctor` can report it and an operator knows which command attaches.
27
47
  *
48
+ * ── AND THE ATTENTION RECORD IS NOT A FIX ────────────────────────────────────
49
+ * Writing that file used to be the whole of the response: the watchdog fired
50
+ * once, at the grace mark, wrote the record, logged a line telling a human to
51
+ * `tmux attach` — and then the supervisor sat in `waitForProbe` for as long as
52
+ * the blocked mux session existed. Days, on three seats. The record said
53
+ * "attach and answer it" to nobody, because the seats are unattended by
54
+ * design and there is no ssh into them.
55
+ *
56
+ * `lib/session/pane#watchdogAction` is the answer the seat can carry out
57
+ * itself: it reads the pane, answers a modal whose safe key is known, and
58
+ * otherwise RESTARTS — a relaunch re-runs {@link ensureClaudeConfig} against
59
+ * the version now installed and so has a real chance of clearing a
60
+ * version-gated screen. The restarts are BOUNDED, and {@link carriedRestarts}
61
+ * is what makes the bound bind: each restart ENDS this process, so a counter
62
+ * held in memory would be zero again on every relaunch.
63
+ *
28
64
  * Pure except the one I/O shell; no ambient clock, fs or home.
29
65
  *
30
66
  * @module lib/session/first-run
@@ -38,12 +74,58 @@ import { join } from "node:path";
38
74
  export const CLAUDE_CONFIG_FILE = ".claude.json";
39
75
  /** A session alive this long without a beat is flagged for attention. */
40
76
  export const HEARTBEAT_GRACE_MS = 120_000;
77
+ /**
78
+ * The `~/.claude.json` keys the CLI compares against the version it is running.
79
+ * A seat that has answered these once is asked again after an upgrade, which
80
+ * is why they are advanced on every launch rather than seeded once.
81
+ */
82
+ export const VERSION_GATED_KEYS = Object.freeze(["lastOnboardingVersion", "lastReleaseNotesSeen"]);
83
+
84
+ /**
85
+ * Pure: compare two dotted version strings. Anything unparseable is `null` —
86
+ * "cannot tell", which every caller treats as "do not touch the value".
87
+ *
88
+ * @param {unknown} a
89
+ * @param {unknown} b
90
+ * @returns {number|null} <0 if a<b, 0 if equal, >0 if a>b, null if either is unreadable
91
+ */
92
+ export function compareVersions(a, b) {
93
+ const parse = (v) => {
94
+ if (typeof v !== "string") return null;
95
+ const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
96
+ if (!m) return null;
97
+ return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
98
+ };
99
+ const x = parse(a);
100
+ const y = parse(b);
101
+ if (!x || !y) return null;
102
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] - y[i];
103
+ return 0;
104
+ }
41
105
 
42
106
  /**
43
- * Pure: merge the two acknowledgements into a `~/.claude.json` body.
107
+ * Pure: merge every one-time acknowledgement an unattended launch cannot give
108
+ * into a `~/.claude.json` body.
109
+ *
110
+ * TWO CLASSES OF KEY, and the difference is the whole point:
111
+ *
112
+ * - ONCE-EVER (`bypassPermissionsModeAccepted`, `hasCompletedOnboarding`,
113
+ * `projects[root].hasTrustDialogAccepted`) — written when absent, never
114
+ * rewritten. A seat passes these on its first launch and keeps them.
115
+ * - VERSION-GATED (`lastOnboardingVersion`, `lastReleaseNotesSeen`) — the CLI
116
+ * compares them against the version it is running, so they go STALE on
117
+ * upgrade and the screen comes back. These are advanced to `cliVersion`,
118
+ * and only forwards: a recorded version AHEAD of the installed one is left
119
+ * alone, because winding it back would re-open the very screen this exists
120
+ * to close.
121
+ *
122
+ * `cliVersion` is a PARAMETER. Without it the version-gated keys are not
123
+ * touched at all — a caller that cannot read the installed version must not
124
+ * guess one, and writing an invented version is strictly worse than leaving
125
+ * the screen to the watchdog.
44
126
  *
45
127
  * @param {string|null|undefined} text current file body (absent → empty)
46
- * @param {{agentRoot:string, bypass?:boolean}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions)
128
+ * @param {{agentRoot:string, bypass?:boolean, cliVersion?:string|null}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions); `cliVersion` — the installed Claude Code version, for the version-gated screens
47
129
  * @returns {{ok:true, changed:boolean, applied:string[], config:object}|{ok:false, changed:false, applied:[], error:string}}
48
130
  */
49
131
  export function seedClaudeConfig(text, a) {
@@ -58,6 +140,26 @@ export function seedClaudeConfig(text, a) {
58
140
  next.bypassPermissionsModeAccepted = true;
59
141
  applied.push("bypassPermissionsModeAccepted");
60
142
  }
143
+ if (next.hasCompletedOnboarding !== true) {
144
+ next.hasCompletedOnboarding = true;
145
+ applied.push("hasCompletedOnboarding");
146
+ }
147
+ // The version-gated pair. `compareVersions` returning null means one side is
148
+ // unreadable — treated as "leave it", never as "overwrite".
149
+ const cliVersion = typeof a.cliVersion === "string" && a.cliVersion.trim() ? a.cliVersion.trim() : null;
150
+ if (cliVersion) {
151
+ for (const key of VERSION_GATED_KEYS) {
152
+ const cur = next[key];
153
+ const cmp = compareVersions(cur, cliVersion);
154
+ // Absent (cmp === null with no current value) → seed it. Behind → advance
155
+ // it. Equal, ahead, or unreadable-but-present → leave it alone.
156
+ const absent = cur === undefined || cur === null || cur === "";
157
+ if (absent || (cmp !== null && cmp < 0)) {
158
+ next[key] = cliVersion;
159
+ applied.push(key);
160
+ }
161
+ }
162
+ }
61
163
  const projects = next.projects && typeof next.projects === "object" && !Array.isArray(next.projects) ? next.projects : {};
62
164
  const proj = projects[a.agentRoot] && typeof projects[a.agentRoot] === "object" && !Array.isArray(projects[a.agentRoot]) ? projects[a.agentRoot] : {};
63
165
  if (proj.hasTrustDialogAccepted !== true) {
@@ -72,7 +174,7 @@ export function seedClaudeConfig(text, a) {
72
174
  * Result frame; never throws (a seat must still launch when this fails —
73
175
  * the dialog then blocks and {@link heartbeatSilence} reports it).
74
176
  *
75
- * @param {{homeDir:string, agentRoot:string, bypass?:boolean, readFileSync:Function, writeFileAtomic:Function}} a
177
+ * @param {{homeDir:string, agentRoot:string, bypass?:boolean, cliVersion?:string|null, readFileSync:Function, writeFileAtomic:Function}} a
76
178
  * @returns {{ok:boolean, changed:boolean, applied:string[], path:string|null, error?:string}}
77
179
  */
78
180
  export function ensureClaudeConfig(a) {
@@ -83,7 +185,7 @@ export function ensureClaudeConfig(a) {
83
185
  if (!err || err.code !== "ENOENT") return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
84
186
  text = null; // first launch on this account
85
187
  }
86
- const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass });
188
+ const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass, cliVersion: a.cliVersion });
87
189
  if (!seeded.ok) return { ok: false, changed: false, applied: [], path, error: seeded.error };
88
190
  if (!seeded.changed) return { ok: true, changed: false, applied: [], path };
89
191
  try {
@@ -95,17 +197,45 @@ export function ensureClaudeConfig(a) {
95
197
  }
96
198
 
97
199
  /**
98
- * Pure: has the session run past the grace period without beating?
200
+ * Pure: is the front door silent right NOW?
201
+ *
202
+ * ── THE BUG THIS SHAPE EXISTS TO NOT HAVE ─────────────────────────────────
203
+ * This used to ask `ts >= startedAt` — "has it beaten since this launch?" — and
204
+ * answer `{silent:false, reason:"beating"}` for any beat at all, however old. A
205
+ * session that beat once thirty seconds after launch and then died was
206
+ * therefore "beating" for the rest of its life. Running the real module on
207
+ * Hannah's measured seat made the size of that plain:
208
+ * `{startedAt:T0, heartbeat:{ts:T0+30s}, now:T0+65h}` returned
209
+ * `{"action":"wait","reason":"beating"}` — 65 hours of silence read as health,
210
+ * no restart ever issued, no attention record ever written. That is the
211
+ * `heartbeat-stale` shape the fleet surface was reporting the whole time, and
212
+ * it is why making the watchdog RUN more often changed nothing: the predicate
213
+ * it ran never flipped.
214
+ *
215
+ * So the beat is compared to `now`, not to `startedAt`. `startedAt` still has
216
+ * one job — telling a beat from a PREVIOUS run (`stale-heartbeat`) apart from
217
+ * one this run produced and then stopped (`beat-stopped`) — because those two
218
+ * are fixed differently: the first means the session never got going, the
219
+ * second means it got going and wedged.
220
+ *
99
221
  * @param {{startedAt:number, heartbeat:{ts:string}|null, now:number, graceMs?:number}} a
100
- * @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat", runMs:number}}
222
+ * @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat"|"beat-stopped", runMs:number, sinceBeatMs?:number}}
101
223
  */
102
224
  export function heartbeatSilence(a) {
103
225
  const graceMs = a.graceMs ?? HEARTBEAT_GRACE_MS;
104
226
  const runMs = Number(a.now) - Number(a.startedAt);
105
227
  if (runMs < graceMs) return { silent: false, reason: "within-grace", runMs };
106
228
  const ts = a.heartbeat && typeof a.heartbeat.ts === "string" ? Date.parse(a.heartbeat.ts) : NaN;
107
- if (Number.isFinite(ts) && ts >= Number(a.startedAt)) return { silent: false, reason: "beating", runMs };
108
- return { silent: true, reason: Number.isFinite(ts) ? "stale-heartbeat" : "no-heartbeat", runMs };
229
+ if (!Number.isFinite(ts)) return { silent: true, reason: "no-heartbeat", runMs };
230
+ // A beat stamped BEFORE this launch belongs to a previous run: this session
231
+ // has never reported in, which is the same fault as no file at all.
232
+ if (ts < Number(a.startedAt)) return { silent: true, reason: "stale-heartbeat", runMs };
233
+ const sinceBeatMs = Number(a.now) - ts;
234
+ // A beat from the FUTURE is clock skew, not liveness. Treated as fresh (the
235
+ // bias is towards not restarting a session on the strength of a bad clock),
236
+ // which `Math.max` below makes explicit rather than accidental.
237
+ if (Math.max(0, sinceBeatMs) > graceMs) return { silent: true, reason: "beat-stopped", runMs, sinceBeatMs };
238
+ return { silent: false, reason: "beating", runMs, sinceBeatMs };
109
239
  }
110
240
 
111
241
  /**
@@ -114,13 +244,80 @@ export function heartbeatSilence(a) {
114
244
  */
115
245
  export function attentionRecord(a) {
116
246
  const attach = a.mux && a.mux.kind === "tmux" ? `tmux attach -t =${a.muxName}` : `screen -r ${a.muxName}`;
247
+ const restarts = Number.isFinite(Number(a.restarts)) && Number(a.restarts) > 0 ? Math.floor(Number(a.restarts)) : 0;
248
+ const action = a.action === "restart" || a.action === "give-up" || a.action === "clear" ? a.action : "wait";
249
+ const secs = Math.round(a.runMs / 1000);
250
+ // THE HINT IS READ BY SOMEBODY WHO CANNOT REACH THE MACHINE. It rides the
251
+ // presence beat to hq (`machine.sessionNote.detail`) and is the only sentence
252
+ // an operator in a browser gets. "Attach and answer it" was the entire text
253
+ // for three days on a seat nobody could ssh into; what it owes the reader now
254
+ // is what the seat has ALREADY tried, so the reader knows whether a restart
255
+ // is still coming or whether this is where it stops.
256
+ const tried = restarts > 0 ? ` Restarted ${restarts}× already and it still has not reported in.` : "";
257
+ const next = action === "restart"
258
+ ? " Restarting it now."
259
+ : action === "give-up"
260
+ ? " Out of automatic restarts — this needs a person at the machine."
261
+ : action === "clear"
262
+ ? " Answering the screen now."
263
+ : "";
117
264
  return {
118
265
  reason: a.reason,
119
266
  since: new Date(Number(a.since)).toISOString(),
120
267
  runMs: a.runMs,
268
+ restarts,
269
+ action,
121
270
  attach,
122
- hint: `The session has been up ${Math.round(a.runMs / 1000)} s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`${attach}\` and answer it.`,
271
+ // WHAT THE SCREEN ACTUALLY SAYS IS NOT GUESSED HERE. The old text asserted
272
+ // "probably waiting on a first-run dialog or a permission prompt" and was
273
+ // wrong on every seat it was read off: the real modal was the
274
+ // subscription-limit chooser, and the trust dialog it blamed is
275
+ // pre-answered at launch by `ensureClaudeConfig`. The diagnosis belongs to
276
+ // the watchdog, which reads the pane and writes `modal`/`modalWhy` beside
277
+ // this record; the hint states only what this function can prove.
278
+ hint: `The session has been up ${secs} s without a heartbeat (${a.reason}).${tried}${next} Attach with \`${attach}\` and answer it.`,
123
279
  };
124
280
  }
125
281
 
126
- export default { CLAUDE_CONFIG_FILE, HEARTBEAT_GRACE_MS, seedClaudeConfig, ensureClaudeConfig, heartbeatSilence, attentionRecord };
282
+ /**
283
+ * Pure: how many silent restarts this launch inherits.
284
+ *
285
+ * The counter has to survive the restart it authorises — each restart ENDS the
286
+ * supervisor process and launchd starts a new one, so a count held in memory
287
+ * would reset to zero every time and the bound in `pane#watchdogAction` would
288
+ * never bind. It rides `state/session/attention.json`, the record the previous
289
+ * run already wrote for exactly this session.
290
+ *
291
+ * It resets on EVIDENCE OF WORK and on nothing else: a heartbeat stamped after
292
+ * the attention record means the session that record describes went on to run,
293
+ * so the next blockage is a new fault and gets its own budget. An attention
294
+ * record with no usable `since` cannot be ordered against a beat, and a real
295
+ * beat is the harder evidence, so that also resets — the bias is towards
296
+ * trying again, never towards giving up early on a seat that recovered once.
297
+ *
298
+ * @param {{attention:object|null, heartbeat:{ts?:string}|null}} a
299
+ * @returns {number}
300
+ */
301
+ export function carriedRestarts(a = {}) {
302
+ const att = a.attention && typeof a.attention === "object" && !Array.isArray(a.attention) ? a.attention : null;
303
+ if (!att) return 0;
304
+ const n = Number(att.restarts);
305
+ const carried = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
306
+ if (carried === 0) return 0;
307
+ const attMs = Date.parse(typeof att.since === "string" ? att.since : "");
308
+ const hbMs = Date.parse(a.heartbeat && typeof a.heartbeat.ts === "string" ? a.heartbeat.ts : "");
309
+ if (Number.isFinite(hbMs) && (!Number.isFinite(attMs) || hbMs > attMs)) return 0;
310
+ return carried;
311
+ }
312
+
313
+ export default {
314
+ CLAUDE_CONFIG_FILE,
315
+ HEARTBEAT_GRACE_MS,
316
+ VERSION_GATED_KEYS,
317
+ compareVersions,
318
+ seedClaudeConfig,
319
+ ensureClaudeConfig,
320
+ heartbeatSilence,
321
+ carriedRestarts,
322
+ attentionRecord,
323
+ };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * lib/session/revive.mjs — the decision to restart a front door that has
3
+ * stopped answering, made from OUTSIDE the process that is stuck.
4
+ *
5
+ * ── WHY THIS IS NOT IN THE SUPERVISOR ──────────────────────────────────────
6
+ *
7
+ * The supervisor already watches its own session: since 2.18.7 it captures the
8
+ * pane, names the modal and answers one whose answer is free. That covers a
9
+ * session which wedges while the supervisor is healthy, and it is the right
10
+ * place for it.
11
+ *
12
+ * It cannot cover the case that actually held this fleet. James Kirkland's
13
+ * front door had been shut for 3.5 days and Isla Roselli's for 15, and their
14
+ * supervisors never relaunched once in that time: each was parked in its own
15
+ * launch probe, waiting on a mux session that would never report ready.
16
+ * Installing a newer SDK does not restart a process that is already stuck, so
17
+ * every fix shipped into the supervisor arrived somewhere it could not run.
18
+ *
19
+ * The hourly autoupdate job can restart it, and does since 2.18.8. An hour is
20
+ * a long time to be unable to answer anybody, and it only happens on the runs
21
+ * that get that far.
22
+ *
23
+ * The daemon is the honest home for this. It is a separate process, it is
24
+ * already alive on every seat that beats, it already reads the front-door
25
+ * state every poll to decide who owns the inbox, and it runs every thirty
26
+ * seconds. A seat whose door shuts is then measured in a minute rather than a
27
+ * day, by something the shut door cannot take down with it.
28
+ *
29
+ * Pure: the decision only. The daemon owns the kickstart.
30
+ */
31
+
32
+ /** Default silence before the daemon restarts the front door. */
33
+ export const DEFAULT_REVIVE_AFTER_MS = 10 * 60 * 1000;
34
+
35
+ /** Default quiet period between attempts. */
36
+ export const DEFAULT_REVIVE_BACKOFF_MS = 15 * 60 * 1000;
37
+
38
+ /** How many restarts before the daemon stops and leaves it to a person. */
39
+ export const DEFAULT_REVIVE_MAX = 3;
40
+
41
+ /**
42
+ * Should the daemon restart the front door right now?
43
+ *
44
+ * The caller supplies the front-door state it already reads for dispatch, the
45
+ * age of the session heartbeat, and what this daemon has already tried. Every
46
+ * bound is explicit because the failure mode of getting this wrong is a seat
47
+ * that restarts its own session every thirty seconds forever — which is worse
48
+ * than the shut door, and is the reason the ladder ends in "stop and say so"
49
+ * rather than in another attempt.
50
+ *
51
+ * @param {{frontDoor?:string, sessionLive?:boolean, silentMs?:number|null,
52
+ * attempts?:number, lastAttemptAt?:number|null, now:number,
53
+ * reviveAfterMs?:number, backoffMs?:number, maxAttempts?:number}} a
54
+ * @returns {{revive:boolean, reason:string}}
55
+ */
56
+ export function shouldReviveFrontDoor(a) {
57
+ const x = a && typeof a === "object" ? a : {};
58
+ const now = Number(x.now);
59
+ const after = Number.isFinite(x.reviveAfterMs) && x.reviveAfterMs > 0 ? x.reviveAfterMs : DEFAULT_REVIVE_AFTER_MS;
60
+ const backoff = Number.isFinite(x.backoffMs) && x.backoffMs > 0 ? x.backoffMs : DEFAULT_REVIVE_BACKOFF_MS;
61
+ const max = Number.isFinite(x.maxAttempts) && x.maxAttempts >= 0 ? x.maxAttempts : DEFAULT_REVIVE_MAX;
62
+
63
+ // A seat whose lane is the daemon has no front door to revive: the daemon
64
+ // itself is answering, and restarting a session job it does not depend on
65
+ // would be a gratuitous interruption.
66
+ if (x.frontDoor !== "session") return { revive: false, reason: "front-door-daemon" };
67
+
68
+ // Working. This is the common case and it must be cheap and obviously safe:
69
+ // the heartbeat rides every tool use, so a session doing anything at all is
70
+ // fresh.
71
+ if (x.sessionLive === true) return { revive: false, reason: "answering" };
72
+
73
+ // Not live, but we cannot say for how long — a missing or unreadable
74
+ // heartbeat is not evidence of a wedge, and a daemon that restarts a session
75
+ // it knows nothing about is a daemon that fights its own launch.
76
+ // `Number(null)` is 0, and 0 is finite — so a MISSING heartbeat, which is
77
+ // exactly what `sessionLiveFromHeartbeat` reports as `ageMs: null`, would
78
+ // otherwise read as "silent for no time at all" and be reported as
79
+ // within-grace. Harmless at today's ten-minute grace and a lie at any grace
80
+ // of zero; either way the reason would name the wrong state.
81
+ const silentMs = x.silentMs === null || x.silentMs === undefined ? NaN : Number(x.silentMs);
82
+ if (!Number.isFinite(silentMs)) return { revive: false, reason: "silence-unknown" };
83
+ if (silentMs < after) return { revive: false, reason: "within-grace" };
84
+
85
+ const attempts = Number(x.attempts) || 0;
86
+ if (attempts >= max) return { revive: false, reason: "budget-spent" };
87
+
88
+ // One attempt, then wait. A front door takes time to come up, and a restart
89
+ // loop that re-fires before the new session can beat never lets it.
90
+ const last = Number(x.lastAttemptAt);
91
+ if (Number.isFinite(last) && now - last < backoff) return { revive: false, reason: "backing-off" };
92
+
93
+ return { revive: true, reason: `front door silent ${Math.round(silentMs / 1000)}s (attempt ${attempts + 1}/${max})` };
94
+ }
95
+
96
+ /**
97
+ * The command that restarts the front-door job. Pure — the caller runs it.
98
+ *
99
+ * `kickstart -k` rather than a polite request: the session this exists for is
100
+ * one that cannot act on a request, and `maestro session restart` without
101
+ * `--force` asks the session to stand itself down at an idle moment it will
102
+ * never reach.
103
+ *
104
+ * @param {string} label launchd label, e.g. "ai.maestro.james-session"
105
+ * @param {number} uid
106
+ * @returns {{file:string, args:string[]}}
107
+ */
108
+ export function reviveCommand(label, uid) {
109
+ return { file: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${label}`] };
110
+ }
111
+
112
+ /**
113
+ * This seat's front-door launchd label, read off disk rather than derived.
114
+ *
115
+ * The label is `ai.maestro.<first>-session`, but `<first>` is whatever the
116
+ * generator wrote — `~/Maya-ai` yields `Maya`, and case is preserved. Guessing
117
+ * it from a directory name is how a healthy seat gets accused of having no job
118
+ * (collect.mjs#sessionJobLabel carries the same warning). A readdir is a hard
119
+ * fact, and there is exactly one such plist on a seat.
120
+ *
121
+ * @param {(dir:string)=>string[]} readdir
122
+ * @param {string} homeDir
123
+ * @returns {string|null}
124
+ */
125
+ export function sessionJobLabelOnDisk(readdir, homeDir) {
126
+ if (!homeDir || typeof readdir !== "function") return null;
127
+ let names = [];
128
+ try { names = readdir(`${homeDir}/Library/LaunchAgents`) || []; } catch { return null; }
129
+ const hits = names
130
+ .filter((n) => /^ai\.maestro\..+-session\.plist$/.test(String(n)))
131
+ .map((n) => String(n).replace(/\.plist$/, ""));
132
+ // Two would mean two seats share a home directory, which is not a thing this
133
+ // may guess its way through.
134
+ return hits.length === 1 ? hits[0] : null;
135
+ }
@@ -17,6 +17,8 @@
17
17
  * - machine.memUsedPct high → WARNING/CRITICAL (memory pressure).
18
18
  * - machine.diskUsedPct high → WARNING/CRITICAL (disk pressure).
19
19
  * - subAgentsRunning over the cap → INFO (more children than expected).
20
+ * - machine.sessionNote present → WARNING/CRITICAL (frontdoor) — the
21
+ * seat's front door is not answering.
20
22
  *
21
23
  * Output: a deterministic array `[{ id, severity, kind, detail }, ...]` (the
22
24
  * snapshot's alert shape) sorted critical→warning→info then by id, so identical
@@ -56,6 +58,46 @@ export const DEFAULT_THRESHOLDS = Object.freeze({
56
58
  subAgents: Object.freeze({
57
59
  infoCap: 10, // more live claude children than this is worth surfacing
58
60
  }),
61
+ /**
62
+ * How long the seat's FRONT DOOR may be unable to answer before the fleet is
63
+ * told. The daemon lane keeps running underneath, so nothing here is about
64
+ * the machine being down — it is about the seat being unable to hold a
65
+ * conversation.
66
+ *
67
+ * ── WHY THIS RULE EXISTS AT ALL ──────────────────────────────────────────
68
+ * Every other rule in this file is about the MACHINE: how hot it is, how
69
+ * full its disk is, how much memory it has left, whether it can still
70
+ * authenticate. In the week three seats had their front door blocked — for
71
+ * 3, 15 and 2.7 days — every alert the fleet opened was one of those, and
72
+ * not one was about a seat nobody could reach. THE SHAPE IS THE POINT AND NO
73
+ * TALLY IS GIVEN: a comment that states a count goes stale the next time the
74
+ * fleet opens an alert, and this one already had — it read "twelve alerts,
75
+ * five disk and seven thermal" while the loudest seat in the fleet was
76
+ * carrying eleven open critical memory rows. A conversational seat that
77
+ * cannot converse is at least as broken as a warm one.
78
+ *
79
+ * ── WHY THE WINDOWS ARE THESE ────────────────────────────────────────────
80
+ * `warnMs` 15 min: the supervisor's own watchdog is bounded (2 modal clears,
81
+ * 3 restarts, one pass per 2-minute grace window), so anything past a
82
+ * quarter of an hour has already outlived every fix the seat can apply to
83
+ * itself. Below that the seat is still trying and an alert would be noise.
84
+ *
85
+ * `critMs` 2 h: past this the silence has spanned a working block. A message
86
+ * sent at the start of it has gone unanswered long enough that the sender
87
+ * has drawn a conclusion about the colleague, which is the actual damage.
88
+ */
89
+ frontDoor: Object.freeze({
90
+ warnMs: 15 * 60 * 1000,
91
+ critMs: 2 * 60 * 60 * 1000,
92
+ }),
93
+ replyDebt: Object.freeze({
94
+ // ONE withheld reply is already worth saying. This is not a resource gauge
95
+ // where a low reading is normal noise — it counts people who asked this
96
+ // seat something and got nothing. The threshold exists only so the severity
97
+ // can climb, not so the first few can be ignored.
98
+ warnCount: 1,
99
+ critCount: 5,
100
+ }),
59
101
  });
60
102
 
61
103
  const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
@@ -109,11 +151,16 @@ function round1(n) { return Math.round(Number(n) * 10) / 10; }
109
151
  /**
110
152
  * Derive the health alerts for a status snapshot.
111
153
  *
154
+ * `now` is a PARAMETER (defaulted, doctrine §2) because one rule — the front
155
+ * door — is about a DURATION, and a rule that reads the clock itself cannot be
156
+ * tested across the boundary it exists to draw.
157
+ *
112
158
  * @param {object} status the SHARED STATUS SNAPSHOT (collect.collectStatus)
113
159
  * @param {object} [thresholds] operator override merged over DEFAULT_THRESHOLDS
160
+ * @param {number} [now] epoch ms
114
161
  * @returns {Array<{id:string, severity:"info"|"warning"|"critical", kind:string, detail:string}>}
115
162
  */
116
- export function deriveAlerts(status, thresholds) {
163
+ export function deriveAlerts(status, thresholds, now = Date.now()) {
117
164
  const alerts = [];
118
165
  try {
119
166
  const s = status && typeof status === "object" ? status : {};
@@ -125,6 +172,9 @@ export function deriveAlerts(status, thresholds) {
125
172
  push(alerts, memRule(machine, t.mem));
126
173
  push(alerts, diskRule(machine, t.disk));
127
174
  push(alerts, subAgentsRule(s, t.subAgents));
175
+ push(alerts, frontDoorRule(machine, t.frontDoor, now));
176
+ push(alerts, scopeFaultRule(machine));
177
+ push(alerts, repliesWithheldRule(machine, t.replyDebt));
128
178
 
129
179
  alerts.sort((a, b) => {
130
180
  const r = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
@@ -187,6 +237,51 @@ function diskRule(machine, cfg = {}) {
187
237
  };
188
238
  }
189
239
 
240
+ /**
241
+ * The seat is missing a scope, which nothing on this machine can grant.
242
+ *
243
+ * Critical, and deliberately louder than a hot CPU: twelve alerts opened on
244
+ * this fleet in a week were all disk and thermal, and none for a seat that
245
+ * could not answer anybody. A thermal warning resolves itself; a scope fault
246
+ * waits for a person forever.
247
+ */
248
+ function scopeFaultRule(machine) {
249
+ const d = machine && machine.replyDebt;
250
+ const n = num(d && d.scopeRefused);
251
+ if (!(n > 0)) return null;
252
+ return {
253
+ id: "seat_missing_scope",
254
+ severity: "critical",
255
+ kind: "scope",
256
+ detail: `${n} ${n === 1 ? "reply was" : "replies were"} refused today for a scope this seat does not hold — a person asked and got nothing. Grant the scope; the seat cannot.`,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * This seat withheld N replies today — the number the whole reply-debt spine
262
+ * exists to make sayable. Scope faults have their own critical rule above, so
263
+ * this one carries the rest (an unparseable CLI envelope, a session-id
264
+ * collision the single retry did not recover).
265
+ */
266
+ function repliesWithheldRule(machine, cfg = {}) {
267
+ const d = machine && machine.replyDebt;
268
+ if (!d) return null;
269
+ const v = num(d.withheld) - num(d.scopeRefused);
270
+ if (!(v > 0)) return null;
271
+ const sev = severityFor(v, num(cfg.warnCount, Infinity), num(cfg.critCount, Infinity));
272
+ if (!sev) return null;
273
+ const parts = [];
274
+ if (num(d.unparseable) > 0) parts.push(`${num(d.unparseable)} unreadable CLI envelope`);
275
+ const lost = Math.max(0, num(d.sessionCollisions) - num(d.recovered));
276
+ if (lost > 0) parts.push(`${lost} session-id collision`);
277
+ return {
278
+ id: "replies_withheld",
279
+ severity: sev,
280
+ kind: "reply",
281
+ detail: `${v} ${v === 1 ? "reply" : "replies"} withheld today${parts.length ? ` (${parts.join(", ")})` : ""} — someone asked and got silence.`,
282
+ };
283
+ }
284
+
190
285
  function subAgentsRule(status, cfg = {}) {
191
286
  const v = num(status.subAgentsRunning);
192
287
  const cap = num(cfg.infoCap, Infinity);
@@ -199,6 +294,68 @@ function subAgentsRule(status, cfg = {}) {
199
294
  };
200
295
  }
201
296
 
297
+ /**
298
+ * The front door is not answering, and has not been for long enough that the
299
+ * seat's own recovery has already been tried and failed.
300
+ *
301
+ * READS `machine.sessionNote` — the exception field `collect.sessionNote()`
302
+ * emits, which is present ONLY when the front door is not live and carries the
303
+ * REASON (`awaiting-input`, `heartbeat-stale`, `never-beaten`, `job-absent`,
304
+ * …). The note is the seat's own diagnosis and it is passed through verbatim in
305
+ * the detail, because the reason is exactly what tells an operator whether this
306
+ * is a dialog nobody answered, a supervisor that died, or a seat deliberately
307
+ * configured without a session job.
308
+ *
309
+ * `job-absent` is the one reason that raises NOTHING. A seat whose front door
310
+ * is the daemon by configuration is not broken, and alerting on it would train
311
+ * the fleet to ignore this rule — which is how the disk and thermal alerts got
312
+ * their current signal-to-noise and this rule must not spend it.
313
+ *
314
+ * A note with no usable `since` cannot be aged, so it cannot clear the warn
315
+ * window and raises nothing. That is deliberate: an alert is a claim about
316
+ * duration, and an undatable note does not support one. The note still rides
317
+ * the beat and the fleet surface still shows it.
318
+ *
319
+ * ── THE DETAIL NAMES AN INSTANT, NEVER A DURATION ────────────────────────────
320
+ * hq opens one AgentAlert row per DISTINCT `severity\0kind\0detail`
321
+ * (`alertClientKey`, src/server/methods/presence/beat.ts). A detail carrying a
322
+ * ticking age — "has not answered for 15m… 16m… 17m…" — is therefore a NEW row
323
+ * on every beat: running this rule against a 30-second beat cadence for eight
324
+ * hours on one seat produced 83 distinct rows, and a `critical` row does not
325
+ * self-clear. That is exactly how a seat in this fleet accumulated eleven open
326
+ * critical `memory` rows whose details differed only in the percentage. So the
327
+ * detail anchors on `note.since`, which is FIXED for as long as one blockage
328
+ * lasts, and the reader does the subtraction. At most two rows per episode —
329
+ * the warn one, then the critical one — and the warn row clears when the door
330
+ * opens.
331
+ *
332
+ * For the same reason `note.detail` is NOT appended: it carries the seat's own
333
+ * ticking text ("no beat for 431 s"), and pasting it here would put the
334
+ * counter straight back into the key. It rides the beat as
335
+ * `machine.sessionNote.detail` and the fleet surface shows it there.
336
+ */
337
+ function frontDoorRule(machine, cfg = {}, now = Date.now()) {
338
+ const note = machine && typeof machine.sessionNote === "object" && machine.sessionNote !== null && !Array.isArray(machine.sessionNote)
339
+ ? machine.sessionNote
340
+ : null;
341
+ if (!note) return null;
342
+ const reason = typeof note.reason === "string" ? note.reason.trim() : "";
343
+ if (!reason || reason === "job-absent") return null;
344
+ const since = Date.parse(typeof note.since === "string" ? note.since : "");
345
+ if (!Number.isFinite(since)) return null;
346
+ const ageMs = num(now) - since;
347
+ if (!(ageMs > 0)) return null; // a future stamp is skew, not a duration
348
+ const sev = severityFor(ageMs, num(cfg.warnMs, Infinity), num(cfg.critMs, Infinity));
349
+ if (!sev) return null;
350
+ return {
351
+ id: "front_door_blocked",
352
+ severity: sev,
353
+ kind: "frontdoor",
354
+ // An INSTANT, never a duration — see `alertClientKey` above.
355
+ detail: `the seat's front door has not answered since ${new Date(since).toISOString()} (${reason}) — messages to this colleague are going unanswered.`,
356
+ };
357
+ }
358
+
202
359
  /**
203
360
  * Does this alert list contain a critical alert? Convenience for the mesh's
204
361
  * local-escalation decision. Never throws.
@@ -211,6 +368,6 @@ export function hasCritical(alerts) {
211
368
  }
212
369
  }
213
370
 
214
- export const _internals = { severityFor, reloginRule, tempRule, memRule, diskRule, subAgentsRule };
371
+ export const _internals = { severityFor, reloginRule, tempRule, memRule, diskRule, subAgentsRule, frontDoorRule };
215
372
 
216
373
  export default { deriveAlerts, hasCritical, mergeThresholds, DEFAULT_THRESHOLDS };