@cohortapp/agent-sdk 2.6.2 → 2.8.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.
@@ -19,9 +19,11 @@
19
19
  * memUsedPct: number, // 1 - free/total
20
20
  * cpuPct?: number, // powermetrics if root, else loadavg/cores
21
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)
22
+ * tempC?: number, // real die °C — unprivileged, via IOHIDEventSystem
23
+ * tempSource?: "iohid"|"powermetrics"|"sudo-powermetrics",
24
+ * thermalState?: "nominal"|"fair"|"serious"|"critical", // coarse; ALWAYS available on darwin
25
+ * thermalSource?: "processinfo"|"sudo-powermetrics"|"pmset",
26
+ * tempDetail?: string, // NOTE ONLY — never the value (see collectThermal)
25
27
  * diskUsedPct?: number,
26
28
  * uptimeDays?: number,
27
29
  * spend24h?: number, // rolling 24h billable USD off the cost ledger
@@ -60,6 +62,7 @@ import os from "node:os";
60
62
  import { execFile } from "node:child_process";
61
63
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
62
64
  import { join, resolve } from "node:path";
65
+ import { fileURLToPath } from "node:url";
63
66
 
64
67
  import { list as listPresence } from "../collective/presence.mjs";
65
68
  import { billableUsd } from "../cost/ledger-row.mjs";
@@ -233,7 +236,8 @@ function humanActivity(intent, taskClass) {
233
236
  *
234
237
  * @returns {Promise<{
235
238
  * loadavg:number[], memUsedPct:number, cpuPct?:number, cpuSource?:string,
236
- * tempC?:number, tempSource?:string, tempDetail?:string, diskUsedPct?:number,
239
+ * tempC?:number, tempSource?:string, thermalState?:string, thermalSource?:string,
240
+ * tempDetail?:string, diskUsedPct?:number,
237
241
  * uptimeDays?:number, spend24h?:number, device?:string, ram?:string, cores?:number
238
242
  * }>}
239
243
  */
@@ -279,15 +283,37 @@ export async function collectMachine(o = {}) {
279
283
  if (Number.isFinite(secs) && secs >= 0) machine.uptimeDays = round1(secs / 86400);
280
284
  } catch { /* no uptimeDays */ }
281
285
 
282
- // Best-effort temp + cpu via powermetrics (guarded, injected, never blocks).
283
- // ALWAYS records why it has no answer — see collectPowermetrics.
286
+ // ── THERMAL ────────────────────────────────────────────────────────────────
287
+ // Real °C where the hardware gives it up unprivileged, a coarse state word
288
+ // everywhere else, and `tempDetail` as an EXPLANATORY NOTE that rides
289
+ // ALONGSIDE the reading rather than standing in for it. See collectThermal
290
+ // for why this no longer runs through powermetrics.
291
+ // `powermetrics: false` has always been the caller's "fork no probes" switch
292
+ // (it is how every hermetic test disables the exec seam), so it still governs
293
+ // thermal unless a caller opts in or out of thermal explicitly.
294
+ const thermalEnabled = o.thermal !== undefined ? o.thermal !== false : o.powermetrics !== false;
295
+ if (thermalEnabled) {
296
+ const th = await collectThermal(o);
297
+ if (Number.isFinite(th.tempC)) {
298
+ machine.tempC = round1(th.tempC);
299
+ if (th.tempSource) machine.tempSource = th.tempSource;
300
+ }
301
+ if (th.thermalState) {
302
+ machine.thermalState = th.thermalState;
303
+ if (th.thermalSource) machine.thermalSource = th.thermalSource;
304
+ }
305
+ if (th.detail) machine.tempDetail = th.detail;
306
+ }
307
+
308
+ // powermetrics is now asked for CPU utilisation only — plus `tempC` on Intel,
309
+ // where the `smc` sampler genuinely carries a die temperature and the IOHID
310
+ // path above does not apply. On Apple Silicon it has no temperature to give
311
+ // at ANY privilege level, so it never gets to answer the thermal question.
284
312
  if (o.powermetrics !== false) {
285
313
  const pm = await collectPowermetrics(o);
286
- if (Number.isFinite(pm.tempC)) {
314
+ if (machine.tempC === undefined && Number.isFinite(pm.tempC)) {
287
315
  machine.tempC = round1(pm.tempC);
288
316
  machine.tempSource = pm.source || "powermetrics";
289
- } else if (pm.detail) {
290
- machine.tempDetail = pm.detail;
291
317
  }
292
318
  if (Number.isFinite(pm.cpuPct)) {
293
319
  machine.cpuPct = pct(pm.cpuPct);
@@ -320,6 +346,212 @@ export async function collectMachine(o = {}) {
320
346
  return machine;
321
347
  }
322
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // Thermal — real °C without root, and a coarse state word that never fails
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** The four thermal pressure levels macOS reports, coldest → hottest. */
354
+ export const THERMAL_STATES = Object.freeze(["nominal", "fair", "serious", "critical"]);
355
+
356
+ /** Normalise any casing/spelling of a pressure level to our enum, or undefined. */
357
+ function normalizeThermalState(raw) {
358
+ const v = String(raw || "").trim().toLowerCase();
359
+ return THERMAL_STATES.includes(v) ? v : undefined;
360
+ }
361
+
362
+ /**
363
+ * Pure parser for `thermal-probe.swift` stdout → { thermalState?, tempC?, sensors? }.
364
+ * Format is `key=value` lines; unknown keys are ignored so the probe can grow.
365
+ */
366
+ export function parseThermalProbe(text) {
367
+ const out = {};
368
+ for (const line of String(text || "").split("\n")) {
369
+ const m = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
370
+ if (!m) continue;
371
+ const [, k, v] = m;
372
+ if (k === "thermalState") {
373
+ const st = normalizeThermalState(v);
374
+ if (st) out.thermalState = st;
375
+ } else if (k === "tempC") {
376
+ const n = Number(v);
377
+ // Band-check AGAIN on this side. The probe filters, but a collector that
378
+ // trusts a subprocess's arithmetic is how "-9201.14 °C" reaches a dashboard.
379
+ if (Number.isFinite(n) && n > 0 && n < 150) out.tempC = n;
380
+ } else if (k === "tempSensors") {
381
+ const n = Number(v);
382
+ if (Number.isFinite(n)) out.sensors = n;
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+
388
+ /**
389
+ * Pure parser for the `**** Thermal pressure ****` block that `powermetrics
390
+ * --samplers thermal` prints — the ONE thermal fact that binary has on Apple
391
+ * Silicon. Root-only, so this is a fallback, not the primary path.
392
+ */
393
+ export function parseThermalPressure(text) {
394
+ const m = String(text || "").match(/Current pressure level:\s*(\w+)/i);
395
+ return m ? normalizeThermalState(m[1]) : undefined;
396
+ }
397
+
398
+ /**
399
+ * Pure parser for `pmset -g therm`. Unprivileged and always present, but it
400
+ * reports a HISTORY of recorded warnings, not a live reading: on a healthy
401
+ * machine every line is "No … has been recorded". That absence is a weak signal
402
+ * and this returns `undefined` for it rather than inventing "nominal" — see
403
+ * collectThermal, which is where the inference is made explicit and labelled.
404
+ */
405
+ export function parsePmsetTherm(text) {
406
+ const s = String(text || "");
407
+ const m = s.match(/CPU_Scheduler_Limit\s*=\s*(\d+)/i) || s.match(/CPU_Speed_Limit\s*=\s*(\d+)/i);
408
+ if (m) {
409
+ const limit = Number(m[1]);
410
+ // 100 = no throttling. Anything below it means the SMC is actively pulling
411
+ // the CPU back, which is a REAL thermal event, not a recorded memory of one.
412
+ if (Number.isFinite(limit)) {
413
+ if (limit >= 100) return "nominal";
414
+ if (limit >= 75) return "fair";
415
+ if (limit >= 50) return "serious";
416
+ return "critical";
417
+ }
418
+ }
419
+ if (/No thermal warning level has been recorded/i.test(s)) return "no-events";
420
+ return undefined;
421
+ }
422
+
423
+ /**
424
+ * Collect the machine's thermal signal — REAL °C when the hardware will give it
425
+ * up unprivileged, a coarse state word when it will not.
426
+ *
427
+ * ── THE ADVICE THIS REPLACES WAS FUTILE ─────────────────────────────────────
428
+ * This used to emit, on every Apple-Silicon seat: "powermetrics requires root
429
+ * (agent runs unprivileged); allowlist it NOPASSWD in sudoers and set
430
+ * TELEMETRY_SUDO_POWERMETRICS=1 to enable". Both halves of that were wrong.
431
+ *
432
+ * - `powermetrics` produces NO die temperature on Apple Silicon at any
433
+ * privilege level. Verified on Mac16,11 / macOS 24D70: `sudo powermetrics
434
+ * -A` (every sampler, 242 lines) contains no `temperature` line at all. An
435
+ * operator who did exactly what the message asked would STILL see a dash.
436
+ * - °C does not need root. It needs IOHIDEventSystem, which any uid can read.
437
+ *
438
+ * ── THE LADDER ──────────────────────────────────────────────────────────────
439
+ * 1. `swift thermal-probe.swift` — unprivileged. Yields BOTH a real `tempC`
440
+ * (private IOHID sensors) and `thermalState` (public ProcessInfo). This is
441
+ * the answer on any Mac with the Xcode command-line tools present.
442
+ * 2. `sudo -n powermetrics --samplers thermal` — ONLY if the operator opted
443
+ * in. No °C, but a true `Current pressure level:`. State-only.
444
+ * 3. `pmset -g therm` — unprivileged, always present, but only reports a
445
+ * live level while the SMC is actually throttling.
446
+ *
447
+ * Every rung records WHY it has no better answer in `detail`. Crucially
448
+ * `detail` is now a NOTE that ships ALONGSIDE a reading, never instead of one:
449
+ * the old code made them mutually exclusive, so hq had nothing to print in the
450
+ * value slot and rendered the 118-character sudoers sentence there instead.
451
+ *
452
+ * @returns {Promise<{ tempC?:number, tempSource?:string, thermalState?:string,
453
+ * thermalSource?:string, detail?:string }>}
454
+ */
455
+ export async function collectThermal(o = {}) {
456
+ const platform = o.platform || (o.os && o.os.platform && o.os.platform()) || process.platform;
457
+ const execFileImpl = o.execFile || execFile;
458
+ if (platform !== "darwin" && !o.execFile) {
459
+ return { detail: `no thermal probe on platform ${platform}` };
460
+ }
461
+ const timeoutMs = Number.isFinite(o.thermalTimeoutMs) ? o.thermalTimeoutMs : 4000;
462
+
463
+ // ── 1. The unprivileged probe: real °C + public thermal state ─────────────
464
+ const swiftBin = o.swiftBin || "/usr/bin/swift";
465
+ const probePath = o.thermalProbePath || defaultThermalProbePath();
466
+ const haveSwift = Boolean(o.execFile) || (existsSync(swiftBin) && existsSync(probePath));
467
+ if (haveSwift) {
468
+ const out = await execFileSafe(execFileImpl, swiftBin, [probePath], timeoutMs);
469
+ const parsed = parseThermalProbe(out);
470
+ if (Number.isFinite(parsed.tempC)) {
471
+ return {
472
+ tempC: parsed.tempC,
473
+ tempSource: "iohid",
474
+ thermalState: parsed.thermalState,
475
+ thermalSource: parsed.thermalState ? "processinfo" : undefined,
476
+ detail: parsed.sensors
477
+ ? `die temperature: hottest of ${parsed.sensors} on-SoC sensors (IOHIDEventSystem, unprivileged)`
478
+ : "die temperature via IOHIDEventSystem (unprivileged)",
479
+ };
480
+ }
481
+ if (parsed.thermalState) {
482
+ return {
483
+ thermalState: parsed.thermalState,
484
+ thermalSource: "processinfo",
485
+ detail:
486
+ "no numeric °C: the IOHID temperature sensors returned nothing on this hardware; " +
487
+ "showing the OS thermal pressure level instead",
488
+ };
489
+ }
490
+ }
491
+
492
+ // ── 2. Opt-in sudo powermetrics — pressure level only, never °C ───────────
493
+ const sudoEnabled = o.sudoPowermetrics !== undefined
494
+ ? Boolean(o.sudoPowermetrics)
495
+ : String(process.env.TELEMETRY_SUDO_POWERMETRICS || "") === "1";
496
+ if (sudoEnabled) {
497
+ const sudoBin = o.sudoBin || "/usr/bin/sudo";
498
+ const pmBin = o.powermetricsBin || "/usr/bin/powermetrics";
499
+ const arch = o.arch || (o.os && o.os.arch && o.os.arch()) || process.arch;
500
+ const out = await execFileSafe(
501
+ execFileImpl, sudoBin,
502
+ ["-n", pmBin, "-n", "1", "-i", "200", "--samplers", powermetricsSamplers(arch)],
503
+ timeoutMs
504
+ );
505
+ const level = parseThermalPressure(out);
506
+ if (level) {
507
+ return {
508
+ thermalState: level,
509
+ thermalSource: "sudo-powermetrics",
510
+ detail:
511
+ "no numeric °C: powermetrics reports thermal PRESSURE only — Apple Silicon " +
512
+ "exposes no die temperature to it even as root",
513
+ };
514
+ }
515
+ }
516
+
517
+ // ── 3. pmset — unprivileged, but only speaks up while actually throttling ──
518
+ const pmsetBin = o.pmsetBin || "/usr/bin/pmset";
519
+ const out = await execFileSafe(execFileImpl, pmsetBin, ["-g", "therm"], timeoutMs);
520
+ const therm = parsePmsetTherm(out);
521
+ if (therm && therm !== "no-events") {
522
+ return {
523
+ thermalState: therm,
524
+ thermalSource: "pmset",
525
+ detail: "derived from the SMC CPU speed limit reported by `pmset -g therm`",
526
+ };
527
+ }
528
+ if (therm === "no-events") {
529
+ // NOT reported as `nominal`. "macOS has never recorded a thermal warning"
530
+ // is a statement about history, and dressing it up as a live reading is
531
+ // exactly the kind of invented number this rewrite exists to remove.
532
+ return {
533
+ detail:
534
+ "no thermal reading: install the Xcode command-line tools (`xcode-select --install`) " +
535
+ "to enable the unprivileged °C probe. `pmset` reports no thermal events on record.",
536
+ };
537
+ }
538
+
539
+ return {
540
+ detail:
541
+ "no thermal reading available: the unprivileged °C probe needs `/usr/bin/swift` " +
542
+ "(Xcode command-line tools) and no fallback thermal source responded",
543
+ };
544
+ }
545
+
546
+ /** Absolute path to the shipped swift probe, resolved next to this module. */
547
+ function defaultThermalProbePath() {
548
+ try {
549
+ return fileURLToPath(new URL("./thermal-probe.swift", import.meta.url));
550
+ } catch {
551
+ return "";
552
+ }
553
+ }
554
+
323
555
  /**
324
556
  * The `powermetrics` sampler that carries die temperature, BY CPU ARCHITECTURE.
325
557
  *
@@ -352,16 +584,20 @@ export function powermetricsSamplers(arch) {
352
584
  * exit now carries a `detail` sentence that the snapshot ships and the drawer
353
585
  * prints.
354
586
  *
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.
587
+ * ── THIS IS NO LONGER THE TEMPERATURE PATH ───────────────────────────────────
588
+ * It used to be, and the comment here used to assert that no unprivileged
589
+ * numeric temperature source existed on Apple Silicon. That was wrong twice
590
+ * over: powermetrics has no die temperature to give on Apple Silicon EVEN AS
591
+ * ROOT (`sudo powermetrics -A` contains no `temperature` line), and °C is
592
+ * readable by any uid through IOHIDEventSystem. `collectThermal` owns that now;
593
+ * this function is kept for CPU utilisation, for the Intel `smc` sampler that
594
+ * genuinely does carry a temperature, and for the thermal-pressure line.
595
+ *
596
+ * `powermetrics` still requires superuser, and the daemon still runs from a user
597
+ * LaunchAgent, so the OPT-IN sudo path remains: set
598
+ * `TELEMETRY_SUDO_POWERMETRICS=1` once the operator has allowlisted the binary
599
+ * NOPASSWD in sudoers. `sudo -n` never prompts — with no allowlist it fails
600
+ * instantly rather than hanging a beat on a password prompt no one will see.
365
601
  *
366
602
  * @returns {Promise<{ tempC?:number, cpuPct?:number, source?:string, detail?:string }>}
367
603
  */
@@ -407,14 +643,12 @@ export async function collectPowermetrics(o = {}) {
407
643
  }
408
644
  }
409
645
  return {
410
- detail: `${bin} yielded nothing under 'sudo -n' — check the NOPASSWD sudoers allowlist`,
646
+ detail: `${bin} ran under 'sudo -n' but reported no CPU utilisation this platform can parse`,
411
647
  };
412
648
  }
413
649
 
414
650
  return {
415
- detail:
416
- `${bin} requires root (agent runs unprivileged); ` +
417
- "allowlist it NOPASSWD in sudoers and set TELEMETRY_SUDO_POWERMETRICS=1 to enable",
651
+ detail: `${bin} requires root (agent runs unprivileged); CPU% falls back to the loadavg estimate`,
418
652
  };
419
653
  }
420
654
 
@@ -425,10 +659,33 @@ export function parsePowermetrics(text) {
425
659
  // Temperature: "CPU die temperature: 54.21 C" or "GPU die temperature: ..."
426
660
  const temp = s.match(/CPU die temperature:\s*([\d.]+)\s*C/i) || s.match(/die temperature:\s*([\d.]+)\s*C/i);
427
661
  if (temp) { const v = Number(temp[1]); if (Number.isFinite(v)) out.tempC = v; }
428
- // Active CPU%: "CPU Average frequency as fraction of nominal: ..." isn't util;
429
- // prefer an explicit "... active residency" total or a "CPU usage" line.
430
- const idle = s.match(/CPU\s+(?:Average\s+)?idle\s+residency:\s*([\d.]+)\s*%/i);
431
- if (idle) { const v = Number(idle[1]); if (Number.isFinite(v)) out.cpuPct = Math.max(0, 100 - v); }
662
+ // Thermal pressure — on Apple Silicon this is the ONLY thermal fact in the
663
+ // whole of powermetrics' output, at any privilege level.
664
+ const pressure = parseThermalPressure(s);
665
+ if (pressure) out.pressure = pressure;
666
+ // Active CPU% = 100 - idle residency.
667
+ //
668
+ // ── THE arm64 SHAPE, WHICH NEVER MATCHED ──────────────────────────────────
669
+ // This only ever looked for a bare `CPU idle residency:` line. Apple Silicon
670
+ // does not print one: it reports PER-CLUSTER (`E-Cluster idle residency:
671
+ // 15.69%`, `P-Cluster …`) and per-core (`CPU 0 idle residency: 46.81%`)
672
+ // lines and no total. `grep -cE "^CPU\s+(Average\s+)?idle residency"` on real
673
+ // M4 Pro output returns 0, so with sudo enabled this fell through to a detail
674
+ // string blaming the operator's sudoers file when sudo had worked perfectly.
675
+ // Fall back to averaging the per-CORE lines, which is the true whole-CPU idle.
676
+ const idle = s.match(/^CPU\s+(?:Average\s+)?idle\s+residency:\s*([\d.]+)\s*%/im);
677
+ if (idle) {
678
+ const v = Number(idle[1]);
679
+ if (Number.isFinite(v)) out.cpuPct = Math.max(0, 100 - v);
680
+ } else {
681
+ const perCore = [...s.matchAll(/^CPU\s+\d+\s+idle\s+residency:\s*([\d.]+)\s*%/gim)]
682
+ .map((m) => Number(m[1]))
683
+ .filter((n) => Number.isFinite(n));
684
+ if (perCore.length > 0) {
685
+ const avgIdle = perCore.reduce((a, b) => a + b, 0) / perCore.length;
686
+ out.cpuPct = Math.max(0, 100 - avgIdle);
687
+ }
688
+ }
432
689
  return out;
433
690
  }
434
691
 
@@ -25,6 +25,9 @@ import {
25
25
  parseDf,
26
26
  collectMachine,
27
27
  collectPowermetrics,
28
+ collectThermal,
29
+ parseThermalProbe,
30
+ parseThermalPressure,
28
31
  powermetricsSamplers,
29
32
  parseSystemProfilerHardware,
30
33
  ramLabel,
@@ -319,21 +322,25 @@ test("collectPowermetrics passes the arch-correct sampler to the binary", async
319
322
  assert.ok(!samplerArg.includes("smc"), "must not send the Intel-only sampler to arm64");
320
323
  });
321
324
 
322
- test("collectPowermetrics ALWAYS says why it has no temperature (fail-open, never silent)", async () => {
325
+ test("collectPowermetrics ALWAYS says why it has no answer (fail-open, never silent)", async () => {
323
326
  // The unprivileged reality on every seat: the probe yields nothing.
324
327
  const pm = await collectPowermetrics({
325
328
  execFile: fakeExecFile({}), platform: "darwin", arch: "arm64", sudoPowermetrics: false,
326
329
  });
327
330
  assert.equal(pm.tempC, undefined);
328
331
  assert.match(pm.detail, /requires root/);
329
- assert.match(pm.detail, /TELEMETRY_SUDO_POWERMETRICS/);
332
+ // It no longer tells the operator to edit sudoers to get a temperature. That
333
+ // advice was futile: powermetrics has no die temperature on Apple Silicon at
334
+ // ANY privilege level, so following it to the letter still yielded nothing.
335
+ assert.ok(!/TELEMETRY_SUDO_POWERMETRICS/.test(pm.detail));
336
+ assert.ok(!/sudoers/.test(pm.detail));
330
337
 
331
338
  // A non-darwin host is a DIFFERENT absence and says so.
332
339
  const linux = await collectPowermetrics({ platform: "linux" });
333
340
  assert.match(linux.detail, /platform linux/);
334
341
  });
335
342
 
336
- test("collectMachine surfaces the temperature failure as machine.tempDetail", async () => {
343
+ test("collectMachine surfaces a thermal failure as a NOTE, never as the value", async () => {
337
344
  const m = await collectMachine({
338
345
  os: fakeOs({}),
339
346
  execFile: fakeExecFile({}),
@@ -346,7 +353,11 @@ test("collectMachine surfaces the temperature failure as machine.tempDetail", as
346
353
  });
347
354
  assert.equal(m.tempC, undefined);
348
355
  assert.equal(m.tempSource, undefined);
349
- assert.match(m.tempDetail, /requires root/);
356
+ assert.equal(m.thermalState, undefined);
357
+ assert.equal(typeof m.tempDetail, "string");
358
+ // The note must not blame the operator's sudoers file for a limitation of the
359
+ // hardware — that is the message this whole rewrite exists to delete.
360
+ assert.ok(!/sudoers/.test(m.tempDetail));
350
361
  // And the cpuPct it DOES have is labelled as the estimate it is.
351
362
  assert.equal(m.cpuSource, "loadavg");
352
363
  });
@@ -380,6 +391,191 @@ test("collectPowermetrics tries the opt-in sudo path only when enabled", async (
380
391
  assert.ok(sudoCall >= 0);
381
392
  });
382
393
 
394
+ // ---------------------------------------------------------------------------
395
+ // Thermal — real °C without root, and a coarse state word that never fails.
396
+ //
397
+ // Every test here stubs the exec seam. NOTHING in this file forks `swift`,
398
+ // `powermetrics` or `pmset`; the probe's real output is pinned as fixture text.
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /** Verbatim stdout of `swift lib/telemetry/thermal-probe.swift` on a Mac16,11. */
402
+ const PROBE_OK = "thermalState=nominal\ntempC=60.76\ntempSensors=42\n";
403
+
404
+ /** Verbatim `sudo powermetrics --samplers thermal` — note: NO temperature line. */
405
+ const PM_THERMAL = [
406
+ "Machine model: Mac16,11",
407
+ "",
408
+ "**** Thermal pressure ****",
409
+ "",
410
+ "Current pressure level: Nominal",
411
+ "",
412
+ ].join("\n");
413
+
414
+ /** Route stdout by binary, so a test can say which rung of the ladder answers. */
415
+ const SWIFT = "/usr/bin/swift";
416
+ const SUDO = "/usr/bin/sudo";
417
+ const PMSET = "/usr/bin/pmset";
418
+
419
+ test("collectThermal reads a REAL °C unprivileged, via the IOHID probe", async () => {
420
+ // The headline fix. powermetrics cannot produce a die temperature on Apple
421
+ // Silicon even as root; IOHIDEventSystem hands it over to any uid.
422
+ const seen = [];
423
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
424
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
425
+ seen.push(cmd);
426
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
427
+ return { on() {} };
428
+ };
429
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
430
+ assert.equal(th.tempC, 60.76);
431
+ assert.equal(th.tempSource, "iohid");
432
+ assert.equal(th.thermalState, "nominal");
433
+ assert.equal(th.thermalSource, "processinfo");
434
+ // No root, no sudo, on the happy path.
435
+ assert.ok(!seen.includes(SUDO), "the °C path must never need sudo");
436
+ // The detail is an EXPLANATION riding alongside a real reading, not a
437
+ // substitute for one — the drawer prints the number and demotes this.
438
+ assert.equal(typeof th.detail, "string");
439
+ assert.match(th.detail, /IOHIDEventSystem/);
440
+ });
441
+
442
+ test("collectThermal falls back to the coarse STATE when there is no °C", async () => {
443
+ // The probe ran but the sensors gave nothing: state alone is still a useful,
444
+ // honest answer, and the field semantics say so rather than faking a number.
445
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
446
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
447
+ queueMicrotask(() => cb(null, cmd === SWIFT ? "thermalState=serious\n" : "", ""));
448
+ return { on() {} };
449
+ };
450
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
451
+ assert.equal(th.tempC, undefined);
452
+ assert.equal(th.thermalState, "serious");
453
+ assert.equal(th.thermalSource, "processinfo");
454
+ assert.match(th.detail, /no numeric °C/);
455
+ });
456
+
457
+ test("collectThermal uses sudo powermetrics for PRESSURE only, and only when opted in", async () => {
458
+ const seen = [];
459
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
460
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
461
+ seen.push(cmd);
462
+ // swift is absent on this host; sudo powermetrics answers.
463
+ queueMicrotask(() => cb(null, cmd === SUDO ? PM_THERMAL : "", ""));
464
+ return { on() {} };
465
+ };
466
+ const off = await collectThermal({
467
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
468
+ });
469
+ assert.ok(!seen.includes(SUDO), "must not shell out to sudo unless asked");
470
+ assert.equal(off.thermalState, undefined);
471
+
472
+ seen.length = 0;
473
+ const on = await collectThermal({
474
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
475
+ });
476
+ assert.ok(seen.includes(SUDO));
477
+ assert.equal(on.thermalState, "nominal");
478
+ assert.equal(on.thermalSource, "sudo-powermetrics");
479
+ // Root buys a pressure LEVEL and nothing more. If this ever starts asserting a
480
+ // tempC from powermetrics on arm64, the fixture is lying.
481
+ assert.equal(on.tempC, undefined);
482
+ assert.match(on.detail, /pressure/i);
483
+ });
484
+
485
+ test("collectThermal will NOT dress 'no thermal events on record' up as a live reading", async () => {
486
+ // `pmset -g therm` on a healthy Mac reports history, not temperature. Calling
487
+ // that "nominal" would be inventing a measurement nobody took.
488
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
489
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
490
+ const out = cmd === PMSET ? "Note: No thermal warning level has been recorded\n" : "";
491
+ queueMicrotask(() => cb(null, out, ""));
492
+ return { on() {} };
493
+ };
494
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
495
+ assert.equal(th.thermalState, undefined);
496
+ assert.equal(th.tempC, undefined);
497
+ assert.match(th.detail, /xcode-select --install/);
498
+
499
+ // But a REAL throttle event — a speed limit below 100 — is a live signal.
500
+ const throttling = (cmd, args, optsOrCb, maybeCb) => {
501
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
502
+ const out = cmd === PMSET ? "CPU_Speed_Limit \t= 60\n" : "";
503
+ queueMicrotask(() => cb(null, out, ""));
504
+ return { on() {} };
505
+ };
506
+ const hot = await collectThermal({ execFile: throttling, platform: "darwin", arch: "arm64" });
507
+ assert.equal(hot.thermalState, "serious");
508
+ assert.equal(hot.thermalSource, "pmset");
509
+ });
510
+
511
+ test("parseThermalProbe rejects the sensor that lies (-9201.14 °C)", () => {
512
+ // `PMU tdev2` returns garbage on real hardware. The probe filters it, and the
513
+ // collector band-checks AGAIN — trusting a subprocess's arithmetic is how an
514
+ // absurd number reaches a dashboard.
515
+ assert.deepEqual(parseThermalProbe("thermalState=nominal\ntempC=-9201.14\n"), {
516
+ thermalState: "nominal",
517
+ });
518
+ assert.equal(parseThermalProbe("tempC=900\n").tempC, undefined);
519
+ assert.equal(parseThermalProbe("tempC=61.2\n").tempC, 61.2);
520
+ // Unknown keys are ignored so the probe can grow a field without breaking us.
521
+ assert.equal(parseThermalProbe("tempC=61.2\nfuture=x\n").tempC, 61.2);
522
+ assert.deepEqual(parseThermalProbe(""), {});
523
+ assert.deepEqual(parseThermalProbe(null), {});
524
+ // Only the four real levels are accepted.
525
+ assert.equal(parseThermalProbe("thermalState=toasty\n").thermalState, undefined);
526
+ });
527
+
528
+ test("parseThermalPressure reads the only thermal fact powermetrics has on arm64", () => {
529
+ assert.equal(parseThermalPressure(PM_THERMAL), "nominal");
530
+ assert.equal(parseThermalPressure("Current pressure level: Critical"), "critical");
531
+ assert.equal(parseThermalPressure("no such block"), undefined);
532
+ assert.equal(parseThermalPressure(""), undefined);
533
+ });
534
+
535
+ test("parsePowermetrics computes CPU% from the PER-CLUSTER arm64 shape", () => {
536
+ // The old regex wanted a bare `CPU idle residency:` line. Apple Silicon never
537
+ // prints one — only per-core and per-cluster lines — so on every seat in the
538
+ // fleet this silently fell through and then blamed the operator's sudoers.
539
+ const arm = [
540
+ "**** Processor usage ****",
541
+ "E-Cluster idle residency: 78.34%",
542
+ "CPU 0 idle residency: 90.00%",
543
+ "CPU 1 idle residency: 80.00%",
544
+ "CPU 2 idle residency: 70.00%",
545
+ "CPU 3 idle residency: 60.00%",
546
+ ].join("\n");
547
+ const parsed = parsePowermetrics(arm);
548
+ assert.equal(parsed.cpuPct, 25); // 100 - mean(90,80,70,60)
549
+ // The Intel/total shape still wins outright where it exists.
550
+ assert.equal(parsePowermetrics("CPU Average idle residency: 78.0 %").cpuPct, 22);
551
+ });
552
+
553
+ test("collectMachine ships tempC + thermalState + the note TOGETHER", async () => {
554
+ // The old collector made the reading and the explanation MUTUALLY EXCLUSIVE
555
+ // (`else if`), which left hq with nothing to put in the value slot — so it
556
+ // printed a 118-character sudoers sentence in a column of "62°C" and "41%".
557
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
558
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
559
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
560
+ return { on() {} };
561
+ };
562
+ const m = await collectMachine({
563
+ os: fakeOs({}),
564
+ execFile: spy,
565
+ platform: "darwin",
566
+ arch: "arm64",
567
+ sudoPowermetrics: false,
568
+ disk: false,
569
+ inventory: false,
570
+ spend: false,
571
+ });
572
+ assert.equal(m.tempC, 60.8); // round1
573
+ assert.equal(m.tempSource, "iohid");
574
+ assert.equal(m.thermalState, "nominal");
575
+ assert.equal(m.thermalSource, "processinfo");
576
+ assert.equal(typeof m.tempDetail, "string");
577
+ });
578
+
383
579
  // ---------------------------------------------------------------------------
384
580
  // spend24h
385
581
  // ---------------------------------------------------------------------------
@@ -0,0 +1,119 @@
1
+ // thermal-probe.swift — the ONLY unprivileged thermal reading on Apple Silicon.
2
+ //
3
+ // ── WHY THIS FILE EXISTS ────────────────────────────────────────────────────
4
+ // The telemetry collector spent two months telling every operator that
5
+ // `powermetrics` "requires root … allowlist it NOPASSWD in sudoers". That
6
+ // advice was both futile and wrong:
7
+ //
8
+ // 1. `powermetrics` emits NO die temperature on Apple Silicon AT ALL — not
9
+ // unprivileged, not as root, not with `-A` (every sampler). Verified on a
10
+ // Mac mini M4 Pro (Mac16,11, macOS 24D70): `sudo powermetrics -A` returns
11
+ // 242 lines and `grep -i temperature` finds nothing. The only thermal fact
12
+ // in that output is `**** Thermal pressure **** / Current pressure level:
13
+ // Nominal`. So an operator who followed the advice to the letter would
14
+ // still get an em-dash.
15
+ // 2. Real °C IS available WITHOUT root — through IOHIDEventSystem, which is
16
+ // where the SoC's PMU temperature sensors actually publish. `ioreg` shows
17
+ // those sensors as HID service nodes but carries no values, which is why
18
+ // the registry looked like a dead end.
19
+ //
20
+ // This probe is invoked as `/usr/bin/swift thermal-probe.swift` and prints two
21
+ // `key=value` lines that `parseThermalProbe()` reads. It requires no root, no
22
+ // entitlement, and triggers no TCC prompt; verified working under `env -i` and
23
+ // detached from any tty, which is the LaunchAgent case the daemon actually runs
24
+ // in.
25
+ //
26
+ // ── THE TWO SIGNALS, AND WHY BOTH ───────────────────────────────────────────
27
+ // `thermalState` comes from ProcessInfo — a PUBLIC, always-present API. It is
28
+ // the floor: it cannot fail, needs no Xcode toolchain beyond running this
29
+ // script, and is a genuinely useful coarse answer (nominal/fair/serious/
30
+ // critical). `tempC` comes from the PRIVATE IOHIDEventSystem symbols; it is the
31
+ // precise answer when it is available. If the private path ever stops working
32
+ // on a future macOS, the state line still prints and the collector degrades to
33
+ // a coarse-but-honest reading rather than to nothing.
34
+ //
35
+ // Output (tempC/tempSensors omitted entirely when the sensor read yields nothing):
36
+ // thermalState=nominal
37
+ // tempC=61.24
38
+ // tempSensors=42
39
+
40
+ import Foundation
41
+ import IOKit
42
+
43
+ // ── 1. Thermal state: public API, cannot fail ───────────────────────────────
44
+ let word: String
45
+ switch ProcessInfo.processInfo.thermalState {
46
+ case .nominal: word = "nominal"
47
+ case .fair: word = "fair"
48
+ case .serious: word = "serious"
49
+ case .critical: word = "critical"
50
+ @unknown default: word = "unknown"
51
+ }
52
+ print("thermalState=\(word)")
53
+
54
+ // ── 2. Die temperature: private IOHIDEventSystem, unprivileged ──────────────
55
+ // Matching dictionary {PrimaryUsagePage: 0xff00, PrimaryUsage: 5} selects the
56
+ // AppleARMPMUTempSensor HID services; kIOHIDEventTypeTemperature = 15 and the
57
+ // float field is (type << 16).
58
+ let kTemperatureEvent: Int32 = 15
59
+ let kHIDPageAppleVendor = 0xff00
60
+ let kHIDUsageAppleVendorTemperatureSensor = 5
61
+
62
+ guard let iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW) else {
63
+ exit(0) // state line already printed; that is a complete, honest answer
64
+ }
65
+
66
+ typealias ClientCreateFn = @convention(c) (CFAllocator?) -> Unmanaged<AnyObject>?
67
+ typealias SetMatchingFn = @convention(c) (AnyObject, CFDictionary) -> Void
68
+ typealias CopyServicesFn = @convention(c) (AnyObject) -> Unmanaged<CFArray>?
69
+ typealias CopyEventFn = @convention(c) (AnyObject, Int32, Int32, Int32) -> Unmanaged<AnyObject>?
70
+ typealias FloatValueFn = @convention(c) (AnyObject, Int32) -> Double
71
+ typealias CopyPropertyFn = @convention(c) (AnyObject, CFString) -> Unmanaged<AnyObject>?
72
+
73
+ guard
74
+ let pCreate = dlsym(iokit, "IOHIDEventSystemClientCreate"),
75
+ let pSetMatching = dlsym(iokit, "IOHIDEventSystemClientSetMatching"),
76
+ let pCopyServices = dlsym(iokit, "IOHIDEventSystemClientCopyServices"),
77
+ let pCopyEvent = dlsym(iokit, "IOHIDServiceClientCopyEvent"),
78
+ let pFloatValue = dlsym(iokit, "IOHIDEventGetFloatValue"),
79
+ let pCopyProperty = dlsym(iokit, "IOHIDServiceClientCopyProperty")
80
+ else { exit(0) }
81
+
82
+ let clientCreate = unsafeBitCast(pCreate, to: ClientCreateFn.self)
83
+ let setMatching = unsafeBitCast(pSetMatching, to: SetMatchingFn.self)
84
+ let copyServices = unsafeBitCast(pCopyServices, to: CopyServicesFn.self)
85
+ let copyEvent = unsafeBitCast(pCopyEvent, to: CopyEventFn.self)
86
+ let floatValue = unsafeBitCast(pFloatValue, to: FloatValueFn.self)
87
+ let copyProperty = unsafeBitCast(pCopyProperty, to: CopyPropertyFn.self)
88
+
89
+ guard let client = clientCreate(kCFAllocatorDefault)?.takeRetainedValue() else { exit(0) }
90
+ setMatching(client, [
91
+ "PrimaryUsagePage": kHIDPageAppleVendor,
92
+ "PrimaryUsage": kHIDUsageAppleVendorTemperatureSensor,
93
+ ] as CFDictionary)
94
+ guard let services = copyServices(client)?.takeRetainedValue() as? [AnyObject] else { exit(0) }
95
+
96
+ // SOME SENSORS LIE. `PMU tdev2` reliably returns -9201.14 on this hardware, so
97
+ // every reading is band-checked before it is allowed to influence the answer.
98
+ // Of 70 matching services, 67 return a plausible value.
99
+ var dieTemps: [Double] = []
100
+ var anyTemps: [Double] = []
101
+ for service in services {
102
+ guard let event = copyEvent(service, kTemperatureEvent, 0, 0)?.takeRetainedValue() else { continue }
103
+ let value = floatValue(event, kTemperatureEvent << 16)
104
+ guard value.isFinite, value > 0, value < 150 else { continue }
105
+ anyTemps.append(value)
106
+ let name = (copyProperty(service, "Product" as CFString)?.takeRetainedValue() as? String) ?? ""
107
+ if name.contains("tdie") { dieTemps.append(value) }
108
+ }
109
+
110
+ // Prefer the SoC die sensors ("PMU tdie<n>"); fall back to whatever plausible
111
+ // sensors exist so a future chip with different naming still reports something.
112
+ let pool = dieTemps.isEmpty ? anyTemps : dieTemps
113
+ guard let hottest = pool.max() else { exit(0) }
114
+
115
+ // The MAX, not the mean: a fleet dashboard wants the worst core, because that
116
+ // is what throttles. Averaging 42 die sensors hides exactly the spike the
117
+ // operator is looking for.
118
+ print(String(format: "tempC=%.2f", hottest))
119
+ print("tempSensors=\(pool.count)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.6.2",
3
+ "version": "2.8.0",
4
4
  "description": "Cohort Agent SDK \u2014 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": {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * responder.test.mjs — conversation history behind holding messages / quick replies.
3
+ * Run: node --test scripts/daemon/responder.test.mjs
4
+ *
5
+ * `loadConversationHistory` used to build its candidate directories as
6
+ * `memory/interactions/slack/<id>` unconditionally, and its writer
7
+ * (`logInteraction`) early-returns for any service that is not Slack. A Cohort
8
+ * DM therefore had nothing written and nothing to read, so every holding
9
+ * message for Cohort was generated from the current message alone. A bare "?"
10
+ * produced "What's on your mind?" — indistinguishable from amnesia, and the
11
+ * CEO read it as exactly that (2026-08-13, dm-h001-a016).
12
+ *
13
+ * Cohort has an authoritative `messaging.history`. These tests pin that the
14
+ * cohort branch reads it, orders it oldest-first, and NEVER throws — the
15
+ * holding path must degrade to "no history" rather than take the send down.
16
+ */
17
+ "use strict";
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { loadConversationHistory } from "./responder.mjs";
26
+
27
+ const cohortItem = {
28
+ sender: "Dana Okafor",
29
+ service: "cohort",
30
+ channel: "dm-h001-a016",
31
+ channel_id: "cmql1802601iihhzpf774vwbk",
32
+ content: "?",
33
+ };
34
+
35
+ // Newest-first, the way messaging.history actually returns it.
36
+ const HISTORY = [
37
+ { id: "m3", createdAt: "2026-08-13T05:47:51Z", authorName: "Dana Okafor", body: "with that in mind, fix these issues you just noted please" },
38
+ { id: "m2", createdAt: "2026-08-12T13:52:53Z", authorName: "Isla Roselli", body: "Both are merged. #19 went in as 94f227c4." },
39
+ { id: "m1", createdAt: "2026-08-12T13:47:30Z", authorName: "Dana Okafor", body: "why haven't you merged? please go ahead" },
40
+ ];
41
+
42
+ test("cohort: reads messaging.history and renders it oldest-first", async () => {
43
+ const seen = [];
44
+ const text = await loadConversationHistory(cohortItem, {
45
+ fetchHistory: async (params, o) => {
46
+ seen.push({ params, hasCfg: Boolean(o && o.cfg) });
47
+ return HISTORY;
48
+ },
49
+ loadOrgConfig: () => ({ org: { cohort: { enabled: true, base: "https://x", token: "t" } } }),
50
+ });
51
+
52
+ assert.ok(text, "expected a transcript, got null");
53
+ assert.match(text, /Dana Okafor: why haven't you merged/);
54
+ assert.match(text, /Isla Roselli: Both are merged/);
55
+ assert.match(text, /Dana Okafor: with that in mind/);
56
+
57
+ // Oldest-first: the model reads a conversation, not a reversed one.
58
+ assert.ok(
59
+ text.indexOf("why haven't you merged") < text.indexOf("with that in mind"),
60
+ "history must be rendered oldest-first",
61
+ );
62
+
63
+ // The channel it asks for is the item's channel_id, and cfg is ALWAYS passed:
64
+ // fetchHistory() fails open to [] when cfg is omitted, which is silently
65
+ // indistinguishable from an empty channel. That is the bug this file exists for.
66
+ assert.equal(seen.length, 1);
67
+ assert.equal(seen[0].params.channel, "cmql1802601iihhzpf774vwbk");
68
+ assert.equal(seen[0].hasCfg, true, "cfg must be passed or fetchHistory returns [] silently");
69
+ });
70
+
71
+ test("cohort: an empty channel is null, not an empty transcript", async () => {
72
+ const text = await loadConversationHistory(cohortItem, {
73
+ fetchHistory: async () => [],
74
+ loadOrgConfig: () => ({}),
75
+ });
76
+ assert.equal(text, null);
77
+ });
78
+
79
+ test("cohort: a failing history call degrades to null and never throws", async () => {
80
+ const text = await loadConversationHistory(cohortItem, {
81
+ fetchHistory: async () => { throw new Error("cohort unreachable"); },
82
+ loadOrgConfig: () => ({}),
83
+ });
84
+ assert.equal(text, null);
85
+ });
86
+
87
+ test("cohort: the current message is not fed back as its own history", async () => {
88
+ const text = await loadConversationHistory(
89
+ { ...cohortItem, message_id: "m3" },
90
+ {
91
+ fetchHistory: async () => HISTORY,
92
+ loadOrgConfig: () => ({}),
93
+ },
94
+ );
95
+ assert.ok(text);
96
+ assert.doesNotMatch(text, /with that in mind/, "the inbound message itself must be excluded");
97
+ });
98
+
99
+ test("slack: still reads the local interaction mirror", async () => {
100
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
101
+ const dir = join(root, "memory", "interactions", "slack", "D099N1JGKRQ");
102
+ mkdirSync(dir, { recursive: true });
103
+ writeFileSync(
104
+ join(dir, "2026-08-13.jsonl"),
105
+ [
106
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "ship it" }),
107
+ JSON.stringify({ ts: "2026-08-13T05:01:00Z", from: "agent", content: "shipped" }),
108
+ ].join("\n"),
109
+ );
110
+
111
+ const text = await loadConversationHistory(
112
+ { sender: "Dana Okafor", service: "slack", channel: "D099N1JGKRQ", content: "?" },
113
+ { agentRepoDir: root },
114
+ );
115
+
116
+ assert.ok(text, "expected the slack mirror to still be read");
117
+ assert.match(text, /Dana Okafor: ship it/);
118
+ assert.match(text, /agent: shipped/);
119
+ });
120
+
121
+ test("cohort: falls back to the local mirror when the server is unreachable", async () => {
122
+ // Isla's original returned null outright for Cohort when messaging.history
123
+ // failed. Falling through to the mirror means a transient API blip degrades
124
+ // to cached context instead of to none — the mirror is a poor source of
125
+ // truth but a perfectly good backstop.
126
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
127
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
128
+ mkdirSync(dir, { recursive: true });
129
+ writeFileSync(
130
+ join(dir, "2026-08-13.jsonl"),
131
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "cached line" }),
132
+ );
133
+
134
+ const text = await loadConversationHistory(
135
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
136
+ {
137
+ agentRepoDir: root,
138
+ loadOrgConfig: () => ({}),
139
+ fetchHistory: async () => { throw new Error("network down"); },
140
+ },
141
+ );
142
+
143
+ assert.ok(text, "a server failure must not erase locally cached context");
144
+ assert.match(text, /Dana Okafor: cached line/);
145
+ });
146
+
147
+ test("cohort: server history wins over the local mirror", async () => {
148
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
149
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(
152
+ join(dir, "2026-08-13.jsonl"),
153
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "stale", content: "outdated mirror line" }),
154
+ );
155
+
156
+ const text = await loadConversationHistory(
157
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
158
+ {
159
+ agentRepoDir: root,
160
+ loadOrgConfig: () => ({}),
161
+ fetchHistory: async () => ([
162
+ { id: "m2", authorName: "Dana Okafor", body: "authoritative line", createdAt: "2026-08-13T06:00:00Z" },
163
+ ]),
164
+ },
165
+ );
166
+
167
+ assert.match(text, /authoritative line/, "the server is the source of truth");
168
+ assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
169
+ });
@@ -523,23 +523,105 @@ function loadUserProfile(sender) {
523
523
  // Load recent conversation history for context
524
524
  // ---------------------------------------------------------------------------
525
525
 
526
+ /** Cohort's message ids as this item carries them, so history can drop itself. */
527
+ function inboundMessageIds(item) {
528
+ const ids = new Set();
529
+ const add = (v) => { if (v) ids.add(String(v)); };
530
+ add(item.message_id);
531
+ // Inbox filenames prefix the service: "cohort-cmsr45u91…" → "cmsr45u91…".
532
+ if (item.id) { add(item.id); add(String(item.id).replace(/^[a-z]+-/, "")); }
533
+ // raw_ref is "cohort:dm:<messageId>:<seq>".
534
+ const m = typeof item.raw_ref === "string" ? item.raw_ref.match(/^cohort:[^:]+:([^:]+)/) : null;
535
+ if (m) add(m[1]);
536
+ return ids;
537
+ }
538
+
539
+ const COHORT_HISTORY_LIMIT = 12;
540
+ const COHORT_HISTORY_CHARS = 400;
541
+
542
+ /**
543
+ * Cohort history straight from the server, via the same `messaging.history` the
544
+ * replies are posted to.
545
+ *
546
+ * The local mirror below is a cache the agent populates itself, so it carries
547
+ * context only if the agent has already been talking AND logging correctly.
548
+ * When the write half broke (logInteraction early-returned for every non-Slack
549
+ * service), the read half silently returned nothing, memory/interactions/ was
550
+ * never even created, and holding messages were composed from the inbound
551
+ * message alone — a bare "?" came back as "What's on your mind?" mid-thread.
552
+ *
553
+ * Asking the authority that stores the message removes that coupling: a fresh
554
+ * agent, or one whose mirror was wiped, still answers with full context.
555
+ *
556
+ * Fails open to null on every path. No amount of history is worth failing a send.
557
+ */
558
+ async function loadCohortHistory(item, deps = {}) {
559
+ const channel = item.channel_id || item.channel || "";
560
+ if (!channel) return null;
561
+
562
+ try {
563
+ const fetchHistory = deps.fetchHistory
564
+ || (await import("../../lib/org/messaging.mjs")).fetchHistory;
565
+ const loadOrgConfig = deps.loadOrgConfig
566
+ || (await import("../../lib/org/client.mjs")).loadOrgConfig;
567
+ const agentRoot = deps.agentRepoDir || process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR;
568
+
569
+ // cfg is mandatory. configFromAgent() reads base+token ONLY from the object
570
+ // handed in — it never loads config/org.yaml — so omitting it returns a
571
+ // fail-open [] that looks exactly like an empty channel.
572
+ const messages = await fetchHistory(
573
+ { channel, limit: COHORT_HISTORY_LIMIT + 1 },
574
+ { cfg: loadOrgConfig(agentRoot), agentRoot },
575
+ );
576
+ if (!Array.isArray(messages) || messages.length === 0) return null;
577
+
578
+ const self = inboundMessageIds(item);
579
+ const rows = messages
580
+ .filter((m) => m && !self.has(String(m.id)))
581
+ .slice(0, COHORT_HISTORY_LIMIT)
582
+ .reverse() // messaging.history is newest-first; a conversation reads the other way
583
+ .map((m) => {
584
+ const from = m.authorName || m.author?.name || m.authorId || "unknown";
585
+ const text = String(m.body ?? m.content ?? "").trim();
586
+ return `[${m.createdAt || ""}] ${from}: ${text.substring(0, COHORT_HISTORY_CHARS)}`;
587
+ });
588
+
589
+ return rows.length > 0 ? rows.join("\n") : null;
590
+ } catch (err) {
591
+ console.warn(`[responder] cohort history unavailable for ${item.sender}: ${err.message}`);
592
+ return null;
593
+ }
594
+ }
595
+
526
596
  /**
527
597
  * The reader half of `logInteraction`. It looked ONLY under
528
598
  * `memory/interactions/slack/…`, so even once a Cohort exchange was written it
529
599
  * would not have been found. Both halves are now keyed on the item's service.
600
+ *
601
+ * Cohort asks the server first (see loadCohortHistory) and falls back to the
602
+ * local mirror, so a transient API failure degrades to cached context rather
603
+ * than to none.
530
604
  */
531
- function loadConversationHistory(item) {
605
+ export async function loadConversationHistory(item, deps = {}) {
532
606
  if (!item || !item.sender || !item.service) return null;
533
607
 
534
608
  const service = String(item.service);
609
+
610
+ if (service === "cohort") {
611
+ const remote = await loadCohortHistory(item, deps);
612
+ if (remote) return remote;
613
+ }
614
+ // Test seam: the mirror's location is a parameter, so a test can point at a
615
+ // tmpdir instead of mutating process.env for the whole worker.
616
+ const repoDir = deps.agentRepoDir || AGENT_REPO_DIR;
535
617
  const senderSlug = item.sender.replace(/\s+/g, "-").toLowerCase();
536
618
  const channelId = service === "slack"
537
619
  ? resolveSlackChannel(item)
538
620
  : (item.channel_id || item.channel || null);
539
621
 
540
622
  const candidateDirs = [];
541
- if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, String(channelId)));
542
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, `dm-${senderSlug}`));
623
+ if (channelId) candidateDirs.push(join(repoDir, "memory", "interactions", service, String(channelId)));
624
+ candidateDirs.push(join(repoDir, "memory", "interactions", service, `dm-${senderSlug}`));
543
625
 
544
626
  const entries = [];
545
627
  for (const dir of candidateDirs) {
@@ -621,7 +703,7 @@ Keep responses focused — 1-4 sentences for simple items, up to a short paragra
621
703
  Match the sender's tone and urgency level.
622
704
  ${profile ? `\nSender profile:\n${profile}` : ""}`;
623
705
 
624
- const conversationHistory = loadConversationHistory(item);
706
+ const conversationHistory = await loadConversationHistory(item);
625
707
 
626
708
  const userContent = [
627
709
  `From: ${item.sender} (${item.sender_privilege || "unknown"})`,