@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.
- package/CHANGELOG.md +53 -0
- package/README.md +7 -5
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +4 -6
- package/dist/commands/exec.js +96 -11
- package/dist/commands/humans.d.ts +10 -0
- package/dist/commands/humans.js +91 -0
- package/dist/commands/packages.js +90 -22
- package/dist/commands/projects.d.ts +2 -5
- package/dist/commands/projects.js +92 -65
- package/dist/commands/resume.d.ts +17 -0
- package/dist/commands/resume.js +80 -0
- package/dist/commands/routines.js +53 -71
- package/dist/commands/sessions.d.ts +13 -4
- package/dist/commands/sessions.js +37 -19
- package/dist/commands/setup-watchdog.d.ts +3 -0
- package/dist/commands/setup-watchdog.js +54 -0
- package/dist/commands/setup.js +8 -1
- package/dist/commands/ssh.d.ts +11 -0
- package/dist/commands/ssh.js +18 -4
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +40 -28
- package/dist/index.js +3 -2
- package/dist/lib/auto-dispatch.d.ts +8 -10
- package/dist/lib/auto-dispatch.js +20 -42
- package/dist/lib/channels/send.d.ts +4 -1
- package/dist/lib/channels/send.js +15 -7
- package/dist/lib/devices/doctor-findings.d.ts +14 -0
- package/dist/lib/devices/doctor-findings.js +92 -51
- package/dist/lib/exec.d.ts +6 -2
- package/dist/lib/exec.js +22 -4
- package/dist/lib/hooks.d.ts +4 -2
- package/dist/lib/hooks.js +173 -23
- package/dist/lib/humans.d.ts +25 -0
- package/dist/lib/humans.js +65 -0
- package/dist/lib/memory.js +2 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +116 -7
- package/dist/lib/notify.js +6 -2
- package/dist/lib/permissions.js +12 -10
- package/dist/lib/project-import.d.ts +7 -46
- package/dist/lib/project-import.js +9 -108
- package/dist/lib/projects.d.ts +20 -3
- package/dist/lib/projects.js +36 -18
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +11 -0
- package/dist/lib/routine-activation.d.ts +14 -0
- package/dist/lib/routine-activation.js +78 -0
- package/dist/lib/routines-project.d.ts +1 -1
- package/dist/lib/routines-project.js +3 -9
- package/dist/lib/routines.d.ts +3 -1
- package/dist/lib/routines.js +32 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +11 -0
- package/dist/lib/secrets/bundles.js +122 -5
- package/dist/lib/session/actor-sidecar.d.ts +5 -2
- package/dist/lib/session/actor-sidecar.js +4 -2
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +19 -3
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/staleness/writers/sources.d.ts +6 -1
- package/dist/lib/staleness/writers/sources.js +93 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +23 -10
- package/dist/lib/types.d.ts +64 -1
- package/dist/lib/versions.js +48 -14
- package/dist/lib/watchdog/rotate.d.ts +1 -1
- package/dist/lib/watchdog/rotate.js +1 -1
- package/package.json +1 -1
- package/dist/lib/watchdog/routine.d.ts +0 -44
- package/dist/lib/watchdog/routine.js +0 -69
package/dist/lib/routines.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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) {
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
|
5
|
+
actor?: string;
|
|
5
6
|
/** Actor kind (`resolveActor().kind`). */
|
|
6
|
-
initiatedBy
|
|
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' &&
|
|
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' &&
|
|
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
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
...getEnabledExtraRepos().map((e) =>
|
|
181
|
+
const roots = [
|
|
182
|
+
path.join(getUserAgentsDir(), 'hooks'),
|
|
183
|
+
getHooksDir(),
|
|
184
|
+
...getEnabledExtraRepos().map((e) => path.join(e.dir, 'hooks')),
|
|
128
185
|
];
|
|
129
|
-
|
|
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
|
};
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -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
|