@phnx-labs/agents-cli 1.22.27 → 1.22.28

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 (87) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.js +15 -2
  45. package/dist/lib/models.d.ts +18 -11
  46. package/dist/lib/models.js +19 -15
  47. package/dist/lib/perf/db.js +16 -3
  48. package/dist/lib/perf/types.d.ts +12 -1
  49. package/dist/lib/project-probe.d.ts +22 -3
  50. package/dist/lib/project-probe.js +105 -17
  51. package/dist/lib/project-status.d.ts +9 -0
  52. package/dist/lib/project-status.js +15 -0
  53. package/dist/lib/refresh.js +22 -11
  54. package/dist/lib/rotate.d.ts +22 -0
  55. package/dist/lib/rotate.js +26 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/secrets/agent.d.ts +19 -3
  59. package/dist/lib/secrets/agent.js +58 -20
  60. package/dist/lib/secrets/audit.d.ts +1 -1
  61. package/dist/lib/secrets/audit.js +2 -0
  62. package/dist/lib/secrets/bundles.d.ts +3 -3
  63. package/dist/lib/secrets/bundles.js +15 -5
  64. package/dist/lib/secrets/filestore.d.ts +2 -0
  65. package/dist/lib/secrets/filestore.js +4 -0
  66. package/dist/lib/secrets/session-store.d.ts +7 -0
  67. package/dist/lib/secrets/session-store.js +21 -0
  68. package/dist/lib/session/active.d.ts +49 -0
  69. package/dist/lib/session/active.js +175 -11
  70. package/dist/lib/session/bash-command.d.ts +16 -0
  71. package/dist/lib/session/bash-command.js +65 -0
  72. package/dist/lib/session/db.d.ts +4 -0
  73. package/dist/lib/session/db.js +32 -15
  74. package/dist/lib/session/remote-bundle.js +1 -1
  75. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  76. package/dist/lib/staleness/detectors/skills.js +17 -2
  77. package/dist/lib/staleness/index.d.ts +10 -1
  78. package/dist/lib/staleness/index.js +28 -3
  79. package/dist/lib/teams/agents.d.ts +1 -0
  80. package/dist/lib/teams/agents.js +38 -11
  81. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  82. package/dist/lib/teams/remoteWorktree.js +29 -27
  83. package/dist/lib/versions.d.ts +1 -0
  84. package/dist/lib/versions.js +40 -22
  85. package/package.json +3 -1
  86. package/dist/lib/exec.bench.d.ts +0 -1
  87. package/dist/lib/exec.bench.js +0 -186
@@ -26,6 +26,7 @@
26
26
  import * as net from 'net';
27
27
  import type { SecretsBundle } from './bundles.js';
28
28
  import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
29
+ import { type SecretLease } from './lease.js';
29
30
  export { GLOBAL_HARNESS, bundleScopeChain };
30
31
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
31
32
  export declare const DEFAULT_TTL_MS: number;
@@ -72,6 +73,7 @@ export interface StoredBundle {
72
73
  /** epoch ms; the entry is gone once Date.now() passes this. */
73
74
  expiresAt: number;
74
75
  harness: string;
76
+ lease?: SecretLease;
75
77
  }
76
78
  /** One unlocked bundle as reported by `status`. */
77
79
  export interface AgentStatusEntry {
@@ -79,6 +81,8 @@ export interface AgentStatusEntry {
79
81
  expiresAt: number;
80
82
  keyCount: number;
81
83
  harness: string;
84
+ leaseId?: string;
85
+ keys?: string[];
82
86
  }
83
87
  /**
84
88
  * Read the current broker capability token, or null if none is present. Clients
@@ -119,11 +123,16 @@ export type Request = {
119
123
  bundle: SecretsBundle;
120
124
  env: Record<string, string>;
121
125
  ttlMs: number;
126
+ lease?: SecretLease;
122
127
  token?: string;
123
128
  } | {
124
129
  cmd: 'lock';
125
130
  name?: string;
126
131
  token?: string;
132
+ } | {
133
+ cmd: 'revoke';
134
+ leaseId: string;
135
+ token?: string;
127
136
  } | {
128
137
  cmd: 'status';
129
138
  token?: string;
@@ -143,6 +152,7 @@ export type Response = {
143
152
  hit: true;
144
153
  bundle: SecretsBundle;
145
154
  env: Record<string, string>;
155
+ lease?: SecretLease;
146
156
  } | {
147
157
  ok: true;
148
158
  cmd: 'load';
@@ -150,6 +160,10 @@ export type Response = {
150
160
  ok: true;
151
161
  cmd: 'lock';
152
162
  wiped: number;
163
+ } | {
164
+ ok: true;
165
+ cmd: 'revoke';
166
+ wiped: number;
153
167
  } | {
154
168
  ok: true;
155
169
  cmd: 'status';
@@ -237,7 +251,7 @@ export declare function runSecretsAgent(opts?: {
237
251
  *
238
252
  * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
239
253
  * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
240
- * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
254
+ * keeps working through the versioned protocol — but it is daemon-safe:
241
255
  *
242
256
  * - no pid-file single-instance guard (the daemon owns the instance);
243
257
  * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
@@ -296,6 +310,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
296
310
  export declare function agentGetSync(name: string, harness?: string): {
297
311
  bundle: SecretsBundle;
298
312
  env: Record<string, string>;
313
+ lease?: SecretLease;
299
314
  } | null;
300
315
  /**
301
316
  * Last non-empty line of a child's stdout — the payload line.
@@ -410,7 +425,7 @@ export declare function clampHoldMs(v: unknown): number;
410
425
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
411
426
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
412
427
  */
413
- export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
428
+ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): void;
414
429
  /**
415
430
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
416
431
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
@@ -425,10 +440,11 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
425
440
  */
426
441
  export declare function runAgentLoadFromStdin(): Promise<void>;
427
442
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
428
- export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
443
+ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): Promise<boolean>;
429
444
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
430
445
  * wiped, or 0 when no broker is running. */
431
446
  export declare function agentLock(name?: string): Promise<number>;
447
+ export declare function agentRevoke(leaseId: string): Promise<number>;
432
448
  /** List currently-unlocked bundles, or [] when no broker is running. The
433
449
  * internal `secrets list` metadata-cache entry is filtered out here as well as
434
450
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -35,9 +35,11 @@ import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
37
  import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
38
- import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
+ import { deleteLeaseSession, rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
39
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
40
  import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
41
+ import { selectLeasedEnv } from './lease.js';
42
+ import { emitSecretAudit } from './audit.js';
41
43
  // Re-exported so callers already reaching for agent.js keep one obvious home for
42
44
  // the scope vocabulary; the definitions live in the leaf module scope.ts because
43
45
  // agent.ts and session-store.ts import each other and a cyclic `const` read can
@@ -45,7 +47,7 @@ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
45
47
  export { GLOBAL_HARNESS, bundleScopeChain };
46
48
  /** Bumped when the wire protocol changes; a client that pings a mismatched
47
49
  * server kills and respawns it rather than talking a stale dialect. */
48
- const PROTOCOL_VERSION = 2;
50
+ const PROTOCOL_VERSION = 3;
49
51
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
50
52
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
51
53
  /**
@@ -154,7 +156,7 @@ function rehydrateStore(now = Date.now()) {
154
156
  const store = new Map();
155
157
  for (const { name, entry } of rehydrateSessions(now)) {
156
158
  const harness = entry.harness || GLOBAL_HARNESS;
157
- store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
159
+ store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness, lease: entry.lease });
158
160
  }
159
161
  return store;
160
162
  }
@@ -352,16 +354,30 @@ export function handleAgentRequest(store, req, now = Date.now()) {
352
354
  continue;
353
355
  if (now >= e.expiresAt) {
354
356
  store.delete(key);
357
+ if (e.lease) {
358
+ deleteLeaseSession(e.lease.id);
359
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
360
+ }
355
361
  continue;
356
- } // drop expired on read
357
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
362
+ }
363
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env, lease: e.lease };
358
364
  }
359
365
  return { ok: true, cmd: 'get', hit: false };
360
366
  }
361
- case 'load':
367
+ case 'load': {
362
368
  const harness = req.harness || GLOBAL_HARNESS;
363
- store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
369
+ let env = req.env;
370
+ if (req.lease) {
371
+ try {
372
+ env = selectLeasedEnv(req.lease, req.env, now);
373
+ }
374
+ catch (err) {
375
+ return { ok: false, error: err.message };
376
+ }
377
+ }
378
+ store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env, expiresAt: req.lease?.expiresAt ?? now + req.ttlMs, harness, lease: req.lease });
364
379
  return { ok: true, cmd: 'load' };
380
+ }
365
381
  case 'lock': {
366
382
  if (req.name) {
367
383
  let wiped = 0;
@@ -374,6 +390,13 @@ export function handleAgentRequest(store, req, now = Date.now()) {
374
390
  store.clear();
375
391
  return { ok: true, cmd: 'lock', wiped };
376
392
  }
393
+ case 'revoke': {
394
+ let wiped = 0;
395
+ for (const [key, entry] of store)
396
+ if (entry.lease?.id === req.leaseId && store.delete(key))
397
+ wiped++;
398
+ return { ok: true, cmd: 'revoke', wiped };
399
+ }
377
400
  case 'status': {
378
401
  const entries = [];
379
402
  for (const [name, e] of store) {
@@ -381,7 +404,7 @@ export function handleAgentRequest(store, req, now = Date.now()) {
381
404
  continue;
382
405
  if (e.bundle.name.startsWith(META_CACHE_PREFIX))
383
406
  continue; // internal list cache
384
- entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
407
+ entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness, leaseId: e.lease?.id, keys: e.lease?.keys });
385
408
  }
386
409
  return { ok: true, cmd: 'status', entries };
387
410
  }
@@ -649,8 +672,13 @@ export async function runSecretsAgent(opts = {}) {
649
672
  const sweep = () => {
650
673
  const now = Date.now();
651
674
  for (const [name, e] of store)
652
- if (now >= e.expiresAt)
675
+ if (now >= e.expiresAt) {
653
676
  store.delete(name);
677
+ if (e.lease) {
678
+ deleteLeaseSession(e.lease.id);
679
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
680
+ }
681
+ }
654
682
  const live = realBundleCount(store);
655
683
  // Self-heal onto a newer in-place install — but ONLY while no real unlocks
656
684
  // are held, so we never wipe live unlocks and force a re-prompt (#435). A
@@ -767,7 +795,7 @@ export async function runSecretsAgent(opts = {}) {
767
795
  *
768
796
  * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
769
797
  * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
770
- * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
798
+ * keeps working through the versioned protocol — but it is daemon-safe:
771
799
  *
772
800
  * - no pid-file single-instance guard (the daemon owns the instance);
773
801
  * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
@@ -796,9 +824,15 @@ export async function startHostedBroker() {
796
824
  // whole daemon down with it.
797
825
  const sweepTimer = setInterval(() => {
798
826
  const now = Date.now();
799
- for (const [name, e] of store)
800
- if (now >= e.expiresAt)
801
- store.delete(name);
827
+ for (const [name, e] of store) {
828
+ if (now < e.expiresAt)
829
+ continue;
830
+ store.delete(name);
831
+ if (e.lease) {
832
+ deleteLeaseSession(e.lease.id);
833
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
834
+ }
835
+ }
802
836
  }, SWEEP_INTERVAL_MS);
803
837
  // Auto-lock on sleep, same as the standalone broker: the signed helper emits
804
838
  // LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
@@ -944,7 +978,7 @@ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
944
978
  const o = JSON.parse(lastLine(r.stdout));
945
979
  if (!o || typeof o !== 'object' || !o.env)
946
980
  return null;
947
- return { bundle: o.bundle, env: o.env };
981
+ return { bundle: o.bundle, env: o.env, lease: o.lease };
948
982
  }
949
983
  catch {
950
984
  return null;
@@ -1026,7 +1060,7 @@ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
1026
1060
  // truncated on exit — the parent would see a partial or empty payload, treat
1027
1061
  // it as a miss, and fall through to a keychain read, silently costing the
1028
1062
  // Touch ID prompt this whole path exists to avoid.
1029
- const payload = JSON.stringify({ bundle: r.bundle, env: r.env }) + '\n';
1063
+ const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
1030
1064
  await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
1031
1065
  return 0;
1032
1066
  }
@@ -1166,10 +1200,10 @@ export function clampHoldMs(v) {
1166
1200
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1167
1201
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1168
1202
  */
1169
- export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1203
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
1170
1204
  if (!onDarwin())
1171
1205
  return;
1172
- const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
1206
+ const payload = JSON.stringify({ name, bundle, env, ttlMs, harness, lease });
1173
1207
  // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1174
1208
  // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1175
1209
  // ping, NOT mere socket-file existence: a broker that died leaving its socket
@@ -1236,13 +1270,13 @@ export async function runAgentLoadFromStdin() {
1236
1270
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1237
1271
  return;
1238
1272
  }
1239
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
1273
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS, payload.lease);
1240
1274
  if (!loaded)
1241
1275
  process.exitCode = 1; // transport failed — did NOT load
1242
1276
  }
1243
1277
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1244
- export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1245
- const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1278
+ export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
1279
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness, lease });
1246
1280
  return r?.ok === true && r.cmd === 'load';
1247
1281
  }
1248
1282
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
@@ -1251,6 +1285,10 @@ export async function agentLock(name) {
1251
1285
  const r = await request({ cmd: 'lock', name });
1252
1286
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
1253
1287
  }
1288
+ export async function agentRevoke(leaseId) {
1289
+ const r = await request({ cmd: 'revoke', leaseId });
1290
+ return r?.ok === true && r.cmd === 'revoke' ? r.wiped : 0;
1291
+ }
1254
1292
  /** List currently-unlocked bundles, or [] when no broker is running. The
1255
1293
  * internal `secrets list` metadata-cache entry is filtered out here as well as
1256
1294
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -1,4 +1,4 @@
1
- export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
1
+ export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire';
2
2
  export interface SecretAuditParams {
3
3
  /** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
4
4
  event: SecretAuditEvent;
@@ -46,6 +46,8 @@ const USAGE_KIND = {
46
46
  'secrets.import': 'import',
47
47
  'secrets.export': 'export',
48
48
  'secrets.view': 'view',
49
+ 'secrets.lease-denied': 'access',
50
+ 'secrets.lease-expire': 'access',
49
51
  };
50
52
  /**
51
53
  * The agent/harness identity to attribute a secret access to. Explicit callers
@@ -264,9 +264,9 @@ export interface ResolveBundleOptions {
264
264
  export declare function canCacheResolvedEnv(bundle: SecretsBundle, selectedKeys: Set<string>, keyMode: ResolveBundleOptions['keyMode']): boolean;
265
265
  /**
266
266
  * Apply the --keys subset + expiry gate to an already-resolved snapshot from
267
- * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
268
- * naive fast-path return would silently defeat --keys and inject expired
269
- * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
267
+ * the secrets-agent fast-path. The agent stores either a full unlock or a
268
+ * scoped lease env, so a naive fast-path return could silently defeat --keys
269
+ * and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
270
270
  * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
271
271
  *
272
272
  * Exported for tests; production callers reach it via the fast-path branch in
@@ -1033,9 +1033,9 @@ export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
1033
1033
  }
1034
1034
  /**
1035
1035
  * Apply the --keys subset + expiry gate to an already-resolved snapshot from
1036
- * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
1037
- * naive fast-path return would silently defeat --keys and inject expired
1038
- * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
1036
+ * the secrets-agent fast-path. The agent stores either a full unlock or a
1037
+ * scoped lease env, so a naive fast-path return could silently defeat --keys
1038
+ * and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
1039
1039
  * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
1040
1040
  *
1041
1041
  * Exported for tests; production callers reach it via the fast-path branch in
@@ -1230,7 +1230,12 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1230
1230
  const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1231
1231
  const hit = agentGetSync(name, harness);
1232
1232
  if (hit) {
1233
- // The agent stores the FULL bundle env. Apply the same subset filter and
1233
+ const denied = (opts.keys ?? []).filter((key) => hit.lease && !hit.lease.keys.includes(key));
1234
+ if (denied.length > 0) {
1235
+ emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1236
+ throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1237
+ }
1238
+ // The agent stores a full unlock or a scoped lease env. Apply the same subset filter and
1234
1239
  // expiry gate as the slow path — without this, `--secrets-keys X` would
1235
1240
  // silently inject every key and an expired key would flow through after
1236
1241
  // the first cache-populating run.
@@ -1256,13 +1261,18 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1256
1261
  const resolved = resolveSession(name, Date.now(), harness);
1257
1262
  if (resolved) {
1258
1263
  const session = resolved.entry;
1264
+ const denied = (opts.keys ?? []).filter((key) => session.lease && !session.lease.keys.includes(key));
1265
+ if (denied.length > 0) {
1266
+ emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'session', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1267
+ throw new Error(`Secret lease '${session.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1268
+ }
1259
1269
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1260
1270
  stampLastUsed(filtered.bundle);
1261
1271
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1262
1272
  // `agents secrets status` is honest. Re-warm under the scope the grant was
1263
1273
  // MADE in (resolved.harness), never the asking scope — re-warming a global
1264
1274
  // grant as `claude` would silently narrow it for every other harness.
1265
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
1275
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
1266
1276
  emitSecretAudit({
1267
1277
  event: 'secrets.get',
1268
1278
  bundle: name,
@@ -51,6 +51,8 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
51
51
  /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
52
52
  * ciphertext (auth-tag mismatch). Exported for tests. */
53
53
  export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
54
+ /** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
55
+ export declare function fileStoreItemPath(item: string): string;
54
56
  declare function fileHas(item: string): boolean;
55
57
  declare function fileGet(item: string): string;
56
58
  declare function fileGetBatch(items: string[]): Map<string, string>;
@@ -214,6 +214,10 @@ export function decryptForFallback(enc, passphrase) {
214
214
  function fileFor(item) {
215
215
  return path.join(fileDir(), `${item}.enc`);
216
216
  }
217
+ /** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
218
+ export function fileStoreItemPath(item) {
219
+ return fileFor(item);
220
+ }
217
221
  function fileHas(item) {
218
222
  return fs.existsSync(fileFor(item));
219
223
  }
@@ -25,6 +25,7 @@
25
25
  * persistence is an optimization, not a correctness dependency.
26
26
  */
27
27
  import type { SecretsBundle } from './bundles.js';
28
+ import type { SecretLease } from './lease.js';
28
29
  /** Prefix for all durable session items (device-local, no-ACL). */
29
30
  export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
30
31
  /** Fixed-name index item — the ONLY thing we ever need to find without a known
@@ -39,6 +40,7 @@ export interface SessionEntry {
39
40
  /** true only for `--durable` unlocks — survives SLEEP. */
40
41
  sleepPersist: boolean;
41
42
  harness?: string;
43
+ lease?: SecretLease;
42
44
  }
43
45
  /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
44
46
  * without reading every blob. */
@@ -90,6 +92,11 @@ export declare function loadSession(name: string, now?: number, harness?: string
90
92
  export declare function deleteBundleSessions(name: string): void;
91
93
  /** Delete one bundle's session blob and prune it from the index. */
92
94
  export declare function deleteSession(name: string, harness?: string): void;
95
+ export declare function deleteLeaseSession(leaseId: string): number;
96
+ export declare function activeLeaseSessions(now?: number): Array<{
97
+ name: string;
98
+ lease: SecretLease;
99
+ }>;
93
100
  /** Delete every session blob + the index (for `secrets lock --all`). */
94
101
  export declare function deleteAllSessions(): void;
95
102
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -192,6 +192,27 @@ export function deleteSession(name, harness = GLOBAL_HARNESS) {
192
192
  /* best-effort */
193
193
  }
194
194
  }
195
+ export function deleteLeaseSession(leaseId) {
196
+ if (!shouldPersist())
197
+ return 0;
198
+ const index = readIndex();
199
+ let deleted = 0;
200
+ for (const [key, meta] of Object.entries(index.bundles)) {
201
+ const bundleName = key.split(':').slice(1).join(':');
202
+ const harness = meta.harness || GLOBAL_HARNESS;
203
+ const entry = loadSession(bundleName, Date.now(), harness);
204
+ if (entry?.lease?.id !== leaseId)
205
+ continue;
206
+ deleteSession(bundleName, harness);
207
+ deleted++;
208
+ }
209
+ return deleted;
210
+ }
211
+ export function activeLeaseSessions(now = Date.now()) {
212
+ return rehydrateSessions(now)
213
+ .filter((item) => Boolean(item.entry.lease))
214
+ .map(({ name, entry }) => ({ name, lease: entry.lease }));
215
+ }
195
216
  /** Delete every session blob + the index (for `secrets lock --all`). */
196
217
  export function deleteAllSessions() {
197
218
  if (!shouldPersist())
@@ -23,6 +23,42 @@ export declare function resolveOwner(pidActor: string | null | undefined, sessio
23
23
  * cwds are just gathered at a bounded spawn rate instead of a single burst.
24
24
  */
25
25
  export declare const LSOF_CONCURRENCY = 4;
26
+ /**
27
+ * Process-local process-table memo (#2047). One `getActiveSessions` scan can
28
+ * call `ps -A` from the terminal path, the headless path, and host ancestry —
29
+ * without this, each is a full process-table snapshot. 5s is well under the
30
+ * ~10–30s menu-bar / `--active` poll so a quiet re-poll usually reuses the
31
+ * snapshot; a brand-new agent pid still appears on the next full refresh.
32
+ */
33
+ export declare const PROCESS_TABLE_FRESH_MS = 5000;
34
+ /**
35
+ * How long {@link listUnattributedActive} may reuse its last full `ps`+`lsof`
36
+ * result (#2047). Between full rescans the previous rows are re-emitted after
37
+ * dropping dead PIDs and PIDs that are now attributed. A *shrinking* attributed
38
+ * set forces a rescan so a process that just left teams/terminals can reappear
39
+ * as headless instead of vanishing until the next cold poll.
40
+ */
41
+ export declare const UNATTRIBUTED_RESCAN_MS = 15000;
42
+ /** Test seam: override the clock used by process-table / unattributed memos. */
43
+ export declare function setActiveScanClockForTest(fn?: () => number): void;
44
+ /** Test seam: drop the process-table + unattributed memos and their counters. */
45
+ export declare function clearActiveScanCachesForTest(): void;
46
+ /** Test seam: how many times the live `ps` / CIM path actually ran. */
47
+ export declare function processTableLiveReadCountForTest(): number;
48
+ /** Test seam: how many full unattributed (ps + lsof) rescans ran. */
49
+ export declare function unattributedFullRescanCountForTest(): number;
50
+ /**
51
+ * True when any pid in `prev` is absent from `next` — the attributed set
52
+ * shrank, so a process may need to reappear as unattributed. Pure for tests.
53
+ */
54
+ export declare function attributedSetLostPids(prev: Set<number>, next: Set<number>): boolean;
55
+ /**
56
+ * Drop rows whose pid is now attributed or no longer alive. Pure over the
57
+ * inputs so the unattributed TTL reuse path is unit-testable without a live
58
+ * process table. `alive` receives the row's `startedAtMs` so callers can apply
59
+ * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
60
+ */
61
+ export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
26
62
  export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
27
63
  /**
28
64
  * Every status here is COMPUTED by the framework from observable signals — PID
@@ -348,6 +384,8 @@ export declare function resolveNamesToSessionIds(sessionNames: string[], deps: {
348
384
  * the start time can't be read, we keep the existence answer.
349
385
  */
350
386
  export declare function isPidAlive(pid: number, startedAtMs?: number): boolean;
387
+ /** Test seam: drop the Claude session-file path memo. */
388
+ export declare function clearClaudeSessionFileCacheForTest(): void;
351
389
  /**
352
390
  * Resolve a Claude transcript for `cwd` across the given project roots, newest
353
391
  * mtime winning when the same id/cwd resolves in more than one version home
@@ -431,6 +469,8 @@ interface LiveSignals {
431
469
  /** Rolling output-token throughput (tokens/sec). Undefined when no usage in the tail. */
432
470
  tokPerSec?: number;
433
471
  }
472
+ /** Test seam: drop the in-process live-signals memo. */
473
+ export declare function clearLiveSignalsCacheForTest(): void;
434
474
  /**
435
475
  * Derive the inferred state (working / waiting / idle + preview/badges) and, for
436
476
  * the two harnesses whose raw lines carry it, the output-token throughput.
@@ -444,6 +484,9 @@ interface LiveSignals {
444
484
  * `unknown` it used to fall through to. An opaque/untracked kind or an
445
485
  * unreadable/empty transcript yields an empty signal set, and the caller's
446
486
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
487
+ *
488
+ * Unchanged-mtime hits reuse the previous result (see {@link liveSignalsCache})
489
+ * so a 30s active-session poll does not re-tail every quiet transcript.
447
490
  */
448
491
  export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
449
492
  /**
@@ -523,6 +566,12 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
523
566
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
524
567
  * helper, terminal-app, or multiplexer) means `terminal`; nothing of the
525
568
  * sort means `headless` (daemon, launchd-spawned, orphan).
569
+ *
570
+ * Full `ps`+`lsof` rescans are throttled to {@link UNATTRIBUTED_RESCAN_MS}
571
+ * (#2047). Between rescans the previous rows are re-emitted after dropping
572
+ * dead PIDs and PIDs that are now attributed. A shrinking attributed set
573
+ * forces a rescan so a process that just left teams/terminals can reappear
574
+ * as headless.
526
575
  */
527
576
  export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
528
577
  /** One tmux pane's resolved agent identity for the authoritative source. */