@phnx-labs/agents-cli 1.22.14 → 1.22.16

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 (76) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +4 -6
  5. package/dist/commands/exec.js +96 -11
  6. package/dist/commands/humans.d.ts +10 -0
  7. package/dist/commands/humans.js +91 -0
  8. package/dist/commands/packages.js +90 -22
  9. package/dist/commands/projects.d.ts +2 -5
  10. package/dist/commands/projects.js +92 -65
  11. package/dist/commands/resume.d.ts +17 -0
  12. package/dist/commands/resume.js +80 -0
  13. package/dist/commands/routines.js +53 -71
  14. package/dist/commands/sessions.d.ts +13 -4
  15. package/dist/commands/sessions.js +37 -19
  16. package/dist/commands/setup-watchdog.d.ts +3 -0
  17. package/dist/commands/setup-watchdog.js +54 -0
  18. package/dist/commands/setup.js +8 -1
  19. package/dist/commands/ssh.d.ts +11 -0
  20. package/dist/commands/ssh.js +18 -4
  21. package/dist/commands/watchdog.d.ts +3 -4
  22. package/dist/commands/watchdog.js +40 -28
  23. package/dist/index.js +3 -2
  24. package/dist/lib/auto-dispatch.d.ts +8 -10
  25. package/dist/lib/auto-dispatch.js +20 -42
  26. package/dist/lib/channels/send.d.ts +4 -1
  27. package/dist/lib/channels/send.js +15 -7
  28. package/dist/lib/devices/doctor-findings.d.ts +14 -0
  29. package/dist/lib/devices/doctor-findings.js +92 -51
  30. package/dist/lib/exec.d.ts +6 -2
  31. package/dist/lib/exec.js +22 -4
  32. package/dist/lib/hooks.d.ts +4 -2
  33. package/dist/lib/hooks.js +173 -23
  34. package/dist/lib/humans.d.ts +25 -0
  35. package/dist/lib/humans.js +65 -0
  36. package/dist/lib/memory.js +2 -1
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  39. package/dist/lib/migrate.d.ts +1 -1
  40. package/dist/lib/migrate.js +116 -7
  41. package/dist/lib/notify.js +6 -2
  42. package/dist/lib/permissions.js +12 -10
  43. package/dist/lib/project-import.d.ts +7 -46
  44. package/dist/lib/project-import.js +9 -108
  45. package/dist/lib/projects.d.ts +20 -3
  46. package/dist/lib/projects.js +36 -18
  47. package/dist/lib/registry.d.ts +1 -1
  48. package/dist/lib/registry.js +11 -0
  49. package/dist/lib/routine-activation.d.ts +14 -0
  50. package/dist/lib/routine-activation.js +78 -0
  51. package/dist/lib/routines-project.d.ts +1 -1
  52. package/dist/lib/routines-project.js +3 -9
  53. package/dist/lib/routines.d.ts +3 -1
  54. package/dist/lib/routines.js +32 -19
  55. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  57. package/dist/lib/secrets/bundles.d.ts +11 -0
  58. package/dist/lib/secrets/bundles.js +122 -5
  59. package/dist/lib/session/actor-sidecar.d.ts +5 -2
  60. package/dist/lib/session/actor-sidecar.js +4 -2
  61. package/dist/lib/session/db.d.ts +2 -1
  62. package/dist/lib/session/db.js +19 -3
  63. package/dist/lib/session/types.d.ts +4 -0
  64. package/dist/lib/staleness/writers/sources.d.ts +6 -1
  65. package/dist/lib/staleness/writers/sources.js +93 -7
  66. package/dist/lib/startup/command-registry.d.ts +2 -0
  67. package/dist/lib/startup/command-registry.js +5 -0
  68. package/dist/lib/state.d.ts +2 -0
  69. package/dist/lib/state.js +23 -10
  70. package/dist/lib/types.d.ts +64 -1
  71. package/dist/lib/versions.js +48 -14
  72. package/dist/lib/watchdog/rotate.d.ts +1 -1
  73. package/dist/lib/watchdog/rotate.js +1 -1
  74. package/package.json +1 -1
  75. package/dist/lib/watchdog/routine.d.ts +0 -44
  76. package/dist/lib/watchdog/routine.js +0 -69
@@ -17,6 +17,7 @@ import { ALL_AGENT_IDS } from './agents.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
18
  import { resolveActor } from './actor.js';
19
19
  import { percentile } from './percentile.js';
20
+ import { enabledRoutineNames, replaceEnabledRoutines, routineEnabledOnThisDevice, setRoutineEnabledOnThisDevice, } from './routine-activation.js';
20
21
  export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
21
22
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
22
23
  export const GITHUB_TRIGGER_EVENTS = [
@@ -82,6 +83,9 @@ export function finalizeRunMeta(meta, status, exitCode, opts) {
82
83
  * catchup/overdue, manual run) gates on this.
83
84
  */
84
85
  export function jobRunsOnThisDevice(config) {
86
+ const activated = routineEnabledOnThisDevice(config.name);
87
+ if (activated !== null)
88
+ return activated;
85
89
  const owner = routineOwnerDevice(config);
86
90
  // Unrestricted: no pin means fleet-wide by design (`watchdog`, `check-updates`).
87
91
  if (owner === null)
@@ -260,7 +264,7 @@ export function listJobs(cwd) {
260
264
  jobs.push(job);
261
265
  }
262
266
  }
263
- return jobs;
267
+ return jobs.map(applyDeviceActivation);
264
268
  }
265
269
  /**
266
270
  * Read a single job config by name, checking project > user > system.
@@ -286,12 +290,16 @@ export function readJob(name, cwd) {
286
290
  const job = readJobFromDir(dir, name);
287
291
  if (job) {
288
292
  if (scope === 'project')
289
- return overlayUserRoutineDevices(job, readJobFromDir(userDir, name));
290
- return job;
293
+ return applyDeviceActivation(overlayUserRoutineDevices(job, readJobFromDir(userDir, name)));
294
+ return applyDeviceActivation(job);
291
295
  }
292
296
  }
293
297
  return null;
294
298
  }
299
+ function applyDeviceActivation(job) {
300
+ const activated = routineEnabledOnThisDevice(job.name);
301
+ return activated === null ? job : { ...job, enabled: activated };
302
+ }
295
303
  function readJobFromDir(dir, name) {
296
304
  for (const ext of ['.yml', '.yaml']) {
297
305
  const filePath = safeJoin(dir, name + ext);
@@ -327,6 +335,10 @@ function readJobFile(filePath) {
327
335
  ...JOB_DEFAULTS,
328
336
  ...parsed,
329
337
  name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
338
+ // Before a device manifest exists, preserve only explicit legacy state.
339
+ // A new built-in definition with no `enabled:` field stays opt-in until
340
+ // setup materializes this host's routines list.
341
+ enabled: Object.prototype.hasOwnProperty.call(parsed, 'enabled') ? parsed.enabled !== false : false,
330
342
  };
331
343
  }
332
344
  catch {
@@ -368,15 +380,12 @@ export function writeJob(config) {
368
380
  delete output.effort;
369
381
  if (output.timeout === '10m')
370
382
  delete output.timeout;
371
- if (output.enabled === true)
372
- delete output.enabled;
383
+ delete output.enabled;
373
384
  if (output.runOnce === false || output.runOnce === undefined)
374
385
  delete output.runOnce;
375
386
  if (output.catchup === true || output.catchup === undefined)
376
387
  delete output.catchup;
377
- const devArr = output.devices;
378
- if (!devArr || devArr.length === 0)
379
- delete output.devices;
388
+ delete output.devices;
380
389
  let existingText = null;
381
390
  if (ymlExists || yamlExists) {
382
391
  try {
@@ -441,8 +450,20 @@ export function setJobEnabled(name, enabled) {
441
450
  const job = readJob(name);
442
451
  if (!job)
443
452
  throw new Error(`Job '${name}' not found`);
444
- job.enabled = enabled;
445
- writeJob(job);
453
+ const legacyEnabled = enabledRoutineNames() === null
454
+ ? listJobs().filter((candidate) => candidate.enabled && jobRunsOnThisDevice(candidate)).map((candidate) => candidate.name)
455
+ : [];
456
+ setRoutineEnabledOnThisDevice(name, enabled, legacyEnabled);
457
+ }
458
+ /** Materialize legacy definition state into this device's activation manifest. */
459
+ export function migrateLegacyRoutineActivation() {
460
+ if (enabledRoutineNames() !== null)
461
+ return false;
462
+ const jobs = listJobs();
463
+ if (!jobs.some((job) => job.enabled || Array.isArray(job.devices)))
464
+ return false;
465
+ replaceEnabledRoutines(jobs.filter((job) => job.enabled && jobRunsOnThisDevice(job)).map((job) => job.name));
466
+ return true;
446
467
  }
447
468
  /** Validate a partial job config, returning a list of human-readable errors. */
448
469
  export function validateJob(config) {
@@ -606,15 +627,6 @@ export function validateJob(config) {
606
627
  if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
607
628
  errors.push('catchup must be a boolean (false to skip running a missed fire late)');
608
629
  }
609
- // Off-box placement without a devices pin fires on every fleet daemon and
610
- // each dispatches once (RUSH-1980). Enforce the pin at validation so hand
611
- // edits and devices --clear cannot re-open the hole.
612
- if (placementRequiresFiringPin(strategy)) {
613
- if (!config.devices || config.devices.length === 0) {
614
- errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
615
- '(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
616
- }
617
- }
618
630
  return errors;
619
631
  }
620
632
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -1155,6 +1167,7 @@ export function installJobFromSource(sourcePath, name) {
1155
1167
  return { success: false, error: errors.join(', ') };
1156
1168
  }
1157
1169
  writeJob(config);
1170
+ setJobEnabled(config.name, config.enabled);
1158
1171
  return { success: true };
1159
1172
  }
1160
1173
  catch (err) {
@@ -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
@@ -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[];
@@ -10,7 +10,7 @@
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { getUserAgentsDir, getAgentsDir, getEnabledExtraRepos, getCommandsDir, getSkillsDir, getHooksDir } from '../../state.js';
13
- import { safeJoin } from '../../paths.js';
13
+ import { isSafeSegmentName, safeJoin } from '../../paths.js';
14
14
  /** Trusted source bases for content-like kinds. Project layer excluded. */
15
15
  export function trustedSourceBases() {
16
16
  return [
@@ -119,14 +119,100 @@ export function listPluginSkillNames(options = {}) {
119
119
  }
120
120
  return Array.from(names);
121
121
  }
122
- /** Find the trusted source file for a hook by name. */
122
+ /**
123
+ * Subdirectories under hooks/ that are never group dirs.
124
+ * Must stay in lockstep with HOOK_GROUP_SKIP_DIRS in hooks.ts.
125
+ */
126
+ const HOOK_GROUP_SKIP_DIRS = new Set(['node_modules', '.git', '.cache']);
127
+ const HOOK_SCRIPT_EXTS = new Set([
128
+ '.sh', '.bash', '.zsh', '.py', '.js', '.ts', '.mjs', '.cjs', '.rb', '.pl', '.ps1', '.cmd', '.bat',
129
+ ]);
130
+ function dirHasTopLevelScripts(groupDir) {
131
+ let entries;
132
+ try {
133
+ entries = fs.readdirSync(groupDir);
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ for (const name of entries) {
139
+ if (name.startsWith('.'))
140
+ continue;
141
+ const ext = path.extname(name).toLowerCase();
142
+ if (!HOOK_SCRIPT_EXTS.has(ext))
143
+ continue;
144
+ if (isLiveFile(path.join(groupDir, name)))
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ function findNestedHookFile(hooksRoot, basename) {
150
+ if (!isLiveDir(hooksRoot))
151
+ return null;
152
+ let entries;
153
+ try {
154
+ entries = fs.readdirSync(hooksRoot).sort();
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ for (const name of entries) {
160
+ if (name.startsWith('.') || HOOK_GROUP_SKIP_DIRS.has(name))
161
+ continue;
162
+ const groupDir = path.join(hooksRoot, name);
163
+ if (!isLiveDir(groupDir))
164
+ continue;
165
+ // Only group dirs (those with scripts) contribute nested scripts.
166
+ if (!dirHasTopLevelScripts(groupDir))
167
+ continue;
168
+ const candidate = path.join(groupDir, basename);
169
+ if (isLiveFile(candidate))
170
+ return candidate;
171
+ }
172
+ return null;
173
+ }
174
+ /**
175
+ * Find the trusted source for a hook by name.
176
+ * - File basename (`04-session-identity.sh`) or relative path
177
+ * (`session-starts/04-session-identity.sh`) → script file
178
+ * - Directory basename (`tests`) → directory bundle (fixtures-only etc.)
179
+ */
123
180
  export function resolveHookSource(name) {
124
- const candidates = [
125
- safeJoin(path.join(getUserAgentsDir(), 'hooks'), name),
126
- safeJoin(getHooksDir(), name),
127
- ...getEnabledExtraRepos().map((e) => safeJoin(path.join(e.dir, 'hooks'), name)),
181
+ const roots = [
182
+ path.join(getUserAgentsDir(), 'hooks'),
183
+ getHooksDir(),
184
+ ...getEnabledExtraRepos().map((e) => path.join(e.dir, 'hooks')),
128
185
  ];
129
- return candidates.find(isLiveFile) ?? null;
186
+ const base = path.basename(name);
187
+ for (const hooksRoot of roots) {
188
+ // Exact relative path (top-level or group/name) — multi-segment needs
189
+ // path.join + containment, not safeJoin (single-segment only).
190
+ if (name.includes('/') || name.includes('\\')) {
191
+ const candidate = path.resolve(hooksRoot, name);
192
+ const rootResolved = path.resolve(hooksRoot);
193
+ if ((candidate === rootResolved || candidate.startsWith(rootResolved + path.sep)) &&
194
+ isLiveFile(candidate)) {
195
+ return candidate;
196
+ }
197
+ }
198
+ else if (isSafeSegmentName(name)) {
199
+ try {
200
+ const top = safeJoin(hooksRoot, name);
201
+ // File script first; then directory bundle (e.g. tests/ fixtures).
202
+ if (isLiveFile(top))
203
+ return top;
204
+ if (isLiveDir(top) && !dirHasTopLevelScripts(top))
205
+ return top;
206
+ }
207
+ catch {
208
+ /* invalid segment */
209
+ }
210
+ }
211
+ const nested = findNestedHookFile(hooksRoot, base);
212
+ if (nested)
213
+ return nested;
214
+ }
215
+ return null;
130
216
  }
131
217
  /** All trusted command-skill source roots, used to dedup name collisions for commands-as-skills writes. */
132
218
  export function trustedSkillRoots() {
@@ -46,6 +46,7 @@ export declare const loadRoutines: ModuleLoader;
46
46
  export declare const loadMonitors: ModuleLoader;
47
47
  export declare const loadProjects: ModuleLoader;
48
48
  export declare const loadRun: ModuleLoader;
49
+ export declare const loadResume: ModuleLoader;
49
50
  export declare const loadFork: ModuleLoader;
50
51
  export declare const loadDefaults: ModuleLoader;
51
52
  export declare const loadSet: ModuleLoader;
@@ -104,6 +105,7 @@ export declare const loadShare: ModuleLoader;
104
105
  export declare const loadAudit: ModuleLoader;
105
106
  export declare const loadWebhook: ModuleLoader;
106
107
  export declare const loadFunnel: ModuleLoader;
108
+ export declare const loadHumans: ModuleLoader;
107
109
  /**
108
110
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
109
111
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -24,6 +24,7 @@ export const loadRoutines = async () => (await import('../../commands/routines.j
24
24
  export const loadMonitors = async () => (await import('../../commands/monitors.js')).registerMonitorsCommands;
25
25
  export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
26
26
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
27
+ export const loadResume = async () => (await import('../../commands/resume.js')).registerResumeCommand;
27
28
  export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
28
29
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
29
30
  export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
@@ -82,6 +83,7 @@ export const loadShare = async () => (await import('../../commands/share.js')).r
82
83
  export const loadAudit = async () => (await import('../../commands/audit.js')).registerAuditCommands;
83
84
  export const loadWebhook = async () => (await import('../../commands/webhook.js')).registerWebhookCommand;
84
85
  export const loadFunnel = async () => (await import('../../commands/funnel.js')).registerFunnelCommand;
86
+ export const loadHumans = async () => (await import('../../commands/humans.js')).registerHumansCommands;
85
87
  /**
86
88
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
87
89
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -93,6 +95,7 @@ export const loadFunnel = async () => (await import('../../commands/funnel.js'))
93
95
  // same SQLite stack, same post-help registration order as sessions.
94
96
  export const LAZY_COMMAND_NAMES = new Set([
95
97
  'sessions',
98
+ 'resume',
96
99
  'roster',
97
100
  'teams',
98
101
  'cloud',
@@ -145,6 +148,7 @@ export const COMMAND_LOADERS = {
145
148
  monitors: [loadMonitors],
146
149
  projects: [loadProjects],
147
150
  run: [loadRun],
151
+ resume: [loadResume],
148
152
  fork: [loadFork],
149
153
  defaults: [loadDefaults],
150
154
  set: [loadSet],
@@ -221,4 +225,5 @@ export const COMMAND_LOADERS = {
221
225
  audit: [loadAudit],
222
226
  webhook: [loadWebhook],
223
227
  funnel: [loadFunnel],
228
+ humans: [loadHumans],
224
229
  };
@@ -23,6 +23,8 @@
23
23
  * and leaves a back-compat symlink at the old path.
24
24
  */
25
25
  import type { Meta } from './types.js';
26
+ /** Return the absolute path to the humans.yaml file. */
27
+ export declare function getHumansFilePath(): string;
26
28
  /**
27
29
  * Header prepended to every agents.yaml the CLI writes (central and per-device
28
30
  * docs). Carries the yaml-language-server schema hint so editors validate the