@phnx-labs/agents-cli 1.20.92 → 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 (146) hide show
  1. package/CHANGELOG.md +385 -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/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -46,7 +46,6 @@ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
46
46
  import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
47
47
  import { setHelpSections } from '../lib/help.js';
48
48
  import { registerSessionsTailCommand } from './sessions-tail.js';
49
- import { registerSessionsSyncCommand } from './sessions-sync.js';
50
49
  import { registerSessionsResumeCommand } from './sessions-resume.js';
51
50
  import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
52
51
  import { isFavorite, listFavorites } from '../lib/session/favorites.js';
@@ -1044,7 +1043,11 @@ export async function gatherActiveSessions(opts = {}) {
1044
1043
  const self = machineId();
1045
1044
  // An explicit --host/--device list scopes the view: seed local sessions only
1046
1045
  // when no hosts are named, or when this machine is one of the named targets.
1047
- const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions() : [];
1046
+ // `localOnly: opts.local` (RUSH-2118) keeps a `--local` query from dialing a
1047
+ // remote-host teammate over ssh even for this machine's OWN local gather —
1048
+ // "this machine only" must mean zero ssh, not just "skip the cross-machine
1049
+ // device fan-out below".
1050
+ const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions({ localOnly: opts.local }) : [];
1048
1051
  for (const s of local)
1049
1052
  if (!s.machine)
1050
1053
  s.machine = self;
@@ -1218,7 +1221,7 @@ function canonicalSessionsCommand(query, options) {
1218
1221
  }
1219
1222
  /** Resolve a session by id/query globally and print its compact preview (no pager).
1220
1223
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
1221
- async function renderSessionPreview(query, scope) {
1224
+ export async function renderSessionPreview(query, scope) {
1222
1225
  const discovered = await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 });
1223
1226
  const pool = applyScopeFilters(discovered, scope);
1224
1227
  const { matches, completeId } = resolveSessionQuery(pool, query);
@@ -1234,9 +1237,11 @@ async function renderSessionPreview(query, scope) {
1234
1237
  }
1235
1238
  // Lead with the live status when the session is still running, so the preview
1236
1239
  // says working / waiting / idle up front — not just the historical transcript.
1240
+ // `--local --preview` is freely combinable with `--local` (RUSH-2118): thread
1241
+ // it through so this probe never dials a remote-host teammate either.
1237
1242
  let live;
1238
1243
  try {
1239
- live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
1244
+ live = indexActiveBySessionId(await getActiveSessions({ localOnly: scope.local === true })).get(session.id);
1240
1245
  }
1241
1246
  catch { /* plain preview on any probe failure */ }
1242
1247
  const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
@@ -1390,7 +1395,7 @@ limitSource) {
1390
1395
  console.error(chalk.red('--preview requires a session id or query.'));
1391
1396
  process.exit(1);
1392
1397
  }
1393
- await renderSessionPreview(query, { agent: options.agent, project: options.project });
1398
+ await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
1394
1399
  return;
1395
1400
  }
1396
1401
  if (options.active) {
@@ -1907,11 +1912,15 @@ function treeSessionRow(session, live) {
1907
1912
  * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
1908
1913
  * escape hatch. Never throws — a probe failure just yields a plain listing.
1909
1914
  */
1910
- async function maybeLiveIndex(options) {
1915
+ export async function maybeLiveIndex(options) {
1911
1916
  if (options.live === false || options.json)
1912
1917
  return undefined;
1913
1918
  try {
1914
- return indexActiveBySessionId(await getActiveSessions());
1919
+ // `--local` promises "this machine only" for the default listing too (see
1920
+ // the `--local` help text), so it must gate the live-enrichment probe the
1921
+ // same way `--active --local` does — never dial a remote-host teammate
1922
+ // over ssh here either (RUSH-2118).
1923
+ return indexActiveBySessionId(await getActiveSessions({ localOnly: options.local === true }));
1915
1924
  }
1916
1925
  catch {
1917
1926
  return undefined;
@@ -3387,7 +3396,6 @@ export function registerSessionsCommands(program) {
3387
3396
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
3388
3397
  });
3389
3398
  registerSessionsTailCommand(sessionsCmd);
3390
- registerSessionsSyncCommand(sessionsCmd);
3391
3399
  registerSessionsResumeCommand(sessionsCmd);
3392
3400
  registerSessionsFavoriteCommand(sessionsCmd);
3393
3401
  registerGoCommand(sessionsCmd);
@@ -13,6 +13,7 @@ import { findFirstInstalledBrowser, listInstalledBrowsers } from '../lib/browser
13
13
  import { DEFAULT_BROWSER_PROFILE_NAME, createProfile, findFreeProfilePort, getConfiguredDefaultProfileName, getProfile, } from '../lib/browser/profiles.js';
14
14
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
15
15
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
+ import { defaultBrowserChoice } from './setup-preferences.js';
16
17
  const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, or Comet, then re-run `agents setup browser`.\n' +
17
18
  '(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
18
19
  /**
@@ -58,11 +59,14 @@ export async function runBrowserWizard() {
58
59
  const { deleteProfile } = await import('../lib/browser/profiles.js');
59
60
  await deleteProfile(existing.name);
60
61
  }
61
- // Pick which installed browser to pin (auto-select if only one).
62
+ // Pick which installed browser to pin (auto-select if only one). The picker
63
+ // highlights the same browser auto-detect would win — choosing is an
64
+ // override of the default, never a guess.
62
65
  let chosen = installed[0];
63
66
  if (installed.length > 1) {
64
67
  const value = await select({
65
68
  message: 'Which browser should the default profile use?',
69
+ default: defaultBrowserChoice(installed) ?? undefined,
66
70
  choices: installed.map((b) => ({ name: `${b.browserType} ${chalk.dim(b.binary)}`, value: b.browserType })),
67
71
  });
68
72
  chosen = installed.find((b) => b.browserType === value) ?? installed[0];
@@ -12,6 +12,7 @@ import { loadDevices } from '../lib/devices/registry.js';
12
12
  import { runDeviceSync } from '../lib/devices/sync.js';
13
13
  import { tailscaleStatusJson } from '../lib/devices/tailscale.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
15
+ import { maybePickInteractiveHost } from './setup-preferences.js';
15
16
  function parseAuth(raw) {
16
17
  const value = (raw ?? 'key').toLowerCase();
17
18
  if (value === 'key' || value === 'password')
@@ -171,6 +172,10 @@ export async function runFleetSetupWizard(opts = {}) {
171
172
  if (choices.fleetUpdate)
172
173
  runAgentsSubcommand(['fleet', 'update']);
173
174
  printOnboardingSummary(names, choices.auth);
175
+ // With a fleet registered, the next wrong-machine trap is artifacts opening on
176
+ // some other box — offer to pin the interactive host (TTY-only, skippable,
177
+ // silent non-TTY and when already set).
178
+ await maybePickInteractiveHost();
174
179
  return true;
175
180
  }
176
181
  /** Register `agents setup fleet` under the parent `setup` command. */
@@ -0,0 +1,53 @@
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 { type DeviceRegistry } from '../lib/devices/registry.js';
16
+ /** Registered macOS device names, sorted — the interactive-host candidates. */
17
+ export declare function macDeviceNames(reg: DeviceRegistry): string[];
18
+ /**
19
+ * The interactive-host picker's highlighted default: this machine when it is a
20
+ * candidate (the common case — you run setup on the box you sit at), else the
21
+ * first candidate. null when there are no candidates.
22
+ */
23
+ export declare function defaultInteractiveHostChoice(candidates: string[], self?: string): string | null;
24
+ /**
25
+ * The browser picker's highlighted default — the same browser auto-detect
26
+ * would win, since `listInstalledBrowsers` returns platform priority order
27
+ * (macOS: chrome first). null when nothing is installed.
28
+ */
29
+ export declare function defaultBrowserChoice<T extends {
30
+ browserType: string;
31
+ }>(installed: T[]): T['browserType'] | null;
32
+ /**
33
+ * Offer to set the interactive host when none is configured and the registry
34
+ * has more than one macOS device. Returns true when a host was set. Silent
35
+ * no-op non-TTY, when already set, or with fewer than two candidates (the
36
+ * answer is obvious or absent).
37
+ */
38
+ export declare function maybePickInteractiveHost(): Promise<boolean>;
39
+ /**
40
+ * Offer to pin this machine's default browser profile to a chosen browser.
41
+ * Only asked when there is nothing to preserve: no configured device default
42
+ * AND no existing `default` profile (an existing profile is the user's earlier
43
+ * choice — never re-pinned behind their back). Returns true when a profile was
44
+ * created and set as the device default. Silent no-op non-TTY.
45
+ */
46
+ export declare function maybePickBrowserProfile(): Promise<boolean>;
47
+ /**
48
+ * The bare-`agents setup` preferences step: interactive host, then browser.
49
+ * Runs AFTER the capability hub so it never delays the bootstrap. Never
50
+ * throws — a prompt cancel or a failed pick ends the step quietly and lets
51
+ * setup complete (the same semantics as the capability hub).
52
+ */
53
+ export declare function runPreferencesStep(): Promise<void>;
@@ -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`));