@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -12,7 +12,8 @@ import * as path from 'path';
|
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
13
|
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
|
|
14
14
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
15
|
-
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, } from '../lib/routines.js';
|
|
15
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
16
|
+
import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
|
|
16
17
|
import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
|
|
17
18
|
import { getRoutinesDir } from '../lib/state.js';
|
|
18
19
|
import { IS_WINDOWS } from '../lib/platform/index.js';
|
|
@@ -131,6 +132,24 @@ function ensureSchedulerRunning(opts = {}) {
|
|
|
131
132
|
function writeJson(payload) {
|
|
132
133
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
133
134
|
}
|
|
135
|
+
function printSyncResult(sync) {
|
|
136
|
+
console.log(chalk.bold(displayProjectPath(sync.projectRoot)));
|
|
137
|
+
if (sync.synced.length === 0 && sync.skipped.length === 0 && sync.removed.length === 0 && sync.errors.length === 0) {
|
|
138
|
+
console.log(chalk.gray(' (no project routines)'));
|
|
139
|
+
}
|
|
140
|
+
if (sync.synced.length > 0) {
|
|
141
|
+
console.log(chalk.green(` synced: ${sync.synced.join(', ')}`));
|
|
142
|
+
}
|
|
143
|
+
if (sync.removed.length > 0) {
|
|
144
|
+
console.log(chalk.gray(` removed: ${sync.removed.join(', ')}`));
|
|
145
|
+
}
|
|
146
|
+
for (const s of sync.skipped) {
|
|
147
|
+
console.log(chalk.yellow(` skipped ${s.name}: ${s.reason}`));
|
|
148
|
+
}
|
|
149
|
+
for (const e of sync.errors) {
|
|
150
|
+
console.log(chalk.red(` error ${e.name}: ${e.error}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
134
153
|
function runMetaJson(run) {
|
|
135
154
|
return {
|
|
136
155
|
jobId: run.jobName,
|
|
@@ -233,6 +252,14 @@ export function registerRoutinesCommands(program) {
|
|
|
233
252
|
# Create from YAML (for complex routines with multiple settings)
|
|
234
253
|
agents routines add weekly-report.yml
|
|
235
254
|
|
|
255
|
+
# Place the job body on a fleet device / cloud / named host (not --host)
|
|
256
|
+
agents routines add drain --schedule "0 3 * * *" --agent claude --placement fleet --prompt "Drain queue"
|
|
257
|
+
agents routines add review --schedule "0 9 * * 1" --agent claude --placement cloud --prompt "Review open PRs"
|
|
258
|
+
|
|
259
|
+
# Opt a project's .agents/routines/*.yml into daemon firing (never auto)
|
|
260
|
+
agents routines enable-project --yes
|
|
261
|
+
agents routines sync
|
|
262
|
+
|
|
236
263
|
# List all routines and their next run times
|
|
237
264
|
agents routines list
|
|
238
265
|
|
|
@@ -323,6 +350,10 @@ export function registerRoutinesCommands(program) {
|
|
|
323
350
|
timezone: job.timezone ?? null,
|
|
324
351
|
devices: job.devices ?? [],
|
|
325
352
|
host: job.host ?? null,
|
|
353
|
+
hostStrategy: resolveHostStrategy(job),
|
|
354
|
+
source: job.source ?? null,
|
|
355
|
+
sourceRepo: job.source?.repo ?? job.repo ?? null,
|
|
356
|
+
sourceBranch: job.source?.branch ?? null,
|
|
326
357
|
runsHere: jobRunsOnThisDevice(job),
|
|
327
358
|
enabled: job.enabled,
|
|
328
359
|
overdue: overdueSet.has(job.name),
|
|
@@ -368,7 +399,12 @@ export function registerRoutinesCommands(program) {
|
|
|
368
399
|
}
|
|
369
400
|
const latestRun = getLatestRun(job.name);
|
|
370
401
|
const lastStatus = latestRun?.status || '-';
|
|
371
|
-
|
|
402
|
+
// Prefer project-source repo (with optional @branch) over bare job.repo.
|
|
403
|
+
const sourceRepo = job.source?.repo ?? job.repo;
|
|
404
|
+
const sourceLabel = sourceRepo
|
|
405
|
+
? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
|
|
406
|
+
: null;
|
|
407
|
+
const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
|
|
372
408
|
const repoCell = link(repoInfo.display, repoInfo.href);
|
|
373
409
|
// Pad based on the display string, not the raw cell (which may include escape codes).
|
|
374
410
|
const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
|
|
@@ -376,8 +412,16 @@ export function registerRoutinesCommands(program) {
|
|
|
376
412
|
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
377
413
|
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
378
414
|
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
379
|
-
// Placement
|
|
380
|
-
const
|
|
415
|
+
// Placement rides in the Devices cell: eligibility →execution strategy.
|
|
416
|
+
const strategy = resolveHostStrategy(job);
|
|
417
|
+
const placementTag = strategy === 'local'
|
|
418
|
+
? (job.host ? `→${job.host}` : '')
|
|
419
|
+
: strategy === 'host'
|
|
420
|
+
? `→${job.host ?? '?'}`
|
|
421
|
+
: strategy === 'fleet'
|
|
422
|
+
? '→fleet'
|
|
423
|
+
: '→cloud';
|
|
424
|
+
const deviceFull = [job.devices?.join(',') ?? '', placementTag]
|
|
381
425
|
.filter(Boolean)
|
|
382
426
|
.join(' ');
|
|
383
427
|
const deviceWord = deviceFull.length === 0
|
|
@@ -423,7 +467,8 @@ export function registerRoutinesCommands(program) {
|
|
|
423
467
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
424
468
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
425
469
|
.option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
|
|
426
|
-
.option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
|
|
470
|
+
.option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Sets hostStrategy=host. Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
|
|
471
|
+
.option('--placement <strategy>', `Where the job body runs: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Not the same as --host (which manages routines on a remote machine).`)
|
|
427
472
|
.option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
|
|
428
473
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
429
474
|
.option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
|
|
@@ -490,12 +535,29 @@ export function registerRoutinesCommands(program) {
|
|
|
490
535
|
if (options.devices !== undefined) {
|
|
491
536
|
devices = await parseAndValidateDevices(options.devices);
|
|
492
537
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
538
|
+
let hostStrategy;
|
|
539
|
+
try {
|
|
540
|
+
hostStrategy = parseHostStrategy(options.placement) ?? undefined;
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
console.error(chalk.red(err.message));
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
// --run-on implies host strategy when the user didn't pick one.
|
|
547
|
+
if (options.runOn && !hostStrategy)
|
|
548
|
+
hostStrategy = 'host';
|
|
549
|
+
if (hostStrategy === 'host' && !options.runOn) {
|
|
550
|
+
console.error(chalk.red('--placement host requires --run-on <name>'));
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
// Off-box placement without a --devices pin would fire on EVERY daemon
|
|
554
|
+
// in the fleet, each dispatching once — duplicate runs (RUSH-1980).
|
|
555
|
+
// Pin to this machine unless the user chose an explicit eligibility set.
|
|
556
|
+
const strategyForPin = hostStrategy
|
|
557
|
+
?? (options.runOn ? 'host' : 'local');
|
|
558
|
+
if (placementRequiresFiringPin(strategyForPin) && !devices) {
|
|
497
559
|
devices = [machineId()];
|
|
498
|
-
console.error(chalk.gray(`--
|
|
560
|
+
console.error(chalk.gray(`--placement ${strategyForPin} with no --devices: pinned firing to this machine (${devices[0]}).`));
|
|
499
561
|
}
|
|
500
562
|
const config = {
|
|
501
563
|
name: nameOrPath,
|
|
@@ -512,6 +574,7 @@ export function registerRoutinesCommands(program) {
|
|
|
512
574
|
timezone: options.timezone,
|
|
513
575
|
...(devices ? { devices } : {}),
|
|
514
576
|
...(options.runOn ? { host: options.runOn } : {}),
|
|
577
|
+
...(hostStrategy ? { hostStrategy } : {}),
|
|
515
578
|
...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
|
|
516
579
|
...(runOnce ? { runOnce: true } : {}),
|
|
517
580
|
...(options.endAt ? { endAt: options.endAt } : {}),
|
|
@@ -586,11 +649,12 @@ export function registerRoutinesCommands(program) {
|
|
|
586
649
|
enabled: true,
|
|
587
650
|
...parsed,
|
|
588
651
|
};
|
|
589
|
-
// Same duplicate-fire guard as
|
|
652
|
+
// Same duplicate-fire guard as --placement/--run-on: off-box placement
|
|
590
653
|
// with no eligibility pin would fire from every daemon in the fleet.
|
|
591
|
-
|
|
654
|
+
const fileStrategy = resolveHostStrategy(config);
|
|
655
|
+
if (placementRequiresFiringPin(fileStrategy) && (!config.devices || config.devices.length === 0)) {
|
|
592
656
|
config.devices = [machineId()];
|
|
593
|
-
console.error(chalk.gray(
|
|
657
|
+
console.error(chalk.gray(`${fileStrategy} placement with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
|
|
594
658
|
}
|
|
595
659
|
writeJob(config);
|
|
596
660
|
if (options.json) {
|
|
@@ -1120,6 +1184,12 @@ export function registerRoutinesCommands(program) {
|
|
|
1120
1184
|
process.exit(1);
|
|
1121
1185
|
}
|
|
1122
1186
|
if (options.clear) {
|
|
1187
|
+
const strategy = resolveHostStrategy(job);
|
|
1188
|
+
if (placementRequiresFiringPin(strategy)) {
|
|
1189
|
+
console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
|
|
1190
|
+
console.error(chalk.gray(`Keep a single-machine pin (e.g. --set ${machineId()}) or switch to --placement local.`));
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
}
|
|
1123
1193
|
job.devices = undefined;
|
|
1124
1194
|
writeJob(job);
|
|
1125
1195
|
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
@@ -1130,6 +1200,12 @@ export function registerRoutinesCommands(program) {
|
|
|
1130
1200
|
if (hasSet) {
|
|
1131
1201
|
const devices = await parseAndValidateDevices(options.set);
|
|
1132
1202
|
job.devices = devices;
|
|
1203
|
+
// Re-validate so host/fleet/cloud can't be left with an empty set via --set ""
|
|
1204
|
+
const errs = validateJob(job);
|
|
1205
|
+
if (errs.length > 0) {
|
|
1206
|
+
console.error(chalk.red(errs.join('\n')));
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
1133
1209
|
writeJob(job);
|
|
1134
1210
|
console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
|
|
1135
1211
|
if (isDaemonRunning())
|
|
@@ -1158,6 +1234,11 @@ export function registerRoutinesCommands(program) {
|
|
|
1158
1234
|
})),
|
|
1159
1235
|
});
|
|
1160
1236
|
if (selected.length === 0) {
|
|
1237
|
+
const strategy = resolveHostStrategy(job);
|
|
1238
|
+
if (placementRequiresFiringPin(strategy)) {
|
|
1239
|
+
console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1161
1242
|
job.devices = undefined;
|
|
1162
1243
|
writeJob(job);
|
|
1163
1244
|
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
@@ -1250,6 +1331,183 @@ export function registerRoutinesCommands(program) {
|
|
|
1250
1331
|
console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
|
|
1251
1332
|
}
|
|
1252
1333
|
});
|
|
1334
|
+
routinesCmd
|
|
1335
|
+
.command('enable-project [path]')
|
|
1336
|
+
.description('Opt a project\'s .agents/routines/*.yml into daemon firing. Requires explicit approval — project routines never auto-fire from a cloned repo. Materialises copies into ~/.agents/routines/ with source provenance.')
|
|
1337
|
+
.option('--yes', 'Skip the interactive confirmation prompt')
|
|
1338
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1339
|
+
.action(async (projectPath, options) => {
|
|
1340
|
+
const root = projectPath
|
|
1341
|
+
? path.resolve(projectPath)
|
|
1342
|
+
: resolveProjectRoot(process.cwd());
|
|
1343
|
+
if (!root) {
|
|
1344
|
+
console.error(chalk.red('No project .agents/ directory found from the current directory.'));
|
|
1345
|
+
console.error(chalk.gray('Run from inside a project, or pass the project path: agents routines enable-project /path/to/repo'));
|
|
1346
|
+
process.exit(1);
|
|
1347
|
+
}
|
|
1348
|
+
const files = listProjectRoutineFiles(root);
|
|
1349
|
+
if (files.length === 0) {
|
|
1350
|
+
console.error(chalk.red(`No routines found under ${path.join(root, '.agents', 'routines')}`));
|
|
1351
|
+
process.exit(1);
|
|
1352
|
+
}
|
|
1353
|
+
if (!options.yes) {
|
|
1354
|
+
if (!isInteractiveTerminal()) {
|
|
1355
|
+
console.error(chalk.red('Refusing to enable project routines non-interactively without --yes.'));
|
|
1356
|
+
console.error(chalk.gray(`Found ${files.length} routine(s) in ${displayProjectPath(root)}. Re-run with --yes to confirm.`));
|
|
1357
|
+
process.exit(1);
|
|
1358
|
+
}
|
|
1359
|
+
try {
|
|
1360
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
1361
|
+
const ok = await confirm({
|
|
1362
|
+
message: `Enable daemon firing for ${files.length} project routine(s) in ${displayProjectPath(root)}?`,
|
|
1363
|
+
default: false,
|
|
1364
|
+
});
|
|
1365
|
+
if (!ok) {
|
|
1366
|
+
console.log(chalk.gray('Cancelled'));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
if (isPromptCancelled(err)) {
|
|
1372
|
+
console.log(chalk.gray('Cancelled'));
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
throw err;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
const newly = enableProjectRoutines(root);
|
|
1379
|
+
const sync = syncProjectRoutines(root);
|
|
1380
|
+
if (isDaemonRunning())
|
|
1381
|
+
signalDaemonReload();
|
|
1382
|
+
if (options.json) {
|
|
1383
|
+
writeJson({
|
|
1384
|
+
ok: true,
|
|
1385
|
+
projectRoot: root,
|
|
1386
|
+
newlyEnabled: newly,
|
|
1387
|
+
synced: sync.synced,
|
|
1388
|
+
skipped: sync.skipped,
|
|
1389
|
+
removed: sync.removed,
|
|
1390
|
+
errors: sync.errors,
|
|
1391
|
+
});
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
console.log(chalk.green(newly
|
|
1395
|
+
? `Enabled project routines for ${displayProjectPath(root)}`
|
|
1396
|
+
: `Project routines already enabled for ${displayProjectPath(root)}`));
|
|
1397
|
+
if (sync.synced.length > 0) {
|
|
1398
|
+
console.log(chalk.gray(` Synced: ${sync.synced.join(', ')}`));
|
|
1399
|
+
}
|
|
1400
|
+
for (const s of sync.skipped) {
|
|
1401
|
+
console.log(chalk.yellow(` Skipped ${s.name}: ${s.reason}`));
|
|
1402
|
+
}
|
|
1403
|
+
for (const e of sync.errors) {
|
|
1404
|
+
console.log(chalk.red(` Error ${e.name}: ${e.error}`));
|
|
1405
|
+
}
|
|
1406
|
+
console.log(chalk.gray('Daemon will fire these after reload. Re-sync later with: agents routines sync'));
|
|
1407
|
+
});
|
|
1408
|
+
routinesCmd
|
|
1409
|
+
.command('disable-project [path]')
|
|
1410
|
+
.description('Remove a project from the project-routines allowlist. Use --remove-synced to also delete the user-layer copies.')
|
|
1411
|
+
.option('--remove-synced', 'Delete user-layer routines that were materialised from this project')
|
|
1412
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1413
|
+
.action(async (projectPath, options) => {
|
|
1414
|
+
const root = projectPath
|
|
1415
|
+
? path.resolve(projectPath)
|
|
1416
|
+
: resolveProjectRoot(process.cwd());
|
|
1417
|
+
if (!root) {
|
|
1418
|
+
console.error(chalk.red('No project .agents/ directory found from the current directory.'));
|
|
1419
|
+
process.exit(1);
|
|
1420
|
+
}
|
|
1421
|
+
const result = disableProjectRoutines(root, { removeSynced: options.removeSynced });
|
|
1422
|
+
if (isDaemonRunning())
|
|
1423
|
+
signalDaemonReload();
|
|
1424
|
+
if (options.json) {
|
|
1425
|
+
writeJson({ ok: true, projectRoot: root, ...result });
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (!result.removed) {
|
|
1429
|
+
console.log(chalk.gray(`Project ${displayProjectPath(root)} was not on the allowlist`));
|
|
1430
|
+
}
|
|
1431
|
+
else {
|
|
1432
|
+
console.log(chalk.green(`Disabled project routines for ${displayProjectPath(root)}`));
|
|
1433
|
+
}
|
|
1434
|
+
if (result.deletedJobs.length > 0) {
|
|
1435
|
+
console.log(chalk.gray(` Removed user-layer copies: ${result.deletedJobs.join(', ')}`));
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
routinesCmd
|
|
1439
|
+
.command('sync [path]')
|
|
1440
|
+
.description('Refresh user-layer copies of opted-in project routines from their .agents/routines/*.yml sources. With no path, syncs every enabled project. Also runs automatically on daemon reload (SIGHUP).')
|
|
1441
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1442
|
+
.action(async (projectPath, options) => {
|
|
1443
|
+
if (projectPath) {
|
|
1444
|
+
const root = path.resolve(projectPath);
|
|
1445
|
+
const isEnabled = listEnabledProjectRoots().some((p) => p === root);
|
|
1446
|
+
if (!isEnabled) {
|
|
1447
|
+
console.error(chalk.red(`Project ${displayProjectPath(root)} is not enabled. Run: agents routines enable-project ${root}`));
|
|
1448
|
+
process.exit(1);
|
|
1449
|
+
}
|
|
1450
|
+
const sync = syncProjectRoutines(root);
|
|
1451
|
+
if (isDaemonRunning())
|
|
1452
|
+
signalDaemonReload();
|
|
1453
|
+
if (options.json) {
|
|
1454
|
+
writeJson({ ok: true, ...sync });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
printSyncResult(sync);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const all = syncAllProjectRoutines();
|
|
1461
|
+
if (isDaemonRunning())
|
|
1462
|
+
signalDaemonReload();
|
|
1463
|
+
if (options.json) {
|
|
1464
|
+
writeJson({ ok: true, ...all });
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (all.projects.length === 0 && all.missing.length === 0) {
|
|
1468
|
+
console.log(chalk.gray('No project roots on the routines allowlist.'));
|
|
1469
|
+
console.log(chalk.gray(' Enable one with: agents routines enable-project'));
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
for (const p of all.projects)
|
|
1473
|
+
printSyncResult(p);
|
|
1474
|
+
for (const m of all.missing) {
|
|
1475
|
+
console.log(chalk.yellow(`Missing project root (still on allowlist): ${displayProjectPath(m)}`));
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
routinesCmd
|
|
1479
|
+
.command('projects')
|
|
1480
|
+
.description('List project roots opted into daemon-fired project routines')
|
|
1481
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1482
|
+
.action((options) => {
|
|
1483
|
+
const roots = listEnabledProjectRoots();
|
|
1484
|
+
if (options.json) {
|
|
1485
|
+
writeJson(roots.map((r) => ({
|
|
1486
|
+
path: r,
|
|
1487
|
+
display: displayProjectPath(r),
|
|
1488
|
+
routines: listProjectRoutineFiles(r).map((f) => f.name),
|
|
1489
|
+
})));
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (roots.length === 0) {
|
|
1493
|
+
console.log(chalk.gray('No projects enabled. Use: agents routines enable-project'));
|
|
1494
|
+
// Offer a discovery hint for the current project.
|
|
1495
|
+
const discovered = discoverProjectRoutinesAt(process.cwd());
|
|
1496
|
+
if (discovered) {
|
|
1497
|
+
console.log(chalk.gray(` Found ${discovered.files.length} routine(s) in ${displayProjectPath(discovered.projectRoot)} — enable with: agents routines enable-project`));
|
|
1498
|
+
}
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
console.log(chalk.bold('Enabled project routines\n'));
|
|
1502
|
+
for (const r of roots) {
|
|
1503
|
+
const files = listProjectRoutineFiles(r);
|
|
1504
|
+
console.log(` ${chalk.cyan(displayProjectPath(r))} ${chalk.gray(`(${files.length} routine${files.length === 1 ? '' : 's'})`)}`);
|
|
1505
|
+
for (const f of files) {
|
|
1506
|
+
console.log(chalk.gray(` - ${f.name}`));
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
console.log();
|
|
1510
|
+
});
|
|
1253
1511
|
routinesCmd
|
|
1254
1512
|
.command('scheduler-logs')
|
|
1255
1513
|
.description('Read scheduler log output (for debugging why a routine did not fire). Use --follow to stream.')
|
package/dist/commands/secrets.js
CHANGED
|
@@ -18,16 +18,17 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
18
18
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
19
19
|
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
20
20
|
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
21
|
-
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
|
|
21
|
+
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
23
23
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
24
24
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
25
25
|
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
26
|
-
import { saveSession,
|
|
26
|
+
import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
|
|
27
27
|
import { getCliVersionFresh } from '../lib/version.js';
|
|
28
28
|
import { readMeta } from '../lib/state.js';
|
|
29
29
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
30
|
-
import { emit } from '../lib/events.js';
|
|
30
|
+
import { emit, query } from '../lib/events.js';
|
|
31
|
+
import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
|
|
31
32
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
32
33
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
33
34
|
import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
|
|
@@ -1096,8 +1097,9 @@ export function registerSecretsCommands(program) {
|
|
|
1096
1097
|
process.exit(1);
|
|
1097
1098
|
}
|
|
1098
1099
|
// `secrets get` is the scriptable automation primitive ($(agents secrets
|
|
1099
|
-
// get bundle KEY)); when embedded in a headless routine/CI script
|
|
1100
|
-
//
|
|
1100
|
+
// get bundle KEY)); when embedded in a headless routine/CI script — or run
|
|
1101
|
+
// beneath any agent, which inherits AGENTS_RUNTIME — it must not pop an
|
|
1102
|
+
// unwatched Touch ID prompt. Typed in a plain shell it still prompts.
|
|
1101
1103
|
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
|
|
1102
1104
|
if (!(key in env)) {
|
|
1103
1105
|
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
@@ -1816,10 +1818,10 @@ Examples:
|
|
|
1816
1818
|
process.exit(1);
|
|
1817
1819
|
}
|
|
1818
1820
|
// `agents secrets export --plaintext` is what release/CI scripts eval.
|
|
1819
|
-
// When it runs detached (both stdio non-TTY) or
|
|
1820
|
-
// resolve broker-only so it can never pop a Touch
|
|
1821
|
-
// interactive user's screen. An
|
|
1822
|
-
//
|
|
1821
|
+
// When it runs detached (both stdio non-TTY) or beneath ANY agent — which
|
|
1822
|
+
// inherits AGENTS_RUNTIME — resolve broker-only so it can never pop a Touch
|
|
1823
|
+
// ID sheet on the interactive user's screen. An `eval "$(...)"` typed in a
|
|
1824
|
+
// plain shell carries no AGENTS_RUNTIME, so it is not headless and still prompts.
|
|
1823
1825
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
1824
1826
|
caller: `export to shell`,
|
|
1825
1827
|
keyMode: 'process',
|
|
@@ -2055,6 +2057,7 @@ Examples:
|
|
|
2055
2057
|
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
|
|
2056
2058
|
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
2057
2059
|
.option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
|
|
2060
|
+
.option('--for <agent>', 'Scope the unlock to one harness type (for example claude, codex, or kimi).')
|
|
2058
2061
|
.option('--all', 'Unlock every configured bundle')
|
|
2059
2062
|
.option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
|
|
2060
2063
|
.action(async (names, opts) => {
|
|
@@ -2127,12 +2130,19 @@ Examples:
|
|
|
2127
2130
|
// (single-instance start lock, #414) and best-effort — never blocks unlock.
|
|
2128
2131
|
ensureDaemonStarted();
|
|
2129
2132
|
let loaded = 0;
|
|
2133
|
+
const harness = opts.for || process.env.AGENTS_AGENT_NAME || 'cli';
|
|
2130
2134
|
for (const name of targets) {
|
|
2131
2135
|
try {
|
|
2132
2136
|
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
2133
2137
|
// agent we're about to populate.
|
|
2134
|
-
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2135
|
-
|
|
2138
|
+
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2139
|
+
noAgent: true,
|
|
2140
|
+
caller: 'unlock secrets',
|
|
2141
|
+
agent: harness,
|
|
2142
|
+
duration: humanRemaining(Date.now() + ttlMs),
|
|
2143
|
+
keyMode: 'storage',
|
|
2144
|
+
});
|
|
2145
|
+
if (await agentLoad(name, bundle, env, ttlMs, harness)) {
|
|
2136
2146
|
loaded++;
|
|
2137
2147
|
// Persist a durable session snapshot so the unlock survives a daemon
|
|
2138
2148
|
// restart / upgrade (and sleep too, with --durable). session-store.ts.
|
|
@@ -2141,6 +2151,7 @@ Examples:
|
|
|
2141
2151
|
env,
|
|
2142
2152
|
expiresAt: Date.now() + ttlMs,
|
|
2143
2153
|
sleepPersist: opts.durable ?? secretsAgentDurable(),
|
|
2154
|
+
harness,
|
|
2144
2155
|
});
|
|
2145
2156
|
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
|
|
2146
2157
|
}
|
|
@@ -2170,7 +2181,7 @@ Examples:
|
|
|
2170
2181
|
let total = 0;
|
|
2171
2182
|
for (const name of names) {
|
|
2172
2183
|
total += await agentLock(name);
|
|
2173
|
-
|
|
2184
|
+
deleteBundleSessions(name); // drop every harness scope
|
|
2174
2185
|
}
|
|
2175
2186
|
console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
2176
2187
|
}
|
|
@@ -2216,16 +2227,47 @@ Examples:
|
|
|
2216
2227
|
} })();
|
|
2217
2228
|
console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
|
|
2218
2229
|
const entries = await agentStatus();
|
|
2230
|
+
const held = new Set(entries.map((e) => e.name));
|
|
2219
2231
|
if (entries.length === 0) {
|
|
2220
2232
|
console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
|
|
2221
2233
|
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
2222
|
-
return;
|
|
2223
2234
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2235
|
+
else {
|
|
2236
|
+
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
2237
|
+
for (const e of entries) {
|
|
2238
|
+
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
|
|
2239
|
+
}
|
|
2240
|
+
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2241
|
+
}
|
|
2242
|
+
// Usage hint: bundles you keep getting a Touch ID prompt for — a keychain
|
|
2243
|
+
// read that fell through to biometry (not served by the broker/session)
|
|
2244
|
+
// >= a few times in the last week, and not currently held. Unlocking each
|
|
2245
|
+
// once silences it for the hold window. This runs even when NO bundle is
|
|
2246
|
+
// held — that user re-prompts on every read and most needs the nudge.
|
|
2247
|
+
// Excludes `never`/no-ACL bundles (never prompt) and file/vault bundles
|
|
2248
|
+
// (resolve by passphrase, not the keychain broker — `unlock` is a no-op).
|
|
2249
|
+
try {
|
|
2250
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
2251
|
+
const reads = query({ eventTypes: ['secrets.get'], startDate: weekAgo });
|
|
2252
|
+
const hot = frequentlyPromptedBundles(reads, held, { minReads: 3 }).filter((h) => {
|
|
2253
|
+
try {
|
|
2254
|
+
return bundleBackend(h.name) === 'keychain' && bundlePolicy(readBundle(h.name)) !== 'never';
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
return false; // bundle gone / unreadable metadata — nothing to suggest
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
if (hot.length > 0) {
|
|
2261
|
+
console.log();
|
|
2262
|
+
console.log(chalk.bold('Prompted often — unlock once to silence:'));
|
|
2263
|
+
for (const h of hot) {
|
|
2264
|
+
console.log(`${chalk.cyan(h.name.padEnd(24))} ${chalk.gray(`${h.count}× in the last 7d`)} → ${chalk.green(`agents secrets unlock ${h.name}`)}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
catch {
|
|
2269
|
+
// Best-effort hint — never let usage analysis break `secrets status`.
|
|
2227
2270
|
}
|
|
2228
|
-
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2229
2271
|
});
|
|
2230
2272
|
cmd
|
|
2231
2273
|
.command('policy <bundle> [policy]')
|
|
@@ -197,6 +197,16 @@ function applyFilters(rows, live, f, self) {
|
|
|
197
197
|
out = out.filter((r) => live.has(r.id));
|
|
198
198
|
return out;
|
|
199
199
|
}
|
|
200
|
+
/** Derive the SSH-launch origin tag for a picker row from the live index. Set
|
|
201
|
+
* only when the live session's provenance is ssh transport; `device` is the
|
|
202
|
+
* resolved origin device (absent → the row shows a bare `ssh`). Rows without a
|
|
203
|
+
* live entry (the running filter off) get no tag — provenance is live-only. */
|
|
204
|
+
function sshOriginTagFor(live, id) {
|
|
205
|
+
const p = live?.get(id)?.provenance;
|
|
206
|
+
if (p?.transport !== 'ssh')
|
|
207
|
+
return undefined;
|
|
208
|
+
return p.origin?.device ? { device: p.origin.device } : {};
|
|
209
|
+
}
|
|
200
210
|
function headerFor(f) {
|
|
201
211
|
const bits = [
|
|
202
212
|
`device:${f.device ?? 'all'}`,
|
|
@@ -287,7 +297,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
287
297
|
initialFilter,
|
|
288
298
|
load,
|
|
289
299
|
keyFor: (s) => s.id,
|
|
290
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols),
|
|
300
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id)),
|
|
291
301
|
matches: sessionMatchesQuery,
|
|
292
302
|
buildPreview,
|
|
293
303
|
headerFor,
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import type { SessionMeta } from '../lib/session/types.js';
|
|
1
|
+
import type { SessionMeta, TodoProgress } from '../lib/session/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
4
|
+
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
5
|
+
* Consumes `SessionMeta.todos` / `ActiveSession.todos` as populated by the
|
|
6
|
+
* state engine — does not re-parse transcripts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatTodoCompact(todos?: Pick<TodoProgress, 'done' | 'total' | 'activeForm'> | null): string;
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort GitHub repo URL from a checkout path shaped like
|
|
11
|
+
* `…/github.com/<owner>/<repo>/…`. Used to make the project name clickable
|
|
12
|
+
* when no Linear project URL is available.
|
|
13
|
+
*/
|
|
14
|
+
export declare function githubRepoUrlFromCwd(cwd?: string): string | undefined;
|
|
2
15
|
export interface PickedSession {
|
|
3
16
|
session: SessionMeta;
|
|
4
17
|
action: 'resume' | 'view';
|