@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -12,7 +12,8 @@
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
14
  import { spawn, spawnSync } from 'child_process';
15
- import { readAndResolveBundleEnv } from '../secrets/bundles.js';
15
+ import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
16
+ import { readMeta, writeMeta } from '../state.js';
16
17
  /** Locate the crabbox binary, or throw an actionable error. */
17
18
  export function findCrabbox() {
18
19
  const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
@@ -21,21 +22,95 @@ export function findCrabbox() {
21
22
  }
22
23
  return 'crabbox';
23
24
  }
25
+ /**
26
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
27
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
28
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
29
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
30
+ */
31
+ export const LEASE_PROVIDER_TOKEN_KEYS = ['HCLOUD_TOKEN', 'AWS_ACCESS_KEY_ID', 'DIGITALOCEAN_TOKEN', 'DO_TOKEN'];
32
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
33
+ export function pickLeaseBundleFromList(bundles) {
34
+ for (const b of bundles) {
35
+ if (Object.keys(b.vars ?? {}).some((k) => LEASE_PROVIDER_TOKEN_KEYS.includes(k)))
36
+ return b.name;
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * The secrets bundle to feed crabbox, resolved in priority order:
42
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
43
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
44
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
45
+ *
46
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
47
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
48
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
49
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
50
+ * process (it is called several times per lease, and we don't want a scan each
51
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
52
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
53
+ */
54
+ export function resolveLeaseBundle() {
55
+ const env = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
56
+ if (env)
57
+ return { name: env };
58
+ try {
59
+ const configured = readMeta().lease?.secretsBundle;
60
+ if (configured && bundleExists(configured))
61
+ return { name: configured };
62
+ }
63
+ catch {
64
+ /* config unreadable — fall through to auto-detect */
65
+ }
66
+ try {
67
+ const bundles = listBundles();
68
+ const name = pickLeaseBundleFromList(bundles);
69
+ if (name) {
70
+ const b = bundles.find((x) => x.name === name);
71
+ const keys = LEASE_PROVIDER_TOKEN_KEYS.filter((k) => !!b && k in (b.vars ?? {}));
72
+ return { name, keys };
73
+ }
74
+ }
75
+ catch {
76
+ /* secrets unreadable — no auto-detect */
77
+ }
78
+ return undefined;
79
+ }
80
+ /** Process-lifetime memo so the tier-3 `listBundles()` scan runs at most once. */
81
+ let leaseBundleMemo;
82
+ function resolveLeaseBundleMemo() {
83
+ if (!leaseBundleMemo)
84
+ leaseBundleMemo = { value: resolveLeaseBundle() };
85
+ return leaseBundleMemo.value;
86
+ }
87
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
88
+ export function setLeaseSecretsBundle(name) {
89
+ const meta = readMeta();
90
+ writeMeta({ ...meta, lease: { ...meta.lease, secretsBundle: name } });
91
+ leaseBundleMemo = undefined; // invalidate so the next resolve sees the new config
92
+ }
24
93
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
25
94
  export function crabboxEnv(opts) {
26
- const bundle = opts.secretsBundle ?? process.env.AGENTS_LEASE_SECRETS_BUNDLE;
27
- if (!bundle)
95
+ const resolved = opts.secretsBundle
96
+ ? { name: opts.secretsBundle }
97
+ : resolveLeaseBundleMemo();
98
+ if (!resolved)
28
99
  return process.env;
29
100
  try {
30
- // Reuse the same resolver `agents secrets exec` uses so a keychain-backed
31
- // bundle (e.g. hetzner.com HCLOUD_TOKEN) reaches crabbox without ever
32
- // touching disk.
33
- const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents run --lease (crabbox)' });
101
+ // Auto-detected bundle inject ONLY the provider token key(s) (least
102
+ // privilege; an unrelated bundle can't leak its other secrets into crabbox).
103
+ // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
104
+ // whole the user chose it. Same resolver `agents secrets exec` uses.
105
+ const { env } = readAndResolveBundleEnv(resolved.name, {
106
+ caller: 'agents run --lease (crabbox)',
107
+ keys: resolved.keys,
108
+ });
34
109
  return { ...process.env, ...env };
35
110
  }
36
111
  catch (e) {
37
- throw new Error(`Could not load secrets bundle "${bundle}" for crabbox: ${e.message}. ` +
38
- `Fix the bundle (agents secrets view ${bundle}) or unset lease.secretsBundle to use crabbox's own login.`);
112
+ throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
113
+ `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
39
114
  }
40
115
  }
41
116
  function normalizeBox(raw) {
@@ -46,6 +121,12 @@ function normalizeBox(raw) {
46
121
  const status = String(raw.status ?? '');
47
122
  const state = String(labels.state ?? '');
48
123
  const publicNet = (raw.public_net ?? {});
124
+ const num = (v) => {
125
+ if (v === undefined || v === '')
126
+ return null;
127
+ const n = Number(v);
128
+ return Number.isFinite(n) ? n : null;
129
+ };
49
130
  return {
50
131
  name: String(raw.name ?? ''),
51
132
  status,
@@ -56,6 +137,11 @@ function normalizeBox(raw) {
56
137
  profile: labels.profile,
57
138
  class: labels.class,
58
139
  ready: status === 'running' && state === 'ready',
140
+ keep: labels.keep === 'true',
141
+ createdAt: num(labels.created_at),
142
+ expiresAt: num(labels.expires_at),
143
+ lastTouchedAt: num(labels.last_touched_at),
144
+ idleTimeoutSecs: num(labels.idle_timeout_secs ?? labels.idle_timeout),
59
145
  };
60
146
  }
61
147
  /** All crabbox machines the broker knows about. */
@@ -113,6 +199,24 @@ export async function crabboxWarmup(opts = {}) {
113
199
  });
114
200
  if (r.status !== 0) {
115
201
  const detail = (r.stderr || r.stdout || '').trim();
202
+ // A provider `server_limit` / `resource_limit_exceeded` 403 means the account's
203
+ // box quota is full. Turn the raw 403 into an actionable message that names the
204
+ // reap-safe orphans + the one-command fix, instead of a generic failure.
205
+ if (/server_limit|resource_limit_exceeded/i.test(detail)) {
206
+ let hint = ' Stop unused boxes (`crabbox list`) or raise your provider server limit.';
207
+ try {
208
+ const orphans = reapSafeOrphans(crabboxList(opts), Math.floor(Date.now() / 1000));
209
+ if (orphans.length) {
210
+ hint =
211
+ ` ${orphans.length} expired, idle box(es) are holding the quota — free them with ` +
212
+ `\`agents lease gc\` (or \`crabbox stop ${orphans[0].slug}\`).`;
213
+ }
214
+ }
215
+ catch {
216
+ /* best-effort hint; fall back to the generic guidance above */
217
+ }
218
+ throw new Error(`crabbox warmup failed: provider server limit reached.${hint}`);
219
+ }
116
220
  throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
117
221
  `Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
118
222
  }
@@ -229,3 +333,48 @@ export function crabboxStop(slug, opts = {}) {
229
333
  return false;
230
334
  }
231
335
  }
336
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
337
+ export const REAP_MIN_IDLE_SECS = 3600;
338
+ /**
339
+ * Whether a box is a genuine orphan that is safe to reap.
340
+ *
341
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
342
+ * past) AND the box has not been touched for a safety window of
343
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
344
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
345
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
346
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
347
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
348
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
349
+ */
350
+ export function isReapSafe(box, nowSecs) {
351
+ if (box.expiresAt === null || box.lastTouchedAt === null)
352
+ return false;
353
+ if (box.expiresAt > nowSecs)
354
+ return false;
355
+ const window = Math.max((box.idleTimeoutSecs ?? 0) * 2, REAP_MIN_IDLE_SECS);
356
+ return nowSecs - box.lastTouchedAt >= window;
357
+ }
358
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
359
+ export function reapSafeOrphans(boxes, nowSecs) {
360
+ return boxes
361
+ .filter((b) => isReapSafe(b, nowSecs))
362
+ .sort((a, b) => (a.lastTouchedAt ?? 0) - (b.lastTouchedAt ?? 0));
363
+ }
364
+ /**
365
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
366
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
367
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
368
+ */
369
+ export function reapOrphans(opts = {}) {
370
+ const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
371
+ const candidates = reapSafeOrphans(crabboxList(opts), nowSecs);
372
+ if (opts.dryRun)
373
+ return { candidates, reaped: [] };
374
+ const reaped = [];
375
+ for (const b of candidates) {
376
+ if (crabboxStop(b.slug, opts))
377
+ reaped.push(b.slug);
378
+ }
379
+ return { candidates, reaped };
380
+ }
@@ -47,6 +47,19 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
47
47
  checked: boolean;
48
48
  disabled: boolean | string;
49
49
  }[]) => Promise<AgentId[]>): Promise<AgentId[]>;
50
+ /**
51
+ * The lease runtime to provision for a headless run of `agentName`.
52
+ *
53
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
54
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
55
+ * lease runtime (preferring claude), or null when none is signed in. This is the
56
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
57
+ * a prompt, so it is headless by contract and must never block on a TTY.
58
+ *
59
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
60
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
61
+ */
62
+ export declare function inferLeaseRuntime(agentName: string, detected: DetectedRuntime[]): AgentId | null;
50
63
  /**
51
64
  * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
52
65
  * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
@@ -79,6 +79,30 @@ export async function pickRuntimes(detected, prompt) {
79
79
  const { checkbox } = await import('@inquirer/prompts');
80
80
  return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
81
81
  }
82
+ /**
83
+ * The lease runtime to provision for a headless run of `agentName`.
84
+ *
85
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
86
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
87
+ * lease runtime (preferring claude), or null when none is signed in. This is the
88
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
89
+ * a prompt, so it is headless by contract and must never block on a TTY.
90
+ *
91
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
92
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
93
+ */
94
+ export function inferLeaseRuntime(agentName, detected) {
95
+ const signedIn = detected.filter((d) => d.signedIn && d.credPath);
96
+ // The agent names a lease runtime directly: require that runtime to be signed
97
+ // in — never silently substitute a different one for an explicit `run <runtime>`
98
+ // (that would lease a billable box only to boot it "Not logged in"). Not signed
99
+ // in → null, so the caller exits with "sign into it locally first".
100
+ if (LEASE_RUNTIMES.some((c) => c.id === agentName)) {
101
+ return signedIn.find((d) => d.id === agentName)?.id ?? null;
102
+ }
103
+ // Custom/workflow agent: fall back to the signed-in runtime (preferring claude).
104
+ return signedIn.find((d) => d.id === 'claude')?.id ?? signedIn[0]?.id ?? null;
105
+ }
82
106
  // A long random sentinel makes an accidental (or malicious) collision with a
83
107
  // token's contents effectively impossible, so the quoted heredoc can never be
84
108
  // closed early by the credential body.
@@ -329,7 +329,12 @@ export async function runDaemon() {
329
329
  log('WARN', `Secrets broker host skipped: ${err.message}`);
330
330
  }
331
331
  const scheduler = new JobScheduler(async (config) => {
332
- log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
332
+ const jobLabel = config.command
333
+ ? 'command'
334
+ : config.workflow
335
+ ? `workflow: ${config.workflow}`
336
+ : `agent: ${config.agent}`;
337
+ log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
333
338
  try {
334
339
  const meta = await executeJobDetached(config);
335
340
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
@@ -419,6 +424,8 @@ export async function runDaemon() {
419
424
  log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
420
425
  (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
421
426
  }
427
+ if (r.warnings.length)
428
+ log('WARN', `sessions sync: ${r.warnings[0]}`);
422
429
  }
423
430
  catch (err) {
424
431
  log('ERROR', `sessions sync failed: ${err.message}`);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
+ export type FleetSkipReason = 'offline' | 'no-address';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export declare const FLEET_VERSION_RE: RegExp;
14
+ export interface FleetTarget {
15
+ device: DeviceProfile;
16
+ /** When set, this device is not reached (skip with reason). */
17
+ skip?: FleetSkipReason;
18
+ }
19
+ export interface FleetRunResult {
20
+ name: string;
21
+ status: 'ok' | 'failed' | 'skipped';
22
+ code: number | null;
23
+ reason?: FleetSkipReason | string;
24
+ /** Truncated combined stderr/stdout for failures. */
25
+ detail?: string;
26
+ }
27
+ /**
28
+ * Classify each registered device for a fleet operation.
29
+ *
30
+ * - Tailscale-offline → skip `offline`
31
+ * - No address → skip `no-address`
32
+ * - Everything else is a target (including this machine, reached over ssh when
33
+ * it has a registry address — same path as any other box).
34
+ */
35
+ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
36
+ /** Human label for a skip reason. */
37
+ export declare function skipLabel(reason: FleetSkipReason): string;
38
+ /**
39
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
40
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
41
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
42
+ * misconfigured device cannot abort the fleet loop.
43
+ */
44
+ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?: {
45
+ timeoutMs?: number;
46
+ }): {
47
+ code: number | null;
48
+ stdout: string;
49
+ stderr: string;
50
+ };
51
+ /**
52
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
53
+ * Rejects anything that is not a plain npm version/tag token so a version pin
54
+ * cannot inject shell metacharacters into the remote command line.
55
+ */
56
+ export declare function upgradeCommand(version?: string): string[];
57
+ /**
58
+ * Execute a command across planned targets. Pure orchestration over
59
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
60
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
61
+ */
62
+ export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
14
+ /**
15
+ * Classify each registered device for a fleet operation.
16
+ *
17
+ * - Tailscale-offline → skip `offline`
18
+ * - No address → skip `no-address`
19
+ * - Everything else is a target (including this machine, reached over ssh when
20
+ * it has a registry address — same path as any other box).
21
+ */
22
+ export function planFleetTargets(reg) {
23
+ const names = Object.keys(reg).sort();
24
+ return names.map((name) => {
25
+ const device = reg[name];
26
+ if (device.tailscale && !device.tailscale.online) {
27
+ return { device, skip: 'offline' };
28
+ }
29
+ try {
30
+ sshTargetFor(device);
31
+ }
32
+ catch {
33
+ return { device, skip: 'no-address' };
34
+ }
35
+ return { device };
36
+ });
37
+ }
38
+ /** Human label for a skip reason. */
39
+ export function skipLabel(reason) {
40
+ switch (reason) {
41
+ case 'offline':
42
+ return 'offline';
43
+ case 'no-address':
44
+ return 'no address';
45
+ }
46
+ }
47
+ /**
48
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
49
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
50
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
51
+ * misconfigured device cannot abort the fleet loop.
52
+ */
53
+ export function runOnDevice(device, cmd, opts = {}) {
54
+ try {
55
+ const shim = writeAskpassShim();
56
+ const { args, env } = buildSshInvocation(device, cmd, shim);
57
+ const res = spawnSync('ssh', args, {
58
+ encoding: 'utf-8',
59
+ env: { ...process.env, ...env },
60
+ timeout: opts.timeoutMs ?? 600_000,
61
+ });
62
+ return {
63
+ code: res.status,
64
+ stdout: res.stdout?.toString() ?? '',
65
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
66
+ };
67
+ }
68
+ catch (err) {
69
+ return {
70
+ code: 1,
71
+ stdout: '',
72
+ stderr: err instanceof Error ? err.message : String(err),
73
+ };
74
+ }
75
+ }
76
+ /**
77
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
78
+ * Rejects anything that is not a plain npm version/tag token so a version pin
79
+ * cannot inject shell metacharacters into the remote command line.
80
+ */
81
+ export function upgradeCommand(version) {
82
+ if (version !== undefined && version !== '') {
83
+ if (!FLEET_VERSION_RE.test(version)) {
84
+ throw new Error(`Invalid version '${version}'. Use a semver or dist-tag (letters, digits, . _ - only).`);
85
+ }
86
+ return ['agents', 'upgrade', version, '--yes'];
87
+ }
88
+ return ['agents', 'upgrade', '--yes'];
89
+ }
90
+ /**
91
+ * Execute a command across planned targets. Pure orchestration over
92
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
93
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
94
+ */
95
+ export function runFleet(targets, cmd, runner = runOnDevice) {
96
+ const results = [];
97
+ for (const t of targets) {
98
+ if (t.skip) {
99
+ results.push({
100
+ name: t.device.name,
101
+ status: 'skipped',
102
+ code: null,
103
+ reason: t.skip,
104
+ });
105
+ continue;
106
+ }
107
+ try {
108
+ const res = runner(t.device, cmd);
109
+ const ok = res.code === 0;
110
+ const detail = (res.stderr || res.stdout).trim().slice(0, 200);
111
+ results.push({
112
+ name: t.device.name,
113
+ status: ok ? 'ok' : 'failed',
114
+ code: res.code,
115
+ detail: ok ? undefined : detail || undefined,
116
+ });
117
+ }
118
+ catch (err) {
119
+ results.push({
120
+ name: t.device.name,
121
+ status: 'failed',
122
+ code: 1,
123
+ detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
124
+ });
125
+ }
126
+ }
127
+ return results;
128
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Device resource probing for `agents devices list`.
3
+ *
4
+ * One SSH round-trip per device gathers load average, memory pressure, and core
5
+ * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
+ * with a bounded timeout so the list stays responsive — a slow or hung box
7
+ * degrades to "no stats" instead of blocking the whole table.
8
+ *
9
+ * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
+ * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
11
+ * separate copy on purpose: the CLI does not import across packages.
12
+ */
13
+ import type { DeviceProfile } from './registry.js';
14
+ /** Default per-device probe budget. Short enough that the list never hangs on a
15
+ * wedged box, long enough for a cold relayed SSH handshake. */
16
+ export declare const PROBE_TIMEOUT_MS = 2500;
17
+ export interface DeviceStats {
18
+ host: string;
19
+ reachable: boolean;
20
+ loadAvg1?: number;
21
+ ncpu?: number;
22
+ /** loadAvg1 / ncpu * 100 — load normalized to core count (the "has room" number). */
23
+ loadPercent?: number;
24
+ memPercent?: number;
25
+ memTotalBytes?: number;
26
+ memFreeBytes?: number;
27
+ fetchedAt: number;
28
+ }
29
+ /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
30
+ export declare function fmtBytes(bytes: number | undefined): string;
31
+ export declare function parseUptime(out: string): {
32
+ loadAvg1?: number;
33
+ };
34
+ interface MemStats {
35
+ memPercent?: number;
36
+ memTotalBytes?: number;
37
+ memFreeBytes?: number;
38
+ }
39
+ export declare function parseVmStat(out: string): MemStats;
40
+ export declare function parseLinuxMemInfo(out: string): MemStats;
41
+ export declare function parseNcpu(out: string): {
42
+ ncpu?: number;
43
+ };
44
+ /** Assemble a DeviceStats from the three snippet sections. */
45
+ export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
46
+ export interface FleetCapacity {
47
+ reachable: number;
48
+ cores: number;
49
+ memTotalBytes: number;
50
+ memFreeBytes: number;
51
+ }
52
+ /** Sum cores and memory across reachable devices for the summary footer. */
53
+ export declare function fleetCapacity(statsList: Iterable<DeviceStats>): FleetCapacity;
54
+ /** Headroom bucket from the worst of normalized-load and memory. */
55
+ export type Headroom = 'idle' | 'light' | 'busy' | 'loaded' | 'unknown';
56
+ export declare function headroom(stats: DeviceStats | undefined): Headroom;
57
+ /** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
58
+ * an unreachable/slow/misconfigured device resolves to `reachable: false`. */
59
+ export declare function probeDeviceStats(device: DeviceProfile, opts?: {
60
+ timeoutMs?: number;
61
+ now?: number;
62
+ }): Promise<DeviceStats>;
63
+ /** Probe the local machine directly (no ssh round-trip) — used for the "this
64
+ * machine" row so it always shows real numbers even if it isn't ssh-reachable
65
+ * from itself. */
66
+ export declare function probeLocalStats(host: string, opts?: {
67
+ timeoutMs?: number;
68
+ now?: number;
69
+ }): Promise<DeviceStats>;
70
+ /** Probe many devices concurrently; returns a name→stats map. Bounded by the
71
+ * per-probe timeout, so total wall time ≈ the slowest single probe. The device
72
+ * named `selfName` is probed locally instead of over ssh. */
73
+ export declare function probeFleetStats(devices: DeviceProfile[], opts?: {
74
+ timeoutMs?: number;
75
+ selfName?: string;
76
+ }): Promise<Map<string, DeviceStats>>;
77
+ export {};