@phnx-labs/agents-cli 1.20.40 → 1.20.42

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 (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/commands/computer-actions.d.ts +2 -0
  3. package/dist/commands/computer-actions.js +60 -1
  4. package/dist/commands/computer.d.ts +2 -2
  5. package/dist/commands/computer.js +4 -4
  6. package/dist/commands/exec.js +2 -0
  7. package/dist/commands/focus.d.ts +31 -0
  8. package/dist/commands/focus.js +150 -0
  9. package/dist/commands/go.d.ts +34 -11
  10. package/dist/commands/go.js +50 -65
  11. package/dist/commands/secrets.js +49 -10
  12. package/dist/commands/sessions.d.ts +9 -0
  13. package/dist/commands/sessions.js +77 -20
  14. package/dist/lib/computer-rpc.js +3 -3
  15. package/dist/lib/exec.d.ts +52 -0
  16. package/dist/lib/exec.js +150 -0
  17. package/dist/lib/hooks/cache.d.ts +1 -1
  18. package/dist/lib/hooks/cache.js +4 -2
  19. package/dist/lib/hosts/option.js +1 -0
  20. package/dist/lib/hosts/passthrough.d.ts +3 -3
  21. package/dist/lib/hosts/passthrough.js +14 -4
  22. package/dist/lib/hosts/remote-cmd.d.ts +7 -1
  23. package/dist/lib/hosts/remote-cmd.js +8 -1
  24. package/dist/lib/menubar/install-menubar.js +2 -2
  25. package/dist/lib/secrets/agent.d.ts +18 -7
  26. package/dist/lib/secrets/agent.js +32 -15
  27. package/dist/lib/secrets/bundles.d.ts +8 -6
  28. package/dist/lib/secrets/bundles.js +14 -8
  29. package/dist/lib/secrets/remote.js +14 -0
  30. package/dist/lib/secrets/sync.js +13 -0
  31. package/dist/lib/session/active.d.ts +47 -3
  32. package/dist/lib/session/active.js +132 -10
  33. package/dist/lib/session/db.js +45 -31
  34. package/dist/lib/session/discover.d.ts +5 -0
  35. package/dist/lib/session/discover.js +9 -2
  36. package/dist/lib/session/viewing-in.d.ts +54 -0
  37. package/dist/lib/session/viewing-in.js +155 -0
  38. package/dist/lib/shims.d.ts +1 -1
  39. package/dist/lib/shims.js +32 -10
  40. package/dist/lib/ssh-tunnel.d.ts +1 -1
  41. package/dist/lib/ssh-tunnel.js +3 -3
  42. package/dist/lib/tmux/session.d.ts +46 -0
  43. package/dist/lib/tmux/session.js +84 -2
  44. package/dist/lib/types.d.ts +3 -3
  45. package/package.json +1 -1
package/dist/lib/exec.js CHANGED
@@ -19,6 +19,8 @@ import { getShimsDir } from './state.js';
19
19
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
20
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
21
  import { composeWin32CommandLine } from './platform/index.js';
22
+ import { isTmuxInstalled } from './tmux/binary.js';
23
+ import { shellQuote } from './ssh-exec.js';
22
24
  /**
23
25
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
24
26
  *
@@ -753,6 +755,131 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
753
755
  });
754
756
  });
755
757
  }
758
+ /**
759
+ * Decide whether to run an interactive agent INSIDE a detached tmux session on
760
+ * the shared socket (then attach the current TTY) instead of a bare spawn.
761
+ *
762
+ * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
763
+ * `agents sessions --active` can tell co-located agents apart, and lets `agents
764
+ * focus` re-attach a live session without forking it. Pure so the gate is unit-
765
+ * tested independently of the (side-effecting) spawn.
766
+ *
767
+ * All five guards must pass:
768
+ * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
769
+ * - not Windows — no tmux path on win32.
770
+ * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
771
+ * - not --raw — explicit opt-out.
772
+ * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
773
+ * - tmux installed — otherwise there is nothing to wrap with.
774
+ */
775
+ export function shouldWrapInTmux(ctx) {
776
+ if (!ctx.interactive)
777
+ return false;
778
+ if (ctx.platform === 'win32')
779
+ return false;
780
+ if (ctx.inTmux)
781
+ return false;
782
+ if (ctx.raw)
783
+ return false;
784
+ if (ctx.noTmuxEnv)
785
+ return false;
786
+ if (!ctx.tmuxAvailable)
787
+ return false;
788
+ return true;
789
+ }
790
+ /**
791
+ * Build the shell command that runs an agent inside a tmux pane with the exact
792
+ * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
793
+ * K=V … <agent> <args…>` so:
794
+ * - `env` materializes the full agent env INTO the pane, independent of the
795
+ * (possibly stale, shared) tmux server environment — additive, so tmux's own
796
+ * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
797
+ * - `exec` replaces the shell so the agent is the pane's leaf process (clean
798
+ * `#{pane_pid}`, clean signal delivery on detach/kill).
799
+ * Keys are filtered to valid identifiers so exported shell functions
800
+ * (`BASH_FUNC_*%%`) can't make `env` choke.
801
+ */
802
+ export function buildTmuxAgentCommand(executable, args, env) {
803
+ const envPrefix = Object.entries(env)
804
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
805
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
806
+ .join(' ');
807
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
808
+ return `exec env ${envPrefix} ${agentCmd}`;
809
+ }
810
+ /**
811
+ * Run an interactive agent inside a detached tmux session on the shared socket,
812
+ * attach the current TTY, and propagate the wrapped agent's exit code.
813
+ *
814
+ * Lifecycle:
815
+ * 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
816
+ * on (global), and returns the pane id.
817
+ * 2. A per-session `pane-died` hook detaches the attach client the instant the
818
+ * AGENT pane exits, so attach returns instead of parking on a dead pane. The
819
+ * hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
820
+ * user-created splits (Ctrl-b " / %) that the user exits are closed in place
821
+ * (`kill-pane`) instead of tearing down the whole client, so exiting one
822
+ * split leaves the agent running full-window rather than kicking you out.
823
+ * 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
824
+ * headless active-scan attributes it, then attach the TTY (blocking).
825
+ * 4. On return: if the pane is dead the agent exited — read its status, tear the
826
+ * session down, return that code. If the pane is still alive the user detached
827
+ * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
828
+ */
829
+ async function runInTmux(options, executable, args) {
830
+ const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
831
+ const { getDefaultSocketPath } = await import('./tmux/paths.js');
832
+ const { attachTmux, runTmux } = await import('./tmux/binary.js');
833
+ const socket = getDefaultSocketPath();
834
+ const cwd = options.cwd || process.cwd();
835
+ const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
836
+ const name = slugifyName(`ag-${options.agent}-${idSeed}`);
837
+ const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
838
+ const labels = { agent: options.agent };
839
+ if (options.sessionId)
840
+ labels.sessionId = options.sessionId;
841
+ const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
842
+ const pane = meta.pane;
843
+ if (pane) {
844
+ // When the AGENT pane dies, detach the client (don't kill) so the session
845
+ // survives just long enough to read the dead pane's exit status below. The
846
+ // `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
847
+ // the window and exits one of THEIR panes, the else-branch `kill-pane` closes
848
+ // that split in place instead of detaching everyone (the pane-died hook runs
849
+ // in the dead pane's context, so bare `kill-pane` targets it). Without the
850
+ // guard, exiting any split kicked the user clean out of tmux.
851
+ await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
852
+ // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
853
+ // pane so the active-scan attributes it exactly and shows the %pane.
854
+ let panePid = 0;
855
+ try {
856
+ const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false });
857
+ panePid = parseInt(r.stdout.trim(), 10) || 0;
858
+ }
859
+ catch { /* best-effort */ }
860
+ writePidSessionEntry({
861
+ pid: panePid,
862
+ agent: options.agent,
863
+ sessionId: options.sessionId,
864
+ cwd,
865
+ tmuxPane: pane,
866
+ startedAtMs: Date.now(),
867
+ });
868
+ }
869
+ // The agent could exit before we attach (fast failure). Don't attach to an
870
+ // already-dead pane — read its status directly and tear down.
871
+ const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
872
+ if (!before.dead) {
873
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
874
+ }
875
+ const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
876
+ if (after.dead) {
877
+ await killSession(name, socket).catch(() => { });
878
+ return { exitCode: after.status ?? 0, stderr: '' };
879
+ }
880
+ // Pane still alive → the user detached; keep the session for `agents focus`.
881
+ return { exitCode: 0, stderr: '' };
882
+ }
756
883
  /**
757
884
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
758
885
  *
@@ -800,6 +927,29 @@ async function spawnAgent(options) {
800
927
  command: executable,
801
928
  args: redactArgs(args.slice(0, 10)),
802
929
  });
930
+ // Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
931
+ // tmux session (then attach this TTY) so it gets a unique, addressable %pane.
932
+ // Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
933
+ // bare spawn below. See shouldWrapInTmux / runInTmux.
934
+ if (shouldWrapInTmux({
935
+ interactive,
936
+ platform: process.platform,
937
+ inTmux: !!process.env.TMUX,
938
+ raw: options.raw === true,
939
+ noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
940
+ tmuxAvailable: isTmuxInstalled(),
941
+ })) {
942
+ timer.mark('startup');
943
+ try {
944
+ const result = await runInTmux(options, executable, args);
945
+ timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
946
+ return result;
947
+ }
948
+ catch (err) {
949
+ timer.end({ error: err.message, exitCode: -1, status: 'error' });
950
+ throw err;
951
+ }
952
+ }
803
953
  return new Promise((resolve, reject) => {
804
954
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
805
955
  // rendering, raw-mode keystrokes, colored output). Headless mode pipes
@@ -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
  /**
@@ -77,7 +77,7 @@ export function menubarServiceInstalled() {
77
77
  * Locate the source `.app` shipped alongside the compiled JS.
78
78
  * 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
79
79
  * 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
80
- * 3. <repo>/packages/menubar-helper/dist/MenubarHelper.app — fresh local build
80
+ * 3. apps/cli/menubar/dist/MenubarHelper.app — fresh local build
81
81
  */
82
82
  function sourceAppPath() {
83
83
  const candidates = [];
@@ -85,7 +85,7 @@ function sourceAppPath() {
85
85
  const here = path.dirname(fileURLToPath(import.meta.url));
86
86
  candidates.push(path.join(here, APP_BUNDLE_NAME));
87
87
  candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
88
- candidates.push(path.resolve(here, '..', '..', '..', 'packages', 'menubar-helper', 'dist', APP_BUNDLE_NAME));
88
+ candidates.push(path.resolve(here, '..', '..', '..', 'menubar', 'dist', APP_BUNDLE_NAME));
89
89
  }
90
90
  catch {
91
91
  /* import.meta.url unavailable */
@@ -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 {
@@ -336,13 +336,13 @@ export function writeBundle(bundle) {
336
336
  // of the tier. On an un-updated pinned helper this write fails loudly (the
337
337
  // no-ACL command is missing) rather than silently landing an ACL'd item.
338
338
  itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
339
- emit('secrets.set', { bundle: bundle.name });
339
+ emit('secrets.set', { module: 'secrets', bundle: bundle.name });
340
340
  }
341
341
  export function deleteBundle(name) {
342
342
  validateBundleName(name);
343
343
  const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
344
344
  if (deleted) {
345
- emit('secrets.delete', { bundle: name });
345
+ emit('secrets.delete', { module: 'secrets', bundle: name });
346
346
  }
347
347
  return deleted;
348
348
  }
@@ -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
  }
@@ -688,6 +692,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
688
692
  const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
689
693
  stampLastUsed(filtered.bundle);
690
694
  emit('secrets.get', {
695
+ module: 'secrets',
691
696
  bundle: name,
692
697
  caller: opts.caller,
693
698
  status: 'success',
@@ -777,6 +782,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
777
782
  keychainKeys.sort();
778
783
  const emitReadAudit = (status, err) => {
779
784
  emit('secrets.get', {
785
+ module: 'secrets',
780
786
  bundle: bundle.name,
781
787
  caller: opts.caller,
782
788
  status,
@@ -939,7 +945,7 @@ export function renameBundle(oldName, newName, opts = {}) {
939
945
  store.delete(oldItem);
940
946
  }
941
947
  deleteBundle(oldName);
942
- emit('secrets.rename', { from: oldName, to: newName });
948
+ emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
943
949
  }
944
950
  /**
945
951
  * The store (keychain or encrypted file) that carries a bundle's items. The
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
+ import { emit } from '../events.js';
20
21
  import { sshTargetFor } from '../hosts/types.js';
21
22
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
22
23
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -136,5 +137,18 @@ export async function remoteResolveEnv(target, bundle) {
136
137
  for (const [k, v] of Object.entries(parsed)) {
137
138
  env[k] = typeof v === 'string' ? v : String(v);
138
139
  }
140
+ // The remote host audits its own `secrets export` read; this emit records the
141
+ // event on the INITIATING host too (values were pulled into this process and
142
+ // injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
143
+ // Values never enter the payload — only the bundle, target host, and count.
144
+ emit('secrets.get', {
145
+ module: 'secrets',
146
+ bundle,
147
+ caller: 'remote resolve',
148
+ source: 'remote',
149
+ host: target,
150
+ status: 'success',
151
+ keyCount: Object.keys(env).length,
152
+ });
139
153
  return env;
140
154
  }