@phnx-labs/agents-cli 1.22.14 → 1.22.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +4 -6
  5. package/dist/commands/exec.js +96 -11
  6. package/dist/commands/humans.d.ts +10 -0
  7. package/dist/commands/humans.js +91 -0
  8. package/dist/commands/packages.js +90 -22
  9. package/dist/commands/projects.d.ts +2 -5
  10. package/dist/commands/projects.js +92 -65
  11. package/dist/commands/resume.d.ts +17 -0
  12. package/dist/commands/resume.js +80 -0
  13. package/dist/commands/routines.js +53 -71
  14. package/dist/commands/sessions.d.ts +13 -4
  15. package/dist/commands/sessions.js +37 -19
  16. package/dist/commands/setup-watchdog.d.ts +3 -0
  17. package/dist/commands/setup-watchdog.js +54 -0
  18. package/dist/commands/setup.js +8 -1
  19. package/dist/commands/ssh.d.ts +11 -0
  20. package/dist/commands/ssh.js +18 -4
  21. package/dist/commands/watchdog.d.ts +3 -4
  22. package/dist/commands/watchdog.js +40 -28
  23. package/dist/index.js +3 -2
  24. package/dist/lib/auto-dispatch.d.ts +8 -10
  25. package/dist/lib/auto-dispatch.js +20 -42
  26. package/dist/lib/channels/send.d.ts +4 -1
  27. package/dist/lib/channels/send.js +15 -7
  28. package/dist/lib/devices/doctor-findings.d.ts +14 -0
  29. package/dist/lib/devices/doctor-findings.js +92 -51
  30. package/dist/lib/exec.d.ts +6 -2
  31. package/dist/lib/exec.js +22 -4
  32. package/dist/lib/hooks.d.ts +4 -2
  33. package/dist/lib/hooks.js +173 -23
  34. package/dist/lib/humans.d.ts +25 -0
  35. package/dist/lib/humans.js +65 -0
  36. package/dist/lib/memory.js +2 -1
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  39. package/dist/lib/migrate.d.ts +1 -1
  40. package/dist/lib/migrate.js +116 -7
  41. package/dist/lib/notify.js +6 -2
  42. package/dist/lib/permissions.js +12 -10
  43. package/dist/lib/project-import.d.ts +7 -46
  44. package/dist/lib/project-import.js +9 -108
  45. package/dist/lib/projects.d.ts +20 -3
  46. package/dist/lib/projects.js +36 -18
  47. package/dist/lib/registry.d.ts +1 -1
  48. package/dist/lib/registry.js +11 -0
  49. package/dist/lib/routine-activation.d.ts +14 -0
  50. package/dist/lib/routine-activation.js +78 -0
  51. package/dist/lib/routines-project.d.ts +1 -1
  52. package/dist/lib/routines-project.js +3 -9
  53. package/dist/lib/routines.d.ts +3 -1
  54. package/dist/lib/routines.js +32 -19
  55. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  57. package/dist/lib/secrets/bundles.d.ts +11 -0
  58. package/dist/lib/secrets/bundles.js +122 -5
  59. package/dist/lib/session/actor-sidecar.d.ts +5 -2
  60. package/dist/lib/session/actor-sidecar.js +4 -2
  61. package/dist/lib/session/db.d.ts +2 -1
  62. package/dist/lib/session/db.js +19 -3
  63. package/dist/lib/session/types.d.ts +4 -0
  64. package/dist/lib/staleness/writers/sources.d.ts +6 -1
  65. package/dist/lib/staleness/writers/sources.js +93 -7
  66. package/dist/lib/startup/command-registry.d.ts +2 -0
  67. package/dist/lib/startup/command-registry.js +5 -0
  68. package/dist/lib/state.d.ts +2 -0
  69. package/dist/lib/state.js +23 -10
  70. package/dist/lib/types.d.ts +64 -1
  71. package/dist/lib/versions.js +48 -14
  72. package/dist/lib/watchdog/rotate.d.ts +1 -1
  73. package/dist/lib/watchdog/rotate.js +1 -1
  74. package/package.json +1 -1
  75. package/dist/lib/watchdog/routine.d.ts +0 -44
  76. package/dist/lib/watchdog/routine.js +0 -69
@@ -20,16 +20,15 @@ import { machineId } from '../lib/machine-id.js';
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
23
- import { factoryProjectsPath } from '../lib/auto-dispatch.js';
24
23
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
25
- import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
24
+ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, validateProjectDef, } from '../lib/projects.js';
26
25
  import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } from '../lib/project-status.js';
27
26
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
28
27
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
29
28
  import { checkRepoSlug } from '../lib/project-doctor.js';
30
29
  import { formatFocusAreas, readFocusAreas } from '../lib/project-focus.js';
31
30
  import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
32
- import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
31
+ import { buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
33
32
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
34
33
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
35
34
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
@@ -73,11 +72,8 @@ function parseGoalFlag(raw) {
73
72
  *
74
73
  * `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
75
74
  * makes git print `error: No such remote 'origin'` on ITS stderr, which is the
76
- * terminal's — the catch below never sees it. That was invisible while this was
77
- * called once from `add` inside a real repo, and became noise the moment
78
- * `import --from-factory` started calling it per row: importing 12 registry
79
- * rows printed two raw git errors between the progress lines. Absence of a
80
- * remote is an expected answer here (`undefined`), not something to report.
75
+ * terminal's — the catch below never sees it. Absence of a remote is an
76
+ * expected answer here (`undefined`), not something to report.
81
77
  */
82
78
  export function originSlug(cwd) {
83
79
  try {
@@ -92,38 +88,6 @@ export function originSlug(cwd) {
92
88
  return undefined;
93
89
  }
94
90
  }
95
- /**
96
- * Read the Factory registry off disk and plan its import. Every read failure is
97
- * fatal and named — an unreadable or malformed registry must not read as "0
98
- * projects to import".
99
- */
100
- function runFactoryImport(existing, opts) {
101
- const src = factoryProjectsPath();
102
- let rawText;
103
- try {
104
- rawText = fs.readFileSync(src, 'utf8');
105
- }
106
- catch {
107
- console.error(chalk.red(`No Factory registry at ${src}`));
108
- process.exit(1);
109
- }
110
- let parsed;
111
- try {
112
- parsed = JSON.parse(rawText);
113
- }
114
- catch {
115
- console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
116
- process.exit(1);
117
- }
118
- const rows = Array.isArray(parsed)
119
- ? parsed
120
- : Array.isArray(parsed?.projects)
121
- ? parsed.projects
122
- : [];
123
- // The checkout's real `origin` overrides the registry's path-derived slug —
124
- // see buildFactoryImportCandidates for why the path guess is not trustworthy.
125
- return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
126
- }
127
91
  /**
128
92
  * List the workspace's Linear projects and plan their import, binding each to a
129
93
  * local checkout under the configured projects root when the names match
@@ -442,7 +406,11 @@ export function registerProjectsCommands(program) {
442
406
  examples: `
443
407
  agents projects import --from-linear # the projects you actually track
444
408
  agents projects add rush --repo phnx-labs/rush --path apps/web
445
- agents projects list
409
+ agents projects list # definitions only (no session scan)
410
+ agents projects list --with-agents # opt-in local active counts
411
+ agents projects list --json # machine-readable defs (Factory uses this)
412
+ echo '{...}' | agents projects save --json # create/update one def from stdin
413
+ agents projects rm rush --json # machine-readable delete
446
414
  agents projects status # every project, across the whole fleet
447
415
  agents projects status rush # one project (same body as view/show)
448
416
  agents projects view rush # alias of status <name>
@@ -452,31 +420,34 @@ export function registerProjectsCommands(program) {
452
420
  `,
453
421
  notes: `
454
422
  Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
455
- machines with 'agents push/pull'.
456
-
457
- 'import --from-factory' reads Factory's auto-detected registry, which
458
- guesses from checkouts on disk — it imports only 'high' confidence rows by
459
- default. Widen with --min-confidence medium or --all, and drop a bad guess
460
- with 'agents projects rm <name>'.
423
+ machines with 'agents push/pull'. Factory reads and writes only through
424
+ these commands — never ~/.agents/factory/projects.json.
461
425
  `,
462
426
  });
463
427
  // ---- list ----
464
428
  projects
465
429
  .command('list')
466
- .description('List defined projects with their root, repo, and live agent count.')
430
+ .description('List defined projects (definitions only by default; no session scan).')
467
431
  .option('--json', 'Machine-readable output')
432
+ .option('--with-agents', 'Include local active agent counts (opt-in; never SSH)')
468
433
  .action(async (opts) => {
469
434
  const defs = listProjectDefs();
435
+ // Definitions-only by default: zero session scan / SSH. --with-agents is
436
+ // an explicit opt-in for local active counts only (getActiveSessions is
437
+ // local; it never fans out).
438
+ const roll = opts.withAgents
439
+ ? rollupSessionsByProject(defs, await getActiveSessions())
440
+ : undefined;
470
441
  if (opts.json) {
471
- const roll = rollupSessionsByProject(defs, await getActiveSessions());
472
- console.log(JSON.stringify(defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 })), null, 2));
442
+ console.log(JSON.stringify(opts.withAgents
443
+ ? defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 }))
444
+ : defs, null, 2));
473
445
  return;
474
446
  }
475
447
  if (!defs.length) {
476
448
  console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
477
449
  return;
478
450
  }
479
- const roll = rollupSessionsByProject(defs, await getActiveSessions());
480
451
  const rows = defs.map((d) => ({
481
452
  name: d.name,
482
453
  path: truncate(d.root ?? d.defaultPath ?? '', LIST_PATH_MAX),
@@ -484,9 +455,9 @@ export function registerProjectsCommands(program) {
484
455
  }));
485
456
  const w = computeProjectListWidths(rows);
486
457
  for (const [i, d] of defs.entries()) {
487
- const agents = roll.get(d.name)?.agents ?? 0;
488
458
  const row = rows[i];
489
- console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))} ${agents} agents`);
459
+ const agentsSuffix = opts.withAgents && roll ? ` ${roll.get(d.name)?.agents ?? 0} agents` : '';
460
+ console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
490
461
  }
491
462
  });
492
463
  // ---- add ----
@@ -732,11 +703,8 @@ export function registerProjectsCommands(program) {
732
703
  // ---- import ----
733
704
  projects
734
705
  .command('import')
735
- .description('Import project definitions from Linear (preferred) or the Factory auto-detection registry.')
736
- .option('--from-linear', 'Import the workspace\'s Linear projects (via the `linear` CLI)')
737
- .option('--from-factory', 'Import the Factory registry (~/.agents/factory/projects.json)')
738
- .option('--min-confidence <level>', '--from-factory only: lowest detection confidence to import — low|medium|high (default: high)')
739
- .option('--all', '--from-factory only: import every row regardless of confidence')
706
+ .description('Import project definitions from Linear (via the `linear` CLI).')
707
+ .option('--from-linear', 'Import the workspace\'s Linear projects')
740
708
  .option('--force', 'Overwrite existing definitions')
741
709
  .action((raw) => {
742
710
  let opts;
@@ -748,7 +716,7 @@ export function registerProjectsCommands(program) {
748
716
  process.exit(1);
749
717
  }
750
718
  const existing = new Map(listProjectDefs().map((d) => [d.name, d]));
751
- const result = opts.source === 'linear' ? runLinearImport(existing, opts) : runFactoryImport(existing, opts);
719
+ const result = runLinearImport(existing, opts);
752
720
  for (const def of result.defs)
753
721
  writeProjectDef(def);
754
722
  const n = result.defs.length;
@@ -756,9 +724,6 @@ export function registerProjectsCommands(program) {
756
724
  console.log(chalk.green(`Imported ${n} project${n === 1 ? '' : 's'}${s ? chalk.gray(` (${s} skipped)`) : ''}`));
757
725
  for (const skip of result.skipped)
758
726
  console.log(chalk.gray(` skip ${skip.name}: ${skip.reason}`));
759
- if (opts.source === 'factory' && s > 0 && opts.minConfidence === 'high') {
760
- console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
761
- }
762
727
  });
763
728
  // ---- set ----
764
729
  projects
@@ -883,17 +848,79 @@ export function registerProjectsCommands(program) {
883
848
  writeProjectDef(def);
884
849
  console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
885
850
  });
851
+ // ---- save ----
852
+ projects
853
+ .command('save')
854
+ .description('Create or update one project from a complete ProjectDef JSON object on stdin.')
855
+ .option('--json', 'Required: read ProjectDef JSON from stdin; print the saved definition as JSON')
856
+ .action(async (opts) => {
857
+ if (!opts.json) {
858
+ console.error(chalk.red('projects save requires --json (pipe one complete ProjectDef JSON object on stdin).'));
859
+ process.exit(1);
860
+ }
861
+ const chunks = [];
862
+ for await (const c of process.stdin)
863
+ chunks.push(c);
864
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
865
+ if (!raw) {
866
+ console.error(chalk.red('projects save --json: empty stdin (expected one ProjectDef JSON object).'));
867
+ process.exit(1);
868
+ }
869
+ let parsed;
870
+ try {
871
+ parsed = JSON.parse(raw);
872
+ }
873
+ catch (e) {
874
+ console.error(chalk.red(`projects save --json: invalid JSON: ${e instanceof Error ? e.message : String(e)}`));
875
+ process.exit(1);
876
+ }
877
+ let def;
878
+ try {
879
+ def = validateProjectDef(parsed);
880
+ }
881
+ catch (e) {
882
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
883
+ process.exit(1);
884
+ }
885
+ writeProjectDef(def);
886
+ const saved = loadProjectDef(def.name);
887
+ if (!saved) {
888
+ console.error(chalk.red(`projects save: wrote "${def.name}" but could not reload it`));
889
+ process.exit(1);
890
+ }
891
+ console.log(JSON.stringify(saved, null, 2));
892
+ });
886
893
  // ---- rm ----
887
894
  projects
888
895
  .command('rm <name>')
889
896
  .alias('remove')
890
897
  .description('Delete a project definition. Never touches the repo.')
891
- .action((name) => {
898
+ .option('--json', 'Machine-readable success / error')
899
+ .action((name, opts) => {
900
+ if (!isSafeProjectName(name)) {
901
+ if (opts.json) {
902
+ console.log(JSON.stringify({ ok: false, name, error: `Invalid project name: "${name}"` }));
903
+ }
904
+ else {
905
+ console.error(chalk.red(`Invalid project name: "${name}"`));
906
+ }
907
+ process.exit(1);
908
+ }
892
909
  if (removeProjectDef(name)) {
893
- console.log(chalk.green(`Removed project "${name}"`));
910
+ if (opts.json) {
911
+ console.log(JSON.stringify({ ok: true, name, removed: true }));
912
+ }
913
+ else {
914
+ console.log(chalk.green(`Removed project "${name}"`));
915
+ }
894
916
  }
895
917
  else {
896
- console.error(chalk.red(`No project named "${name}".`));
918
+ if (opts.json) {
919
+ console.log(JSON.stringify({ ok: false, name, error: `No project named "${name}"` }));
920
+ }
921
+ else {
922
+ console.error(chalk.red(`No project named "${name}".`));
923
+ }
897
924
  process.exit(1);
898
925
  }
899
926
  });
@@ -0,0 +1,17 @@
1
+ import type { Command } from 'commander';
2
+ interface ResumeOptions {
3
+ mode?: string;
4
+ interactive?: boolean;
5
+ headless?: boolean;
6
+ cwd?: string;
7
+ quiet?: boolean;
8
+ }
9
+ /** Translate `agents resume <id>` to the canonical `agents run` surface after
10
+ * resolving the source identity. The run command remains the only executor. */
11
+ export declare function buildResumeRunArgs(session: {
12
+ id: string;
13
+ agent: string;
14
+ version?: string;
15
+ }, prompt: string | undefined, options: ResumeOptions): string[];
16
+ export declare function registerResumeCommand(program: Command): void;
17
+ export {};
@@ -0,0 +1,80 @@
1
+ /** Strict session resume convenience command. */
2
+ import { spawn } from 'child_process';
3
+ import chalk from 'chalk';
4
+ import { setHelpSections } from '../lib/help.js';
5
+ import { resolveSessionMetadataValue } from './sessions.js';
6
+ /** Translate `agents resume <id>` to the canonical `agents run` surface after
7
+ * resolving the source identity. The run command remains the only executor. */
8
+ export function buildResumeRunArgs(session, prompt, options) {
9
+ const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
10
+ const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--resume', session.id];
11
+ if (options.mode)
12
+ args.push('--mode', options.mode);
13
+ if (options.interactive)
14
+ args.push('--interactive');
15
+ if (options.headless)
16
+ args.push('--headless');
17
+ if (options.cwd)
18
+ args.push('--cwd', options.cwd);
19
+ if (options.quiet)
20
+ args.push('--quiet');
21
+ return args;
22
+ }
23
+ export function registerResumeCommand(program) {
24
+ const cmd = program
25
+ .command('resume <session-id> [prompt]')
26
+ .description('Resume a session with its original harness, version, device, account, cwd, and mode')
27
+ .option('-m, --mode <mode>', 'Override the recorded launch mode')
28
+ .option('-i, --interactive', 'Resume interactively even when a prompt is provided')
29
+ .option('--headless', 'Resume headlessly (a prompt is required)')
30
+ .option('--cwd <path>', 'Override the recorded working directory')
31
+ .option('-q, --quiet', 'Suppress routing banners')
32
+ .action(async (sessionId, prompt, options) => {
33
+ const outcome = await resolveSessionMetadataValue(sessionId.trim());
34
+ if (outcome.kind === 'partial') {
35
+ console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
36
+ process.exitCode = 2;
37
+ return;
38
+ }
39
+ if (outcome.kind === 'not-found') {
40
+ console.error(chalk.red(`No session matching "${sessionId}".`));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (outcome.kind === 'ambiguous') {
45
+ console.error(chalk.red(`"${sessionId}" matches ${outcome.candidates.length} sessions. Pass the full session id.`));
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const args = buildResumeRunArgs(outcome.session, prompt, options);
50
+ const child = spawn(process.execPath, [process.argv[1], ...args], {
51
+ stdio: 'inherit',
52
+ env: {
53
+ ...process.env,
54
+ // Avoid repeating the fleet lookup in the delegated local `run`
55
+ // process. The value is metadata-only and is not forwarded over SSH;
56
+ // the owner performs its own local SQLite lookup.
57
+ AGENTS_RESUME_SOURCE_JSON: JSON.stringify(outcome.session),
58
+ },
59
+ });
60
+ const exitCode = await new Promise((resolve) => {
61
+ child.once('error', () => resolve(127));
62
+ child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
63
+ });
64
+ process.exitCode = exitCode;
65
+ });
66
+ setHelpSections(cmd, {
67
+ examples: `
68
+ # Resume by full id
69
+ agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897
70
+
71
+ # Resume and continue headlessly
72
+ agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 "finish the tests"
73
+
74
+ # Deliberately change permissions
75
+ agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 --mode edit`,
76
+ notes: `
77
+ Full IDs resolve from the local session database first; an SSH fleet lookup runs only after a local miss.
78
+ Use agents run auto --resume <id> when the original account is unavailable and another harness may continue.`,
79
+ });
80
+ }
@@ -14,7 +14,7 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
17
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, } from '../lib/routines.js';
18
18
  import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
19
19
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
20
20
  import { getRoutinesDir } from '../lib/state.js';
@@ -29,6 +29,9 @@ import { setHelpSections } from '../lib/help.js';
29
29
  import { loadDevices, loadDevicesSync } from '../lib/devices/registry.js';
30
30
  import { machineId, normalizeHost } from '../lib/machine-id.js';
31
31
  import { addHostOption } from '../lib/hosts/option.js';
32
+ import { devicesWithRoutineEnabled } from '../lib/routine-activation.js';
33
+ import { spawnSync } from 'node:child_process';
34
+ import { getCliLaunch } from '../lib/cli-entry.js';
32
35
  /**
33
36
  * Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
34
37
  * when it hasn't completed or timestamps are unparseable. Leading separator lets
@@ -131,7 +134,7 @@ function placementTag(job) {
131
134
  return '->cloud';
132
135
  }
133
136
  function deviceLabel(job, width) {
134
- const full = [job.devices?.join(',') ?? '', placementTag(job)]
137
+ const full = [devicesWithRoutineEnabled(job.name).join(','), placementTag(job)]
135
138
  .filter(Boolean)
136
139
  .join(' ');
137
140
  const raw = full.length === 0 ? 'all' : full;
@@ -182,9 +185,9 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
182
185
  add(`host:${normalizeHost(host)}`, titleWithDeviceState(`Host: ${host}`, host, registry), job);
183
186
  continue;
184
187
  }
185
- const devices = job.devices ?? [];
188
+ const devices = devicesWithRoutineEnabled(job.name);
186
189
  if (devices.length === 0) {
187
- add('fleet', 'Fleet-wide', job);
190
+ add('disabled', 'Disabled', job);
188
191
  continue;
189
192
  }
190
193
  for (const device of devices) {
@@ -202,12 +205,14 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
202
205
  return 0;
203
206
  if (group.key === 'fleet')
204
207
  return 1;
205
- if (group.key === 'cloud')
208
+ if (group.key === 'disabled')
206
209
  return 2;
207
- if (group.key.startsWith('device:'))
210
+ if (group.key === 'cloud')
208
211
  return 3;
209
- if (group.key.startsWith('host:'))
212
+ if (group.key.startsWith('device:'))
210
213
  return 4;
214
+ if (group.key.startsWith('host:'))
215
+ return 5;
211
216
  return 5;
212
217
  };
213
218
  return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
@@ -545,6 +550,7 @@ export function registerRoutinesCommands(program) {
545
550
  const nowJson = new Date();
546
551
  const payload = jobs.map((job) => {
547
552
  const latestRun = localLatestRun(job);
553
+ const enabledDevices = devicesWithRoutineEnabled(job.name);
548
554
  return {
549
555
  name: job.name,
550
556
  agent: job.agent ?? null,
@@ -555,7 +561,8 @@ export function registerRoutinesCommands(program) {
555
561
  scheduleHuman: fireConditionLabel(job),
556
562
  trigger: job.trigger ?? null,
557
563
  timezone: job.timezone ?? null,
558
- devices: job.devices ?? [],
564
+ devices: enabledDevices,
565
+ enabledDevices,
559
566
  host: job.host ?? null,
560
567
  hostStrategy: resolveHostStrategy(job),
561
568
  source: job.source ?? null,
@@ -628,7 +635,7 @@ export function registerRoutinesCommands(program) {
628
635
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
629
636
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
630
637
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
631
- .option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host. Not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given. Same model as agents run --where device:<name> (docs/00-concepts.md#placement).')
638
+ .option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host. Same model as agents run --where device:<name> (docs/00-concepts.md#placement).')
632
639
  .option('--placement <strategy>', `BODY placement strategy: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Maps to the shared Placement model (local|device|fleet|cloud). Not the same as --host (which manages routines on a remote machine).`)
633
640
  .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
634
641
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
@@ -727,15 +734,6 @@ export function registerRoutinesCommands(program) {
727
734
  console.error(chalk.red('--placement host requires --run-on <name>'));
728
735
  process.exit(1);
729
736
  }
730
- // Off-box placement without a --devices pin would fire on EVERY daemon
731
- // in the fleet, each dispatching once — duplicate runs (RUSH-1980).
732
- // Pin to this machine unless the user chose an explicit eligibility set.
733
- const strategyForPin = hostStrategy
734
- ?? (options.runOn ? 'host' : 'local');
735
- if (placementRequiresFiringPin(strategyForPin) && !devices) {
736
- devices = [machineId()];
737
- console.error(chalk.gray(`--placement ${strategyForPin} with no --devices: pinned firing to this machine (${devices[0]}).`));
738
- }
739
737
  const config = {
740
738
  name: nameOrPath,
741
739
  ...(schedule ? { schedule } : {}),
@@ -767,6 +765,7 @@ export function registerRoutinesCommands(program) {
767
765
  process.exit(1);
768
766
  }
769
767
  writeJob(config);
768
+ setJobEnabled(config.name, config.enabled && (!devices || devices.map(normalizeHost).includes(normalizeHost(machineId()))));
770
769
  if (options.json) {
771
770
  writeJson({
772
771
  ok: true,
@@ -831,14 +830,8 @@ export function registerRoutinesCommands(program) {
831
830
  config.runOnce = true;
832
831
  console.error(chalk.yellow(`Schedule "${config.schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
833
832
  }
834
- // Same duplicate-fire guard as --placement/--run-on: off-box placement
835
- // with no eligibility pin would fire from every daemon in the fleet.
836
- const fileStrategy = resolveHostStrategy(config);
837
- if (placementRequiresFiringPin(fileStrategy) && (!config.devices || config.devices.length === 0)) {
838
- config.devices = [machineId()];
839
- console.error(chalk.gray(`${fileStrategy} placement with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
840
- }
841
833
  writeJob(config);
834
+ setJobEnabled(config.name, config.enabled && (!config.devices || config.devices.map(normalizeHost).includes(normalizeHost(machineId()))));
842
835
  if (options.json) {
843
836
  writeJson({
844
837
  ok: true,
@@ -1435,12 +1428,13 @@ export function registerRoutinesCommands(program) {
1435
1428
  process.exit(1);
1436
1429
  }
1437
1430
  });
1438
- // Fleet allowlist management for a single routine.
1431
+ // Device activation management for a single routine. Each mutation executes
1432
+ // on the target device so only that host writes devices/<hostname>/agents.yaml.
1439
1433
  routinesCmd
1440
1434
  .command('devices [name]')
1441
- .description('View or change which devices may run a routine. Without flags, opens an interactive picker (requires a TTY).')
1442
- .option('--set <devices>', 'Replace the allowlist with this comma-separated list (strict fleet validation)')
1443
- .option('--clear', 'Remove the allowlist so the routine runs on every device')
1435
+ .description('View or change the devices where a routine is enabled. Without flags, opens an interactive picker (requires a TTY).')
1436
+ .option('--set <devices>', 'Replace the enabled-device set with this comma-separated list')
1437
+ .option('--clear', 'Disable the routine on every registered device')
1444
1438
  .action(async (name, options) => {
1445
1439
  const hasSet = options.set !== undefined;
1446
1440
  if (hasSet && options.clear) {
@@ -1457,33 +1451,37 @@ export function registerRoutinesCommands(program) {
1457
1451
  console.log(chalk.red(`Job '${name}' not found`));
1458
1452
  process.exit(1);
1459
1453
  }
1454
+ const applyDevices = async (selected) => {
1455
+ const registry = await loadDevices();
1456
+ const registered = Object.keys(registry).map(normalizeHost);
1457
+ const all = [...new Set([...registered, machineId()].map(normalizeHost))].sort();
1458
+ const selectedSet = new Set(selected.map(normalizeHost));
1459
+ const unknown = [...selectedSet].filter((device) => !all.includes(device));
1460
+ if (unknown.length > 0)
1461
+ throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
1462
+ for (const device of all) {
1463
+ const action = selectedSet.has(device) ? 'resume' : 'pause';
1464
+ if (device === normalizeHost(machineId())) {
1465
+ setJobEnabled(name, action === 'resume');
1466
+ if (isDaemonRunning())
1467
+ signalDaemonReload();
1468
+ continue;
1469
+ }
1470
+ const launch = getCliLaunch(['routines', action, name, '--host', device]);
1471
+ const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', env: process.env });
1472
+ if ((result.status ?? 1) !== 0)
1473
+ throw new Error(`Could not ${action} '${name}' on ${device}`);
1474
+ }
1475
+ console.log(chalk.green(selected.length === 0
1476
+ ? `Routine '${name}' disabled on every registered device`
1477
+ : `Routine '${name}' enabled on: ${selected.join(', ')}`));
1478
+ };
1460
1479
  if (options.clear) {
1461
- const strategy = resolveHostStrategy(job);
1462
- if (placementRequiresFiringPin(strategy)) {
1463
- console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1464
- console.error(chalk.gray(`Keep a single-machine pin (e.g. --set ${machineId()}) or switch to --placement local.`));
1465
- process.exit(1);
1466
- }
1467
- job.devices = undefined;
1468
- writeJob(job);
1469
- console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
1470
- if (isDaemonRunning())
1471
- signalDaemonReload();
1480
+ await applyDevices([]);
1472
1481
  return;
1473
1482
  }
1474
1483
  if (hasSet) {
1475
- const devices = await parseAndValidateDevices(options.set);
1476
- job.devices = devices;
1477
- // Re-validate so host/fleet/cloud can't be left with an empty set via --set ""
1478
- const errs = validateJob(job);
1479
- if (errs.length > 0) {
1480
- console.error(chalk.red(errs.join('\n')));
1481
- process.exit(1);
1482
- }
1483
- writeJob(job);
1484
- console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
1485
- if (isDaemonRunning())
1486
- signalDaemonReload();
1484
+ await applyDevices(await parseAndValidateDevices(options.set));
1487
1485
  return;
1488
1486
  }
1489
1487
  // Interactive picker
@@ -1496,34 +1494,18 @@ export function registerRoutinesCommands(program) {
1496
1494
  console.log(chalk.yellow('No devices registered. Enroll with: agents devices sync'));
1497
1495
  return;
1498
1496
  }
1499
- const currentSet = new Set((job.devices ?? []).map((d) => normalizeHost(d)));
1497
+ const currentSet = new Set(devicesWithRoutineEnabled(name).map(normalizeHost));
1500
1498
  try {
1501
1499
  const { checkbox } = await import('@inquirer/prompts');
1502
1500
  const selected = await checkbox({
1503
- message: `Devices allowed to run '${name}' (space to toggle, enter to confirm, empty = unrestricted):`,
1501
+ message: `Devices where '${name}' is enabled (space to toggle, enter to confirm):`,
1504
1502
  choices: registeredNames.map((d) => ({
1505
1503
  value: d,
1506
1504
  name: d,
1507
1505
  checked: currentSet.has(d),
1508
1506
  })),
1509
1507
  });
1510
- if (selected.length === 0) {
1511
- const strategy = resolveHostStrategy(job);
1512
- if (placementRequiresFiringPin(strategy)) {
1513
- console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1514
- return;
1515
- }
1516
- job.devices = undefined;
1517
- writeJob(job);
1518
- console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
1519
- }
1520
- else {
1521
- job.devices = selected;
1522
- writeJob(job);
1523
- console.log(chalk.green(`Devices for '${name}' set to: ${selected.join(', ')}`));
1524
- }
1525
- if (isDaemonRunning())
1526
- signalDaemonReload();
1508
+ await applyDevices(selected);
1527
1509
  }
1528
1510
  catch (err) {
1529
1511
  if (isPromptCancelled(err)) {
@@ -281,8 +281,8 @@ export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: s
281
281
  */
282
282
  export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
283
283
  /** The intentionally small metadata contract emitted by `sessions --resolve`.
284
- * Transcript locations, extracted plans, account data, costs, and other indexed
285
- * payload stay local to the machine that owns them. */
284
+ * It includes only launch identity needed to route/resume. Transcript paths,
285
+ * extracted plans, costs, and content stay on the machine that owns them. */
286
286
  export declare function serializeResolvedSessionsJson(sessions: SessionMeta[]): string;
287
287
  /**
288
288
  * Whether the local machine's sessions belong in an `--active` view. Local is
@@ -636,12 +636,21 @@ export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: strin
636
636
  export declare function metadataResolveForwardedArgs(selector: string, scope: Pick<SessionFilterOptions, 'agent' | 'project'>): string[];
637
637
  /** Build the local-only peer argv for a distributed tool search. */
638
638
  export declare function toolSearchForwardedArgs(argv: string[], hosts: string[]): string[];
639
- /** Resolution must fail closed when any selected peer did not answer. Choosing
640
- * from a partial fleet can turn an unseen candidate into a false unique match. */
639
+ /** Resolution fails closed for prefixes/keywords when a peer did not answer.
640
+ * A full UUID is globally unique, so one exact hit is sufficient even when an
641
+ * unrelated registered device is offline. */
641
642
  export declare function metadataResolveOutcome(localMatches: SessionMeta[], remote: {
642
643
  sessions: SessionMeta[];
643
644
  unreachable: string[];
644
645
  }, selector: string): MetadataResolveOutcome;
646
+ /** Reusable local-first resolver for `agents run --resume` and `agents resume`.
647
+ * Full UUIDs hit the local SQLite index without any SSH fan-out. */
648
+ export declare function resolveSessionMetadataValue(selector: string, scope?: {
649
+ agent?: string;
650
+ project?: string;
651
+ local?: boolean;
652
+ hosts?: string[];
653
+ }, deps?: Pick<FleetResolveDeps, 'gatherRemoteList'>): Promise<MetadataResolveOutcome>;
645
654
  /**
646
655
  * Locate a full session id or short id prefix across the online fleet and render it from the machine
647
656
  * that holds it. The local disk already missed; this fans `sessions <id> --json