@cohortapp/agent-sdk 2.5.1 → 2.6.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.
- package/bin/maestro.mjs +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
|
@@ -14,11 +14,21 @@
|
|
|
14
14
|
* subAgentsRunning: number, // running claude child processes (best-effort)
|
|
15
15
|
* session: { id?, taskClass?, model? } | null,
|
|
16
16
|
* machine: {
|
|
17
|
+
* // MOMENTARY — true at `ts`, expires with the snapshot
|
|
17
18
|
* loadavg: [n,n,n],
|
|
18
19
|
* memUsedPct: number, // 1 - free/total
|
|
19
|
-
* cpuPct?: number, //
|
|
20
|
-
*
|
|
20
|
+
* cpuPct?: number, // powermetrics if root, else loadavg/cores
|
|
21
|
+
* cpuSource?: "powermetrics"|"sudo-powermetrics"|"loadavg",
|
|
22
|
+
* tempC?: number, // best-effort (powermetrics; needs root)
|
|
23
|
+
* tempSource?: "powermetrics"|"sudo-powermetrics",
|
|
24
|
+
* tempDetail?: string, // why there is no tempC (never silent)
|
|
21
25
|
* diskUsedPct?: number,
|
|
26
|
+
* uptimeDays?: number,
|
|
27
|
+
* spend24h?: number, // rolling 24h billable USD off the cost ledger
|
|
28
|
+
* // INVENTORY — describes the machine; does not perish with the snapshot
|
|
29
|
+
* device?: string, // "Mac mini M4 Pro"
|
|
30
|
+
* ram?: string, // "24 GB"
|
|
31
|
+
* cores?: number,
|
|
22
32
|
* },
|
|
23
33
|
* claudeAuth: "ok"|"relogin_required"|"unknown",
|
|
24
34
|
* alerts: [{ id, severity, kind, detail }],
|
|
@@ -48,10 +58,11 @@
|
|
|
48
58
|
|
|
49
59
|
import os from "node:os";
|
|
50
60
|
import { execFile } from "node:child_process";
|
|
51
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
61
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
52
62
|
import { join, resolve } from "node:path";
|
|
53
63
|
|
|
54
64
|
import { list as listPresence } from "../collective/presence.mjs";
|
|
65
|
+
import { billableUsd } from "../cost/ledger-row.mjs";
|
|
55
66
|
import { liveClaudeStats } from "../resource-governor.mjs";
|
|
56
67
|
|
|
57
68
|
/** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
|
|
@@ -191,10 +202,40 @@ function humanActivity(intent, taskClass) {
|
|
|
191
202
|
|
|
192
203
|
/**
|
|
193
204
|
* Gather machine stats from node:os plus best-effort `powermetrics` (temp/cpu)
|
|
194
|
-
* and `df` (disk). The exec probes are guarded + injected
|
|
195
|
-
* needs root so a non-root agent simply gets no temp/cpu (those fields drop out).
|
|
205
|
+
* and `df` (disk). The exec probes are guarded + injected.
|
|
196
206
|
*
|
|
197
|
-
*
|
|
207
|
+
* ── THE VOCABULARY IS THE CONTRACT ───────────────────────────────────────────
|
|
208
|
+
* Every key below is READ BY NAME on the other side of the beat
|
|
209
|
+
* (`hq/src/components/fleet/machine.ts#buildFleetMachine`). This collector and
|
|
210
|
+
* that reader once shared ZERO keys in production — it emitted
|
|
211
|
+
* `loadavg/memUsedPct/cpuPct/diskUsedPct` and the Fleet row read
|
|
212
|
+
* `tempC/uptimeDays/spend24h/ram/device`, so four measured numbers were
|
|
213
|
+
* persisted and rendered nowhere while five columns em-dashed for want of a
|
|
214
|
+
* producer. Adding a key here without adding its reader there (or vice versa)
|
|
215
|
+
* re-opens exactly that hole; `machine.test.ts` asserts the two sets agree.
|
|
216
|
+
*
|
|
217
|
+
* ── MOMENTARY vs INVENTORY ───────────────────────────────────────────────────
|
|
218
|
+
* MOMENTARY (describes the machine AT `status.ts`, and expires with it):
|
|
219
|
+
* loadavg, memUsedPct, cpuPct, tempC, diskUsedPct, uptimeDays, spend24h
|
|
220
|
+
* INVENTORY (describes the machine, does not perish):
|
|
221
|
+
* device, ram, cores
|
|
222
|
+
* The reader nulls the momentary set once the snapshot goes stale and keeps the
|
|
223
|
+
* inventory set. That split is why they are documented as one list here.
|
|
224
|
+
*
|
|
225
|
+
* ── EVERY BEST-EFFORT FIELD CARRIES ITS PROVENANCE ───────────────────────────
|
|
226
|
+
* `cpuSource` and `tempSource` say WHERE the number came from, and `tempDetail`
|
|
227
|
+
* says why there is none. A `cpuPct` derived from loadavg/cores is a different
|
|
228
|
+
* claim from one measured by powermetrics, and a missing `tempC` because the
|
|
229
|
+
* probe needs root is a different fact from a machine with no sensor. Both used
|
|
230
|
+
* to be indistinguishable from success: `collectPowermetrics` returned `{}` on
|
|
231
|
+
* every failure path and the field simply vanished. Fail-open is fine; silent
|
|
232
|
+
* is not.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<{
|
|
235
|
+
* loadavg:number[], memUsedPct:number, cpuPct?:number, cpuSource?:string,
|
|
236
|
+
* tempC?:number, tempSource?:string, tempDetail?:string, diskUsedPct?:number,
|
|
237
|
+
* uptimeDays?:number, spend24h?:number, device?:string, ram?:string, cores?:number
|
|
238
|
+
* }>}
|
|
198
239
|
*/
|
|
199
240
|
export async function collectMachine(o = {}) {
|
|
200
241
|
const osImpl = o.os || os;
|
|
@@ -224,15 +265,34 @@ export async function collectMachine(o = {}) {
|
|
|
224
265
|
const n = Array.isArray(cpus) ? cpus.length : 0;
|
|
225
266
|
if (n > 0 && Number.isFinite(machine.loadavg[0])) {
|
|
226
267
|
const est = (machine.loadavg[0] / n) * 100;
|
|
227
|
-
if (Number.isFinite(est))
|
|
268
|
+
if (Number.isFinite(est)) {
|
|
269
|
+
machine.cpuPct = pct(est);
|
|
270
|
+
machine.cpuSource = "loadavg";
|
|
271
|
+
}
|
|
228
272
|
}
|
|
229
273
|
} catch { /* no cpuPct */ }
|
|
230
274
|
|
|
275
|
+
// uptimeDays — os.uptime() is seconds since boot. Momentary, and one of the
|
|
276
|
+
// four fields the Fleet row read from a producer that never wrote them.
|
|
277
|
+
try {
|
|
278
|
+
const secs = Number(osImpl.uptime ? osImpl.uptime() : NaN);
|
|
279
|
+
if (Number.isFinite(secs) && secs >= 0) machine.uptimeDays = round1(secs / 86400);
|
|
280
|
+
} catch { /* no uptimeDays */ }
|
|
281
|
+
|
|
231
282
|
// Best-effort temp + cpu via powermetrics (guarded, injected, never blocks).
|
|
283
|
+
// ALWAYS records why it has no answer — see collectPowermetrics.
|
|
232
284
|
if (o.powermetrics !== false) {
|
|
233
285
|
const pm = await collectPowermetrics(o);
|
|
234
|
-
if (Number.isFinite(pm.tempC))
|
|
235
|
-
|
|
286
|
+
if (Number.isFinite(pm.tempC)) {
|
|
287
|
+
machine.tempC = round1(pm.tempC);
|
|
288
|
+
machine.tempSource = pm.source || "powermetrics";
|
|
289
|
+
} else if (pm.detail) {
|
|
290
|
+
machine.tempDetail = pm.detail;
|
|
291
|
+
}
|
|
292
|
+
if (Number.isFinite(pm.cpuPct)) {
|
|
293
|
+
machine.cpuPct = pct(pm.cpuPct);
|
|
294
|
+
machine.cpuSource = pm.source || "powermetrics";
|
|
295
|
+
}
|
|
236
296
|
}
|
|
237
297
|
|
|
238
298
|
// Best-effort diskUsedPct via df.
|
|
@@ -241,30 +301,121 @@ export async function collectMachine(o = {}) {
|
|
|
241
301
|
if (Number.isFinite(disk)) machine.diskUsedPct = pct(disk);
|
|
242
302
|
}
|
|
243
303
|
|
|
304
|
+
// INVENTORY — device / ram / cores. Cached on disk (see collectInventory):
|
|
305
|
+
// the chip and the installed RAM do not change between beats, so the
|
|
306
|
+
// system_profiler fork happens once per machine, not every 30 seconds.
|
|
307
|
+
if (o.inventory !== false) {
|
|
308
|
+
const inv = await collectInventory(o);
|
|
309
|
+
if (inv.device) machine.device = inv.device;
|
|
310
|
+
if (inv.ram) machine.ram = inv.ram;
|
|
311
|
+
if (Number.isFinite(inv.cores)) machine.cores = inv.cores;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// spend24h — the agent's own rolling 24h LLM spend, off the cost ledger.
|
|
315
|
+
if (o.spend !== false) {
|
|
316
|
+
const spend = collectSpend24h(o);
|
|
317
|
+
if (Number.isFinite(spend)) machine.spend24h = spend;
|
|
318
|
+
}
|
|
319
|
+
|
|
244
320
|
return machine;
|
|
245
321
|
}
|
|
246
322
|
|
|
323
|
+
/**
|
|
324
|
+
* The `powermetrics` sampler that carries die temperature, BY CPU ARCHITECTURE.
|
|
325
|
+
*
|
|
326
|
+
* ── A BUG THAT MADE THE PROBE UNREACHABLE ON EVERY APPLE-SILICON MAC ─────────
|
|
327
|
+
* This used to be the constant `"smc,cpu_power"`. `smc` is an INTEL-ONLY
|
|
328
|
+
* sampler: on arm64 `powermetrics` rejects the argument list outright —
|
|
329
|
+
* `powermetrics: unrecognized sampler: smc` — and exits non-zero BEFORE it ever
|
|
330
|
+
* reaches the root check. So on the only hardware the fleet actually runs on,
|
|
331
|
+
* the probe failed at argument parsing, `execFileSafe` swallowed the non-zero
|
|
332
|
+
* exit into `""`, and the caller could not tell that apart from a machine with
|
|
333
|
+
* no thermal sensor. The arm64 spelling is `thermal`.
|
|
334
|
+
*
|
|
335
|
+
* `cpu_power` is valid on both and is what yields the idle-residency line the
|
|
336
|
+
* parser reads for `cpuPct`, so it stays in either list.
|
|
337
|
+
*/
|
|
338
|
+
export function powermetricsSamplers(arch) {
|
|
339
|
+
return String(arch || "") === "arm64" ? "thermal,cpu_power" : "smc,cpu_power";
|
|
340
|
+
}
|
|
341
|
+
|
|
247
342
|
/**
|
|
248
343
|
* Parse `powermetrics` for CPU temperature + active CPU%. Guarded: only attempts
|
|
249
344
|
* the exec on darwin (unless an explicit `deps.execFile` + `deps.platform` say
|
|
250
|
-
* otherwise for tests).
|
|
345
|
+
* otherwise for tests). NEVER blocks past timeoutMs, NEVER throws.
|
|
251
346
|
*
|
|
252
|
-
*
|
|
347
|
+
* ── IT ALWAYS SAYS WHY IT HAS NOTHING ────────────────────────────────────────
|
|
348
|
+
* This returned a bare `{}` for all four of its failure modes — wrong sampler,
|
|
349
|
+
* missing binary, not root, no sensor — and the caller dropped the field. The
|
|
350
|
+
* Fleet view then printed an em-dash that meant "we do not know, and we cannot
|
|
351
|
+
* tell you why", which is how a two-month-dead probe stayed invisible. Every
|
|
352
|
+
* exit now carries a `detail` sentence that the snapshot ships and the drawer
|
|
353
|
+
* prints.
|
|
354
|
+
*
|
|
355
|
+
* ── ROOT, AND THE ONE HONEST WAY AROUND IT ───────────────────────────────────
|
|
356
|
+
* `powermetrics` requires superuser on macOS, full stop; the daemon runs from a
|
|
357
|
+
* user LaunchAgent. There is NO non-root numeric temperature source on Apple
|
|
358
|
+
* Silicon without a compiled IOKit helper (`ioreg` exposes the PMU/NVMe sensors
|
|
359
|
+
* as HID services whose values are only readable through IOHIDEventSystem, not
|
|
360
|
+
* through the registry), so this does not pretend to have one. Instead it
|
|
361
|
+
* supports an OPT-IN sudo path: set `TELEMETRY_SUDO_POWERMETRICS=1` once the
|
|
362
|
+
* operator has allowlisted the binary NOPASSWD in sudoers. `sudo -n` never
|
|
363
|
+
* prompts — with no allowlist it fails instantly rather than hanging a beat on
|
|
364
|
+
* a password prompt no one will ever see.
|
|
365
|
+
*
|
|
366
|
+
* @returns {Promise<{ tempC?:number, cpuPct?:number, source?:string, detail?:string }>}
|
|
253
367
|
*/
|
|
254
368
|
export async function collectPowermetrics(o = {}) {
|
|
255
369
|
const platform = o.platform || (o.os && o.os.platform && o.os.platform()) || process.platform;
|
|
256
370
|
const execFileImpl = o.execFile || execFile;
|
|
257
|
-
|
|
371
|
+
// only darwin in the wild; tests inject execFile
|
|
372
|
+
if (platform !== "darwin" && !o.execFile) {
|
|
373
|
+
return { detail: `no temperature probe on platform ${platform}` };
|
|
374
|
+
}
|
|
258
375
|
const timeoutMs = Number.isFinite(o.pmTimeoutMs) ? o.pmTimeoutMs : 1500;
|
|
259
376
|
const bin = o.powermetricsBin || "/usr/bin/powermetrics";
|
|
260
|
-
if (!o.execFile && !existsSync(bin))
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
377
|
+
if (!o.execFile && !existsSync(bin)) {
|
|
378
|
+
return { detail: `${bin} not present` };
|
|
379
|
+
}
|
|
380
|
+
const arch = o.arch || (o.os && o.os.arch && o.os.arch()) || process.arch;
|
|
381
|
+
const args = [
|
|
382
|
+
"-n", "1", "-i", "200",
|
|
383
|
+
"--samplers", powermetricsSamplers(arch),
|
|
384
|
+
"--show-process-energy",
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
// 1. Direct. Works when the daemon happens to run as root.
|
|
388
|
+
const direct = await execFileSafe(execFileImpl, bin, args, timeoutMs);
|
|
389
|
+
if (direct) {
|
|
390
|
+
const parsed = parsePowermetrics(direct);
|
|
391
|
+
if (Number.isFinite(parsed.tempC) || Number.isFinite(parsed.cpuPct)) {
|
|
392
|
+
return { ...parsed, source: "powermetrics" };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 2. Opt-in sudo, only when the operator has allowlisted it. `-n` = never prompt.
|
|
397
|
+
const sudoEnabled = o.sudoPowermetrics !== undefined
|
|
398
|
+
? Boolean(o.sudoPowermetrics)
|
|
399
|
+
: String(process.env.TELEMETRY_SUDO_POWERMETRICS || "") === "1";
|
|
400
|
+
if (sudoEnabled) {
|
|
401
|
+
const sudoBin = o.sudoBin || "/usr/bin/sudo";
|
|
402
|
+
const out = await execFileSafe(execFileImpl, sudoBin, ["-n", bin, ...args], timeoutMs);
|
|
403
|
+
if (out) {
|
|
404
|
+
const parsed = parsePowermetrics(out);
|
|
405
|
+
if (Number.isFinite(parsed.tempC) || Number.isFinite(parsed.cpuPct)) {
|
|
406
|
+
return { ...parsed, source: "sudo-powermetrics" };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
detail: `${bin} yielded nothing under 'sudo -n' — check the NOPASSWD sudoers allowlist`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
detail:
|
|
416
|
+
`${bin} requires root (agent runs unprivileged); ` +
|
|
417
|
+
"allowlist it NOPASSWD in sudoers and set TELEMETRY_SUDO_POWERMETRICS=1 to enable",
|
|
418
|
+
};
|
|
268
419
|
}
|
|
269
420
|
|
|
270
421
|
/** Pure parser for powermetrics text → { tempC?, cpuPct? }. Tolerant of format drift. */
|
|
@@ -307,6 +458,190 @@ export function parseDf(text) {
|
|
|
307
458
|
function round1(n) { return Math.round(Number(n) * 10) / 10; }
|
|
308
459
|
function round2(n) { return Math.round(Number(n) * 100) / 100; }
|
|
309
460
|
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Inventory (device / ram / cores) — what the machine IS, not what it is doing
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Human-readable installed RAM, e.g. `24 GB`. Binary GB (the unit every Apple
|
|
467
|
+
* spec sheet and Activity Monitor uses), rounded to whole units — 25769803776
|
|
468
|
+
* bytes is "24 GB", never "25.8 GB".
|
|
469
|
+
*/
|
|
470
|
+
export function ramLabel(totalBytes) {
|
|
471
|
+
const b = Number(totalBytes);
|
|
472
|
+
if (!Number.isFinite(b) || b <= 0) return "";
|
|
473
|
+
const gb = b / 1024 ** 3;
|
|
474
|
+
return `${gb >= 10 ? Math.round(gb) : round1(gb)} GB`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Pure parser for `system_profiler SPHardwareDataType` text → the inventory
|
|
479
|
+
* triple. Tolerant of format drift and of a machine that reports only some of
|
|
480
|
+
* the three (an absent key simply drops out).
|
|
481
|
+
*
|
|
482
|
+
* @returns {{device?:string, ram?:string, cores?:number}}
|
|
483
|
+
*/
|
|
484
|
+
export function parseSystemProfilerHardware(text) {
|
|
485
|
+
const s = String(text || "");
|
|
486
|
+
const out = {};
|
|
487
|
+
const grab = (label) => {
|
|
488
|
+
const m = s.match(new RegExp(`^\\s*${label}:\\s*(.+)$`, "im"));
|
|
489
|
+
return m ? m[1].trim() : "";
|
|
490
|
+
};
|
|
491
|
+
// "Mac mini" + "Apple M4 Pro" → "Mac mini M4 Pro", matching the design's
|
|
492
|
+
// "Mac Studio M3 Ultra" idiom. Either half alone is still an honest answer.
|
|
493
|
+
const model = grab("Model Name");
|
|
494
|
+
const chip = grab("Chip") || grab("Processor Name");
|
|
495
|
+
const chipShort = chip.replace(/^Apple\s+/i, "");
|
|
496
|
+
const device = [model, model && chipShort && chipShort !== model ? chipShort : chip && !model ? chip : ""]
|
|
497
|
+
.filter(Boolean)
|
|
498
|
+
.join(" ")
|
|
499
|
+
.trim();
|
|
500
|
+
if (device) out.device = device;
|
|
501
|
+
|
|
502
|
+
const mem = grab("Memory");
|
|
503
|
+
if (mem) out.ram = mem;
|
|
504
|
+
|
|
505
|
+
const cores = grab("Total Number of Cores");
|
|
506
|
+
const n = Number(String(cores).match(/^\d+/)?.[0]);
|
|
507
|
+
if (Number.isFinite(n) && n > 0) out.cores = n;
|
|
508
|
+
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Resolve device / ram / cores, CACHED ON DISK.
|
|
514
|
+
*
|
|
515
|
+
* WHY CACHED. `system_profiler` costs ~200ms and forks a privileged-ish helper;
|
|
516
|
+
* the chip and the installed RAM do not change between two beats 30 seconds
|
|
517
|
+
* apart. Probing once per machine and reading a JSON file thereafter keeps the
|
|
518
|
+
* beat's cost where it belongs. The cache is KEYED on `os.cpus()[0].model` +
|
|
519
|
+
* total bytes, so swapping the machine (or an agent root moved to different
|
|
520
|
+
* hardware) invalidates it rather than reporting the old box forever.
|
|
521
|
+
*
|
|
522
|
+
* Fail-open in both directions: no agentRoot → probe every time; probe fails →
|
|
523
|
+
* fall back to what node:os alone can prove (`Apple M4 Pro`, `24 GB`, `12`),
|
|
524
|
+
* which is never nothing.
|
|
525
|
+
*
|
|
526
|
+
* @returns {Promise<{device?:string, ram?:string, cores?:number}>}
|
|
527
|
+
*/
|
|
528
|
+
export async function collectInventory(o = {}) {
|
|
529
|
+
const osImpl = o.os || os;
|
|
530
|
+
let chip = "";
|
|
531
|
+
let totalBytes = 0;
|
|
532
|
+
let cores = 0;
|
|
533
|
+
try {
|
|
534
|
+
const cpus = osImpl.cpus();
|
|
535
|
+
cores = Array.isArray(cpus) ? cpus.length : 0;
|
|
536
|
+
chip = (Array.isArray(cpus) && cpus[0] && cpus[0].model) || "";
|
|
537
|
+
} catch { /* fall through */ }
|
|
538
|
+
try { totalBytes = Number(osImpl.totalmem()) || 0; } catch { /* fall through */ }
|
|
539
|
+
|
|
540
|
+
const key = `${chip}|${totalBytes}`;
|
|
541
|
+
const cachePath = o.agentRoot
|
|
542
|
+
? join(resolve(o.agentRoot), "state", "telemetry", "inventory.json")
|
|
543
|
+
: null;
|
|
544
|
+
|
|
545
|
+
if (cachePath && o.inventoryCache !== false) {
|
|
546
|
+
const cached = safeReadJson(cachePath);
|
|
547
|
+
if (cached && cached.key === key && cached.inv && typeof cached.inv === "object") {
|
|
548
|
+
return cached.inv;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// What node:os alone can prove — the floor, never overwritten by a worse probe.
|
|
553
|
+
const inv = {};
|
|
554
|
+
if (chip) inv.device = chip;
|
|
555
|
+
const ram = ramLabel(totalBytes);
|
|
556
|
+
if (ram) inv.ram = ram;
|
|
557
|
+
if (cores > 0) inv.cores = cores;
|
|
558
|
+
|
|
559
|
+
const platform = o.platform || (osImpl.platform && osImpl.platform()) || process.platform;
|
|
560
|
+
if (platform === "darwin" || o.execFile) {
|
|
561
|
+
const execFileImpl = o.execFile || execFile;
|
|
562
|
+
const bin = o.systemProfilerBin || "/usr/sbin/system_profiler";
|
|
563
|
+
const timeoutMs = Number.isFinite(o.spTimeoutMs) ? o.spTimeoutMs : 3000;
|
|
564
|
+
if (o.execFile || existsSync(bin)) {
|
|
565
|
+
const out = await execFileSafe(execFileImpl, bin, ["SPHardwareDataType"], timeoutMs);
|
|
566
|
+
if (out) Object.assign(inv, parseSystemProfilerHardware(out));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (cachePath && Object.keys(inv).length > 0) {
|
|
571
|
+
try {
|
|
572
|
+
mkdirSync(join(resolve(o.agentRoot), "state", "telemetry"), { recursive: true });
|
|
573
|
+
writeFileSync(cachePath, JSON.stringify({ key, inv }, null, 2));
|
|
574
|
+
} catch { /* cache is an optimisation; never fatal */ }
|
|
575
|
+
}
|
|
576
|
+
return inv;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// spend24h — the agent's own rolling LLM spend
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Sum the agent's BILLABLE spend over the trailing 24 hours from the cost
|
|
585
|
+
* ledger (`state/cost-tracking/<UTC date>.jsonl`).
|
|
586
|
+
*
|
|
587
|
+
* Reads today's and yesterday's files only — a 24h window can never span three
|
|
588
|
+
* UTC dates — and filters by each row's own `ts`, so the number is a true
|
|
589
|
+
* rolling window rather than "today so far".
|
|
590
|
+
*
|
|
591
|
+
* PRICING IS NOT RE-IMPLEMENTED HERE. `lib/cost/ledger-row.mjs#billableUsd` is
|
|
592
|
+
* the one definition of what a row cost and whether we actually know; it is
|
|
593
|
+
* imported rather than approximated, because the two ledger row shapes (v1
|
|
594
|
+
* tracker rows, v2 model-router rows) and the cache-tier pricing hole are
|
|
595
|
+
* exactly the things a second summing loop would get wrong again. A row whose
|
|
596
|
+
* cost is UNKNOWN (`usd === null`) contributes nothing and is not counted as
|
|
597
|
+
* zero — an unmeasured session is not a free one.
|
|
598
|
+
*
|
|
599
|
+
* Returns `undefined` (field drops out) when there is no ledger to read, which
|
|
600
|
+
* is honestly different from `0` ("the ledger says nothing was spent").
|
|
601
|
+
*
|
|
602
|
+
* @returns {number|undefined} USD, 2dp
|
|
603
|
+
*/
|
|
604
|
+
export function collectSpend24h(o = {}) {
|
|
605
|
+
const agentRoot = o.agentRoot;
|
|
606
|
+
if (!agentRoot) return undefined;
|
|
607
|
+
const dir = o.costDir || join(resolve(agentRoot), "state", "cost-tracking");
|
|
608
|
+
let existsDir = false;
|
|
609
|
+
try { existsDir = existsSync(dir); } catch { existsDir = false; }
|
|
610
|
+
if (!existsDir) return undefined;
|
|
611
|
+
|
|
612
|
+
const nowMs = nowMsFrom(o.now);
|
|
613
|
+
const cutoff = nowMs - 24 * 60 * 60 * 1000;
|
|
614
|
+
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
615
|
+
const files = [...new Set([day(cutoff), day(nowMs)])];
|
|
616
|
+
|
|
617
|
+
let total = 0;
|
|
618
|
+
let sawAny = false;
|
|
619
|
+
for (const name of files) {
|
|
620
|
+
let body = "";
|
|
621
|
+
try {
|
|
622
|
+
const p = join(dir, `${name}.jsonl`);
|
|
623
|
+
if (!existsSync(p)) continue;
|
|
624
|
+
body = readFileSync(p, "utf8");
|
|
625
|
+
} catch { continue; }
|
|
626
|
+
sawAny = true;
|
|
627
|
+
for (const line of body.split("\n")) {
|
|
628
|
+
const trimmed = line.trim();
|
|
629
|
+
if (!trimmed) continue;
|
|
630
|
+
let row;
|
|
631
|
+
try { row = JSON.parse(trimmed); } catch { continue; }
|
|
632
|
+
const ts = Date.parse(row && row.ts);
|
|
633
|
+
if (!Number.isFinite(ts) || ts < cutoff || ts > nowMs) continue;
|
|
634
|
+
let billed;
|
|
635
|
+
try { billed = billableUsd(row); } catch { continue; }
|
|
636
|
+
// `usd === null` means UNKNOWN, not free. Skipping it under-reports on
|
|
637
|
+
// purpose rather than inventing a zero — the same call budget-guard makes.
|
|
638
|
+
if (billed && Number.isFinite(billed.usd)) total += billed.usd;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!sawAny) return undefined;
|
|
642
|
+
return Math.round(total * 100) / 100;
|
|
643
|
+
}
|
|
644
|
+
|
|
310
645
|
// ---------------------------------------------------------------------------
|
|
311
646
|
// claudeAuth detection
|
|
312
647
|
// ---------------------------------------------------------------------------
|
|
@@ -499,9 +834,14 @@ export const _internals = {
|
|
|
499
834
|
humanActivity,
|
|
500
835
|
collectMachine,
|
|
501
836
|
collectPowermetrics,
|
|
837
|
+
powermetricsSamplers,
|
|
502
838
|
parsePowermetrics,
|
|
503
839
|
collectDisk,
|
|
504
840
|
parseDf,
|
|
841
|
+
collectInventory,
|
|
842
|
+
parseSystemProfilerHardware,
|
|
843
|
+
ramLabel,
|
|
844
|
+
collectSpend24h,
|
|
505
845
|
detectClaudeAuth,
|
|
506
846
|
scanForRelogin,
|
|
507
847
|
countSubAgents,
|