@phnx-labs/agents-cli 1.20.93 → 1.21.0

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 (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Preferences onboarding — the guided path over the same device-config keys the
3
+ * `agents devices …` commands write (lib/device-config.ts). Two questions, each
4
+ * TTY-only, skippable, and absent non-interactively:
5
+ *
6
+ * 1. "Which machine do you sit at?" → `interactive.host` (central config:)
7
+ * 2. "Which browser should agents drive on THIS machine?"
8
+ * → `browser.profile` (device-local default)
9
+ *
10
+ * Shared by the bare `agents setup` flow (a short step after the capability
11
+ * hub) and by `agents setup fleet` (interactive host after a successful sync).
12
+ * Unset always keeps today's behavior — skipping is a real choice, not a
13
+ * partial state.
14
+ */
15
+ import chalk from 'chalk';
16
+ import { listInstalledBrowsers } from '../lib/browser/chrome.js';
17
+ import { DEFAULT_BROWSER_PROFILE_NAME, createProfile, findFreeProfilePort, getConfiguredDefaultProfileName, getProfile, } from '../lib/browser/profiles.js';
18
+ import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
19
+ import { getConfigValue, setConfigValue } from '../lib/device-config.js';
20
+ import { loadDevices } from '../lib/devices/registry.js';
21
+ import { machineId } from '../lib/machine-id.js';
22
+ import { isInteractiveTerminal } from './utils.js';
23
+ const SKIP = '__skip__';
24
+ /** Registered macOS device names, sorted — the interactive-host candidates. */
25
+ export function macDeviceNames(reg) {
26
+ return Object.values(reg)
27
+ .filter((d) => d.platform === 'macos')
28
+ .map((d) => d.name)
29
+ .sort();
30
+ }
31
+ /**
32
+ * The interactive-host picker's highlighted default: this machine when it is a
33
+ * candidate (the common case — you run setup on the box you sit at), else the
34
+ * first candidate. null when there are no candidates.
35
+ */
36
+ export function defaultInteractiveHostChoice(candidates, self = machineId()) {
37
+ if (candidates.length === 0)
38
+ return null;
39
+ return candidates.includes(self) ? self : candidates[0];
40
+ }
41
+ /**
42
+ * The browser picker's highlighted default — the same browser auto-detect
43
+ * would win, since `listInstalledBrowsers` returns platform priority order
44
+ * (macOS: chrome first). null when nothing is installed.
45
+ */
46
+ export function defaultBrowserChoice(installed) {
47
+ return installed.length > 0 ? installed[0].browserType : null;
48
+ }
49
+ /**
50
+ * Offer to set the interactive host when none is configured and the registry
51
+ * has more than one macOS device. Returns true when a host was set. Silent
52
+ * no-op non-TTY, when already set, or with fewer than two candidates (the
53
+ * answer is obvious or absent).
54
+ */
55
+ export async function maybePickInteractiveHost() {
56
+ if (!isInteractiveTerminal())
57
+ return false;
58
+ if (getConfigValue('interactive.host').value !== undefined)
59
+ return false;
60
+ const macs = macDeviceNames(await loadDevices());
61
+ if (macs.length < 2)
62
+ return false;
63
+ const { select } = await import('@inquirer/prompts');
64
+ const self = machineId();
65
+ const picked = await select({
66
+ message: 'Which machine do you sit at? (agents open browser windows and artifacts there)',
67
+ default: defaultInteractiveHostChoice(macs, self) ?? SKIP,
68
+ choices: [
69
+ ...macs.map((n) => ({ name: n === self ? `${n} ${chalk.dim('(this machine)')}` : n, value: n })),
70
+ { name: `Skip ${chalk.dim('— decide later: agents devices set-interactive <name>')}`, value: SKIP },
71
+ ],
72
+ });
73
+ if (picked === SKIP)
74
+ return false;
75
+ setConfigValue('interactive.host', picked);
76
+ console.log(chalk.green(`Interactive host: '${picked}'`) + chalk.dim(' — marked ★ interactive in `agents devices list`.'));
77
+ return true;
78
+ }
79
+ /**
80
+ * Offer to pin this machine's default browser profile to a chosen browser.
81
+ * Only asked when there is nothing to preserve: no configured device default
82
+ * AND no existing `default` profile (an existing profile is the user's earlier
83
+ * choice — never re-pinned behind their back). Returns true when a profile was
84
+ * created and set as the device default. Silent no-op non-TTY.
85
+ */
86
+ export async function maybePickBrowserProfile() {
87
+ if (!isInteractiveTerminal())
88
+ return false;
89
+ if (getConfiguredDefaultProfileName())
90
+ return false;
91
+ if (await getProfile(DEFAULT_BROWSER_PROFILE_NAME))
92
+ return false;
93
+ const installed = listInstalledBrowsers();
94
+ if (installed.length === 0)
95
+ return false;
96
+ const { select } = await import('@inquirer/prompts');
97
+ const picked = await select({
98
+ message: 'Which browser should agents drive on THIS machine?',
99
+ default: defaultBrowserChoice(installed) ?? SKIP,
100
+ choices: [
101
+ ...installed.map((b) => ({ name: `${b.browserType} ${chalk.dim(b.binary)}`, value: b.browserType })),
102
+ { name: `Skip ${chalk.dim('— auto-detect on first use (priority order)')}`, value: SKIP },
103
+ ],
104
+ });
105
+ if (picked === SKIP)
106
+ return false;
107
+ const chosen = installed.find((b) => b.browserType === picked) ?? installed[0];
108
+ const freePort = await findFreeProfilePort();
109
+ const profile = {
110
+ name: DEFAULT_BROWSER_PROFILE_NAME,
111
+ description: `${chosen.browserType} profile (chosen during setup)`,
112
+ browser: chosen.browserType,
113
+ binary: chosen.binary,
114
+ endpoints: [`cdp://127.0.0.1:${freePort}`],
115
+ viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
116
+ };
117
+ await createProfile(profile);
118
+ // The same key `agents browser profiles set-default` writes (device-local).
119
+ setConfigValue('browser.profile', profile.name);
120
+ console.log(chalk.green(`Browser: '${chosen.browserType}'`) +
121
+ chalk.dim(` — profile "${profile.name}" is this machine's default (agents browser profiles set-default to change).`));
122
+ return true;
123
+ }
124
+ /**
125
+ * The bare-`agents setup` preferences step: interactive host, then browser.
126
+ * Runs AFTER the capability hub so it never delays the bootstrap. Never
127
+ * throws — a prompt cancel or a failed pick ends the step quietly and lets
128
+ * setup complete (the same semantics as the capability hub).
129
+ */
130
+ export async function runPreferencesStep() {
131
+ if (!isInteractiveTerminal())
132
+ return;
133
+ try {
134
+ const pickedHost = await maybePickInteractiveHost();
135
+ const pickedBrowser = await maybePickBrowserProfile();
136
+ if (pickedHost || pickedBrowser)
137
+ console.log();
138
+ }
139
+ catch {
140
+ // Cancel (ctrl-c) or a picker failure — the step is optional; end it.
141
+ }
142
+ }
@@ -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 { runPreferencesStep } from './setup-preferences.js';
27
28
  const HOME = os.homedir();
28
29
  /**
29
30
  * Import an existing unmanaged agent installation into agents-cli.
@@ -130,15 +131,6 @@ export async function runSetup(program, options = {}) {
130
131
  if (dev.ok && dev.synced > 0) {
131
132
  console.log(chalk.gray(`Discovered ${dev.synced} device${dev.synced === 1 ? '' : 's'} on your tailnet (agents devices list).`));
132
133
  }
133
- // Offer guided cross-machine session-sync provisioning (interactive, opt-in,
134
- // and never blocking — any failure/decline falls through to the rest of setup).
135
- try {
136
- const { promptAndProvisionSessionSync } = await import('./sync-provision.js');
137
- await promptAndProvisionSessionSync({ explicit: false });
138
- }
139
- catch (err) {
140
- console.log(chalk.yellow(`Session-sync setup skipped: ${err.message}`));
141
- }
142
134
  // Offer to import existing unmanaged installations
143
135
  if (unmanaged.length > 0 && isInteractiveTerminal()) {
144
136
  console.log(chalk.bold('\nFound existing installations:\n'));
@@ -201,6 +193,10 @@ export async function runSetup(program, options = {}) {
201
193
  // own guided flow. TTY-only and fully opt-in — a non-interactive `agents setup`
202
194
  // stops at the system-repo bootstrap above, unchanged.
203
195
  await runSetupHub();
196
+ // Preferences step: the two questions that keep agents off the wrong machine —
197
+ // which box you sit at (interactive host) and which browser agents drive here.
198
+ // TTY-only, skippable, and writes the same keys as `agents devices …`.
199
+ await runPreferencesStep();
204
200
  console.log(chalk.bold('\nSetup complete. Try:'));
205
201
  console.log(chalk.cyan(' agents view ') + chalk.gray(' # see what\'s installed'));
206
202
  console.log(chalk.cyan(' agents run <agent> "hello" ') + chalk.gray(' # run an agent'));
@@ -307,6 +303,9 @@ export function registerSetupCommand(program) {
307
303
  1. Clones the system repo into ~/.agents/.system/
308
304
  2. Imports any unmanaged agent installations it finds
309
305
  3. On a TTY, offers to set up optional capabilities (browser/computer/share/secrets/fleet)
306
+ 4. On a TTY, asks preferences: which machine you sit at (interactive host)
307
+ and which browser agents drive here — both skippable, both the same
308
+ keys 'agents devices set-interactive' / 'browser profiles set-default' write
310
309
 
311
310
  Capability setup can also be run any time on its own:
312
311
  agents setup browser # detect a browser + create the default profile
@@ -47,9 +47,12 @@ import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js'
47
47
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort } from './lease.js';
48
48
  import { authCellColor, formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
49
49
  import { runFleetLogin } from '../lib/fleet/remote-login.js';
50
+ import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from '../lib/device-config.js';
51
+ import { setHelpSections } from '../lib/help.js';
50
52
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
51
- * command is running on so it stands out from the rest of the tailnet. */
52
- function deviceSummary(d, isSelf = false, stats) {
53
+ * command is running on so it stands out from the rest of the tailnet.
54
+ * `isInteractive` marks the configured interactive host (`devices set-interactive`). */
55
+ function deviceSummary(d, isSelf = false, stats, isInteractive = false) {
53
56
  const addr = hostNameFor(d) ?? chalk.gray('no address');
54
57
  // Prefer a fresh live verdict (this run's probe, else the written-back
55
58
  // reachability) over the stale tailscale.online snapshot (RUSH-1965).
@@ -63,7 +66,8 @@ function deviceSummary(d, isSelf = false, stats) {
63
66
  const marker = isSelf ? chalk.cyan('▸ ') : ' ';
64
67
  const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
65
68
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
66
- return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
69
+ const interactive = isInteractive ? chalk.yellow(' ★ interactive') : '';
70
+ return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}`;
67
71
  }
68
72
  const HEADROOM_BADGE = {
69
73
  idle: chalk.green('○ idle'),
@@ -90,9 +94,9 @@ function pctCell(v, width) {
90
94
  * Without it (probe skipped) the classic reachability line is used. A fleet
91
95
  * capacity summary is appended whenever stats were gathered.
92
96
  */
93
- function renderDeviceTable(reg, names, self, statsMap, full = false) {
97
+ function renderDeviceTable(reg, names, self, statsMap, full = false, interactiveHost) {
94
98
  if (!statsMap)
95
- return names.map((n) => deviceSummary(reg[n], n === self));
99
+ return names.map((n) => deviceSummary(reg[n], n === self, undefined, n === interactiveHost));
96
100
  const lines = [];
97
101
  const head = ' ' +
98
102
  chalk.gray('device'.padEnd(16)) +
@@ -132,7 +136,8 @@ function renderDeviceTable(reg, names, self, statsMap, full = false) {
132
136
  : '';
133
137
  const badge = HEADROOM_BADGE[headroom(stats)];
134
138
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
135
- lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}`);
139
+ const interactive = name === interactiveHost ? chalk.yellow(' ★ interactive') : '';
140
+ lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}`);
136
141
  }
137
142
  // Fleet capacity summary — total cores + how much RAM is free right now.
138
143
  const cap = fleetCapacity(statsMap.values());
@@ -685,10 +690,13 @@ function registerDevicesCommands(program) {
685
690
  Typical workflow:
686
691
  agents devices sync # curate: pick which tailscale nodes to keep (TTY)
687
692
  agents devices sync --yes # non-interactive: register all non-ignored nodes
688
- agents devices list # see what's registered
693
+ agents devices list # see what's registered (★ = interactive host)
689
694
  agents devices ignore ipad165 # dismiss a node so it's never re-suggested
690
695
  agents devices disable zion # exclude a device from Factory auto-launch
691
696
  agents devices prefer mac-mini # boost a device in Factory auto-launch ranking
697
+ agents devices set-interactive zion # where agents show YOU artifacts
698
+ agents devices configure mac-mini --max-agents 4 --scheduler off
699
+ agents devices note mac-mini "runs the releases — don't reboot"
692
700
  agents devices set win-mini --auth password --bundle muqsit
693
701
  agents devices render --write # write ~/.ssh/config.d/agents include
694
702
  agents fleet update # roll out latest agents-cli to every online device
@@ -824,12 +832,214 @@ Typical workflow:
824
832
  process.exit(1);
825
833
  }
826
834
  });
835
+ const setInteractiveCmd = devicesCmd
836
+ .command('set-interactive [name]')
837
+ .description('Get or set the interactive host — the one device that shows YOU artifacts (browser opens, dashboards, rendered plans). Stored fleet-wide as config.interactiveHost in central agents.yaml.')
838
+ .option('--unset', 'clear the interactive host')
839
+ .option('--json', 'output machine-readable JSON')
840
+ .action(async (name, opts) => {
841
+ try {
842
+ if (opts.unset) {
843
+ unsetConfigValue('interactive.host');
844
+ if (opts.json)
845
+ process.stdout.write(JSON.stringify({ interactiveHost: null }, null, 2) + '\n');
846
+ else
847
+ console.log(chalk.green('Cleared the interactive host.'));
848
+ return;
849
+ }
850
+ if (name) {
851
+ await mustGetDevice(name);
852
+ setConfigValue('interactive.host', name);
853
+ if (opts.json)
854
+ process.stdout.write(JSON.stringify({ interactiveHost: name }, null, 2) + '\n');
855
+ else
856
+ console.log(chalk.green(`Interactive host: '${name}'`) + chalk.gray(' — agents show you artifacts there. Clear with --unset.'));
857
+ return;
858
+ }
859
+ const current = getConfigValue('interactive.host').value;
860
+ if (opts.json) {
861
+ process.stdout.write(JSON.stringify({ interactiveHost: current ?? null }, null, 2) + '\n');
862
+ }
863
+ else if (current) {
864
+ console.log(`${chalk.bold('Interactive host:')} ${chalk.cyan(current)}`);
865
+ }
866
+ else {
867
+ console.log(chalk.gray("No interactive host set. Set one with 'agents devices set-interactive <name>'."));
868
+ }
869
+ }
870
+ catch (err) {
871
+ console.error(chalk.red(err.message));
872
+ process.exit(1);
873
+ }
874
+ });
875
+ setHelpSections(setInteractiveCmd, {
876
+ examples: `
877
+ agents devices set-interactive zion # zion is where artifacts open for you
878
+ agents devices set-interactive # print the current interactive host
879
+ agents devices set-interactive --unset # back to no interactive host
880
+ agents devices set-interactive --json # machine-readable (for skills)
881
+ `,
882
+ notes: `
883
+ The interactive host answers "which online macOS device do I show this on?"
884
+ so skills stop guessing. It is marked ★ interactive in 'agents devices list'.
885
+ The value syncs fleet-wide (central agents.yaml, config.interactiveHost);
886
+ per-device settings live under 'agents devices configure' instead.
887
+ `,
888
+ });
889
+ const configureCmd = devicesCmd
890
+ .command('configure <name>')
891
+ .description('Get or set per-device config: --max-agents, --scheduler. Written to ~/.agents/devices/<name>/agents.yaml (works for any device — the devices/ tree syncs). Unset = default behavior.')
892
+ .option('--max-agents <n>', 'cap concurrent agents (Factory auto-launch counts device-wide; teams placement counts the team’s roster on the device)')
893
+ .option('--scheduler <on|off>', 'allow the routines scheduler (daemon) to fire on this device (takes effect on daemon reload/restart)')
894
+ .option('--json', 'output machine-readable JSON')
895
+ .action(async (name, opts) => {
896
+ try {
897
+ await mustGetDevice(name);
898
+ const parseOnOff = (flag, raw) => {
899
+ if (raw === 'on')
900
+ return true;
901
+ if (raw === 'off')
902
+ return false;
903
+ throw new Error(`--${flag} expects 'on' or 'off', got '${raw}'.`);
904
+ };
905
+ const writes = [];
906
+ if (opts.maxAgents !== undefined) {
907
+ const n = Number(opts.maxAgents);
908
+ if (!Number.isInteger(n))
909
+ throw new Error(`--max-agents expects an integer, got '${opts.maxAgents}'.`);
910
+ writes.push(['agents.max-concurrent', n]);
911
+ }
912
+ if (opts.scheduler !== undefined)
913
+ writes.push(['scheduler.enabled', parseOnOff('scheduler', opts.scheduler)]);
914
+ if (writes.length > 0) {
915
+ for (const [key, value] of writes)
916
+ setConfigValue(key, value, { device: name });
917
+ if (!opts.json) {
918
+ for (const [key, value] of writes) {
919
+ console.log(chalk.green(`Set ${key} = ${JSON.stringify(value)}`) + chalk.gray(` on '${name}'.`));
920
+ }
921
+ }
922
+ }
923
+ if (opts.json || writes.length === 0) {
924
+ const entries = listConfig({ device: name }).filter((e) => e.spec.scope === 'device');
925
+ if (opts.json) {
926
+ const config = {};
927
+ for (const e of entries)
928
+ if (e.value !== undefined)
929
+ config[e.spec.name] = e.value;
930
+ process.stdout.write(JSON.stringify({ device: name, config }, null, 2) + '\n');
931
+ }
932
+ else {
933
+ console.log(chalk.bold(`Config for '${name}'`));
934
+ for (const e of entries) {
935
+ const value = e.value === undefined ? chalk.gray('— (default)') : chalk.cyan(JSON.stringify(e.value));
936
+ console.log(` ${e.spec.name.padEnd(24)} ${value}${chalk.gray(` ${e.spec.description}`)}`);
937
+ }
938
+ }
939
+ }
940
+ }
941
+ catch (err) {
942
+ console.error(chalk.red(err.message));
943
+ process.exit(1);
944
+ }
945
+ });
946
+ setHelpSections(configureCmd, {
947
+ examples: `
948
+ agents devices configure mac-mini --max-agents 4 # cap concurrent agents
949
+ agents devices configure mac-mini --scheduler off # no routines firing there
950
+ agents devices configure mac-mini # print its current config
951
+ agents devices configure mac-mini --json # machine-readable
952
+ `,
953
+ notes: `
954
+ Run it on any machine for any device: the value lands in
955
+ ~/.agents/devices/<name>/agents.yaml locally and reaches the device on the
956
+ next 'agents repo push/pull'. Unset keys keep today's behavior.
957
+ --scheduler takes effect when the daemon reloads or restarts on that
958
+ device ('agents routines start' / the reload a 'routines add' sends).
959
+ For the default browser profile use 'agents browser profiles set-default
960
+ <name>'; for free-form text use 'agents devices note'.
961
+ `,
962
+ });
963
+ const noteCmd = devicesCmd
964
+ .command('note <name> [text...]')
965
+ .description('Append a free-form note to a device (repeat to append more). No text prints the notes; --clear empties them.')
966
+ .option('--clear', 'remove all notes from the device')
967
+ .option('--json', 'output machine-readable JSON')
968
+ .action(async (name, text, opts) => {
969
+ try {
970
+ await mustGetDevice(name);
971
+ if (opts.clear) {
972
+ unsetConfigValue('notes', { device: name });
973
+ if (opts.json)
974
+ process.stdout.write(JSON.stringify({ device: name, notes: [] }, null, 2) + '\n');
975
+ else
976
+ console.log(chalk.green(`Cleared notes on '${name}'.`));
977
+ return;
978
+ }
979
+ if (text.length > 0) {
980
+ const existing = getConfigValue('notes', { device: name }).value ?? [];
981
+ const notes = [...existing, text.join(' ')];
982
+ setConfigValue('notes', notes, { device: name });
983
+ if (opts.json)
984
+ process.stdout.write(JSON.stringify({ device: name, notes }, null, 2) + '\n');
985
+ else
986
+ console.log(chalk.green(`Noted on '${name}':`) + ` ${text.join(' ')}`);
987
+ return;
988
+ }
989
+ const notes = getConfigValue('notes', { device: name }).value ?? [];
990
+ if (opts.json) {
991
+ process.stdout.write(JSON.stringify({ device: name, notes }, null, 2) + '\n');
992
+ }
993
+ else if (notes.length > 0) {
994
+ console.log(chalk.bold(`Notes for '${name}'`));
995
+ for (const n of notes)
996
+ console.log(` ${chalk.gray('•')} ${n}`);
997
+ }
998
+ else {
999
+ console.log(chalk.gray(`No notes on '${name}'. Add one with 'agents devices note ${name} "..."'.`));
1000
+ }
1001
+ }
1002
+ catch (err) {
1003
+ console.error(chalk.red(err.message));
1004
+ process.exit(1);
1005
+ }
1006
+ });
1007
+ setHelpSections(noteCmd, {
1008
+ examples: `
1009
+ agents devices note mac-mini "runs the releases — don't reboot"
1010
+ agents devices note mac-mini "4 displays attached" # appends a second note
1011
+ agents devices note mac-mini # print its notes
1012
+ agents devices note mac-mini --clear # drop them all
1013
+ `,
1014
+ notes: `
1015
+ Notes are operator memory for a box (why it exists, what to never do to
1016
+ it). They sync like every other device doc; 'agents devices list --json'
1017
+ carries them under config.notes.
1018
+ `,
1019
+ });
1020
+ /** Device-scope config block for `list --json`, keyed by yamlKey (set keys only). */
1021
+ const deviceConfigJson = (name) => {
1022
+ const config = {};
1023
+ for (const entry of listConfig({ device: name })) {
1024
+ if (entry.spec.scope !== 'device' || entry.value === undefined)
1025
+ continue;
1026
+ config[entry.spec.yamlKey] = entry.value;
1027
+ }
1028
+ return Object.keys(config).length > 0 ? config : undefined;
1029
+ };
827
1030
  const runList = async (opts = {}) => {
828
1031
  const reg = await loadDevices();
829
1032
  const names = Object.keys(reg).sort();
1033
+ const interactiveHost = getConfigValue('interactive.host').value;
830
1034
  if (opts.json) {
831
- // Registry-only, always fast — the Factory extension polls this path.
832
- process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
1035
+ // Registry + local device docs, always fast — the Factory extension polls
1036
+ // this path. Each row carries its device-scope `config` (maxAgents,
1037
+ // schedulerEnabled, notes, defaultBrowserProfile — set keys
1038
+ // only) and an `interactive` flag for the configured interactive host.
1039
+ process.stdout.write(JSON.stringify(names.map((n) => {
1040
+ const config = deviceConfigJson(n);
1041
+ return { ...reg[n], interactive: n === interactiveHost, ...(config ? { config } : {}) };
1042
+ }), null, 2) + '\n');
833
1043
  return;
834
1044
  }
835
1045
  if (names.length === 0) {
@@ -867,7 +1077,7 @@ Typical workflow:
867
1077
  await writeReachability(collectReachabilityWriteBacks(reg, statsMap)).catch(() => { });
868
1078
  }
869
1079
  console.log(chalk.bold(`Devices (${names.length})`));
870
- for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
1080
+ for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full, interactiveHost))
871
1081
  console.log(line);
872
1082
  if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
873
1083
  console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
@@ -2,8 +2,8 @@
2
2
  * `agents sync` — synchronize central resources into an installed agent version.
3
3
  *
4
4
  * Forms:
5
- * agents sync # umbrella: fetch config repos -> reconcile all (secrets/sessions opt-in)
6
- * agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
5
+ * agents sync # umbrella: fetch config repos -> reconcile all (secrets opt-in)
6
+ * agents sync --repos|--secrets # umbrella: fetch only those, then reconcile
7
7
  * agents sync --cloud # umbrella: fetch all, skip reconcile
8
8
  * agents sync --local # umbrella: reconcile all, no fetch
9
9
  * agents sync system # one repo: git pull --rebase (pull-only mirror)
@@ -2,8 +2,8 @@
2
2
  * `agents sync` — synchronize central resources into an installed agent version.
3
3
  *
4
4
  * Forms:
5
- * agents sync # umbrella: fetch config repos -> reconcile all (secrets/sessions opt-in)
6
- * agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
5
+ * agents sync # umbrella: fetch config repos -> reconcile all (secrets opt-in)
6
+ * agents sync --repos|--secrets # umbrella: fetch only those, then reconcile
7
7
  * agents sync --cloud # umbrella: fetch all, skip reconcile
8
8
  * agents sync --local # umbrella: reconcile all, no fetch
9
9
  * agents sync system # one repo: git pull --rebase (pull-only mirror)
@@ -47,7 +47,7 @@ import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../l
47
47
  export function registerSyncCommand(program) {
48
48
  addHostOption(program.command('sync [agentSpec] [repo]'))
49
49
  .summary('Make this machine current, or sync resources into one agent')
50
- .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nGive a DotAgent repo name ALONE — "agents sync system" / "agents sync user" / "agents sync <alias>" — to git-sync that one repo: refuse if the tree is dirty, else git pull --rebase against origin. The user repo and extra aliases also push local commits up; the system repo is a pull-only mirror.\n\nWith NO agent, runs the umbrella verb: fetch the config repos then reconcile them into every installed agent. Secrets and sessions are opt-in — add --secrets to pull secret bundles or --sessions to sync transcripts (sessions are also queryable live via "agents sessions --host <machine>"). Also: --cloud (fetch only), --local (reconcile only).')
50
+ .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nGive a DotAgent repo name ALONE — "agents sync system" / "agents sync user" / "agents sync <alias>" — to git-sync that one repo: refuse if the tree is dirty, else git pull --rebase against origin. The user repo and extra aliases also push local commits up; the system repo is a pull-only mirror.\n\nWith NO agent, runs the umbrella verb: fetch the config repos then reconcile them into every installed agent. Secrets are opt-in — add --secrets to pull secret bundles. Session transcripts are queryable live via "agents sessions --host <machine>", or moved with "agents sessions export/import". Also: --cloud (fetch only), --local (reconcile only).')
51
51
  .option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
52
52
  .option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
53
53
  .option('--repo <name>', 'Scope the sync to a single DotAgent repo: system / user / project / <alias> (also accepted as a positional)')
@@ -60,7 +60,6 @@ export function registerSyncCommand(program) {
60
60
  // Umbrella verb (no agent given): make this machine current.
61
61
  .option('--repos', 'Umbrella: git-pull ~/.agents + enabled ~/.agents-* extras', false)
62
62
  .option('--secrets', 'Umbrella: pull encrypted secret bundles from the remote', false)
63
- .option('--sessions', 'Umbrella: sync session transcripts across machines', false)
64
63
  .option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
65
64
  .option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
66
65
  .action(async (agentSpec, repo, opts) => {
@@ -206,7 +205,7 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
206
205
  // Interactive bare `agents sync` (a TTY, no --yes, no scope flag) drops into
207
206
  // the two-checklist picker: which repos to sync from, which agents to sync
208
207
  // into. Any explicit flag or --yes keeps the non-interactive umbrella below.
209
- const anyExplicitFlag = !!(opts.repos || opts.secrets || opts.sessions || opts.cloud || opts.local);
208
+ const anyExplicitFlag = !!(opts.repos || opts.secrets || opts.cloud || opts.local);
210
209
  if (!quiet && !opts.yes && !anyExplicitFlag && isInteractiveTerminal()) {
211
210
  await runInteractiveReconcile(opts, outLog, errLog);
212
211
  return;
@@ -214,7 +213,6 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
214
213
  const flags = {
215
214
  repos: opts.repos,
216
215
  secrets: opts.secrets,
217
- sessions: opts.sessions,
218
216
  cloud: opts.cloud,
219
217
  local: opts.local,
220
218
  };
@@ -238,9 +236,6 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
238
236
  if (result.secrets) {
239
237
  parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
240
238
  }
241
- if (result.sessions) {
242
- parts.push(result.sessions.ran ? `sessions ${result.sessions.merged} merged` : 'sessions off');
243
- }
244
239
  if (result.reconciled)
245
240
  parts.push('reconciled');
246
241
  outLog(chalk.green(`✓ sync: ${parts.join(' · ') || 'nothing to do'}`));
@@ -24,6 +24,9 @@ import { setHelpSections } from '../lib/help.js';
24
24
  import { parseDuration } from '../lib/hooks/cache.js';
25
25
  import { getRuntimeStateDir } from '../lib/state.js';
26
26
  import { setJobEnabled } from '../lib/routines.js';
27
+ import { getActiveSessions } from '../lib/session/active.js';
28
+ import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
29
+ import { gcMailbox } from '../lib/mailbox-gc.js';
27
30
  import { ensureWatchdogRoutine, isWatchdogRoutineEnabled, watchdogRoutineExists, WATCHDOG_ROUTINE_NAME, WATCHDOG_ROUTINE_SCHEDULE, } from '../lib/watchdog/routine.js';
28
31
  import { runWatchdogTick, writePolicySentinel, DEFAULT_THRESHOLDS, } from '../lib/watchdog/runner.js';
29
32
  /** Default state dir the runner and these subcommands share. */
@@ -58,6 +61,21 @@ function humanMs(ms) {
58
61
  return `${Math.round(ms / 60_000)}m`;
59
62
  return `${Math.round(ms / 1000)}s`;
60
63
  }
64
+ /**
65
+ * Run the mailbox liveness sweep against the same session set the tick just used.
66
+ * Idempotent and cheap: archiving a message twice is a no-op, and GC only scans
67
+ * directories. Failures are swallowed so a mailbox-root problem cannot break the
68
+ * watchdog tick.
69
+ */
70
+ async function runMailboxGc(sessions) {
71
+ const activeBoxIds = new Set(sessions.map(mailboxIdForActiveSession).filter((id) => !!id));
72
+ try {
73
+ gcMailbox(activeBoxIds);
74
+ }
75
+ catch {
76
+ // GC is best-effort housekeeping; the next tick will retry.
77
+ }
78
+ }
61
79
  function colorForOutcome(o) {
62
80
  if (o.injected)
63
81
  return chalk.green;
@@ -118,7 +136,7 @@ export function registerWatchdogCommand(program) {
118
136
  // path is the daemon routine, which runs `agents watchdog --nudge` — so the
119
137
  // routine's enabled state IS the on/off switch, not a flag read here.
120
138
  const computeWillInject = () => opts.nudge === true;
121
- const tickOnce = async (willInject) => runWatchdogTick({
139
+ const tickOnce = async (willInject, sessions) => runWatchdogTick({
122
140
  nudge: willInject,
123
141
  nudgeText: opts.text,
124
142
  smart: opts.smart === true,
@@ -126,10 +144,13 @@ export function registerWatchdogCommand(program) {
126
144
  thresholds,
127
145
  allowGhosttyFocus: opts.allowGhosttyFocus === true,
128
146
  stateDir: stateDir(),
147
+ sessions,
129
148
  });
130
149
  if (!opts.watch) {
131
150
  const willInject = computeWillInject();
132
- const result = await tickOnce(willInject);
151
+ const sessions = await getActiveSessions();
152
+ const result = await tickOnce(willInject, sessions);
153
+ await runMailboxGc(sessions);
133
154
  if (opts.json)
134
155
  console.log(JSON.stringify(result, null, 2));
135
156
  else
@@ -146,7 +167,9 @@ export function registerWatchdogCommand(program) {
146
167
  while (true) {
147
168
  // Re-evaluated each tick: picks up enable/disable flips mid-run.
148
169
  const willInject = computeWillInject();
149
- const result = await tickOnce(willInject);
170
+ const sessions = await getActiveSessions();
171
+ const result = await tickOnce(willInject, sessions);
172
+ await runMailboxGc(sessions);
150
173
  if (opts.json)
151
174
  console.log(JSON.stringify(result));
152
175
  else