@cohortapp/agent-sdk 2.13.0 → 2.14.0

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.
@@ -47,6 +47,43 @@ direction. The beat reports which lane is active: `machine.frontDoor`
47
47
  open record — rather than `session`, which hq validates strictly and which
48
48
  stays `null` while no work session runs.
49
49
 
50
+ **And WHY it is not live.** `sessionLive: false` on its own is a symptom with
51
+ several cures, and no seat in the fleet accepts SSH, so the beat has to carry
52
+ the cause too. When (and only when) the front door is down, the beat adds
53
+ `machine.sessionNote` — `{reason, since?, detail?}`:
54
+
55
+ | `reason` | what happened | what to do |
56
+ | --- | --- | --- |
57
+ | `job-absent` | no `ai.maestro.<first>-session` plist in `~/Library/LaunchAgents`. A statement of configuration, not necessarily a fault — a seat whose front door is deliberately the daemon lane reports this on every beat | nothing, if that is the intent; else `maestro session install` |
58
+ | `awaiting-input` | `state/session/attention.json` exists and no later heartbeat has superseded it: the session is up but has never beaten, so it is sitting on a first-run trust/permission dialog | attach with the command in `detail` and answer it |
59
+ | `heartbeat-stale` | a heartbeat exists but has aged out — supervisor died, or the mux session ended; `since` is the last beat | `maestro session restart` |
60
+ | `heartbeat-unreadable` | `heartbeat.json` is there and will not parse. The job may well have beaten — this is NOT "never beaten" | delete the file and let the feed rewrite it |
61
+ | `never-beaten` | no heartbeat file at all and no watchdog record yet (the supervisor's watchdog fires 120 s in) | wait a minute, then `maestro session status` |
62
+
63
+ `detail` is capped at 200 characters and scrubbed of home paths, session ids
64
+ and token-shaped words before it leaves the machine
65
+ (`lib/telemetry/collect.mjs#sanitizeNoteDetail`); the attach command survives,
66
+ because that is the actionable part. `since` is never passed through from the
67
+ file either — it is parsed and re-emitted as canonical ISO 8601, so nothing can
68
+ ride it that the `detail` scrubber would have caught.
69
+
70
+ Two rules the reasons obey, because a note that misleads is worse than no note:
71
+
72
+ - **An unknown is reported as neither an absence nor a presence.** `job-absent`
73
+ comes only from a directory that was read and genuinely did not hold the
74
+ label; an unreadable `~/Library/LaunchAgents` (or a non-Mac seat) yields
75
+ `never-beaten` with a `detail` that says the job's presence could not be
76
+ checked, rather than claiming an installation nobody verified.
77
+ - **Every reason is PRESENT-TENSE and expires with the snapshot**, like
78
+ `frontDoor` and `sessionLive` and unlike `machine.upgrade`. `job-absent` and
79
+ `never-beaten` carry no `since`, so a consumer dates them from the snapshot's
80
+ own `ts` — printing "attach and answer the dialog" off a week-old snapshot is
81
+ the mistake this note exists to prevent, not to cause.
82
+
83
+ The decision itself is the pure `sessionNote(...)` over injected inputs — the
84
+ reads that feed it are fail-open, so a throw anywhere in them drops the field
85
+ rather than the beat.
86
+
50
87
  **Exactly one.** The supervisor takes an O_EXCL lock (`lib/singleton.js`,
51
88
  name `session`); a second supervisor exits 0 so launchd does not thrash. An
52
89
  orphaned mux session with no lock holder is adopted, not duplicated.
@@ -47,6 +47,38 @@
47
47
  * // silently drops the seat out of the mesh.
48
48
  * frontDoor: "session"|"daemon", // the main session job, or the daemon's --print lane
49
49
  * sessionLive: boolean, // state/session/heartbeat.json fresher than 90 s
50
+ * // WHY the front door is not live (WP-M8). Present ONLY when
51
+ * // `sessionLive` is false — a live seat says nothing. The beat is the
52
+ * // only channel to a seat that takes no SSH, so it has to carry the
53
+ * // cause, not just the symptom. Small and bounded by construction:
54
+ * // three fields, `detail` capped at SESSION_NOTE_DETAIL_MAX and
55
+ * // scrubbed of home paths and token-shaped words (see sanitizeNoteDetail),
56
+ * // `since` re-formatted from a parsed instant rather than passed through.
57
+ * // MOMENTARY, like frontDoor/sessionLive: every reason is PRESENT-TENSE
58
+ * // and expires with the snapshot, so a consumer must gate it on
59
+ * // snapshot freshness — it is NOT read the way `upgrade` (a fact about
60
+ * // the past) is read. Two of the reasons have no moment to date and so
61
+ * // carry no `since`; age then comes from the snapshot's own `ts`.
62
+ * // "job-absent" no <first>-session plist in ~/Library/LaunchAgents.
63
+ * // A statement of CONFIGURATION, not necessarily a
64
+ * // fault: a seat whose front door is deliberately the
65
+ * // daemon --print lane reports this on every beat.
66
+ * // Emitted only from a hard "not there", never from
67
+ * // a directory we could not read. No `since`.
68
+ * // "awaiting-input" state/session/attention.json exists AND has not
69
+ * // been superseded by a later heartbeat: the session
70
+ * // is up but has never beaten, so it is sitting on a
71
+ * // first-run trust/permission dialog nobody answered.
72
+ * // `since` and `detail` carry the supervisor's own
73
+ * // attentionRecord moment + hint (truncated).
74
+ * // "heartbeat-stale" a heartbeat exists but has aged out — the supervisor
75
+ * // died or the mux session ended. `since` is the last beat.
76
+ * // "heartbeat-unreadable" the file is there and will not parse: the job
77
+ * // may well have beaten, so this is NOT "never beaten".
78
+ * // "never-beaten" no heartbeat file at all and no watchdog record yet.
79
+ * // `detail` claims the job is installed only when its
80
+ * // presence was actually established. No `since`.
81
+ * sessionNote?: { reason: string, since?: ISO8601, detail?: string },
50
82
  * // LAST UPGRADE — the outcome autoupdate.sh recorded in
51
83
  * // state/autoupdate/last.json (WP-M6); absent until a first attempt.
52
84
  * // Also on `machine` (open record), so hq's fleet view can show it next
@@ -92,6 +124,7 @@ import { probeClaude as defaultProbeClaude } from "../setup/claude-probe.mjs";
92
124
  import { list as listPresence } from "../collective/presence.mjs";
93
125
  import { billableUsd } from "../cost/ledger-row.mjs";
94
126
  import { liveClaudeStats } from "../resource-governor.mjs";
127
+ import { agentFirstName } from "../session/identity.mjs";
95
128
 
96
129
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
97
130
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1031,6 +1064,216 @@ export function sessionLiveness(hb, nowMs, opts = {}) {
1031
1064
  return { frontDoor: "session", sessionLive: live };
1032
1065
  }
1033
1066
 
1067
+ /** Hard cap on `machine.sessionNote.detail` — the beat is a heartbeat, not a log. */
1068
+ export const SESSION_NOTE_DETAIL_MAX = 200;
1069
+
1070
+ /**
1071
+ * Absolute paths that could name a person, a home or a private temp dir.
1072
+ *
1073
+ * A path segment may CONTAIN a space — `/Users/Olivia Chen/…` is an ordinary
1074
+ * macOS home — so the match does not simply stop at the first whitespace, which
1075
+ * left `<path> Chen/olivia-ai/state` (a surname and the seat's layout) standing
1076
+ * in the note. A space is swallowed only when what follows it is still a path
1077
+ * (more non-space characters and then a `/`), so ordinary prose after a path
1078
+ * survives: `blocked in /Users/maya and then retry` redacts only the path.
1079
+ */
1080
+ const NOTE_PATH_RE = /(?:~|\/(?:Users|home|private|var\/folders))\/(?:[^\s"'`]|[ \t](?=[^\s"'`]*\/))*/g;
1081
+ /** Token-shaped words: an api-key prefix, or any long opaque run (uuids included). */
1082
+ const NOTE_TOKEN_RE = /\b(?:sk-[A-Za-z0-9_-]{8,}|[A-Za-z0-9][A-Za-z0-9_-]{31,})\b/g;
1083
+
1084
+ /**
1085
+ * Pure: make a free-text note safe and small enough to ride every beat.
1086
+ *
1087
+ * The text this scrubs is OUR OWN (the supervisor's attention hint), so it is
1088
+ * not a sanitiser standing between us and an attacker — it is the guarantee the
1089
+ * contract makes to hq and to every operator reading a fleet view: a note never
1090
+ * carries a home directory, a session id, a token or a file path, and never
1091
+ * grows past {@link SESSION_NOTE_DETAIL_MAX}. The hint names an attach command
1092
+ * (`tmux attach -t =maestro-alex`), which is exactly the actionable part and
1093
+ * survives; the paths and ids around it do not.
1094
+ *
1095
+ * @param {unknown} text
1096
+ * @param {number} [max]
1097
+ * @returns {string} "" when there is nothing safe left to say
1098
+ */
1099
+ export function sanitizeNoteDetail(text, max = SESSION_NOTE_DETAIL_MAX) {
1100
+ if (typeof text !== "string" || !text) return "";
1101
+ const cap = Number.isFinite(max) && max > 0 ? Math.floor(max) : SESSION_NOTE_DETAIL_MAX;
1102
+ let out = text.replace(/[\u0000-\u001F\u007F]+/g, " ");
1103
+ out = out.replace(NOTE_PATH_RE, "<path>");
1104
+ out = out.replace(NOTE_TOKEN_RE, "<redacted>");
1105
+ out = out.replace(/\s+/g, " ").trim();
1106
+ if (out.length > cap) out = `${out.slice(0, cap - 1).trimEnd()}…`;
1107
+ return out;
1108
+ }
1109
+
1110
+ /** ms-or-ISO `ts` off a heartbeat record -> epoch ms, else null. */
1111
+ function heartbeatTs(hb) {
1112
+ if (!hb || typeof hb !== "object") return null;
1113
+ const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
1114
+ return Number.isFinite(ts) ? ts : null;
1115
+ }
1116
+
1117
+ /**
1118
+ * Pure: an ISO-8601 string -> epoch ms, else null.
1119
+ *
1120
+ * `since` rides the beat as the note's only structured field, and it used to
1121
+ * ride VERBATIM behind a bare `Date.parse` finiteness check — which V8's legacy
1122
+ * date parser passes for a string carrying trailing prose, so a home path or a
1123
+ * token-shaped run could travel on the one field {@link sanitizeNoteDetail}
1124
+ * does not cover. Parsing to ms here and re-formatting through `toISOString()`
1125
+ * makes `since` bounded and canonical by CONSTRUCTION rather than by trusting
1126
+ * the file it came from.
1127
+ */
1128
+ function instantMs(v) {
1129
+ if (typeof v !== "string" || !v) return null;
1130
+ const ms = Date.parse(v);
1131
+ return Number.isFinite(ms) ? ms : null;
1132
+ }
1133
+
1134
+ /**
1135
+ * The supervisor watchdog's vocabulary (`lib/session/first-run#heartbeatSilence`)
1136
+ * in plain words.
1137
+ *
1138
+ * Its `"no-heartbeat"` means "the session is UP and has never beaten"; the
1139
+ * beat's own reasons of nearly the same spelling mean something else, and the
1140
+ * two met inside one payload (`{reason:"awaiting-input", detail:"no-heartbeat: …"}`),
1141
+ * which reads as a contradiction to anything that shows reason and detail side
1142
+ * by side. An unrecognised value still passes through verbatim — a word we do
1143
+ * not know is better than a word we invent.
1144
+ */
1145
+ const ATTENTION_WHY = {
1146
+ "no-heartbeat": "up, never beaten",
1147
+ "stale-heartbeat": "no beat since this launch",
1148
+ };
1149
+
1150
+ /**
1151
+ * Pure: WHY the front door is not live — the beat's `machine.sessionNote`.
1152
+ *
1153
+ * THE GAP (WP-M8): after 2.12.0, seats A010 and A016 beat
1154
+ * `frontDoor:"daemon", sessionLive:false` for days and nothing on the beat
1155
+ * could tell a job that was never installed from a session sitting on an
1156
+ * unanswered first-run dialog from a supervisor that had died — three states an
1157
+ * operator fixes three different ways. No seat in the fleet accepts SSH, so the
1158
+ * beat is the only channel; the seat already knew the answer
1159
+ * (`state/session/attention.json`), it just never left the machine.
1160
+ *
1161
+ * ORDER, and why each step outranks the next:
1162
+ *
1163
+ * 1. `job-absent` first: a seat with no job cannot have a meaningful heartbeat
1164
+ * or attention record, and a stale `attention.json` left behind by a
1165
+ * previous install must not mask "the job is gone". It is a statement of
1166
+ * CONFIGURATION, not necessarily a fault — a seat whose front door is
1167
+ * deliberately the daemon --print lane reports it on every beat, and the
1168
+ * detail says so rather than accusing the seat.
1169
+ * 2. `awaiting-input` next — but ONLY while the record still describes the
1170
+ * present. `scripts/session/supervisor.mjs` clears `attention.json` at the
1171
+ * START of the next launch and never when the human answers the dialog, so
1172
+ * a record can outlive its truth by days: the session gets answered, beats
1173
+ * for a week, then dies. A heartbeat LATER than the record is proof the
1174
+ * dialog was answered, and the record is then superseded — otherwise the
1175
+ * beat hands the operator a week-old attach command for a mux session that
1176
+ * no longer exists while the real fault stays invisible.
1177
+ * 3. `heartbeat-stale` / `heartbeat-unreadable` / `never-beaten` last, from
1178
+ * what the heartbeat file itself can prove.
1179
+ *
1180
+ * Every emitted `detail` must be TRUE of the state it describes: an unknown is
1181
+ * never reported as an absence (`job-absent` comes only from a hard `false`)
1182
+ * and, symmetrically, never as a presence (`never-beaten` says the job is
1183
+ * installed only when it was actually checked).
1184
+ *
1185
+ * Everything is INJECTED — no clock, no fs, no launchctl. Every malformed
1186
+ * input resolves to a coarser answer or to `null`; nothing here throws.
1187
+ *
1188
+ * @param {object} a
1189
+ * @param {object|null} [a.heartbeat] parsed state/session/heartbeat.json (null when absent/corrupt)
1190
+ * @param {boolean} [a.heartbeatUnreadable] the file was THERE but would not parse (a different fault from "never beaten")
1191
+ * @param {object|null} [a.attention] parsed state/session/attention.json (lib/session/first-run#attentionRecord)
1192
+ * @param {boolean|null} [a.jobInstalled] is the `<first>-session` launchd job present? null = COULD NOT TELL (reported as neither an absence nor a presence)
1193
+ * @param {string} [a.label] the launchd label, for the job-absent detail
1194
+ * @param {number} a.now epoch ms
1195
+ * @param {number} [a.staleMs] heartbeat staleness bound (default {@link SESSION_STALE_MS})
1196
+ * @returns {{reason:string, since?:string, detail?:string}|null} null when the front door is live (or `now` is unusable)
1197
+ */
1198
+ export function sessionNote(a = {}) {
1199
+ const o = a && typeof a === "object" ? a : {};
1200
+ const now = Number(o.now);
1201
+ if (!Number.isFinite(now)) return null;
1202
+ const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : SESSION_STALE_MS;
1203
+ const hb = o.heartbeat && typeof o.heartbeat === "object" && !Array.isArray(o.heartbeat) ? o.heartbeat : null;
1204
+
1205
+ // A live front door has nothing to explain — the note is an EXCEPTION field.
1206
+ if (sessionLiveness(hb, now, { staleMs }).sessionLive) return null;
1207
+
1208
+ // 1. The job is not on this seat at all. `false` only; `null` is "unknown",
1209
+ // and an unknown must never be reported as an absence.
1210
+ if (o.jobInstalled === false) {
1211
+ const label = typeof o.label === "string" && o.label.trim() ? o.label.trim() : "";
1212
+ return {
1213
+ reason: "job-absent",
1214
+ detail: sanitizeNoteDetail(
1215
+ `no ${label || "main-session"} launchd job on this seat — the daemon --print lane is the front door (by configuration, or the session job was never installed)`,
1216
+ ),
1217
+ };
1218
+ }
1219
+
1220
+ const hbMs = heartbeatTs(hb);
1221
+
1222
+ // 2. The supervisor's watchdog has already named it: alive, never beaten —
1223
+ // a first-run trust/permission dialog with nobody attached to answer.
1224
+ // Unless a LATER heartbeat proves the dialog was answered and the record
1225
+ // is simply one the supervisor never got to clear (see ORDER above). A
1226
+ // record with no usable `since` cannot be ordered against the heartbeat,
1227
+ // and a heartbeat with a real timestamp is the harder evidence, so that
1228
+ // too counts as superseded.
1229
+ const att = o.attention && typeof o.attention === "object" && !Array.isArray(o.attention) ? o.attention : null;
1230
+ const attMs = att ? instantMs(att.since) : null;
1231
+ const superseded = att !== null && hbMs !== null && (attMs === null || hbMs > attMs);
1232
+ if (att && !superseded) {
1233
+ const note = { reason: "awaiting-input" };
1234
+ if (attMs !== null) note.since = new Date(attMs).toISOString();
1235
+ const raw = typeof att.reason === "string" ? att.reason.trim() : "";
1236
+ const why = raw ? (ATTENTION_WHY[raw] || raw) : "";
1237
+ const hint = typeof att.hint === "string" ? att.hint : "";
1238
+ const detail = sanitizeNoteDetail(why && hint ? `${why}: ${hint}` : why || hint);
1239
+ if (detail) note.detail = detail;
1240
+ return note;
1241
+ }
1242
+
1243
+ // 3. It beat once and stopped: the supervisor died, or the mux session ended.
1244
+ if (hbMs !== null) {
1245
+ const ageMs = now - hbMs;
1246
+ return {
1247
+ reason: "heartbeat-stale",
1248
+ since: new Date(hbMs).toISOString(),
1249
+ detail: ageMs < 0 ? "last beat is in the future (clock skew)" : `no beat for ${Math.round(ageMs / 1000)} s`,
1250
+ };
1251
+ }
1252
+
1253
+ // 4. A heartbeat file is THERE but says nothing usable. Not the same fault as
1254
+ // "never beaten" — the job may well have beaten and the file be corrupt —
1255
+ // so it gets its own reason and its own fix.
1256
+ if (hb || o.heartbeatUnreadable === true) {
1257
+ return {
1258
+ reason: "heartbeat-unreadable",
1259
+ detail: hb
1260
+ ? "the heartbeat file is present but carries no readable timestamp"
1261
+ : "the heartbeat file is present but could not be parsed",
1262
+ };
1263
+ }
1264
+
1265
+ // 5. Nothing on disk at all: a job that has never beaten and whose watchdog
1266
+ // has not fired yet. What we say about the job depends on whether its
1267
+ // presence was actually established — `true`, and only `true`, licenses
1268
+ // "the session job is installed".
1269
+ return {
1270
+ reason: "never-beaten",
1271
+ detail: o.jobInstalled === true
1272
+ ? "the session job is installed but has never written a heartbeat"
1273
+ : "no heartbeat from the session job, and its presence on this seat could not be checked",
1274
+ };
1275
+ }
1276
+
1034
1277
  /**
1035
1278
  * Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
1036
1279
  * (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
@@ -1053,10 +1296,87 @@ function readAutoupdateLast(agentRoot) {
1053
1296
  return safeReadJson(join(resolve(agentRoot), "state", "autoupdate", "last.json"));
1054
1297
  }
1055
1298
 
1056
- /** Read state/session/heartbeat.json; null when absent or malformed. */
1057
- function readSessionHeartbeat(agentRoot) {
1299
+ /**
1300
+ * Read state/session/heartbeat.json, distinguishing ABSENT from PRESENT-BUT-
1301
+ * UNPARSEABLE.
1302
+ *
1303
+ * `safeReadJson` maps both to `null`, which is right for liveness (neither is a
1304
+ * live beat) and wrong for the note: a corrupt file told the operator the job
1305
+ * "has never written a heartbeat" when in fact it had, and the fix for the two
1306
+ * is not the same. The two facts are separated here and rejoined in
1307
+ * {@link sessionNote}.
1308
+ *
1309
+ * @returns {{heartbeat:object|null, unreadable:boolean}}
1310
+ */
1311
+ function readSessionHeartbeatState(agentRoot) {
1312
+ if (!agentRoot) return { heartbeat: null, unreadable: false };
1313
+ let text;
1314
+ try {
1315
+ text = readFileSync(join(resolve(agentRoot), "state", "session", "heartbeat.json"), "utf8");
1316
+ } catch {
1317
+ return { heartbeat: null, unreadable: false }; // no file: the job has never beaten
1318
+ }
1319
+ try {
1320
+ const v = JSON.parse(text);
1321
+ if (v && typeof v === "object" && !Array.isArray(v)) return { heartbeat: v, unreadable: false };
1322
+ return { heartbeat: null, unreadable: true }; // a JSON scalar is not a heartbeat
1323
+ } catch {
1324
+ return { heartbeat: null, unreadable: true };
1325
+ }
1326
+ }
1327
+
1328
+ /** Read state/session/attention.json (the supervisor's watchdog record); null when absent or malformed. */
1329
+ function readSessionAttention(agentRoot) {
1058
1330
  if (!agentRoot) return null;
1059
- return safeReadJson(join(resolve(agentRoot), "state", "session", "heartbeat.json"));
1331
+ return safeReadJson(join(resolve(agentRoot), "state", "session", "attention.json"));
1332
+ }
1333
+
1334
+ /**
1335
+ * The launchd label of this seat's main-session job, as
1336
+ * `scripts/local-triggers/generate-plists.sh` and `maestro session` spell it.
1337
+ * "" when the first name cannot be derived.
1338
+ */
1339
+ function sessionJobLabel(agentRoot, first) {
1340
+ // NOT lower-cased: `agentFirstName` deliberately leaves its directory-name
1341
+ // fallback as-is (`~/Maya-ai` -> `Maya`) because the generator does, so
1342
+ // lower-casing an INJECTED first here built a label the plist file does not
1343
+ // carry — and a label that does not match is how a healthy seat gets accused
1344
+ // of `job-absent`, the one verdict that must come from a hard fact.
1345
+ const f = typeof first === "string" && first.trim() ? first.trim() : (agentRoot ? agentFirstName(resolve(agentRoot)) : "");
1346
+ return f ? `ai.maestro.${f}-session` : "";
1347
+ }
1348
+
1349
+ /**
1350
+ * Is the main-session launchd job on this seat? A filename scan of
1351
+ * ~/Library/LaunchAgents — a readdir, NOT a `launchctl list` fork, because this
1352
+ * runs on every presence beat.
1353
+ *
1354
+ * Returns `null` for "could not tell" (no label, no home, an unreadable
1355
+ * directory, a non-darwin box) and only ever `false` when the directory was
1356
+ * read and the label was genuinely not in it: `sessionNote` reports an absence
1357
+ * only from a `false`, never from an unknown.
1358
+ *
1359
+ * @returns {boolean|null}
1360
+ */
1361
+ function sessionJobInstalled(o, label) {
1362
+ if (typeof o.sessionJobInstalled === "boolean") return o.sessionJobInstalled;
1363
+ if (!label) return null;
1364
+ let names = o.launchAgents;
1365
+ if (!Array.isArray(names)) {
1366
+ try {
1367
+ const osImpl = o.os || os;
1368
+ const home = typeof osImpl.homedir === "function" ? String(osImpl.homedir() || "") : "";
1369
+ if (!home) return null;
1370
+ names = readdirSync(join(home, "Library", "LaunchAgents"));
1371
+ } catch {
1372
+ return null; // no directory, no permission, not a Mac — unknown, not absent
1373
+ }
1374
+ }
1375
+ // Case-insensitively: HFS+/APFS are case-preserving but case-INsensitive by
1376
+ // default, so `ai.maestro.Maya-session.plist` and the lower-cased label are
1377
+ // the same file to launchd. A case difference must not read as an absence.
1378
+ const want = label.toLowerCase();
1379
+ return names.some((n) => String(n).replace(/\.plist$/, "").toLowerCase() === want);
1060
1380
  }
1061
1381
 
1062
1382
  // ---------------------------------------------------------------------------
@@ -1357,12 +1677,39 @@ export async function collectStatus(o = {}) {
1357
1677
  // and `machine` open, so this is the only place a new seat-level fact can
1358
1678
  // land without a lock-step hq deploy. `session` stays `null` when idle.
1359
1679
  let door = { frontDoor: "daemon", sessionLive: false };
1680
+ let heartbeat = null;
1681
+ let heartbeatUnreadable = false;
1360
1682
  try {
1361
- const hb = opt.heartbeat !== undefined ? opt.heartbeat : readSessionHeartbeat(opt.agentRoot);
1362
- door = sessionLiveness(hb, nowMs, { staleMs: opt.sessionStaleMs });
1683
+ if (opt.heartbeat !== undefined) heartbeat = opt.heartbeat;
1684
+ else {
1685
+ const hbState = readSessionHeartbeatState(opt.agentRoot);
1686
+ heartbeat = hbState.heartbeat;
1687
+ heartbeatUnreadable = hbState.unreadable;
1688
+ }
1689
+ door = sessionLiveness(heartbeat, nowMs, { staleMs: opt.sessionStaleMs });
1363
1690
  } catch { /* defaults: daemon front door, not live */ }
1364
1691
  machine = { ...machine, ...door };
1365
1692
 
1693
+ // 4b-ii. WHY it is not live (WP-M8) — `machine.sessionNote`, present ONLY
1694
+ // when `sessionLive` is false. The decision is the pure `sessionNote`; this
1695
+ // is just the reads that feed it, and it is fail-open in the same way every
1696
+ // other probe here is: a throw anywhere drops the field, never the beat.
1697
+ if (door.sessionLive !== true) {
1698
+ try {
1699
+ const label = typeof opt.sessionLabel === "string" ? opt.sessionLabel : sessionJobLabel(opt.agentRoot, opt.agentFirst);
1700
+ const note = sessionNote({
1701
+ heartbeat,
1702
+ heartbeatUnreadable: opt.heartbeatUnreadable !== undefined ? opt.heartbeatUnreadable === true : heartbeatUnreadable,
1703
+ attention: opt.attention !== undefined ? opt.attention : readSessionAttention(opt.agentRoot),
1704
+ jobInstalled: sessionJobInstalled(opt, label),
1705
+ label,
1706
+ now: nowMs,
1707
+ staleMs: opt.sessionStaleMs,
1708
+ });
1709
+ if (note) machine.sessionNote = note;
1710
+ } catch { /* no sessionNote — the beat still carries frontDoor/sessionLive */ }
1711
+ }
1712
+
1366
1713
  // 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
1367
1714
  // first autoupdate attempt; a corrupt file drops the field, never the beat.
1368
1715
  try {
@@ -1409,6 +1756,11 @@ export const _internals = {
1409
1756
  collectTailnetIp,
1410
1757
  readSdkVersion,
1411
1758
  sessionLiveness,
1759
+ sessionNote,
1760
+ sanitizeNoteDetail,
1761
+ sessionJobLabel,
1762
+ sessionJobInstalled,
1763
+ readSessionAttention,
1412
1764
  upgradeSummary,
1413
1765
  detectClaudeAuth,
1414
1766
  resetAuthProbeCache,
@@ -933,3 +933,288 @@ test("collectStatus: machine.upgrade rides the beat when state/autoupdate/last.j
933
933
  assert.equal(injected.machine.upgrade.ok, false);
934
934
  } finally { rmSync(root, { recursive: true, force: true }); }
935
935
  });
936
+
937
+ // ---------------------------------------------------------------------------
938
+ // machine.sessionNote (WP-M8) — WHY the front door is not live
939
+ // ---------------------------------------------------------------------------
940
+
941
+ const M8_NOW = Date.parse("2026-09-11T12:00:00Z");
942
+ /** The record scripts/session/supervisor.mjs writes via first-run#attentionRecord. */
943
+ const attention = (over = {}) => ({
944
+ reason: "no-heartbeat",
945
+ since: "2026-09-11T11:57:00.000Z",
946
+ runMs: 180_000,
947
+ attach: "tmux attach -t =maestro-maya",
948
+ hint: "The session has been up 180 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with `tmux attach -t =maestro-maya` and answer it.",
949
+ ...over,
950
+ });
951
+
952
+ test("sessionNote: a live front door says nothing at all", () => {
953
+ const { sessionNote } = _internals;
954
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 1_000 }, now: M8_NOW }), null);
955
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 89_000 }, jobInstalled: false, attention: attention(), now: M8_NOW }), null,
956
+ "liveness wins over every other input — the note exists only to explain a door that is NOT open");
957
+ // A shorter staleness bound makes the same beat stale, and then it does speak.
958
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 30_000 }, jobInstalled: true, now: M8_NOW, staleMs: 10_000 }).reason, "heartbeat-stale");
959
+ });
960
+
961
+ test("sessionNote: job-absent — no plist for this seat, and only ever from a hard false", () => {
962
+ const { sessionNote } = _internals;
963
+ const n = sessionNote({ jobInstalled: false, label: "ai.maestro.maya-session", now: M8_NOW });
964
+ assert.equal(n.reason, "job-absent");
965
+ assert.match(n.detail, /ai\.maestro\.maya-session/);
966
+ assert.equal(n.since, undefined, "there is no moment to date — the job was never there");
967
+ // It outranks a stale attention record left behind by a previous install.
968
+ assert.equal(sessionNote({ jobInstalled: false, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "job-absent");
969
+ // UNKNOWN is not absent: a home we could not read must never accuse the seat.
970
+ assert.equal(sessionNote({ jobInstalled: null, now: M8_NOW }).reason, "never-beaten");
971
+ assert.equal(sessionNote({ now: M8_NOW }).reason, "never-beaten");
972
+ // No label still yields a usable line.
973
+ assert.match(sessionNote({ jobInstalled: false, now: M8_NOW }).detail, /main-session launchd job/);
974
+ // …and it does not call a deliberate daemon-front-door seat broken: this seat
975
+ // reports job-absent on EVERY beat forever, so the line states a configuration.
976
+ assert.match(n.detail, /the daemon --print lane is the front door/);
977
+ assert.match(n.detail, /by configuration, or the session job was never installed/);
978
+ });
979
+
980
+ test("sessionNote: awaiting-input passes the supervisor's own reason, since and hint through", () => {
981
+ const { sessionNote } = _internals;
982
+ const n = sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW });
983
+ assert.equal(n.reason, "awaiting-input", "the beat's vocabulary — the record's own reason rides in detail");
984
+ assert.equal(n.since, "2026-09-11T11:57:00.000Z", "attentionRecord.since, normalised to canonical ISO");
985
+ assert.match(n.detail, /^up, never beaten: /, "the record's own reason survives, rendered out of the watchdog's vocabulary");
986
+ assert.match(n.detail, /tmux attach -t =maestro-maya/, "the attach command is the actionable part");
987
+ assert.ok(n.detail.length <= 200);
988
+ // screen seats say screen.
989
+ assert.match(sessionNote({ attention: attention({ attach: "screen -r maestro-isla", hint: "… Attach with `screen -r maestro-isla` and answer it." }), now: M8_NOW }).detail, /screen -r maestro-isla/);
990
+ // It outranks a stale heartbeat: "waiting on a human" is the actionable fact.
991
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
992
+ });
993
+
994
+ test("sessionNote: a heartbeat LATER than the attention record supersedes it", () => {
995
+ const { sessionNote } = _internals;
996
+ // supervisor.mjs clears attention.json only at the START of the next launch —
997
+ // never when the human answers the dialog — so the record outlives its truth.
998
+ // Answered at 11:57, beat until 3 days later, then the session died.
999
+ const beat = M8_NOW + 3 * 86_400_000;
1000
+ const n = sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: beat }, now: beat + 86_400_000 });
1001
+ assert.equal(n.reason, "heartbeat-stale", "a days-old attach command for a mux session that ended is worse than no note");
1002
+ assert.equal(n.since, new Date(beat).toISOString());
1003
+ // A record with no orderable `since` loses to a heartbeat that has one.
1004
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "not a date" }), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "heartbeat-stale");
1005
+ // But the record still wins while it is the LATER fact — the ordinary case:
1006
+ // the session came up, never beat, and the watchdog flagged it.
1007
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
1008
+ // …and with no heartbeat at all there is nothing to supersede it.
1009
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { pid: 7 }, now: M8_NOW }).reason, "awaiting-input");
1010
+ });
1011
+
1012
+ test("sessionNote: `since` is re-formatted from a parsed instant, never passed through", () => {
1013
+ const { sessionNote } = _internals;
1014
+ // V8's legacy date parser accepts trailing prose, so a bare Date.parse check
1015
+ // let a home path and a token-shaped run ride the one field the detail
1016
+ // sanitiser does not cover. `since` is canonical ISO or it is absent.
1017
+ const evil = "Thu, 01 Jan 2026 00:00:00 GMT (token sk-ant-supersecretvalue1234567890 /Users/maya/x)";
1018
+ assert.ok(Number.isFinite(Date.parse(evil)), "the hostile string really does parse — that is the hazard");
1019
+ const n = sessionNote({ jobInstalled: true, attention: attention({ since: evil }), now: M8_NOW });
1020
+ assert.equal(n.since, "2026-01-01T00:00:00.000Z");
1021
+ assert.ok(!/Users|sk-ant/.test(n.since));
1022
+ // Non-ISO spellings are normalised rather than echoed.
1023
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "2026-09-11T11:57:00+00:00" }), now: M8_NOW }).since, "2026-09-11T11:57:00.000Z");
1024
+ });
1025
+
1026
+ test("sessionNote: the attention record's own vocabulary is rendered, not collided with", () => {
1027
+ const { sessionNote } = _internals;
1028
+ // first-run#heartbeatSilence's "no-heartbeat" means "up, never beaten" — the
1029
+ // opposite of what a beat reason of nearly that spelling would mean. Rendering
1030
+ // it keeps `{reason, detail}` from reading as a contradiction side by side.
1031
+ assert.match(sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW }).detail, /^up, never beaten: /);
1032
+ assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "stale-heartbeat" }), now: M8_NOW }).detail, /^no beat since this launch: /);
1033
+ // A word we do not know passes through verbatim rather than being invented.
1034
+ assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "wedged-mcp" }), now: M8_NOW }).detail, /^wedged-mcp: /);
1035
+ // And no beat reason collides with a watchdog reason any more.
1036
+ const beatReasons = new Set(["job-absent", "awaiting-input", "heartbeat-stale", "heartbeat-unreadable", "never-beaten"]);
1037
+ for (const r of ["no-heartbeat", "stale-heartbeat", "within-grace", "beating"]) assert.ok(!beatReasons.has(r), `${r} is the watchdog's word, not the beat's`);
1038
+ });
1039
+
1040
+ test("sessionNote: heartbeat-stale carries the last beat and its age", () => {
1041
+ const { sessionNote } = _internals;
1042
+ const n = sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW - 600_000, pid: 4242 }, now: M8_NOW });
1043
+ assert.deepEqual(n, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1044
+ // ISO timestamps are the shape the feed actually writes.
1045
+ assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: "2026-09-11T11:50:00Z" }, now: M8_NOW }).since, "2026-09-11T11:50:00.000Z");
1046
+ // A beat from the future is clock skew, not a negative age.
1047
+ assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW + 600_000 }, now: M8_NOW }).detail, "last beat is in the future (clock skew)");
1048
+ });
1049
+
1050
+ test("sessionNote: every malformed input degrades to a coarser note or to null, and nothing throws", () => {
1051
+ const { sessionNote } = _internals;
1052
+ // No `now` at all → no note (a note with no clock behind it would be a guess).
1053
+ assert.equal(sessionNote({ heartbeat: { ts: 1 } }), null);
1054
+ assert.equal(sessionNote({ now: NaN, jobInstalled: false }), null);
1055
+ assert.equal(sessionNote(), null);
1056
+ assert.equal(sessionNote(null), null);
1057
+ assert.equal(sessionNote("nonsense"), null);
1058
+ // Absent files (the reader's null) → the installed-but-silent note.
1059
+ assert.deepEqual(sessionNote({ heartbeat: null, attention: null, jobInstalled: true, now: M8_NOW }),
1060
+ { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1061
+ // …and when job presence was never established, the note says THAT rather
1062
+ // than affirming an installation nobody checked.
1063
+ assert.deepEqual(sessionNote({ jobInstalled: null, now: M8_NOW }),
1064
+ { reason: "never-beaten", detail: "no heartbeat from the session job, and its presence on this seat could not be checked" });
1065
+ assert.equal(sessionNote({ now: M8_NOW }).detail, "no heartbeat from the session job, and its presence on this seat could not be checked");
1066
+ // A parsed-but-wrong SHAPE must not throw.
1067
+ assert.equal(sessionNote({ heartbeat: "{}", attention: "{}", now: M8_NOW }).reason, "never-beaten");
1068
+ assert.equal(sessionNote({ heartbeat: [], attention: [], jobInstalled: true, now: M8_NOW }).reason, "never-beaten", "arrays are not records");
1069
+ // A heartbeat file that is THERE but says nothing usable is its own fault —
1070
+ // the job may well have beaten, so it is never called "never beaten".
1071
+ assert.deepEqual(sessionNote({ heartbeat: { pid: 7 }, jobInstalled: true, now: M8_NOW }),
1072
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but carries no readable timestamp" });
1073
+ assert.equal(sessionNote({ heartbeat: { ts: "not a date" }, jobInstalled: true, now: M8_NOW }).reason, "heartbeat-unreadable");
1074
+ assert.deepEqual(sessionNote({ heartbeatUnreadable: true, jobInstalled: true, now: M8_NOW }),
1075
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1076
+ // An attention record missing every field is still the right REASON — it is
1077
+ // the file's existence that says "the supervisor flagged this seat".
1078
+ assert.deepEqual(sessionNote({ attention: {}, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1079
+ assert.deepEqual(sessionNote({ attention: { since: 12345, reason: 7, hint: null }, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1080
+ assert.equal(sessionNote({ attention: { since: "not a date", reason: "no-heartbeat" }, jobInstalled: true, now: M8_NOW }).since, undefined);
1081
+ });
1082
+
1083
+ test("sanitizeNoteDetail: no home paths, no ids, no control characters, never past the cap", () => {
1084
+ const { sanitizeNoteDetail } = _internals;
1085
+ assert.equal(sanitizeNoteDetail("attach with `tmux attach -t =maestro-maya`"), "attach with `tmux attach -t =maestro-maya`");
1086
+ // Home directories name a person and leak the seat's layout.
1087
+ assert.equal(sanitizeNoteDetail("blocked in /Users/maya/maya-ai/state"), "blocked in <path>");
1088
+ // A home directory with a space in it is ordinary on macOS, and its tail
1089
+ // carries the person's surname — the match may not stop at the whitespace.
1090
+ assert.equal(sanitizeNoteDetail("blocked in /Users/olivia chen/maya-ai/state/session"), "blocked in <path>");
1091
+ assert.equal(sanitizeNoteDetail("blocked in /Users/Olivia Chen/Library/Application Support/x"), "blocked in <path>");
1092
+ // …but ordinary prose after a path is not swallowed with it.
1093
+ assert.equal(sanitizeNoteDetail("blocked in /Users/maya and then retry"), "blocked in <path> and then retry");
1094
+ assert.equal(sanitizeNoteDetail("attach to /Users/maya/x and answer it"), "attach to <path> and answer it");
1095
+ assert.equal(sanitizeNoteDetail("blocked in ~/maya-ai and /home/maya/x and /private/var/folders/t/x"), "blocked in <path> and <path> and <path>");
1096
+ // Session ids and token-shaped runs.
1097
+ assert.equal(sanitizeNoteDetail("resume 3f2a1c94-7b0e-4a11-9c3d-0b7e2f8a6d51 failed"), "resume <redacted> failed");
1098
+ assert.equal(sanitizeNoteDetail("token sk-ant-0123456789abcdef rejected"), "token <redacted> rejected");
1099
+ // Newlines and control characters collapse; the note is one line.
1100
+ assert.equal(sanitizeNoteDetail("a\nb\tc\r\nd"), "a b c d");
1101
+ // The cap holds, with an ellipsis so a reader knows it was cut.
1102
+ const long = sanitizeNoteDetail("lorem ipsum ".repeat(50));
1103
+ assert.equal(long.length, 200);
1104
+ assert.ok(long.endsWith("…"));
1105
+ assert.ok(sanitizeNoteDetail("ab ".repeat(20), 10).length <= 10, "the cap is a ceiling, never exceeded (a word boundary can land under it)");
1106
+ // Nothing to say → "", and the caller then omits the field entirely.
1107
+ assert.equal(sanitizeNoteDetail(""), "");
1108
+ assert.equal(sanitizeNoteDetail(null), "");
1109
+ assert.equal(sanitizeNoteDetail(undefined), "");
1110
+ assert.equal(sanitizeNoteDetail(42), "");
1111
+ assert.equal(sanitizeNoteDetail(" \n "), "");
1112
+ // A hint long enough to overflow is truncated, not dropped.
1113
+ const hint = sanitizeNoteDetail(`no-heartbeat: The session has been up 1800 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`tmux attach -t =maestro-maya\` and answer it. ${"tail ".repeat(20)}`);
1114
+ assert.ok(hint.length <= 200 && hint.endsWith("…"), "an over-long hint is truncated, not dropped");
1115
+ });
1116
+
1117
+ test("sessionJobInstalled: a readdir of ~/Library/LaunchAgents, and 'unknown' whenever it cannot be read", () => {
1118
+ const { sessionJobInstalled, sessionJobLabel } = _internals;
1119
+ const label = "ai.maestro.maya-session";
1120
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist", "ai.maestro.maya-session.plist"] }, label), true);
1121
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session"] }, label), true, "bare labels count too");
1122
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist"] }, label), false);
1123
+ assert.equal(sessionJobInstalled({ launchAgents: [] }, label), false);
1124
+ // Unknowns: no label, an unreadable home, a home that is not there.
1125
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, ""), null);
1126
+ assert.equal(sessionJobInstalled({ os: { homedir: () => "" } }, label), null);
1127
+ assert.equal(sessionJobInstalled({ os: { homedir: () => { throw new Error("nope"); } } }, label), null);
1128
+ assert.equal(sessionJobInstalled({ os: { homedir: () => join(tmpdir(), "no-such-home-xyz") } }, label), null);
1129
+ // An explicit override wins (the daemon may already know).
1130
+ assert.equal(sessionJobInstalled({ sessionJobInstalled: true, launchAgents: [] }, label), true);
1131
+ // A case difference is not an absence: agentFirstName leaves its directory
1132
+ // fallback as-is (`~/Maya-ai` -> `Maya`), and the volume is case-insensitive.
1133
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.Maya-session.plist"] }, "ai.maestro.maya-session"), true);
1134
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, "ai.maestro.Maya-session"), true);
1135
+ // The label matches what generate-plists.sh and `maestro session` spell —
1136
+ // including its casing, which the generator does not lower-case either.
1137
+ assert.equal(sessionJobLabel("/x/maya-ai", "maya"), "ai.maestro.maya-session");
1138
+ assert.equal(sessionJobLabel("/x/Maya-ai", "Maya"), "ai.maestro.Maya-session");
1139
+ assert.equal(sessionJobLabel("", ""), "");
1140
+ });
1141
+
1142
+ test("collectStatus: machine.sessionNote appears ONLY when the front door is down, and rides machine (never session)", async () => {
1143
+ const root = mkdtempSync(join(tmpdir(), "session-note-"));
1144
+ try {
1145
+ const now = Date.parse("2026-09-11T12:00:00Z");
1146
+ const osImpl = fakeOs({});
1147
+ osImpl.hostname = () => "seat.local";
1148
+ const base = {
1149
+ agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0,
1150
+ powermetrics: false, disk: false, spend: false, agentFirst: "maya", launchAgents: [],
1151
+ };
1152
+ const sess = join(root, "state", "session");
1153
+ mkdirSync(sess, { recursive: true });
1154
+
1155
+ // 1. No job on the seat — the case A010/A016 could not tell apart.
1156
+ const absent = await collectStatus(base);
1157
+ assert.equal(absent.machine.sessionLive, false);
1158
+ assert.equal(absent.machine.sessionNote.reason, "job-absent");
1159
+ assert.match(absent.machine.sessionNote.detail, /ai\.maestro\.maya-session/);
1160
+ assert.equal(absent.session, null, "the note NEVER touches session — hq validates that object strictly");
1161
+ assert.ok(!("sessionNote" in (absent.session || {})));
1162
+
1163
+ // 2. Job installed, session up, waiting on a first-run dialog.
1164
+ const installed = { ...base, launchAgents: ["ai.maestro.maya-session.plist"] };
1165
+ writeFileSync(join(sess, "attention.json"), JSON.stringify(attention()));
1166
+ const waiting = await collectStatus(installed);
1167
+ assert.equal(waiting.machine.sessionNote.reason, "awaiting-input");
1168
+ assert.equal(waiting.machine.sessionNote.since, "2026-09-11T11:57:00.000Z");
1169
+ assert.match(waiting.machine.sessionNote.detail, /tmux attach -t =maestro-maya/);
1170
+
1171
+ // 3. The supervisor cleared attention.json and the beat then went stale.
1172
+ rmSync(join(sess, "attention.json"));
1173
+ writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:50:00Z" }));
1174
+ const stale = await collectStatus(installed);
1175
+ assert.equal(stale.machine.frontDoor, "session");
1176
+ assert.deepEqual(stale.machine.sessionNote, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1177
+
1178
+ // 4. A live front door carries NO note — the key is absent, not null.
1179
+ writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:59:50Z" }));
1180
+ const live = await collectStatus(installed);
1181
+ assert.equal(live.machine.sessionLive, true);
1182
+ assert.ok(!("sessionNote" in live.machine), "a healthy seat must not pay for a field it has nothing to say in");
1183
+
1184
+ // 5. Corrupt state files never break the beat — and a heartbeat file that
1185
+ // is THERE and will not parse is reported as such, not as "never beaten".
1186
+ writeFileSync(join(sess, "heartbeat.json"), "{not json");
1187
+ writeFileSync(join(sess, "attention.json"), "{not json");
1188
+ const corrupt = await collectStatus(installed);
1189
+ assert.equal(corrupt.machine.sessionLive, false);
1190
+ assert.deepEqual(corrupt.machine.sessionNote,
1191
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1192
+ assert.equal(typeof corrupt.ts, "string", "the snapshot is intact");
1193
+
1194
+ // 5b. No heartbeat file at all, job installed → never-beaten.
1195
+ rmSync(join(sess, "heartbeat.json"));
1196
+ rmSync(join(sess, "attention.json"));
1197
+ const silent = await collectStatus(installed);
1198
+ assert.deepEqual(silent.machine.sessionNote,
1199
+ { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1200
+
1201
+ // 6. The whole note is a best-effort extra: a throwing os.homedir drops the
1202
+ // launchd read, never the beat — and an unknown job presence is reported
1203
+ // as neither an absence nor a presence.
1204
+ const hostile = await collectStatus({ ...base, launchAgents: undefined, os: { ...osImpl, homedir: () => { throw new Error("no home"); } } });
1205
+ assert.equal(hostile.machine.sessionNote.reason, "never-beaten", "unknown job presence is not an absence");
1206
+ assert.equal(hostile.machine.sessionNote.detail, "no heartbeat from the session job, and its presence on this seat could not be checked",
1207
+ "…and it is not reported as a presence either");
1208
+ assert.equal(hostile.machine.sessionLive, false);
1209
+
1210
+ // 7. The emitted shape is exactly {reason, since?, detail?} — nothing else,
1211
+ // and nothing unbounded.
1212
+ for (const s of [absent, waiting, stale, corrupt, silent, hostile]) {
1213
+ const note = s.machine.sessionNote;
1214
+ assert.equal(typeof note.reason, "string");
1215
+ for (const k of Object.keys(note)) assert.ok(["reason", "since", "detail"].includes(k), `sessionNote.${k} is not in the contract`);
1216
+ if (note.since !== undefined) assert.ok(Number.isFinite(Date.parse(note.since)), "since is ISO 8601");
1217
+ if (note.detail !== undefined) assert.ok(note.detail.length <= 200 && !/\/Users\//.test(note.detail));
1218
+ }
1219
+ } finally { rmSync(root, { recursive: true, force: true }); }
1220
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
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": {