@phnx-labs/agents-cli 1.20.69 → 1.20.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +19 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +36 -3
  5. package/dist/commands/check.js +3 -1
  6. package/dist/commands/doctor.js +5 -0
  7. package/dist/commands/exec.js +12 -0
  8. package/dist/commands/fleet-capture.d.ts +14 -0
  9. package/dist/commands/fleet-capture.js +107 -0
  10. package/dist/commands/fork.js +13 -7
  11. package/dist/commands/hosts.js +11 -0
  12. package/dist/commands/secrets.d.ts +1 -0
  13. package/dist/commands/secrets.js +24 -4
  14. package/dist/commands/sessions-tail.js +1 -2
  15. package/dist/commands/sessions.js +6 -6
  16. package/dist/commands/ssh.js +63 -16
  17. package/dist/commands/usage.d.ts +13 -0
  18. package/dist/commands/usage.js +50 -28
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +5 -2
  21. package/dist/lib/auth-health.d.ts +38 -0
  22. package/dist/lib/auth-health.js +48 -3
  23. package/dist/lib/commands.d.ts +10 -0
  24. package/dist/lib/commands.js +31 -7
  25. package/dist/lib/computer/download.d.ts +3 -0
  26. package/dist/lib/computer/download.js +13 -12
  27. package/dist/lib/daemon.js +38 -0
  28. package/dist/lib/devices/connect.d.ts +13 -0
  29. package/dist/lib/devices/connect.js +20 -0
  30. package/dist/lib/devices/fleet.d.ts +36 -3
  31. package/dist/lib/devices/fleet.js +42 -4
  32. package/dist/lib/devices/health-report.d.ts +11 -0
  33. package/dist/lib/devices/health-report.js +73 -4
  34. package/dist/lib/devices/stats-cache.d.ts +36 -0
  35. package/dist/lib/devices/stats-cache.js +115 -0
  36. package/dist/lib/devices/sync.d.ts +30 -0
  37. package/dist/lib/devices/sync.js +50 -0
  38. package/dist/lib/doctor-diff.js +38 -16
  39. package/dist/lib/fleet/apply.d.ts +4 -0
  40. package/dist/lib/fleet/apply.js +28 -5
  41. package/dist/lib/fleet/capture.d.ts +35 -0
  42. package/dist/lib/fleet/capture.js +51 -0
  43. package/dist/lib/fleet/manifest.d.ts +6 -0
  44. package/dist/lib/fleet/manifest.js +24 -1
  45. package/dist/lib/fleet/types.d.ts +24 -1
  46. package/dist/lib/git.d.ts +14 -0
  47. package/dist/lib/git.js +39 -1
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  49. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  50. package/dist/lib/menubar/install-menubar.js +26 -13
  51. package/dist/lib/secrets/agent.d.ts +5 -0
  52. package/dist/lib/secrets/agent.js +35 -3
  53. package/dist/lib/secrets/bundles.js +28 -0
  54. package/dist/lib/secrets/index.d.ts +14 -0
  55. package/dist/lib/secrets/index.js +36 -5
  56. package/dist/lib/secrets/session-store.d.ts +90 -0
  57. package/dist/lib/secrets/session-store.js +221 -0
  58. package/dist/lib/session/render.d.ts +9 -1
  59. package/dist/lib/session/render.js +35 -4
  60. package/dist/lib/share/capture.d.ts +2 -0
  61. package/dist/lib/share/capture.js +12 -5
  62. package/dist/lib/skills.d.ts +8 -1
  63. package/dist/lib/skills.js +11 -1
  64. package/dist/lib/types.d.ts +5 -1
  65. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.71
4
+
5
+ - **Capture your whole fleet into `agents.yaml`, then rebuild it anywhere with
6
+ `agents apply` (#1305).** New `agents fleet capture` (alias `agents devices
7
+ capture`) snapshots the live environment into the portable `fleet:` block — the
8
+ device roster (**names only**), the source's agents as `defaults`, secrets-bundle
9
+ **names**, and routine **names**. It commits **zero** Tailscale IPs or usernames:
10
+ `agents apply` reconstructs a fresh machine's roster by resolving each device
11
+ name **live from Tailscale** (`ensureDevicesRegistered`), so `git clone` +
12
+ `agents apply` replicates the fleet with nothing sensitive in the repo. `apply`
13
+ now also passes declared `sync:` scopes through to `agents sync <scope>`
14
+ (previously a bare `sync`) and surfaces declared secrets-bundle names to recreate
15
+ on each device (values stay keychain-local, never pushed). Browser profiles are
16
+ intentionally not duplicated into `fleet:` — they already sync via the central
17
+ `browser:` block. Source: `apps/cli/src/commands/fleet-capture.ts`,
18
+ `apps/cli/src/lib/fleet/capture.ts`, `apps/cli/src/lib/devices/sync.ts`,
19
+ `apps/cli/src/lib/fleet/{types,manifest,apply}.ts`.
20
+
21
+ - **`agents fleet status` no longer hangs on a stale `~/.ssh/config`.** The fleet probes (version / doctor / `fleet ping`) now dial each device at its registry Tailscale address (`dnsName`/IP) instead of the bare host name — so a hand-written `Host <name>` block carrying a drifted LAN IP can no longer shadow the correct entry and make a reachable box look dead. It also fails fast: a device the stats probe already found unreachable is skipped straight to an unreachable row instead of eating a 15s+30s version+doctor timeout. Source: `apps/cli/src/commands/ssh.ts`.
22
+
23
+ - **Fix the macOS menu-bar auto-heal so upgrades actually restart the helper.**
24
+ `agents` has an on-startup self-heal that re-copies `MenubarHelper.app` when
25
+ the CLI version changes, but on modern macOS `launchctl bootstrap` fails when
26
+ the job is already bootstrapped, and the deprecated `launchctl load -w`
27
+ fallback plus `kickstart -k` did not recover a job that launchd had stopped
28
+ respawning after a `WindowServer event port death`. The helper would stay
29
+ updated on disk but invisible in the menu bar. `enableMenubarService` now
30
+ boots the old job out, bootstraps the fresh plist, and kickstarts it — the
31
+ same sequence that reliably restores the icon by hand. Source:
32
+ `apps/cli/src/lib/menubar/install-menubar.ts`,
33
+ `apps/cli/src/lib/menubar/install-menubar.test.ts`.
34
+
35
+ - **`agents repo pull` no longer wedges on per-machine pin drift.** The committed
36
+ `devices/<machineId>/agents.yaml` (each box's agent version pins) is rewritten
37
+ whenever a pin changes, leaving the working tree perpetually dirty — so
38
+ `agents repo pull`, which refuses a dirty tree, kept failing until the file was
39
+ hand-committed. `pullRepo` now durably commits **just that one path** (explicit
40
+ pathspec) before pulling, via `commitOwnDeviceMeta`. Genuine uncommitted edits to
41
+ any other file still (correctly) block the pull. No-op for the system/extra repos
42
+ that don't own the path. Source: `apps/cli/src/lib/git.ts`.
43
+
44
+ - **`agents secrets unlock` now stays unlocked across an agents-cli upgrade (and,
45
+ with `--durable`, across sleep + reboot).** The macOS secrets broker held an
46
+ unlock only in RAM, so it evaporated every time the daemon restarted (upgrade)
47
+ or the machine slept — forcing a Touch ID re-tap and breaking headless reads
48
+ with "not unlocked in the secrets agent". An unlock now also persists a
49
+ device-local, non-biometry keychain session item that the broker **rehydrates on
50
+ start** and that reads **fall back to** silently. Split default: it survives
51
+ upgrade/restart automatically; pass `--durable` (or set `secrets.agent.durable:
52
+ true`) to also survive sleep/reboot — otherwise a bundle re-locks on sleep as
53
+ before. `lock` / rotate / delete clear it. On Linux and Windows `unlock` is now a
54
+ friendly no-op (secrets already resolve durably from the OS store with no
55
+ prompt), so the command behaves the same on all three platforms. Source:
56
+ `apps/cli/src/lib/secrets/session-store.ts` (new),
57
+ `apps/cli/src/lib/secrets/agent.ts`, `apps/cli/src/lib/secrets/bundles.ts`,
58
+ `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/commands/secrets.ts`,
59
+ `apps/cli/src/lib/types.ts`.
60
+
61
+ - **`agents share` cover capture finds Playwright's `chrome-headless-shell` packages.** `scanCaches()` only knew the classic `chrome-mac/Chromium.app` and `Google Chrome for Testing` layouts, so on machines whose Playwright cache holds only the newer `chromium_headless_shell-*` packages (a raw `chrome-headless-shell` binary, not an `.app` bundle) — and no system Chrome/Brave/Edge — the OG cover capture silently returned null and shared plans published without a preview card. The scan now matches `chrome-headless-shell-mac-arm64`, `chrome-headless-shell-mac-x64`, and `chrome-headless-shell-linux64` layouts alongside the existing ones. Source: `apps/cli/src/lib/share/capture.ts`, `apps/cli/src/lib/share/capture.test.ts`.
62
+
63
+ ## 1.20.70
64
+
65
+ - **Fix `agents setup computer` / `agents computer setup` refusing to install a
66
+ valid downloaded helper.** The signature check read `codesign -dv` from stdout,
67
+ but that command writes its details to **stderr** on success — so the Team-ID
68
+ check saw an empty string, found no `TeamIdentifier`, and rejected every
69
+ validly-signed, notarized helper with "signed by unexpected Team (none)". It now
70
+ reads both streams via `spawnSync`. Verified end-to-end against the real
71
+ published `v1.20.69` release asset (download → sha256 → extract → codesign +
72
+ Team `2HTP252L87` + `spctl` notarization → install). Source:
73
+ `apps/cli/src/lib/computer/download.ts`.
74
+
75
+ - **The bundled macOS menu-bar helper is now a true universal binary on
76
+ Xcode-less release hosts.** `menubar/scripts/build.sh release` used
77
+ `swift build --arch arm64 --arch x86_64` (needs Xcode's xcbuild) and, on a
78
+ Command-Line-Tools-only host, silently fell back to a **single-arch** build —
79
+ shipping an arm64-only `MenubarHelper.app` in the tarball that could not run on
80
+ Intel Macs. It now builds each slice via `--triple` and `lipo`s them into one
81
+ universal binary, matching the computer helper. Source:
82
+ `apps/cli/menubar/scripts/build.sh`.
83
+
3
84
  ## 1.20.69
4
85
 
5
86
  - **Choose a safe account with `agents run <agent>@`.** A trailing `@` opens a
package/README.md CHANGED
@@ -432,13 +432,15 @@ agents sync --host gpu-box # make the remote machine current
432
432
  agents doctor --devices # readiness matrix for every registered device
433
433
  agents doctor --devices --json # machine-readable fleet readiness
434
434
  agents doctor --device mac-mini # same matrix, scoped to one device
435
- agents fleet status # warnings rollup + health/sync/version matrix
435
+ agents fleet status # warnings rollup + health/sync/version/Auth matrix (cache-first)
436
+ agents fleet status --live # force a live resource probe (alias of --refresh)
436
437
  agents fleet status --json --strict # scriptable fleet health gate
437
438
  agents check --devices # CI drift gate across every registered device
438
439
 
439
440
  # Your Tailscale fleet, auto-discovered
440
441
  agents devices sync # ingest `tailscale status`
441
- agents devices list # fleet + live headroom: load, mem, idle/busy — which box has room
442
+ agents devices list # fleet + headroom: load, mem, idle/busy — which box has room (cache-first)
443
+ agents devices list --live # force a live probe of every device (alias of --refresh)
442
444
  agents devices list --full # add per-device cores and free/total RAM
443
445
  agents devices list --no-stats # instant: names/addresses only, skip the probe
444
446
  agents ssh mac-mini # hardened SSH: fails fast if offline,
@@ -453,11 +455,22 @@ agents cloud run "nightly benchmark" --host gpu-box --agent claude # task in c
453
455
  agents routines add nightly -s "0 2 * * *" -a claude -p "run the sweep" --run-on gpu-box
454
456
  ```
455
457
 
456
- `agents devices list` probes every reachable box in parallel (bounded timeout, so a
457
- slow node degrades to `—` instead of hanging) and shows normalized load, memory
458
- pressure, and an idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
458
+ `agents devices list` shows normalized load, memory pressure, and an
459
+ idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
459
460
  (`164 cores · 421G free / 518G RAM`). It answers "which machine has room right now?" —
460
- the utilization signal the teammate scheduler doesn't yet see.
461
+ the utilization signal the teammate scheduler doesn't yet see. It's **cache-first**:
462
+ reads serve instantly from a stats cache the daemon warms (~every 3 min), probing only
463
+ this machine locally plus any device missing from the cache; pass `--refresh` (or the
464
+ shorter `--live`) to force a full live probe of every box. Cache-served output notes its
465
+ age (`updated 2m ago — pass --refresh (--live) for a live probe`).
466
+
467
+ `agents fleet status` adds an **Auth column** — which agent accounts are actually
468
+ logged in, per device, read from the auth-health cache (no network). Four buckets keep
469
+ it from crying wolf: `●live` (verified), `·present` (signed in but the agent has no
470
+ live-probe endpoint — e.g. codex/grok — benign), `◐degraded` (soft/self-healing:
471
+ expired-but-refreshing, rate-limited), and `○revoked` (server rejected — re-login now).
472
+ Only `○` means a real re-login is needed. Run `agents fleet ping` to force a live
473
+ re-verification across the fleet.
461
474
 
462
475
  **Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH and feed one host pool: devices appear in `agents hosts list` and capability routing without a second enrollment. On `--host` runs every `agents run` option is either forwarded (`--effort --env --timeout --loop …`), rejected loud (`--secrets` never crosses SSH implicitly), or consumed locally — nothing silently drops. See [docs/00-concepts.md](apps/cli/docs/00-concepts.md#devices--hosts).
463
476
 
package/dist/bin/agents CHANGED
Binary file
@@ -15,6 +15,7 @@ import chalk from 'chalk';
15
15
  import { setHelpSections } from '../lib/help.js';
16
16
  import { machineId } from '../lib/session/sync/config.js';
17
17
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
18
+ import { ensureDevicesRegistered } from '../lib/devices/sync.js';
18
19
  import { readFleetFile, resolveDesired } from '../lib/fleet/manifest.js';
19
20
  import { snapshotAuth, materializeAuth, parseAuthBundle, KEYCHAIN_BOUND_ON_MAC } from '../lib/fleet/auth-sync.js';
20
21
  import { agentIdOf, diffFleet, probeDevice, runFleetApply, pool, sourceHome, } from '../lib/fleet/apply.js';
@@ -121,6 +122,15 @@ function renderPlan(plan) {
121
122
  if (noToken.length > 0) {
122
123
  console.log(chalk.yellow(` manual login needed (no portable token on source): ${noToken.join(', ')}`));
123
124
  }
125
+ // Secrets bundles are declared once for the fleet; surface the distinct set to
126
+ // recreate on any device missing them (values are keychain-local, never pushed).
127
+ const bundles = [...new Set(rows.flatMap((r) => r.secretsNeeded))];
128
+ if (bundles.length > 0) {
129
+ const shown = bundles.slice(0, 12);
130
+ const more = bundles.length - shown.length;
131
+ const list = shown.join(', ') + (more > 0 ? `, +${more} more` : '');
132
+ console.log(chalk.yellow(` ${bundles.length} secrets bundle(s) to recreate where missing (keychain-local, never pushed): ${list}`));
133
+ }
124
134
  }
125
135
  /** padEnd on the visible width, ignoring chalk color codes. Exported for tests. */
126
136
  export function stripPad(s, width) {
@@ -135,6 +145,24 @@ async function runApply(opts) {
135
145
  const file = opts.file ?? 'agents.yaml';
136
146
  const manifest = readFleetFile(path.resolve(file));
137
147
  const source = machineId();
148
+ // Fresh-machine bootstrap: an explicit `devices:` map may name boxes this
149
+ // machine has never registered (e.g. a freshly-cloned agents.yaml). Resolve
150
+ // those names live from Tailscale and register them BEFORE resolveDesired
151
+ // validates the roster — so replication needs only the repo, never committed
152
+ // IPs/usernames. `devices: all` needs no bootstrap (it targets what's already
153
+ // registered + online).
154
+ let unresolved = [];
155
+ if (manifest.devices !== 'all') {
156
+ const wanted = Object.keys(manifest.devices).filter((n) => n !== source);
157
+ const boot = await ensureDevicesRegistered(wanted);
158
+ unresolved = boot.unresolved;
159
+ if (boot.registered.length > 0) {
160
+ console.log(chalk.gray(`Registered ${boot.registered.length} device(s) from Tailscale: ${boot.registered.join(', ')}`));
161
+ }
162
+ if (boot.unresolved.length > 0) {
163
+ console.log(chalk.yellow(`Not resolvable on Tailscale — skipped, reconcile continues for the rest: ${boot.unresolved.join(', ')}`));
164
+ }
165
+ }
138
166
  const registry = await loadDevices();
139
167
  // Control devices (a cockpit) never run agents — exclude them from the
140
168
  // reconcile set entirely so `agents apply` doesn't try to install/sync/login
@@ -142,7 +170,9 @@ async function runApply(opts) {
142
170
  const all = Object.values(registry).filter((d) => !isControlDevice(d));
143
171
  const online = all.filter((d) => d.tailscale?.online === true).map((d) => d.name);
144
172
  const registered = all.map((d) => d.name);
145
- let desired = resolveDesired(manifest, { onlineDevices: online, registeredDevices: registered, source });
173
+ // Unresolved names are skipped (surfaced above), never fatal a manifest
174
+ // naming an asleep box must not abort the reconcile for every other device.
175
+ let desired = resolveDesired(manifest, { onlineDevices: online, registeredDevices: registered, source, unresolved });
146
176
  if (opts.device) {
147
177
  desired = desired.filter((d) => d.device === opts.device);
148
178
  if (desired.length === 0)
@@ -174,7 +204,7 @@ async function runApply(opts) {
174
204
  const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device)));
175
205
  const probes = new Map(probeList.map((p) => [p.device, p]));
176
206
  const targetCliVersion = localCliVersion();
177
- let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth });
207
+ let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth, secretsBundles: manifest.secrets?.bundles });
178
208
  // --only filter.
179
209
  if (opts.only) {
180
210
  const keep = new Set();
@@ -192,7 +222,10 @@ async function runApply(opts) {
192
222
  const isDry = opts.plan || opts.dryRun;
193
223
  if (isDry)
194
224
  return;
195
- if (plan.actions.filter((a) => a.kind !== 'needs-login').length === 0) {
225
+ // `needs-login`/`needs-secret` are surfaced manual reminders, not executable
226
+ // mutations — exclude them so an otherwise-converged fleet still says "nothing
227
+ // to do" instead of looping forever on un-actionable surfacing.
228
+ if (plan.actions.filter((a) => a.kind !== 'needs-login' && a.kind !== 'needs-secret').length === 0) {
196
229
  console.log(chalk.green('\nNothing to do — fleet already matches the profile.'));
197
230
  return;
198
231
  }
@@ -4,6 +4,7 @@ import { setHelpSections } from '../lib/help.js';
4
4
  import { computeDrift } from '../lib/drift.js';
5
5
  import { loadDevices } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
+ import { fleetDialTarget } from '../lib/devices/connect.js';
7
8
  import { machineId } from '../lib/session/sync/config.js';
8
9
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
9
10
  import { sshExecAsync } from '../lib/ssh-exec.js';
@@ -107,7 +108,7 @@ function checkPayload(device, drift) {
107
108
  async function probeDeviceCheck(target) {
108
109
  const isWin = /^win/i.test((target.platform ?? '').trim());
109
110
  const remoteCmd = buildRemoteAgentsInvocation(['check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
110
- const res = await sshExecAsync(target.name, remoteCmd, { timeoutMs: 30000, multiplex: true });
111
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
111
112
  if (res.code !== 0 && !res.stdout.trim()) {
112
113
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
113
114
  }
@@ -135,6 +136,7 @@ async function runDevicesCheck(opts, cwd) {
135
136
  name: t.device.name,
136
137
  platform: t.device.platform,
137
138
  skip: t.skip,
139
+ dialTarget: fleetDialTarget(t.device),
138
140
  }));
139
141
  const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
140
142
  const devices = [local];
@@ -14,6 +14,7 @@ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalled
14
14
  import { loadManifest, isStale } from '../lib/staleness/index.js';
15
15
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
16
16
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
17
+ import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
17
18
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
18
19
  import { listCliStatus } from '../lib/cli-resources.js';
19
20
  import { setHelpSections } from '../lib/help.js';
@@ -741,6 +742,10 @@ export function registerDoctorCommand(program) {
741
742
  console.log(JSON.stringify({
742
743
  clis,
743
744
  signIn,
745
+ // Cached auth-health rollup for THIS host — lets `agents fleet status`
746
+ // show a live-verified Auth column from the same fan-out it already
747
+ // runs, without a separate fleet-wide `fleet ping`.
748
+ auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
744
749
  sync: syncRows,
745
750
  orphans: orphanRows,
746
751
  hostClis: {
@@ -8,6 +8,7 @@
8
8
  import { Option } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import { setHelpSections } from '../lib/help.js';
11
+ import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
11
12
  import { parseLoopInterval } from '../lib/loop.js';
12
13
  import { AGENTS } from '../lib/agents.js';
13
14
  import { recordDispatchedRun } from '../lib/audit/log.js';
@@ -703,6 +704,17 @@ export function registerRunCommand(program) {
703
704
  `Make sure the host is reachable over SSH, then re-run with --copy-creds.`));
704
705
  process.exit(1);
705
706
  }
707
+ // --copy-creds ships live credentials to a host, so it deliberately
708
+ // requires an interactive terminal to pick which signed-in runtimes to
709
+ // send and to confirm the transfer. Fail clean in a non-TTY/headless
710
+ // shell instead of hanging on the picker/confirm below — auto-shipping
711
+ // every signed-in token without consent would defeat the gate's purpose.
712
+ if (!isInteractiveTerminal()) {
713
+ // No non-interactive form: --copy-creds must pick which signed-in
714
+ // runtimes to ship and confirm the transfer, so pass no alternatives
715
+ // (an empty list prints just the "needs an interactive terminal" line).
716
+ requireInteractiveSelection('--copy-creds (it selects which credentials to ship and confirms the transfer)', []);
717
+ }
706
718
  const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
707
719
  const { confirm } = await import('@inquirer/prompts');
708
720
  const detected = await detectSignedInRuntimes();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents fleet capture` (alias surface of `agents devices capture`) — snapshot
3
+ * the live environment into the `fleet:` block of `agents.yaml` so a fresh
4
+ * machine can reconstruct it with `agents apply`.
5
+ *
6
+ * Local-read + local-write, zero SSH. Records device NAMES only (never IPs or
7
+ * usernames — those are re-resolved live from Tailscale at apply-time), plus the
8
+ * source's own agents as defaults, browser profiles, secrets-bundle names, and
9
+ * routine names. The heavy lifting is the pure `captureFleet` builder; this
10
+ * command just gathers inputs and persists via `updateMeta`.
11
+ */
12
+ import { Command } from 'commander';
13
+ /** Attach `capture` to the `devices`/`fleet` command tree. */
14
+ export declare function registerFleetCaptureCommand(devicesCmd: Command): void;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `agents fleet capture` (alias surface of `agents devices capture`) — snapshot
3
+ * the live environment into the `fleet:` block of `agents.yaml` so a fresh
4
+ * machine can reconstruct it with `agents apply`.
5
+ *
6
+ * Local-read + local-write, zero SSH. Records device NAMES only (never IPs or
7
+ * usernames — those are re-resolved live from Tailscale at apply-time), plus the
8
+ * source's own agents as defaults, browser profiles, secrets-bundle names, and
9
+ * routine names. The heavy lifting is the pure `captureFleet` builder; this
10
+ * command just gathers inputs and persists via `updateMeta`.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import chalk from 'chalk';
15
+ import * as yaml from 'yaml';
16
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
17
+ import { readMeta, updateMeta, getDeviceMetaPath } from '../lib/state.js';
18
+ import { listBundles } from '../lib/secrets/bundles.js';
19
+ import { listJobs } from '../lib/routines.js';
20
+ import { captureFleet } from '../lib/fleet/capture.js';
21
+ /** The committed per-device pin dir: `~/.agents/devices/`. Derived from the
22
+ * device-meta path so it honors any AGENTS_HOME override the same way. */
23
+ function devicesPinDir() {
24
+ return path.dirname(path.dirname(getDeviceMetaPath()));
25
+ }
26
+ /** Read `devices/<name>/agents.yaml` pin files into `name -> [id@latest]`. Only
27
+ * used with `--from-pins`; keeps capture zero-SSH by reading committed state. */
28
+ function agentsFromPins(names) {
29
+ const dir = devicesPinDir();
30
+ const out = {};
31
+ for (const name of names) {
32
+ const p = path.join(dir, name, 'agents.yaml');
33
+ if (!fs.existsSync(p))
34
+ continue;
35
+ try {
36
+ const doc = yaml.parse(fs.readFileSync(p, 'utf-8'));
37
+ const ids = doc?.agents ? Object.keys(doc.agents) : [];
38
+ if (ids.length > 0)
39
+ out[name] = ids.map((id) => `${id}@latest`);
40
+ }
41
+ catch {
42
+ /* skip an unparsable pin file — capture is best-effort per device */
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ async function runCapture(opts) {
48
+ const meta = readMeta();
49
+ // Roster: registered, non-control device names only.
50
+ const registry = await loadDevices();
51
+ let names = Object.values(registry)
52
+ .filter((d) => !isControlDevice(d))
53
+ .map((d) => d.name)
54
+ .sort();
55
+ if (opts.device) {
56
+ names = names.filter((n) => n === opts.device);
57
+ if (names.length === 0)
58
+ throw new Error(`Device '${opts.device}' is not a registered device.`);
59
+ }
60
+ // Defaults seeded from the source machine's own installed agents.
61
+ const sourceAgents = Object.keys(meta.agents ?? {}).sort();
62
+ const defaults = {
63
+ agents: sourceAgents.map((id) => `${id}@latest`),
64
+ sync: ['user'],
65
+ login: 'sync',
66
+ };
67
+ const inputs = {
68
+ devices: names,
69
+ defaults,
70
+ agentsByDevice: opts.fromPins ? agentsFromPins(names) : undefined,
71
+ // Browser profiles are intentionally NOT captured — the central `browser:`
72
+ // block already syncs via the repo, and its ssh:// endpoints can carry
73
+ // `user@host`, which must never be copied into the fleet: block.
74
+ secretsBundles: listBundles().map((b) => b.name),
75
+ routines: listJobs().map((j) => j.name),
76
+ };
77
+ const next = captureFleet(meta.fleet, inputs);
78
+ if (opts.dryRun) {
79
+ console.log(chalk.gray('# agents.yaml fleet: block (dry run — not written)'));
80
+ console.log(yaml.stringify({ fleet: next }).trimEnd());
81
+ return;
82
+ }
83
+ updateMeta((m) => ({ ...m, fleet: next }));
84
+ const deviceCount = Object.keys(next.devices === 'all' ? {} : next.devices).length;
85
+ console.log(chalk.green('Captured fleet profile') +
86
+ chalk.gray(` — ${deviceCount} device(s), ${defaults.agents?.length ?? 0} agent(s), ` +
87
+ `${inputs.secretsBundles?.length ?? 0} secret bundle(s), ${inputs.routines?.length ?? 0} routine(s).`));
88
+ console.log(chalk.gray(' Wrote agents.yaml → fleet:. Push it (`agents repo push`) and run `agents apply` on any machine.'));
89
+ }
90
+ /** Attach `capture` to the `devices`/`fleet` command tree. */
91
+ export function registerFleetCaptureCommand(devicesCmd) {
92
+ devicesCmd
93
+ .command('capture')
94
+ .description('Snapshot the live environment (roster names, agents, browser, secret-bundle names, routines) into agents.yaml fleet:.')
95
+ .option('--dry-run', 'print the fleet: block that would be written, and exit')
96
+ .option('--from-pins', 'record per-device agents from committed devices/<name>/agents.yaml')
97
+ .option('--device <name>', 'capture a single device')
98
+ .action(async (opts) => {
99
+ try {
100
+ await runCapture(opts);
101
+ }
102
+ catch (e) {
103
+ console.error(chalk.red(e.message));
104
+ process.exit(1);
105
+ }
106
+ });
107
+ }
@@ -34,21 +34,26 @@ export function registerForkCommand(program) {
34
34
  matches = findSessionsById(sessionArg, {});
35
35
  }
36
36
  if (matches.length === 0) {
37
- console.log(chalk.red(`No session matching "${sessionArg}".`));
38
- console.log(chalk.gray('List candidates with: agents sessions'));
37
+ // Errors go to stderr and set a non-zero exit code so a script chaining on
38
+ // `agents fork <id> && agents resume <new>` doesn't proceed on a failed fork.
39
+ console.error(chalk.red(`No session matching "${sessionArg}".`));
40
+ console.error(chalk.gray('List candidates with: agents sessions'));
41
+ process.exitCode = 1;
39
42
  return;
40
43
  }
41
44
  if (matches.length > 1) {
42
- console.log(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
45
+ console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
43
46
  for (const m of matches.slice(0, 8)) {
44
- console.log(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
47
+ console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
45
48
  }
49
+ process.exitCode = 1;
46
50
  return;
47
51
  }
48
52
  const source = matches[0];
49
53
  if (!isForkableAgent(source.agent)) {
50
- console.log(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
51
- console.log(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
54
+ console.error(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
55
+ console.error(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
56
+ process.exitCode = 1;
52
57
  return;
53
58
  }
54
59
  let result;
@@ -56,7 +61,8 @@ export function registerForkCommand(program) {
56
61
  result = forkSession(source, { name: options.name });
57
62
  }
58
63
  catch (err) {
59
- console.log(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
64
+ console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
65
+ process.exitCode = 1;
60
66
  return;
61
67
  }
62
68
  console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
@@ -9,6 +9,7 @@
9
9
  import chalk from 'chalk';
10
10
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
11
11
  import { checkbox, confirm } from '@inquirer/prompts';
12
+ import { isInteractiveTerminal } from './utils.js';
12
13
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
13
14
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
14
15
  import { getDevice } from '../lib/devices/registry.js';
@@ -42,6 +43,12 @@ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolv
42
43
  const remoteVer = remoteAgentsVersion(target, os);
43
44
  const localVer = localCliVersion();
44
45
  if (!remoteVer) {
46
+ // Non-TTY: this is a best-effort bootstrap prompt, so report and return
47
+ // instead of hanging on confirm() in a headless/piped shell.
48
+ if (!isInteractiveTerminal()) {
49
+ console.log(chalk.gray(` agents-cli not found on ${hostName} — install it there, then: agents hosts check ${hostName}`));
50
+ return;
51
+ }
45
52
  const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
46
53
  if (ok) {
47
54
  console.log(chalk.gray(' Installing agents-cli on the host…'));
@@ -52,6 +59,10 @@ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolv
52
59
  }
53
60
  const remoteClean = remoteVer.replace(/^v/, '');
54
61
  if (localVer && remoteClean !== localVer) {
62
+ if (!isInteractiveTerminal()) {
63
+ console.log(chalk.gray(` ${hostName} has agents-cli ${remoteClean}, you have ${localVer} — versions differ; upgrade the host to match when convenient.`));
64
+ return;
65
+ }
55
66
  const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
56
67
  if (ok) {
57
68
  const r = bootstrapAgentsCli(target, localVer, os);
@@ -49,6 +49,7 @@ export declare function parseImportSource(opts: {
49
49
  export declare function buildRemoteUnlockArgs(names: string[], opts: {
50
50
  all?: boolean;
51
51
  ttl?: string;
52
+ durable?: boolean;
52
53
  }): string[];
53
54
  export { SSH_TARGET_RE, assertValidSshTarget };
54
55
  /**
@@ -20,7 +20,8 @@ import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBund
20
20
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
21
21
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
22
22
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
23
- import { secretsHoldMs, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
23
+ import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
24
+ import { saveSession, deleteSession, deleteAllSessions } from '../lib/secrets/session-store.js';
24
25
  import { getCliVersionFresh } from '../lib/version.js';
25
26
  import { readMeta } from '../lib/state.js';
26
27
  import { parseDuration } from '../lib/hooks/cache.js';
@@ -249,6 +250,9 @@ export function buildRemoteUnlockArgs(names, opts) {
249
250
  'unlock',
250
251
  ...(opts.all ? ['--all'] : names),
251
252
  ...(opts.ttl ? ['--ttl', opts.ttl] : []),
253
+ // Forward --durable so a remote unlock honors it too; without this the remote
254
+ // silently falls back to its own secrets.agent.durable default (off).
255
+ ...(opts.durable ? ['--durable'] : []),
252
256
  ];
253
257
  }
254
258
  // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
@@ -1840,6 +1844,7 @@ Examples:
1840
1844
  .command('unlock [names...]')
1841
1845
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
1842
1846
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
1847
+ .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
1843
1848
  .option('--all', 'Unlock every configured bundle')
1844
1849
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
1845
1850
  .action(async (names, opts) => {
@@ -1880,8 +1885,12 @@ Examples:
1880
1885
  return;
1881
1886
  }
1882
1887
  if (process.platform !== 'darwin') {
1883
- console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
1884
- process.exit(1);
1888
+ // No broker + no biometry prompt off darwin: secrets already resolve
1889
+ // durably from the OS store (libsecret / Credential Manager) on every
1890
+ // read with no prompt, so an unlock is a friendly no-op — not an error.
1891
+ // Accept --durable as a documented no-op so the command is uniform.
1892
+ console.log(chalk.gray('Nothing to unlock: secrets already persist on this OS — reads never re-prompt.'));
1893
+ return;
1885
1894
  }
1886
1895
  let targets = opts.all ? listBundles().map((b) => b.name) : names;
1887
1896
  if (!targets || targets.length === 0) {
@@ -1915,6 +1924,14 @@ Examples:
1915
1924
  const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock' });
1916
1925
  if (await agentLoad(name, bundle, env, ttlMs)) {
1917
1926
  loaded++;
1927
+ // Persist a durable session snapshot so the unlock survives a daemon
1928
+ // restart / upgrade (and sleep too, with --durable). session-store.ts.
1929
+ saveSession(name, {
1930
+ bundle,
1931
+ env,
1932
+ expiresAt: Date.now() + ttlMs,
1933
+ sleepPersist: opts.durable ?? secretsAgentDurable(),
1934
+ });
1918
1935
  console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
1919
1936
  }
1920
1937
  else {
@@ -1941,12 +1958,15 @@ Examples:
1941
1958
  return; // nothing to lock off darwin
1942
1959
  if (names && names.length > 0 && !opts.all) {
1943
1960
  let total = 0;
1944
- for (const name of names)
1961
+ for (const name of names) {
1945
1962
  total += await agentLock(name);
1963
+ deleteSession(name); // also drop the durable snapshot, or a restart re-warms it
1964
+ }
1946
1965
  console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
1947
1966
  }
1948
1967
  else {
1949
1968
  const wiped = await agentLock();
1969
+ deleteAllSessions();
1950
1970
  console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
1951
1971
  }
1952
1972
  });
@@ -218,8 +218,7 @@ export function registerSessionsTailCommand(sessionsCmd) {
218
218
  .command('tail [sessionId]')
219
219
  .description('Stream JSONL events from a session file as they are written, one event per line. Long-running: Ctrl+C to stop. Claude and Codex only.')
220
220
  .option('--latest', 'Tail the most recent tailable session (claude or codex)')
221
- .option('--from-start', 'Emit the full file first, then follow (default: start at EOF)')
222
- .option('--json', 'Raw JSONL passthrough (default)');
221
+ .option('--from-start', 'Emit the full file first, then follow (default: start at EOF)');
223
222
  setHelpSections(tailCmd, {
224
223
  examples: `
225
224
  # Follow the most recent active Claude or Codex session