@phnx-labs/agents-cli 1.22.14 → 1.22.15

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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +4 -6
  5. package/dist/commands/packages.js +90 -22
  6. package/dist/commands/projects.d.ts +2 -5
  7. package/dist/commands/projects.js +92 -65
  8. package/dist/commands/routines.js +53 -71
  9. package/dist/commands/setup-watchdog.d.ts +3 -0
  10. package/dist/commands/setup-watchdog.js +54 -0
  11. package/dist/commands/setup.js +8 -1
  12. package/dist/commands/ssh.d.ts +11 -0
  13. package/dist/commands/ssh.js +18 -4
  14. package/dist/commands/watchdog.d.ts +3 -4
  15. package/dist/commands/watchdog.js +40 -28
  16. package/dist/lib/auto-dispatch.d.ts +8 -10
  17. package/dist/lib/auto-dispatch.js +20 -42
  18. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  19. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  20. package/dist/lib/migrate.d.ts +1 -1
  21. package/dist/lib/migrate.js +4 -3
  22. package/dist/lib/project-import.d.ts +7 -46
  23. package/dist/lib/project-import.js +9 -108
  24. package/dist/lib/projects.d.ts +17 -0
  25. package/dist/lib/projects.js +17 -1
  26. package/dist/lib/registry.d.ts +1 -1
  27. package/dist/lib/registry.js +11 -0
  28. package/dist/lib/routine-activation.d.ts +14 -0
  29. package/dist/lib/routine-activation.js +78 -0
  30. package/dist/lib/routines-project.d.ts +1 -1
  31. package/dist/lib/routines-project.js +3 -9
  32. package/dist/lib/routines.d.ts +3 -1
  33. package/dist/lib/routines.js +32 -19
  34. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  36. package/dist/lib/state.js +17 -4
  37. package/dist/lib/types.d.ts +14 -1
  38. package/dist/lib/watchdog/rotate.d.ts +1 -1
  39. package/dist/lib/watchdog/rotate.js +1 -1
  40. package/package.json +1 -1
  41. package/dist/lib/watchdog/routine.d.ts +0 -44
  42. package/dist/lib/watchdog/routine.js +0 -69
@@ -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)) {
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function runWatchdogSetupWizard(): Promise<void>;
3
+ export declare function registerSetupWatchdogCommand(setup: Command): void;
@@ -0,0 +1,54 @@
1
+ import chalk from 'chalk';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { getCliLaunch } from '../lib/cli-entry.js';
4
+ import { loadDevices } from '../lib/devices/registry.js';
5
+ import { machineId, normalizeHost } from '../lib/machine-id.js';
6
+ import { devicesWithRoutineEnabled } from '../lib/routine-activation.js';
7
+ import { readJob, setJobEnabled } from '../lib/routines.js';
8
+ import { isPromptCancelled, requireInteractiveSelection } from './utils.js';
9
+ const ROUTINE = 'watchdog';
10
+ export async function runWatchdogSetupWizard() {
11
+ if (!readJob(ROUTINE)) {
12
+ throw new Error("Built-in routine 'watchdog' is missing. Run: agents repo pull system");
13
+ }
14
+ const registry = await loadDevices();
15
+ const devices = [...new Set([...Object.keys(registry), machineId()].map(normalizeHost))].sort();
16
+ const enabled = new Set(devicesWithRoutineEnabled(ROUTINE).map(normalizeHost));
17
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
18
+ requireInteractiveSelection('watchdog devices', ['agents watchdog on', 'agents watchdog on --host <device>']);
19
+ }
20
+ try {
21
+ const { checkbox } = await import('@inquirer/prompts');
22
+ const selected = await checkbox({
23
+ message: 'Devices where the watchdog should run:',
24
+ choices: devices.map((device) => ({ name: device, value: device, checked: enabled.has(device) })),
25
+ });
26
+ const selectedSet = new Set(selected);
27
+ for (const device of devices) {
28
+ const on = selectedSet.has(device);
29
+ if (device === normalizeHost(machineId())) {
30
+ setJobEnabled(ROUTINE, on);
31
+ continue;
32
+ }
33
+ const launch = getCliLaunch(['routines', on ? 'resume' : 'pause', ROUTINE, '--host', device]);
34
+ const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', env: process.env });
35
+ if ((result.status ?? 1) !== 0)
36
+ throw new Error(`Could not configure watchdog on ${device}`);
37
+ }
38
+ console.log(chalk.green(selected.length === 0
39
+ ? 'Watchdog disabled on every registered device'
40
+ : `Watchdog enabled on: ${selected.join(', ')}`));
41
+ }
42
+ catch (error) {
43
+ if (isPromptCancelled(error)) {
44
+ console.log(chalk.gray('Cancelled'));
45
+ return;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ export function registerSetupWatchdogCommand(setup) {
51
+ setup.command('watchdog')
52
+ .description('Choose the devices where the built-in watchdog routine runs.')
53
+ .action(runWatchdogSetupWizard);
54
+ }
@@ -24,6 +24,7 @@ import { registerSetupShareCommand, runShareWizard } from './setup-share.js';
24
24
  import { registerSetupMineCommand } from './setup-mine.js';
25
25
  import { registerSetupSecretsCommand } from './setup-secrets.js';
26
26
  import { registerSetupFleetCommand } from './setup-fleet.js';
27
+ import { registerSetupWatchdogCommand, runWatchdogSetupWizard } from './setup-watchdog.js';
27
28
  import { runPreferencesStep } from './setup-preferences.js';
28
29
  const HOME = os.homedir();
29
30
  /**
@@ -247,6 +248,7 @@ async function runSetupHub() {
247
248
  { name: 'share — publish shareable links (Cloudflare R2 + Worker)', value: 'share' },
248
249
  { name: 'secrets — choose storage defaults and import existing credentials', value: 'secrets' },
249
250
  { name: 'fleet — discover Tailscale devices and configure SSH access', value: 'fleet' },
251
+ { name: 'watchdog — resume stalled agent sessions on selected devices', value: 'watchdog' },
250
252
  ],
251
253
  });
252
254
  for (const pick of picks) {
@@ -261,6 +263,8 @@ async function runSetupHub() {
261
263
  await import('./setup-secrets.js').then((m) => m.runSecretsSetupWizard());
262
264
  else if (pick === 'fleet')
263
265
  await import('./setup-fleet.js').then((m) => m.runFleetSetupWizard());
266
+ else if (pick === 'watchdog')
267
+ await runWatchdogSetupWizard();
264
268
  }
265
269
  }
266
270
  catch (err) {
@@ -283,6 +287,7 @@ export function registerSetupCommand(program) {
283
287
  registerSetupMineCommand(setupCmd);
284
288
  registerSetupSecretsCommand(setupCmd);
285
289
  registerSetupFleetCommand(setupCmd);
290
+ registerSetupWatchdogCommand(setupCmd);
286
291
  setHelpSections(setupCmd, {
287
292
  examples: `
288
293
  # First-time setup (clones the system repo into ~/.agents/.system/)
@@ -297,12 +302,13 @@ export function registerSetupCommand(program) {
297
302
  agents setup share
298
303
  agents setup secrets
299
304
  agents setup fleet
305
+ agents setup watchdog
300
306
  `,
301
307
  notes: `
302
308
  What it does:
303
309
  1. Clones the system repo into ~/.agents/.system/
304
310
  2. Imports any unmanaged agent installations it finds
305
- 3. On a TTY, offers to set up optional capabilities (browser/computer/share/secrets/fleet)
311
+ 3. On a TTY, offers to set up optional capabilities (browser/computer/share/secrets/fleet/watchdog)
306
312
  4. On a TTY, asks preferences: which machine you sit at (interactive host)
307
313
  and which browser agents drive here — both skippable, both the same
308
314
  keys 'agents devices set-interactive' / 'browser profiles set-default' write
@@ -313,6 +319,7 @@ export function registerSetupCommand(program) {
313
319
  agents setup share # provision or join a Cloudflare share endpoint
314
320
  agents setup secrets # choose secrets backend/policy defaults + import
315
321
  agents setup fleet # discover Tailscale devices + configure SSH access
322
+ agents setup watchdog # choose which devices run the watchdog routine
316
323
 
317
324
  To install CLIs from agents.yaml and sync resources into version homes:
318
325
  agents sync --local -y
@@ -20,6 +20,17 @@ import { type CrabboxBox } from '../lib/crabbox/cli.js';
20
20
  * the row formatting is deterministic in tests.
21
21
  */
22
22
  export declare function renderLeasedBoxesSection(boxes: CrabboxBox[], nowSecs: number): string[];
23
+ /**
24
+ * Whether `agents devices list` shows the "Leased boxes" section (RUSH-2190).
25
+ * Opt-in via --all only: loading it scans the keychain for bundle credentials
26
+ * and can raise a Touch ID sheet after the table has printed, so the default
27
+ * list must never reach for it. --no-stats stays the hard "instant, no provider
28
+ * calls" opt-out even when --all is passed.
29
+ */
30
+ export declare function showLeasedBoxesSection(opts: {
31
+ all?: boolean;
32
+ stats?: boolean;
33
+ }): boolean;
23
34
  /**
24
35
  * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
25
36
  *
@@ -191,6 +191,16 @@ function loadLeasedBoxesSection() {
191
191
  return []; // crabbox not installed / no provider creds — omit the section
192
192
  }
193
193
  }
194
+ /**
195
+ * Whether `agents devices list` shows the "Leased boxes" section (RUSH-2190).
196
+ * Opt-in via --all only: loading it scans the keychain for bundle credentials
197
+ * and can raise a Touch ID sheet after the table has printed, so the default
198
+ * list must never reach for it. --no-stats stays the hard "instant, no provider
199
+ * calls" opt-out even when --all is passed.
200
+ */
201
+ export function showLeasedBoxesSection(opts) {
202
+ return opts.all === true && opts.stats !== false;
203
+ }
194
204
  /**
195
205
  * `agents ssh <slug>` targeting a leased crabbox box. crabbox provisions a
196
206
  * per-lease identity key, so we ssh via crabbox's OWN emitted invocation
@@ -1152,10 +1162,13 @@ Typical workflow:
1152
1162
  console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
1153
1163
  }
1154
1164
  // Ephemeral crabbox leases live alongside the registered fleet but are never
1155
- // written into the registry — surface them as their own live section. This is
1156
- // a live provider call, so honor --no-stats (the explicit "instant, no probes"
1157
- // opt-out) and bound it so a slow provider can't hang `agents devices`.
1158
- if (opts.stats !== false) {
1165
+ // written into the registry — surface them as their own live section, but only
1166
+ // behind --all (RUSH-2190). Reading them routes through crabboxEnv, whose
1167
+ // bundle auto-detect scans the keychain and can raise a Touch ID sheet AFTER
1168
+ // the table has printed — unacceptable for the default list, which hooks and
1169
+ // other non-interactive callers rely on. The predicate is exported so the
1170
+ // gate itself is unit-tested; keep it the ONLY condition guarding this call.
1171
+ if (showLeasedBoxesSection(opts)) {
1159
1172
  for (const line of loadLeasedBoxesSection())
1160
1173
  console.log(line);
1161
1174
  }
@@ -1170,6 +1183,7 @@ Typical workflow:
1170
1183
  .option('--refresh', 'force a live probe of every device, bypassing the cache')
1171
1184
  .option('--live', 'alias of --refresh (shorter to type)')
1172
1185
  .option('-f, --full', 'full mode: add per-device core count and free/total memory')
1186
+ .option('--all', 'also show ephemeral leased boxes (live crabbox call; may need bundle secrets)')
1173
1187
  .action(runList);
1174
1188
  devicesCmd
1175
1189
  .command('status')
@@ -10,13 +10,12 @@
10
10
  * agents watchdog --nudge one tick, actually injects (explicit opt-in)
11
11
  * agents watchdog --watch manual poll loop (dry unless --nudge)
12
12
  * agents watchdog --json machine-readable tick output (for the menu-bar)
13
- * agents watchdog enable|disable turn the always-on watchdog routine on/off
13
+ * agents watchdog on|off turn the always-on watchdog routine on/off
14
14
  * agents watchdog policy <id> <p> per-session policy: off | keep | handsoff
15
15
  *
16
16
  * The always-on watchdog is a daemon-fired ROUTINE, not a private sentinel + a
17
- * hand-rolled loop: `enable` creates/enables a `watchdog` command routine
18
- * (`agents watchdog --nudge` every couple of minutes) and reloads the daemon;
19
- * `disable` pauses it. See ../lib/watchdog/routine.ts.
17
+ * hand-rolled loop. Its immutable definition comes from the system DotAgents
18
+ * repo; on/off only changes this device's routine membership.
20
19
  */
21
20
  import type { Command } from 'commander';
22
21
  /** Register the `agents watchdog` command tree. */
@@ -10,26 +10,26 @@
10
10
  * agents watchdog --nudge one tick, actually injects (explicit opt-in)
11
11
  * agents watchdog --watch manual poll loop (dry unless --nudge)
12
12
  * agents watchdog --json machine-readable tick output (for the menu-bar)
13
- * agents watchdog enable|disable turn the always-on watchdog routine on/off
13
+ * agents watchdog on|off turn the always-on watchdog routine on/off
14
14
  * agents watchdog policy <id> <p> per-session policy: off | keep | handsoff
15
15
  *
16
16
  * The always-on watchdog is a daemon-fired ROUTINE, not a private sentinel + a
17
- * hand-rolled loop: `enable` creates/enables a `watchdog` command routine
18
- * (`agents watchdog --nudge` every couple of minutes) and reloads the daemon;
19
- * `disable` pauses it. See ../lib/watchdog/routine.ts.
17
+ * hand-rolled loop. Its immutable definition comes from the system DotAgents
18
+ * repo; on/off only changes this device's routine membership.
20
19
  */
21
20
  import chalk from 'chalk';
22
21
  import * as path from 'path';
23
22
  import { setHelpSections } from '../lib/help.js';
24
23
  import { parseDuration } from '../lib/hooks/cache.js';
25
24
  import { getRuntimeStateDir } from '../lib/state.js';
26
- import { setJobEnabled } from '../lib/routines.js';
25
+ import { readJob, setJobEnabled } from '../lib/routines.js';
27
26
  import { getActiveSessions } from '../lib/session/active.js';
28
27
  import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
29
28
  import { gcMailbox } from '../lib/mailbox-gc.js';
30
- import { ensureWatchdogRoutine, isWatchdogRoutineEnabled, watchdogRoutineExists, WATCHDOG_ROUTINE_NAME, WATCHDOG_ROUTINE_SCHEDULE, } from '../lib/watchdog/routine.js';
29
+ import { devicesWithRoutineEnabled, routineEnabledOnThisDevice } from '../lib/routine-activation.js';
31
30
  import { runWatchdogTick, writePolicySentinel, DEFAULT_THRESHOLDS, } from '../lib/watchdog/runner.js';
32
31
  import { isWatchdogRotateEnabled, listRotateStates, setWatchdogRotateEnabled } from '../lib/watchdog/rotate.js';
32
+ const WATCHDOG_ROUTINE_NAME = 'watchdog';
33
33
  /** Default state dir the runner and these subcommands share. */
34
34
  function stateDir() {
35
35
  return path.join(getRuntimeStateDir(), 'watchdog');
@@ -120,7 +120,7 @@ export function registerWatchdogCommand(program) {
120
120
  .command('watchdog')
121
121
  .description('Auto-nudge stalled agent terminals: detect stalls, resolve the exact split, inject "Continue." — no menu-bar needed.')
122
122
  .option('--nudge', 'Actually inject (default is a dry run that only reports what it would do)')
123
- .option('--watch', 'Manual poll loop: run a tick every --interval (dry unless --nudge; the always-on path is `watchdog enable`)')
123
+ .option('--watch', 'Manual poll loop: run a tick every --interval (dry unless --nudge; the always-on path is `watchdog on`)')
124
124
  .option('--interval <dur>', 'Poll interval in --watch mode (e.g. 30s, 1m)', '30s')
125
125
  .option('--stall <dur>', 'Idle time before a session counts as stalled', humanMs(DEFAULT_THRESHOLDS.stallMs))
126
126
  .option('--cooldown <dur>', 'Minimum time between nudges to the same session', humanMs(DEFAULT_THRESHOLDS.cooldownMs))
@@ -166,7 +166,7 @@ export function registerWatchdogCommand(program) {
166
166
  const intervalMs = durationMsOr(opts.interval, 30_000);
167
167
  if (!computeWillInject() && !opts.json) {
168
168
  console.log(chalk.yellow(`watchdog --watch is DETECT-ONLY. Pass --nudge to inject, ` +
169
- `or run 'agents watchdog enable' for the always-on daemon routine.`));
169
+ `or run 'agents watchdog on' for the always-on daemon routine.`));
170
170
  }
171
171
  // eslint-disable-next-line no-constant-condition
172
172
  while (true) {
@@ -197,7 +197,7 @@ export function registerWatchdogCommand(program) {
197
197
  agents watchdog --json
198
198
 
199
199
  # Turn on the ALWAYS-ON watchdog (a daemon-fired routine)
200
- agents watchdog enable
200
+ agents watchdog on
201
201
 
202
202
  # Show the routine, rotate config, and any in-flight in-place rotates
203
203
  agents watchdog status
@@ -242,36 +242,45 @@ export function registerWatchdogCommand(program) {
242
242
  'watchdog.rotate: off' to ~/.agents/agents.yaml; nudging stays on).
243
243
  State machine: ~/.agents/.cache/state/watchdog/rotate/<sessionId>.json.
244
244
 
245
- Always-on: 'agents watchdog enable' creates + enables a 'watchdog' command
246
- routine ('${WATCHDOG_ROUTINE_SCHEDULE}' -> agents watchdog --nudge) and reloads the
247
- daemon; 'disable' pauses it. Inspect it with 'agents routines list'. Defaults
245
+ Always-on: 'agents watchdog on' enables the system-defined 'watchdog' routine
246
+ on this device and reloads the daemon; 'off' disables it here. Inspect it with
247
+ 'agents routines list'. Defaults
248
248
  OFF. Per-session policy: off (ignore), keep (default), handsoff (detect + flag).
249
249
 
250
250
  State (tray-readable): ${path.join('~/.agents/.cache/state/watchdog', '{nudges,flags,last-tick}.json')}
251
251
  `,
252
252
  });
253
253
  // --- always-on enable/disable/status (backed by the daemon routine) --------
254
- cmd.command('enable')
255
- .description('Turn ON the always-on watchdog: create/enable the `watchdog` routine and (re)load the daemon.')
256
- .action(async () => {
257
- ensureWatchdogRoutine(true);
254
+ const turnOn = async () => {
255
+ const routine = readJob(WATCHDOG_ROUTINE_NAME);
256
+ if (!routine)
257
+ throw new Error("Built-in routine 'watchdog' is missing. Run: agents repo pull system");
258
+ setJobEnabled(WATCHDOG_ROUTINE_NAME, true);
258
259
  await reloadDaemonForRoutine(true);
259
- console.log(chalk.green(`watchdog: ENABLED — routine '${WATCHDOG_ROUTINE_NAME}' fires ${WATCHDOG_ROUTINE_SCHEDULE} (agents watchdog --nudge)`));
260
- });
261
- cmd.command('disable')
262
- .description('Turn OFF the always-on watchdog (pause the `watchdog` routine).')
263
- .action(async () => {
264
- if (!watchdogRoutineExists()) {
265
- console.log(chalk.dim('watchdog: already off (no routine)'));
266
- return;
267
- }
260
+ console.log(chalk.green(`watchdog: ON on this device (${routine.schedule ?? 'event-triggered'})`));
261
+ };
262
+ const turnOff = async () => {
263
+ if (!readJob(WATCHDOG_ROUTINE_NAME))
264
+ throw new Error("Built-in routine 'watchdog' is missing. Run: agents repo pull system");
268
265
  setJobEnabled(WATCHDOG_ROUTINE_NAME, false);
269
266
  await reloadDaemonForRoutine(false);
270
- console.log(chalk.yellow('watchdog: DISABLED (routine paused)'));
267
+ console.log(chalk.yellow('watchdog: OFF on this device'));
268
+ };
269
+ cmd.command('on')
270
+ .description('Enable the built-in watchdog routine on this device.')
271
+ .action(async () => {
272
+ await turnOn();
273
+ });
274
+ cmd.command('off')
275
+ .description('Disable the built-in watchdog routine on this device.')
276
+ .action(async () => {
277
+ await turnOff();
271
278
  });
279
+ cmd.command('enable', { hidden: true }).action(turnOn);
280
+ cmd.command('disable', { hidden: true }).action(turnOff);
272
281
  cmd.command('rotate <state>')
273
282
  .description('Turn in-place rotate of rate-limited sessions on|off (watchdog.rotate in agents.yaml). ' +
274
- 'Rotate-only: nudging stays on — unlike `watchdog disable`, which pauses the whole watchdog.')
283
+ 'Rotate-only: nudging stays on — unlike `watchdog off`, which disables the whole watchdog on this device.')
275
284
  .action((state) => {
276
285
  const s = state.toLowerCase();
277
286
  if (s !== 'on' && s !== 'off') {
@@ -292,13 +301,15 @@ export function registerWatchdogCommand(program) {
292
301
  // parent, not this subcommand. optsWithGlobals() merges both levels, so we
293
302
  // read it correctly regardless of which command commander bound it to.
294
303
  const json = command.optsWithGlobals().json === true;
295
- const on = isWatchdogRoutineEnabled();
304
+ const on = routineEnabledOnThisDevice(WATCHDOG_ROUTINE_NAME) === true;
305
+ const enabledDevices = devicesWithRoutineEnabled(WATCHDOG_ROUTINE_NAME);
296
306
  const rotate = isWatchdogRotateEnabled() ? 'on' : 'off';
297
307
  const rotates = listRotateStates(stateDir());
298
308
  const inflight = rotates.filter((r) => r.phase !== 'done' && r.phase !== 'failed');
299
309
  if (json) {
300
310
  console.log(JSON.stringify({
301
311
  enabled: on,
312
+ enabledDevices,
302
313
  routine: WATCHDOG_ROUTINE_NAME,
303
314
  stateDir: stateDir(),
304
315
  rotate,
@@ -314,6 +325,7 @@ export function registerWatchdogCommand(program) {
314
325
  return;
315
326
  }
316
327
  console.log(`always-on watchdog: ${on ? chalk.green('ON') : chalk.dim('off')} (routine '${WATCHDOG_ROUTINE_NAME}')`);
328
+ console.log(`enabled devices: ${enabledDevices.length > 0 ? enabledDevices.join(', ') : chalk.dim('none')}`);
317
329
  console.log(`rotate: ${rotate === 'on' ? chalk.green('on') : chalk.yellow('off')} (watchdog.rotate in agents.yaml) · ${inflight.length} in-flight`);
318
330
  for (const r of inflight) {
319
331
  console.log(` ${chalk.magenta(r.phase.padEnd(12))} ${chalk.bold(r.sessionId.slice(0, 8))} → ${r.newSessionId.slice(0, 8)}${r.error ? chalk.red(` ${r.error}`) : ''}`);
@@ -8,12 +8,13 @@
8
8
  *
9
9
  * The dispatch goes through the same provider layer as `agents cloud run`, so Rush
10
10
  * (Prix) is just one provider among rush/codex/factory — NOT a hidden requirement.
11
- * A project may pin its provider via `provider` in ~/.agents/factory/projects.json.
11
+ * A project pins its provider via `dispatch.provider` in its
12
+ * ~/.agents/projects/<name>.yaml definition.
12
13
  *
13
- * OPT-IN: a project auto-dispatches ONLY when `autoDispatch: true` AND `maxAgents > 0`.
14
- * Nothing global is on by default.
14
+ * OPT-IN: a project auto-dispatches ONLY when `dispatch.enabled: true` AND
15
+ * `dispatch.maxAgents > 0` AND it has a `linear.projectId`. Nothing is on by default.
15
16
  */
16
- /** The subset of a managed project this module needs (mirror of the shared JSON). */
17
+ /** The subset of a project definition this module needs. */
17
18
  export interface AutoDispatchProject {
18
19
  id: string;
19
20
  name: string;
@@ -44,13 +45,10 @@ export interface PlannedDispatch {
44
45
  delegateName: string;
45
46
  }
46
47
  /**
47
- * Path to the shared factory project registry (written by the factory UI).
48
- * `AGENTS_FACTORY_PROJECTS_PATH` overrides it, mirroring `AGENTS_PROJECTS_DIR`
49
- * (`state.ts`) — that seam is what lets `projects import --from-factory` be
50
- * tested end-to-end against a real fixture file instead of a mock.
48
+ * Read auto-dispatch candidates from canonical project YAML definitions
49
+ * (~/.agents/projects/<name>.yaml). Only projects with `dispatch.enabled: true`
50
+ * are returned; the caller's `isEligible` gate then checks the cap and Linear id.
51
51
  */
52
- export declare function factoryProjectsPath(): string;
53
- /** Read the project registry rows this module cares about. */
54
52
  export declare function readAutoDispatchProjects(): AutoDispatchProject[];
55
53
  /** A project is eligible only when it has explicitly opted in with a positive cap. */
56
54
  export declare function isEligible(p: AutoDispatchProject): boolean;