@cohortapp/agent-sdk 2.18.7 → 2.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,16 +2,36 @@
2
2
  * lib/session/first-run.mjs — the dialogs an unattended session cannot answer.
3
3
  *
4
4
  * The supervisor launches an INTERACTIVE `claude` inside a multiplexer with
5
- * nobody attached. On a fresh seat that first launch shows two one-time
6
- * dialogs and waits for a keypress that never comes:
5
+ * nobody attached. That launch can stop on a one-time screen and wait for a
6
+ * keypress that never comes. Each such screen is recorded in `~/.claude.json`
7
+ * once it is answered, and the supervisor SEEDS those records before launch:
7
8
  *
8
9
  * - the bypass-permissions acknowledgement (`--dangerously-skip-permissions`),
9
- * recorded in `~/.claude.json` as `bypassPermissionsModeAccepted: true`;
10
+ * recorded as `bypassPermissionsModeAccepted: true`;
10
11
  * - the per-directory trust dialog, recorded as
11
- * `projects[<cwd>].hasTrustDialogAccepted: true`.
12
+ * `projects[<cwd>].hasTrustDialogAccepted: true`;
13
+ * - onboarding, recorded as `hasCompletedOnboarding: true` AND
14
+ * `lastOnboardingVersion: "<cli version>"`;
15
+ * - the release notes screen, recorded as `lastReleaseNotesSeen: "<cli version>"`.
12
16
  *
13
- * Neither is a secret and both are exactly what the operator answered when
14
- * they set the seat up by hand, so the supervisor SEEDS them before launch:
17
+ * ── WHY THE LAST TWO ARE NOT OPTIONAL: A SEEDED SEAT CAN GO BACK TO BLOCKED ──
18
+ * The first two are answered ONCE per seat and stay answered. The last two are
19
+ * VERSION-GATED: they are compared against the CLI version that is running, so
20
+ * an UPGRADE re-opens a screen a seeded seat had already passed. That is the
21
+ * difference between "the seat was set up wrong" and "the seat was fine until
22
+ * Tuesday", and it is the second that produced three seats which had been
23
+ * answering for weeks and then stopped inside an upgrade window.
24
+ *
25
+ * A seeder that hardcodes the screens it knows about is the autoupdater's
26
+ * lesson a second time: the check passed because it only ever asked the
27
+ * questions it already knew the answers to. {@link seedClaudeConfig} is
28
+ * therefore version-aware, and the supervisor's watchdog
29
+ * (`lib/session/pane#watchdogAction`) exists precisely because it CANNOT be
30
+ * complete — a screen this build has never heard of must still end in a running
31
+ * session, not in a three-day silence.
32
+ *
33
+ * Nothing here is a secret and every value is exactly what the operator
34
+ * answered when they set the seat up by hand:
15
35
  * {@link seedClaudeConfig} is the pure merge (adds only the missing flags,
16
36
  * preserves every other key, refuses to touch a malformed file), and
17
37
  * {@link ensureClaudeConfig} is its I/O shell (writes only when something
@@ -25,6 +45,22 @@
25
45
  * it and writes `state/session/attention.json` ({@link attentionRecord}) so
26
46
  * `doctor` can report it and an operator knows which command attaches.
27
47
  *
48
+ * ── AND THE ATTENTION RECORD IS NOT A FIX ────────────────────────────────────
49
+ * Writing that file used to be the whole of the response: the watchdog fired
50
+ * once, at the grace mark, wrote the record, logged a line telling a human to
51
+ * `tmux attach` — and then the supervisor sat in `waitForProbe` for as long as
52
+ * the blocked mux session existed. Days, on three seats. The record said
53
+ * "attach and answer it" to nobody, because the seats are unattended by
54
+ * design and there is no ssh into them.
55
+ *
56
+ * `lib/session/pane#watchdogAction` is the answer the seat can carry out
57
+ * itself: it reads the pane, answers a modal whose safe key is known, and
58
+ * otherwise RESTARTS — a relaunch re-runs {@link ensureClaudeConfig} against
59
+ * the version now installed and so has a real chance of clearing a
60
+ * version-gated screen. The restarts are BOUNDED, and {@link carriedRestarts}
61
+ * is what makes the bound bind: each restart ENDS this process, so a counter
62
+ * held in memory would be zero again on every relaunch.
63
+ *
28
64
  * Pure except the one I/O shell; no ambient clock, fs or home.
29
65
  *
30
66
  * @module lib/session/first-run
@@ -38,12 +74,58 @@ import { join } from "node:path";
38
74
  export const CLAUDE_CONFIG_FILE = ".claude.json";
39
75
  /** A session alive this long without a beat is flagged for attention. */
40
76
  export const HEARTBEAT_GRACE_MS = 120_000;
77
+ /**
78
+ * The `~/.claude.json` keys the CLI compares against the version it is running.
79
+ * A seat that has answered these once is asked again after an upgrade, which
80
+ * is why they are advanced on every launch rather than seeded once.
81
+ */
82
+ export const VERSION_GATED_KEYS = Object.freeze(["lastOnboardingVersion", "lastReleaseNotesSeen"]);
83
+
84
+ /**
85
+ * Pure: compare two dotted version strings. Anything unparseable is `null` —
86
+ * "cannot tell", which every caller treats as "do not touch the value".
87
+ *
88
+ * @param {unknown} a
89
+ * @param {unknown} b
90
+ * @returns {number|null} <0 if a<b, 0 if equal, >0 if a>b, null if either is unreadable
91
+ */
92
+ export function compareVersions(a, b) {
93
+ const parse = (v) => {
94
+ if (typeof v !== "string") return null;
95
+ const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
96
+ if (!m) return null;
97
+ return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
98
+ };
99
+ const x = parse(a);
100
+ const y = parse(b);
101
+ if (!x || !y) return null;
102
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] - y[i];
103
+ return 0;
104
+ }
41
105
 
42
106
  /**
43
- * Pure: merge the two acknowledgements into a `~/.claude.json` body.
107
+ * Pure: merge every one-time acknowledgement an unattended launch cannot give
108
+ * into a `~/.claude.json` body.
109
+ *
110
+ * TWO CLASSES OF KEY, and the difference is the whole point:
111
+ *
112
+ * - ONCE-EVER (`bypassPermissionsModeAccepted`, `hasCompletedOnboarding`,
113
+ * `projects[root].hasTrustDialogAccepted`) — written when absent, never
114
+ * rewritten. A seat passes these on its first launch and keeps them.
115
+ * - VERSION-GATED (`lastOnboardingVersion`, `lastReleaseNotesSeen`) — the CLI
116
+ * compares them against the version it is running, so they go STALE on
117
+ * upgrade and the screen comes back. These are advanced to `cliVersion`,
118
+ * and only forwards: a recorded version AHEAD of the installed one is left
119
+ * alone, because winding it back would re-open the very screen this exists
120
+ * to close.
121
+ *
122
+ * `cliVersion` is a PARAMETER. Without it the version-gated keys are not
123
+ * touched at all — a caller that cannot read the installed version must not
124
+ * guess one, and writing an invented version is strictly worse than leaving
125
+ * the screen to the watchdog.
44
126
  *
45
127
  * @param {string|null|undefined} text current file body (absent → empty)
46
- * @param {{agentRoot:string, bypass?:boolean}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions)
128
+ * @param {{agentRoot:string, bypass?:boolean, cliVersion?:string|null}} a `bypass` — seed the bypass-permissions flag (only needed when launching with --dangerously-skip-permissions); `cliVersion` — the installed Claude Code version, for the version-gated screens
47
129
  * @returns {{ok:true, changed:boolean, applied:string[], config:object}|{ok:false, changed:false, applied:[], error:string}}
48
130
  */
49
131
  export function seedClaudeConfig(text, a) {
@@ -58,6 +140,26 @@ export function seedClaudeConfig(text, a) {
58
140
  next.bypassPermissionsModeAccepted = true;
59
141
  applied.push("bypassPermissionsModeAccepted");
60
142
  }
143
+ if (next.hasCompletedOnboarding !== true) {
144
+ next.hasCompletedOnboarding = true;
145
+ applied.push("hasCompletedOnboarding");
146
+ }
147
+ // The version-gated pair. `compareVersions` returning null means one side is
148
+ // unreadable — treated as "leave it", never as "overwrite".
149
+ const cliVersion = typeof a.cliVersion === "string" && a.cliVersion.trim() ? a.cliVersion.trim() : null;
150
+ if (cliVersion) {
151
+ for (const key of VERSION_GATED_KEYS) {
152
+ const cur = next[key];
153
+ const cmp = compareVersions(cur, cliVersion);
154
+ // Absent (cmp === null with no current value) → seed it. Behind → advance
155
+ // it. Equal, ahead, or unreadable-but-present → leave it alone.
156
+ const absent = cur === undefined || cur === null || cur === "";
157
+ if (absent || (cmp !== null && cmp < 0)) {
158
+ next[key] = cliVersion;
159
+ applied.push(key);
160
+ }
161
+ }
162
+ }
61
163
  const projects = next.projects && typeof next.projects === "object" && !Array.isArray(next.projects) ? next.projects : {};
62
164
  const proj = projects[a.agentRoot] && typeof projects[a.agentRoot] === "object" && !Array.isArray(projects[a.agentRoot]) ? projects[a.agentRoot] : {};
63
165
  if (proj.hasTrustDialogAccepted !== true) {
@@ -72,7 +174,7 @@ export function seedClaudeConfig(text, a) {
72
174
  * Result frame; never throws (a seat must still launch when this fails —
73
175
  * the dialog then blocks and {@link heartbeatSilence} reports it).
74
176
  *
75
- * @param {{homeDir:string, agentRoot:string, bypass?:boolean, readFileSync:Function, writeFileAtomic:Function}} a
177
+ * @param {{homeDir:string, agentRoot:string, bypass?:boolean, cliVersion?:string|null, readFileSync:Function, writeFileAtomic:Function}} a
76
178
  * @returns {{ok:boolean, changed:boolean, applied:string[], path:string|null, error?:string}}
77
179
  */
78
180
  export function ensureClaudeConfig(a) {
@@ -83,7 +185,7 @@ export function ensureClaudeConfig(a) {
83
185
  if (!err || err.code !== "ENOENT") return { ok: false, changed: false, applied: [], path, error: err && err.message ? err.message : String(err) };
84
186
  text = null; // first launch on this account
85
187
  }
86
- const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass });
188
+ const seeded = seedClaudeConfig(text, { agentRoot: a.agentRoot, bypass: a.bypass, cliVersion: a.cliVersion });
87
189
  if (!seeded.ok) return { ok: false, changed: false, applied: [], path, error: seeded.error };
88
190
  if (!seeded.changed) return { ok: true, changed: false, applied: [], path };
89
191
  try {
@@ -95,17 +197,45 @@ export function ensureClaudeConfig(a) {
95
197
  }
96
198
 
97
199
  /**
98
- * Pure: has the session run past the grace period without beating?
200
+ * Pure: is the front door silent right NOW?
201
+ *
202
+ * ── THE BUG THIS SHAPE EXISTS TO NOT HAVE ─────────────────────────────────
203
+ * This used to ask `ts >= startedAt` — "has it beaten since this launch?" — and
204
+ * answer `{silent:false, reason:"beating"}` for any beat at all, however old. A
205
+ * session that beat once thirty seconds after launch and then died was
206
+ * therefore "beating" for the rest of its life. Running the real module on
207
+ * Hannah's measured seat made the size of that plain:
208
+ * `{startedAt:T0, heartbeat:{ts:T0+30s}, now:T0+65h}` returned
209
+ * `{"action":"wait","reason":"beating"}` — 65 hours of silence read as health,
210
+ * no restart ever issued, no attention record ever written. That is the
211
+ * `heartbeat-stale` shape the fleet surface was reporting the whole time, and
212
+ * it is why making the watchdog RUN more often changed nothing: the predicate
213
+ * it ran never flipped.
214
+ *
215
+ * So the beat is compared to `now`, not to `startedAt`. `startedAt` still has
216
+ * one job — telling a beat from a PREVIOUS run (`stale-heartbeat`) apart from
217
+ * one this run produced and then stopped (`beat-stopped`) — because those two
218
+ * are fixed differently: the first means the session never got going, the
219
+ * second means it got going and wedged.
220
+ *
99
221
  * @param {{startedAt:number, heartbeat:{ts:string}|null, now:number, graceMs?:number}} a
100
- * @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat", runMs:number}}
222
+ * @returns {{silent:boolean, reason:"within-grace"|"beating"|"no-heartbeat"|"stale-heartbeat"|"beat-stopped", runMs:number, sinceBeatMs?:number}}
101
223
  */
102
224
  export function heartbeatSilence(a) {
103
225
  const graceMs = a.graceMs ?? HEARTBEAT_GRACE_MS;
104
226
  const runMs = Number(a.now) - Number(a.startedAt);
105
227
  if (runMs < graceMs) return { silent: false, reason: "within-grace", runMs };
106
228
  const ts = a.heartbeat && typeof a.heartbeat.ts === "string" ? Date.parse(a.heartbeat.ts) : NaN;
107
- if (Number.isFinite(ts) && ts >= Number(a.startedAt)) return { silent: false, reason: "beating", runMs };
108
- return { silent: true, reason: Number.isFinite(ts) ? "stale-heartbeat" : "no-heartbeat", runMs };
229
+ if (!Number.isFinite(ts)) return { silent: true, reason: "no-heartbeat", runMs };
230
+ // A beat stamped BEFORE this launch belongs to a previous run: this session
231
+ // has never reported in, which is the same fault as no file at all.
232
+ if (ts < Number(a.startedAt)) return { silent: true, reason: "stale-heartbeat", runMs };
233
+ const sinceBeatMs = Number(a.now) - ts;
234
+ // A beat from the FUTURE is clock skew, not liveness. Treated as fresh (the
235
+ // bias is towards not restarting a session on the strength of a bad clock),
236
+ // which `Math.max` below makes explicit rather than accidental.
237
+ if (Math.max(0, sinceBeatMs) > graceMs) return { silent: true, reason: "beat-stopped", runMs, sinceBeatMs };
238
+ return { silent: false, reason: "beating", runMs, sinceBeatMs };
109
239
  }
110
240
 
111
241
  /**
@@ -114,13 +244,80 @@ export function heartbeatSilence(a) {
114
244
  */
115
245
  export function attentionRecord(a) {
116
246
  const attach = a.mux && a.mux.kind === "tmux" ? `tmux attach -t =${a.muxName}` : `screen -r ${a.muxName}`;
247
+ const restarts = Number.isFinite(Number(a.restarts)) && Number(a.restarts) > 0 ? Math.floor(Number(a.restarts)) : 0;
248
+ const action = a.action === "restart" || a.action === "give-up" || a.action === "clear" ? a.action : "wait";
249
+ const secs = Math.round(a.runMs / 1000);
250
+ // THE HINT IS READ BY SOMEBODY WHO CANNOT REACH THE MACHINE. It rides the
251
+ // presence beat to hq (`machine.sessionNote.detail`) and is the only sentence
252
+ // an operator in a browser gets. "Attach and answer it" was the entire text
253
+ // for three days on a seat nobody could ssh into; what it owes the reader now
254
+ // is what the seat has ALREADY tried, so the reader knows whether a restart
255
+ // is still coming or whether this is where it stops.
256
+ const tried = restarts > 0 ? ` Restarted ${restarts}× already and it still has not reported in.` : "";
257
+ const next = action === "restart"
258
+ ? " Restarting it now."
259
+ : action === "give-up"
260
+ ? " Out of automatic restarts — this needs a person at the machine."
261
+ : action === "clear"
262
+ ? " Answering the screen now."
263
+ : "";
117
264
  return {
118
265
  reason: a.reason,
119
266
  since: new Date(Number(a.since)).toISOString(),
120
267
  runMs: a.runMs,
268
+ restarts,
269
+ action,
121
270
  attach,
122
- hint: `The session has been up ${Math.round(a.runMs / 1000)} s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`${attach}\` and answer it.`,
271
+ // WHAT THE SCREEN ACTUALLY SAYS IS NOT GUESSED HERE. The old text asserted
272
+ // "probably waiting on a first-run dialog or a permission prompt" and was
273
+ // wrong on every seat it was read off: the real modal was the
274
+ // subscription-limit chooser, and the trust dialog it blamed is
275
+ // pre-answered at launch by `ensureClaudeConfig`. The diagnosis belongs to
276
+ // the watchdog, which reads the pane and writes `modal`/`modalWhy` beside
277
+ // this record; the hint states only what this function can prove.
278
+ hint: `The session has been up ${secs} s without a heartbeat (${a.reason}).${tried}${next} Attach with \`${attach}\` and answer it.`,
123
279
  };
124
280
  }
125
281
 
126
- export default { CLAUDE_CONFIG_FILE, HEARTBEAT_GRACE_MS, seedClaudeConfig, ensureClaudeConfig, heartbeatSilence, attentionRecord };
282
+ /**
283
+ * Pure: how many silent restarts this launch inherits.
284
+ *
285
+ * The counter has to survive the restart it authorises — each restart ENDS the
286
+ * supervisor process and launchd starts a new one, so a count held in memory
287
+ * would reset to zero every time and the bound in `pane#watchdogAction` would
288
+ * never bind. It rides `state/session/attention.json`, the record the previous
289
+ * run already wrote for exactly this session.
290
+ *
291
+ * It resets on EVIDENCE OF WORK and on nothing else: a heartbeat stamped after
292
+ * the attention record means the session that record describes went on to run,
293
+ * so the next blockage is a new fault and gets its own budget. An attention
294
+ * record with no usable `since` cannot be ordered against a beat, and a real
295
+ * beat is the harder evidence, so that also resets — the bias is towards
296
+ * trying again, never towards giving up early on a seat that recovered once.
297
+ *
298
+ * @param {{attention:object|null, heartbeat:{ts?:string}|null}} a
299
+ * @returns {number}
300
+ */
301
+ export function carriedRestarts(a = {}) {
302
+ const att = a.attention && typeof a.attention === "object" && !Array.isArray(a.attention) ? a.attention : null;
303
+ if (!att) return 0;
304
+ const n = Number(att.restarts);
305
+ const carried = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
306
+ if (carried === 0) return 0;
307
+ const attMs = Date.parse(typeof att.since === "string" ? att.since : "");
308
+ const hbMs = Date.parse(a.heartbeat && typeof a.heartbeat.ts === "string" ? a.heartbeat.ts : "");
309
+ if (Number.isFinite(hbMs) && (!Number.isFinite(attMs) || hbMs > attMs)) return 0;
310
+ return carried;
311
+ }
312
+
313
+ export default {
314
+ CLAUDE_CONFIG_FILE,
315
+ HEARTBEAT_GRACE_MS,
316
+ VERSION_GATED_KEYS,
317
+ compareVersions,
318
+ seedClaudeConfig,
319
+ ensureClaudeConfig,
320
+ heartbeatSilence,
321
+ carriedRestarts,
322
+ attentionRecord,
323
+ };
@@ -17,6 +17,8 @@
17
17
  * - machine.memUsedPct high → WARNING/CRITICAL (memory pressure).
18
18
  * - machine.diskUsedPct high → WARNING/CRITICAL (disk pressure).
19
19
  * - subAgentsRunning over the cap → INFO (more children than expected).
20
+ * - machine.sessionNote present → WARNING/CRITICAL (frontdoor) — the
21
+ * seat's front door is not answering.
20
22
  *
21
23
  * Output: a deterministic array `[{ id, severity, kind, detail }, ...]` (the
22
24
  * snapshot's alert shape) sorted critical→warning→info then by id, so identical
@@ -56,6 +58,46 @@ export const DEFAULT_THRESHOLDS = Object.freeze({
56
58
  subAgents: Object.freeze({
57
59
  infoCap: 10, // more live claude children than this is worth surfacing
58
60
  }),
61
+ /**
62
+ * How long the seat's FRONT DOOR may be unable to answer before the fleet is
63
+ * told. The daemon lane keeps running underneath, so nothing here is about
64
+ * the machine being down — it is about the seat being unable to hold a
65
+ * conversation.
66
+ *
67
+ * ── WHY THIS RULE EXISTS AT ALL ──────────────────────────────────────────
68
+ * Every other rule in this file is about the MACHINE: how hot it is, how
69
+ * full its disk is, how much memory it has left, whether it can still
70
+ * authenticate. In the week three seats had their front door blocked — for
71
+ * 3, 15 and 2.7 days — every alert the fleet opened was one of those, and
72
+ * not one was about a seat nobody could reach. THE SHAPE IS THE POINT AND NO
73
+ * TALLY IS GIVEN: a comment that states a count goes stale the next time the
74
+ * fleet opens an alert, and this one already had — it read "twelve alerts,
75
+ * five disk and seven thermal" while the loudest seat in the fleet was
76
+ * carrying eleven open critical memory rows. A conversational seat that
77
+ * cannot converse is at least as broken as a warm one.
78
+ *
79
+ * ── WHY THE WINDOWS ARE THESE ────────────────────────────────────────────
80
+ * `warnMs` 15 min: the supervisor's own watchdog is bounded (2 modal clears,
81
+ * 3 restarts, one pass per 2-minute grace window), so anything past a
82
+ * quarter of an hour has already outlived every fix the seat can apply to
83
+ * itself. Below that the seat is still trying and an alert would be noise.
84
+ *
85
+ * `critMs` 2 h: past this the silence has spanned a working block. A message
86
+ * sent at the start of it has gone unanswered long enough that the sender
87
+ * has drawn a conclusion about the colleague, which is the actual damage.
88
+ */
89
+ frontDoor: Object.freeze({
90
+ warnMs: 15 * 60 * 1000,
91
+ critMs: 2 * 60 * 60 * 1000,
92
+ }),
93
+ replyDebt: Object.freeze({
94
+ // ONE withheld reply is already worth saying. This is not a resource gauge
95
+ // where a low reading is normal noise — it counts people who asked this
96
+ // seat something and got nothing. The threshold exists only so the severity
97
+ // can climb, not so the first few can be ignored.
98
+ warnCount: 1,
99
+ critCount: 5,
100
+ }),
59
101
  });
60
102
 
61
103
  const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
@@ -109,11 +151,16 @@ function round1(n) { return Math.round(Number(n) * 10) / 10; }
109
151
  /**
110
152
  * Derive the health alerts for a status snapshot.
111
153
  *
154
+ * `now` is a PARAMETER (defaulted, doctrine §2) because one rule — the front
155
+ * door — is about a DURATION, and a rule that reads the clock itself cannot be
156
+ * tested across the boundary it exists to draw.
157
+ *
112
158
  * @param {object} status the SHARED STATUS SNAPSHOT (collect.collectStatus)
113
159
  * @param {object} [thresholds] operator override merged over DEFAULT_THRESHOLDS
160
+ * @param {number} [now] epoch ms
114
161
  * @returns {Array<{id:string, severity:"info"|"warning"|"critical", kind:string, detail:string}>}
115
162
  */
116
- export function deriveAlerts(status, thresholds) {
163
+ export function deriveAlerts(status, thresholds, now = Date.now()) {
117
164
  const alerts = [];
118
165
  try {
119
166
  const s = status && typeof status === "object" ? status : {};
@@ -125,6 +172,9 @@ export function deriveAlerts(status, thresholds) {
125
172
  push(alerts, memRule(machine, t.mem));
126
173
  push(alerts, diskRule(machine, t.disk));
127
174
  push(alerts, subAgentsRule(s, t.subAgents));
175
+ push(alerts, frontDoorRule(machine, t.frontDoor, now));
176
+ push(alerts, scopeFaultRule(machine));
177
+ push(alerts, repliesWithheldRule(machine, t.replyDebt));
128
178
 
129
179
  alerts.sort((a, b) => {
130
180
  const r = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
@@ -187,6 +237,51 @@ function diskRule(machine, cfg = {}) {
187
237
  };
188
238
  }
189
239
 
240
+ /**
241
+ * The seat is missing a scope, which nothing on this machine can grant.
242
+ *
243
+ * Critical, and deliberately louder than a hot CPU: twelve alerts opened on
244
+ * this fleet in a week were all disk and thermal, and none for a seat that
245
+ * could not answer anybody. A thermal warning resolves itself; a scope fault
246
+ * waits for a person forever.
247
+ */
248
+ function scopeFaultRule(machine) {
249
+ const d = machine && machine.replyDebt;
250
+ const n = num(d && d.scopeRefused);
251
+ if (!(n > 0)) return null;
252
+ return {
253
+ id: "seat_missing_scope",
254
+ severity: "critical",
255
+ kind: "scope",
256
+ detail: `${n} ${n === 1 ? "reply was" : "replies were"} refused today for a scope this seat does not hold — a person asked and got nothing. Grant the scope; the seat cannot.`,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * This seat withheld N replies today — the number the whole reply-debt spine
262
+ * exists to make sayable. Scope faults have their own critical rule above, so
263
+ * this one carries the rest (an unparseable CLI envelope, a session-id
264
+ * collision the single retry did not recover).
265
+ */
266
+ function repliesWithheldRule(machine, cfg = {}) {
267
+ const d = machine && machine.replyDebt;
268
+ if (!d) return null;
269
+ const v = num(d.withheld) - num(d.scopeRefused);
270
+ if (!(v > 0)) return null;
271
+ const sev = severityFor(v, num(cfg.warnCount, Infinity), num(cfg.critCount, Infinity));
272
+ if (!sev) return null;
273
+ const parts = [];
274
+ if (num(d.unparseable) > 0) parts.push(`${num(d.unparseable)} unreadable CLI envelope`);
275
+ const lost = Math.max(0, num(d.sessionCollisions) - num(d.recovered));
276
+ if (lost > 0) parts.push(`${lost} session-id collision`);
277
+ return {
278
+ id: "replies_withheld",
279
+ severity: sev,
280
+ kind: "reply",
281
+ detail: `${v} ${v === 1 ? "reply" : "replies"} withheld today${parts.length ? ` (${parts.join(", ")})` : ""} — someone asked and got silence.`,
282
+ };
283
+ }
284
+
190
285
  function subAgentsRule(status, cfg = {}) {
191
286
  const v = num(status.subAgentsRunning);
192
287
  const cap = num(cfg.infoCap, Infinity);
@@ -199,6 +294,68 @@ function subAgentsRule(status, cfg = {}) {
199
294
  };
200
295
  }
201
296
 
297
+ /**
298
+ * The front door is not answering, and has not been for long enough that the
299
+ * seat's own recovery has already been tried and failed.
300
+ *
301
+ * READS `machine.sessionNote` — the exception field `collect.sessionNote()`
302
+ * emits, which is present ONLY when the front door is not live and carries the
303
+ * REASON (`awaiting-input`, `heartbeat-stale`, `never-beaten`, `job-absent`,
304
+ * …). The note is the seat's own diagnosis and it is passed through verbatim in
305
+ * the detail, because the reason is exactly what tells an operator whether this
306
+ * is a dialog nobody answered, a supervisor that died, or a seat deliberately
307
+ * configured without a session job.
308
+ *
309
+ * `job-absent` is the one reason that raises NOTHING. A seat whose front door
310
+ * is the daemon by configuration is not broken, and alerting on it would train
311
+ * the fleet to ignore this rule — which is how the disk and thermal alerts got
312
+ * their current signal-to-noise and this rule must not spend it.
313
+ *
314
+ * A note with no usable `since` cannot be aged, so it cannot clear the warn
315
+ * window and raises nothing. That is deliberate: an alert is a claim about
316
+ * duration, and an undatable note does not support one. The note still rides
317
+ * the beat and the fleet surface still shows it.
318
+ *
319
+ * ── THE DETAIL NAMES AN INSTANT, NEVER A DURATION ────────────────────────────
320
+ * hq opens one AgentAlert row per DISTINCT `severity\0kind\0detail`
321
+ * (`alertClientKey`, src/server/methods/presence/beat.ts). A detail carrying a
322
+ * ticking age — "has not answered for 15m… 16m… 17m…" — is therefore a NEW row
323
+ * on every beat: running this rule against a 30-second beat cadence for eight
324
+ * hours on one seat produced 83 distinct rows, and a `critical` row does not
325
+ * self-clear. That is exactly how a seat in this fleet accumulated eleven open
326
+ * critical `memory` rows whose details differed only in the percentage. So the
327
+ * detail anchors on `note.since`, which is FIXED for as long as one blockage
328
+ * lasts, and the reader does the subtraction. At most two rows per episode —
329
+ * the warn one, then the critical one — and the warn row clears when the door
330
+ * opens.
331
+ *
332
+ * For the same reason `note.detail` is NOT appended: it carries the seat's own
333
+ * ticking text ("no beat for 431 s"), and pasting it here would put the
334
+ * counter straight back into the key. It rides the beat as
335
+ * `machine.sessionNote.detail` and the fleet surface shows it there.
336
+ */
337
+ function frontDoorRule(machine, cfg = {}, now = Date.now()) {
338
+ const note = machine && typeof machine.sessionNote === "object" && machine.sessionNote !== null && !Array.isArray(machine.sessionNote)
339
+ ? machine.sessionNote
340
+ : null;
341
+ if (!note) return null;
342
+ const reason = typeof note.reason === "string" ? note.reason.trim() : "";
343
+ if (!reason || reason === "job-absent") return null;
344
+ const since = Date.parse(typeof note.since === "string" ? note.since : "");
345
+ if (!Number.isFinite(since)) return null;
346
+ const ageMs = num(now) - since;
347
+ if (!(ageMs > 0)) return null; // a future stamp is skew, not a duration
348
+ const sev = severityFor(ageMs, num(cfg.warnMs, Infinity), num(cfg.critMs, Infinity));
349
+ if (!sev) return null;
350
+ return {
351
+ id: "front_door_blocked",
352
+ severity: sev,
353
+ kind: "frontdoor",
354
+ // An INSTANT, never a duration — see `alertClientKey` above.
355
+ detail: `the seat's front door has not answered since ${new Date(since).toISOString()} (${reason}) — messages to this colleague are going unanswered.`,
356
+ };
357
+ }
358
+
202
359
  /**
203
360
  * Does this alert list contain a critical alert? Convenience for the mesh's
204
361
  * local-escalation decision. Never throws.
@@ -211,6 +368,6 @@ export function hasCritical(alerts) {
211
368
  }
212
369
  }
213
370
 
214
- export const _internals = { severityFor, reloginRule, tempRule, memRule, diskRule, subAgentsRule };
371
+ export const _internals = { severityFor, reloginRule, tempRule, memRule, diskRule, subAgentsRule, frontDoorRule };
215
372
 
216
373
  export default { deriveAlerts, hasCritical, mergeThresholds, DEFAULT_THRESHOLDS };
@@ -140,6 +140,8 @@ import { list as listPresence } from "../collective/presence.mjs";
140
140
  import { billableUsd } from "../cost/ledger-row.mjs";
141
141
  import { liveClaudeStats } from "../resource-governor.mjs";
142
142
  import { agentFirstName } from "../session/identity.mjs";
143
+ import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
144
+ import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
143
145
 
144
146
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
145
147
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1226,6 +1228,7 @@ function instantMs(v) {
1226
1228
  const ATTENTION_WHY = {
1227
1229
  "no-heartbeat": "up, never beaten",
1228
1230
  "stale-heartbeat": "no beat since this launch",
1231
+ "beat-stopped": "beat, then stopped",
1229
1232
  };
1230
1233
 
1231
1234
  /**
@@ -1938,6 +1941,24 @@ export async function collectStatus(o = {}) {
1938
1941
  if (up) machine.upgrade = up;
1939
1942
  } catch { /* no upgrade field */ }
1940
1943
 
1944
+ // 4c-ii. REPLIES THIS SEAT OWED AND DID NOT SEND — `machine.replyDebt`,
1945
+ // absent on a seat that owes nothing today. Three measured paths end the
1946
+ // reply path in nothing (a session-id collision, a scope the seat does not
1947
+ // hold, a CLI stdout that will not parse as the envelope), and all three left
1948
+ // their only trace in a log file and a JSON on this disk. A silence nobody
1949
+ // off the box can count is indistinguishable from a quiet day, which is how
1950
+ // a seat can stop answering people for three days without anything saying so.
1951
+ //
1952
+ // It rides `machine` for the same reason `frontDoor` does: hq's presence.beat
1953
+ // validator keeps `session` strict and `machine` open, so a new seat-level
1954
+ // fact lands without a lock-step hq deploy. Fail-open like every probe here —
1955
+ // an unreadable counters stream drops the field, never the beat.
1956
+ try {
1957
+ const totals = opt.counterTotals !== undefined ? opt.counterTotals : countersSnapshot({ agentRoot: opt.agentRoot });
1958
+ const debt = replyDebtFromCounters(totals || {}, { date: new Date(nowMs).toISOString().slice(0, 10) });
1959
+ if (debt) machine.replyDebt = debt;
1960
+ } catch { /* no replyDebt field */ }
1961
+
1941
1962
  // 4d. WHO is beating — `machine.daemon` {pid, bootAt, uptimeS, sdkVersion,
1942
1963
  // dashboardAt, healthy?, healthReason?}. This is what makes fleet version
1943
1964
  // drift a QUERY instead of an ssh tour: `machine.sdkVersion` is what is
@@ -1968,7 +1989,12 @@ export async function collectStatus(o = {}) {
1968
1989
  if (opt.withAlerts !== false) {
1969
1990
  try {
1970
1991
  const mod = await import("./alerts.mjs");
1971
- status.alerts = mod.deriveAlerts(status, opt.thresholds) || [];
1992
+ // `nowMs` is handed over, not left to the default: one rule (the front
1993
+ // door) is a DURATION measured against `machine.sessionNote.since`, and
1994
+ // it must be measured against the same instant the rest of this snapshot
1995
+ // was, or a slow collection could date the silence differently from the
1996
+ // note that describes it.
1997
+ status.alerts = mod.deriveAlerts(status, opt.thresholds, nowMs) || [];
1972
1998
  } catch { status.alerts = []; }
1973
1999
  }
1974
2000
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.7",
3
+ "version": "2.18.10",
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": {
@@ -74,6 +74,27 @@ posture:
74
74
  deployment's own policy requires unprompted disclosure.
75
75
  proactive_disclosure: true
76
76
  machine_readable_marking: true
77
+ # WHO THE PROACTIVE DUTY IS OWED TO. Art. 50(1) addresses informing a
78
+ # person that they are interacting with an AI system. A colleague inside
79
+ # the deploying organisation — who provisioned the agent, sees an AI badge
80
+ # beside its name in every surface, and is three hundred messages into a
81
+ # working thread with it — is not that person.
82
+ #
83
+ # Unscoped, this duty made every agent open every post with "Quick note
84
+ # before we get into it: I'm <name>, an AI assistant working with
85
+ # <principal>", including mid-thread corrections to their own numbers. That
86
+ # is not a transparency control; it reads as the agent disclaiming its own
87
+ # work, and it tells people something they already know in a way that makes
88
+ # them feel worse about the colleague they are talking to.
89
+ #
90
+ # The email footer learned this and was scoped (channels.email.footer_scope,
91
+ # first_contact_only + external_recipients_only); the identity line never
92
+ # was. It is the same duty and it takes the same scope.
93
+ #
94
+ # The truthfulness invariant is NOT scoped by this and never will be: a
95
+ # sincere question about the agent's nature is answered plainly on any
96
+ # message, internal or external, first or thousandth.
97
+ internal_recipients_exempt: true
77
98
 
78
99
  # Do not volunteer, but never conceal and always answer truthfully (invariant).
79
100
  disclose-on-ask:
@@ -133,10 +154,36 @@ governance:
133
154
  # own voice (policies/communication-style.md governs tone — prose, no filler).
134
155
  # ───────────────────────────────────────────────────────────────────────────
135
156
  channels:
157
+ # The organisation's own workspace: channels, DMs, threads, boards. Every
158
+ # member here provisioned these agents or works beside them daily, and the
159
+ # product marks them structurally — an AI badge on the member row, the
160
+ # directory, the mailbox list and the composer. That marking is always on and
161
+ # needs no sentence from the agent.
162
+ cohort:
163
+ identity_line: >
164
+ {agent_name} here — I'm an AI assistant working with {principal_name}.
165
+ marking:
166
+ kind: product_badge
167
+ detail: >
168
+ Cohort labels every AI member in the UI (member rows, directory,
169
+ mailbox list, message headers). The marking is structural and
170
+ unconditional; the identity line is not.
171
+ identity_scope:
172
+ first_contact_only: true
173
+ external_recipients_only: true
174
+ notes: >
175
+ In practice this means the line is effectively never used inside the
176
+ workspace, which is correct: it is for someone meeting the agent for the
177
+ first time from outside, and Cohort has no such surface today. If one is
178
+ added, the line is here and already scoped.
179
+
136
180
  slack:
137
181
  identity_line: >
138
182
  Quick note before we get into it — I'm {agent_name}, an AI assistant
139
183
  working with {principal_name}.
184
+ identity_scope:
185
+ first_contact_only: true
186
+ external_recipients_only: true
140
187
  marking:
141
188
  kind: profile_metadata # bot/app badge + display-name suffix
142
189
  detail: "Display name carries an AI marker; profile states the principal."