@phnx-labs/agents-cli 1.20.69 → 1.20.70

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.70
4
+
5
+ - **Fix `agents setup computer` / `agents computer setup` refusing to install a
6
+ valid downloaded helper.** The signature check read `codesign -dv` from stdout,
7
+ but that command writes its details to **stderr** on success — so the Team-ID
8
+ check saw an empty string, found no `TeamIdentifier`, and rejected every
9
+ validly-signed, notarized helper with "signed by unexpected Team (none)". It now
10
+ reads both streams via `spawnSync`. Verified end-to-end against the real
11
+ published `v1.20.69` release asset (download → sha256 → extract → codesign +
12
+ Team `2HTP252L87` + `spctl` notarization → install). Source:
13
+ `apps/cli/src/lib/computer/download.ts`.
14
+
15
+ - **The bundled macOS menu-bar helper is now a true universal binary on
16
+ Xcode-less release hosts.** `menubar/scripts/build.sh release` used
17
+ `swift build --arch arm64 --arch x86_64` (needs Xcode's xcbuild) and, on a
18
+ Command-Line-Tools-only host, silently fell back to a **single-arch** build —
19
+ shipping an arm64-only `MenubarHelper.app` in the tarball that could not run on
20
+ Intel Macs. It now builds each slice via `--triple` and `lipo`s them into one
21
+ universal binary, matching the computer helper. Source:
22
+ `apps/cli/menubar/scripts/build.sh`.
23
+
3
24
  ## 1.20.69
4
25
 
5
26
  - **Choose a safe account with `agents run <agent>@`.** A trailing `@` opens a
package/README.md CHANGED
@@ -432,13 +432,15 @@ agents sync --host gpu-box # make the remote machine current
432
432
  agents doctor --devices # readiness matrix for every registered device
433
433
  agents doctor --devices --json # machine-readable fleet readiness
434
434
  agents doctor --device mac-mini # same matrix, scoped to one device
435
- agents fleet status # warnings rollup + health/sync/version matrix
435
+ agents fleet status # warnings rollup + health/sync/version/Auth matrix (cache-first)
436
+ agents fleet status --live # force a live resource probe (alias of --refresh)
436
437
  agents fleet status --json --strict # scriptable fleet health gate
437
438
  agents check --devices # CI drift gate across every registered device
438
439
 
439
440
  # Your Tailscale fleet, auto-discovered
440
441
  agents devices sync # ingest `tailscale status`
441
- agents devices list # fleet + live headroom: load, mem, idle/busy — which box has room
442
+ agents devices list # fleet + headroom: load, mem, idle/busy — which box has room (cache-first)
443
+ agents devices list --live # force a live probe of every device (alias of --refresh)
442
444
  agents devices list --full # add per-device cores and free/total RAM
443
445
  agents devices list --no-stats # instant: names/addresses only, skip the probe
444
446
  agents ssh mac-mini # hardened SSH: fails fast if offline,
@@ -453,11 +455,22 @@ agents cloud run "nightly benchmark" --host gpu-box --agent claude # task in c
453
455
  agents routines add nightly -s "0 2 * * *" -a claude -p "run the sweep" --run-on gpu-box
454
456
  ```
455
457
 
456
- `agents devices list` probes every reachable box in parallel (bounded timeout, so a
457
- slow node degrades to `—` instead of hanging) and shows normalized load, memory
458
- pressure, and an idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
458
+ `agents devices list` shows normalized load, memory pressure, and an
459
+ idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
459
460
  (`164 cores · 421G free / 518G RAM`). It answers "which machine has room right now?" —
460
- the utilization signal the teammate scheduler doesn't yet see.
461
+ the utilization signal the teammate scheduler doesn't yet see. It's **cache-first**:
462
+ reads serve instantly from a stats cache the daemon warms (~every 3 min), probing only
463
+ this machine locally plus any device missing from the cache; pass `--refresh` (or the
464
+ shorter `--live`) to force a full live probe of every box. Cache-served output notes its
465
+ age (`updated 2m ago — pass --refresh (--live) for a live probe`).
466
+
467
+ `agents fleet status` adds an **Auth column** — which agent accounts are actually
468
+ logged in, per device, read from the auth-health cache (no network). Four buckets keep
469
+ it from crying wolf: `●live` (verified), `·present` (signed in but the agent has no
470
+ live-probe endpoint — e.g. codex/grok — benign), `◐degraded` (soft/self-healing:
471
+ expired-but-refreshing, rate-limited), and `○revoked` (server rejected — re-login now).
472
+ Only `○` means a real re-login is needed. Run `agents fleet ping` to force a live
473
+ re-verification across the fleet.
461
474
 
462
475
  **Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH and feed one host pool: devices appear in `agents hosts list` and capability routing without a second enrollment. On `--host` runs every `agents run` option is either forwarded (`--effort --env --timeout --loop …`), rejected loud (`--secrets` never crosses SSH implicitly), or consumed locally — nothing silently drops. See [docs/00-concepts.md](apps/cli/docs/00-concepts.md#devices--hosts).
463
476
 
package/dist/bin/agents CHANGED
Binary file
@@ -14,6 +14,7 @@ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalled
14
14
  import { loadManifest, isStale } from '../lib/staleness/index.js';
15
15
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
16
16
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
17
+ import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
17
18
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
18
19
  import { listCliStatus } from '../lib/cli-resources.js';
19
20
  import { setHelpSections } from '../lib/help.js';
@@ -741,6 +742,10 @@ export function registerDoctorCommand(program) {
741
742
  console.log(JSON.stringify({
742
743
  clis,
743
744
  signIn,
745
+ // Cached auth-health rollup for THIS host — lets `agents fleet status`
746
+ // show a live-verified Auth column from the same fan-out it already
747
+ // runs, without a separate fleet-wide `fleet ping`.
748
+ auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
744
749
  sync: syncRows,
745
750
  orphans: orphanRows,
746
751
  hostClis: {
@@ -31,14 +31,15 @@ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassSh
31
31
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
32
32
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
33
33
  import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
34
- import { fleetCapacity, fmtBytes, headroom, probeLocalStats, probeFleetStats, } from '../lib/devices/health.js';
34
+ import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
35
35
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetWarnings, } from '../lib/devices/health-report.js';
36
+ import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
36
37
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
37
38
  import { checkAllClis } from '../lib/teams/agents.js';
38
39
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
39
40
  import { sshExecAsync } from '../lib/ssh-exec.js';
40
41
  import { ALL_AGENT_IDS } from '../lib/agents.js';
41
- import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
42
+ import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
42
43
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
43
44
  * command is running on so it stands out from the rest of the tailnet. */
44
45
  function deviceSummary(d, isSelf = false) {
@@ -256,19 +257,23 @@ async function probeRemoteHealth(target) {
256
257
  clis: parsed.clis ?? {},
257
258
  sync: parsed.sync ?? [],
258
259
  orphans: parsed.orphans ?? [],
260
+ // The remote self-reports its own cached auth rollup (fresh via its daemon),
261
+ // so the Auth column is current without a prior fleet-wide `fleet ping`.
262
+ // Older remotes that don't emit it fall back to this host's cache below.
263
+ auth: parsed.auth,
259
264
  };
260
265
  }
261
266
  async function runFleetStatus(opts) {
262
267
  const reg = await loadDevices();
263
268
  const self = machineId();
269
+ const forceRefresh = Boolean(opts.refresh || opts.live);
264
270
  const planned = planFleetTargets(reg);
265
271
  const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
272
+ // Cache-first: serve remote stats from the daemon-warmed cache (instant),
273
+ // probe this machine locally, and only ssh out for missing/forced rows.
266
274
  const statsMap = opts.stats === false
267
275
  ? new Map()
268
- : await probeFleetStats(probeable, { selfName: self });
269
- if (opts.stats !== false && !statsMap.has(self)) {
270
- statsMap.set(self, await probeLocalStats(self));
271
- }
276
+ : (await loadFleetStats(probeable, { forceRefresh, selfName: self })).stats;
272
277
  const rows = [localHealthRow(self, statsMap.get(self))];
273
278
  const remoteTargets = remoteFleetTargets(planned, self)
274
279
  .map((t) => ({
@@ -300,6 +305,15 @@ async function runFleetStatus(opts) {
300
305
  });
301
306
  }
302
307
  }
308
+ // Auth column: remote rows already carry the host's self-reported rollup from
309
+ // its `doctor --json`. Fill the rest (this machine; older remotes that don't
310
+ // emit it) from this host's local cache — written by `agents fleet ping` and
311
+ // the daemon's local refresh. A never-probed host rolls up to "—". No network.
312
+ const authCache = readAuthHealthCache();
313
+ for (const row of rows) {
314
+ if (!row.auth)
315
+ row.auth = summarizeHostAuth(authCache, row.name);
316
+ }
303
317
  const report = buildFleetHealthReport(rows);
304
318
  if (opts.json) {
305
319
  console.log(JSON.stringify(report, null, 2));
@@ -550,6 +564,8 @@ Typical workflow:
550
564
  .description('List registered devices with platform, address, reachability, and live resource headroom.')
551
565
  .option('--json', 'output the registry as a JSON array (for scripts and hooks)')
552
566
  .option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
567
+ .option('--refresh', 'force a live probe of every device, bypassing the cache')
568
+ .option('--live', 'alias of --refresh (shorter to type)')
553
569
  .option('-f, --full', 'full mode: add per-device core count and free/total memory')
554
570
  .action(async (opts) => {
555
571
  const reg = await loadDevices();
@@ -564,18 +580,26 @@ Typical workflow:
564
580
  return;
565
581
  }
566
582
  const self = machineId();
583
+ const forceRefresh = Boolean(opts.refresh || opts.live);
567
584
  let statsMap;
585
+ let freshness;
568
586
  if (opts.stats !== false) {
569
- // Probe only reachable devices, in parallel, bounded by the per-probe
570
- // timeout a slow box degrades to "—", it never hangs the table.
587
+ // Cache-first: serve remote devices from the daemon-warmed cache
588
+ // (instant), probe this machine locally, and only ssh out for missing or
589
+ // forced (--refresh/--live) rows — so a warm read never hangs on a box.
571
590
  const probeable = planFleetTargets(reg)
572
591
  .filter((t) => !t.skip)
573
592
  .map((t) => t.device);
574
- const spinner = isInteractiveTerminal()
593
+ // Only spin when we'll actually ssh (forced, or a cold/partial cache).
594
+ const cache = readStatsCache();
595
+ const willSsh = forceRefresh || probeable.some((d) => d.name !== self && !cache[d.name]);
596
+ const spinner = willSsh && isInteractiveTerminal()
575
597
  ? ora(`Probing ${probeable.length} device${probeable.length === 1 ? '' : 's'}…`).start()
576
598
  : undefined;
577
599
  try {
578
- statsMap = await probeFleetStats(probeable, { selfName: self });
600
+ const res = await loadFleetStats(probeable, { forceRefresh, selfName: self });
601
+ statsMap = res.stats;
602
+ freshness = res;
579
603
  }
580
604
  finally {
581
605
  spinner?.stop();
@@ -584,6 +608,9 @@ Typical workflow:
584
608
  console.log(chalk.bold(`Devices (${names.length})`));
585
609
  for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
586
610
  console.log(line);
611
+ if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
612
+ console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
613
+ }
587
614
  });
588
615
  devicesCmd
589
616
  .command('status')
@@ -591,6 +618,8 @@ Typical workflow:
591
618
  .option('--json', 'output machine-readable JSON')
592
619
  .option('--strict', 'exit non-zero when any device has drift or is unreachable')
593
620
  .option('--no-stats', 'skip the live resource probe')
621
+ .option('--refresh', 'force a live probe of every device, bypassing the cache')
622
+ .option('--live', 'alias of --refresh (shorter to type)')
594
623
  .action(async (opts) => {
595
624
  await runFleetStatus(opts);
596
625
  });
@@ -123,6 +123,7 @@ export declare function viewAction(agentArg?: string, options?: {
123
123
  resources?: string | boolean;
124
124
  detailed?: boolean;
125
125
  refresh?: boolean;
126
+ live?: boolean;
126
127
  } & ViewSectionFilter): Promise<void>;
127
128
  /** Register the `agents view` command. */
128
129
  export declare function registerViewCommand(program: Command): void;
@@ -1400,6 +1400,8 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1400
1400
  * Exported for use by deprecated aliases.
1401
1401
  */
1402
1402
  export async function viewAction(agentArg, options) {
1403
+ // --live is a shorter-to-type alias of --refresh; both force a live probe.
1404
+ const forceRefresh = options?.refresh === true || options?.live === true;
1403
1405
  // --resources / --detailed imply --json (they only shape structured output).
1404
1406
  const explicitResources = options?.detailed === true || options?.resources !== undefined;
1405
1407
  const json = options?.json === true || explicitResources;
@@ -1440,7 +1442,7 @@ export async function viewAction(agentArg, options) {
1440
1442
  return;
1441
1443
  }
1442
1444
  // No argument: show all installed versions
1443
- await showInstalledVersions(undefined, { forceRefresh: options?.refresh === true });
1445
+ await showInstalledVersions(undefined, { forceRefresh });
1444
1446
  return;
1445
1447
  }
1446
1448
  // Parse agent@version syntax
@@ -1501,7 +1503,7 @@ export async function viewAction(agentArg, options) {
1501
1503
  }
1502
1504
  else {
1503
1505
  // Just agent name: show versions for that agent
1504
- await showInstalledVersions(agentId, { forceRefresh: options?.refresh === true });
1506
+ await showInstalledVersions(agentId, { forceRefresh });
1505
1507
  }
1506
1508
  }
1507
1509
  /** Register the `agents view` command. */
@@ -1512,6 +1514,7 @@ export function registerViewCommand(program) {
1512
1514
  .option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
1513
1515
  .option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
1514
1516
  .option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
1517
+ .option('--live', 'Alias of --refresh (shorter to type).')
1515
1518
  .option('--prune', 'Remove older installed versions that share an account with a newer installed version. Skips the global default.')
1516
1519
  .option('--dry-run', 'With --prune, show duplicate versions without deleting')
1517
1520
  .option('-y, --yes', 'Skip the prune confirmation prompt.')
@@ -49,6 +49,44 @@ export interface VerdictSummary {
49
49
  export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
50
50
  /** Verdicts that mean "this token was rejected by the server — re-login required". */
51
51
  export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
52
+ /**
53
+ * A host's rolled-up auth state for the `fleet status` Auth column.
54
+ *
55
+ * The four display buckets are deliberately finer-grained than
56
+ * {@link VerdictSummary}'s live/bad/warn: they separate "present but this agent
57
+ * has no live probe" (`unverified`) and "soft, self-healing expiry"
58
+ * (`expired`/`rate_limited`) from a genuine server rejection (`revoked`). The
59
+ * old three-bucket rollup lumped all of those into `warn` and the column painted
60
+ * them one alarming yellow — so a fleet of perfectly logged-in accounts on
61
+ * codex/grok/etc (which can NEVER be probed live) read as half-degraded. These
62
+ * buckets let the renderer show `unverified` as neutral and reserve red for the
63
+ * only verdict that actually means "re-login now" ({@link isDeadVerdict}).
64
+ */
65
+ export interface HostAuthSummary {
66
+ /** Live-verified accounts (a real 2xx). */
67
+ live: number;
68
+ /** Signed in but this agent has no live-probe endpoint — benign, neutral. */
69
+ present: number;
70
+ /** Soft/degraded: expired (self-healing) / rate_limited / error. Mild warning. */
71
+ degraded: number;
72
+ /** Server rejected the token — genuinely needs re-login. */
73
+ revoked: number;
74
+ /** Total cached rows for this host (0 → the renderer shows "—"). */
75
+ total: number;
76
+ /** Oldest `checkedAt` (epoch ms) among this host's cached rows, or null when none. */
77
+ oldestCheckedAt: number | null;
78
+ }
79
+ /**
80
+ * Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
81
+ * plus the age of its stalest entry. Pure — reads the map the caller already
82
+ * loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
83
+ * column without any network probe. A host with no cached rows yields an empty
84
+ * summary (total 0), which the renderer shows as "—".
85
+ *
86
+ * Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
87
+ * prefix so agent/version segments can never be mistaken for a host.
88
+ */
89
+ export declare function summarizeHostAuth(cache: Record<string, AuthHealth>, host: string): HostAuthSummary;
52
90
  /** Human "3m ago" style age for a checkedAt timestamp. */
53
91
  export declare function formatCheckedAge(checkedAt: number, now?: number): string;
54
92
  /**
@@ -5,9 +5,12 @@
5
5
  * The rest of the CLI reports "signed in" from a local heuristic — a credential
6
6
  * file is present and its email decodes — which cannot distinguish a good token
7
7
  * from a revoked-but-unexpired one. This module completes a real request per
8
- * (agent, account) and records the verdict in a small cache that `agents view`,
9
- * `agents fleet status`, and the run rotation all read. The daemon and
10
- * `agents fleet ping` are the writers; everyone else reads.
8
+ * (agent, account) and records the verdict in a small cache that `agents view`
9
+ * (per-version chip), `agents fleet status` (the per-host Auth column, via
10
+ * {@link summarizeHostAuth}), and the run rotation all read. The writers are
11
+ * the daemon (a periodic local refresh) and `agents fleet ping` (which also
12
+ * fans out to write remote hosts' rows into the local cache); everyone else
13
+ * reads.
11
14
  *
12
15
  * The network probes themselves live in lib/usage.ts (where the per-provider
13
16
  * token loaders + endpoints already are); this module classifies their result,
@@ -95,6 +98,48 @@ export function summarizeVerdicts(verdicts) {
95
98
  export function isDeadVerdict(verdict) {
96
99
  return verdict === 'revoked';
97
100
  }
101
+ /**
102
+ * Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
103
+ * plus the age of its stalest entry. Pure — reads the map the caller already
104
+ * loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
105
+ * column without any network probe. A host with no cached rows yields an empty
106
+ * summary (total 0), which the renderer shows as "—".
107
+ *
108
+ * Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
109
+ * prefix so agent/version segments can never be mistaken for a host.
110
+ */
111
+ export function summarizeHostAuth(cache, host) {
112
+ const prefix = `${host}:`;
113
+ let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
114
+ let oldest = null;
115
+ for (const [key, health] of Object.entries(cache)) {
116
+ if (!key.startsWith(prefix))
117
+ continue;
118
+ // `unconfigured` = no credential at all — not a probed account. Writers
119
+ // already drop these before they reach the cache; skip here too so a stray
120
+ // one never counts toward total or the freshness age (belt-and-suspenders).
121
+ if (health.verdict === 'unconfigured')
122
+ continue;
123
+ total++;
124
+ switch (health.verdict) {
125
+ case 'live':
126
+ live++;
127
+ break;
128
+ case 'unverified':
129
+ present++;
130
+ break; // signed in, no probe — benign
131
+ case 'revoked':
132
+ revoked++;
133
+ break; // server said no — re-login
134
+ default:
135
+ degraded++;
136
+ break; // expired / rate_limited / error — soft
137
+ }
138
+ if (oldest === null || health.checkedAt < oldest)
139
+ oldest = health.checkedAt;
140
+ }
141
+ return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
142
+ }
98
143
  /** Human "3m ago" style age for a checkedAt timestamp. */
99
144
  export function formatCheckedAge(checkedAt, now = Date.now()) {
100
145
  const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
@@ -29,6 +29,9 @@ export declare function macHelperAssetUrls(version: string): {
29
29
  zip: string;
30
30
  sha256: string;
31
31
  };
32
+ /** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
33
+ * emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
34
+ export declare function parseTeamId(codesignInfo: string): string | null;
32
35
  /**
33
36
  * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
34
37
  * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
@@ -13,7 +13,7 @@
13
13
  * A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
14
14
  * we extract it with `ditto -x -k` after the checksum passes.
15
15
  */
16
- import { execFileSync } from 'node:child_process';
16
+ import { execFileSync, spawnSync } from 'node:child_process';
17
17
  import * as fs from 'node:fs';
18
18
  import * as os from 'node:os';
19
19
  import * as path from 'node:path';
@@ -41,6 +41,11 @@ export function macHelperAssetUrls(version) {
41
41
  const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
42
42
  return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
43
43
  }
44
+ /** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
45
+ * emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
46
+ export function parseTeamId(codesignInfo) {
47
+ return codesignInfo.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1] ?? null;
48
+ }
44
49
  /**
45
50
  * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
46
51
  * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
@@ -55,17 +60,13 @@ export function verifyMacHelper(appPath) {
55
60
  throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
56
61
  }
57
62
  // 2. Team identity — must be our Developer ID, not some other valid signer.
58
- let info = '';
59
- try {
60
- info = execFileSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { stdio: 'pipe' }).toString();
61
- }
62
- catch (e) {
63
- // codesign -dv writes to stderr; capture whatever it emitted.
64
- info = (e.stderr?.toString() ?? '') + (e.stdout?.toString() ?? '');
65
- if (!info)
66
- throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
67
- }
68
- const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
63
+ // `codesign -dv` writes its details to STDERR even on success (exit 0), so we
64
+ // must read stderr, not stdout. spawnSync captures both streams regardless of
65
+ // exit code; execFileSync would return only (empty) stdout and the Team check
66
+ // would falsely reject every validly-signed helper.
67
+ const dv = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf8' });
68
+ const info = `${dv.stdout ?? ''}${dv.stderr ?? ''}`;
69
+ const team = parseTeamId(info);
69
70
  if (team !== EXPECTED_TEAM_ID) {
70
71
  throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
71
72
  }
@@ -625,6 +625,42 @@ export async function runDaemon() {
625
625
  };
626
626
  const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
627
627
  const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
628
+ // Fleet cache warm: keep the caches that `agents devices list`, `fleet status`,
629
+ // and `agents view` read cache-first actually fresh, so a default read never
630
+ // has to ssh out. Two cheap refreshes: (1) this host's auth-health verdicts
631
+ // (also feeds the `doctor --json` Auth rollup other hosts read), and (2) the
632
+ // fleet resource-stats cache (one bounded parallel probe of the tailnet). Both
633
+ // best-effort + overlap-guarded like the probes above. ~every 3 min, plus once
634
+ // ~60s after startup (staggered off launch).
635
+ let warmingFleetCache = false;
636
+ const runFleetCacheWarm = async () => {
637
+ if (warmingFleetCache)
638
+ return;
639
+ warmingFleetCache = true;
640
+ try {
641
+ const { machineId } = await import('./machine-id.js');
642
+ const self = machineId();
643
+ const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
644
+ const { getCliVersion } = await import('./version.js');
645
+ const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
646
+ writeFleetAuthRows(self, authRows);
647
+ const { loadDevices } = await import('./devices/registry.js');
648
+ const { planFleetTargets } = await import('./devices/fleet.js');
649
+ const { loadFleetStats } = await import('./devices/stats-cache.js');
650
+ const reg = await loadDevices();
651
+ const probeable = planFleetTargets(reg).filter((t) => !t.skip).map((t) => t.device);
652
+ const res = await loadFleetStats(probeable, { forceRefresh: true, selfName: self });
653
+ log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${res.stats.size} device stat(s)`);
654
+ }
655
+ catch (err) {
656
+ log('ERROR', `fleet cache warm failed: ${err.message}`);
657
+ }
658
+ finally {
659
+ warmingFleetCache = false;
660
+ }
661
+ };
662
+ const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
663
+ const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
628
664
  const handleReload = () => {
629
665
  log('INFO', 'Reloading jobs (SIGHUP)');
630
666
  scheduler.reloadAll();
@@ -657,6 +693,8 @@ export async function runDaemon() {
657
693
  clearTimeout(tmuxReconcileKickoff);
658
694
  clearInterval(launchHealthInterval);
659
695
  clearTimeout(launchHealthKickoff);
696
+ clearInterval(fleetCacheInterval);
697
+ clearTimeout(fleetCacheKickoff);
660
698
  hostedBroker?.close();
661
699
  removeDaemonPid();
662
700
  removeHeartbeat();
@@ -1,4 +1,5 @@
1
1
  import { type DeviceStats } from './health.js';
2
+ import { type HostAuthSummary } from '../auth-health.js';
2
3
  export interface FleetCliStatus {
3
4
  installed: boolean;
4
5
  path: string | null;
@@ -28,6 +29,9 @@ export interface FleetHealthRow {
28
29
  clis: Record<string, FleetCliStatus>;
29
30
  sync: FleetSyncStatus[];
30
31
  orphans: FleetOrphanStatus[];
32
+ /** Cached auth-health rollup for this host (the Auth column). Undefined when
33
+ * the host has never been probed (`agents fleet ping`) or the cache is cold. */
34
+ auth?: HostAuthSummary;
31
35
  }
32
36
  export interface FleetWarning {
33
37
  kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
@@ -44,3 +48,10 @@ export interface FleetHealthReport {
44
48
  export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
45
49
  export declare function renderFleetWarnings(report: FleetHealthReport): string[];
46
50
  export declare function renderFleetMatrix(report: FleetHealthReport): string[];
51
+ /**
52
+ * "as of …" line so cache-served output is honest about age and points at the
53
+ * refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
54
+ * cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
55
+ * table carries no timestamped data at all (nothing to date).
56
+ */
57
+ export declare function freshnessFooter(rows: FleetHealthRow[], now?: number): string | null;
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { padToWidth, terminalWidth, truncateToWidth } from '../session/width.js';
2
+ import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
3
3
  import { fmtBytes, headroom } from './health.js';
4
+ import { formatCheckedAge } from '../auth-health.js';
4
5
  function driftRows(row) {
5
6
  return row.sync.filter((s) => s.status !== 'fresh');
6
7
  }
@@ -121,6 +122,47 @@ function loadLabel(stats) {
121
122
  const mem = stats.memPercent === undefined ? '-' : `${Math.round(stats.memPercent)}%`;
122
123
  return `${load}/${mem}`;
123
124
  }
125
+ /**
126
+ * Compact per-host auth cell. Four buckets, deliberately distinct so the column
127
+ * doesn't cry wolf on healthy accounts:
128
+ * `●{live}` green — live-verified
129
+ * `·{present}` gray — signed in but no live probe (codex/grok/…): benign
130
+ * `◐{degraded}` yellow — soft/self-healing (expired/limited/error)
131
+ * `○{revoked}` red — server rejected the token: re-login now
132
+ * A host with no cached auth rows shows "—"; an unreachable/skipped row shows
133
+ * "-" like the other probe columns.
134
+ */
135
+ function authLabel(row) {
136
+ if (row.error || row.skipped)
137
+ return chalk.gray('-');
138
+ const s = row.auth;
139
+ if (!s || s.total === 0)
140
+ return chalk.gray('—');
141
+ const parts = [];
142
+ if (s.live > 0)
143
+ parts.push(chalk.green(`●${s.live}`));
144
+ if (s.present > 0)
145
+ parts.push(chalk.gray(`·${s.present}`));
146
+ if (s.degraded > 0)
147
+ parts.push(chalk.yellow(`◐${s.degraded}`));
148
+ if (s.revoked > 0)
149
+ parts.push(chalk.red(`○${s.revoked}`));
150
+ // All-zero can't happen (total > 0); but if only present/degraded exist we
151
+ // still lead with them — never show an empty cell for a probed host.
152
+ return parts.length > 0 ? parts.join(' ') : chalk.gray('—');
153
+ }
154
+ /** Oldest epoch-ms timestamp across rows for a field, or null when none present. */
155
+ function oldestAcross(rows, pick) {
156
+ let oldest = null;
157
+ for (const row of rows) {
158
+ const t = pick(row);
159
+ if (t == null)
160
+ continue;
161
+ if (oldest === null || t < oldest)
162
+ oldest = t;
163
+ }
164
+ return oldest;
165
+ }
124
166
  export function renderFleetWarnings(report) {
125
167
  if (report.warnings.length === 0)
126
168
  return [chalk.green('Fleet warnings: none')];
@@ -134,14 +176,19 @@ export function renderFleetMatrix(report) {
134
176
  return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
135
177
  const nameW = Math.min(22, Math.max(6, ...report.devices.map((r) => r.name.length)));
136
178
  const versionW = Math.min(14, Math.max(7, ...report.devices.map((r) => (r.version ?? '-').length)));
179
+ // Auth cells are variable-length (up to four space-separated buckets, e.g.
180
+ // `●2 ·3 ◐1 ○1`); size the column to the widest so a mixed-auth row can't
181
+ // overflow the fixed slot and shove every later column out of alignment.
182
+ const authW = Math.max(9, ...report.devices.map((r) => stringWidth(authLabel(r))));
137
183
  const width = terminalWidth();
138
184
  // 4 = leading " " + the per-row status glyph + its trailing space (rows prefix
139
185
  // ` ${statusGlyph} `; the header reserves the same 4 cols so every column lines up).
140
- const fixed = 4 + nameW + 2 + 8 + 2 + 9 + 2 + 9 + 2 + versionW + 2 + 9 + 2 + 9;
186
+ // Columns: Device, OS(8), Health(9), Sync(9), CLI(9), Auth(authW), Version, Load/Mem(9), then Note.
187
+ const fixed = 4 + nameW + 2 + 8 + 2 + 9 + 2 + 9 + 2 + 9 + 2 + authW + 2 + versionW + 2 + 9;
141
188
  const noteW = Math.max(12, width - fixed);
142
189
  const lines = [
143
190
  chalk.bold('Fleet status'),
144
- chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
191
+ chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Auth', authW)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
145
192
  ];
146
193
  for (const row of report.devices) {
147
194
  const note = row.error ?? row.skipped ?? (row.orphans.length > 0 ? `${row.orphans.length} orphaned version${row.orphans.length === 1 ? '' : 's'}` : '');
@@ -150,10 +197,32 @@ export function renderFleetMatrix(report) {
150
197
  `${padToWidth(headroomLabel(row), 9)} ` +
151
198
  `${padToWidth(driftLabel(row), 9)} ` +
152
199
  `${padToWidth(cliLabel(row), 9)} ` +
200
+ `${padToWidth(authLabel(row), authW)} ` +
153
201
  `${padToWidth(truncateToWidth(row.version ?? '-', versionW), versionW)} ` +
154
202
  `${padToWidth(loadLabel(row.stats), 9)} ` +
155
203
  chalk.gray(truncateToWidth(note || `free ${fmtBytes(row.stats?.memFreeBytes)}`, noteW)));
156
204
  }
157
- lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped'));
205
+ lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped · Auth ●live ·present ◐degraded ○revoked'));
206
+ const foot = freshnessFooter(report.devices);
207
+ if (foot)
208
+ lines.push(chalk.gray(foot));
158
209
  return lines;
159
210
  }
211
+ /**
212
+ * "as of …" line so cache-served output is honest about age and points at the
213
+ * refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
214
+ * cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
215
+ * table carries no timestamped data at all (nothing to date).
216
+ */
217
+ export function freshnessFooter(rows, now = Date.now()) {
218
+ const oldestStats = oldestAcross(rows, (r) => r.stats?.fetchedAt);
219
+ const oldestAuth = oldestAcross(rows, (r) => r.auth?.oldestCheckedAt);
220
+ const parts = [];
221
+ if (oldestStats != null)
222
+ parts.push(`stats ${formatCheckedAge(oldestStats, now)}`);
223
+ if (oldestAuth != null)
224
+ parts.push(`auth ${formatCheckedAge(oldestAuth, now)}`);
225
+ if (parts.length === 0)
226
+ return null;
227
+ return ` updated ${parts.join(' · ')} — pass --refresh (--live) for a live probe`;
228
+ }
@@ -0,0 +1,36 @@
1
+ import { probeFleetStats, probeLocalStats, type DeviceStats } from './health.js';
2
+ import type { DeviceProfile } from './registry.js';
3
+ /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
4
+ export declare function readStatsCache(): Record<string, DeviceStats>;
5
+ /**
6
+ * Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
7
+ * devices not in `entries` are preserved, so a partial probe (gap-fill, or a
8
+ * single-device refresh) never drops the rest of the fleet's cached stats.
9
+ */
10
+ export declare function writeStatsCache(entries: Record<string, DeviceStats>): void;
11
+ export interface FleetStatsResult {
12
+ /** name → stats for every requested device (cache-served + freshly probed). */
13
+ stats: Map<string, DeviceStats>;
14
+ /** Oldest `fetchedAt` among the returned rows, or null when empty. Drives the
15
+ * "as of …" freshness note. */
16
+ oldestFetchedAt: number | null;
17
+ /** True when at least one row was served from cache rather than probed this call. */
18
+ servedFromCache: boolean;
19
+ }
20
+ export interface LoadFleetStatsOptions {
21
+ /** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
22
+ forceRefresh?: boolean;
23
+ /** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
24
+ selfName?: string;
25
+ /** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
26
+ probeFleet?: typeof probeFleetStats;
27
+ probeLocal?: typeof probeLocalStats;
28
+ readCache?: typeof readStatsCache;
29
+ writeCache?: typeof writeStatsCache;
30
+ }
31
+ /**
32
+ * Load fleet stats cache-first. See the module doc for the default vs
33
+ * `--refresh` behaviour. Never throws — an unreachable box degrades to a
34
+ * `reachable: false` row exactly as the live probe does.
35
+ */
36
+ export declare function loadFleetStats(devices: DeviceProfile[], opts?: LoadFleetStatsOptions): Promise<FleetStatsResult>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Disk cache for fleet {@link DeviceStats} so `agents devices list` and
3
+ * `agents fleet status` render instantly from the last probe instead of
4
+ * live-SSHing every registered box on every invocation.
5
+ *
6
+ * The old behaviour probed the whole fleet over ssh on every call
7
+ * ({@link probeFleetStats}) — with a dozen devices, a few of them cold or
8
+ * timing out, that turned a status glance into a multi-second hang. This module
9
+ * makes the reads cache-first:
10
+ *
11
+ * - **Default:** serve remote devices from the cache (instant); always probe
12
+ * *this* machine locally (no ssh, sub-ms) so the "this machine" row is live;
13
+ * probe only the remote devices missing from the cache (first run / a
14
+ * newly-added box), then persist them.
15
+ * - **`--refresh` / `--live`:** skip the cache and live-probe every device,
16
+ * rewriting the cache.
17
+ *
18
+ * The daemon warms this cache (~every 3 min, see `lib/daemon.ts
19
+ * runFleetCacheWarm`), so in steady state a default read has zero
20
+ * remote ssh round-trips and returns immediately with data that is at most a
21
+ * few minutes old — surfaced to the user as an "as of …" freshness note.
22
+ */
23
+ import * as fs from 'fs';
24
+ import * as path from 'path';
25
+ import { getCacheDir } from '../state.js';
26
+ import { probeFleetStats, probeLocalStats } from './health.js';
27
+ const CACHE_FILE = '.fleet-stats.json';
28
+ function cacheFilePath() {
29
+ return path.join(getCacheDir(), CACHE_FILE);
30
+ }
31
+ /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
32
+ export function readStatsCache() {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
35
+ if (parsed && parsed.entries && typeof parsed.entries === 'object')
36
+ return parsed.entries;
37
+ }
38
+ catch {
39
+ // missing or corrupt — treat as empty
40
+ }
41
+ return {};
42
+ }
43
+ /**
44
+ * Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
45
+ * devices not in `entries` are preserved, so a partial probe (gap-fill, or a
46
+ * single-device refresh) never drops the rest of the fleet's cached stats.
47
+ */
48
+ export function writeStatsCache(entries) {
49
+ try {
50
+ const dir = getCacheDir();
51
+ if (!fs.existsSync(dir))
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ const merged = {
54
+ version: 1,
55
+ entries: { ...readStatsCache(), ...entries },
56
+ };
57
+ fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
58
+ }
59
+ catch {
60
+ // best-effort; a failed write just means the next read falls back to a live probe
61
+ }
62
+ }
63
+ /**
64
+ * Load fleet stats cache-first. See the module doc for the default vs
65
+ * `--refresh` behaviour. Never throws — an unreachable box degrades to a
66
+ * `reachable: false` row exactly as the live probe does.
67
+ */
68
+ export async function loadFleetStats(devices, opts = {}) {
69
+ const probeFleet = opts.probeFleet ?? probeFleetStats;
70
+ const probeLocal = opts.probeLocal ?? probeLocalStats;
71
+ const readCache = opts.readCache ?? readStatsCache;
72
+ const writeCache = opts.writeCache ?? writeStatsCache;
73
+ const self = opts.selfName;
74
+ const cache = opts.forceRefresh ? {} : readCache();
75
+ const stats = new Map();
76
+ const toProbe = [];
77
+ let servedFromCache = false;
78
+ for (const d of devices) {
79
+ if (d.name === self) {
80
+ // This machine is always probed locally — cheap, no ssh, always live.
81
+ toProbe.push(d);
82
+ continue;
83
+ }
84
+ const cached = cache[d.name];
85
+ if (cached) {
86
+ stats.set(d.name, cached);
87
+ servedFromCache = true;
88
+ }
89
+ else {
90
+ toProbe.push(d);
91
+ }
92
+ }
93
+ if (toProbe.length > 0) {
94
+ const probed = await probeFleet(toProbe, { selfName: self });
95
+ const fresh = {};
96
+ for (const [name, s] of probed) {
97
+ stats.set(name, s);
98
+ fresh[name] = s;
99
+ }
100
+ if (Object.keys(fresh).length > 0)
101
+ writeCache(fresh);
102
+ }
103
+ // Guarantee a row for this machine even when it isn't in the passed device
104
+ // list (e.g. self not registered as an ssh target) — matches the old callers'
105
+ // explicit local fallback.
106
+ if (self && !stats.has(self)) {
107
+ stats.set(self, await probeLocal(self));
108
+ }
109
+ let oldest = null;
110
+ for (const s of stats.values()) {
111
+ if (oldest === null || s.fetchedAt < oldest)
112
+ oldest = s.fetchedAt;
113
+ }
114
+ return { stats, oldestFetchedAt: oldest, servedFromCache };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.69",
3
+ "version": "1.20.70",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",