@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
@@ -814,8 +814,8 @@ export function serializeSessionsJson(sessions) {
814
814
  return JSON.stringify(serializable, null, 2) + '\n';
815
815
  }
816
816
  /** The intentionally small metadata contract emitted by `sessions --resolve`.
817
- * Transcript locations, extracted plans, account data, costs, and other indexed
818
- * payload stay local to the machine that owns them. */
817
+ * It includes only launch identity needed to route/resume. Transcript paths,
818
+ * extracted plans, costs, and content stay on the machine that owns them. */
819
819
  export function serializeResolvedSessionsJson(sessions) {
820
820
  const safe = sessions.map((session) => ({
821
821
  id: session.id,
@@ -826,6 +826,7 @@ export function serializeResolvedSessionsJson(sessions) {
826
826
  lastActivity: session.lastActivity,
827
827
  project: session.project,
828
828
  version: session.version,
829
+ mode: session.mode,
829
830
  label: session.label,
830
831
  topic: session.topic,
831
832
  machine: session.machine,
@@ -3447,6 +3448,7 @@ function modeFlag(mode) {
3447
3448
  return '--json';
3448
3449
  return undefined; // summary — the peer's default render
3449
3450
  }
3451
+ const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3450
3452
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
3451
3453
  * local lookups, then group copies by logical session id. Synced mirrors of one
3452
3454
  * session therefore stay one candidate even when several machines report them;
@@ -3501,19 +3503,46 @@ export function toolSearchForwardedArgs(argv, hosts) {
3501
3503
  args.push('--local');
3502
3504
  return args;
3503
3505
  }
3504
- /** Resolution must fail closed when any selected peer did not answer. Choosing
3505
- * from a partial fleet can turn an unseen candidate into a false unique match. */
3506
+ /** Resolution fails closed for prefixes/keywords when a peer did not answer.
3507
+ * A full UUID is globally unique, so one exact hit is sufficient even when an
3508
+ * unrelated registered device is offline. */
3506
3509
  export function metadataResolveOutcome(localMatches, remote, selector) {
3507
- if (remote.unreachable.length > 0) {
3508
- return { kind: 'partial', failedPeers: remote.unreachable };
3509
- }
3510
3510
  const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector);
3511
+ if (FULL_SESSION_ID_RE.test(selector) && candidates.length === 1 && candidates[0].id.toLowerCase() === selector.toLowerCase()) {
3512
+ return { kind: 'resolved', session: candidates[0].hits[0].session };
3513
+ }
3514
+ if (remote.unreachable.length > 0)
3515
+ return { kind: 'partial', failedPeers: remote.unreachable };
3511
3516
  if (candidates.length === 0)
3512
3517
  return { kind: 'not-found' };
3513
3518
  if (candidates.length > 1)
3514
3519
  return { kind: 'ambiguous', candidates };
3515
3520
  return { kind: 'resolved', session: candidates[0].hits[0].session };
3516
3521
  }
3522
+ /** Reusable local-first resolver for `agents run --resume` and `agents resume`.
3523
+ * Full UUIDs hit the local SQLite index without any SSH fan-out. */
3524
+ export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
3525
+ const localMachine = machineId();
3526
+ const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
3527
+ const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
3528
+ const localMatches = resolveIndexedMetadataRows(indexed, selector)
3529
+ .map(session => ({ ...session, machine: session.machine || localMachine }));
3530
+ if (FULL_SESSION_ID_RE.test(selector)) {
3531
+ const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3532
+ if (localOutcome.kind === 'resolved')
3533
+ return localOutcome;
3534
+ }
3535
+ if (scope.local === true)
3536
+ return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
3537
+ try {
3538
+ const forwarded = metadataResolveForwardedArgs(selector, scope);
3539
+ const remote = await deps.gatherRemoteList(forwarded, scope.hosts);
3540
+ return metadataResolveOutcome(localMatches, remote, selector);
3541
+ }
3542
+ catch (error) {
3543
+ return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
3544
+ }
3545
+ }
3517
3546
  /** Resolve one selector to indexed metadata across the fleet without reading or
3518
3547
  * rendering transcript events. A peer answering the parent sweep returns every
3519
3548
  * local candidate; the parent performs the one logical-session uniqueness gate. */
@@ -3529,18 +3558,7 @@ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList
3529
3558
  process.stdout.write(serializeResolvedSessionsJson(localMatches));
3530
3559
  return;
3531
3560
  }
3532
- let remoteResult = { sessions: [], unreachable: [] };
3533
- if (scope.local !== true) {
3534
- try {
3535
- const forwarded = metadataResolveForwardedArgs(selector, scope);
3536
- const result = await deps.gatherRemoteList(forwarded, scope.hosts);
3537
- remoteResult = { sessions: result.sessions, unreachable: result.unreachable };
3538
- }
3539
- catch (error) {
3540
- remoteResult = { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] };
3541
- }
3542
- }
3543
- const outcome = metadataResolveOutcome(localMatches, remoteResult, selector);
3561
+ const outcome = await resolveSessionMetadataValue(selector, scope, deps);
3544
3562
  if (outcome.kind === 'partial') {
3545
3563
  console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
3546
3564
  console.error(chalk.gray('No unique/no-match decision was made. Upgrade or reconnect every peer, then retry.'));
@@ -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}`) : ''}`);
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -980,6 +980,7 @@ async function registerAllEagerCommands() {
980
980
  await reg(loadAudit);
981
981
  await reg(loadWebhook);
982
982
  await reg(loadFunnel);
983
+ await reg(loadHumans);
983
984
  registerHqTombstoneCommand(program);
984
985
  await reg(loadFeed);
985
986
  await reg(loadMailboxes);
@@ -1177,7 +1178,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
1177
1178
  // Bumping the suffix re-runs migrations for every user; binary releases that
1178
1179
  // don't change the schema must NOT re-run (they would destroy user content
1179
1180
  // when migration steps overlap with user-authored paths). See issue #20.
1180
- const sentinelValue = 'v14';
1181
+ const sentinelValue = 'v15';
1181
1182
  let needRun = true;
1182
1183
  try {
1183
1184
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -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;
@@ -8,53 +8,31 @@
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
- import * as fs from 'fs';
17
- import * as path from 'path';
18
- import { homedir } from 'os';
17
+ import { listProjectDefs } from './projects.js';
19
18
  /**
20
- * Path to the shared factory project registry (written by the factory UI).
21
- * `AGENTS_FACTORY_PROJECTS_PATH` overrides it, mirroring `AGENTS_PROJECTS_DIR`
22
- * (`state.ts`) — that seam is what lets `projects import --from-factory` be
23
- * tested end-to-end against a real fixture file instead of a mock.
19
+ * Read auto-dispatch candidates from canonical project YAML definitions
20
+ * (~/.agents/projects/<name>.yaml). Only projects with `dispatch.enabled: true`
21
+ * are returned; the caller's `isEligible` gate then checks the cap and Linear id.
24
22
  */
25
- export function factoryProjectsPath() {
26
- return process.env.AGENTS_FACTORY_PROJECTS_PATH ?? path.join(homedir(), '.agents', 'factory', 'projects.json');
27
- }
28
- /** Read the project registry rows this module cares about. */
29
23
  export function readAutoDispatchProjects() {
30
- let raw;
31
- try {
32
- raw = JSON.parse(fs.readFileSync(factoryProjectsPath(), 'utf-8'));
33
- }
34
- catch {
35
- return [];
36
- }
37
- if (!Array.isArray(raw))
38
- return [];
39
- const out = [];
40
- for (const r of raw) {
41
- if (!r || typeof r !== 'object')
42
- continue;
43
- const o = r;
44
- if (typeof o.id !== 'string' || typeof o.name !== 'string')
45
- continue;
46
- out.push({
47
- id: o.id,
48
- name: o.name,
49
- linearProjectId: typeof o.linearProjectId === 'string' ? o.linearProjectId : undefined,
50
- repoSlug: typeof o.repoSlug === 'string' ? o.repoSlug : undefined,
51
- autoDispatch: o.autoDispatch === true,
52
- maxAgents: typeof o.maxAgents === 'number' ? o.maxAgents : undefined,
53
- provider: typeof o.provider === 'string' ? o.provider : undefined,
54
- host: typeof o.host === 'string' ? o.host : undefined,
55
- });
56
- }
57
- return out;
24
+ return listProjectDefs()
25
+ .filter((d) => d.dispatch?.enabled === true)
26
+ .map((d) => ({
27
+ id: d.name,
28
+ name: d.name,
29
+ linearProjectId: d.linear?.projectId,
30
+ repoSlug: d.repo ?? d.repos?.[0]?.slug,
31
+ autoDispatch: d.dispatch?.enabled ?? false,
32
+ maxAgents: d.dispatch?.maxAgents,
33
+ provider: d.dispatch?.provider,
34
+ host: d.dispatch?.host,
35
+ }));
58
36
  }
59
37
  /** A project is eligible only when it has explicitly opted in with a positive cap. */
60
38
  export function isEligible(p) {
@@ -59,7 +59,10 @@ export type ResolveSendResult = {
59
59
  export declare function isOwnerAlias(to: string | undefined): boolean;
60
60
  /** Compose body + optional URL lines (skip urls already present in the body). */
61
61
  export declare function composeSendText(text: string, urls?: string[]): string;
62
- /** Read notify.owner; null when either field is missing. */
62
+ /**
63
+ * Read the owner destination; humans.yaml is the primary source, agents.yaml
64
+ * notify.owner is the fallback. Returns null when neither is set.
65
+ */
63
66
  export declare function readOwnerDest(meta: Meta): {
64
67
  channel: string;
65
68
  to: string;