@phnx-labs/agents-cli 1.20.70 → 1.20.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/exec.js +12 -0
  6. package/dist/commands/fleet-capture.d.ts +14 -0
  7. package/dist/commands/fleet-capture.js +107 -0
  8. package/dist/commands/fork.js +13 -7
  9. package/dist/commands/hosts.js +11 -0
  10. package/dist/commands/secrets.d.ts +1 -0
  11. package/dist/commands/secrets.js +24 -4
  12. package/dist/commands/sessions-tail.js +1 -2
  13. package/dist/commands/sessions.js +6 -6
  14. package/dist/commands/ssh.js +24 -6
  15. package/dist/commands/usage.d.ts +13 -0
  16. package/dist/commands/usage.js +50 -28
  17. package/dist/lib/commands.d.ts +10 -0
  18. package/dist/lib/commands.js +31 -7
  19. package/dist/lib/devices/connect.d.ts +13 -0
  20. package/dist/lib/devices/connect.js +20 -0
  21. package/dist/lib/devices/fleet.d.ts +36 -3
  22. package/dist/lib/devices/fleet.js +42 -4
  23. package/dist/lib/devices/sync.d.ts +30 -0
  24. package/dist/lib/devices/sync.js +50 -0
  25. package/dist/lib/doctor-diff.js +38 -16
  26. package/dist/lib/fleet/apply.d.ts +4 -0
  27. package/dist/lib/fleet/apply.js +28 -5
  28. package/dist/lib/fleet/capture.d.ts +35 -0
  29. package/dist/lib/fleet/capture.js +51 -0
  30. package/dist/lib/fleet/manifest.d.ts +6 -0
  31. package/dist/lib/fleet/manifest.js +24 -1
  32. package/dist/lib/fleet/types.d.ts +24 -1
  33. package/dist/lib/git.d.ts +14 -0
  34. package/dist/lib/git.js +39 -1
  35. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  36. package/dist/lib/menubar/install-menubar.js +26 -13
  37. package/dist/lib/secrets/agent.d.ts +5 -0
  38. package/dist/lib/secrets/agent.js +35 -3
  39. package/dist/lib/secrets/bundles.js +28 -0
  40. package/dist/lib/secrets/index.d.ts +14 -0
  41. package/dist/lib/secrets/index.js +36 -5
  42. package/dist/lib/secrets/session-store.d.ts +90 -0
  43. package/dist/lib/secrets/session-store.js +221 -0
  44. package/dist/lib/session/render.d.ts +9 -1
  45. package/dist/lib/session/render.js +35 -4
  46. package/dist/lib/share/capture.d.ts +2 -0
  47. package/dist/lib/share/capture.js +12 -5
  48. package/dist/lib/skills.d.ts +8 -1
  49. package/dist/lib/skills.js +11 -1
  50. package/dist/lib/types.d.ts +5 -1
  51. package/package.json +1 -1
@@ -26,6 +26,7 @@ import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
28
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
29
+ import { loadSession, deleteSession } from './session-store.js';
29
30
  import { createHash } from 'node:crypto';
30
31
  const keychainStore = {
31
32
  has: hasKeychainToken,
@@ -362,6 +363,9 @@ export function writeBundle(bundle, opts = {}) {
362
363
  // re-resolves from the keychain instead of serving stale values.
363
364
  if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
364
365
  agentEvictSync(bundle.name);
366
+ // Also drop any durable session snapshot, or a broker restart would rehydrate
367
+ // the stale env after a rotate/rename (session-store.ts).
368
+ deleteSession(bundle.name);
365
369
  }
366
370
  }
367
371
  export function deleteBundle(name) {
@@ -371,6 +375,7 @@ export function deleteBundle(name) {
371
375
  emit('secrets.delete', { module: 'secrets', bundle: name });
372
376
  if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
373
377
  agentEvictSync(name);
378
+ deleteSession(name); // a deleted bundle must not be rehydratable
374
379
  }
375
380
  }
376
381
  return deleted;
@@ -787,6 +792,29 @@ export function readAndResolveBundleEnv(name, opts = {}) {
787
792
  });
788
793
  return filtered;
789
794
  }
795
+ // Durable-session fallback (Correction B). After a daemon restart / agents-cli
796
+ // upgrade the broker RAM is empty, so the fast-path above misses — but the
797
+ // unlock persisted a no-ACL session item (session-store.ts) that reads with NO
798
+ // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
799
+ // across restart — this fixes BOTH the interactive re-prompt and the headless
800
+ // throw below (which now fires only when there is genuinely no session).
801
+ const session = loadSession(name);
802
+ if (session) {
803
+ const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
804
+ stampLastUsed(filtered.bundle);
805
+ // Re-warm the broker with the remaining TTL so later reads hit RAM and
806
+ // `agents secrets status` is honest. Best-effort; no-ops off darwin.
807
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
808
+ emit('secrets.get', {
809
+ module: 'secrets',
810
+ bundle: name,
811
+ operation: opts.caller,
812
+ status: 'success',
813
+ source: 'session',
814
+ keyCount: Object.keys(filtered.env).length,
815
+ });
816
+ return filtered;
817
+ }
790
818
  }
791
819
  // Only keychain-backed bundles can pop a Touch ID prompt and are the only ones
792
820
  // the broker ever holds. A file-backed bundle resolves via passphrase with no
@@ -22,6 +22,7 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
+ import { type SpawnSyncOptions } from 'child_process';
25
26
  import type { NativeImportReport } from './fallback.js';
26
27
  export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
27
28
  export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
@@ -221,6 +222,19 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
221
222
  * a `ps` snapshot. Exported so a test can assert the value is absent from argv.
222
223
  */
223
224
  export declare function buildAddGenericPasswordArgs(account: string, item: string): string[];
225
+ /**
226
+ * spawnSync options for the bare `-w` keychain write. Pure so the two
227
+ * load-bearing properties are unit-testable without touching the real keychain:
228
+ * - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
229
+ * fails the confirm and stores an empty secret).
230
+ * - `detached: true` runs `security` in a new session with no controlling
231
+ * terminal, so readpassphrase(3) falls back to our piped stdin instead of
232
+ * prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
233
+ */
234
+ export declare function buildAddGenericPasswordSpawnOptions(value: string): SpawnSyncOptions & {
235
+ input: string;
236
+ detached: boolean;
237
+ };
224
238
  export declare function setKeychainToken(item: string, value: string, opts?: {
225
239
  noAcl?: boolean;
226
240
  }): void;
@@ -506,6 +506,11 @@ export function computeRekeyPlan(services, values, key) {
506
506
  if (dot > 0)
507
507
  noAcl = bundleNoAcl(rest.slice(0, dot));
508
508
  }
509
+ // Durable "unlocked session" items (agents-cli.session.*, see session-store.ts)
510
+ // are always no-ACL — re-wrapping them in a biometry ACL on rekey would break
511
+ // their silent (no-Touch-ID) reads that the rehydrate/fallback paths depend on.
512
+ if (service.startsWith('agents-cli.session.'))
513
+ noAcl = true;
509
514
  items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
510
515
  }
511
516
  return { items, unreadable };
@@ -853,6 +858,25 @@ function parseBatchRecords(out, record) {
853
858
  export function buildAddGenericPasswordArgs(account, item) {
854
859
  return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
855
860
  }
861
+ /**
862
+ * spawnSync options for the bare `-w` keychain write. Pure so the two
863
+ * load-bearing properties are unit-testable without touching the real keychain:
864
+ * - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
865
+ * fails the confirm and stores an empty secret).
866
+ * - `detached: true` runs `security` in a new session with no controlling
867
+ * terminal, so readpassphrase(3) falls back to our piped stdin instead of
868
+ * prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
869
+ */
870
+ export function buildAddGenericPasswordSpawnOptions(value) {
871
+ // `detached` is honored by spawnSync at runtime (libuv setsid) but is not
872
+ // declared on Node's SpawnSyncOptions type, so widen the return explicitly.
873
+ return {
874
+ input: `${value}\n${value}\n`,
875
+ stdio: ['pipe', 'pipe', 'pipe'],
876
+ timeout: 10_000,
877
+ detached: true,
878
+ };
879
+ }
856
880
  export function setKeychainToken(item, value, opts) {
857
881
  // Validate the CLEARTEXT name (a hashed storage name is always clean), then
858
882
  // resolve the storage name.
@@ -893,11 +917,18 @@ export function setKeychainToken(item, value, opts) {
893
917
  // (assertValueStorable above), so each line carries the whole secret verbatim
894
918
  // (no shell/quoting layer). `timeout` bounds the call so a context that cannot
895
919
  // read the prompt fails loudly instead of hanging.
896
- const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), {
897
- input: `${value}\n${value}\n`,
898
- stdio: ['pipe', 'pipe', 'pipe'],
899
- timeout: 10_000,
900
- });
920
+ //
921
+ // `detached: true` is load-bearing, not an afterthought: readpassphrase(3)
922
+ // reads from the *controlling terminal* (`/dev/tty`) when one exists, and only
923
+ // falls back to fd 0 when it cannot open one. So piping over stdin works in a
924
+ // headless/CI context (no controlling tty) but is IGNORED in an interactive
925
+ // shell — there `security` prompts the real user ("password data for new
926
+ // item:") and hangs to the timeout, and any keystroke would be stored AS the
927
+ // secret. `detached` runs the child in a new session (setsid) with no
928
+ // controlling terminal, so readpassphrase always falls back to our piped
929
+ // stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
930
+ // OAuth token) or `agents secrets add` pops a keychain password sheet.
931
+ const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value));
901
932
  if (sec.status !== 0) {
902
933
  const msg = sec.stderr?.toString().trim();
903
934
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Durable "unlocked session" store (macOS).
3
+ *
4
+ * The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
5
+ * `agents secrets unlock` grant is lost whenever that process dies: on an
6
+ * agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
7
+ * This module persists the resolved bundle+env of an unlocked bundle as a
8
+ * NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
9
+ * FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
10
+ * REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
11
+ *
12
+ * Split-default posture: `sleepPersist` is false by default — the item is deleted
13
+ * on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
14
+ * `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
15
+ *
16
+ * Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
17
+ * (Credential Manager) have no broker and resolve secrets durably from the OS
18
+ * store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
19
+ * ephemeral unlock to persist.
20
+ *
21
+ * NEVER enumerate session items: once keychain-name hashing (#316) is active a
22
+ * `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
23
+ * fixed-name index item lists the held bundles + their metadata, and one blob per
24
+ * bundle holds the env. Every adapter call is best-effort and never throws;
25
+ * persistence is an optimization, not a correctness dependency.
26
+ */
27
+ import type { SecretsBundle } from './bundles.js';
28
+ /** Prefix for all durable session items (device-local, no-ACL). */
29
+ export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
30
+ /** Fixed-name index item — the ONLY thing we ever need to find without a known
31
+ * bundle name, so it is read/written by this exact name (never enumerated). */
32
+ export declare const SESSION_INDEX_ITEM = "agents-cli.session.index";
33
+ /** One persisted unlocked bundle (mirrors the broker's StoredBundle + posture). */
34
+ export interface SessionEntry {
35
+ bundle: SecretsBundle;
36
+ env: Record<string, string>;
37
+ /** epoch ms; the entry is dead once Date.now() passes this. */
38
+ expiresAt: number;
39
+ /** true only for `--durable` unlocks — survives SLEEP. */
40
+ sleepPersist: boolean;
41
+ }
42
+ /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
43
+ * without reading every blob. */
44
+ export interface SessionIndexMeta {
45
+ expiresAt: number;
46
+ sleepPersist: boolean;
47
+ }
48
+ export interface SessionIndex {
49
+ bundles: Record<string, SessionIndexMeta>;
50
+ }
51
+ /** The bundle names in the index that are still within their TTL at `now`. */
52
+ export declare function selectRehydratable(index: SessionIndex, now: number): string[];
53
+ /** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
54
+ * (which the caller deletes from the keychain). Default entries re-lock on sleep. */
55
+ export declare function pruneOnSleep(index: SessionIndex): {
56
+ survivors: SessionIndex;
57
+ deletedNames: string[];
58
+ };
59
+ /** Drop entries past their TTL; report which were dropped so the caller can
60
+ * delete their blobs. */
61
+ export declare function pruneExpired(index: SessionIndex, now: number): {
62
+ survivors: SessionIndex;
63
+ expiredNames: string[];
64
+ };
65
+ /** Insert/replace one bundle in the index (pure). */
66
+ export declare function upsertEntry(index: SessionIndex, name: string, meta: SessionIndexMeta): SessionIndex;
67
+ /** Remove one bundle from the index (pure). */
68
+ export declare function removeEntry(index: SessionIndex, name: string): SessionIndex;
69
+ /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
70
+ export declare function readIndex(): SessionIndex;
71
+ /** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
72
+ export declare function writeIndex(index: SessionIndex): void;
73
+ /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
74
+ export declare function saveSession(name: string, entry: SessionEntry): void;
75
+ /** Read one session blob by known name. Null when absent/expired/malformed. */
76
+ export declare function loadSession(name: string, now?: number): SessionEntry | null;
77
+ /** Delete one bundle's session blob and prune it from the index. */
78
+ export declare function deleteSession(name: string): void;
79
+ /** Delete every session blob + the index (for `secrets lock --all`). */
80
+ export declare function deleteAllSessions(): void;
81
+ /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
82
+ * load on start; prunes expired index entries + blobs as a side effect. Survives
83
+ * upgrade/restart. Empty off darwin or when nothing is held. */
84
+ export declare function rehydrateSessions(now?: number): Array<{
85
+ name: string;
86
+ entry: SessionEntry;
87
+ }>;
88
+ /** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
89
+ * keep the durable ones. Default bundles thus re-lock on sleep. */
90
+ export declare function pruneSessionsOnSleep(): void;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Durable "unlocked session" store (macOS).
3
+ *
4
+ * The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
5
+ * `agents secrets unlock` grant is lost whenever that process dies: on an
6
+ * agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
7
+ * This module persists the resolved bundle+env of an unlocked bundle as a
8
+ * NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
9
+ * FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
10
+ * REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
11
+ *
12
+ * Split-default posture: `sleepPersist` is false by default — the item is deleted
13
+ * on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
14
+ * `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
15
+ *
16
+ * Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
17
+ * (Credential Manager) have no broker and resolve secrets durably from the OS
18
+ * store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
19
+ * ephemeral unlock to persist.
20
+ *
21
+ * NEVER enumerate session items: once keychain-name hashing (#316) is active a
22
+ * `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
23
+ * fixed-name index item lists the held bundles + their metadata, and one blob per
24
+ * bundle holds the env. Every adapter call is best-effort and never throws;
25
+ * persistence is an optimization, not a correctness dependency.
26
+ */
27
+ import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
28
+ /** Prefix for all durable session items (device-local, no-ACL). */
29
+ export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
30
+ /** Fixed-name index item — the ONLY thing we ever need to find without a known
31
+ * bundle name, so it is read/written by this exact name (never enumerated). */
32
+ export const SESSION_INDEX_ITEM = 'agents-cli.session.index';
33
+ // ─── Pure core (no I/O — unit-testable on any platform) ─────────────────────
34
+ /** The bundle names in the index that are still within their TTL at `now`. */
35
+ export function selectRehydratable(index, now) {
36
+ return Object.entries(index.bundles)
37
+ .filter(([, m]) => now < m.expiresAt)
38
+ .map(([name]) => name);
39
+ }
40
+ /** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
41
+ * (which the caller deletes from the keychain). Default entries re-lock on sleep. */
42
+ export function pruneOnSleep(index) {
43
+ const survivors = { bundles: {} };
44
+ const deletedNames = [];
45
+ for (const [name, m] of Object.entries(index.bundles)) {
46
+ if (m.sleepPersist)
47
+ survivors.bundles[name] = m;
48
+ else
49
+ deletedNames.push(name);
50
+ }
51
+ return { survivors, deletedNames };
52
+ }
53
+ /** Drop entries past their TTL; report which were dropped so the caller can
54
+ * delete their blobs. */
55
+ export function pruneExpired(index, now) {
56
+ const survivors = { bundles: {} };
57
+ const expiredNames = [];
58
+ for (const [name, m] of Object.entries(index.bundles)) {
59
+ if (now < m.expiresAt)
60
+ survivors.bundles[name] = m;
61
+ else
62
+ expiredNames.push(name);
63
+ }
64
+ return { survivors, expiredNames };
65
+ }
66
+ /** Insert/replace one bundle in the index (pure). */
67
+ export function upsertEntry(index, name, meta) {
68
+ return { bundles: { ...index.bundles, [name]: meta } };
69
+ }
70
+ /** Remove one bundle from the index (pure). */
71
+ export function removeEntry(index, name) {
72
+ const { [name]: _drop, ...rest } = index.bundles;
73
+ void _drop;
74
+ return { bundles: rest };
75
+ }
76
+ // ─── Keychain adapter (macOS only; best-effort, never throws) ────────────────
77
+ /** Whether to actually persist. macOS in production; also whenever a test
78
+ * keychain backend is installed, so the adapter is exercisable on Linux CI
79
+ * against an in-memory store (mirrors the broker's hermetic-test gating). In real
80
+ * Linux/Windows production this is false — secrets already persist in the OS store
81
+ * with no broker, so there is nothing to mirror. */
82
+ function shouldPersist() {
83
+ return process.platform === 'darwin' || isKeychainBackendOverridden();
84
+ }
85
+ function sessionBlobItem(name) {
86
+ return `${SESSION_ITEM_PREFIX}${name}`;
87
+ }
88
+ /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
89
+ export function readIndex() {
90
+ try {
91
+ const raw = getKeychainToken(SESSION_INDEX_ITEM);
92
+ const parsed = JSON.parse(raw);
93
+ if (parsed && typeof parsed === 'object' && parsed.bundles)
94
+ return parsed;
95
+ }
96
+ catch {
97
+ /* absent or malformed → empty */
98
+ }
99
+ return { bundles: {} };
100
+ }
101
+ /** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
102
+ export function writeIndex(index) {
103
+ try {
104
+ if (Object.keys(index.bundles).length === 0) {
105
+ deleteKeychainToken(SESSION_INDEX_ITEM);
106
+ return;
107
+ }
108
+ setKeychainToken(SESSION_INDEX_ITEM, JSON.stringify(index), { noAcl: true });
109
+ }
110
+ catch {
111
+ /* best-effort */
112
+ }
113
+ }
114
+ /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
115
+ export function saveSession(name, entry) {
116
+ if (!shouldPersist())
117
+ return;
118
+ try {
119
+ setKeychainToken(sessionBlobItem(name), JSON.stringify(entry), { noAcl: true });
120
+ writeIndex(upsertEntry(readIndex(), name, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist }));
121
+ }
122
+ catch {
123
+ /* best-effort — persistence is an optimization */
124
+ }
125
+ }
126
+ /** Read one session blob by known name. Null when absent/expired/malformed. */
127
+ export function loadSession(name, now = Date.now()) {
128
+ if (!shouldPersist())
129
+ return null;
130
+ try {
131
+ const raw = getKeychainToken(sessionBlobItem(name));
132
+ const entry = JSON.parse(raw);
133
+ if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
134
+ return null;
135
+ if (now >= entry.expiresAt) {
136
+ deleteSession(name); // drop expired on read, mirroring the broker's get handler
137
+ return null;
138
+ }
139
+ return entry;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ /** Delete one bundle's session blob and prune it from the index. */
146
+ export function deleteSession(name) {
147
+ if (!shouldPersist())
148
+ return;
149
+ try {
150
+ deleteKeychainToken(sessionBlobItem(name));
151
+ writeIndex(removeEntry(readIndex(), name));
152
+ }
153
+ catch {
154
+ /* best-effort */
155
+ }
156
+ }
157
+ /** Delete every session blob + the index (for `secrets lock --all`). */
158
+ export function deleteAllSessions() {
159
+ if (!shouldPersist())
160
+ return;
161
+ try {
162
+ for (const name of Object.keys(readIndex().bundles)) {
163
+ try {
164
+ deleteKeychainToken(sessionBlobItem(name));
165
+ }
166
+ catch { /* keep going */ }
167
+ }
168
+ deleteKeychainToken(SESSION_INDEX_ITEM);
169
+ }
170
+ catch {
171
+ /* best-effort */
172
+ }
173
+ }
174
+ /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
175
+ * load on start; prunes expired index entries + blobs as a side effect. Survives
176
+ * upgrade/restart. Empty off darwin or when nothing is held. */
177
+ export function rehydrateSessions(now = Date.now()) {
178
+ if (!shouldPersist())
179
+ return [];
180
+ const out = [];
181
+ try {
182
+ const index = readIndex();
183
+ const { survivors, expiredNames } = pruneExpired(index, now);
184
+ for (const name of expiredNames) {
185
+ try {
186
+ deleteKeychainToken(sessionBlobItem(name));
187
+ }
188
+ catch { /* keep going */ }
189
+ }
190
+ if (expiredNames.length)
191
+ writeIndex(survivors);
192
+ for (const name of selectRehydratable(survivors, now)) {
193
+ const entry = loadSession(name, now);
194
+ if (entry)
195
+ out.push({ name, entry });
196
+ }
197
+ }
198
+ catch {
199
+ /* best-effort */
200
+ }
201
+ return out;
202
+ }
203
+ /** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
204
+ * keep the durable ones. Default bundles thus re-lock on sleep. */
205
+ export function pruneSessionsOnSleep() {
206
+ if (!shouldPersist())
207
+ return;
208
+ try {
209
+ const { survivors, deletedNames } = pruneOnSleep(readIndex());
210
+ for (const name of deletedNames) {
211
+ try {
212
+ deleteKeychainToken(sessionBlobItem(name));
213
+ }
214
+ catch { /* keep going */ }
215
+ }
216
+ writeIndex(survivors);
217
+ }
218
+ catch {
219
+ /* best-effort */
220
+ }
221
+ }
@@ -108,6 +108,14 @@ export declare function renderConversationMarkdown(events: SessionEvent[], opts?
108
108
  * `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
109
109
  * live on `SessionMeta` are visible without re-parsing events. Legacy
110
110
  * callers that pass no meta still get a bare `SessionEvent[]` array.
111
+ *
112
+ * Secrets are redacted by default (`opts.redact !== false`), matching
113
+ * `renderConversationMarkdown` — the JSON path must not be the one output
114
+ * format that leaks credentials. Redaction covers both events AND the session
115
+ * meta (e.g. `topic`, a verbatim first-message excerpt). Pass `{ redact: false }`
116
+ * for `--no-redact`.
111
117
  */
112
- export declare function renderJson(events: SessionEvent[], meta?: SessionMeta): string;
118
+ export declare function renderJson(events: SessionEvent[], meta?: SessionMeta, opts?: {
119
+ redact?: boolean;
120
+ }): string;
113
121
  export {};
@@ -913,19 +913,50 @@ export function renderConversationMarkdown(events, opts = {}) {
913
913
  }
914
914
  return parts.join('\n\n');
915
915
  }
916
+ /**
917
+ * Deep-redact every string reachable from a JSON value, preserving structure.
918
+ * `redactSecrets` only rewrites secret-shaped substrings, so non-secret strings
919
+ * (paths, URLs, ids) and non-strings (numbers, booleans) pass through unchanged.
920
+ * Applied to the whole `--json` payload so every free-text field — event
921
+ * `content`/`command`/`output`/`args`, and meta `topic`/`label`/`plan`/`todos`
922
+ * — is covered, including fields added later.
923
+ */
924
+ function redactDeep(value, sanitize) {
925
+ if (typeof value === 'string')
926
+ return sanitize(value);
927
+ if (Array.isArray(value))
928
+ return value.map((item) => redactDeep(item, sanitize));
929
+ if (value && typeof value === 'object') {
930
+ const out = {};
931
+ for (const [key, val] of Object.entries(value))
932
+ out[key] = redactDeep(val, sanitize);
933
+ return out;
934
+ }
935
+ return value;
936
+ }
916
937
  /**
917
938
  * Render one session's JSON output — the shape `agents sessions <id> --json`
918
939
  * emits. When `meta` is provided (the standard path), returns a
919
940
  * `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
920
941
  * live on `SessionMeta` are visible without re-parsing events. Legacy
921
942
  * callers that pass no meta still get a bare `SessionEvent[]` array.
943
+ *
944
+ * Secrets are redacted by default (`opts.redact !== false`), matching
945
+ * `renderConversationMarkdown` — the JSON path must not be the one output
946
+ * format that leaks credentials. Redaction covers both events AND the session
947
+ * meta (e.g. `topic`, a verbatim first-message excerpt). Pass `{ redact: false }`
948
+ * for `--no-redact`.
922
949
  */
923
- export function renderJson(events, meta) {
950
+ export function renderJson(events, meta, opts = {}) {
951
+ const redact = opts.redact !== false;
952
+ const sanitize = (text) => redactSecrets(text);
953
+ const safeEvents = redact ? events.map((event) => redactDeep(event, sanitize)) : events;
924
954
  if (!meta)
925
- return JSON.stringify(events, null, 2);
955
+ return JSON.stringify(safeEvents, null, 2);
926
956
  // Strip internal-only bookkeeping fields the listing --json path also strips.
927
- const { _matchedTerms, _bm25Score, _remote, ...session } = meta;
928
- return JSON.stringify({ session, events }, null, 2);
957
+ const { _matchedTerms, _bm25Score, _remote, ...rest } = meta;
958
+ const session = redact ? redactDeep(rest, sanitize) : rest;
959
+ return JSON.stringify({ session, events: safeEvents }, null, 2);
929
960
  }
930
961
  /** Replace the home directory prefix with ~ for trace display. */
931
962
  function shortenPathTrace(p) {
@@ -21,6 +21,8 @@ export declare const OG_HEIGHT = 630;
21
21
  export declare const OG_SCALE = 2;
22
22
  /** Ordered list of candidate Chromium-family binaries to try for headless capture. */
23
23
  export declare function candidateBrowsers(): string[];
24
+ /** Newest-first Chromium binaries under the Playwright / Puppeteer download caches. */
25
+ export declare function scanCaches(home?: string, platform?: NodeJS.Platform): string[];
24
26
  /**
25
27
  * Screenshot `htmlPath`'s top 1200×630 (its hero) to a PNG buffer, or null if no
26
28
  * headless-capable browser is available or every candidate fails. Never throws —
@@ -55,22 +55,29 @@ export function candidateBrowsers() {
55
55
  return out;
56
56
  }
57
57
  /** Newest-first Chromium binaries under the Playwright / Puppeteer download caches. */
58
- function scanCaches() {
59
- const home = os.homedir();
60
- const roots = os.platform() === 'darwin'
58
+ export function scanCaches(home = os.homedir(), platform = os.platform()) {
59
+ const roots = platform === 'darwin'
61
60
  ? [
62
61
  path.join(home, 'Library/Caches/ms-playwright'),
63
62
  path.join(home, '.cache/ms-playwright'),
64
63
  path.join(home, '.cache/puppeteer/chrome'),
65
64
  ]
66
65
  : [path.join(home, '.cache/ms-playwright'), path.join(home, '.cache/puppeteer/chrome')];
67
- const rel = os.platform() === 'darwin'
66
+ const rel = platform === 'darwin'
68
67
  ? [
69
68
  'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
70
69
  'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
71
70
  'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
71
+ // Playwright's newer headless-shell packages (chromium_headless_shell-*) —
72
+ // a raw binary, not an .app bundle.
73
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell',
74
+ 'chrome-headless-shell-mac-x64/chrome-headless-shell',
72
75
  ]
73
- : ['chrome-linux/chrome', 'chrome-linux64/chrome'];
76
+ : [
77
+ 'chrome-linux/chrome',
78
+ 'chrome-linux64/chrome',
79
+ 'chrome-headless-shell-linux64/chrome-headless-shell',
80
+ ];
74
81
  const found = [];
75
82
  for (const root of roots) {
76
83
  let entries;
@@ -92,7 +92,14 @@ export declare function listAllSkills(): string[];
92
92
  */
93
93
  export declare function getVersionSkillsDir(agent: AgentId, version: string): string;
94
94
  /**
95
- * List skill names installed in a specific version home.
95
+ * List real skill names installed in a specific version home.
96
+ *
97
+ * Command-as-skill WRAPPERS (a `skills/<name>/SKILL.md` carrying the
98
+ * `agents_command` marker — how command-runtime agents like Codex/Kimi install
99
+ * commands) are excluded: they are commands, reconciled by the commands diff
100
+ * against the command source, not skills. Counting them here made the skill
101
+ * orphan/extra detectors flag every command wrapper (tickets, prune, swarm-plan,
102
+ * …) as an unmanaged skill — a false positive that `prune cleanup` would delete.
96
103
  */
97
104
  export declare function listSkillsInVersionHome(agent: AgentId, version: string): string[];
98
105
  export interface VersionSkillDiff {
@@ -14,6 +14,7 @@ import { AGENTS, ensureSkillsDir, agentConfigDirName } from './agents.js';
14
14
  import { capableAgents, isCapable } from './capabilities.js';
15
15
  import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, getTrashSkillsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
17
+ import { listCommandSkillsInVersion } from './command-skills.js';
17
18
  import { emit } from './events.js';
18
19
  const HOME = os.homedir();
19
20
  /** User-scoped skills dir (~/.agents/skills/). Used for installs. */
@@ -392,16 +393,25 @@ export function getVersionSkillsDir(agent, version) {
392
393
  return path.join(home, agentConfigDirName(agent), 'skills');
393
394
  }
394
395
  /**
395
- * List skill names installed in a specific version home.
396
+ * List real skill names installed in a specific version home.
397
+ *
398
+ * Command-as-skill WRAPPERS (a `skills/<name>/SKILL.md` carrying the
399
+ * `agents_command` marker — how command-runtime agents like Codex/Kimi install
400
+ * commands) are excluded: they are commands, reconciled by the commands diff
401
+ * against the command source, not skills. Counting them here made the skill
402
+ * orphan/extra detectors flag every command wrapper (tickets, prune, swarm-plan,
403
+ * …) as an unmanaged skill — a false positive that `prune cleanup` would delete.
396
404
  */
397
405
  export function listSkillsInVersionHome(agent, version) {
398
406
  const dir = getVersionSkillsDir(agent, version);
399
407
  if (!fs.existsSync(dir))
400
408
  return [];
409
+ const commandWrappers = new Set(listCommandSkillsInVersion(path.join(getVersionHomePath(agent, version), agentConfigDirName(agent))));
401
410
  return fs.readdirSync(dir, { withFileTypes: true })
402
411
  .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
403
412
  .filter((e) => fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
404
413
  .map((e) => e.name)
414
+ .filter((name) => !commandWrappers.has(name))
405
415
  .sort();
406
416
  }
407
417
  /**
@@ -664,12 +664,16 @@ export interface Meta {
664
664
  * concurrent runs read silently — set it `false` to force a prompt on every read.
665
665
  * `holdMs` caps how long an unlocked/auto-cached bundle is held before the next
666
666
  * read re-prompts (default 7 days; e.g. 86400000 for a 24h cap). Clamped to
667
- * [1 minute, 30 days]. */
667
+ * [1 minute, 30 days]. `durable` (default off) makes every `agents secrets
668
+ * unlock` survive sleep + reboot as well as upgrade/restart — the same effect as
669
+ * passing `--durable` per unlock; off means the secure split default (survive
670
+ * upgrade/restart, re-lock on sleep). */
668
671
  secrets?: {
669
672
  policy?: 'always' | 'daily';
670
673
  agent?: {
671
674
  auto?: boolean;
672
675
  holdMs?: number;
676
+ durable?: boolean;
673
677
  };
674
678
  };
675
679
  /** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.70",
3
+ "version": "1.20.71",
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",