@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. 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
- const repoInfo = formatRepoLink(job.repo);
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 (`→host`) rides in the Devices cell: eligibility →execution.
380
- const deviceFull = [job.devices?.join(',') ?? '', job.host ? `→${job.host}` : '']
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
- // --run-on without a --devices pin would fire on EVERY daemon in the
494
- // fleet, each dispatching to the same host — duplicate runs. Pin to
495
- // this machine unless the user chose an explicit eligibility set.
496
- if (options.runOn && !devices) {
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(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
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 the --run-on flag: a host-placed routine
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
- if (config.host && (!config.devices || config.devices.length === 0)) {
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(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
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.')
@@ -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, deleteSession, deleteAllSessions } from '../lib/secrets/session-store.js';
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 it must
1100
- // not pop an unwatched Touch ID prompt. Interactive use still prompts.
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 under a headless agent,
1820
- // resolve broker-only so it can never pop a Touch ID sheet on the
1821
- // interactive user's screen. An interactive `eval "$(...)"` keeps its
1822
- // terminal stdin, so it is not headless and still prompts.
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, { noAgent: true, caller: 'unlock', keyMode: 'storage' });
2135
- if (await agentLoad(name, bundle, env, ttlMs)) {
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
- deleteSession(name); // also drop the durable snapshot, or a restart re-warms it
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
- console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
2225
- for (const e of entries) {
2226
- console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
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';