@cohortapp/agent-sdk 2.13.0 → 2.15.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"]);
@@ -1027,10 +1060,240 @@ export function sessionLiveness(hb, nowMs, opts = {}) {
1027
1060
  const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : SESSION_STALE_MS;
1028
1061
  const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
1029
1062
  const age = nowMs - ts;
1030
- const live = Number.isFinite(age) && age >= 0 && age <= staleMs;
1063
+ // SYMMETRIC around now, matching lib/session/liveness.mjs#heartbeatLiveness,
1064
+ // which this used to contradict: it accepted `-staleMs < age < staleMs` while
1065
+ // this demanded `age >= 0`, so the same heartbeat could be live to the session
1066
+ // runtime and dead to the beat.
1067
+ //
1068
+ // The asymmetry was not theoretical. A039 flapped between live and not-live
1069
+ // across consecutive beats on 2026-09-10/11 while its session kept running,
1070
+ // because a heartbeat written microseconds before the wall clock stepped back
1071
+ // reads as future-dated, and ANY future dating failed `age >= 0`. The seat is
1072
+ // within 30 s of the server, so this is ordinary same-machine clock motion
1073
+ // (NTP correcting a box 95 days up), not a broken clock — and a front-door
1074
+ // signal that flickers is worse than none, because it is the one field an
1075
+ // operator uses to decide whether to walk to a machine.
1076
+ //
1077
+ // Beyond the window a future heartbeat is still NOT live, and sessionNote
1078
+ // reports it as such ("last beat is in the future (clock skew)") rather than
1079
+ // silently believing a timestamp it cannot trust.
1080
+ // Strict on BOTH edges, because that is what heartbeatLiveness does
1081
+ // (`age >= staleMs` and `age <= -staleMs` are each "not live"). An edge that
1082
+ // differs by one millisecond is still two answers to one question.
1083
+ const live = Number.isFinite(age) && Math.abs(age) < staleMs;
1031
1084
  return { frontDoor: "session", sessionLive: live };
1032
1085
  }
1033
1086
 
1087
+ /** Hard cap on `machine.sessionNote.detail` — the beat is a heartbeat, not a log. */
1088
+ export const SESSION_NOTE_DETAIL_MAX = 200;
1089
+
1090
+ /**
1091
+ * Absolute paths that could name a person, a home or a private temp dir.
1092
+ *
1093
+ * A path segment may CONTAIN a space — `/Users/Olivia Chen/…` is an ordinary
1094
+ * macOS home — so the match does not simply stop at the first whitespace, which
1095
+ * left `<path> Chen/olivia-ai/state` (a surname and the seat's layout) standing
1096
+ * in the note. A space is swallowed only when what follows it is still a path
1097
+ * (more non-space characters and then a `/`), so ordinary prose after a path
1098
+ * survives: `blocked in /Users/maya and then retry` redacts only the path.
1099
+ */
1100
+ const NOTE_PATH_RE = /(?:~|\/(?:Users|home|private|var\/folders))\/(?:[^\s"'`]|[ \t](?=[^\s"'`]*\/))*/g;
1101
+ /** Token-shaped words: an api-key prefix, or any long opaque run (uuids included). */
1102
+ const NOTE_TOKEN_RE = /\b(?:sk-[A-Za-z0-9_-]{8,}|[A-Za-z0-9][A-Za-z0-9_-]{31,})\b/g;
1103
+
1104
+ /**
1105
+ * Pure: make a free-text note safe and small enough to ride every beat.
1106
+ *
1107
+ * The text this scrubs is OUR OWN (the supervisor's attention hint), so it is
1108
+ * not a sanitiser standing between us and an attacker — it is the guarantee the
1109
+ * contract makes to hq and to every operator reading a fleet view: a note never
1110
+ * carries a home directory, a session id, a token or a file path, and never
1111
+ * grows past {@link SESSION_NOTE_DETAIL_MAX}. The hint names an attach command
1112
+ * (`tmux attach -t =maestro-alex`), which is exactly the actionable part and
1113
+ * survives; the paths and ids around it do not.
1114
+ *
1115
+ * @param {unknown} text
1116
+ * @param {number} [max]
1117
+ * @returns {string} "" when there is nothing safe left to say
1118
+ */
1119
+ export function sanitizeNoteDetail(text, max = SESSION_NOTE_DETAIL_MAX) {
1120
+ if (typeof text !== "string" || !text) return "";
1121
+ const cap = Number.isFinite(max) && max > 0 ? Math.floor(max) : SESSION_NOTE_DETAIL_MAX;
1122
+ let out = text.replace(/[\u0000-\u001F\u007F]+/g, " ");
1123
+ out = out.replace(NOTE_PATH_RE, "<path>");
1124
+ out = out.replace(NOTE_TOKEN_RE, "<redacted>");
1125
+ out = out.replace(/\s+/g, " ").trim();
1126
+ if (out.length > cap) out = `${out.slice(0, cap - 1).trimEnd()}…`;
1127
+ return out;
1128
+ }
1129
+
1130
+ /** ms-or-ISO `ts` off a heartbeat record -> epoch ms, else null. */
1131
+ function heartbeatTs(hb) {
1132
+ if (!hb || typeof hb !== "object") return null;
1133
+ const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
1134
+ return Number.isFinite(ts) ? ts : null;
1135
+ }
1136
+
1137
+ /**
1138
+ * Pure: an ISO-8601 string -> epoch ms, else null.
1139
+ *
1140
+ * `since` rides the beat as the note's only structured field, and it used to
1141
+ * ride VERBATIM behind a bare `Date.parse` finiteness check — which V8's legacy
1142
+ * date parser passes for a string carrying trailing prose, so a home path or a
1143
+ * token-shaped run could travel on the one field {@link sanitizeNoteDetail}
1144
+ * does not cover. Parsing to ms here and re-formatting through `toISOString()`
1145
+ * makes `since` bounded and canonical by CONSTRUCTION rather than by trusting
1146
+ * the file it came from.
1147
+ */
1148
+ function instantMs(v) {
1149
+ if (typeof v !== "string" || !v) return null;
1150
+ const ms = Date.parse(v);
1151
+ return Number.isFinite(ms) ? ms : null;
1152
+ }
1153
+
1154
+ /**
1155
+ * The supervisor watchdog's vocabulary (`lib/session/first-run#heartbeatSilence`)
1156
+ * in plain words.
1157
+ *
1158
+ * Its `"no-heartbeat"` means "the session is UP and has never beaten"; the
1159
+ * beat's own reasons of nearly the same spelling mean something else, and the
1160
+ * two met inside one payload (`{reason:"awaiting-input", detail:"no-heartbeat: …"}`),
1161
+ * which reads as a contradiction to anything that shows reason and detail side
1162
+ * by side. An unrecognised value still passes through verbatim — a word we do
1163
+ * not know is better than a word we invent.
1164
+ */
1165
+ const ATTENTION_WHY = {
1166
+ "no-heartbeat": "up, never beaten",
1167
+ "stale-heartbeat": "no beat since this launch",
1168
+ };
1169
+
1170
+ /**
1171
+ * Pure: WHY the front door is not live — the beat's `machine.sessionNote`.
1172
+ *
1173
+ * THE GAP (WP-M8): after 2.12.0, seats A010 and A016 beat
1174
+ * `frontDoor:"daemon", sessionLive:false` for days and nothing on the beat
1175
+ * could tell a job that was never installed from a session sitting on an
1176
+ * unanswered first-run dialog from a supervisor that had died — three states an
1177
+ * operator fixes three different ways. No seat in the fleet accepts SSH, so the
1178
+ * beat is the only channel; the seat already knew the answer
1179
+ * (`state/session/attention.json`), it just never left the machine.
1180
+ *
1181
+ * ORDER, and why each step outranks the next:
1182
+ *
1183
+ * 1. `job-absent` first: a seat with no job cannot have a meaningful heartbeat
1184
+ * or attention record, and a stale `attention.json` left behind by a
1185
+ * previous install must not mask "the job is gone". It is a statement of
1186
+ * CONFIGURATION, not necessarily a fault — a seat whose front door is
1187
+ * deliberately the daemon --print lane reports it on every beat, and the
1188
+ * detail says so rather than accusing the seat.
1189
+ * 2. `awaiting-input` next — but ONLY while the record still describes the
1190
+ * present. `scripts/session/supervisor.mjs` clears `attention.json` at the
1191
+ * START of the next launch and never when the human answers the dialog, so
1192
+ * a record can outlive its truth by days: the session gets answered, beats
1193
+ * for a week, then dies. A heartbeat LATER than the record is proof the
1194
+ * dialog was answered, and the record is then superseded — otherwise the
1195
+ * beat hands the operator a week-old attach command for a mux session that
1196
+ * no longer exists while the real fault stays invisible.
1197
+ * 3. `heartbeat-stale` / `heartbeat-unreadable` / `never-beaten` last, from
1198
+ * what the heartbeat file itself can prove.
1199
+ *
1200
+ * Every emitted `detail` must be TRUE of the state it describes: an unknown is
1201
+ * never reported as an absence (`job-absent` comes only from a hard `false`)
1202
+ * and, symmetrically, never as a presence (`never-beaten` says the job is
1203
+ * installed only when it was actually checked).
1204
+ *
1205
+ * Everything is INJECTED — no clock, no fs, no launchctl. Every malformed
1206
+ * input resolves to a coarser answer or to `null`; nothing here throws.
1207
+ *
1208
+ * @param {object} a
1209
+ * @param {object|null} [a.heartbeat] parsed state/session/heartbeat.json (null when absent/corrupt)
1210
+ * @param {boolean} [a.heartbeatUnreadable] the file was THERE but would not parse (a different fault from "never beaten")
1211
+ * @param {object|null} [a.attention] parsed state/session/attention.json (lib/session/first-run#attentionRecord)
1212
+ * @param {boolean|null} [a.jobInstalled] is the `<first>-session` launchd job present? null = COULD NOT TELL (reported as neither an absence nor a presence)
1213
+ * @param {string} [a.label] the launchd label, for the job-absent detail
1214
+ * @param {number} a.now epoch ms
1215
+ * @param {number} [a.staleMs] heartbeat staleness bound (default {@link SESSION_STALE_MS})
1216
+ * @returns {{reason:string, since?:string, detail?:string}|null} null when the front door is live (or `now` is unusable)
1217
+ */
1218
+ export function sessionNote(a = {}) {
1219
+ const o = a && typeof a === "object" ? a : {};
1220
+ const now = Number(o.now);
1221
+ if (!Number.isFinite(now)) return null;
1222
+ const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : SESSION_STALE_MS;
1223
+ const hb = o.heartbeat && typeof o.heartbeat === "object" && !Array.isArray(o.heartbeat) ? o.heartbeat : null;
1224
+
1225
+ // A live front door has nothing to explain — the note is an EXCEPTION field.
1226
+ if (sessionLiveness(hb, now, { staleMs }).sessionLive) return null;
1227
+
1228
+ // 1. The job is not on this seat at all. `false` only; `null` is "unknown",
1229
+ // and an unknown must never be reported as an absence.
1230
+ if (o.jobInstalled === false) {
1231
+ const label = typeof o.label === "string" && o.label.trim() ? o.label.trim() : "";
1232
+ return {
1233
+ reason: "job-absent",
1234
+ detail: sanitizeNoteDetail(
1235
+ `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)`,
1236
+ ),
1237
+ };
1238
+ }
1239
+
1240
+ const hbMs = heartbeatTs(hb);
1241
+
1242
+ // 2. The supervisor's watchdog has already named it: alive, never beaten —
1243
+ // a first-run trust/permission dialog with nobody attached to answer.
1244
+ // Unless a LATER heartbeat proves the dialog was answered and the record
1245
+ // is simply one the supervisor never got to clear (see ORDER above). A
1246
+ // record with no usable `since` cannot be ordered against the heartbeat,
1247
+ // and a heartbeat with a real timestamp is the harder evidence, so that
1248
+ // too counts as superseded.
1249
+ const att = o.attention && typeof o.attention === "object" && !Array.isArray(o.attention) ? o.attention : null;
1250
+ const attMs = att ? instantMs(att.since) : null;
1251
+ const superseded = att !== null && hbMs !== null && (attMs === null || hbMs > attMs);
1252
+ if (att && !superseded) {
1253
+ const note = { reason: "awaiting-input" };
1254
+ if (attMs !== null) note.since = new Date(attMs).toISOString();
1255
+ const raw = typeof att.reason === "string" ? att.reason.trim() : "";
1256
+ const why = raw ? (ATTENTION_WHY[raw] || raw) : "";
1257
+ const hint = typeof att.hint === "string" ? att.hint : "";
1258
+ const detail = sanitizeNoteDetail(why && hint ? `${why}: ${hint}` : why || hint);
1259
+ if (detail) note.detail = detail;
1260
+ return note;
1261
+ }
1262
+
1263
+ // 3. It beat once and stopped: the supervisor died, or the mux session ended.
1264
+ if (hbMs !== null) {
1265
+ const ageMs = now - hbMs;
1266
+ return {
1267
+ reason: "heartbeat-stale",
1268
+ since: new Date(hbMs).toISOString(),
1269
+ detail: ageMs < 0 ? "last beat is in the future (clock skew)" : `no beat for ${Math.round(ageMs / 1000)} s`,
1270
+ };
1271
+ }
1272
+
1273
+ // 4. A heartbeat file is THERE but says nothing usable. Not the same fault as
1274
+ // "never beaten" — the job may well have beaten and the file be corrupt —
1275
+ // so it gets its own reason and its own fix.
1276
+ if (hb || o.heartbeatUnreadable === true) {
1277
+ return {
1278
+ reason: "heartbeat-unreadable",
1279
+ detail: hb
1280
+ ? "the heartbeat file is present but carries no readable timestamp"
1281
+ : "the heartbeat file is present but could not be parsed",
1282
+ };
1283
+ }
1284
+
1285
+ // 5. Nothing on disk at all: a job that has never beaten and whose watchdog
1286
+ // has not fired yet. What we say about the job depends on whether its
1287
+ // presence was actually established — `true`, and only `true`, licenses
1288
+ // "the session job is installed".
1289
+ return {
1290
+ reason: "never-beaten",
1291
+ detail: o.jobInstalled === true
1292
+ ? "the session job is installed but has never written a heartbeat"
1293
+ : "no heartbeat from the session job, and its presence on this seat could not be checked",
1294
+ };
1295
+ }
1296
+
1034
1297
  /**
1035
1298
  * Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
1036
1299
  * (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
@@ -1053,10 +1316,87 @@ function readAutoupdateLast(agentRoot) {
1053
1316
  return safeReadJson(join(resolve(agentRoot), "state", "autoupdate", "last.json"));
1054
1317
  }
1055
1318
 
1056
- /** Read state/session/heartbeat.json; null when absent or malformed. */
1057
- function readSessionHeartbeat(agentRoot) {
1319
+ /**
1320
+ * Read state/session/heartbeat.json, distinguishing ABSENT from PRESENT-BUT-
1321
+ * UNPARSEABLE.
1322
+ *
1323
+ * `safeReadJson` maps both to `null`, which is right for liveness (neither is a
1324
+ * live beat) and wrong for the note: a corrupt file told the operator the job
1325
+ * "has never written a heartbeat" when in fact it had, and the fix for the two
1326
+ * is not the same. The two facts are separated here and rejoined in
1327
+ * {@link sessionNote}.
1328
+ *
1329
+ * @returns {{heartbeat:object|null, unreadable:boolean}}
1330
+ */
1331
+ function readSessionHeartbeatState(agentRoot) {
1332
+ if (!agentRoot) return { heartbeat: null, unreadable: false };
1333
+ let text;
1334
+ try {
1335
+ text = readFileSync(join(resolve(agentRoot), "state", "session", "heartbeat.json"), "utf8");
1336
+ } catch {
1337
+ return { heartbeat: null, unreadable: false }; // no file: the job has never beaten
1338
+ }
1339
+ try {
1340
+ const v = JSON.parse(text);
1341
+ if (v && typeof v === "object" && !Array.isArray(v)) return { heartbeat: v, unreadable: false };
1342
+ return { heartbeat: null, unreadable: true }; // a JSON scalar is not a heartbeat
1343
+ } catch {
1344
+ return { heartbeat: null, unreadable: true };
1345
+ }
1346
+ }
1347
+
1348
+ /** Read state/session/attention.json (the supervisor's watchdog record); null when absent or malformed. */
1349
+ function readSessionAttention(agentRoot) {
1058
1350
  if (!agentRoot) return null;
1059
- return safeReadJson(join(resolve(agentRoot), "state", "session", "heartbeat.json"));
1351
+ return safeReadJson(join(resolve(agentRoot), "state", "session", "attention.json"));
1352
+ }
1353
+
1354
+ /**
1355
+ * The launchd label of this seat's main-session job, as
1356
+ * `scripts/local-triggers/generate-plists.sh` and `maestro session` spell it.
1357
+ * "" when the first name cannot be derived.
1358
+ */
1359
+ function sessionJobLabel(agentRoot, first) {
1360
+ // NOT lower-cased: `agentFirstName` deliberately leaves its directory-name
1361
+ // fallback as-is (`~/Maya-ai` -> `Maya`) because the generator does, so
1362
+ // lower-casing an INJECTED first here built a label the plist file does not
1363
+ // carry — and a label that does not match is how a healthy seat gets accused
1364
+ // of `job-absent`, the one verdict that must come from a hard fact.
1365
+ const f = typeof first === "string" && first.trim() ? first.trim() : (agentRoot ? agentFirstName(resolve(agentRoot)) : "");
1366
+ return f ? `ai.maestro.${f}-session` : "";
1367
+ }
1368
+
1369
+ /**
1370
+ * Is the main-session launchd job on this seat? A filename scan of
1371
+ * ~/Library/LaunchAgents — a readdir, NOT a `launchctl list` fork, because this
1372
+ * runs on every presence beat.
1373
+ *
1374
+ * Returns `null` for "could not tell" (no label, no home, an unreadable
1375
+ * directory, a non-darwin box) and only ever `false` when the directory was
1376
+ * read and the label was genuinely not in it: `sessionNote` reports an absence
1377
+ * only from a `false`, never from an unknown.
1378
+ *
1379
+ * @returns {boolean|null}
1380
+ */
1381
+ function sessionJobInstalled(o, label) {
1382
+ if (typeof o.sessionJobInstalled === "boolean") return o.sessionJobInstalled;
1383
+ if (!label) return null;
1384
+ let names = o.launchAgents;
1385
+ if (!Array.isArray(names)) {
1386
+ try {
1387
+ const osImpl = o.os || os;
1388
+ const home = typeof osImpl.homedir === "function" ? String(osImpl.homedir() || "") : "";
1389
+ if (!home) return null;
1390
+ names = readdirSync(join(home, "Library", "LaunchAgents"));
1391
+ } catch {
1392
+ return null; // no directory, no permission, not a Mac — unknown, not absent
1393
+ }
1394
+ }
1395
+ // Case-insensitively: HFS+/APFS are case-preserving but case-INsensitive by
1396
+ // default, so `ai.maestro.Maya-session.plist` and the lower-cased label are
1397
+ // the same file to launchd. A case difference must not read as an absence.
1398
+ const want = label.toLowerCase();
1399
+ return names.some((n) => String(n).replace(/\.plist$/, "").toLowerCase() === want);
1060
1400
  }
1061
1401
 
1062
1402
  // ---------------------------------------------------------------------------
@@ -1357,12 +1697,39 @@ export async function collectStatus(o = {}) {
1357
1697
  // and `machine` open, so this is the only place a new seat-level fact can
1358
1698
  // land without a lock-step hq deploy. `session` stays `null` when idle.
1359
1699
  let door = { frontDoor: "daemon", sessionLive: false };
1700
+ let heartbeat = null;
1701
+ let heartbeatUnreadable = false;
1360
1702
  try {
1361
- const hb = opt.heartbeat !== undefined ? opt.heartbeat : readSessionHeartbeat(opt.agentRoot);
1362
- door = sessionLiveness(hb, nowMs, { staleMs: opt.sessionStaleMs });
1703
+ if (opt.heartbeat !== undefined) heartbeat = opt.heartbeat;
1704
+ else {
1705
+ const hbState = readSessionHeartbeatState(opt.agentRoot);
1706
+ heartbeat = hbState.heartbeat;
1707
+ heartbeatUnreadable = hbState.unreadable;
1708
+ }
1709
+ door = sessionLiveness(heartbeat, nowMs, { staleMs: opt.sessionStaleMs });
1363
1710
  } catch { /* defaults: daemon front door, not live */ }
1364
1711
  machine = { ...machine, ...door };
1365
1712
 
1713
+ // 4b-ii. WHY it is not live (WP-M8) — `machine.sessionNote`, present ONLY
1714
+ // when `sessionLive` is false. The decision is the pure `sessionNote`; this
1715
+ // is just the reads that feed it, and it is fail-open in the same way every
1716
+ // other probe here is: a throw anywhere drops the field, never the beat.
1717
+ if (door.sessionLive !== true) {
1718
+ try {
1719
+ const label = typeof opt.sessionLabel === "string" ? opt.sessionLabel : sessionJobLabel(opt.agentRoot, opt.agentFirst);
1720
+ const note = sessionNote({
1721
+ heartbeat,
1722
+ heartbeatUnreadable: opt.heartbeatUnreadable !== undefined ? opt.heartbeatUnreadable === true : heartbeatUnreadable,
1723
+ attention: opt.attention !== undefined ? opt.attention : readSessionAttention(opt.agentRoot),
1724
+ jobInstalled: sessionJobInstalled(opt, label),
1725
+ label,
1726
+ now: nowMs,
1727
+ staleMs: opt.sessionStaleMs,
1728
+ });
1729
+ if (note) machine.sessionNote = note;
1730
+ } catch { /* no sessionNote — the beat still carries frontDoor/sessionLive */ }
1731
+ }
1732
+
1366
1733
  // 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
1367
1734
  // first autoupdate attempt; a corrupt file drops the field, never the beat.
1368
1735
  try {
@@ -1409,6 +1776,11 @@ export const _internals = {
1409
1776
  collectTailnetIp,
1410
1777
  readSdkVersion,
1411
1778
  sessionLiveness,
1779
+ sessionNote,
1780
+ sanitizeNoteDetail,
1781
+ sessionJobLabel,
1782
+ sessionJobInstalled,
1783
+ readSessionAttention,
1412
1784
  upgradeSummary,
1413
1785
  detectClaudeAuth,
1414
1786
  resetAuthProbeCache,
@@ -33,6 +33,8 @@ import {
33
33
  ramLabel,
34
34
  collectSpend24h,
35
35
  collectInventory,
36
+ sessionLiveness,
37
+ SESSION_STALE_MS,
36
38
  _internals,
37
39
  } from "./collect.mjs";
38
40
 
@@ -933,3 +935,340 @@ test("collectStatus: machine.upgrade rides the beat when state/autoupdate/last.j
933
935
  assert.equal(injected.machine.upgrade.ok, false);
934
936
  } finally { rmSync(root, { recursive: true, force: true }); }
935
937
  });
938
+
939
+ // ---------------------------------------------------------------------------
940
+ // machine.sessionNote (WP-M8) — WHY the front door is not live
941
+ // ---------------------------------------------------------------------------
942
+
943
+ const M8_NOW = Date.parse("2026-09-11T12:00:00Z");
944
+ /** The record scripts/session/supervisor.mjs writes via first-run#attentionRecord. */
945
+ const attention = (over = {}) => ({
946
+ reason: "no-heartbeat",
947
+ since: "2026-09-11T11:57:00.000Z",
948
+ runMs: 180_000,
949
+ attach: "tmux attach -t =maestro-maya",
950
+ 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.",
951
+ ...over,
952
+ });
953
+
954
+ test("sessionNote: a live front door says nothing at all", () => {
955
+ const { sessionNote } = _internals;
956
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 1_000 }, now: M8_NOW }), null);
957
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 89_000 }, jobInstalled: false, attention: attention(), now: M8_NOW }), null,
958
+ "liveness wins over every other input — the note exists only to explain a door that is NOT open");
959
+ // A shorter staleness bound makes the same beat stale, and then it does speak.
960
+ assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 30_000 }, jobInstalled: true, now: M8_NOW, staleMs: 10_000 }).reason, "heartbeat-stale");
961
+ });
962
+
963
+ test("sessionNote: job-absent — no plist for this seat, and only ever from a hard false", () => {
964
+ const { sessionNote } = _internals;
965
+ const n = sessionNote({ jobInstalled: false, label: "ai.maestro.maya-session", now: M8_NOW });
966
+ assert.equal(n.reason, "job-absent");
967
+ assert.match(n.detail, /ai\.maestro\.maya-session/);
968
+ assert.equal(n.since, undefined, "there is no moment to date — the job was never there");
969
+ // It outranks a stale attention record left behind by a previous install.
970
+ assert.equal(sessionNote({ jobInstalled: false, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "job-absent");
971
+ // UNKNOWN is not absent: a home we could not read must never accuse the seat.
972
+ assert.equal(sessionNote({ jobInstalled: null, now: M8_NOW }).reason, "never-beaten");
973
+ assert.equal(sessionNote({ now: M8_NOW }).reason, "never-beaten");
974
+ // No label still yields a usable line.
975
+ assert.match(sessionNote({ jobInstalled: false, now: M8_NOW }).detail, /main-session launchd job/);
976
+ // …and it does not call a deliberate daemon-front-door seat broken: this seat
977
+ // reports job-absent on EVERY beat forever, so the line states a configuration.
978
+ assert.match(n.detail, /the daemon --print lane is the front door/);
979
+ assert.match(n.detail, /by configuration, or the session job was never installed/);
980
+ });
981
+
982
+ test("sessionNote: awaiting-input passes the supervisor's own reason, since and hint through", () => {
983
+ const { sessionNote } = _internals;
984
+ const n = sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW });
985
+ assert.equal(n.reason, "awaiting-input", "the beat's vocabulary — the record's own reason rides in detail");
986
+ assert.equal(n.since, "2026-09-11T11:57:00.000Z", "attentionRecord.since, normalised to canonical ISO");
987
+ assert.match(n.detail, /^up, never beaten: /, "the record's own reason survives, rendered out of the watchdog's vocabulary");
988
+ assert.match(n.detail, /tmux attach -t =maestro-maya/, "the attach command is the actionable part");
989
+ assert.ok(n.detail.length <= 200);
990
+ // screen seats say screen.
991
+ 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/);
992
+ // It outranks a stale heartbeat: "waiting on a human" is the actionable fact.
993
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
994
+ });
995
+
996
+ test("sessionNote: a heartbeat LATER than the attention record supersedes it", () => {
997
+ const { sessionNote } = _internals;
998
+ // supervisor.mjs clears attention.json only at the START of the next launch —
999
+ // never when the human answers the dialog — so the record outlives its truth.
1000
+ // Answered at 11:57, beat until 3 days later, then the session died.
1001
+ const beat = M8_NOW + 3 * 86_400_000;
1002
+ const n = sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: beat }, now: beat + 86_400_000 });
1003
+ assert.equal(n.reason, "heartbeat-stale", "a days-old attach command for a mux session that ended is worse than no note");
1004
+ assert.equal(n.since, new Date(beat).toISOString());
1005
+ // A record with no orderable `since` loses to a heartbeat that has one.
1006
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "not a date" }), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "heartbeat-stale");
1007
+ // But the record still wins while it is the LATER fact — the ordinary case:
1008
+ // the session came up, never beat, and the watchdog flagged it.
1009
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
1010
+ // …and with no heartbeat at all there is nothing to supersede it.
1011
+ assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { pid: 7 }, now: M8_NOW }).reason, "awaiting-input");
1012
+ });
1013
+
1014
+ test("sessionNote: `since` is re-formatted from a parsed instant, never passed through", () => {
1015
+ const { sessionNote } = _internals;
1016
+ // V8's legacy date parser accepts trailing prose, so a bare Date.parse check
1017
+ // let a home path and a token-shaped run ride the one field the detail
1018
+ // sanitiser does not cover. `since` is canonical ISO or it is absent.
1019
+ const evil = "Thu, 01 Jan 2026 00:00:00 GMT (token sk-ant-supersecretvalue1234567890 /Users/maya/x)";
1020
+ assert.ok(Number.isFinite(Date.parse(evil)), "the hostile string really does parse — that is the hazard");
1021
+ const n = sessionNote({ jobInstalled: true, attention: attention({ since: evil }), now: M8_NOW });
1022
+ assert.equal(n.since, "2026-01-01T00:00:00.000Z");
1023
+ assert.ok(!/Users|sk-ant/.test(n.since));
1024
+ // Non-ISO spellings are normalised rather than echoed.
1025
+ 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");
1026
+ });
1027
+
1028
+ test("sessionNote: the attention record's own vocabulary is rendered, not collided with", () => {
1029
+ const { sessionNote } = _internals;
1030
+ // first-run#heartbeatSilence's "no-heartbeat" means "up, never beaten" — the
1031
+ // opposite of what a beat reason of nearly that spelling would mean. Rendering
1032
+ // it keeps `{reason, detail}` from reading as a contradiction side by side.
1033
+ assert.match(sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW }).detail, /^up, never beaten: /);
1034
+ assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "stale-heartbeat" }), now: M8_NOW }).detail, /^no beat since this launch: /);
1035
+ // A word we do not know passes through verbatim rather than being invented.
1036
+ assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "wedged-mcp" }), now: M8_NOW }).detail, /^wedged-mcp: /);
1037
+ // And no beat reason collides with a watchdog reason any more.
1038
+ const beatReasons = new Set(["job-absent", "awaiting-input", "heartbeat-stale", "heartbeat-unreadable", "never-beaten"]);
1039
+ 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`);
1040
+ });
1041
+
1042
+ test("sessionNote: heartbeat-stale carries the last beat and its age", () => {
1043
+ const { sessionNote } = _internals;
1044
+ const n = sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW - 600_000, pid: 4242 }, now: M8_NOW });
1045
+ assert.deepEqual(n, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1046
+ // ISO timestamps are the shape the feed actually writes.
1047
+ assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: "2026-09-11T11:50:00Z" }, now: M8_NOW }).since, "2026-09-11T11:50:00.000Z");
1048
+ // A beat from the future is clock skew, not a negative age.
1049
+ assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW + 600_000 }, now: M8_NOW }).detail, "last beat is in the future (clock skew)");
1050
+ });
1051
+
1052
+ test("sessionNote: every malformed input degrades to a coarser note or to null, and nothing throws", () => {
1053
+ const { sessionNote } = _internals;
1054
+ // No `now` at all → no note (a note with no clock behind it would be a guess).
1055
+ assert.equal(sessionNote({ heartbeat: { ts: 1 } }), null);
1056
+ assert.equal(sessionNote({ now: NaN, jobInstalled: false }), null);
1057
+ assert.equal(sessionNote(), null);
1058
+ assert.equal(sessionNote(null), null);
1059
+ assert.equal(sessionNote("nonsense"), null);
1060
+ // Absent files (the reader's null) → the installed-but-silent note.
1061
+ assert.deepEqual(sessionNote({ heartbeat: null, attention: null, jobInstalled: true, now: M8_NOW }),
1062
+ { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1063
+ // …and when job presence was never established, the note says THAT rather
1064
+ // than affirming an installation nobody checked.
1065
+ assert.deepEqual(sessionNote({ jobInstalled: null, now: M8_NOW }),
1066
+ { reason: "never-beaten", detail: "no heartbeat from the session job, and its presence on this seat could not be checked" });
1067
+ assert.equal(sessionNote({ now: M8_NOW }).detail, "no heartbeat from the session job, and its presence on this seat could not be checked");
1068
+ // A parsed-but-wrong SHAPE must not throw.
1069
+ assert.equal(sessionNote({ heartbeat: "{}", attention: "{}", now: M8_NOW }).reason, "never-beaten");
1070
+ assert.equal(sessionNote({ heartbeat: [], attention: [], jobInstalled: true, now: M8_NOW }).reason, "never-beaten", "arrays are not records");
1071
+ // A heartbeat file that is THERE but says nothing usable is its own fault —
1072
+ // the job may well have beaten, so it is never called "never beaten".
1073
+ assert.deepEqual(sessionNote({ heartbeat: { pid: 7 }, jobInstalled: true, now: M8_NOW }),
1074
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but carries no readable timestamp" });
1075
+ assert.equal(sessionNote({ heartbeat: { ts: "not a date" }, jobInstalled: true, now: M8_NOW }).reason, "heartbeat-unreadable");
1076
+ assert.deepEqual(sessionNote({ heartbeatUnreadable: true, jobInstalled: true, now: M8_NOW }),
1077
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1078
+ // An attention record missing every field is still the right REASON — it is
1079
+ // the file's existence that says "the supervisor flagged this seat".
1080
+ assert.deepEqual(sessionNote({ attention: {}, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1081
+ assert.deepEqual(sessionNote({ attention: { since: 12345, reason: 7, hint: null }, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1082
+ assert.equal(sessionNote({ attention: { since: "not a date", reason: "no-heartbeat" }, jobInstalled: true, now: M8_NOW }).since, undefined);
1083
+ });
1084
+
1085
+ test("sanitizeNoteDetail: no home paths, no ids, no control characters, never past the cap", () => {
1086
+ const { sanitizeNoteDetail } = _internals;
1087
+ assert.equal(sanitizeNoteDetail("attach with `tmux attach -t =maestro-maya`"), "attach with `tmux attach -t =maestro-maya`");
1088
+ // Home directories name a person and leak the seat's layout.
1089
+ assert.equal(sanitizeNoteDetail("blocked in /Users/maya/maya-ai/state"), "blocked in <path>");
1090
+ // A home directory with a space in it is ordinary on macOS, and its tail
1091
+ // carries the person's surname — the match may not stop at the whitespace.
1092
+ assert.equal(sanitizeNoteDetail("blocked in /Users/olivia chen/maya-ai/state/session"), "blocked in <path>");
1093
+ assert.equal(sanitizeNoteDetail("blocked in /Users/Olivia Chen/Library/Application Support/x"), "blocked in <path>");
1094
+ // …but ordinary prose after a path is not swallowed with it.
1095
+ assert.equal(sanitizeNoteDetail("blocked in /Users/maya and then retry"), "blocked in <path> and then retry");
1096
+ assert.equal(sanitizeNoteDetail("attach to /Users/maya/x and answer it"), "attach to <path> and answer it");
1097
+ assert.equal(sanitizeNoteDetail("blocked in ~/maya-ai and /home/maya/x and /private/var/folders/t/x"), "blocked in <path> and <path> and <path>");
1098
+ // Session ids and token-shaped runs.
1099
+ assert.equal(sanitizeNoteDetail("resume 3f2a1c94-7b0e-4a11-9c3d-0b7e2f8a6d51 failed"), "resume <redacted> failed");
1100
+ assert.equal(sanitizeNoteDetail("token sk-ant-0123456789abcdef rejected"), "token <redacted> rejected");
1101
+ // Newlines and control characters collapse; the note is one line.
1102
+ assert.equal(sanitizeNoteDetail("a\nb\tc\r\nd"), "a b c d");
1103
+ // The cap holds, with an ellipsis so a reader knows it was cut.
1104
+ const long = sanitizeNoteDetail("lorem ipsum ".repeat(50));
1105
+ assert.equal(long.length, 200);
1106
+ assert.ok(long.endsWith("…"));
1107
+ assert.ok(sanitizeNoteDetail("ab ".repeat(20), 10).length <= 10, "the cap is a ceiling, never exceeded (a word boundary can land under it)");
1108
+ // Nothing to say → "", and the caller then omits the field entirely.
1109
+ assert.equal(sanitizeNoteDetail(""), "");
1110
+ assert.equal(sanitizeNoteDetail(null), "");
1111
+ assert.equal(sanitizeNoteDetail(undefined), "");
1112
+ assert.equal(sanitizeNoteDetail(42), "");
1113
+ assert.equal(sanitizeNoteDetail(" \n "), "");
1114
+ // A hint long enough to overflow is truncated, not dropped.
1115
+ 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)}`);
1116
+ assert.ok(hint.length <= 200 && hint.endsWith("…"), "an over-long hint is truncated, not dropped");
1117
+ });
1118
+
1119
+ test("sessionJobInstalled: a readdir of ~/Library/LaunchAgents, and 'unknown' whenever it cannot be read", () => {
1120
+ const { sessionJobInstalled, sessionJobLabel } = _internals;
1121
+ const label = "ai.maestro.maya-session";
1122
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist", "ai.maestro.maya-session.plist"] }, label), true);
1123
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session"] }, label), true, "bare labels count too");
1124
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist"] }, label), false);
1125
+ assert.equal(sessionJobInstalled({ launchAgents: [] }, label), false);
1126
+ // Unknowns: no label, an unreadable home, a home that is not there.
1127
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, ""), null);
1128
+ assert.equal(sessionJobInstalled({ os: { homedir: () => "" } }, label), null);
1129
+ assert.equal(sessionJobInstalled({ os: { homedir: () => { throw new Error("nope"); } } }, label), null);
1130
+ assert.equal(sessionJobInstalled({ os: { homedir: () => join(tmpdir(), "no-such-home-xyz") } }, label), null);
1131
+ // An explicit override wins (the daemon may already know).
1132
+ assert.equal(sessionJobInstalled({ sessionJobInstalled: true, launchAgents: [] }, label), true);
1133
+ // A case difference is not an absence: agentFirstName leaves its directory
1134
+ // fallback as-is (`~/Maya-ai` -> `Maya`), and the volume is case-insensitive.
1135
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.Maya-session.plist"] }, "ai.maestro.maya-session"), true);
1136
+ assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, "ai.maestro.Maya-session"), true);
1137
+ // The label matches what generate-plists.sh and `maestro session` spell —
1138
+ // including its casing, which the generator does not lower-case either.
1139
+ assert.equal(sessionJobLabel("/x/maya-ai", "maya"), "ai.maestro.maya-session");
1140
+ assert.equal(sessionJobLabel("/x/Maya-ai", "Maya"), "ai.maestro.Maya-session");
1141
+ assert.equal(sessionJobLabel("", ""), "");
1142
+ });
1143
+
1144
+ test("collectStatus: machine.sessionNote appears ONLY when the front door is down, and rides machine (never session)", async () => {
1145
+ const root = mkdtempSync(join(tmpdir(), "session-note-"));
1146
+ try {
1147
+ const now = Date.parse("2026-09-11T12:00:00Z");
1148
+ const osImpl = fakeOs({});
1149
+ osImpl.hostname = () => "seat.local";
1150
+ const base = {
1151
+ agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0,
1152
+ powermetrics: false, disk: false, spend: false, agentFirst: "maya", launchAgents: [],
1153
+ };
1154
+ const sess = join(root, "state", "session");
1155
+ mkdirSync(sess, { recursive: true });
1156
+
1157
+ // 1. No job on the seat — the case A010/A016 could not tell apart.
1158
+ const absent = await collectStatus(base);
1159
+ assert.equal(absent.machine.sessionLive, false);
1160
+ assert.equal(absent.machine.sessionNote.reason, "job-absent");
1161
+ assert.match(absent.machine.sessionNote.detail, /ai\.maestro\.maya-session/);
1162
+ assert.equal(absent.session, null, "the note NEVER touches session — hq validates that object strictly");
1163
+ assert.ok(!("sessionNote" in (absent.session || {})));
1164
+
1165
+ // 2. Job installed, session up, waiting on a first-run dialog.
1166
+ const installed = { ...base, launchAgents: ["ai.maestro.maya-session.plist"] };
1167
+ writeFileSync(join(sess, "attention.json"), JSON.stringify(attention()));
1168
+ const waiting = await collectStatus(installed);
1169
+ assert.equal(waiting.machine.sessionNote.reason, "awaiting-input");
1170
+ assert.equal(waiting.machine.sessionNote.since, "2026-09-11T11:57:00.000Z");
1171
+ assert.match(waiting.machine.sessionNote.detail, /tmux attach -t =maestro-maya/);
1172
+
1173
+ // 3. The supervisor cleared attention.json and the beat then went stale.
1174
+ rmSync(join(sess, "attention.json"));
1175
+ writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:50:00Z" }));
1176
+ const stale = await collectStatus(installed);
1177
+ assert.equal(stale.machine.frontDoor, "session");
1178
+ assert.deepEqual(stale.machine.sessionNote, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1179
+
1180
+ // 4. A live front door carries NO note — the key is absent, not null.
1181
+ writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:59:50Z" }));
1182
+ const live = await collectStatus(installed);
1183
+ assert.equal(live.machine.sessionLive, true);
1184
+ assert.ok(!("sessionNote" in live.machine), "a healthy seat must not pay for a field it has nothing to say in");
1185
+
1186
+ // 5. Corrupt state files never break the beat — and a heartbeat file that
1187
+ // is THERE and will not parse is reported as such, not as "never beaten".
1188
+ writeFileSync(join(sess, "heartbeat.json"), "{not json");
1189
+ writeFileSync(join(sess, "attention.json"), "{not json");
1190
+ const corrupt = await collectStatus(installed);
1191
+ assert.equal(corrupt.machine.sessionLive, false);
1192
+ assert.deepEqual(corrupt.machine.sessionNote,
1193
+ { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1194
+ assert.equal(typeof corrupt.ts, "string", "the snapshot is intact");
1195
+
1196
+ // 5b. No heartbeat file at all, job installed → never-beaten.
1197
+ rmSync(join(sess, "heartbeat.json"));
1198
+ rmSync(join(sess, "attention.json"));
1199
+ const silent = await collectStatus(installed);
1200
+ assert.deepEqual(silent.machine.sessionNote,
1201
+ { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1202
+
1203
+ // 6. The whole note is a best-effort extra: a throwing os.homedir drops the
1204
+ // launchd read, never the beat — and an unknown job presence is reported
1205
+ // as neither an absence nor a presence.
1206
+ const hostile = await collectStatus({ ...base, launchAgents: undefined, os: { ...osImpl, homedir: () => { throw new Error("no home"); } } });
1207
+ assert.equal(hostile.machine.sessionNote.reason, "never-beaten", "unknown job presence is not an absence");
1208
+ assert.equal(hostile.machine.sessionNote.detail, "no heartbeat from the session job, and its presence on this seat could not be checked",
1209
+ "…and it is not reported as a presence either");
1210
+ assert.equal(hostile.machine.sessionLive, false);
1211
+
1212
+ // 7. The emitted shape is exactly {reason, since?, detail?} — nothing else,
1213
+ // and nothing unbounded.
1214
+ for (const s of [absent, waiting, stale, corrupt, silent, hostile]) {
1215
+ const note = s.machine.sessionNote;
1216
+ assert.equal(typeof note.reason, "string");
1217
+ for (const k of Object.keys(note)) assert.ok(["reason", "since", "detail"].includes(k), `sessionNote.${k} is not in the contract`);
1218
+ if (note.since !== undefined) assert.ok(Number.isFinite(Date.parse(note.since)), "since is ISO 8601");
1219
+ if (note.detail !== undefined) assert.ok(note.detail.length <= 200 && !/\/Users\//.test(note.detail));
1220
+ }
1221
+ } finally { rmSync(root, { recursive: true, force: true }); }
1222
+ });
1223
+
1224
+ /**
1225
+ * The front-door signal must not flicker while the session is running.
1226
+ *
1227
+ * This function and `lib/session/liveness.mjs#heartbeatLiveness` answer one
1228
+ * question — "is the main session alive" — and they used to disagree: the
1229
+ * runtime accepted a heartbeat up to `staleMs` either side of now, this one
1230
+ * demanded `age >= 0`. So a heartbeat written microseconds before the wall
1231
+ * clock stepped back read as future-dated and flipped the seat to DOWN, while
1232
+ * the session carried on. A039 did exactly that across consecutive beats on
1233
+ * 2026-09-10/11 with its session healthy and its clock within 30 s of the
1234
+ * server. Symmetric now, and pinned here because the asymmetry was invisible:
1235
+ * every test used a past-dated heartbeat.
1236
+ */
1237
+ test("sessionLiveness tolerates clock skew symmetrically, and reports a far-future beat as not live", () => {
1238
+ const now = Date.parse("2026-09-11T12:00:00Z");
1239
+ const at = (ms) => ({ ts: new Date(now - ms).toISOString() });
1240
+
1241
+ // Fresh, and a beat from the recent past: live, as before.
1242
+ assert.equal(sessionLiveness(at(0), now).sessionLive, true);
1243
+ assert.equal(sessionLiveness(at(30_000), now).sessionLive, true);
1244
+
1245
+ // THE REGRESSION: a beat dated slightly in the FUTURE is the clock moving,
1246
+ // not the session dying. Both of these used to return false.
1247
+ assert.equal(sessionLiveness(at(-1), now).sessionLive, true, "1 ms ahead is not a dead session");
1248
+ assert.equal(sessionLiveness(at(-30_000), now).sessionLive, true, "30 s ahead is ordinary clock motion");
1249
+
1250
+ // The window is symmetric and OPEN at both edges — the runtime's rule.
1251
+ assert.equal(sessionLiveness(at(SESSION_STALE_MS - 1), now).sessionLive, true);
1252
+ assert.equal(sessionLiveness(at(-(SESSION_STALE_MS - 1)), now).sessionLive, true);
1253
+ assert.equal(sessionLiveness(at(SESSION_STALE_MS), now).sessionLive, false, "too old");
1254
+ assert.equal(sessionLiveness(at(-SESSION_STALE_MS), now).sessionLive, false, "too far ahead to believe");
1255
+
1256
+ // The front door is still named in every case a heartbeat exists.
1257
+ assert.equal(sessionLiveness(at(10 * SESSION_STALE_MS), now).frontDoor, "session");
1258
+ // No heartbeat at all remains the daemon's lane.
1259
+ assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
1260
+ });
1261
+
1262
+ test("sessionLiveness agrees with the session runtime's own window", async () => {
1263
+ const { heartbeatLiveness, STALE_MS } = await import("../session/liveness.mjs");
1264
+ assert.equal(STALE_MS, SESSION_STALE_MS, "one window, two readers");
1265
+ const now = Date.parse("2026-09-11T12:00:00Z");
1266
+ for (const offset of [0, 1_000, -1_000, 45_000, -45_000, STALE_MS, -STALE_MS, STALE_MS + 1, -(STALE_MS + 1)]) {
1267
+ const hb = { ts: new Date(now - offset).toISOString(), pid: 0 };
1268
+ assert.equal(
1269
+ sessionLiveness(hb, now).sessionLive,
1270
+ heartbeatLiveness(hb, { now }).live,
1271
+ `the two readers disagree at offset ${offset} ms`
1272
+ );
1273
+ }
1274
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.13.0",
3
+ "version": "2.15.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": {