@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.
- package/CHANGELOG.md +66 -0
- package/README.md +30 -7
- package/dist/bin/agents +0 -0
- package/dist/commands/computer.d.ts +26 -0
- package/dist/commands/computer.js +173 -149
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +88 -6
- package/dist/commands/fork.d.ts +9 -0
- package/dist/commands/fork.js +67 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +131 -0
- package/dist/commands/setup-browser.d.ts +18 -0
- package/dist/commands/setup-browser.js +142 -0
- package/dist/commands/setup-computer.d.ts +19 -0
- package/dist/commands/setup-computer.js +133 -0
- package/dist/commands/setup-share.d.ts +17 -0
- package/dist/commands/setup-share.js +85 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +58 -1
- package/dist/commands/share.d.ts +15 -0
- package/dist/commands/share.js +10 -4
- package/dist/commands/ssh.js +202 -9
- package/dist/commands/view.d.ts +4 -14
- package/dist/commands/view.js +32 -30
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +10 -0
- package/dist/lib/agents.js +32 -0
- package/dist/lib/auth-health.d.ts +141 -0
- package/dist/lib/auth-health.js +277 -0
- package/dist/lib/browser/chrome.d.ts +9 -0
- package/dist/lib/browser/chrome.js +22 -0
- package/dist/lib/computer/download.d.ts +54 -0
- package/dist/lib/computer/download.js +146 -0
- package/dist/lib/computer-rpc.js +9 -3
- package/dist/lib/daemon.js +38 -0
- package/dist/lib/devices/connect.d.ts +15 -0
- package/dist/lib/devices/connect.js +17 -5
- package/dist/lib/devices/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- package/dist/lib/devices/terminfo.d.ts +44 -0
- package/dist/lib/devices/terminfo.js +167 -0
- package/dist/lib/rotate.d.ts +12 -8
- package/dist/lib/rotate.js +22 -23
- package/dist/lib/session/fork.d.ts +32 -0
- package/dist/lib/session/fork.js +101 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.d.ts +23 -0
- package/dist/lib/usage.js +84 -0
- package/package.json +1 -1
package/dist/lib/computer-rpc.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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) {
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|