@phnx-labs/agents-cli 1.22.15 → 1.22.17

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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +0 -1
  5. package/dist/commands/cloud.js +19 -185
  6. package/dist/commands/doctor.js +16 -14
  7. package/dist/commands/exec.js +153 -16
  8. package/dist/commands/feed.d.ts +1 -0
  9. package/dist/commands/feed.js +36 -32
  10. package/dist/commands/humans.d.ts +10 -0
  11. package/dist/commands/humans.js +91 -0
  12. package/dist/commands/resume.d.ts +17 -0
  13. package/dist/commands/resume.js +80 -0
  14. package/dist/commands/run-cloud.d.ts +26 -0
  15. package/dist/commands/run-cloud.js +162 -0
  16. package/dist/commands/sessions.d.ts +13 -4
  17. package/dist/commands/sessions.js +37 -19
  18. package/dist/commands/versions.js +4 -2
  19. package/dist/commands/view.js +1 -54
  20. package/dist/index.js +3 -2
  21. package/dist/lib/agents.js +4 -2
  22. package/dist/lib/channels/send.d.ts +5 -2
  23. package/dist/lib/channels/send.js +16 -13
  24. package/dist/lib/cloud/dispatch.d.ts +27 -0
  25. package/dist/lib/cloud/dispatch.js +214 -0
  26. package/dist/lib/devices/doctor-findings.d.ts +14 -0
  27. package/dist/lib/devices/doctor-findings.js +92 -51
  28. package/dist/lib/exec.d.ts +6 -2
  29. package/dist/lib/exec.js +22 -4
  30. package/dist/lib/feed-post.d.ts +2 -2
  31. package/dist/lib/feed-post.js +15 -10
  32. package/dist/lib/hooks.d.ts +4 -2
  33. package/dist/lib/hooks.js +173 -23
  34. package/dist/lib/hosts/remote-cmd.js +8 -0
  35. package/dist/lib/humans.d.ts +26 -0
  36. package/dist/lib/humans.js +73 -0
  37. package/dist/lib/memory.js +2 -1
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  39. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  40. package/dist/lib/migrate.js +112 -4
  41. package/dist/lib/notify.d.ts +4 -4
  42. package/dist/lib/notify.js +4 -3
  43. package/dist/lib/permissions.js +12 -10
  44. package/dist/lib/placement.d.ts +8 -4
  45. package/dist/lib/placement.js +14 -8
  46. package/dist/lib/projects.d.ts +3 -3
  47. package/dist/lib/projects.js +19 -17
  48. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  49. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  50. package/dist/lib/secrets/bundles.d.ts +11 -0
  51. package/dist/lib/secrets/bundles.js +122 -5
  52. package/dist/lib/session/actor-sidecar.d.ts +5 -2
  53. package/dist/lib/session/actor-sidecar.js +4 -2
  54. package/dist/lib/session/db.d.ts +2 -1
  55. package/dist/lib/session/db.js +19 -3
  56. package/dist/lib/session/types.d.ts +4 -0
  57. package/dist/lib/settings-manifest.js +7 -1
  58. package/dist/lib/staleness/writers/sources.d.ts +6 -1
  59. package/dist/lib/staleness/writers/sources.js +93 -7
  60. package/dist/lib/startup/command-registry.d.ts +2 -0
  61. package/dist/lib/startup/command-registry.js +5 -0
  62. package/dist/lib/state.d.ts +2 -0
  63. package/dist/lib/state.js +6 -6
  64. package/dist/lib/types.d.ts +52 -0
  65. package/dist/lib/versions.js +48 -14
  66. package/package.json +1 -1
@@ -122,9 +122,9 @@ export function convertDenyToCodexRules(deny) {
122
122
  * Ensure central permissions directory exists.
123
123
  */
124
124
  function ensurePermissionsDir() {
125
- const dir = getUserPermissionsDir();
126
- if (!fs.existsSync(dir)) {
127
- fs.mkdirSync(dir, { recursive: true });
125
+ const groupsDir = path.join(getUserPermissionsDir(), 'groups');
126
+ if (!fs.existsSync(groupsDir)) {
127
+ fs.mkdirSync(groupsDir, { recursive: true });
128
128
  }
129
129
  }
130
130
  /**
@@ -350,7 +350,8 @@ export function listInstalledPermissions() {
350
350
  ensureAgentsDir();
351
351
  const seen = new Set();
352
352
  const results = [];
353
- for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
353
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
354
+ const dir = path.join(baseDir, 'groups');
354
355
  if (!fs.existsSync(dir))
355
356
  continue;
356
357
  try {
@@ -377,10 +378,11 @@ export function listInstalledPermissions() {
377
378
  return results;
378
379
  }
379
380
  /**
380
- * Get a specific permission set by name. Searches user dir first, then system.
381
+ * Get a specific permission set by name. Searches user groups/ dir first, then system groups/.
381
382
  */
382
383
  function getPermissionSet(name) {
383
- for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
384
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
385
+ const dir = path.join(baseDir, 'groups');
384
386
  for (const ext of ['.yml', '.yaml']) {
385
387
  const filePath = safeJoin(dir, name + ext);
386
388
  if (fs.existsSync(filePath)) {
@@ -402,7 +404,7 @@ export function installPermissionSet(sourcePath, name) {
402
404
  if (!set) {
403
405
  return { success: false, error: 'Invalid permission file' };
404
406
  }
405
- const targetPath = safeJoin(getUserPermissionsDir(), name + '.yml');
407
+ const targetPath = safeJoin(path.join(getUserPermissionsDir(), 'groups'), name + '.yml');
406
408
  try {
407
409
  fs.copyFileSync(sourcePath, targetPath);
408
410
  return { success: true };
@@ -416,9 +418,9 @@ export function installPermissionSet(sourcePath, name) {
416
418
  * sets are intentionally not deletable from user commands.
417
419
  */
418
420
  export function removePermissionSet(name) {
419
- const dir = getUserPermissionsDir();
421
+ const groupsDir = path.join(getUserPermissionsDir(), 'groups');
420
422
  for (const ext of ['.yml', '.yaml']) {
421
- const filePath = safeJoin(dir, name + ext);
423
+ const filePath = safeJoin(groupsDir, name + ext);
422
424
  if (fs.existsSync(filePath)) {
423
425
  try {
424
426
  fs.unlinkSync(filePath);
@@ -2025,7 +2027,7 @@ export function exportPermissionsFromPath(filePath) {
2025
2027
  */
2026
2028
  function savePermissionSet(set) {
2027
2029
  ensurePermissionsDir();
2028
- const filePath = safeJoin(getUserPermissionsDir(), set.name + '.yml');
2030
+ const filePath = safeJoin(path.join(getUserPermissionsDir(), 'groups'), set.name + '.yml');
2029
2031
  try {
2030
2032
  const content = yaml.stringify({
2031
2033
  name: set.name,
@@ -2,7 +2,7 @@
2
2
  * Placement — one model for "where does the body run?"
3
3
  *
4
4
  * The CLI grew several doors that all mean execution target:
5
- * run --host / --device / --lease / --box
5
+ * run --host / --device / --lease / --box / --cloud
6
6
  * routines --placement / --run-on / hostStrategy
7
7
  * monitors --run-on (body) vs --device (owner — NOT placement)
8
8
  * teams --device (teammate pin)
@@ -41,6 +41,10 @@ export interface RunPlacementFlags {
41
41
  computer?: string;
42
42
  lease?: string | boolean;
43
43
  box?: string;
44
+ /** --cloud: vendor cloud placement (the agent's native cloud provider). */
45
+ cloud?: boolean;
46
+ /** --provider: refines the cloud placement; not a placement on its own. */
47
+ provider?: string;
44
48
  }
45
49
  export declare class PlacementError extends Error {
46
50
  constructor(message: string);
@@ -68,10 +72,10 @@ export declare function placementFromRunFlags(flags: RunPlacementFlags): Placeme
68
72
  * Expand a resolved placement into the concrete run option fields the
69
73
  * existing dispatch paths already understand. Pure — does not mutate input.
70
74
  *
71
- * `cloud` and `fleet` are not valid for a bare `agents run` (use `cloud run`
72
- * / routines); they throw so callers fail loud.
75
+ * `fleet` is not valid for a bare `agents run` (it is a routines placement);
76
+ * it throws so callers fail loud.
73
77
  */
74
- export declare function expandPlacementToRunFlags(placement: Placement): Pick<RunPlacementFlags, 'host' | 'device' | 'lease' | 'box'>;
78
+ export declare function expandPlacementToRunFlags(placement: Placement): Pick<RunPlacementFlags, 'host' | 'device' | 'lease' | 'box' | 'cloud' | 'provider'>;
75
79
  /** Map routines hostStrategy (+ optional host) onto the shared Placement. */
76
80
  export declare function placementFromHostStrategy(strategy: 'local' | 'host' | 'fleet' | 'cloud', host?: string): Placement;
77
81
  /** One-line human form for logs / help. */
@@ -2,7 +2,7 @@
2
2
  * Placement — one model for "where does the body run?"
3
3
  *
4
4
  * The CLI grew several doors that all mean execution target:
5
- * run --host / --device / --lease / --box
5
+ * run --host / --device / --lease / --box / --cloud
6
6
  * routines --placement / --run-on / hostStrategy
7
7
  * monitors --run-on (body) vs --device (owner — NOT placement)
8
8
  * teams --device (teammate pin)
@@ -93,6 +93,7 @@ export function placementFromRunFlags(flags) {
93
93
  const hostT = hostFamilyTarget(flags);
94
94
  const hasLease = flags.lease !== undefined && flags.lease !== false;
95
95
  const hasBox = !!flags.box;
96
+ const hasCloud = flags.cloud === true;
96
97
  const placementFlags = [];
97
98
  if (where)
98
99
  placementFlags.push('--where');
@@ -102,12 +103,16 @@ export function placementFromRunFlags(flags) {
102
103
  placementFlags.push('--lease');
103
104
  if (hasBox)
104
105
  placementFlags.push('--box');
106
+ if (hasCloud)
107
+ placementFlags.push('--cloud');
105
108
  if (placementFlags.length > 1) {
106
109
  throw new PlacementError(`Conflicting placement flags: ${placementFlags.join(' + ')}. ` +
107
- `Use one door — prefer --where (device:<name> | auto | lease | local).`);
110
+ `Use one door — prefer --where (device:<name> | auto | lease | cloud | local).`);
108
111
  }
109
112
  if (where)
110
113
  return parseWhereSpec(where, '--where');
114
+ if (hasCloud)
115
+ return { kind: 'cloud', target: flags.provider, source: '--cloud' };
111
116
  if (hasBox)
112
117
  return { kind: 'lease', target: flags.box, source: '--box' };
113
118
  if (hasLease) {
@@ -122,8 +127,8 @@ export function placementFromRunFlags(flags) {
122
127
  * Expand a resolved placement into the concrete run option fields the
123
128
  * existing dispatch paths already understand. Pure — does not mutate input.
124
129
  *
125
- * `cloud` and `fleet` are not valid for a bare `agents run` (use `cloud run`
126
- * / routines); they throw so callers fail loud.
130
+ * `fleet` is not valid for a bare `agents run` (it is a routines placement);
131
+ * it throws so callers fail loud.
127
132
  */
128
133
  export function expandPlacementToRunFlags(placement) {
129
134
  switch (placement.kind) {
@@ -140,12 +145,13 @@ export function expandPlacementToRunFlags(placement) {
140
145
  if (placement.source === '--box')
141
146
  return { box: placement.target };
142
147
  return placement.target ? { lease: placement.target } : { lease: true };
148
+ case 'cloud':
149
+ // Vendor cloud placement — `--where cloud[:provider]` expands to the
150
+ // --cloud flag (+ --provider refinement) the run action dispatches on.
151
+ return placement.target ? { cloud: true, provider: placement.target } : { cloud: true };
143
152
  case 'fleet':
144
153
  throw new PlacementError(`fleet placement is for routines (agents routines add … --placement fleet), not agents run. ` +
145
154
  `Use --where device:auto for an affinity pick, or --where device:<name>.`);
146
- case 'cloud':
147
- throw new PlacementError(`cloud placement is agents cloud run (vendor cloud), not agents run. ` +
148
- `For a disposable box use --where lease; for your fleet use --where device:<name>.`);
149
155
  }
150
156
  }
151
157
  /** Map routines hostStrategy (+ optional host) onto the shared Placement. */
@@ -180,9 +186,9 @@ export const PLACEMENT_MATRIX = `
180
186
  Affinity pick (14d usage) --where auto (= --device auto)
181
187
  Disposable cloud box --where lease (= --lease)
182
188
  Reuse warm crabbox --box <slug>
189
+ Vendor cloud task --cloud (= --where cloud[:provider])
183
190
  Routines: body on one box --run-on <name> / --placement host
184
191
  Routines: pick any online --placement fleet
185
- Vendor cloud task agents cloud run …
186
192
  Monitors: who evaluates --device <owner> (NOT body placement)
187
193
  Monitors: where action runs --run-on <host>
188
194
  `.trim();
@@ -132,9 +132,9 @@ export declare function validateProjectDef(raw: unknown, sourceName?: string): P
132
132
  */
133
133
  export declare function loadProjectDef(name: string): ProjectDef | undefined;
134
134
  /**
135
- * List every defined project, sorted by name. Skips (does not throw on) a
136
- * malformed file so one bad definition can't break `projects list`; the loader
137
- * for a single named project stays strict.
135
+ * List every defined project, sorted by name. A missing projects directory is
136
+ * the empty state; malformed definitions and filesystem failures stay loud so
137
+ * CLI callers (including Factory) can show the actual error.
138
138
  */
139
139
  export declare function listProjectDefs(): ProjectDef[];
140
140
  /**
@@ -187,15 +187,18 @@ export function loadProjectDef(name) {
187
187
  try {
188
188
  raw = fs.readFileSync(projectDefPath(name), 'utf8');
189
189
  }
190
- catch {
191
- return undefined; // absent — not a defined project
190
+ catch (error) {
191
+ if (error.code === 'ENOENT') {
192
+ return undefined; // absent — not a defined project
193
+ }
194
+ throw error;
192
195
  }
193
196
  return validateProjectDef(yaml.parse(raw), name);
194
197
  }
195
198
  /**
196
- * List every defined project, sorted by name. Skips (does not throw on) a
197
- * malformed file so one bad definition can't break `projects list`; the loader
198
- * for a single named project stays strict.
199
+ * List every defined project, sorted by name. A missing projects directory is
200
+ * the empty state; malformed definitions and filesystem failures stay loud so
201
+ * CLI callers (including Factory) can show the actual error.
199
202
  */
200
203
  export function listProjectDefs() {
201
204
  let files;
@@ -205,20 +208,17 @@ export function listProjectDefs() {
205
208
  // loader and silently drop the project. One extension, one code path.
206
209
  files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
207
210
  }
208
- catch {
209
- return [];
211
+ catch (error) {
212
+ if (error.code === 'ENOENT')
213
+ return [];
214
+ throw error;
210
215
  }
211
216
  const out = [];
212
217
  for (const f of files) {
213
218
  const name = f.replace(/\.yaml$/, '');
214
- try {
215
- const def = loadProjectDef(name);
216
- if (def)
217
- out.push(def);
218
- }
219
- catch {
220
- /* malformed — skip in the listing */
221
- }
219
+ const def = loadProjectDef(name);
220
+ if (def)
221
+ out.push(def);
222
222
  }
223
223
  return out.sort((a, b) => a.name.localeCompare(b.name));
224
224
  }
@@ -255,8 +255,10 @@ export function removeProjectDef(name) {
255
255
  fs.unlinkSync(projectDefPath(name));
256
256
  return true;
257
257
  }
258
- catch {
259
- return false;
258
+ catch (error) {
259
+ if (error.code === 'ENOENT')
260
+ return false;
261
+ throw error;
260
262
  }
261
263
  }
262
264
  /**
@@ -178,6 +178,17 @@ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAg
178
178
  export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
179
179
  export declare function writeBundleWithItems(bundle: SecretsBundle, items: Map<string, string>, opts?: WriteBundleOptions): void;
180
180
  export declare function deleteBundle(name: string): boolean;
181
+ declare function readBundleMetaIndex(): string[] | null;
182
+ declare function writeBundleMetaIndex(services: string[]): void;
183
+ declare function addBundleToMetaIndex(name: string): void;
184
+ declare function removeBundleFromMetaIndex(name: string): void;
185
+ /** Test-only accessors for the metadata-name index. Never called in production. */
186
+ export declare const __metaIndexForTest: {
187
+ read: typeof readBundleMetaIndex;
188
+ write: typeof writeBundleMetaIndex;
189
+ add: typeof addBundleToMetaIndex;
190
+ remove: typeof removeBundleFromMetaIndex;
191
+ };
181
192
  /**
182
193
  * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
183
194
  * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
@@ -468,6 +468,8 @@ export function writeBundle(bundle, opts = {}) {
468
468
  // un-updated pinned helper this write fails loudly (the no-ACL command is
469
469
  // missing) rather than silently landing an ACL'd item.
470
470
  itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
471
+ if (prepared.backend === 'keychain')
472
+ addBundleToMetaIndex(bundle.name);
471
473
  finishBundleWrite(bundle, opts);
472
474
  }
473
475
  export function writeBundleWithItems(bundle, items, opts = {}) {
@@ -485,6 +487,7 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
485
487
  store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
486
488
  }
487
489
  store.set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
490
+ addBundleToMetaIndex(bundle.name);
488
491
  }
489
492
  else {
490
493
  // file / vault: no ACL concept (noAcl is ignored), so one batched write is
@@ -497,8 +500,11 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
497
500
  }
498
501
  export function deleteBundle(name) {
499
502
  validateBundleName(name);
500
- const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
503
+ const backend = bundleBackend(name);
504
+ const deleted = itemStore(backend).delete(bundleMetaItem(name));
501
505
  if (deleted) {
506
+ if (backend === 'keychain')
507
+ removeBundleFromMetaIndex(name);
502
508
  emit('secrets.delete', { module: 'secrets', bundle: name });
503
509
  if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
504
510
  agentEvictSync(name);
@@ -581,6 +587,104 @@ function markBundleMetadataAclHealed() {
581
587
  // on the next broker-miss listing.
582
588
  }
583
589
  }
590
+ // ── No-ACL bundle-metadata name index (kills the enumeration Touch ID storm) ──
591
+ // listBundles cannot ask the keychain for "just the metadata items": with hashed
592
+ // service names (#316) the metadata names are opaque (`agents-cli.h.<ns>.m`), so
593
+ // listKeychainItems(BUNDLE_META_PREFIX) falls back to a BROAD `agents-cli.` scan
594
+ // that also MATCHES the ACL'd secret VALUE items. On some machines macOS
595
+ // evaluates those value ACLs during that attributes-only scan and pops a generic
596
+ // "Agents CLI needs to authenticate" sheet — on EVERY launch (session-title
597
+ // generation, `agents devices list`, every agent run), because listBundles runs
598
+ // on essentially every secrets touch. Neither UIFail nor LAContext can list the
599
+ // no-ACL items while skipping the ACL'd ones (both return nothing), so the fix is
600
+ // to NOT do the broad scan: keep a per-machine index of the metadata items'
601
+ // STORAGE names in the regenerable helpers dir and read THAT (a silent file read)
602
+ // instead. The index holds opaque hashes only — no cleartext bundle names, so it
603
+ // leaks nothing #316 didn't already. It self-heals: absent/unbuilt → listBundles
604
+ // rebuilds it from the one-time broad scan; a stale entry only makes `secrets
605
+ // list` cosmetically incomplete and never affects a resolve-by-name (which
606
+ // computes the hashed name directly, never through this index).
607
+ function bundleMetaIndexPath() {
608
+ // Test-only override (mirrors AGENTS_DAEMON_DIR): redirect to a fork-private
609
+ // temp so unit tests never touch the real helpers dir. Never set in prod.
610
+ return (process.env.AGENTS_SECRETS_META_INDEX_FILE ||
611
+ path.join(getHelpersDir(), 'secrets-agent', 'bundle-meta-index.json'));
612
+ }
613
+ // Changes iff the service-name hashing key changes (the #316 re-key, or a
614
+ // cleartext<->hashed transition). Stamped into the index so an index built under
615
+ // an OLD key reads as absent and is rebuilt — otherwise a re-key would leave
616
+ // stale hashed names that resolve to nothing and make every bundle "vanish".
617
+ function metaIndexFingerprint() {
618
+ return keychainServiceAlias(`${BUNDLE_META_PREFIX}meta-index-fingerprint`);
619
+ }
620
+ function readBundleMetaIndex() {
621
+ // A test-installed in-memory keychain is NOT the real store this index mirrors;
622
+ // reading (and later writing) the real ~/.agents index from a mock-backend test
623
+ // would leak fixture bundle names into a developer's live cache. Same guard the
624
+ // sibling healKeychainBundleMetadataAclOnce uses — treat the index as absent so
625
+ // listBundles falls back to the (mock) scan.
626
+ if (isKeychainBackendOverridden())
627
+ return null;
628
+ try {
629
+ const parsed = JSON.parse(fs.readFileSync(bundleMetaIndexPath(), 'utf-8'));
630
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.services))
631
+ return null;
632
+ if (parsed.fp !== metaIndexFingerprint())
633
+ return null; // built under a different hashing key
634
+ return parsed.services.every((s) => typeof s === 'string') ? parsed.services : null;
635
+ }
636
+ catch {
637
+ return null;
638
+ }
639
+ }
640
+ function writeBundleMetaIndex(services) {
641
+ if (isKeychainBackendOverridden())
642
+ return; // never write the real index from a mock-backend test
643
+ try {
644
+ const file = bundleMetaIndexPath();
645
+ fs.mkdirSync(path.dirname(file), { recursive: true });
646
+ const payload = { fp: metaIndexFingerprint(), services: [...new Set(services)].sort() };
647
+ // Atomic write (unique temp + rename) so a concurrent reader/writer never
648
+ // sees a half-written file. The read-modify-write in add/remove can still
649
+ // race two concurrent BUNDLE mutations and drop an entry, but that only makes
650
+ // a `secrets list` cosmetically incomplete (never a resolve-by-name) and
651
+ // self-heals on the next rebuild — an acceptable trade for rare bundle edits.
652
+ const tmp = `${file}.${process.pid}.tmp`;
653
+ fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8');
654
+ fs.renameSync(tmp, file);
655
+ }
656
+ catch {
657
+ // Best effort — a missing index just means listBundles rebuilds it from the
658
+ // one-time broad scan on the next enumeration.
659
+ }
660
+ }
661
+ // Append a metadata item's STORAGE name to an ALREADY-BUILT index. No-op when the
662
+ // index has not been built yet (null): never create a one-entry index that would
663
+ // hide every OTHER bundle — listBundles builds the complete index on its first
664
+ // scan, and this newly-written bundle is included in that scan.
665
+ function addBundleToMetaIndex(name) {
666
+ const cur = readBundleMetaIndex();
667
+ if (cur === null)
668
+ return;
669
+ const svc = keychainServiceAlias(bundleMetaItem(name));
670
+ if (!cur.includes(svc))
671
+ writeBundleMetaIndex([...cur, svc]);
672
+ }
673
+ function removeBundleFromMetaIndex(name) {
674
+ const cur = readBundleMetaIndex();
675
+ if (cur === null)
676
+ return;
677
+ const svc = keychainServiceAlias(bundleMetaItem(name));
678
+ if (cur.includes(svc))
679
+ writeBundleMetaIndex(cur.filter((s) => s !== svc));
680
+ }
681
+ /** Test-only accessors for the metadata-name index. Never called in production. */
682
+ export const __metaIndexForTest = {
683
+ read: readBundleMetaIndex,
684
+ write: writeBundleMetaIndex,
685
+ add: addBundleToMetaIndex,
686
+ remove: removeBundleFromMetaIndex,
687
+ };
584
688
  /**
585
689
  * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
586
690
  * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
@@ -643,11 +747,24 @@ export function listBundles() {
643
747
  // file store is the single source of truth, so the block below covers all.
644
748
  if (!keychainUsesFileFallback()) {
645
749
  let keychainServices = [];
646
- try {
647
- keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
750
+ // Prefer the no-ACL metadata-name index (a silent file read) over the broad
751
+ // `agents-cli.` keychain scan — that scan also matches ACL'd secret VALUE
752
+ // items and pops Touch ID on every launch (see the index helpers above).
753
+ // Absent index (first list after upgrade / a cache wipe) → do the one broad
754
+ // scan and build the index from it, so that scan is the LAST one this machine
755
+ // performs.
756
+ const indexedServices = readBundleMetaIndex();
757
+ if (indexedServices !== null) {
758
+ keychainServices = indexedServices;
648
759
  }
649
- catch {
650
- keychainServices = [];
760
+ else {
761
+ try {
762
+ keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
763
+ writeBundleMetaIndex(keychainServices);
764
+ }
765
+ catch {
766
+ keychainServices = [];
767
+ }
651
768
  }
652
769
  // With hashed service names (macOS, #316) the enumerated services are
653
770
  // opaque (`agents-cli.h.<ns>.m`) — the display name is recovered from the
@@ -1,9 +1,12 @@
1
+ import type { SessionRunMode } from './types.js';
1
2
  export interface SessionActorRecord {
2
3
  sessionId: string;
3
4
  /** Resolved actor id (`resolveActor().id`) — the responsible human/agent. */
4
- actor: string;
5
+ actor?: string;
5
6
  /** Actor kind (`resolveActor().kind`). */
6
- initiatedBy: 'human' | 'agent';
7
+ initiatedBy?: 'human' | 'agent';
8
+ /** Effective permissions mode used by the launcher. */
9
+ mode?: SessionRunMode;
7
10
  startedAtMs: number;
8
11
  }
9
12
  /**
@@ -62,7 +62,8 @@ export function readSessionActorRecord(sessionId) {
62
62
  }
63
63
  try {
64
64
  const parsed = JSON.parse(raw);
65
- if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' && typeof parsed.actor === 'string') {
65
+ if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
66
+ (typeof parsed.actor === 'string' || typeof parsed.mode === 'string')) {
66
67
  return parsed;
67
68
  }
68
69
  }
@@ -89,7 +90,8 @@ export function loadSessionActorIndex() {
89
90
  for (const f of files) {
90
91
  try {
91
92
  const parsed = JSON.parse(fs.readFileSync(path.join(sidecarDir(), f), 'utf8'));
92
- if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' && typeof parsed.actor === 'string') {
93
+ if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
94
+ (typeof parsed.actor === 'string' || typeof parsed.mode === 'string')) {
93
95
  out.set(parsed.sessionId, parsed);
94
96
  }
95
97
  }
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 31;
15
+ export declare const SCHEMA_VERSION = 32;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -30,6 +30,7 @@ export interface SessionRow {
30
30
  routine_run_id: string | null;
31
31
  version: string | null;
32
32
  account: string | null;
33
+ mode: string | null;
33
34
  timestamp: string;
34
35
  last_activity: string | null;
35
36
  project: string | null;
@@ -25,7 +25,7 @@ const DB_PATH = getSessionsDbPath();
25
25
  /** Current schema version; bumped when migrations are added. Exported so tests
26
26
  * assert against the constant instead of hardcoding a number that every bump
27
27
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
28
- export const SCHEMA_VERSION = 31;
28
+ export const SCHEMA_VERSION = 32;
29
29
  /**
30
30
  * Bump to force `agents sessions backfill resources` to re-derive every
31
31
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sessions (
67
67
  routine_run_id TEXT,
68
68
  version TEXT,
69
69
  account TEXT,
70
+ mode TEXT,
70
71
  timestamp TEXT NOT NULL,
71
72
  last_activity TEXT,
72
73
  project TEXT,
@@ -721,6 +722,14 @@ function migrateSchema(db, fromVersion) {
721
722
  );
722
723
  `);
723
724
  }
725
+ if (fromVersion < 32) {
726
+ // v31 → v32: persist the effective managed launch mode so resume can
727
+ // restore the same permission boundary instead of falling back to a CLI
728
+ // default that may be more or less permissive.
729
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((column) => column.name));
730
+ if (!cols.has('mode'))
731
+ db.exec(`ALTER TABLE sessions ADD COLUMN mode TEXT`);
732
+ }
724
733
  }
725
734
  /** Open (or return the cached) sessions database, applying migrations as needed. */
726
735
  export function getDB() {
@@ -1115,7 +1124,7 @@ export function recordDirScans(entries) {
1115
1124
  const upsertSessionStmt = (db) => db.prepare(`
1116
1125
  INSERT INTO sessions (
1117
1126
  id, short_id, agent, origin, routine_name, routine_run_id,
1118
- version, account, timestamp, last_activity,
1127
+ version, account, mode, timestamp, last_activity,
1119
1128
  project, cwd, git_branch, topic, label, message_count, token_count,
1120
1129
  output_tokens, cost_usd, duration_ms, model, tool_call_count,
1121
1130
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1124,7 +1133,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1124
1133
  actor, initiated_by, used_browser, used_computer
1125
1134
  ) VALUES (
1126
1135
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
1127
- @version, @account, @timestamp, @last_activity,
1136
+ @version, @account, @mode, @timestamp, @last_activity,
1128
1137
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1129
1138
  @output_tokens, @cost_usd, @duration_ms, @model, @tool_call_count,
1130
1139
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1140,6 +1149,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1140
1149
  routine_run_id = excluded.routine_run_id,
1141
1150
  version = excluded.version,
1142
1151
  account = excluded.account,
1152
+ mode = COALESCE(excluded.mode, sessions.mode),
1143
1153
  timestamp = excluded.timestamp,
1144
1154
  last_activity = excluded.last_activity,
1145
1155
  project = excluded.project,
@@ -1382,6 +1392,7 @@ export function upsertSession(meta, content, scan) {
1382
1392
  routine_run_id: meta.routineRunId ?? null,
1383
1393
  version: meta.version ?? null,
1384
1394
  account: meta.account ?? null,
1395
+ mode: meta.mode ?? actorRec?.mode ?? null,
1385
1396
  timestamp: meta.timestamp,
1386
1397
  last_activity: resolveLastActivity(meta, scan),
1387
1398
  project: meta.project ?? null,
@@ -1545,6 +1556,7 @@ export function upsertSessionsBatch(entries) {
1545
1556
  routine_run_id: meta.routineRunId ?? null,
1546
1557
  version: meta.version ?? null,
1547
1558
  account: meta.account ?? null,
1559
+ mode: meta.mode ?? actorIndex.get(meta.id)?.mode ?? null,
1548
1560
  timestamp: meta.timestamp,
1549
1561
  last_activity: resolveLastActivity(meta, scan),
1550
1562
  project: meta.project ?? null,
@@ -1768,6 +1780,7 @@ function rowToMeta(row) {
1768
1780
  toolCallCount: row.tool_call_count ?? undefined,
1769
1781
  version: row.version ?? undefined,
1770
1782
  account: row.account ?? undefined,
1783
+ mode: isSessionRunMode(row.mode) ? row.mode : undefined,
1771
1784
  topic: row.topic ?? undefined,
1772
1785
  label: row.label ?? undefined,
1773
1786
  isTeamOrigin: row.is_team_origin === 1,
@@ -1793,6 +1806,9 @@ function rowToMeta(row) {
1793
1806
  usedComputer: row.used_computer === null ? undefined : row.used_computer === 1,
1794
1807
  };
1795
1808
  }
1809
+ function isSessionRunMode(value) {
1810
+ return value === 'plan' || value === 'edit' || value === 'auto' || value === 'skip';
1811
+ }
1796
1812
  function parseJsonColumn(value) {
1797
1813
  if (!value)
1798
1814
  return undefined;
@@ -8,6 +8,8 @@
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
10
  export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid' | 'cursor';
11
+ /** Effective permissions mode used to launch a managed agent session. */
12
+ export type SessionRunMode = 'plan' | 'edit' | 'auto' | 'skip';
11
13
  /** All agents with session discovery support, in display order. */
12
14
  export declare const SESSION_AGENTS: SessionAgentId[];
13
15
  /**
@@ -151,6 +153,8 @@ export interface SessionMeta {
151
153
  toolCallCount?: number;
152
154
  version?: string;
153
155
  account?: string;
156
+ /** Effective normalized launch mode captured by the SessionStart hook. */
157
+ mode?: SessionRunMode;
154
158
  topic?: string;
155
159
  /**
156
160
  * The session's human-readable name — one field, several sources with a plain
@@ -30,7 +30,13 @@ const SETTINGS_MANIFEST = {
30
30
  strategy: 'toml-merge',
31
31
  stateKeys: ['notice', 'windows_wsl_setup_acknowledged'],
32
32
  },
33
- { rel: '.codex/auth.json', strategy: 'copy-if-absent', restrictMode: true },
33
+ // `.codex/auth.json` is deliberately NOT carried forward. Copying it seeded
34
+ // every new Codex version with the current default's ChatGPT token, so two
35
+ // installed versions always reported the same account and could never sign
36
+ // into separate accounts. Claude omits its credential (`.claude.json`) for
37
+ // the same reason — a version home holds its own login, keeping accounts
38
+ // per-version. A fresh Codex version installs signed-out; run `codex login`
39
+ // (or `agents run codex --version <v>`) inside it to authenticate.
34
40
  { rel: '.codex/instructions.md', strategy: 'copy-if-absent' },
35
41
  { rel: '.codex/hooks.json', strategy: 'copy-if-absent' },
36
42
  { rel: '.codex/prompts', strategy: 'dir-entries' },
@@ -19,7 +19,12 @@ export declare function listPluginSkillNames(options?: {
19
19
  agent?: AgentId;
20
20
  plugins?: Set<string>;
21
21
  }): string[];
22
- /** Find the trusted source file for a hook by name. */
22
+ /**
23
+ * Find the trusted source for a hook by name.
24
+ * - File basename (`04-session-identity.sh`) or relative path
25
+ * (`session-starts/04-session-identity.sh`) → script file
26
+ * - Directory basename (`tests`) → directory bundle (fixtures-only etc.)
27
+ */
23
28
  export declare function resolveHookSource(name: string): string | null;
24
29
  /** All trusted command-skill source roots, used to dedup name collisions for commands-as-skills writes. */
25
30
  export declare function trustedSkillRoots(): string[];