@phnx-labs/agents-cli 1.20.69 → 1.20.71

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +19 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +36 -3
  5. package/dist/commands/check.js +3 -1
  6. package/dist/commands/doctor.js +5 -0
  7. package/dist/commands/exec.js +12 -0
  8. package/dist/commands/fleet-capture.d.ts +14 -0
  9. package/dist/commands/fleet-capture.js +107 -0
  10. package/dist/commands/fork.js +13 -7
  11. package/dist/commands/hosts.js +11 -0
  12. package/dist/commands/secrets.d.ts +1 -0
  13. package/dist/commands/secrets.js +24 -4
  14. package/dist/commands/sessions-tail.js +1 -2
  15. package/dist/commands/sessions.js +6 -6
  16. package/dist/commands/ssh.js +63 -16
  17. package/dist/commands/usage.d.ts +13 -0
  18. package/dist/commands/usage.js +50 -28
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +5 -2
  21. package/dist/lib/auth-health.d.ts +38 -0
  22. package/dist/lib/auth-health.js +48 -3
  23. package/dist/lib/commands.d.ts +10 -0
  24. package/dist/lib/commands.js +31 -7
  25. package/dist/lib/computer/download.d.ts +3 -0
  26. package/dist/lib/computer/download.js +13 -12
  27. package/dist/lib/daemon.js +38 -0
  28. package/dist/lib/devices/connect.d.ts +13 -0
  29. package/dist/lib/devices/connect.js +20 -0
  30. package/dist/lib/devices/fleet.d.ts +36 -3
  31. package/dist/lib/devices/fleet.js +42 -4
  32. package/dist/lib/devices/health-report.d.ts +11 -0
  33. package/dist/lib/devices/health-report.js +73 -4
  34. package/dist/lib/devices/stats-cache.d.ts +36 -0
  35. package/dist/lib/devices/stats-cache.js +115 -0
  36. package/dist/lib/devices/sync.d.ts +30 -0
  37. package/dist/lib/devices/sync.js +50 -0
  38. package/dist/lib/doctor-diff.js +38 -16
  39. package/dist/lib/fleet/apply.d.ts +4 -0
  40. package/dist/lib/fleet/apply.js +28 -5
  41. package/dist/lib/fleet/capture.d.ts +35 -0
  42. package/dist/lib/fleet/capture.js +51 -0
  43. package/dist/lib/fleet/manifest.d.ts +6 -0
  44. package/dist/lib/fleet/manifest.js +24 -1
  45. package/dist/lib/fleet/types.d.ts +24 -1
  46. package/dist/lib/git.d.ts +14 -0
  47. package/dist/lib/git.js +39 -1
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  49. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  50. package/dist/lib/menubar/install-menubar.js +26 -13
  51. package/dist/lib/secrets/agent.d.ts +5 -0
  52. package/dist/lib/secrets/agent.js +35 -3
  53. package/dist/lib/secrets/bundles.js +28 -0
  54. package/dist/lib/secrets/index.d.ts +14 -0
  55. package/dist/lib/secrets/index.js +36 -5
  56. package/dist/lib/secrets/session-store.d.ts +90 -0
  57. package/dist/lib/secrets/session-store.js +221 -0
  58. package/dist/lib/session/render.d.ts +9 -1
  59. package/dist/lib/session/render.js +35 -4
  60. package/dist/lib/share/capture.d.ts +2 -0
  61. package/dist/lib/share/capture.js +12 -5
  62. package/dist/lib/skills.d.ts +8 -1
  63. package/dist/lib/skills.js +11 -1
  64. package/dist/lib/types.d.ts +5 -1
  65. package/package.json +1 -1
@@ -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();
@@ -8,6 +8,19 @@ export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
8
8
  * it against the shared injection guard. Throws if the device has no address.
9
9
  */
10
10
  export declare function sshTargetFor(device: DeviceProfile): string;
11
+ /**
12
+ * The address a fleet fan-out probe should hand to `ssh` for a device: the
13
+ * registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
14
+ * present, else the bare name (an address-less manual device dials by name as
15
+ * before — never worse than the old behaviour).
16
+ *
17
+ * Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
18
+ * it: dialing the bare device name lets ssh resolve it through the user's config,
19
+ * where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
20
+ * shadows the correct entry, times out, and makes a reachable box look dead.
21
+ * Pure/testable.
22
+ */
23
+ export declare function fleetDialTarget(device: DeviceProfile): string;
11
24
  /**
12
25
  * Wrap a remote command for the device's shell. Windows devices speak
13
26
  * PowerShell, so a bare command is run through `powershell -NoProfile
@@ -38,6 +38,26 @@ export function sshTargetFor(device) {
38
38
  assertValidSshTarget(target);
39
39
  return target;
40
40
  }
41
+ /**
42
+ * The address a fleet fan-out probe should hand to `ssh` for a device: the
43
+ * registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
44
+ * present, else the bare name (an address-less manual device dials by name as
45
+ * before — never worse than the old behaviour).
46
+ *
47
+ * Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
48
+ * it: dialing the bare device name lets ssh resolve it through the user's config,
49
+ * where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
50
+ * shadows the correct entry, times out, and makes a reachable box look dead.
51
+ * Pure/testable.
52
+ */
53
+ export function fleetDialTarget(device) {
54
+ try {
55
+ return sshTargetFor(device);
56
+ }
57
+ catch {
58
+ return device.user ? `${device.user}@${device.name}` : device.name;
59
+ }
60
+ }
41
61
  /**
42
62
  * Wrap a remote command for the device's shell. Windows devices speak
43
63
  * PowerShell, so a bare command is run through `powershell -NoProfile
@@ -70,17 +70,50 @@ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?:
70
70
  stdout: string;
71
71
  stderr: string;
72
72
  };
73
+ /**
74
+ * Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
75
+ * self target: a box frequently can't ssh to itself (no self-authorized key, as
76
+ * `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
77
+ * `agents upgrade` etc. runs identically as a local process. Mirrors
78
+ * {@link runOnDevice}'s return shape and never throws. The argv is space-joined
79
+ * and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
80
+ * `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
81
+ * powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
82
+ * Windows self runs under the default OS shell (cmd.exe), which still resolves
83
+ * `agents` on PATH for the only self commands that matter (`agents upgrade …`).
84
+ */
85
+ export declare function runLocalCommand(cmd: string[], opts?: {
86
+ timeoutMs?: number;
87
+ }): {
88
+ code: number | null;
89
+ stdout: string;
90
+ stderr: string;
91
+ };
73
92
  /**
74
93
  * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
75
94
  * Rejects anything that is not a plain npm version/tag token so a version pin
76
95
  * cannot inject shell metacharacters into the remote command line.
77
96
  */
78
97
  export declare function upgradeCommand(version?: string): string[];
98
+ export interface RunFleetOptions {
99
+ /**
100
+ * Name of THIS machine. Its target runs the command **locally** (no ssh) — a
101
+ * box can't reliably ssh to itself and doesn't need to. Omit to ssh every
102
+ * target (the old behaviour). Callers pass `machineId()`.
103
+ */
104
+ self?: string;
105
+ /** Injectable ssh runner (tests). */
106
+ runner?: typeof runOnDevice;
107
+ /** Injectable local runner (tests). */
108
+ localRunner?: typeof runLocalCommand;
109
+ }
79
110
  /**
80
111
  * Execute a command across planned targets. Pure orchestration over
81
- * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
82
- * the runner are recorded as `failed` so one bad device never aborts the rest.
112
+ * {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
113
+ * The `self` target runs locally so `agents fleet update` upgrades this machine
114
+ * too instead of failing to ssh to itself. Per-device throws from a runner are
115
+ * recorded as `failed` so one bad device never aborts the rest.
83
116
  */
84
- export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
117
+ export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
85
118
  /** Run one async probe per device in parallel, preserving input order. */
86
119
  export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
@@ -94,6 +94,39 @@ export function runOnDevice(device, cmd, opts = {}) {
94
94
  };
95
95
  }
96
96
  }
97
+ /**
98
+ * Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
99
+ * self target: a box frequently can't ssh to itself (no self-authorized key, as
100
+ * `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
101
+ * `agents upgrade` etc. runs identically as a local process. Mirrors
102
+ * {@link runOnDevice}'s return shape and never throws. The argv is space-joined
103
+ * and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
104
+ * `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
105
+ * powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
106
+ * Windows self runs under the default OS shell (cmd.exe), which still resolves
107
+ * `agents` on PATH for the only self commands that matter (`agents upgrade …`).
108
+ */
109
+ export function runLocalCommand(cmd, opts = {}) {
110
+ try {
111
+ const res = spawnSync(cmd.join(' '), {
112
+ shell: true,
113
+ encoding: 'utf-8',
114
+ timeout: opts.timeoutMs ?? 600_000,
115
+ });
116
+ return {
117
+ code: res.status,
118
+ stdout: res.stdout?.toString() ?? '',
119
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
120
+ };
121
+ }
122
+ catch (err) {
123
+ return {
124
+ code: 1,
125
+ stdout: '',
126
+ stderr: err instanceof Error ? err.message : String(err),
127
+ };
128
+ }
129
+ }
97
130
  /**
98
131
  * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
99
132
  * Rejects anything that is not a plain npm version/tag token so a version pin
@@ -110,10 +143,14 @@ export function upgradeCommand(version) {
110
143
  }
111
144
  /**
112
145
  * Execute a command across planned targets. Pure orchestration over
113
- * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
114
- * the runner are recorded as `failed` so one bad device never aborts the rest.
146
+ * {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
147
+ * The `self` target runs locally so `agents fleet update` upgrades this machine
148
+ * too instead of failing to ssh to itself. Per-device throws from a runner are
149
+ * recorded as `failed` so one bad device never aborts the rest.
115
150
  */
116
- export function runFleet(targets, cmd, runner = runOnDevice) {
151
+ export function runFleet(targets, cmd, opts = {}) {
152
+ const runner = opts.runner ?? runOnDevice;
153
+ const localRunner = opts.localRunner ?? runLocalCommand;
117
154
  const results = [];
118
155
  for (const t of targets) {
119
156
  if (t.skip) {
@@ -126,7 +163,8 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
126
163
  continue;
127
164
  }
128
165
  try {
129
- const res = runner(t.device, cmd);
166
+ const isSelf = opts.self !== undefined && t.device.name === opts.self;
167
+ const res = isSelf ? localRunner(cmd) : runner(t.device, cmd);
130
168
  const ok = res.code === 0;
131
169
  const detail = (res.stderr || res.stdout).trim().slice(0, 200);
132
170
  results.push({
@@ -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
+ }
@@ -69,6 +69,36 @@ export declare function runDeviceSync(opts?: {
69
69
  soft?: boolean;
70
70
  mode?: DeviceSyncMode;
71
71
  }): Promise<DeviceSyncResult>;
72
+ export interface EnsureDevicesResult {
73
+ /** Names newly resolved from Tailscale and upserted into the registry. */
74
+ registered: string[];
75
+ /** Names that could not be resolved (not on the tailnet / tailscale absent). */
76
+ unresolved: string[];
77
+ }
78
+ /**
79
+ * Pure decision for `ensureDevicesRegistered`: split the wanted names into those
80
+ * to resolve+register (missing from the registry, present on the tailnet, not
81
+ * ignored) vs. unresolved (missing and either off-tailnet or ignored). Already-
82
+ * registered names need nothing. Pure so the bootstrap's filter matrix is
83
+ * unit-testable without a tailnet or registry writes.
84
+ */
85
+ export interface WantedPartition {
86
+ toRegister: string[];
87
+ unresolved: string[];
88
+ }
89
+ export declare function partitionWantedDevices(wanted: string[], registered: Set<string>, tailscaleNames: Set<string>, ignored: Set<string>): WantedPartition;
90
+ /**
91
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
92
+ * manifest wants, register any that aren't in the local registry by resolving
93
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
94
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
95
+ * connection details. Soft by design: a missing tailscale binary or an offline
96
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
97
+ * devices aren't reachable yet" instead of aborting. Reuses the same
98
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
99
+ * is identical to a synced one.
100
+ */
101
+ export declare function ensureDevicesRegistered(wantedNames: string[]): Promise<EnsureDevicesResult>;
72
102
  /**
73
103
  * The register/remove/ignore decision for the interactive curation picker.
74
104
  * Pure so the highest-risk reconcile logic is unit-testable without a tailnet
@@ -125,6 +125,56 @@ export async function runDeviceSync(opts = {}) {
125
125
  throw err;
126
126
  }
127
127
  }
128
+ export function partitionWantedDevices(wanted, registered, tailscaleNames, ignored) {
129
+ const out = { toRegister: [], unresolved: [] };
130
+ for (const name of wanted) {
131
+ if (registered.has(name))
132
+ continue;
133
+ if (tailscaleNames.has(name) && !ignored.has(name))
134
+ out.toRegister.push(name);
135
+ else
136
+ out.unresolved.push(name);
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
142
+ * manifest wants, register any that aren't in the local registry by resolving
143
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
144
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
145
+ * connection details. Soft by design: a missing tailscale binary or an offline
146
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
147
+ * devices aren't reachable yet" instead of aborting. Reuses the same
148
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
149
+ * is identical to a synced one.
150
+ */
151
+ export async function ensureDevicesRegistered(wantedNames) {
152
+ const registryBefore = await loadDevices();
153
+ const registered = new Set(Object.keys(registryBefore));
154
+ const missing = wantedNames.filter((n) => !registered.has(n));
155
+ if (missing.length === 0)
156
+ return { registered: [], unresolved: [] };
157
+ let nodes;
158
+ try {
159
+ nodes = parseTailscaleStatus(tailscaleStatusJson());
160
+ }
161
+ catch {
162
+ // Tailscale absent/unreachable — nothing resolvable; report all missing.
163
+ return { registered: [], unresolved: missing };
164
+ }
165
+ const ignored = await loadIgnored();
166
+ const tailscaleNames = new Set(nodes.map((n) => n.name));
167
+ const { toRegister, unresolved } = partitionWantedDevices(missing, registered, tailscaleNames, ignored);
168
+ const byName = new Map(nodes.map((n) => [n.name, n]));
169
+ const localUser = localLoginUser();
170
+ const done = [];
171
+ for (const name of toRegister) {
172
+ const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), registryBefore[name]?.user, localUser);
173
+ await upsertDevice(name, input);
174
+ done.push(name);
175
+ }
176
+ return { registered: done, unresolved };
177
+ }
128
178
  export function planDeviceReconciliation(allNames, keep, registered, ignored) {
129
179
  const keepSet = new Set(keep);
130
180
  const regSet = new Set(registered);