@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.
- package/CHANGELOG.md +81 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.js +6 -6
- package/dist/commands/ssh.js +63 -16
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -0
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- 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/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/types.d.ts +5 -1
- 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 +
|
|
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`
|
|
457
|
-
|
|
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
|
package/dist/commands/apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/check.js
CHANGED
|
@@ -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.
|
|
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];
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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: {
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -34,21 +34,26 @@ export function registerForkCommand(program) {
|
|
|
34
34
|
matches = findSessionsById(sessionArg, {});
|
|
35
35
|
}
|
|
36
36
|
if (matches.length === 0) {
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
-
console.
|
|
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.
|
|
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}`));
|
package/dist/commands/hosts.js
CHANGED
|
@@ -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);
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
-
|
|
1884
|
-
|
|
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
|