@phnx-labs/agents-cli 1.20.39 → 1.20.41

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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.41
6
+
7
+ - **NEW: `agents sessions focus [id]`** — one command to get back to a session, however it's reachable. It **attaches** a live session in place (tmux `switch-client`/`attach-session`, a remote tmux over `ssh -tt`, or a Ghostty tab — joining the live process without forking); where there's **no live terminal to attach**, it **opens a new tab and resumes** the session — locally, or on the remote peer over SSH (`runOnPeer`, so the peer resolves the version-pinned binary). No id opens the rich live-session picker (this-machine first). Reuses the live-session detection and the terminal launch engine (`openSurfaces`), and folds `go`'s attach paths in. Source: `src/commands/focus.ts`, `src/commands/go.ts`.
8
+ - **`--device` is now a first-class alias of `--host`** on every host-routable command (`sessions`, `run`, …), registered centrally on `addHostOption` so a local fall-through no longer errors. Source: `src/lib/hosts/`.
9
+ - **`agents computer` steers Electron/webview targets over CDP** instead of reporting a fake success when the native-automation path can't reach them (#716).
10
+ - **Secrets: the "remember" policy hold now lasts 7 days and survives screen-lock**, instead of re-prompting after every lock/sleep; stale copies are evicted when a policy is tightened. Source: `src/lib/secrets/`.
11
+ - **Fixes:** shim `machine_id()` normalizes to match `normalizeHost()`, and shim resolution honors the per-device default pin (not just the central `agents.yaml`).
12
+ - **`agents sessions go` is retired as a deprecated alias for `agents sessions focus --attach-only`.** `go` was already a strict subset of `focus` — its only unique behavior was "attach the live terminal or refuse, never fork/resume." That behavior is now a first-class `--attach-only` flag on `focus` (`focus.ts`: `selectFallback()` picks `refuseFallback` under `--attach-only`, else the resume-in-a-new-tab fallback). `go` now prints a one-line deprecation notice and delegates to `focusAction(id, { attachOnly: true })`; the shared reach engine (`jumpTo`/`gatherLiveTargets`/`pickLiveTarget`/`refuseFallback`) still lives in `go.ts` and is imported by `focus.ts`. Source: `src/commands/go.ts`, `src/commands/focus.ts`.
13
+ - **`agents sessions --json --host <h>` now emits a clean JSON array** of recent (non-active) sessions instead of the legacy per-host raw banner stream, so a UI can fetch a remote device's recent sessions when it has no live agents. `serializeSessionsJson()` is shared by the local and remote `--json` paths; `runRemoteSessionsJson()` reuses the existing `gatherRemoteList` SSH fan-out. The non-JSON banner path and `--active` are unchanged (#711).
14
+
5
15
  ## 1.20.36
6
16
 
7
17
  **[windows] `agents sessions --active` detects sessions on Windows, and shim launches carry cwd + session identity everywhere**
@@ -80,4 +80,6 @@ export declare function shouldRaise(opts: {
80
80
  id?: string;
81
81
  raise?: boolean;
82
82
  }): boolean;
83
+ export declare function appPathIsElectron(appPath: string | null, exists?: (p: string) => boolean): boolean;
84
+ export declare function electronWebviewTip(appLabel: string): string;
83
85
  export declare function registerActionCommands(program: Command): void;
@@ -6,6 +6,7 @@
6
6
  // The daemon already implements every method; this file is the thin, typed
7
7
  // CLI skin over it plus a shared target resolver so callers stay in bundle-id
8
8
  // space and never hand-manage pids.
9
+ import { execFileSync } from 'child_process';
9
10
  import * as fs from 'fs';
10
11
  import * as path from 'path';
11
12
  import { openComputerClient, describeTransport, resolvePolicyPath, } from '../lib/computer-rpc.js';
@@ -288,6 +289,59 @@ async function applyFocusPolicy(client, pid, opts) {
288
289
  if (shouldRaise(opts))
289
290
  unwrap(await client.call('focus_window', { pid }));
290
291
  }
292
+ // Electron/webview steering. macOS accepts an AX action (AXPress / set-AXValue)
293
+ // on an Electron/Chromium window, but it does NOT run the web app's real DOM
294
+ // handlers — React ignores it — so a reported `clicked`/`typed` on a webview can
295
+ // be a silent no-op. Detect Electron targets and steer the caller to CDP
296
+ // (`agents browser --electron`), which drives the webview for real. We warn, not
297
+ // block: the caller may still want the raw action (e.g. to focus + coordinate).
298
+ const electronCache = new Map();
299
+ // Resolve a bundle id to its .app path via Spotlight. Best-effort; null on miss.
300
+ function appPathForBundle(bundleId) {
301
+ try {
302
+ const out = execFileSync('mdfind', [`kMDItemCFBundleIdentifier == '${bundleId}'`], {
303
+ encoding: 'utf-8',
304
+ timeout: 3000,
305
+ });
306
+ return out.split('\n').map((s) => s.trim()).find((s) => s.endsWith('.app')) ?? null;
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ // Pure + unit-tested: does the .app at this path bundle the Electron framework?
313
+ export function appPathIsElectron(appPath, exists = fs.existsSync) {
314
+ if (!appPath)
315
+ return false;
316
+ return exists(path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework'));
317
+ }
318
+ // Is the app for this bundle id an Electron/webview app? macOS-only; memoized so
319
+ // the mdfind lookup runs at most once per bundle id per process.
320
+ function isElectronApp(bundleId) {
321
+ if (!bundleId || process.platform !== 'darwin')
322
+ return false;
323
+ const cached = electronCache.get(bundleId);
324
+ if (cached !== undefined)
325
+ return cached;
326
+ const result = appPathIsElectron(appPathForBundle(bundleId));
327
+ electronCache.set(bundleId, result);
328
+ return result;
329
+ }
330
+ // Pure + unit-tested: the CDP-steer note printed for a webview target.
331
+ export function electronWebviewTip(appLabel) {
332
+ return `note: ${appLabel} is an Electron/web UI — an AX click/type may not reach the webview `
333
+ + `(a reported success can be a no-op). To drive it reliably, relaunch it with `
334
+ + '`--remote-debugging-port=9222` and use `agents browser --electron` (CDP).';
335
+ }
336
+ // Print the CDP steer when the target is a known Electron app. Keyed off --bundle
337
+ // (the recommended way to target); a frontmost-resolved target without --bundle is
338
+ // left alone to avoid a second RPC on the hot path. Skipped for remote --host.
339
+ function warnIfElectronWebview(opts) {
340
+ if (opts.host)
341
+ return;
342
+ if (opts.bundle && isElectronApp(opts.bundle))
343
+ console.error(electronWebviewTip(opts.bundle));
344
+ }
291
345
  function emit(result, json, human) {
292
346
  if (json) {
293
347
  console.log(JSON.stringify(result, null, 2));
@@ -355,6 +409,7 @@ export function registerActionCommands(program) {
355
409
  .option('--json', 'Emit JSON'))).action(async (opts) => {
356
410
  await withClient(async (client) => {
357
411
  const pid = await resolveTargetPid(client, opts, { verb: 'click' });
412
+ warnIfElectronWebview(opts);
358
413
  const spec = buildElementOrCoords(opts);
359
414
  if (!spec.ok) {
360
415
  console.error(spec.error);
@@ -377,6 +432,7 @@ export function registerActionCommands(program) {
377
432
  .option('--json', 'Emit JSON'))).action(async (opts) => {
378
433
  await withClient(async (client) => {
379
434
  const pid = await resolveTargetPid(client, opts, { verb: 'right-click' });
435
+ warnIfElectronWebview(opts);
380
436
  const spec = buildElementOrCoords(opts);
381
437
  if (!spec.ok) {
382
438
  console.error(spec.error);
@@ -397,6 +453,7 @@ export function registerActionCommands(program) {
397
453
  .option('--json', 'Emit JSON'))).action(async (opts) => {
398
454
  await withClient(async (client) => {
399
455
  const pid = await resolveTargetPid(client, opts, { verb: 'type' });
456
+ warnIfElectronWebview(opts);
400
457
  const spec = buildElementOrCoords(opts);
401
458
  if (!spec.ok) {
402
459
  console.error(spec.error);
@@ -424,6 +481,7 @@ export function registerActionCommands(program) {
424
481
  .option('--json', 'Emit JSON')).action(async (opts) => {
425
482
  await withClient(async (client) => {
426
483
  const pid = await resolveTargetPid(client, opts, { verb: 'type-text' });
484
+ warnIfElectronWebview(opts);
427
485
  await applyFocusPolicy(client, pid, opts);
428
486
  const params = { pid, text: opts.text };
429
487
  if (opts.commit)
@@ -448,6 +506,7 @@ export function registerActionCommands(program) {
448
506
  .option('--json', 'Emit JSON')).action(async (opts) => {
449
507
  await withClient(async (client) => {
450
508
  const pid = await resolveTargetPid(client, opts, { verb: 'key' });
509
+ warnIfElectronWebview(opts);
451
510
  await applyFocusPolicy(client, pid, opts);
452
511
  const params = { pid, keys: opts.keys };
453
512
  if (opts.requireFrontmost)
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `agents sessions focus [id]` — take me to a live session, however it's reachable.
3
+ *
4
+ * Same detection as `go`, but where `go` *refuses* an un-attachable session,
5
+ * `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
6
+ * (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
7
+ * - in tmux (local/remote) -> attach the live pane (join it, no fork)
8
+ * - in Ghostty -> focus its tab
9
+ * - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
10
+ * original keeps going; a clean continue if it's idle)
11
+ *
12
+ * NOTE: joining a live process without forking is only possible via tmux — that's
13
+ * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
+ */
15
+ import type { Command } from 'commander';
16
+ import { type UnreachableFallback } from './go.js';
17
+ import type { ActiveSession } from '../lib/session/active.js';
18
+ import type { SessionMeta } from '../lib/session/types.js';
19
+ export declare function registerFocusCommand(program: Command): void;
20
+ /**
21
+ * Which fallback fires when a session has no attach rail. `--attach-only` (the old
22
+ * `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
23
+ * without touching `jumpTo`'s side effects.
24
+ */
25
+ export declare function selectFallback(attachOnly: boolean | undefined): UnreachableFallback;
26
+ export declare function focusAction(id: string | undefined, opts: {
27
+ local?: boolean;
28
+ attachOnly?: boolean;
29
+ }): Promise<void>;
30
+ /** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
31
+ export declare function metaFromActive(s: ActiveSession): SessionMeta;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `agents sessions focus [id]` — take me to a live session, however it's reachable.
3
+ *
4
+ * Same detection as `go`, but where `go` *refuses* an un-attachable session,
5
+ * `focus` **opens a new tab and resumes it** — locally, or on the remote over SSH
6
+ * (via the terminal launch engine's `openSurfaces`, `host` = the peer). So:
7
+ * - in tmux (local/remote) -> attach the live pane (join it, no fork)
8
+ * - in Ghostty -> focus its tab
9
+ * - headless / plain / etc. -> new tab + `resume` (a copy if it's mid-run — the
10
+ * original keeps going; a clean continue if it's idle)
11
+ *
12
+ * NOTE: joining a live process without forking is only possible via tmux — that's
13
+ * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
+ */
15
+ import fs from 'node:fs';
16
+ import chalk from 'chalk';
17
+ import { gatherLiveTargets, pickLiveTarget, jumpTo, refuseFallback } from './go.js';
18
+ import { buildResumeCommand, resumeSessionInPlace } from './sessions.js';
19
+ import { runOnPeer } from '../lib/session/remote-list.js';
20
+ import { discoverSessions } from '../lib/session/discover.js';
21
+ import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
22
+ import { isInteractiveTerminal } from './utils.js';
23
+ export function registerFocusCommand(program) {
24
+ program
25
+ .command('focus')
26
+ .argument('[id]', 'Short/full session id to focus; omit for an interactive picker')
27
+ .option('--local', 'Only this machine (skip the cross-host sweep)')
28
+ .option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
29
+ .description('Focus a live session — attach its terminal, or open a new tab and resume it')
30
+ .action(async (id, opts) => {
31
+ await focusAction(id, opts);
32
+ });
33
+ }
34
+ /**
35
+ * Which fallback fires when a session has no attach rail. `--attach-only` (the old
36
+ * `go`) refuses; the default opens a new tab and resumes a copy. Pure so it's testable
37
+ * without touching `jumpTo`'s side effects.
38
+ */
39
+ export function selectFallback(attachOnly) {
40
+ return attachOnly ? refuseFallback : resumeInNewTab;
41
+ }
42
+ export async function focusAction(id, opts) {
43
+ const { self, activeById } = await gatherLiveTargets(!!opts.local);
44
+ const fallback = selectFallback(opts.attachOnly);
45
+ if (id) {
46
+ const q = id.toLowerCase();
47
+ const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
48
+ if (matches.length === 1) {
49
+ await jumpTo(matches[0], self, fallback);
50
+ return;
51
+ }
52
+ if (matches.length > 1) {
53
+ console.error(chalk.red(`"${id}" is ambiguous (${matches.length} live matches). Use more of the id.`));
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ // Not live — it's a past session; resume is the right tool (multi-select + placement).
58
+ console.log(chalk.yellow(`No live session matching "${id}".`) +
59
+ chalk.gray(`\nTo resume a past session: agents sessions resume ${id}`));
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ if (!isInteractiveTerminal()) {
64
+ console.error(chalk.red('focus needs an interactive terminal, or pass a session id.'));
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ if (activeById.size === 0) {
69
+ console.log(chalk.gray('No live sessions to focus. To resume a past one: agents sessions resume'));
70
+ return;
71
+ }
72
+ const target = await pickLiveTarget(activeById, self, 'Focus a live session:', 'focus');
73
+ if (!target)
74
+ return;
75
+ await jumpTo(target, self, fallback);
76
+ }
77
+ function shortId(s) {
78
+ return (s.sessionId ?? '').slice(0, 8) || '-';
79
+ }
80
+ /** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
81
+ export function metaFromActive(s) {
82
+ return {
83
+ id: s.sessionId ?? '',
84
+ shortId: shortId(s),
85
+ agent: s.kind,
86
+ timestamp: new Date(s.startedAtMs ?? Date.now()).toISOString(),
87
+ filePath: '',
88
+ cwd: s.cwd,
89
+ };
90
+ }
91
+ /** Look up the rich indexed SessionMeta by id so `version` survives (version-pinned resume). */
92
+ async function richMetaById(id) {
93
+ try {
94
+ const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
95
+ return metas.find((m) => m.id === id) ?? metas.find((m) => m.id.startsWith(id));
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ /**
102
+ * `focus`'s fallback for a session with no attach rail: reopen it and hand you to it.
103
+ * - remote → resume ON the peer over SSH (foreground) — the peer resolves the pinned
104
+ * version and holds the transcript, and `-tt` delivers you there.
105
+ * - local → resume in a new tab in your terminal, version-pinned via the indexed meta.
106
+ * Note: for a session that's still mid-run, this opens a COPY (the original keeps going);
107
+ * only tmux can *join* a live one without forking (see the header).
108
+ */
109
+ const resumeInNewTab = async (s, remote) => {
110
+ const id = s.sessionId ?? '';
111
+ if (!id) {
112
+ console.log(chalk.yellow('This session has no id to resume.'));
113
+ return;
114
+ }
115
+ // Remote: the transcript + pinned version live on the peer, so resume THERE over SSH.
116
+ // runOnPeer runs `agents sessions resume <id>` with a real TTY (`-tt`) in the foreground —
117
+ // it actually delivers you to the session (the peer picks the right version + HOME).
118
+ if (remote) {
119
+ console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
120
+ const rc = await runOnPeer(['sessions', 'resume', id], remote, { tty: true });
121
+ if (rc === 'no-target') {
122
+ console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
123
+ console.log(chalk.gray(` or run it yourself: ssh ${remote} 'agents sessions resume ${shortId(s)}'`));
124
+ }
125
+ return;
126
+ }
127
+ // Local: resume in a new tab. Use the indexed meta so the version-pinned binary
128
+ // resumes in the same isolated HOME the transcript was written in.
129
+ const meta = (await richMetaById(id)) ?? metaFromActive(s);
130
+ const command = buildResumeCommand(meta);
131
+ if (!command) {
132
+ console.log(chalk.yellow(`${meta.shortId} — ${meta.agent} sessions aren't resumable, so there's no way to reopen it.`));
133
+ return;
134
+ }
135
+ const cwd = meta.cwd && fs.existsSync(meta.cwd) ? meta.cwd : process.cwd();
136
+ const ctx = currentContext();
137
+ const backend = detectCurrentBackend(ctx) ?? availableBackends(ctx)[0]?.id;
138
+ if (!backend) {
139
+ // No tab-capable surface (off-macOS, not in tmux) — resume in this process.
140
+ await resumeSessionInPlace(meta);
141
+ return;
142
+ }
143
+ console.log(chalk.gray(`${shortId(s)} has no live terminal to attach — opening a new ${backend} tab and resuming a copy.`));
144
+ const results = await openSurfaces([{ cwd, command }], { backend, packing: 'tabs' });
145
+ const r = results[0];
146
+ if (!r || !r.ok) {
147
+ console.log(chalk.red(` failed to open — ${r?.error ?? 'unknown error'}`));
148
+ console.log(chalk.gray(` try: agents sessions resume ${meta.shortId}`));
149
+ }
150
+ };
@@ -1,21 +1,35 @@
1
1
  /**
2
- * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
2
+ * `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
3
3
  *
4
- * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
- * changed files, tools, tests, last response this-machine first),
6
- * filtered to sessions that are running right now.
7
- * With id -> jump directly.
4
+ * `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
5
+ * that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
8
6
  *
9
- * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
- * you to the already-running terminal:
11
- * local tmux -> attach (switch-client when already inside tmux)
12
- * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
- * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
- * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
7
+ * This file still owns the shared reach engine that `focus` imports:
8
+ * - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
9
+ * - `jumpTo` the side-effecting jump: attach the already-running terminal
10
+ * local tmux -> attach (switch-client when already inside tmux)
11
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
12
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
13
+ * otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
14
+ * - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
15
15
  */
16
16
  import type { Command } from 'commander';
17
17
  import { type ActiveSession } from '../lib/session/active.js';
18
+ import type { SessionMeta } from '../lib/session/types.js';
18
19
  export declare function registerGoCommand(program: Command): void;
20
+ /** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
21
+ export declare function gatherLiveTargets(local: boolean): Promise<{
22
+ self: string;
23
+ activeById: Map<string, ActiveSession>;
24
+ }>;
25
+ /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
26
+ export declare function pickLiveTarget(activeById: Map<string, ActiveSession>, self: string, message: string, enterHint: string): Promise<ActiveSession | null>;
27
+ /**
28
+ * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
29
+ * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
30
+ * sessions get a minimal synthesized meta so they still appear and jump.
31
+ */
32
+ export declare function buildLivePool(activeById: Map<string, ActiveSession>, self: string): Promise<SessionMeta[]>;
19
33
  export interface Where {
20
34
  label: string;
21
35
  action: string;
@@ -26,3 +40,12 @@ export interface Where {
26
40
  * `jumpTo` below: remote-tmux, then local-tmux, then ghostty, then refuse.
27
41
  */
28
42
  export declare function describeWhere(s: ActiveSession, self: string): Where;
43
+ /**
44
+ * What to do when a session can't be *attached* (no tmux/Ghostty rail). `go`
45
+ * refuses; `focus` opens a new tab and resumes. `remote` is the peer name when
46
+ * the session lives on another machine, else undefined.
47
+ */
48
+ export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
49
+ /** Default (attach-only): open a login shell on the remote, or refuse locally. */
50
+ export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
51
+ export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;
@@ -1,17 +1,17 @@
1
1
  /**
2
- * `agents sessions go [id]` — jump to a LIVE agent session's terminal.
2
+ * `agents sessions go [id]` — DEPRECATED alias for `agents sessions focus --attach-only`.
3
3
  *
4
- * No id -> the SAME rich interactive picker as `agents sessions` (worktree, PR,
5
- * changed files, tools, tests, last response this-machine first),
6
- * filtered to sessions that are running right now.
7
- * With id -> jump directly.
4
+ * `go` was "attach or refuse" (never fork/resume). `focus --attach-only` is exactly
5
+ * that behavior, so `go` now prints a deprecation notice and delegates to `focusAction`.
8
6
  *
9
- * "Jump" is not "resume" (which spawns a new process from the transcript). It walks
10
- * you to the already-running terminal:
11
- * local tmux -> attach (switch-client when already inside tmux)
12
- * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
13
- * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
14
- * otherwise -> refuse with a reason + resume hint (cloud / no attach rail)
7
+ * This file still owns the shared reach engine that `focus` imports:
8
+ * - `gatherLiveTargets` / `pickLiveTarget` / `buildLivePool` — live-session discovery + picker
9
+ * - `jumpTo` the side-effecting jump: attach the already-running terminal
10
+ * local tmux -> attach (switch-client when already inside tmux)
11
+ * local Ghostty -> focus its tab (Cmd+<n> via System Events; tab # from ghostty-tabs)
12
+ * remote tmux -> ssh -tt + tmux attach (pane->session resolved on the remote)
13
+ * otherwise -> hand off to the `UnreachableFallback` (attach-only refuses; focus resumes)
14
+ * - `refuseFallback` — the attach-only fallback (remote -> login shell; local -> refuse)
15
15
  */
16
16
  import chalk from 'chalk';
17
17
  import path from 'path';
@@ -21,8 +21,8 @@ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { discoverSessions } from '../lib/session/discover.js';
23
23
  import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
24
+ import { focusAction } from './focus.js';
24
25
  import { machineId } from '../lib/session/sync/config.js';
25
- import { isInteractiveTerminal } from './utils.js';
26
26
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
27
27
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
28
28
  import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
@@ -33,20 +33,21 @@ export function registerGoCommand(program) {
33
33
  .command('go')
34
34
  .argument('[id]', 'Short/full session id to jump to; omit for an interactive picker')
35
35
  .option('--local', 'Only this machine (skip the cross-host sweep)')
36
- .description('Jump to a live agent session — attach its tmux, or focus its terminal tab')
36
+ .description('Deprecated alias for `sessions focus --attach-only`')
37
37
  .action(async (id, opts) => {
38
- await goAction(id, opts);
38
+ console.error(chalk.yellow('`sessions go` is deprecated — use `sessions focus --attach-only`'));
39
+ await focusAction(id, { local: opts.local, attachOnly: true });
39
40
  });
40
41
  }
41
- async function goAction(id, opts) {
42
+ /** Live jump targets (local + remote), keyed by session id. Cloud excluded (no pid). */
43
+ export async function gatherLiveTargets(local) {
42
44
  const self = machineId();
43
- // Live jump targets (local + remote), keyed by session id.
44
45
  const localActive = await getActiveSessions();
45
46
  for (const s of localActive)
46
47
  if (!s.machine)
47
48
  s.machine = self;
48
49
  let active = localActive;
49
- if (!opts.local) {
50
+ if (!local) {
50
51
  try {
51
52
  const remote = await gatherRemoteActive();
52
53
  active = dedupeByMachineSession([...localActive, ...remote.sessions]);
@@ -57,54 +58,24 @@ async function goAction(id, opts) {
57
58
  for (const s of active)
58
59
  if (s.context !== 'cloud' && s.sessionId)
59
60
  activeById.set(s.sessionId, s);
60
- if (activeById.size === 0) {
61
- console.log(chalk.gray('No live agent sessions to jump to.'));
62
- return;
63
- }
64
- // Direct jump by id — no picker.
65
- if (id) {
66
- const q = id.toLowerCase();
67
- const matches = [...activeById.values()].filter((s) => s.sessionId.toLowerCase().startsWith(q));
68
- if (matches.length === 0) {
69
- console.error(chalk.red(`No live session matching "${id}".`));
70
- process.exitCode = 1;
71
- return;
72
- }
73
- if (matches.length > 1) {
74
- console.error(chalk.red(`"${id}" is ambiguous (${matches.length} matches). Use more of the id.`));
75
- process.exitCode = 1;
76
- return;
77
- }
78
- await jumpTo(matches[0], self);
79
- return;
80
- }
81
- if (!isInteractiveTerminal()) {
82
- console.error(chalk.red('go needs an interactive terminal, or pass a session id.'));
83
- process.exitCode = 1;
84
- return;
85
- }
86
- // Reuse the rich `sessions` picker over the live sessions' full SessionMeta.
61
+ return { self, activeById };
62
+ }
63
+ /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
64
+ export async function pickLiveTarget(activeById, self, message, enterHint) {
87
65
  const pool = await buildLivePool(activeById, self);
88
- if (pool.length === 0) {
89
- console.log(chalk.gray('No live sessions to jump to.'));
90
- return;
91
- }
92
- const picked = await pickSessionInteractive(pool, 'Jump to a live session:', undefined, 0, 'jump');
66
+ if (pool.length === 0)
67
+ return null;
68
+ const picked = await pickSessionInteractive(pool, message, undefined, 0, enterHint);
93
69
  if (!picked)
94
- return;
95
- const target = activeById.get(picked.session.id);
96
- if (!target) {
97
- console.log(chalk.yellow(`${picked.session.shortId} is no longer live — try: `) + chalk.gray(`agents sessions resume ${picked.session.shortId}`));
98
- return;
99
- }
100
- await jumpTo(target, self);
70
+ return null;
71
+ return activeById.get(picked.session.id) ?? null;
101
72
  }
102
73
  /**
103
74
  * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
104
75
  * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
105
76
  * sessions get a minimal synthesized meta so they still appear and jump.
106
77
  */
107
- async function buildLivePool(activeById, self) {
78
+ export async function buildLivePool(activeById, self) {
108
79
  let metas = [];
109
80
  try {
110
81
  metas = await discoverSessions({ all: true, since: '30d', limit: 1000 });
@@ -161,7 +132,17 @@ export function describeWhere(s, self) {
161
132
  return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
162
133
  return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
163
134
  }
164
- async function jumpTo(s, self) {
135
+ /** Default (attach-only): open a login shell on the remote, or refuse locally. */
136
+ export async function refuseFallback(s, remote) {
137
+ if (remote) {
138
+ console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux — opening a shell on ${remote} instead.`));
139
+ assertValidSshTarget(remote);
140
+ process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
141
+ }
142
+ console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
143
+ chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
144
+ }
145
+ export async function jumpTo(s, self, fallback = refuseFallback) {
165
146
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
166
147
  const mux = s.provenance?.mux;
167
148
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
@@ -177,9 +158,9 @@ async function jumpTo(s, self) {
177
158
  console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
178
159
  process.exit(sshStream(remote, remoteCmd, { tty: true }));
179
160
  }
180
- console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux opening a shell on ${remote} instead.`));
181
- assertValidSshTarget(remote);
182
- process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
161
+ // Remote, not in tmux hand off to the fallback (go: shell; focus: resume in a tab).
162
+ await fallback(s, remote);
163
+ return;
183
164
  }
184
165
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
185
166
  if (mux?.kind === 'tmux' && mux.pane) {
@@ -218,9 +199,8 @@ async function jumpTo(s, self) {
218
199
  chalk.gray(tab != null ? ` — switch to tab ${tab} (Cmd+${tab}).` : " — couldn't pinpoint its tab (same-repo forks are ambiguous); switch tabs manually."));
219
200
  return;
220
201
  }
221
- // Path D: refuse with a reason.
222
- console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
223
- chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
202
+ // Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
203
+ await fallback(s, undefined);
224
204
  }
225
205
  /** Resolve a local tmux pane id to its session name + window index. */
226
206
  async function resolveLocalPane(socket, pane) {
@@ -490,7 +490,7 @@ export function registerSecretsCommands(program) {
490
490
  # See what's in the bundle (values masked); shows its prompt policy
491
491
  agents secrets view prod
492
492
 
493
- # Stop a noisy automation bundle from prompting every run: ask once a day
493
+ # Stop a noisy automation bundle from prompting every run: ask once a week
494
494
  agents secrets policy prod daily
495
495
 
496
496
  # Eval the bundle into your current shell
@@ -510,15 +510,16 @@ export function registerSecretsCommands(program) {
510
510
  Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
511
511
  a prompt policy, shown in the POLICY column of 'agents secrets list':
512
512
  daily (default) ask once, then hold it silently in the local agent up
513
- to ~24h, until screen-lock / sleep / logout or 'lock'.
513
+ to ~7 days, until sleep / logout or 'lock' (a bare
514
+ screen-lock does NOT drop it). Name is historical.
514
515
  always ask for Touch ID every time — never auto-held.
515
- The default is 'daily' (one Touch ID per ~24h); change it globally with
516
+ The default is 'daily' (one Touch ID per ~7 days); change it globally with
516
517
  'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
517
518
  <bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
518
519
  prompt regardless of policy. Nothing on disk.
519
520
 
520
521
  See also:
521
- agents secrets policy <bundle> daily ask once a day, not every run
522
+ agents secrets policy <bundle> daily ask once a week, not every run
522
523
  agents secrets unlock <bundle> hold a bundle after one Touch ID
523
524
  agents secrets lock wipe held bundles (re-prompt next read)
524
525
  agents secrets status show held bundles + when they lock
@@ -623,7 +624,7 @@ export function registerSecretsCommands(program) {
623
624
  }
624
625
  else {
625
626
  console.log(bundlePolicy(bundle) === 'daily'
626
- ? chalk.gray('policy: daily (ask once, then held ~24h until screen-lock / sleep / logout)')
627
+ ? chalk.gray('policy: daily (ask once, then held ~7 days until sleep / logout — screen-lock does not drop it)')
627
628
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
628
629
  }
629
630
  if (bundle.created_at)
@@ -747,7 +748,7 @@ export function registerSecretsCommands(program) {
747
748
  .description('Create an empty bundle')
748
749
  .option('--description <text>', 'Free-form description')
749
750
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
750
- .option('--policy <policy>', 'prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
751
+ .option('--policy <policy>', 'prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
751
752
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
752
753
  .option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
753
754
  .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
@@ -1528,7 +1529,7 @@ Examples:
1528
1529
  cmd
1529
1530
  .command('unlock [names...]')
1530
1531
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
1531
- .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default 24h.')
1532
+ .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
1532
1533
  .option('--all', 'Unlock every configured bundle')
1533
1534
  .action(async (names, opts) => {
1534
1535
  if (process.platform !== 'darwin') {
@@ -1544,7 +1545,7 @@ Examples:
1544
1545
  if (opts.ttl) {
1545
1546
  const secs = parseDuration(opts.ttl);
1546
1547
  if (!secs) {
1547
- console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
1548
+ console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
1548
1549
  process.exit(1);
1549
1550
  }
1550
1551
  ttlMs = secs * 1000;
@@ -1628,7 +1629,7 @@ Examples:
1628
1629
  cmd
1629
1630
  .command('policy <bundle> [policy]')
1630
1631
  .alias('tier')
1631
- .description("Show or set a bundle's prompt policy: daily (default, ask once a day), always (ask every time), or never (silent, NO biometry ACL).")
1632
+ .description("Show or set a bundle's prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL).")
1632
1633
  .option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
1633
1634
  .action(async (bundleName, policyArg, opts) => {
1634
1635
  try {
@@ -1644,11 +1645,23 @@ Examples:
1644
1645
  console.error(chalk.yellow('Aborted.'));
1645
1646
  return;
1646
1647
  }
1648
+ const wasDaily = bundlePolicy(bundle) === 'daily';
1647
1649
  bundle.policy = next;
1648
1650
  writeBundle(bundle);
1651
+ // Tightening daily -> always/never must take effect NOW, not up to the
1652
+ // ~7d hold later. If the broker is already serving this bundle silently
1653
+ // (auto-cached under the old `daily` policy), evict it so the next read
1654
+ // re-prompts (`always`) or reads its no-ACL item directly (`never`).
1655
+ // macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
1656
+ if (wasDaily && next !== 'daily') {
1657
+ try {
1658
+ await agentLock(bundle.name);
1659
+ }
1660
+ catch { /* broker down — nothing held */ }
1661
+ }
1649
1662
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
1650
1663
  if (next === 'daily') {
1651
- console.log(chalk.gray('Held by the secrets-agent for ~24h after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1664
+ console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1652
1665
  }
1653
1666
  else if (next === 'always') {
1654
1667
  console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
@@ -98,6 +98,15 @@ export declare function dedupeByMachineSession(sessions: ActiveSession[]): Activ
98
98
  * `localMachine` is injected so the ordering is testable without os.hostname().
99
99
  */
100
100
  export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: string): SessionMeta[];
101
+ /**
102
+ * Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
103
+ * emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
104
+ * `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
105
+ * the public record, then pretty-print as a 2-space array with a trailing
106
+ * newline. The single seam shared by the local `--json` path and the
107
+ * `--json --host` remote fan-out so both emit byte-identical row shapes.
108
+ */
109
+ export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
101
110
  /**
102
111
  * Whether the local machine's sessions belong in an `--active` view. Local is
103
112
  * included by default; an explicit `--host`/`--device` list scopes the view to
@@ -40,6 +40,7 @@ import { registerSessionsTailCommand } from './sessions-tail.js';
40
40
  import { registerSessionsSyncCommand } from './sessions-sync.js';
41
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
42
42
  import { registerGoCommand } from './go.js';
43
+ import { registerFocusCommand } from './focus.js';
43
44
  import { registerSessionsInjectCommand } from './sessions-inject.js';
44
45
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
45
46
  /**
@@ -507,6 +508,40 @@ export function mergeLocalFirst(sessions, localMachine) {
507
508
  });
508
509
  return keys.flatMap((k) => byMachine.get(k));
509
510
  }
511
+ /**
512
+ * Serialize a `SessionMeta[]` to the clean JSON shape the `--json` listing
513
+ * emits: strip the internal-only scoring/provenance fields (`_matchedTerms`,
514
+ * `_bm25Score`, `_remote`) that are search/fan-out bookkeeping, never part of
515
+ * the public record, then pretty-print as a 2-space array with a trailing
516
+ * newline. The single seam shared by the local `--json` path and the
517
+ * `--json --host` remote fan-out so both emit byte-identical row shapes.
518
+ */
519
+ export function serializeSessionsJson(sessions) {
520
+ const serializable = sessions.map((s) => {
521
+ const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
522
+ return rest;
523
+ });
524
+ return JSON.stringify(serializable, null, 2) + '\n';
525
+ }
526
+ /**
527
+ * `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
528
+ * to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
529
+ * the same shape the local `--json` path emits. Reuses `gatherRemoteList` (the
530
+ * exact SSH fan-out the interactive cross-machine listing already uses) and
531
+ * serializes the merged, machine-tagged rows — instead of `runRemoteSessions`,
532
+ * which streams each remote's raw stdout under a per-host banner and so can
533
+ * never be JSON.parsed. A dead host contributes `[]` (with a stderr note from
534
+ * the fan-out), so stdout is always a valid array and the exit stays 0.
535
+ */
536
+ async function runRemoteSessionsJson(hosts) {
537
+ // Forward the caller's own filters (query, --limit, --since, …) minus --host,
538
+ // and guarantee --json so each peer answers with a parseable array.
539
+ const forwarded = buildForwardedArgs(process.argv, new Set(hosts));
540
+ if (!forwarded.includes('--json'))
541
+ forwarded.push('--json');
542
+ const { sessions } = await gatherRemoteList(forwarded, hosts);
543
+ process.stdout.write(serializeSessionsJson(sessions));
544
+ }
510
545
  /**
511
546
  * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
512
547
  * buckets omitted). Same bucketing as the grand-total summary so per-group
@@ -715,10 +750,17 @@ async function sessionsAction(query, options) {
715
750
  if (options.device && options.device.length > 0) {
716
751
  options.host = [...(options.host ?? []), ...options.device];
717
752
  }
718
- // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
719
- // stdout under a `── host ──` banner). With --active, the hosts are folded
720
- // into the merged machine-grouped view instead (handled below).
753
+ // --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
754
+ // clean merged SessionMeta[] array (same shape as the local --json path), for
755
+ // scripts/extensions that JSON.parse a remote's history. Without --json it
756
+ // keeps the legacy per-host stream (each remote's raw stdout under a
757
+ // `── host ──` banner). With --active, the hosts are folded into the merged
758
+ // machine-grouped view instead (handled below).
721
759
  if (options.host && options.host.length > 0 && !options.active) {
760
+ if (options.json) {
761
+ await runRemoteSessionsJson(options.host);
762
+ return;
763
+ }
722
764
  try {
723
765
  runRemoteSessions(options.host);
724
766
  }
@@ -858,11 +900,7 @@ async function sessionsAction(query, options) {
858
900
  }
859
901
  if (options.json) {
860
902
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
861
- const serializable = filtered.map(s => {
862
- const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
863
- return rest;
864
- });
865
- process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
903
+ process.stdout.write(serializeSessionsJson(filtered));
866
904
  return;
867
905
  }
868
906
  // Cross-machine fan-out: unless --local (or we ARE a peer answering a
@@ -2014,6 +2052,7 @@ export function registerSessionsCommands(program) {
2014
2052
  registerSessionsSyncCommand(sessionsCmd);
2015
2053
  registerSessionsResumeCommand(sessionsCmd);
2016
2054
  registerGoCommand(sessionsCmd);
2055
+ registerFocusCommand(sessionsCmd);
2017
2056
  registerSessionsInjectCommand(sessionsCmd);
2018
2057
  }
2019
2058
  function formatNoSessionsMessage(showAll, project) {
@@ -5,7 +5,7 @@ import type { HookCache, HookCacheConfig } from '../types.js';
5
5
  * Returns null if the value is missing or unparseable.
6
6
  */
7
7
  export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
8
- /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
8
+ /** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
9
9
  export declare function parseDuration(d: number | string | undefined): number | null;
10
10
  /**
11
11
  * Reject hook names that could escape the shims directory when interpolated
@@ -51,19 +51,21 @@ function parseShorthand(s) {
51
51
  return null;
52
52
  return { ttl: ttlSec, key: 'global', prefetch };
53
53
  }
54
- /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
54
+ /** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
55
55
  export function parseDuration(d) {
56
56
  if (d == null)
57
57
  return null;
58
58
  if (typeof d === 'number')
59
59
  return Number.isFinite(d) && d > 0 ? Math.floor(d) : null;
60
- const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i);
60
+ const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)?$/i);
61
61
  if (!m)
62
62
  return null;
63
63
  const value = parseInt(m[1], 10);
64
64
  if (!Number.isFinite(value) || value <= 0)
65
65
  return null;
66
66
  const unit = (m[2] || 's').toLowerCase();
67
+ if (unit.startsWith('d'))
68
+ return value * 86400;
67
69
  if (unit.startsWith('h'))
68
70
  return value * 3600;
69
71
  if (unit.startsWith('m'))
@@ -13,6 +13,7 @@
13
13
  export function addHostOption(cmd) {
14
14
  return cmd
15
15
  .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
16
+ .option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
16
17
  .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
18
  .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
19
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
@@ -20,9 +20,9 @@
20
20
  export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
21
21
  /**
22
22
  * Route `agents <command> … --host <name>` to a remote if the command is
23
- * host-routable and a `--host` was given. Returns `false` (run locally) when
24
- * there is no `--host`, the command isn't in the table, or the target is this
25
- * very machine.
23
+ * host-routable and a `--host` (or its `--device` alias) was given. Returns
24
+ * `false` (run locally) when neither flag is present, the command isn't in the
25
+ * table, or the target is this very machine.
26
26
  *
27
27
  * @param command the resolved subcommand name (`process.argv`'s first non-flag).
28
28
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
@@ -80,9 +80,9 @@ async function resolveTargetHost(name, any) {
80
80
  }
81
81
  /**
82
82
  * Route `agents <command> … --host <name>` to a remote if the command is
83
- * host-routable and a `--host` was given. Returns `false` (run locally) when
84
- * there is no `--host`, the command isn't in the table, or the target is this
85
- * very machine.
83
+ * host-routable and a `--host` (or its `--device` alias) was given. Returns
84
+ * `false` (run locally) when neither flag is present, the command isn't in the
85
+ * table, or the target is this very machine.
86
86
  *
87
87
  * @param command the resolved subcommand name (`process.argv`'s first non-flag).
88
88
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
@@ -91,7 +91,17 @@ export async function maybeRunOnHost(command, allArgs) {
91
91
  const spec = REMOTE_PASSTHROUGH[command];
92
92
  if (!spec)
93
93
  return false;
94
- const hostName = flagValue(allArgs, 'host', 'H');
94
+ // `--device` is a first-class alias of `--host` (mirrors `agents run`); the
95
+ // device registry is the source of truth for machine identity. Reject a
96
+ // conflicting pair rather than silently preferring one — same rule as run.
97
+ const hostFlag = flagValue(allArgs, 'host', 'H');
98
+ const deviceFlag = flagValue(allArgs, 'device');
99
+ if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
100
+ console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
101
+ process.exitCode = 1;
102
+ return true;
103
+ }
104
+ const hostName = hostFlag ?? deviceFlag;
95
105
  if (!hostName)
96
106
  return false;
97
107
  // Running against your own machine is just a local run — skip the SSH round-trip.
@@ -25,7 +25,13 @@ export interface StripSpec {
25
25
  * @param args the command's args (already past the command name).
26
26
  */
27
27
  export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
28
- /** The routing flags every `--host`-capable command shares. */
28
+ /**
29
+ * The routing flags every `--host`-capable command shares. `--device` is a
30
+ * first-class alias of `--host` (the device registry is the source of truth for
31
+ * machine identity — see `agents devices`), mirroring `agents run --device`.
32
+ * Both are stripped before forwarding so the alias never leaks to the remote
33
+ * binary (which would re-trigger routing).
34
+ */
29
35
  export declare const HOST_ROUTING_SPECS: StripSpec[];
30
36
  /**
31
37
  * Build the single command string for `ssh <target> <cmd>`. The forwarded args
@@ -38,9 +38,16 @@ export function stripRoutingFlags(args, specs) {
38
38
  }
39
39
  return out;
40
40
  }
41
- /** The routing flags every `--host`-capable command shares. */
41
+ /**
42
+ * The routing flags every `--host`-capable command shares. `--device` is a
43
+ * first-class alias of `--host` (the device registry is the source of truth for
44
+ * machine identity — see `agents devices`), mirroring `agents run --device`.
45
+ * Both are stripped before forwarding so the alias never leaks to the remote
46
+ * binary (which would re-trigger routing).
47
+ */
42
48
  export const HOST_ROUTING_SPECS = [
43
49
  { long: 'host', short: 'H', takesValue: true },
50
+ { long: 'device', takesValue: true },
44
51
  { long: 'remote-cwd', takesValue: true },
45
52
  ];
46
53
  /**
@@ -16,8 +16,9 @@
16
16
  * trust boundary the keychain already concedes (docs/secrets.md: the ACL is
17
17
  * user-presence, not code-identity — any same-user process can pop the prompt
18
18
  * and read), minus the visible prompt. We bound it with: explicit per-bundle
19
- * opt-in (nothing is held unless you `unlock` it), an absolute TTL, auto-lock
20
- * on screen-lock / sleep, and `agents secrets lock`. Nothing ever touches disk.
19
+ * opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
20
+ * auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
21
+ * NOT a wipe (the login password already gates it). Nothing ever touches disk.
21
22
  *
22
23
  * macOS only: Linux libsecret has no biometry prompt, so there's nothing to
23
24
  * deduplicate — every entry point here no-ops off darwin.
@@ -30,7 +31,7 @@ export declare const DEFAULT_TTL_MS: number;
30
31
  * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
31
32
  * NO resolved secret values beyond the literals already in metadata) keyed by a
32
33
  * hash of the current keychain bundle name-set, so the second and later
33
- * `secrets list` within the daily window read metadata without a Touch ID
34
+ * `secrets list` within the hold window read metadata without a Touch ID
34
35
  * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
35
36
  * changes the key and misses the cache automatically — no active invalidation.
36
37
  * The '!' sentinel can never collide with a real bundle name
@@ -43,7 +44,7 @@ export declare const META_CACHE_PREFIX = "!meta:";
43
44
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
44
45
  * with bundles still unlocked wipes them from memory, so the next reader falls
45
46
  * back to a direct keychain read and re-prompts for Touch ID. Deferring the
46
- * restart until the cache is idle (TTL-expired / screen-locked) means an
47
+ * restart until the cache is idle (TTL-expired / slept) means an
47
48
  * in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
48
49
  * next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
49
50
  * cache on every bump and produced a recurring Touch ID storm.
@@ -141,10 +142,20 @@ export type Response = {
141
142
  */
142
143
  export declare function realBundleCount(store: Map<string, StoredBundle>): number;
143
144
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
145
+ /**
146
+ * Decide whether a `watch-lock` helper line should wipe the in-memory store.
147
+ * The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
148
+ * sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
149
+ * password, and with the ~7d hold, re-authing after every lock would defeat the
150
+ * point. Logout needs no line — it tears down the launchd session and kills the
151
+ * broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
152
+ * has direct regression coverage (the inline stdout handler isn't unit-testable).
153
+ */
154
+ export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
144
155
  /**
145
156
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
146
157
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
147
- * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
158
+ * sweeps expired entries, wipes on sleep, and self-exits when idle.
148
159
  */
149
160
  export declare function runSecretsAgent(opts?: {
150
161
  service?: boolean;
@@ -171,7 +182,7 @@ export declare function agentGetSync(name: string): {
171
182
  export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
172
183
  /**
173
184
  * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
174
- * the next `secrets list` within the daily window renders without a prompt.
185
+ * the next `secrets list` within the hold window renders without a prompt.
175
186
  * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
176
187
  * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
177
188
  * detached worker (never argv/disk), same as value caching. macOS only.
@@ -179,7 +190,7 @@ export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] |
179
190
  export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
180
191
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
181
192
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
182
- * per ~24h), so auto-caching is ON by default; opt out with
193
+ * per ~7d), so auto-caching is ON by default; opt out with
183
194
  * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
184
195
  export declare function secretsAgentAutoEnabled(): boolean;
185
196
  /**
@@ -16,8 +16,9 @@
16
16
  * trust boundary the keychain already concedes (docs/secrets.md: the ACL is
17
17
  * user-presence, not code-identity — any same-user process can pop the prompt
18
18
  * and read), minus the visible prompt. We bound it with: explicit per-bundle
19
- * opt-in (nothing is held unless you `unlock` it), an absolute TTL, auto-lock
20
- * on screen-lock / sleep, and `agents secrets lock`. Nothing ever touches disk.
19
+ * opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
20
+ * auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
21
+ * NOT a wipe (the login password already gates it). Nothing ever touches disk.
21
22
  *
22
23
  * macOS only: Linux libsecret has no biometry prompt, so there's nothing to
23
24
  * deduplicate — every entry point here no-ops off darwin.
@@ -35,13 +36,13 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
35
36
  * server kills and respawns it rather than talking a stale dialect. */
36
37
  const PROTOCOL_VERSION = 1;
37
38
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
38
- export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
39
+ export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
39
40
  /**
40
41
  * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
41
42
  * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
42
43
  * NO resolved secret values beyond the literals already in metadata) keyed by a
43
44
  * hash of the current keychain bundle name-set, so the second and later
44
- * `secrets list` within the daily window read metadata without a Touch ID
45
+ * `secrets list` within the hold window read metadata without a Touch ID
45
46
  * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
46
47
  * changes the key and misses the cache automatically — no active invalidation.
47
48
  * The '!' sentinel can never collide with a real bundle name
@@ -59,7 +60,7 @@ const SWEEP_INTERVAL_MS = 30 * 1000;
59
60
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
60
61
  * with bundles still unlocked wipes them from memory, so the next reader falls
61
62
  * back to a direct keychain read and re-prompts for Touch ID. Deferring the
62
- * restart until the cache is idle (TTL-expired / screen-locked) means an
63
+ * restart until the cache is idle (TTL-expired / slept) means an
63
64
  * in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
64
65
  * next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
65
66
  * cache on every bump and produced a recurring Touch ID storm.
@@ -301,10 +302,22 @@ export function handleAgentRequest(store, req, now = Date.now()) {
301
302
  }
302
303
  }
303
304
  }
305
+ /**
306
+ * Decide whether a `watch-lock` helper line should wipe the in-memory store.
307
+ * The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
308
+ * sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
309
+ * password, and with the ~7d hold, re-authing after every lock would defeat the
310
+ * point. Logout needs no line — it tears down the launchd session and kills the
311
+ * broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
312
+ * has direct regression coverage (the inline stdout handler isn't unit-testable).
313
+ */
314
+ export function shouldWipeOnWatchEvent(chunk) {
315
+ return /\bSLEEP\b/.test(chunk);
316
+ }
304
317
  /**
305
318
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
306
319
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
307
- * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
320
+ * sweeps expired entries, wipes on sleep, and self-exits when idle.
308
321
  */
309
322
  export async function runSecretsAgent(opts = {}) {
310
323
  if (!onDarwin())
@@ -351,9 +364,9 @@ export async function runSecretsAgent(opts = {}) {
351
364
  // this value for the process lifetime; getCliVersionFresh re-reads on disk.
352
365
  const runningVersion = getCliVersion();
353
366
  // "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
354
- // the internal `secrets list` metadata cache (#524). Otherwise a 24h-TTL list
367
+ // the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
355
368
  // cache would keep the store non-empty and (a) block the persistent broker
356
- // from self-healing onto a freshly-installed version for up to a day (#435's
369
+ // from self-healing onto a freshly-installed version for up to a week (#435's
357
370
  // gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
358
371
  // metadata cache is a disposable list snapshot — wiping it on upgrade/idle
359
372
  // costs at most one extra prompt on the next `secrets list`.
@@ -450,15 +463,19 @@ export async function runSecretsAgent(opts = {}) {
450
463
  });
451
464
  });
452
465
  sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
453
- // Auto-lock on screen-lock / sleep. The signed helper emits LOCK / SLEEP
454
- // lines; on any of them we wipe everything. If the installed helper predates
455
- // watch-lock (exits non-zero immediately), we fall back to TTL-only and log
456
- // nothing the unlock already warned when lock_on_sleep couldn't be armed.
466
+ // Auto-lock on sleep. The signed helper emits LOCK / SLEEP lines; we wipe
467
+ // everything on SLEEP (and, implicitly, logout that tears down the launchd
468
+ // session and kills this in-memory broker). A bare screen-lock is deliberately
469
+ // NOT a wipe: with the ~7d hold, re-prompting after every lock would defeat the
470
+ // point, and a locked screen is already gated by the login password. If the
471
+ // installed helper predates watch-lock (exits non-zero immediately), we fall
472
+ // back to TTL-only and log nothing — the unlock already warned when
473
+ // lock_on_sleep couldn't be armed.
457
474
  try {
458
475
  watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
459
476
  watcher.stdout?.setEncoding('utf-8');
460
477
  watcher.stdout?.on('data', (chunk) => {
461
- if (/\b(LOCK|SLEEP)\b/.test(chunk)) {
478
+ if (shouldWipeOnWatchEvent(chunk)) {
462
479
  store.clear();
463
480
  emptySince = Date.now();
464
481
  }
@@ -590,7 +607,7 @@ export function agentGetMetaSync(nameSetHash) {
590
607
  }
591
608
  /**
592
609
  * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
593
- * the next `secrets list` within the daily window renders without a prompt.
610
+ * the next `secrets list` within the hold window renders without a prompt.
594
611
  * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
595
612
  * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
596
613
  * detached worker (never argv/disk), same as value caching. macOS only.
@@ -604,7 +621,7 @@ export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
604
621
  }
605
622
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
606
623
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
607
- * per ~24h), so auto-caching is ON by default; opt out with
624
+ * per ~7d), so auto-caching is ON by default; opt out with
608
625
  * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
609
626
  export function secretsAgentAutoEnabled() {
610
627
  try {
@@ -40,11 +40,13 @@ export interface VarMeta {
40
40
  }
41
41
  /**
42
42
  * A bundle's prompt policy — how often macOS asks for Touch ID to read it:
43
- * - `daily` (default): ask once, then hold it silently for up to ~24h. Eligible
44
- * for the secrets-agent — the first real keychain read auto-loads it (auto-cache
45
- * is on by default) so concurrent runs read it silently, or `unlock` it
46
- * explicitly. Held from that unlock (not refreshed on use); re-asks sooner
47
- * after screen-lock, sleep, logout, or `agents secrets lock`.
43
+ * - `daily` (default): ask once, then hold it silently for up to ~7 days.
44
+ * (Historical name — the window is now a rolling ~1 week, not one calendar day.)
45
+ * Eligible for the secrets-agent the first real keychain read auto-loads it
46
+ * (auto-cache is on by default) so concurrent runs read it silently, or `unlock`
47
+ * it explicitly. Held from that unlock (not refreshed on use); re-asks sooner
48
+ * after sleep, logout, or `agents secrets lock`. A bare screen-lock does NOT
49
+ * drop it (the login password already gates a locked screen).
48
50
  * - `always`: asks every time. Never auto-held — only an explicit `agents
49
51
  * secrets unlock` ever holds it; every other read pops Touch ID. Opt a
50
52
  * high-value bundle into this when you want to confirm every single read.
@@ -107,7 +109,7 @@ export declare function bundleExists(name: string): boolean;
107
109
  export declare function readBundle(name: string): SecretsBundle;
108
110
  /** The default prompt policy applied to bundles without an explicit per-bundle
109
111
  * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
110
- * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
112
+ * ID per ~7d) unless the user explicitly opts back into prompt-every-time with
111
113
  * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
112
114
  export declare function secretsDefaultPolicy(): SecretsPolicy;
113
115
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
@@ -263,7 +263,7 @@ function parsePolicy(raw) {
263
263
  }
264
264
  /** The default prompt policy applied to bundles without an explicit per-bundle
265
265
  * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
266
- * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
266
+ * ID per ~7d) unless the user explicitly opts back into prompt-every-time with
267
267
  * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
268
268
  export function secretsDefaultPolicy() {
269
269
  try {
@@ -414,11 +414,15 @@ export function listBundles() {
414
414
  // so the getKeychainTokens batch below pops Touch ID on every `secrets
415
415
  // list` — the broker/`daily` mechanism only ever covered value reads, not
416
416
  // this listing. Serve a broker-cached metadata snapshot when one is held,
417
- // so only the first list per ~24h prompts. The cache key is a hash of the
417
+ // so only the first list per ~7d prompts. The cache key is a hash of the
418
418
  // current keychain name-set (enumerated silently above): add / remove /
419
419
  // rename a bundle and the key changes, so the stale snapshot is never
420
- // served no active invalidation needed. Values are never cached here;
421
- // this is metadata only.
420
+ // served. A same-name metadata edit (e.g. `secrets policy <b> always`)
421
+ // does NOT change the key, so the POLICY column in `secrets list` can lag
422
+ // by up to the hold window (~7d) until the next name-set change or `lock`.
423
+ // This is cosmetic only — enforcement always reads the bundle's live
424
+ // policy (readBundle), never this snapshot, and `secrets view <b>` shows
425
+ // the fresh value immediately. Values are never cached here; metadata only.
422
426
  const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
423
427
  !isKeychainBackendOverridden() &&
424
428
  secretsAgentAutoEnabled();
@@ -444,7 +448,7 @@ export function listBundles() {
444
448
  }
445
449
  for (const bundle of keychainBundles)
446
450
  out.push(bundle);
447
- // Populate the broker for the rest of the daily window (fire-and-forget).
451
+ // Populate the broker for the rest of the hold window (fire-and-forget).
448
452
  if (useAgent && keychainBundles.length > 0) {
449
453
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
450
454
  }
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 23;
80
+ export declare const SHIM_SCHEMA_VERSION = 24;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -211,7 +211,7 @@ async function promptConflictStrategy(conflictInfos) {
211
211
  // v22 — export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
212
212
  // install can't self-mutate: Claude Code's background auto-updater would
213
213
  // otherwise rewrite the pinned binary in place. Explicit user value wins.
214
- export const SHIM_SCHEMA_VERSION = 23;
214
+ export const SHIM_SCHEMA_VERSION = 24;
215
215
  /** Internal marker string used to embed the schema version in shim scripts. */
216
216
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
217
217
  function shellQuote(value) {
@@ -346,16 +346,38 @@ find_project_version() {
346
346
  return 1
347
347
  }
348
348
 
349
- # Resolve version from agents.yaml (user default)
349
+ # Parse the agents: default map of one agents.yaml for this AGENT's version.
350
+ parse_agents_default() {
351
+ local meta="$1"
352
+ [ -f "$meta" ] || return 0
353
+ awk -v agent="$AGENT" '
354
+ /^agents:/ { in_agents=1; next }
355
+ in_agents && /^[^ ]/ { in_agents=0 }
356
+ in_agents && $0 ~ "^ " agent ":" { gsub(/.*:[[:space:]]*["'"'"']?|["'"'"']?[[:space:]]*$/, ""); print; exit }
357
+ ' "$meta"
358
+ }
359
+
360
+ # This machine's device id — mirrors machineId()/normalizeHost() in
361
+ # src/lib/machine-id.ts: first hostname label, lowercased, non-[a-z0-9_-] -> '-'.
362
+ # MUST stay in sync or the shim reads the wrong device folder.
363
+ machine_id() {
364
+ local raw="\${AGENTS_SYNC_MACHINE_ID:-$(hostname 2>/dev/null)}"
365
+ # first label -> trim -> lowercase -> non-[a-z0-9_-] to '-' (matches normalizeHost order).
366
+ raw=$(printf '%s' "$raw" | cut -d. -f1 | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')
367
+ [ -n "$raw" ] && printf '%s' "$raw" || printf 'unknown'
368
+ }
369
+
370
+ # Resolve the default version. The agents: version pins are stored PER-DEVICE at
371
+ # devices/<machine>/agents.yaml (moved there so multi-machine syncs never
372
+ # conflict); read that first, then fall back to the central agents.yaml for
373
+ # pre-split installs. Must match readMeta()'s central+device merge in state.ts --
374
+ # reading only the central file (the old behavior) missed every device pin and
375
+ # made the shim re-prompt "no default set" on every launch.
350
376
  resolve_default_version() {
351
- local meta="$AGENTS_USER_DIR/agents.yaml"
352
- if [ -f "$meta" ]; then
353
- awk -v agent="$AGENT" '
354
- /^agents:/ { in_agents=1; next }
355
- in_agents && /^[^ ]/ { in_agents=0 }
356
- in_agents && $0 ~ "^ " agent ":" { gsub(/.*:[[:space:]]*["'"'"']?|["'"'"']?[[:space:]]*$/, ""); print; exit }
357
- ' "$meta"
358
- fi
377
+ local v
378
+ v=$(parse_agents_default "$AGENTS_USER_DIR/devices/$(machine_id)/agents.yaml")
379
+ [ -n "$v" ] || v=$(parse_agents_default "$AGENTS_USER_DIR/agents.yaml")
380
+ printf '%s' "$v"
359
381
  }
360
382
 
361
383
  # Find the latest installed version by numeric component comparison.
@@ -599,9 +599,9 @@ export interface Meta {
599
599
  run?: RunConfig;
600
600
  /** macOS secrets-agent config. `policy` is the default prompt policy for
601
601
  * bundles without an explicit per-bundle policy: `daily` (the default) asks
602
- * once per ~24h, `always` asks every time. `auto` (default on) lets the first
603
- * real keychain read of a `daily` bundle populate the broker so concurrent
604
- * runs read silently — set it `false` to force a prompt on every read. */
602
+ * once per ~7 days, `always` asks every time. `auto` (default on) lets the
603
+ * first real keychain read of a `daily` bundle populate the broker so
604
+ * concurrent runs read silently — set it `false` to force a prompt on every read. */
605
605
  secrets?: {
606
606
  policy?: 'always' | 'daily';
607
607
  agent?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.39",
3
+ "version": "1.20.41",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",