@phnx-labs/agents-cli 1.20.70 → 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 (51) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/exec.js +12 -0
  6. package/dist/commands/fleet-capture.d.ts +14 -0
  7. package/dist/commands/fleet-capture.js +107 -0
  8. package/dist/commands/fork.js +13 -7
  9. package/dist/commands/hosts.js +11 -0
  10. package/dist/commands/secrets.d.ts +1 -0
  11. package/dist/commands/secrets.js +24 -4
  12. package/dist/commands/sessions-tail.js +1 -2
  13. package/dist/commands/sessions.js +6 -6
  14. package/dist/commands/ssh.js +24 -6
  15. package/dist/commands/usage.d.ts +13 -0
  16. package/dist/commands/usage.js +50 -28
  17. package/dist/lib/commands.d.ts +10 -0
  18. package/dist/lib/commands.js +31 -7
  19. package/dist/lib/devices/connect.d.ts +13 -0
  20. package/dist/lib/devices/connect.js +20 -0
  21. package/dist/lib/devices/fleet.d.ts +36 -3
  22. package/dist/lib/devices/fleet.js +42 -4
  23. package/dist/lib/devices/sync.d.ts +30 -0
  24. package/dist/lib/devices/sync.js +50 -0
  25. package/dist/lib/doctor-diff.js +38 -16
  26. package/dist/lib/fleet/apply.d.ts +4 -0
  27. package/dist/lib/fleet/apply.js +28 -5
  28. package/dist/lib/fleet/capture.d.ts +35 -0
  29. package/dist/lib/fleet/capture.js +51 -0
  30. package/dist/lib/fleet/manifest.d.ts +6 -0
  31. package/dist/lib/fleet/manifest.js +24 -1
  32. package/dist/lib/fleet/types.d.ts +24 -1
  33. package/dist/lib/git.d.ts +14 -0
  34. package/dist/lib/git.js +39 -1
  35. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  36. package/dist/lib/menubar/install-menubar.js +26 -13
  37. package/dist/lib/secrets/agent.d.ts +5 -0
  38. package/dist/lib/secrets/agent.js +35 -3
  39. package/dist/lib/secrets/bundles.js +28 -0
  40. package/dist/lib/secrets/index.d.ts +14 -0
  41. package/dist/lib/secrets/index.js +36 -5
  42. package/dist/lib/secrets/session-store.d.ts +90 -0
  43. package/dist/lib/secrets/session-store.js +221 -0
  44. package/dist/lib/session/render.d.ts +9 -1
  45. package/dist/lib/session/render.js +35 -4
  46. package/dist/lib/share/capture.d.ts +2 -0
  47. package/dist/lib/share/capture.js +12 -5
  48. package/dist/lib/skills.d.ts +8 -1
  49. package/dist/lib/skills.js +11 -1
  50. package/dist/lib/types.d.ts +5 -1
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
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
+
3
63
  ## 1.20.70
4
64
 
5
65
  - **Fix `agents setup computer` / `agents computer setup` refusing to install a
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];
@@ -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
@@ -998,7 +998,7 @@ async function sessionsAction(query, options) {
998
998
  // When the user explicitly asks to render (via mode flag), resolve the
999
999
  // query globally so sessions outside the default cwd/30d window are found.
1000
1000
  if (wantsRender && searchQuery) {
1001
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1001
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1002
1002
  return;
1003
1003
  }
1004
1004
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1068,7 +1068,7 @@ async function sessionsAction(query, options) {
1068
1068
  return;
1069
1069
  }
1070
1070
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1071
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1071
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1072
1072
  return;
1073
1073
  }
1074
1074
  }
@@ -1475,7 +1475,7 @@ async function renderSession(session, mode, filters, options = {}) {
1475
1475
  chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
1476
1476
  (session.account ? chalk.gray(` (${session.account})`) : ''));
1477
1477
  console.log(chalk.gray('─'.repeat(60)));
1478
- process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.noRedact !== true })));
1478
+ process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.redact !== false })));
1479
1479
  return;
1480
1480
  }
1481
1481
  // json — normalized events plus the durable session signals from the state
@@ -1486,7 +1486,7 @@ async function renderSession(session, mode, filters, options = {}) {
1486
1486
  // checklist reflects true session state regardless of any `--include` filter;
1487
1487
  // it lets the Factory panel read the CLI's checklist instead of re-parsing.
1488
1488
  const todos = inferSessionState(parsedEvents, { cwd: session.cwd }).todos;
1489
- process.stdout.write(renderJson(events, todos ? { ...session, todos } : session));
1489
+ process.stdout.write(renderJson(events, todos ? { ...session, todos } : session, { redact: options.redact !== false }));
1490
1490
  }
1491
1491
  function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
1492
1492
  const lbl = (label ?? '').trim();
@@ -2162,7 +2162,7 @@ async function renderOneSession(query, mode, scope) {
2162
2162
  throw new Error('Session resolution failed');
2163
2163
  }
2164
2164
  spinner.stop();
2165
- await renderSession(session, mode, scope.filter, { noRedact: scope.noRedact });
2165
+ await renderSession(session, mode, scope.filter, { redact: scope.redact });
2166
2166
  }
2167
2167
  catch (err) {
2168
2168
  if (isPromptCancelled(err))
@@ -2195,7 +2195,7 @@ export function registerSessionsCommands(program) {
2195
2195
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
2196
2196
  .option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
2197
2197
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2198
- .option('--no-redact', 'Disable default secret redaction in markdown session output')
2198
+ .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2199
2199
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2200
2200
  .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2201
2201
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')