@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.39
4
+
5
+ - **The auto-detected `default` browser profile no longer sits in the fleet-shared `agents.yaml`, which was wedging `agents repos pull user` fleet-wide (RUSH-2161).** `browser` is a central key because named profiles a user creates are real fleet config, but the one `default` entry inside it is machine-local: its `binary` is an OS-specific path and its endpoint is a locally chosen free port. `createProfile`/`updateProfile` have routed that entry to the per-device file since 1.22.38 (`isMachineLocalProfile`), but nothing removed the copy older versions had already written into the shared file, and `serializeCentral` could not — it deletes whole device-scoped KEYS, and `browser` is not one. So the stale entry stayed committed and every box rewrote it with its own browser. Measured across three boxes all running 1.22.38: zion `chrome` + `/Applications/Google Chrome.app/...`, yosemite-s1 `brave` + `/opt/brave.com/brave/brave`, mark-1 the same shape, each with an empty `deviceBrowser`. Because the pull refuses when an incoming change touches a locally modified path, a permanently dirty `agents.yaml` blocked fleet config sync outright — those boxes sat 5, 8 and 79 commits behind, so merged config (a `notify.owner` block, device roles) reached zero machines. A new migration moves the entry into `devices/<machine>/agents.yaml` and deletes it from central, writing the device file first so a crash cannot lose the profile and keeping an existing device entry when one is already there. Central is edited through a YAML `Document`, so the hand-written comments in the committed file survive rather than being flattened by a re-stringify — which would have re-created the same churn. Named profiles are untouched. Source: `apps/cli/src/lib/migrate.ts`, `apps/cli/src/bootstrap.ts`.
6
+
7
+ - **`agents config set devices.<name>.tmux off` turns the interactive tmux wrap off for one machine (RUSH-2620).** Interactive `agents run` wraps the harness in the shared-socket tmux session so every agent gets an addressable `%pane` — that is what lets `agents sessions --active` tell co-located agents apart and `agents focus` re-attach without forking. Until now the only ways out were per-run (`--raw` / `--no-tmux` / `--disable-tmux`) or the `AGENTS_NO_TMUX=1` env var, so a box whose tmux is broken needed the flag retyped on every launch. The new key is machine-local by design (a broken tmux is a property of one machine): it never enters the fleet-shared `agents.yaml` and is refused for a peer. Unset still means wrap. `agents devices config <name> tmux.enabled off` sets the same key. Source: `apps/cli/src/lib/device-config.ts` (`isTmuxEnabled`), `apps/cli/src/lib/exec.ts` (`shouldWrapInTmux`).
8
+
9
+ - **`agents devices role <name> worker` marks which boxes agents run on, and `--device auto` follows it.** Roles are fleet-wide (`worker` / `personal`), stored in the shared `fleet.devices.<name>.config.role` block of `~/.agents/agents.yaml`, so a mark set on any box travels with `agents repo push/pull` — the per-device files under `~/.agents/devices/` are written only by the machine they name, so they could never carry a fleet-wide statement about a different box. Marking ANY device `worker` turns automatic placement into an allowlist: `agents run --device auto`, `agents teams add --device auto`, and the AGI EXT launch commands then pick only from the marked workers. A device marked `personal` (a machine you sit at) is never picked automatically, under any mode, and a paired cockpit stays excluded through its existing registry `control` role. When roles leave the pool empty, `--device auto` fails loud naming the fix instead of quietly running on the local machine — through `agents ssh auto` and the `--host auto` passthrough too, not just `agents run`. Nothing marked = today's behavior, every online device. Widen it back with `agents config set auto.pool all`. `agents devices role` with no arguments prints who is marked what and exactly which devices `--device auto` would consider; `agents devices list` tags marked rows and `--json` carries `role` plus an `autoPool` boolean per device. Source: `apps/cli/src/lib/devices/pool.ts`, `apps/cli/src/lib/device-config.ts`, `apps/cli/src/lib/smart-launch.ts`.
10
+
3
11
  ## 1.22.38
4
12
 
5
13
  - Webhook `stateTo` triggers and handlers now fire only on the delivery that actually moved a Linear issue INTO that state, not on every later update while it still sits there. Previously a `stateTo: Plan` handler re-matched on any subsequent `Issue/update` — a label edit, an assignee change, a description touch — because it checked the issue's *current* state instead of the transition, which accumulated 11 duplicate plan comments on one issue. It now additionally requires the delivery's `updatedFrom` to record a state change. (RUSH-2539)
package/dist/bin/agents CHANGED
Binary file
package/dist/bootstrap.js CHANGED
@@ -1095,7 +1095,7 @@ const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help', 'uninstall']);
1095
1095
  //
1096
1096
  // Skipped for --help/--version (RUSH-2454): pure documentation paths must not
1097
1097
  // load any migration graph. Loaded from migrate-fold.js (leaf: fs + createLink),
1098
- // not migrate.js, so a real command pays only the fold hop unless the v19
1098
+ // not migrate.js, so a real command pays only the fold hop unless the v20
1099
1099
  // sentinel is missing and runMigration() is required below.
1100
1100
  if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !helpOrVersionRequested) {
1101
1101
  try {
@@ -1130,7 +1130,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !helpOrVersionRequested) {
1130
1130
  // Bumping the suffix re-runs migrations for every user; binary releases that
1131
1131
  // don't change the schema must NOT re-run (they would destroy user content
1132
1132
  // when migration steps overlap with user-authored paths). See issue #20.
1133
- const sentinelValue = 'v19';
1133
+ const sentinelValue = 'v20';
1134
1134
  let needRun = true;
1135
1135
  try {
1136
1136
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -41,12 +41,17 @@ function parseValue(key, parsed, raw) {
41
41
  return raw.trim();
42
42
  case 'usage':
43
43
  return raw.trim();
44
+ case 'auto':
45
+ return raw.trim();
44
46
  case 'browser':
45
47
  return raw.trim();
46
48
  case 'project':
47
49
  return raw.trim();
48
50
  case 'device': {
49
- switch (parsed.property) {
51
+ const property = parsed.property;
52
+ switch (property) {
53
+ case 'role':
54
+ return raw.trim();
50
55
  case 'max-agents':
51
56
  if (!/^\d+$/.test(raw.trim())) {
52
57
  throw new Error(`Config key '${key}' expects an integer, got '${raw}'.`);
@@ -55,6 +60,7 @@ function parseValue(key, parsed, raw) {
55
60
  case 'scheduler':
56
61
  case 'daemon':
57
62
  case 'watchdog':
63
+ case 'tmux':
58
64
  case 'browser.remote-control':
59
65
  return parseBool(raw, key);
60
66
  case 'notes':
@@ -62,6 +68,13 @@ function parseValue(key, parsed, raw) {
62
68
  case 'browser.profile':
63
69
  return raw.trim();
64
70
  }
71
+ // A device property with no arm above used to fall out of the switch and
72
+ // return `undefined`, so the write failed downstream with "expects a
73
+ // boolean, got undefined" instead of naming the real gap. The `never`
74
+ // binding makes adding a DeviceConfigProperty without a parse rule a
75
+ // compile error rather than a runtime mystery.
76
+ const unhandled = property;
77
+ throw new Error(`Config key '${key}' has no parse rule for device property '${String(unhandled)}'.`);
65
78
  }
66
79
  }
67
80
  }
@@ -93,6 +106,10 @@ function setConfig(parsed, value) {
93
106
  setConfigValue('usage.primary-host', value);
94
107
  return;
95
108
  }
109
+ case 'auto': {
110
+ setConfigValue('auto.pool', value);
111
+ return;
112
+ }
96
113
  case 'browser': {
97
114
  // Device-local default lives in the central fleet.devices.<name>.config
98
115
  // block (same store `agents devices config` / getConfigValue use). Bare
@@ -137,6 +154,11 @@ function unsetConfig(parsed) {
137
154
  unsetConfigValue('usage.primary-host');
138
155
  return had;
139
156
  }
157
+ case 'auto': {
158
+ const had = getConfigValue('auto.pool').value !== undefined;
159
+ unsetConfigValue('auto.pool');
160
+ return had;
161
+ }
140
162
  case 'browser': {
141
163
  const target = parsed.device ? { device: parsed.device } : undefined;
142
164
  const had = getConfigValue('browser.profile', target).value !== undefined;
@@ -173,6 +195,8 @@ function getConfig(parsed) {
173
195
  return getConfigValue('interactive.host').value;
174
196
  case 'usage':
175
197
  return getConfigValue('usage.primary-host').value;
198
+ case 'auto':
199
+ return getConfigValue('auto.pool').value;
176
200
  case 'browser': {
177
201
  return getConfigValue('browser.profile', parsed.device ? { device: parsed.device } : undefined).value;
178
202
  }
@@ -231,6 +255,9 @@ function* listCentralConfigEntries() {
231
255
  if (meta.config?.usagePrimaryHost !== undefined) {
232
256
  yield { key: 'usage.primary-host', value: meta.config.usagePrimaryHost, hint: 'config.usagePrimaryHost' };
233
257
  }
258
+ if (meta.config?.autoPool !== undefined) {
259
+ yield { key: 'auto.pool', value: meta.config.autoPool, hint: 'config.autoPool' };
260
+ }
234
261
  if (meta.projectRoot !== undefined) {
235
262
  yield { key: 'project.root', value: meta.projectRoot, hint: 'devices.<self>.projectRoot' };
236
263
  }
@@ -262,6 +289,9 @@ function* listDeviceConfigEntries(device) {
262
289
  case 'watchdog.enabled':
263
290
  key = `${prefix}watchdog`;
264
291
  break;
292
+ case 'tmux.enabled':
293
+ key = `${prefix}tmux`;
294
+ break;
265
295
  case 'browser.remote-control':
266
296
  key = `${prefix}browser.remote-control`;
267
297
  break;
@@ -557,7 +557,7 @@ export function registerRunCommand(program) {
557
557
  .option('--terminal [backend]', "Open this run in a real terminal tab instead of here. Without a value the terminal is detected from your live sessions (`agents sessions --active` host), so it lands where you already work — Ghostty for a Ghostty user, iTerm for an iTerm user. Name one to force it: iterm | ghostty | terminal | tmux | vscodium-agent. This is how the menu bar's New Session opens.")
558
558
  .option('--verbose', 'Show detailed execution logs')
559
559
  .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
560
- .option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing.')
560
+ .option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing; to turn the wrap off for every run on this machine, set `agents config set devices.<name>.tmux off`.')
561
561
  .option('--disable-tmux', 'Alias for --no-tmux.')
562
562
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
563
563
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,antigravity). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
@@ -56,7 +56,8 @@ import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js'
56
56
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort, registerLeaseCommand } from './lease.js';
57
57
  import { authCellColor, formatCheckedAge, isDeadVerdict, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
58
58
  import { runFleetLogin } from '../lib/fleet/remote-login.js';
59
- import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec } from '../lib/device-config.js';
59
+ import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
60
+ import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
60
61
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
61
62
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
62
63
  * command is running on so it stands out from the rest of the tailnet.
@@ -77,7 +78,20 @@ function deviceSummary(d, isSelf = false, stats, isInteractive = false) {
77
78
  const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
78
79
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
79
80
  const interactive = isInteractive ? chalk.yellow(' ★ interactive') : '';
80
- return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}`;
81
+ const role = roleTag(d.name, listConfiguredDeviceRoles());
82
+ return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}${role}`;
83
+ }
84
+ /** The fleet-wide role mark, rendered for a device row. Empty when unmarked —
85
+ * an unmarked device is the common case and must not add a column of noise. */
86
+ function roleTag(name, roles) {
87
+ const role = roles[name];
88
+ if (!role)
89
+ return '';
90
+ if (role === 'worker')
91
+ return chalk.green(' worker');
92
+ if (role === 'personal')
93
+ return chalk.yellow(' personal');
94
+ return chalk.gray(' control');
81
95
  }
82
96
  const HEADROOM_BADGE = {
83
97
  idle: chalk.green('○ idle'),
@@ -107,6 +121,7 @@ function pctCell(v, width) {
107
121
  function renderDeviceTable(reg, names, self, statsMap, full = false, interactiveHost) {
108
122
  if (!statsMap)
109
123
  return names.map((n) => deviceSummary(reg[n], n === self, undefined, n === interactiveHost));
124
+ const deviceRoles = listConfiguredDeviceRoles();
110
125
  const lines = [];
111
126
  const head = ' ' +
112
127
  chalk.gray('device'.padEnd(16)) +
@@ -149,7 +164,7 @@ function renderDeviceTable(reg, names, self, statsMap, full = false, interactive
149
164
  const badge = HEADROOM_BADGE[headroom(stats)];
150
165
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
151
166
  const interactive = name === interactiveHost ? chalk.yellow(' ★ interactive') : '';
152
- lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}`);
167
+ lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}${roleTag(name, deviceRoles)}`);
153
168
  }
154
169
  // Fleet capacity summary — total cores + how much RAM is free right now.
155
170
  const cap = fleetCapacity(statsMap.values());
@@ -1161,6 +1176,84 @@ function registerDevicesCommands(program) {
1161
1176
  }
1162
1177
  await runDevicesConfigMenu(name);
1163
1178
  };
1179
+ /**
1180
+ * The `devices role` engine — read or write the fleet-wide role mark, and say
1181
+ * what it does to automatic placement.
1182
+ *
1183
+ * A role written here lands in the SHARED block (`fleet.devices.<name>.config.role`)
1184
+ * because every box has to agree on it. The vocabulary is deliberately
1185
+ * `worker | personal` only: a paired cockpit's `control` role lives in the
1186
+ * per-machine device registry, is written by `agents devices pair-ios`, and is
1187
+ * what the existing dial-exclusion filters (`isControlDevice`) read. Accepting
1188
+ * `control` here too would promise a fleet-wide dial exclusion this key cannot
1189
+ * deliver — those filters read each box's own registry, so the mark would only
1190
+ * hold on the machine that ran the command.
1191
+ */
1192
+ const runDevicesRole = async (name, role, opts) => {
1193
+ if (!name) {
1194
+ if (role)
1195
+ throw new Error('Name a device: agents devices role <name> <worker|personal>');
1196
+ const roles = listConfiguredDeviceRoles();
1197
+ const mode = autoPoolMode();
1198
+ const reg = await loadDevices();
1199
+ const online = Object.entries(reg)
1200
+ .filter(([, d]) => d?.tailscale?.online !== false)
1201
+ .map(([n]) => n);
1202
+ const pool = filterAutoPool(online, { mode, roles });
1203
+ if (opts.json) {
1204
+ writeJson({ mode, roles, autoPool: pool });
1205
+ return;
1206
+ }
1207
+ const marked = Object.entries(roles);
1208
+ if (marked.length === 0) {
1209
+ console.log(chalk.gray('No device is marked. `--device auto` considers every online device.'));
1210
+ }
1211
+ else {
1212
+ for (const [device, r] of marked) {
1213
+ const tint = r === 'worker' ? chalk.green : r === 'personal' ? chalk.yellow : chalk.gray;
1214
+ console.log(` ${device.padEnd(20)} ${tint(r)}`);
1215
+ }
1216
+ }
1217
+ console.log();
1218
+ console.log(chalk.bold('--device auto picks from: ') + (pool.length > 0 ? pool.join(', ') : chalk.red('nothing — no eligible device')));
1219
+ if (mode === 'all')
1220
+ console.log(chalk.gray('auto.pool=all — worker marks are ignored (a personal device is still excluded).'));
1221
+ return;
1222
+ }
1223
+ await mustGetDevice(name);
1224
+ if (opts.clear || role === 'none') {
1225
+ setConfiguredDeviceRole(name, undefined);
1226
+ if (opts.json)
1227
+ writeJson({ device: name, role: null });
1228
+ else
1229
+ console.log(chalk.green(`Cleared the role on '${name}'.`));
1230
+ return;
1231
+ }
1232
+ if (!role) {
1233
+ const current = configuredDeviceRole(name);
1234
+ if (opts.json)
1235
+ writeJson({ device: name, role: current ?? null });
1236
+ else
1237
+ console.log(` ${name.padEnd(20)} ${current ? chalk.cyan(current) : chalk.gray('— (unmarked)')}`);
1238
+ return;
1239
+ }
1240
+ // configuredDeviceRole's key spec validates the value; a bad one throws with
1241
+ // the accepted list, which the command's catch turns into exit 1.
1242
+ setConfiguredDeviceRole(name, role);
1243
+ if (opts.json) {
1244
+ writeJson({ device: name, role, autoPoolWorkers: listWorkerDevices() });
1245
+ return;
1246
+ }
1247
+ console.log(chalk.green(`Marked '${name}' role=${role}.`));
1248
+ const workers = listWorkerDevices();
1249
+ if (workers.length > 0) {
1250
+ console.log(chalk.gray(`\`--device auto\` now picks only from: ${workers.join(', ')}`));
1251
+ }
1252
+ else {
1253
+ console.log(chalk.gray('No device is marked worker, so `--device auto` still considers every online device.'));
1254
+ }
1255
+ console.log(chalk.gray('Sync it to the fleet with `agents repo push`.'));
1256
+ };
1164
1257
  /** The interactive settings menu: pick a key, edit it, repeat. TTY-only. */
1165
1258
  const runDevicesConfigMenu = async (name) => {
1166
1259
  const { select, input, confirm } = await import('@inquirer/prompts');
@@ -1250,22 +1343,27 @@ function registerDevicesCommands(program) {
1250
1343
  agents devices config win-mini ssh.auth password # password auth…
1251
1344
  agents devices config win-mini ssh.bundle muqsit # …from this secrets bundle
1252
1345
  agents devices config worker ssh.identity-file ~/.ssh/worker_ed25519
1346
+ agents devices config mac-mini role worker # same as \`agents devices role mac-mini worker\`
1253
1347
  agents devices config mac-mini auto-launch.enabled off # exclude from AGI EXT auto-launch
1254
1348
  agents devices config mac-mini auto-launch.preferred on # boost in auto-launch ranking
1255
1349
  agents devices config zion interactive.host zion # user scope: where agents show YOU artifacts
1256
1350
  agents devices config mac-mini --json # machine-readable
1257
1351
  `,
1258
1352
  notes: `
1259
- Keys: agents.max-concurrent, scheduler.enabled, daemon.enabled,
1260
- watchdog.enabled, browser.remote-control, browser.profile, notes,
1261
- ssh.user, ssh.auth (key|password), ssh.bundle, ssh.bundle-key,
1353
+ Keys: role (worker|personal), see 'agents devices role',
1354
+ agents.max-concurrent, scheduler.enabled, daemon.enabled,
1355
+ watchdog.enabled, tmux.enabled, browser.remote-control, browser.profile,
1356
+ notes, ssh.user, ssh.auth (key|password), ssh.bundle, ssh.bundle-key,
1262
1357
  ssh.identity-file, platform (windows|linux|macos|unknown),
1263
1358
  auto-launch.enabled, auto-launch.preferred — plus the user-scope
1264
1359
  interactive.host (stored centrally; the device name is syntax only).
1265
1360
 
1266
1361
  Booleans take on/off (or true/false). 'notes' appends one entry per
1267
- invocation. Values land in ~/.agents/agents.yaml under
1268
- fleet.devices.<name>.config and sync with 'agents repo push/pull'.
1362
+ invocation. Values a PEER reads land in ~/.agents/agents.yaml under
1363
+ fleet.devices.<name>.config and sync with 'agents repo push/pull'. The
1364
+ keys only the owning box reads — scheduler.enabled, daemon.enabled,
1365
+ tmux.enabled, browser.remote-control, browser.profile — stay in that
1366
+ machine's own doc, never sync, and can only be set on the device itself.
1269
1367
  ssh.* / platform / user overlay the discovered registry profile at dial
1270
1368
  time. scheduler.enabled / daemon.enabled take effect when the daemon
1271
1369
  reloads or restarts on that device.
@@ -1274,6 +1372,49 @@ function registerDevicesCommands(program) {
1274
1372
  set, set-interactive, enable, disable, prefer, unprefer.
1275
1373
  `,
1276
1374
  });
1375
+ const roleCmd = devicesCmd
1376
+ .command('role [name] [role]')
1377
+ .description('Show or set what a device is for: worker (agents run here) or personal (you sit here — never picked automatically). ' +
1378
+ 'Marking any device worker makes `--device auto` an allowlist over the marked workers.')
1379
+ .option('--clear', 'remove the mark, returning the device to unmarked')
1380
+ .option('--json', 'output machine-readable JSON')
1381
+ .action(async (name, role, opts) => {
1382
+ try {
1383
+ await runDevicesRole(name, role, opts);
1384
+ }
1385
+ catch (err) {
1386
+ console.error(chalk.red(err.message));
1387
+ process.exit(1);
1388
+ }
1389
+ });
1390
+ setHelpSections(roleCmd, {
1391
+ examples: `
1392
+ agents devices role # who is marked what, and what --device auto would pick
1393
+ agents devices role yosemite-s0 worker # agents spin up here
1394
+ agents devices role yosemite-s1 worker # …and here; auto now rotates over these two only
1395
+ agents devices role zion personal # your laptop — keep automatic placement off it
1396
+ agents devices role yosemite-s0 --clear # unmark
1397
+ agents devices role --json # machine-readable
1398
+ `,
1399
+ notes: `
1400
+ Roles are stored fleet-wide in ~/.agents/agents.yaml under
1401
+ fleet.devices.<name>.config.role and travel with 'agents repo push/pull',
1402
+ so a mark set on one box is the whole fleet's answer.
1403
+
1404
+ Effect on '--device auto' (agents run, teams, agents ssh auto, and the AGI
1405
+ EXT launch commands, which all resolve placement through the CLI):
1406
+ no device marked -> every online device, as before
1407
+ any worker marked -> ONLY the marked workers
1408
+ personal -> never picked, under either state
1409
+
1410
+ Turn the allowlist off with 'agents config set auto.pool all'; a personal
1411
+ box stays excluded, since that is what the mark is for.
1412
+
1413
+ A paired iPhone/iPad cockpit is a separate role: 'agents devices pair-ios'
1414
+ marks it control in that box's device registry, and the fleet never dials
1415
+ it — including for placement. This command does not set that role.
1416
+ `,
1417
+ });
1277
1418
  /** Deprecation notice for a retired subcommand — STDERR only, so a --json consumer's stdout stays parseable. */
1278
1419
  const configTombstoneNotice = (retired, replacement) => {
1279
1420
  console.error(chalk.yellow(`Deprecated: "agents devices ${retired}" is now "agents devices ${replacement}". Running that for you.\n`));
@@ -1493,12 +1634,19 @@ function registerDevicesCommands(program) {
1493
1634
  await writeReachability(collectReachabilityWriteBacks(reg, statsMap)).catch(() => { });
1494
1635
  }
1495
1636
  if (opts.json) {
1637
+ const jsonRoles = listConfiguredDeviceRoles();
1638
+ const autoPool = new Set(filterAutoPool(names, { roles: jsonRoles }));
1496
1639
  process.stdout.write(JSON.stringify(names.map((name) => {
1497
1640
  const config = deviceConfigJson(name);
1498
1641
  const health = statsMap?.get(name);
1499
1642
  return {
1500
1643
  ...resolveDeviceProfile(reg[name]),
1501
1644
  interactive: name === interactiveHost,
1645
+ // Roles as machine-readable fields: `role` is what the operator
1646
+ // marked (absent when unmarked), `autoPool` is the answer that
1647
+ // matters to a caller — may `--device auto` pick this box.
1648
+ ...(jsonRoles[name] ? { role: jsonRoles[name] } : {}),
1649
+ autoPool: autoPool.has(name),
1502
1650
  ...(config ? { config } : {}),
1503
1651
  ...(health ? { health: { ...health, headroom: headroom(health) } } : {}),
1504
1652
  };
@@ -9,7 +9,7 @@
9
9
  import type { AgentId } from './types.js';
10
10
  import { type ModelTier } from './model-tiers.js';
11
11
  /** The top-level scope of a unified config key. */
12
- export type ConfigScope = 'run' | 'interactive' | 'usage' | 'browser' | 'project' | 'device';
12
+ export type ConfigScope = 'run' | 'interactive' | 'usage' | 'auto' | 'browser' | 'project' | 'device';
13
13
  /** A run-time default key: model, mode, effort, or tier override. */
14
14
  export interface ParsedRunConfigKey {
15
15
  scope: 'run';
@@ -28,6 +28,11 @@ export interface ParsedUsageConfigKey {
28
28
  scope: 'usage';
29
29
  property: 'primary-host';
30
30
  }
31
+ /** Which devices automatic placement (`--device auto`) may pick. */
32
+ export interface ParsedAutoConfigKey {
33
+ scope: 'auto';
34
+ property: 'pool';
35
+ }
31
36
  /** The default browser profile (device-scope, self or peer). */
32
37
  export interface ParsedBrowserConfigKey {
33
38
  scope: 'browser';
@@ -44,8 +49,8 @@ export interface ParsedDeviceConfigKey {
44
49
  device: string;
45
50
  property: DeviceConfigProperty;
46
51
  }
47
- export type ParsedConfigKey = ParsedRunConfigKey | ParsedInteractiveConfigKey | ParsedUsageConfigKey | ParsedBrowserConfigKey | ParsedProjectConfigKey | ParsedDeviceConfigKey;
48
- export type DeviceConfigProperty = 'max-agents' | 'scheduler' | 'daemon' | 'watchdog' | 'browser.remote-control' | 'notes' | 'browser.profile';
52
+ export type ParsedConfigKey = ParsedRunConfigKey | ParsedInteractiveConfigKey | ParsedUsageConfigKey | ParsedAutoConfigKey | ParsedBrowserConfigKey | ParsedProjectConfigKey | ParsedDeviceConfigKey;
53
+ export type DeviceConfigProperty = 'role' | 'max-agents' | 'scheduler' | 'daemon' | 'watchdog' | 'tmux' | 'browser.remote-control' | 'notes' | 'browser.profile';
49
54
  /** Normalize agent@version to use `@` consistently. */
50
55
  export declare function formatAgentVersion(agent: AgentId, version: string): string;
51
56
  /**
@@ -58,12 +63,15 @@ export declare function formatAgentVersion(agent: AgentId, version: string): str
58
63
  * run.<agent@version>.tier.<cheap|default|best|ultra>
59
64
  * interactive.host
60
65
  * usage.primary-host
66
+ * auto.pool
61
67
  * browser.profile
62
68
  * project.root
69
+ * devices.<name>.role
63
70
  * devices.<name>.max-agents
64
71
  * devices.<name>.scheduler
65
72
  * devices.<name>.daemon
66
73
  * devices.<name>.watchdog
74
+ * devices.<name>.tmux
67
75
  * devices.<name>.browser.remote-control
68
76
  * devices.<name>.notes
69
77
  * devices.<name>.browser.profile
@@ -10,10 +10,12 @@ import { AGENTS } from './agents.js';
10
10
  import { MODEL_TIERS } from './model-tiers.js';
11
11
  import { VERSION_RE } from './run-defaults.js';
12
12
  const DEVICE_CONFIG_PROPERTIES = [
13
+ 'role',
13
14
  'max-agents',
14
15
  'scheduler',
15
16
  'daemon',
16
17
  'watchdog',
18
+ 'tmux',
17
19
  'browser.remote-control',
18
20
  'notes',
19
21
  'browser.profile',
@@ -45,12 +47,15 @@ export function formatAgentVersion(agent, version) {
45
47
  * run.<agent@version>.tier.<cheap|default|best|ultra>
46
48
  * interactive.host
47
49
  * usage.primary-host
50
+ * auto.pool
48
51
  * browser.profile
49
52
  * project.root
53
+ * devices.<name>.role
50
54
  * devices.<name>.max-agents
51
55
  * devices.<name>.scheduler
52
56
  * devices.<name>.daemon
53
57
  * devices.<name>.watchdog
58
+ * devices.<name>.tmux
54
59
  * devices.<name>.browser.remote-control
55
60
  * devices.<name>.notes
56
61
  * devices.<name>.browser.profile
@@ -75,13 +80,16 @@ export function parseConfigKey(key) {
75
80
  if (raw === 'usage.primary-host') {
76
81
  return { scope: 'usage', property: 'primary-host' };
77
82
  }
83
+ if (raw === 'auto.pool') {
84
+ return { scope: 'auto', property: 'pool' };
85
+ }
78
86
  if (raw === 'browser.profile') {
79
87
  return { scope: 'browser', property: 'profile' };
80
88
  }
81
89
  if (raw === 'project.root') {
82
90
  return { scope: 'project', property: 'root' };
83
91
  }
84
- const deviceMatch = raw.match(/^devices\.(.+)\.(max-agents|scheduler|daemon|watchdog|notes|browser\.remote-control|browser\.profile)$/);
92
+ const deviceMatch = raw.match(/^devices\.(.+)\.(role|max-agents|scheduler|daemon|watchdog|tmux|notes|browser\.remote-control|browser\.profile)$/);
85
93
  if (deviceMatch) {
86
94
  return {
87
95
  scope: 'device',
@@ -99,6 +107,9 @@ export function parseConfigKey(key) {
99
107
  if (raw.startsWith('usage.')) {
100
108
  throw new Error(`Invalid usage config key '${key}'. Use usage.primary-host.`);
101
109
  }
110
+ if (raw.startsWith('auto.')) {
111
+ throw new Error(`Invalid auto config key '${key}'. Use auto.pool.`);
112
+ }
102
113
  if (raw.startsWith('browser.')) {
103
114
  throw new Error(`Invalid browser config key '${key}'. Use browser.profile.`);
104
115
  }
@@ -108,7 +119,7 @@ export function parseConfigKey(key) {
108
119
  if (raw.startsWith('devices.')) {
109
120
  throw new Error(`Invalid device config key '${key}'. Expected devices.<name>.<${DEVICE_CONFIG_PROPERTIES.join('|')}>.`);
110
121
  }
111
- throw new Error(`Unknown config scope in '${key}'. Use one of: run, interactive, usage, browser, project, devices.`);
122
+ throw new Error(`Unknown config scope in '${key}'. Use one of: run, interactive, usage, auto, browser, project, devices.`);
112
123
  }
113
124
  /** Render a parsed key back to its canonical dotted string. */
114
125
  export function formatConfigKey(parsed) {
@@ -122,6 +133,8 @@ export function formatConfigKey(parsed) {
122
133
  return 'interactive.host';
123
134
  case 'usage':
124
135
  return 'usage.primary-host';
136
+ case 'auto':
137
+ return 'auto.pool';
125
138
  case 'browser':
126
139
  return parsed.device ? `devices.${parsed.device}.browser.profile` : 'browser.profile';
127
140
  case 'project':
@@ -137,7 +150,7 @@ export function listKnownConfigKeys() {
137
150
  for (const tier of MODEL_TIERS) {
138
151
  keys.push(`run.<agent@version>.tier.${tier}`);
139
152
  }
140
- keys.push('interactive.host', 'usage.primary-host', 'browser.profile', 'project.root');
153
+ keys.push('interactive.host', 'usage.primary-host', 'auto.pool', 'browser.profile', 'project.root');
141
154
  for (const prop of DEVICE_CONFIG_PROPERTIES) {
142
155
  keys.push(`devices.<name>.${prop}`);
143
156
  }
@@ -150,6 +163,8 @@ export function listKnownConfigKeys() {
150
163
  */
151
164
  export function devicePropertyToConfigName(property) {
152
165
  switch (property) {
166
+ case 'role':
167
+ return 'role';
153
168
  case 'max-agents':
154
169
  return 'agents.max-concurrent';
155
170
  case 'scheduler':
@@ -158,6 +173,8 @@ export function devicePropertyToConfigName(property) {
158
173
  return 'daemon.enabled';
159
174
  case 'watchdog':
160
175
  return 'watchdog.enabled';
176
+ case 'tmux':
177
+ return 'tmux.enabled';
161
178
  case 'browser.remote-control':
162
179
  return 'browser.remote-control';
163
180
  case 'notes':
@@ -181,6 +198,8 @@ export function configKeyStorageHint(parsed) {
181
198
  return 'config.interactiveHost';
182
199
  case 'usage':
183
200
  return 'config.usagePrimaryHost';
201
+ case 'auto':
202
+ return 'config.autoPool';
184
203
  case 'browser':
185
204
  return parsed.device
186
205
  ? `fleet.devices.${parsed.device}.config.defaultBrowserProfile`
@@ -14,4 +14,5 @@ export const MACHINE_LOCAL_YAML_KEYS = new Set([
14
14
  'browserRemoteControl', // browser.remote-control — a consent flag; syncing it is a leak
15
15
  'schedulerEnabled', // scheduler.enabled
16
16
  'daemonEnabled', // daemon.enabled
17
+ 'tmuxEnabled', // tmux.enabled — whether an interactive run is tmux-wrapped on THIS box
17
18
  ]);
@@ -91,6 +91,10 @@ export interface ConfigEntry {
91
91
  export interface ConfigTarget {
92
92
  device?: string;
93
93
  }
94
+ /** Roles a device can be marked with — see the `role` key below. */
95
+ declare const DEVICE_ROLES: readonly ["worker", "personal"];
96
+ /** Which devices automatic placement may pick — see the `auto.pool` key below. */
97
+ declare const AUTO_POOL_MODES: readonly ["workers", "all"];
94
98
  export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
95
99
  /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
96
100
  export declare function configKeySpec(name: string): ConfigKeySpec;
@@ -140,6 +144,27 @@ export declare function resolveUsagePrimaryHost(): string | null;
140
144
  export declare function setConfigValue(name: string, value: unknown, opts?: ConfigTarget): void;
141
145
  /** Unset a config key — restores default behavior. No-op when already unset. */
142
146
  export declare function unsetConfigValue(name: string, opts?: ConfigTarget): void;
147
+ /** A role an operator marked a device with (`agents devices role <name> <role>`). */
148
+ export type ConfiguredDeviceRole = (typeof DEVICE_ROLES)[number];
149
+ /** Which devices automatic placement may pick (`auto.pool`). */
150
+ export type AutoPoolMode = (typeof AUTO_POOL_MODES)[number];
151
+ /**
152
+ * The role marked on one device, or undefined when the operator never marked it.
153
+ *
154
+ * Undefined is meaningful and is NOT the same as `worker`: an unmarked device is
155
+ * eligible for automatic placement only while no device anywhere carries an
156
+ * explicit `worker` mark (see {@link listConfiguredDeviceRoles}).
157
+ */
158
+ export declare function configuredDeviceRole(name: string): ConfiguredDeviceRole | undefined;
159
+ /** Mark a device's role fleet-wide; `undefined` clears the mark. */
160
+ export declare function setConfiguredDeviceRole(name: string, role: ConfiguredDeviceRole | undefined): void;
161
+ /**
162
+ * Every device an operator has marked, keyed by device name. Devices with no
163
+ * mark are absent — that absence is what makes the worker allowlist opt-in.
164
+ */
165
+ export declare function listConfiguredDeviceRoles(): Record<string, ConfiguredDeviceRole>;
166
+ /** The configured automatic-placement pool mode. Unset means `workers`. */
167
+ export declare function autoPoolMode(): AutoPoolMode;
143
168
  /** A device's auto-launch flags, as read by the ext's launch ranking. */
144
169
  export interface AutoLaunchPreference {
145
170
  enabled?: boolean;
@@ -167,6 +192,15 @@ export declare function isSchedulerEnabled(): boolean;
167
192
  * scheduler init) refuses with.
168
193
  */
169
194
  export declare function assertSchedulerEnabled(): void;
195
+ /**
196
+ * True unless this machine's config turns off the managed tmux wrap for
197
+ * interactive `agents run` launches (`tmux.enabled=false`).
198
+ *
199
+ * Read as one of the guards in `shouldWrapInTmux` (lib/exec.ts) — the durable,
200
+ * per-machine form of `--no-tmux` / `AGENTS_NO_TMUX=1`, for a box whose tmux is
201
+ * broken or unwanted. Unset means today's behavior: wrap.
202
+ */
203
+ export declare function isTmuxEnabled(): boolean;
170
204
  /** True unless this machine's config disables the daemon outright (top-level kill switch). */
171
205
  export declare function isDaemonEnabled(): boolean;
172
206
  /**