@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
|
-
/**
|
|
1057
|
-
|
|
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", "
|
|
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
|
-
|
|
1362
|
-
|
|
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.
|
|
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": {
|