@phnx-labs/agents-cli 1.22.13 → 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 (48) hide show
  1. package/CHANGELOG.md +30 -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/secrets.js +10 -6
  10. package/dist/commands/sessions-optimize.d.ts +14 -0
  11. package/dist/commands/sessions-optimize.js +41 -0
  12. package/dist/commands/sessions.js +2 -0
  13. package/dist/commands/setup-watchdog.d.ts +3 -0
  14. package/dist/commands/setup-watchdog.js +54 -0
  15. package/dist/commands/setup.js +8 -1
  16. package/dist/commands/ssh.d.ts +11 -0
  17. package/dist/commands/ssh.js +18 -4
  18. package/dist/commands/watchdog.d.ts +3 -4
  19. package/dist/commands/watchdog.js +40 -28
  20. package/dist/lib/auto-dispatch.d.ts +8 -10
  21. package/dist/lib/auto-dispatch.js +20 -42
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  24. package/dist/lib/migrate.d.ts +1 -1
  25. package/dist/lib/migrate.js +4 -3
  26. package/dist/lib/project-import.d.ts +7 -46
  27. package/dist/lib/project-import.js +9 -108
  28. package/dist/lib/projects.d.ts +17 -0
  29. package/dist/lib/projects.js +17 -1
  30. package/dist/lib/registry.d.ts +1 -1
  31. package/dist/lib/registry.js +11 -0
  32. package/dist/lib/routine-activation.d.ts +14 -0
  33. package/dist/lib/routine-activation.js +78 -0
  34. package/dist/lib/routines-project.d.ts +1 -1
  35. package/dist/lib/routines-project.js +3 -9
  36. package/dist/lib/routines.d.ts +3 -1
  37. package/dist/lib/routines.js +32 -19
  38. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  39. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  40. package/dist/lib/session/db.d.ts +19 -0
  41. package/dist/lib/session/db.js +24 -0
  42. package/dist/lib/state.js +17 -4
  43. package/dist/lib/types.d.ts +14 -1
  44. package/dist/lib/watchdog/rotate.d.ts +1 -1
  45. package/dist/lib/watchdog/rotate.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/lib/watchdog/routine.d.ts +0 -44
  48. 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)) {
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } 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 { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
24
24
  import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
@@ -1254,7 +1254,10 @@ export function registerSecretsCommands(program) {
1254
1254
  const { env } = readAndResolveBundleEnv(bundle.name, {
1255
1255
  caller: 'view --reveal --json',
1256
1256
  keyMode: 'storage',
1257
- agentOnly: true,
1257
+ // An explicit human `--reveal` at a terminal is a deliberate value
1258
+ // access → resolve interactively (one Touch ID). Under an agent
1259
+ // (AGENTS_RUNTIME) or headless (no TTY) it stays broker-only.
1260
+ agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
1258
1261
  });
1259
1262
  for (const entry of entries) {
1260
1263
  if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
@@ -1369,15 +1372,16 @@ export function registerSecretsCommands(program) {
1369
1372
  console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
1370
1373
  process.exit(1);
1371
1374
  }
1372
- // Resolve through the broker / durable-session path. A locked keychain
1373
- // bundle errors with the explicit unlock hint; viewing never raises a
1374
- // Touch ID sheet itself.
1375
+ // An explicit human `view --reveal` at a terminal is a deliberate value
1376
+ // access: resolve interactively (one Touch ID) so a locked bundle shows
1377
+ // its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
1378
+ // stays broker-only and errors with the unlock hint — never a sheet.
1375
1379
  const revealedValues = new Map();
1376
1380
  if (reveal) {
1377
1381
  const { env } = readAndResolveBundleEnv(bundle.name, {
1378
1382
  caller: 'view --reveal',
1379
1383
  keyMode: 'storage',
1380
- agentOnly: true,
1384
+ agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
1381
1385
  });
1382
1386
  for (const entry of entries) {
1383
1387
  if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents sessions optimize` — compact the FTS5 session/tool-call search index.
3
+ *
4
+ * The scanner delete+inserts a session's docs into the `tool_call_text` /
5
+ * `session_text` FTS5 indexes on every rescan (and on every extractor-version
6
+ * bump), and FTS5 never merges the resulting segments on its own. Over thousands
7
+ * of sessions and re-index passes the `%_data` shadow tables bloat with hundreds
8
+ * of thousands of unmerged segments — gigabytes of index for tens of MB of
9
+ * actual content — and `agents sessions` queries slow to a crawl. This runs the
10
+ * FTS5 `'optimize'` command to merge every segment into one and purge tombstones.
11
+ * Non-destructive: no searchable content is lost.
12
+ */
13
+ import type { Command } from 'commander';
14
+ export declare function registerSessionsOptimizeCommand(sessionsCmd: Command): void;
@@ -0,0 +1,41 @@
1
+ import chalk from 'chalk';
2
+ import { optimizeSessionSearchIndex } from '../lib/session/db.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ export function registerSessionsOptimizeCommand(sessionsCmd) {
5
+ const cmd = sessionsCmd
6
+ .command('optimize')
7
+ .description('Compact the session search index (FTS5), reclaiming bloat from repeated re-indexing')
8
+ .option('--json', 'Emit machine-readable JSON')
9
+ .action((_opts, c) => {
10
+ const opts = c.optsWithGlobals();
11
+ const results = optimizeSessionSearchIndex();
12
+ if (opts.json) {
13
+ console.log(JSON.stringify(results, null, 2));
14
+ return;
15
+ }
16
+ for (const r of results) {
17
+ const merged = Math.max(0, r.segmentsBefore - r.segmentsAfter);
18
+ console.log(` ${chalk.cyan(r.table)}: ${r.segmentsBefore} -> ${r.segmentsAfter} segments ` +
19
+ `(${chalk.green(merged)} merged)`);
20
+ }
21
+ console.log(chalk.gray(' Reclaimed space stays as reusable pages inside the file; VACUUM (daemon stopped) returns it to disk.'));
22
+ });
23
+ setHelpSections(cmd, {
24
+ examples: `
25
+ # Compact the session/tool search index once it has grown fragmented
26
+ agents sessions optimize
27
+
28
+ # Machine-readable segment counts
29
+ agents sessions optimize --json
30
+
31
+ # Wire it to a weekly routine so the index never re-bloats
32
+ agents routines add sessions-optimize --schedule "0 4 * * 0" --agent claude \\
33
+ --prompt "Run: agents sessions optimize"
34
+ `,
35
+ notes: `
36
+ - FTS5 appends a segment on every insert and tombstones every delete; the scanner delete+inserts a session's docs on each rescan and never self-merges, so \`tool_call_text_data\` / \`session_text_data\` bloat with unmerged segments — GBs of index for tens of MB of content, and queries slow down.
37
+ - This runs FTS5 \`'optimize'\`: it merges every segment into one and purges tombstones. Non-destructive — no searchable content is lost.
38
+ - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, stop the daemon (\`agents routines stop\`) and run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`.
39
+ `,
40
+ });
41
+ }
@@ -61,6 +61,7 @@ import { registerSessionsImportCommand } from './sessions-import.js';
61
61
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
62
62
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
63
63
  import { registerSessionsStatsCommand } from './sessions-stats.js';
64
+ import { registerSessionsOptimizeCommand } from './sessions-optimize.js';
64
65
  import { runBrowserSessions } from '../lib/browser/sessions-list.js';
65
66
  import { countToolProgramOccurrences, parseToolProgramCountClause, readToolIndexCoverage, searchToolCalls, TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, serializeToolSearchEnvelope, toolSearchRemoteReceiveBudget, } from '../lib/session/tool-index.js';
66
67
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
@@ -3811,6 +3812,7 @@ export function registerSessionsCommands(program) {
3811
3812
  registerSessionsMigrationsCommand(sessionsCmd);
3812
3813
  registerSessionsBackfillCommand(sessionsCmd);
3813
3814
  registerSessionsStatsCommand(sessionsCmd);
3815
+ registerSessionsOptimizeCommand(sessionsCmd);
3814
3816
  // Observe-umbrella alias (Phase 3): roster → sessions --active.
3815
3817
  registerSessionsObserveAliases(program);
3816
3818
  }
@@ -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. */