@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.
- package/docs/runbooks/fleet-rollout.md +30 -0
- package/lib/comms/send-gate.mjs +28 -4
- package/lib/daemon/reply-debt.mjs +105 -0
- package/lib/org/inbound/directedness.mjs +60 -0
- package/lib/org/inbound/facts.mjs +29 -4
- package/lib/org/inbound/project.mjs +38 -1
- package/lib/org/inbound/surfaces.mjs +68 -0
- package/lib/runtime/adapter.mjs +39 -4
- package/lib/session/first-run.mjs +213 -16
- package/lib/telemetry/alerts.mjs +159 -2
- package/lib/telemetry/collect.mjs +27 -1
- package/package.json +1 -1
- package/policies/ai-disclosure.yaml +47 -0
- package/scripts/daemon/agent-daemon.mjs +206 -13
- package/scripts/daemon/assurance.mjs +5 -0
- package/scripts/daemon/deliver.mjs +79 -11
- package/scripts/daemon/dispatcher.mjs +68 -26
- package/scripts/daemon/lib/session-router.mjs +21 -0
- package/scripts/daemon/responder.mjs +92 -17
- package/scripts/local-triggers/autoupdate.sh +56 -1
- package/scripts/session/supervisor.mjs +70 -13
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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:
|
|
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)
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/telemetry/alerts.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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."
|