@phnx-labs/agents-cli 1.20.68 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +30 -7
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer.d.ts +26 -0
  5. package/dist/commands/computer.js +173 -149
  6. package/dist/commands/doctor.js +5 -0
  7. package/dist/commands/exec.d.ts +18 -0
  8. package/dist/commands/exec.js +88 -6
  9. package/dist/commands/fork.d.ts +9 -0
  10. package/dist/commands/fork.js +67 -0
  11. package/dist/commands/run-account-picker.d.ts +14 -0
  12. package/dist/commands/run-account-picker.js +131 -0
  13. package/dist/commands/setup-browser.d.ts +18 -0
  14. package/dist/commands/setup-browser.js +142 -0
  15. package/dist/commands/setup-computer.d.ts +19 -0
  16. package/dist/commands/setup-computer.js +133 -0
  17. package/dist/commands/setup-share.d.ts +17 -0
  18. package/dist/commands/setup-share.js +85 -0
  19. package/dist/commands/setup.d.ts +1 -1
  20. package/dist/commands/setup.js +58 -1
  21. package/dist/commands/share.d.ts +15 -0
  22. package/dist/commands/share.js +10 -4
  23. package/dist/commands/ssh.js +202 -9
  24. package/dist/commands/view.d.ts +4 -14
  25. package/dist/commands/view.js +32 -30
  26. package/dist/index.js +2 -1
  27. package/dist/lib/agents.d.ts +10 -0
  28. package/dist/lib/agents.js +32 -0
  29. package/dist/lib/auth-health.d.ts +141 -0
  30. package/dist/lib/auth-health.js +277 -0
  31. package/dist/lib/browser/chrome.d.ts +9 -0
  32. package/dist/lib/browser/chrome.js +22 -0
  33. package/dist/lib/computer/download.d.ts +54 -0
  34. package/dist/lib/computer/download.js +146 -0
  35. package/dist/lib/computer-rpc.js +9 -3
  36. package/dist/lib/daemon.js +38 -0
  37. package/dist/lib/devices/connect.d.ts +15 -0
  38. package/dist/lib/devices/connect.js +17 -5
  39. package/dist/lib/devices/health-report.d.ts +11 -0
  40. package/dist/lib/devices/health-report.js +73 -4
  41. package/dist/lib/devices/stats-cache.d.ts +36 -0
  42. package/dist/lib/devices/stats-cache.js +115 -0
  43. package/dist/lib/devices/terminfo.d.ts +44 -0
  44. package/dist/lib/devices/terminfo.js +167 -0
  45. package/dist/lib/rotate.d.ts +12 -8
  46. package/dist/lib/rotate.js +22 -23
  47. package/dist/lib/session/fork.d.ts +32 -0
  48. package/dist/lib/session/fork.js +101 -0
  49. package/dist/lib/startup/command-registry.d.ts +1 -0
  50. package/dist/lib/startup/command-registry.js +2 -0
  51. package/dist/lib/usage.d.ts +23 -0
  52. package/dist/lib/usage.js +84 -0
  53. package/package.json +1 -1
@@ -163,14 +163,20 @@ export function writeComputerPeers(allowedExecPaths) {
163
163
  }
164
164
  fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
165
165
  }
166
- // Resolve the helper executable inside the dist .app bundle. Used by the
167
- // stdio fallback and by install-helper to find the source bundle.
166
+ // Resolve the helper executable inside the dist .app bundle. Used by the stdio
167
+ // fallback and by install-helper to find the source bundle. Only TRUSTED sources
168
+ // are listed here: a local checkout build and the bundled tarball copy. The
169
+ // download cache is deliberately NOT a candidate — a cached bundle is
170
+ // user-writable and must only ever be read through downloadMacHelperApp(), which
171
+ // re-verifies its signature + Team ID + notarization on every cache hit
172
+ // (lib/computer/download.ts). Listing the cache here would let a same-user
173
+ // process plant a differently-signed .app that install-helper trusts.
168
174
  export function resolveHelperExec() {
169
175
  const here = path.dirname(fileURLToPath(import.meta.url));
170
176
  const candidates = [
171
177
  // Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
172
178
  path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
173
- // Bundled with the npm package (later: CDN download lands here).
179
+ // Bundled with the npm package.
174
180
  path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
175
181
  ];
176
182
  for (const c of candidates) {
@@ -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();
@@ -43,6 +43,21 @@ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[],
43
43
  args: string[];
44
44
  env: Record<string, string>;
45
45
  };
46
+ /**
47
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
48
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
49
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
50
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
51
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
52
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
53
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
54
+ * works on both the standalone and JS/dev builds. Every argv element is
55
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
56
+ */
57
+ export declare function buildAskpassShimBody(launch?: {
58
+ command: string;
59
+ args: string[];
60
+ }): string;
46
61
  /**
47
62
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
48
63
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { getCliLaunch } from '../cli-entry.js';
19
20
  import { encodePwshBase64 } from '../pwsh.js';
20
21
  import { getCacheDir } from '../state.js';
21
22
  import { hostKeyCheckingOpts } from './known-hosts.js';
@@ -92,6 +93,21 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
92
93
  args.push(remote);
93
94
  return { args, env };
94
95
  }
96
+ /**
97
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
98
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
99
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
100
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
101
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
102
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
103
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
104
+ * works on both the standalone and JS/dev builds. Every argv element is
105
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
106
+ */
107
+ export function buildAskpassShimBody(launch = getCliLaunch(['ssh', '__askpass'])) {
108
+ const exec = [launch.command, ...launch.args].map(shellQuote).join(' ');
109
+ return `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${exec}\n`;
110
+ }
95
111
  /**
96
112
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
97
113
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -102,10 +118,6 @@ export function writeAskpassShim() {
102
118
  const dir = path.join(getCacheDir(), 'devices');
103
119
  fs.mkdirSync(dir, { recursive: true });
104
120
  const shimPath = path.join(dir, 'askpass.sh');
105
- // process.execPath = the node/bun binary; argv[1] = this CLI's entry script.
106
- const node = process.execPath;
107
- const entry = process.argv[1] ?? '';
108
- const body = `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${shellQuote(node)} ${shellQuote(entry)} ssh __askpass\n`;
109
- fs.writeFileSync(shimPath, body, { mode: 0o700 });
121
+ fs.writeFileSync(shimPath, buildAskpassShimBody(), { mode: 0o700 });
110
122
  return shimPath;
111
123
  }
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ import { type DeviceProfile } from './registry.js';
2
+ /**
3
+ * Decide whether an interactive login warrants a terminfo push. Pure so the
4
+ * gating logic is unit-testable without touching ssh or the filesystem.
5
+ *
6
+ * @param interactive true only for a real login (no remote command) on a human tty.
7
+ */
8
+ export declare function shouldSyncTerminfo(params: {
9
+ term?: string;
10
+ shell: DeviceProfile['shell'];
11
+ interactive: boolean;
12
+ }): boolean;
13
+ /**
14
+ * Cache key for a device: the remote **user@host**, not just the host. terminfo
15
+ * is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
16
+ * are distinct sync targets — keying on host alone would let the first user's
17
+ * stamp suppress the second user's (never-installed) sync. Mirrors
18
+ * {@link sshTargetFor}'s user handling; falls back to the device name when the
19
+ * address is unresolved.
20
+ */
21
+ export declare function terminfoHostKey(device: Pick<DeviceProfile, 'user' | 'name'>, addr: string | undefined): string;
22
+ /** True when a fresh successful-sync stamp exists for this host+TERM. */
23
+ export declare function terminfoSynced(host: string, term: string, cacheRoot?: string): boolean;
24
+ /** Record a successful sync so repeat logins skip the push. Best-effort. */
25
+ export declare function markTerminfoSynced(host: string, term: string, cacheRoot?: string): void;
26
+ /** Export the local terminfo source for a TERM, or null if it can't be produced. */
27
+ export declare function localTerminfoSource(term: string): string | null;
28
+ /**
29
+ * Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
30
+ * before an interactive login. Never throws; returns whether a push succeeded
31
+ * (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
32
+ * SAME host-key + auth options the real login uses, minus the interactive tty —
33
+ * so a password-auth device resolves through the askpass shim without a second
34
+ * human prompt.
35
+ */
36
+ export declare function syncTerminfoToDevice(opts: {
37
+ device: DeviceProfile;
38
+ host: string;
39
+ term: string | undefined;
40
+ /** ssh argv for a non-interactive `tic -x -` exec against this device. */
41
+ sshArgs: string[];
42
+ /** Env overlay for that ssh (askpass wiring for password devices). */
43
+ sshEnv: Record<string, string>;
44
+ }): boolean;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Terminfo propagation for `agents ssh` interactive logins.
3
+ *
4
+ * Modern terminals (Ghostty, kitty, Alacritty, WezTerm, foot, rio) advertise a
5
+ * custom `TERM` (e.g. `xterm-ghostty`) whose terminfo entry ships with the
6
+ * terminal, not with the remote host's ncurses. SSH into a box that lacks the
7
+ * entry and the session is subtly broken: wrong backspace, missing colors, a
8
+ * garbled clear/alt-screen. Ghostty's own shell integration fixes this for the
9
+ * bare `ssh` command, but `agents ssh` (Tailscale-relayed, spawned directly)
10
+ * bypasses that wrapper — so we handle it here.
11
+ *
12
+ * Strategy: on an interactive POSIX login, if the local `$TERM` is one the
13
+ * remote is unlikely to have, export it locally (`infocmp -x`) and compile it on
14
+ * the remote (`tic -x -`, writes to the user's `~/.terminfo`, no sudo). This is
15
+ * the canonical, terminal-agnostic technique.
16
+ *
17
+ * Two invariants keep it safe and cheap:
18
+ * - **Fail-safe.** Every failure is swallowed. A push that errors, times out,
19
+ * or hits a remote without `tic` leaves the user exactly where they are today
20
+ * — it never blocks or delays the actual login beyond the short push timeout.
21
+ * - **Cached.** A successful sync stamps a local marker keyed by host+TERM, so
22
+ * only the first login to each host pays the one extra round-trip; every
23
+ * repeat login is zero-cost.
24
+ */
25
+ import { spawnSync } from 'child_process';
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import { getCacheDir } from '../state.js';
29
+ /**
30
+ * Terminfo names that ncurses ships essentially everywhere, so pushing them is
31
+ * wasted work. Anything NOT in this set (the exotic terminal entries) is a sync
32
+ * candidate. Kept deliberately conservative: when unsure, we push — `tic` is
33
+ * idempotent and the result is cached.
34
+ */
35
+ const UNIVERSAL_TERMS = new Set([
36
+ 'dumb',
37
+ 'ansi',
38
+ 'vt100',
39
+ 'vt102',
40
+ 'vt220',
41
+ 'linux',
42
+ 'cygwin',
43
+ 'xterm',
44
+ 'xterm-color',
45
+ 'xterm-16color',
46
+ 'xterm-256color',
47
+ 'screen',
48
+ 'screen-256color',
49
+ 'tmux',
50
+ 'tmux-256color',
51
+ 'rxvt',
52
+ 'rxvt-unicode',
53
+ 'rxvt-unicode-256color',
54
+ ]);
55
+ /** How long a successful sync suppresses re-syncing the same host+TERM. */
56
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
57
+ /** Cap on the push so a stalled remote can't delay the login indefinitely. */
58
+ const PUSH_TIMEOUT_MS = 8000;
59
+ /**
60
+ * Decide whether an interactive login warrants a terminfo push. Pure so the
61
+ * gating logic is unit-testable without touching ssh or the filesystem.
62
+ *
63
+ * @param interactive true only for a real login (no remote command) on a human tty.
64
+ */
65
+ export function shouldSyncTerminfo(params) {
66
+ if (!params.interactive)
67
+ return false;
68
+ if (params.shell === 'powershell')
69
+ return false; // Windows console ignores terminfo
70
+ const term = params.term?.trim();
71
+ if (!term)
72
+ return false;
73
+ if (UNIVERSAL_TERMS.has(term))
74
+ return false;
75
+ return true;
76
+ }
77
+ /**
78
+ * Cache key for a device: the remote **user@host**, not just the host. terminfo
79
+ * is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
80
+ * are distinct sync targets — keying on host alone would let the first user's
81
+ * stamp suppress the second user's (never-installed) sync. Mirrors
82
+ * {@link sshTargetFor}'s user handling; falls back to the device name when the
83
+ * address is unresolved.
84
+ */
85
+ export function terminfoHostKey(device, addr) {
86
+ const host = addr ?? device.name;
87
+ return device.user ? `${device.user}@${host}` : host;
88
+ }
89
+ /** Directory holding per-host+TERM sync stamps. `cacheRoot` override is for tests. */
90
+ function stampDir(cacheRoot) {
91
+ return path.join(cacheRoot ?? getCacheDir(), 'devices', 'terminfo');
92
+ }
93
+ /** Filesystem-safe stamp name for a host+TERM pair. */
94
+ function stampFile(host, term, cacheRoot) {
95
+ const safe = `${host}__${term}`.replace(/[^A-Za-z0-9._-]/g, '_');
96
+ return path.join(stampDir(cacheRoot), safe);
97
+ }
98
+ /** True when a fresh successful-sync stamp exists for this host+TERM. */
99
+ export function terminfoSynced(host, term, cacheRoot) {
100
+ try {
101
+ const st = fs.statSync(stampFile(host, term, cacheRoot));
102
+ return Date.now() - st.mtimeMs < CACHE_TTL_MS;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /** Record a successful sync so repeat logins skip the push. Best-effort. */
109
+ export function markTerminfoSynced(host, term, cacheRoot) {
110
+ try {
111
+ fs.mkdirSync(stampDir(cacheRoot), { recursive: true });
112
+ fs.writeFileSync(stampFile(host, term, cacheRoot), `${new Date().toISOString()}\n`);
113
+ }
114
+ catch {
115
+ /* a cache write failure just means we re-sync next time — never fatal */
116
+ }
117
+ }
118
+ /** Export the local terminfo source for a TERM, or null if it can't be produced. */
119
+ export function localTerminfoSource(term) {
120
+ try {
121
+ const res = spawnSync('infocmp', ['-x', term], {
122
+ encoding: 'utf8',
123
+ timeout: 4000,
124
+ });
125
+ if (res.status === 0 && res.stdout && res.stdout.trim().length > 0) {
126
+ return res.stdout;
127
+ }
128
+ }
129
+ catch {
130
+ /* infocmp missing or errored — nothing we can push */
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
136
+ * before an interactive login. Never throws; returns whether a push succeeded
137
+ * (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
138
+ * SAME host-key + auth options the real login uses, minus the interactive tty —
139
+ * so a password-auth device resolves through the askpass shim without a second
140
+ * human prompt.
141
+ */
142
+ export function syncTerminfoToDevice(opts) {
143
+ const term = opts.term?.trim();
144
+ if (!term)
145
+ return false;
146
+ if (terminfoSynced(opts.host, term))
147
+ return false;
148
+ const source = localTerminfoSource(term);
149
+ if (!source)
150
+ return false;
151
+ try {
152
+ const res = spawnSync('ssh', opts.sshArgs, {
153
+ input: source,
154
+ env: { ...process.env, ...opts.sshEnv },
155
+ timeout: PUSH_TIMEOUT_MS,
156
+ stdio: ['pipe', 'ignore', 'ignore'],
157
+ });
158
+ if (res.status === 0) {
159
+ markTerminfoSynced(opts.host, term);
160
+ return true;
161
+ }
162
+ }
163
+ catch {
164
+ /* connection failed / timed out — fail-safe, login proceeds unaffected */
165
+ }
166
+ return false;
167
+ }