@phnx-labs/agents-cli 1.22.38 → 1.22.39

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.
@@ -38,6 +38,10 @@ import { assertValidDeviceName } from './devices/registry.js';
38
38
  import { fleetDevicesMapForWrite, migrateDeviceConfigToCentral } from './devices/config-migration.js';
39
39
  const DEVICE_PLATFORMS = ['windows', 'linux', 'macos', 'unknown'];
40
40
  const SSH_AUTH_METHODS = ['key', 'password'];
41
+ /** Roles a device can be marked with — see the `role` key below. */
42
+ const DEVICE_ROLES = ['worker', 'personal'];
43
+ /** Which devices automatic placement may pick — see the `auto.pool` key below. */
44
+ const AUTO_POOL_MODES = ['workers', 'all'];
41
45
  export const CONFIG_KEYS = [
42
46
  {
43
47
  name: 'interactive.host',
@@ -71,6 +75,19 @@ export const CONFIG_KEYS = [
71
75
  }
72
76
  },
73
77
  },
78
+ {
79
+ name: 'auto.pool',
80
+ yamlKey: 'autoPool',
81
+ scope: 'user',
82
+ type: 'string',
83
+ description: "Which devices automatic placement (`--device auto`) may pick: 'workers' (default — only devices marked role=worker, " +
84
+ "once at least one is marked) or 'all' (every online device, ignoring worker marks). A device marked personal is " +
85
+ 'never picked automatically under either mode.',
86
+ defaultValue: 'workers',
87
+ validate: (v) => AUTO_POOL_MODES.includes(v)
88
+ ? null
89
+ : `auto.pool must be one of ${AUTO_POOL_MODES.join(' | ')}.`,
90
+ },
74
91
  {
75
92
  name: 'browser.profile',
76
93
  yamlKey: 'defaultBrowserProfile',
@@ -119,6 +136,18 @@ export const CONFIG_KEYS = [
119
136
  defaultValue: false,
120
137
  description: 'Whether the daemon runs the watchdog pass on this device.',
121
138
  },
139
+ {
140
+ name: 'tmux.enabled',
141
+ yamlKey: 'tmuxEnabled',
142
+ scope: 'device',
143
+ visibility: 'machine',
144
+ type: 'bool',
145
+ defaultValue: true,
146
+ description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session. ' +
147
+ 'On gives every agent an addressable pane (`agents sessions --active` tells co-located agents apart, ' +
148
+ '`agents focus` re-attaches without forking). Off spawns the agent directly on this box — the durable ' +
149
+ 'form of `--no-tmux`, for a machine whose tmux is broken or unwanted.',
150
+ },
122
151
  {
123
152
  name: 'browser.remote-control',
124
153
  yamlKey: 'browserRemoteControl',
@@ -191,6 +220,20 @@ export const CONFIG_KEYS = [
191
220
  ? null
192
221
  : `platform must be one of ${DEVICE_PLATFORMS.join(' | ')}.`,
193
222
  },
223
+ {
224
+ name: 'role',
225
+ yamlKey: 'role',
226
+ scope: 'device',
227
+ visibility: 'shared',
228
+ type: 'string',
229
+ description: "What this device is for, fleet-wide: 'worker' (a box agents run on) or 'personal' (a machine you sit at — never " +
230
+ 'picked automatically). Marking ANY device worker turns automatic placement into an allowlist: `--device auto` then ' +
231
+ 'picks only from the marked workers. (A paired iPhone/iPad cockpit is marked control by `agents devices pair-ios` ' +
232
+ 'and is excluded from placement by that role, not this key.)',
233
+ validate: (v) => DEVICE_ROLES.includes(v)
234
+ ? null
235
+ : `role must be one of ${DEVICE_ROLES.join(' | ')}.`,
236
+ },
194
237
  {
195
238
  name: 'auto-launch.enabled',
196
239
  yamlKey: 'autoLaunchEnabled',
@@ -442,6 +485,48 @@ export function unsetConfigValue(name, opts) {
442
485
  }
443
486
  unsetInCentralBlock(device, spec);
444
487
  }
488
+ /**
489
+ * The role marked on one device, or undefined when the operator never marked it.
490
+ *
491
+ * Undefined is meaningful and is NOT the same as `worker`: an unmarked device is
492
+ * eligible for automatic placement only while no device anywhere carries an
493
+ * explicit `worker` mark (see {@link listConfiguredDeviceRoles}).
494
+ */
495
+ export function configuredDeviceRole(name) {
496
+ assertValidDeviceName(name);
497
+ return getConfigValue('role', { device: name }).value;
498
+ }
499
+ /** Mark a device's role fleet-wide; `undefined` clears the mark. */
500
+ export function setConfiguredDeviceRole(name, role) {
501
+ assertValidDeviceName(name);
502
+ if (role === undefined)
503
+ unsetConfigValue('role', { device: name });
504
+ else
505
+ setConfigValue('role', role, { device: name });
506
+ }
507
+ /**
508
+ * Every device an operator has marked, keyed by device name. Devices with no
509
+ * mark are absent — that absence is what makes the worker allowlist opt-in.
510
+ */
511
+ export function listConfiguredDeviceRoles() {
512
+ ensureDeviceConfigMigrated();
513
+ const devices = readMeta().fleet?.devices;
514
+ const out = {};
515
+ if (!devices || devices === 'all')
516
+ return out;
517
+ for (const [name, override] of Object.entries(devices)) {
518
+ const role = override?.config?.role;
519
+ if (typeof role === 'string' && DEVICE_ROLES.includes(role)) {
520
+ out[name] = role;
521
+ }
522
+ }
523
+ return out;
524
+ }
525
+ /** The configured automatic-placement pool mode. Unset means `workers`. */
526
+ export function autoPoolMode() {
527
+ const value = getConfigValue('auto.pool').value;
528
+ return value === 'all' ? 'all' : 'workers';
529
+ }
445
530
  /** True if the device is enabled for auto-launch. Unset defaults to true. */
446
531
  export function isAutoLaunchEnabled(name) {
447
532
  assertValidDeviceName(name);
@@ -509,6 +594,17 @@ export function assertSchedulerEnabled() {
509
594
  throw new Error(`The routines scheduler is disabled on this device (scheduler.enabled=false in ~/.agents/agents.yaml fleet.devices.${machineId()}.config). ` +
510
595
  `Re-enable with: agents devices config ${machineId()} scheduler.enabled on`);
511
596
  }
597
+ /**
598
+ * True unless this machine's config turns off the managed tmux wrap for
599
+ * interactive `agents run` launches (`tmux.enabled=false`).
600
+ *
601
+ * Read as one of the guards in `shouldWrapInTmux` (lib/exec.ts) — the durable,
602
+ * per-machine form of `--no-tmux` / `AGENTS_NO_TMUX=1`, for a box whose tmux is
603
+ * broken or unwanted. Unset means today's behavior: wrap.
604
+ */
605
+ export function isTmuxEnabled() {
606
+ return getConfigValue('tmux.enabled').value !== false;
607
+ }
512
608
  /** True unless this machine's config disables the daemon outright (top-level kill switch). */
513
609
  export function isDaemonEnabled() {
514
610
  return getConfigValue('daemon.enabled').value !== false;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The automatic-placement pool — which devices `--device auto` may pick from.
3
+ *
4
+ * One rule, in one place, so every automatic-placement path agrees: `agents run
5
+ * --device auto`, `agents teams add --device auto`, the generic host resolver,
6
+ * and the AGI EXT launch commands (which emit `--device auto` rather than
7
+ * scoring devices themselves) all draw from {@link filterAutoPool}.
8
+ *
9
+ * The pool is an ALLOWLIST the moment the operator marks a worker. Roles are
10
+ * stored in the fleet-shared `fleet.devices.<name>.config.role` block of
11
+ * `~/.agents/agents.yaml` (see `lib/device-config.ts`), which is the only
12
+ * device store that syncs — the device registry under `~/.agents/devices/` is
13
+ * gitignored and per-machine, so a role kept there could never be a fleet-wide
14
+ * statement.
15
+ *
16
+ * | Fleet state | `--device auto` picks from |
17
+ * |---|---|
18
+ * | no device marked | every online device (unchanged behavior) |
19
+ * | some marked `worker` | ONLY those workers |
20
+ * | marked `personal` | never, under either state |
21
+ *
22
+ * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
23
+ * machine the user sits at is marked precisely so agents stay off it.
24
+ *
25
+ * Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
26
+ * by `agents devices pair-ios`) are excluded by the CALLER that reads the
27
+ * registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
28
+ * role is machine-local by nature and already has a home; duplicating it in the
29
+ * shared config would be a second store for one concept.
30
+ */
31
+ import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
32
+ export interface AutoPoolOptions {
33
+ /** Pool mode; defaults to the configured `auto.pool`. */
34
+ mode?: AutoPoolMode;
35
+ /** Configured roles by device name; defaults to the fleet-shared block. */
36
+ roles?: Record<string, ConfiguredDeviceRole>;
37
+ }
38
+ /**
39
+ * Narrow a candidate host list to the devices automatic placement may pick.
40
+ *
41
+ * Returns the input order, minus the excluded devices. An empty result is a
42
+ * real answer — "you marked workers and none of them is a candidate right now"
43
+ * — and callers surface it as their own no-healthy-device error rather than
44
+ * quietly widening back to the full fleet.
45
+ */
46
+ export declare function filterAutoPool(pool: string[], opts?: AutoPoolOptions): string[];
47
+ /** True when this host is one automatic placement may pick. */
48
+ export declare function isAutoPoolMember(host: string, opts?: AutoPoolOptions): boolean;
49
+ /** Device names explicitly marked `worker`, in registry order. */
50
+ export declare function listWorkerDevices(opts?: Pick<AutoPoolOptions, 'roles'>): string[];
51
+ /**
52
+ * One line naming why the pool is what it is, for the `--device auto` banner and
53
+ * the no-healthy-device error. Empty string when no role narrows anything, so
54
+ * callers can append it unconditionally.
55
+ */
56
+ export declare function describeAutoPool(opts?: AutoPoolOptions): string;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The automatic-placement pool — which devices `--device auto` may pick from.
3
+ *
4
+ * One rule, in one place, so every automatic-placement path agrees: `agents run
5
+ * --device auto`, `agents teams add --device auto`, the generic host resolver,
6
+ * and the AGI EXT launch commands (which emit `--device auto` rather than
7
+ * scoring devices themselves) all draw from {@link filterAutoPool}.
8
+ *
9
+ * The pool is an ALLOWLIST the moment the operator marks a worker. Roles are
10
+ * stored in the fleet-shared `fleet.devices.<name>.config.role` block of
11
+ * `~/.agents/agents.yaml` (see `lib/device-config.ts`), which is the only
12
+ * device store that syncs — the device registry under `~/.agents/devices/` is
13
+ * gitignored and per-machine, so a role kept there could never be a fleet-wide
14
+ * statement.
15
+ *
16
+ * | Fleet state | `--device auto` picks from |
17
+ * |---|---|
18
+ * | no device marked | every online device (unchanged behavior) |
19
+ * | some marked `worker` | ONLY those workers |
20
+ * | marked `personal` | never, under either state |
21
+ *
22
+ * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
23
+ * machine the user sits at is marked precisely so agents stay off it.
24
+ *
25
+ * Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
26
+ * by `agents devices pair-ios`) are excluded by the CALLER that reads the
27
+ * registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
28
+ * role is machine-local by nature and already has a home; duplicating it in the
29
+ * shared config would be a second store for one concept.
30
+ */
31
+ import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
32
+ import { normalizeHost } from '../machine-id.js';
33
+ /** Roles that automatic placement never picks, whatever the pool mode. */
34
+ const NEVER_AUTO = new Set(['personal']);
35
+ /**
36
+ * Narrow a candidate host list to the devices automatic placement may pick.
37
+ *
38
+ * Returns the input order, minus the excluded devices. An empty result is a
39
+ * real answer — "you marked workers and none of them is a candidate right now"
40
+ * — and callers surface it as their own no-healthy-device error rather than
41
+ * quietly widening back to the full fleet.
42
+ */
43
+ export function filterAutoPool(pool, opts = {}) {
44
+ const roles = opts.roles ?? listConfiguredDeviceRoles();
45
+ const byHost = new Map(Object.entries(roles).map(([name, role]) => [normalizeHost(name), role]));
46
+ const roleOf = (host) => byHost.get(normalizeHost(host));
47
+ const eligible = pool.filter((host) => {
48
+ const role = roleOf(host);
49
+ return role === undefined || !NEVER_AUTO.has(role);
50
+ });
51
+ const mode = opts.mode ?? autoPoolMode();
52
+ if (mode === 'all')
53
+ return eligible;
54
+ const anyWorkerMarked = [...byHost.values()].some((role) => role === 'worker');
55
+ if (!anyWorkerMarked)
56
+ return eligible;
57
+ return eligible.filter((host) => roleOf(host) === 'worker');
58
+ }
59
+ /** True when this host is one automatic placement may pick. */
60
+ export function isAutoPoolMember(host, opts = {}) {
61
+ return filterAutoPool([host], opts).length > 0;
62
+ }
63
+ /** Device names explicitly marked `worker`, in registry order. */
64
+ export function listWorkerDevices(opts = {}) {
65
+ const roles = opts.roles ?? listConfiguredDeviceRoles();
66
+ return Object.entries(roles)
67
+ .filter(([, role]) => role === 'worker')
68
+ .map(([name]) => name);
69
+ }
70
+ /**
71
+ * One line naming why the pool is what it is, for the `--device auto` banner and
72
+ * the no-healthy-device error. Empty string when no role narrows anything, so
73
+ * callers can append it unconditionally.
74
+ */
75
+ export function describeAutoPool(opts = {}) {
76
+ const roles = opts.roles ?? listConfiguredDeviceRoles();
77
+ const mode = opts.mode ?? autoPoolMode();
78
+ const workers = listWorkerDevices({ roles });
79
+ if (mode === 'all') {
80
+ return workers.length > 0 ? 'auto.pool=all (worker marks ignored)' : '';
81
+ }
82
+ if (workers.length === 0)
83
+ return '';
84
+ return `workers: ${workers.join(', ')}`;
85
+ }
@@ -398,6 +398,8 @@ export interface TmuxWrapContext {
398
398
  raw: boolean;
399
399
  /** The AGENTS_NO_TMUX=1 escape hatch. */
400
400
  noTmuxEnv: boolean;
401
+ /** This device's `tmux.enabled` config — false turns the wrap off for every launch on this box. */
402
+ configEnabled: boolean;
401
403
  /** Whether a tmux binary is on PATH. */
402
404
  tmuxAvailable: boolean;
403
405
  }
@@ -410,12 +412,13 @@ export interface TmuxWrapContext {
410
412
  * focus` re-attach a live session without forking it. Pure so the gate is unit-
411
413
  * tested independently of the (side-effecting) spawn.
412
414
  *
413
- * All five guards must pass:
415
+ * All seven guards must pass:
414
416
  * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
415
417
  * - not Windows — no tmux path on win32.
416
418
  * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
417
419
  * - not --raw — explicit opt-out.
418
420
  * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
421
+ * - tmux.enabled — this device's durable opt-out, for a box whose tmux is broken.
419
422
  * - tmux installed — otherwise there is nothing to wrap with.
420
423
  */
421
424
  export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
package/dist/lib/exec.js CHANGED
@@ -29,6 +29,8 @@ import { recordRunName } from './session/run-names.js';
29
29
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
30
30
  import { composeWin32CommandLine } from './platform/index.js';
31
31
  import { isTmuxInstalled } from './tmux/binary.js';
32
+ import { isTmuxEnabled } from './device-config.js';
33
+ import { machineId } from './machine-id.js';
32
34
  import { shellQuote } from './ssh-exec.js';
33
35
  import { resolveClaudeSetupToken } from './claude-account-token.js';
34
36
  import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
@@ -1327,12 +1329,13 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1327
1329
  * focus` re-attach a live session without forking it. Pure so the gate is unit-
1328
1330
  * tested independently of the (side-effecting) spawn.
1329
1331
  *
1330
- * All five guards must pass:
1332
+ * All seven guards must pass:
1331
1333
  * - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
1332
1334
  * - not Windows — no tmux path on win32.
1333
1335
  * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
1334
1336
  * - not --raw — explicit opt-out.
1335
1337
  * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
1338
+ * - tmux.enabled — this device's durable opt-out, for a box whose tmux is broken.
1336
1339
  * - tmux installed — otherwise there is nothing to wrap with.
1337
1340
  */
1338
1341
  export function shouldWrapInTmux(ctx) {
@@ -1346,6 +1349,8 @@ export function shouldWrapInTmux(ctx) {
1346
1349
  return false;
1347
1350
  if (ctx.noTmuxEnv)
1348
1351
  return false;
1352
+ if (!ctx.configEnabled)
1353
+ return false;
1349
1354
  if (!ctx.tmuxAvailable)
1350
1355
  return false;
1351
1356
  return true;
@@ -1459,7 +1464,7 @@ async function runInTmux(options, executable, args) {
1459
1464
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
1460
1465
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
1461
1466
  const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
1462
- const NO_TMUX_TIP = `${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`;
1467
+ const NO_TMUX_TIP = `${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.\n If tmux is broken on this machine, turn the wrap off for good: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
1463
1468
  // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
1464
1469
  // detaches the client the instant the agent exits, so a fast failure (a
1465
1470
  // gutted install that dies with ENOENT, a bad flag, a crash on startup) would
@@ -1766,6 +1771,7 @@ async function spawnAgent(options) {
1766
1771
  inTmux: !!process.env.TMUX,
1767
1772
  raw: options.raw === true,
1768
1773
  noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
1774
+ configEnabled: isTmuxEnabled(),
1769
1775
  tmuxAvailable: isTmuxInstalled(),
1770
1776
  })) {
1771
1777
  timer.mark('startup');
@@ -58,6 +58,42 @@ export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): vo
58
58
  * can drive a fixture tree without touching the user's ~/.agents.
59
59
  */
60
60
  export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string, historyDir?: string): void;
61
+ /**
62
+ * Move the auto-detected `default` browser profile OUT of the committed central
63
+ * agents.yaml and into this machine's per-device file.
64
+ *
65
+ * `browser` is a CENTRAL key because named profiles a user creates are real fleet
66
+ * config, but the ONE `default` entry inside it is machine-local: its `binary` is
67
+ * an OS-specific path and its endpoint is a locally-chosen free port.
68
+ * `createProfile`/`updateProfile` already route that entry to `deviceBrowser`
69
+ * (`isMachineLocalProfile`, browser/profiles.ts) — but nothing ever removed the
70
+ * copy older versions had already written into the shared file, and
71
+ * `serializeCentral` cannot: it deletes whole KEYS that are device-scoped, and
72
+ * `browser` is not one.
73
+ *
74
+ * So the entry sat in the committed file and every box rewrote it with its own
75
+ * browser. Measured 2026-08-13, all three boxes on 1.22.38 (which HAS the writer
76
+ * fix) with an empty `deviceBrowser` and a machine-specific `default` in central:
77
+ *
78
+ * zion browser: chrome binary: /Applications/Google Chrome.app/...
79
+ * yosemite-s1 browser: brave binary: /opt/brave.com/brave/brave
80
+ * mark-1 (same shape)
81
+ *
82
+ * `agents repos pull user` refuses when an incoming change touches a locally
83
+ * modified path, so agents.yaml being permanently dirty wedged the fleet config
84
+ * sync outright — those boxes sat 5, 8 and 79 commits behind. (RUSH-2161)
85
+ *
86
+ * Idempotent: no-op once central carries no `default` entry. The device file is
87
+ * written FIRST so a crash between the two writes can never lose the profile,
88
+ * and an entry already in the device file wins (this machine's live value is
89
+ * newer than the stale central copy by construction).
90
+ *
91
+ * Central is edited through a `yaml.Document` rather than re-stringified, so the
92
+ * hand-written comments in the committed agents.yaml survive — a plain
93
+ * `yaml.stringify` would drop every one of them and rewrite the whole file,
94
+ * which is the same churn this migration exists to stop (see `serializeCentral`).
95
+ */
96
+ export declare function migrateMachineLocalBrowserProfileOutOfCentral(userDir?: string, machine?: string): void;
61
97
  /**
62
98
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
63
99
  * inside every installed agent version-home, and rewrite cross-references in
@@ -21,6 +21,10 @@ import { setConfigValue } from './device-config.js';
21
21
  import { enabledRoutineNames, replaceEnabledRoutines } from './routine-activation.js';
22
22
  import { evaluateActivationReadiness } from './routine-readiness.js';
23
23
  import { migrateDeviceConfigToCentral } from './devices/config-migration.js';
24
+ // Two constants only, never the read/write API — migrations still operate on raw
25
+ // YAML so they never take the meta lock or prime the meta cache mid-migration.
26
+ import { DEFAULT_BROWSER_PROFILE_NAME } from './browser/profiles.js';
27
+ import { META_HEADER as DEVICE_META_HEADER } from './state.js';
24
28
  const HOME = process.env.HOME ?? os.homedir();
25
29
  const USER_DIR = path.join(HOME, '.agents');
26
30
  /** Canonical system-repo location (post-fold). */
@@ -1688,6 +1692,103 @@ function migrateSplitDeviceLocalMeta() {
1688
1692
  console.error('Split agents.yaml: agents: -> devices/, versions: -> .history/version-resources.json');
1689
1693
  }
1690
1694
  }
1695
+ /**
1696
+ * Move the auto-detected `default` browser profile OUT of the committed central
1697
+ * agents.yaml and into this machine's per-device file.
1698
+ *
1699
+ * `browser` is a CENTRAL key because named profiles a user creates are real fleet
1700
+ * config, but the ONE `default` entry inside it is machine-local: its `binary` is
1701
+ * an OS-specific path and its endpoint is a locally-chosen free port.
1702
+ * `createProfile`/`updateProfile` already route that entry to `deviceBrowser`
1703
+ * (`isMachineLocalProfile`, browser/profiles.ts) — but nothing ever removed the
1704
+ * copy older versions had already written into the shared file, and
1705
+ * `serializeCentral` cannot: it deletes whole KEYS that are device-scoped, and
1706
+ * `browser` is not one.
1707
+ *
1708
+ * So the entry sat in the committed file and every box rewrote it with its own
1709
+ * browser. Measured 2026-08-13, all three boxes on 1.22.38 (which HAS the writer
1710
+ * fix) with an empty `deviceBrowser` and a machine-specific `default` in central:
1711
+ *
1712
+ * zion browser: chrome binary: /Applications/Google Chrome.app/...
1713
+ * yosemite-s1 browser: brave binary: /opt/brave.com/brave/brave
1714
+ * mark-1 (same shape)
1715
+ *
1716
+ * `agents repos pull user` refuses when an incoming change touches a locally
1717
+ * modified path, so agents.yaml being permanently dirty wedged the fleet config
1718
+ * sync outright — those boxes sat 5, 8 and 79 commits behind. (RUSH-2161)
1719
+ *
1720
+ * Idempotent: no-op once central carries no `default` entry. The device file is
1721
+ * written FIRST so a crash between the two writes can never lose the profile,
1722
+ * and an entry already in the device file wins (this machine's live value is
1723
+ * newer than the stale central copy by construction).
1724
+ *
1725
+ * Central is edited through a `yaml.Document` rather than re-stringified, so the
1726
+ * hand-written comments in the committed agents.yaml survive — a plain
1727
+ * `yaml.stringify` would drop every one of them and rewrite the whole file,
1728
+ * which is the same churn this migration exists to stop (see `serializeCentral`).
1729
+ */
1730
+ export function migrateMachineLocalBrowserProfileOutOfCentral(userDir = USER_DIR, machine = machineId()) {
1731
+ const metaFile = path.join(userDir, 'agents.yaml');
1732
+ if (!fs.existsSync(metaFile))
1733
+ return;
1734
+ let doc;
1735
+ try {
1736
+ doc = yaml.parseDocument(fs.readFileSync(metaFile, 'utf-8'));
1737
+ }
1738
+ catch {
1739
+ return;
1740
+ }
1741
+ if (doc.errors.length > 0)
1742
+ return;
1743
+ const central = doc.toJSON() ?? {};
1744
+ const browser = central.browser;
1745
+ if (!browser || typeof browser !== 'object' || Array.isArray(browser))
1746
+ return;
1747
+ const entry = browser[DEFAULT_BROWSER_PROFILE_NAME];
1748
+ if (entry === undefined)
1749
+ return;
1750
+ // Device file first — a crash before central is rewritten leaves a harmless
1751
+ // duplicate, while the reverse order would drop the profile entirely.
1752
+ const devicePath = path.join(userDir, 'devices', machine, 'agents.yaml');
1753
+ let deviceDoc = {};
1754
+ try {
1755
+ deviceDoc = yaml.parse(fs.readFileSync(devicePath, 'utf-8')) || {};
1756
+ }
1757
+ catch { /* absent — first write */ }
1758
+ const deviceBrowser = (deviceDoc.browser && typeof deviceDoc.browser === 'object' && !Array.isArray(deviceDoc.browser))
1759
+ ? deviceDoc.browser
1760
+ : {};
1761
+ if (deviceBrowser[DEFAULT_BROWSER_PROFILE_NAME] === undefined) {
1762
+ deviceBrowser[DEFAULT_BROWSER_PROFILE_NAME] = entry;
1763
+ deviceDoc.browser = deviceBrowser;
1764
+ fs.mkdirSync(path.dirname(devicePath), { recursive: true });
1765
+ atomicWriteFileSync(devicePath, DEVICE_META_HEADER + yaml.stringify(deviceDoc));
1766
+ }
1767
+ // Then strip it from the synced file, dropping `browser:` entirely when the
1768
+ // machine-local entry was its only member.
1769
+ doc.deleteIn(['browser', DEFAULT_BROWSER_PROFILE_NAME]);
1770
+ if (Object.keys(browser).length === 1) {
1771
+ const itemsOf = () => (doc.contents?.items) ?? [];
1772
+ const idx = itemsOf().findIndex((pair) => pair.key?.value === 'browser');
1773
+ const orphaned = idx >= 0 ? itemsOf()[idx]?.key?.commentBefore ?? undefined : undefined;
1774
+ doc.delete('browser');
1775
+ if (orphaned) {
1776
+ // Deleting shifted the following pair down into `idx`.
1777
+ const next = itemsOf()[idx]?.key;
1778
+ if (next)
1779
+ next.commentBefore = next.commentBefore ? `${orphaned}\n${next.commentBefore}` : orphaned;
1780
+ else
1781
+ doc.commentBefore = orphaned;
1782
+ }
1783
+ }
1784
+ // Everything cleared -> header only, never a bare `{}`. stringifyDoc emits a
1785
+ // FLOW empty map for an empty root, and a later parseDocument inherits that
1786
+ // flow and renders the whole rewritten file inline. serializeCentral guards
1787
+ // the identical case (state.ts, `isEmpty ? META_HEADER : stringifyDoc(doc)`).
1788
+ const remaining = Object.keys(doc.toJSON() ?? {}).length;
1789
+ atomicWriteFileSync(metaFile, remaining === 0 ? DEVICE_META_HEADER : stringifyDoc(doc));
1790
+ console.error(`Migrated agents.yaml: browser '${DEFAULT_BROWSER_PROFILE_NAME}' profile -> devices/${machine}/agents.yaml`);
1791
+ }
1691
1792
  /**
1692
1793
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
1693
1794
  * inside every installed agent version-home, and rewrite cross-references in
@@ -2299,6 +2400,12 @@ export async function runMigration() {
2299
2400
  // agents.yaml. After migrateVersionResourcesToPatterns so versions: is already
2300
2401
  // in pattern form when it moves to the history file.
2301
2402
  migrateSplitDeviceLocalMeta();
2403
+ // Same split, one level deeper: `browser` stays central (named profiles are
2404
+ // fleet config) but its auto-detected `default` entry is machine-local and was
2405
+ // left behind in the synced file, keeping every box dirty. After
2406
+ // migrateSplitDeviceLocalMeta so the device file is already in its canonical
2407
+ // location before this merges an entry into it.
2408
+ migrateMachineLocalBrowserProfileOutOfCentral();
2302
2409
  // Fold per-device operator config (device-doc config:/defaultBrowserProfile
2303
2410
  // and .history/devices/auto-launch.json) into the central
2304
2411
  // fleet.devices.<name>.config block. After migrateSplitDeviceLocalMeta so the
@@ -25,14 +25,32 @@ export declare function affinityWeights(rows: AffinityRow[], alpha?: number): We
25
25
  * Pure — inject `rng` for tests.
26
26
  */
27
27
  export declare function sampleWeighted(candidates: WeightedCandidate[], rng?: () => number): string | null;
28
- /** Online device names from the local registry (+ always include local). */
28
+ /**
29
+ * Online device names from the local registry (+ local), narrowed to the
30
+ * automatic-placement pool.
31
+ *
32
+ * The pool rule lives in `devices/pool.ts` and is an allowlist once any device
33
+ * is marked `role=worker`: this is the single place both automatic-placement
34
+ * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
35
+ * marking workers moves every `--device auto` at once instead of one surface.
36
+ *
37
+ * Paired cockpits (`role: control` in the device registry) are dropped here,
38
+ * where the registry is already being read — they are control surfaces, not
39
+ * compute. It CAN return an empty list — a fleet where every marked worker is
40
+ * offline, or where this box is the only candidate and is marked `personal`. That is a
41
+ * real answer, and both callers fail loud on it rather than falling back to the
42
+ * local machine (which would be the exact box the operator marked personal to
43
+ * keep agents off).
44
+ */
29
45
  export declare function listOnlineDeviceNames(localName?: string): string[];
30
46
  export interface DeviceAffinityOptions {
31
47
  sinceDays?: number;
32
48
  alpha?: number;
33
49
  /**
34
- * Eligible hosts (normalized). Defaults to online devices + local.
35
- * Empty after filter fall back to local.
50
+ * Eligible hosts (normalized). Defaults to the automatic-placement pool
51
+ * (online devices + local, narrowed by device roles). An explicitly empty
52
+ * list falls back to local — the caller supplied it; an empty DEFAULT pool
53
+ * throws, because roles emptied it on purpose.
36
54
  */
37
55
  eligibleHosts?: string[];
38
56
  localMachine?: string;
@@ -59,6 +77,12 @@ export interface DeviceAutoPlan {
59
77
  }>;
60
78
  pickedDeviceKey: string;
61
79
  }
80
+ /**
81
+ * The error both automatic-placement resolvers raise when device roles leave no
82
+ * candidate at all. Fail loud: the alternative — quietly running on the local
83
+ * machine — puts the agent on the box the operator marked `personal`.
84
+ */
85
+ export declare function formatEmptyAutoPoolError(): string;
62
86
  export declare function formatNoHealthyDeviceError(pool: string[], signals: Map<string, DevicePlacementSignal>, agent?: string): string;
63
87
  /**
64
88
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -74,6 +98,12 @@ export declare function resolveDeviceAuto(agent?: string, opts?: {
74
98
  }): Promise<DeviceAutoPlan>;
75
99
  /**
76
100
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
101
+ *
102
+ * Draws from the same automatic-placement pool as {@link resolveDeviceAuto}, so
103
+ * `agents ssh auto`, the generic `--host auto` passthrough, and `matchHost`'s
104
+ * `auto` sentinel honour device roles too. Throws when roles leave the pool
105
+ * empty — a `null` host here means "run locally", which for a box marked
106
+ * `personal` is the outcome the mark exists to prevent.
77
107
  */
78
108
  export declare function resolveDeviceAffinity(opts?: DeviceAffinityOptions): DeviceAffinityPlan;
79
109
  /** True when a host flag value means affinity pick. */