@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 +8 -0
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +2 -2
- package/dist/commands/config.js +31 -1
- package/dist/commands/exec.js +1 -1
- package/dist/commands/ssh.js +156 -8
- package/dist/lib/config-keys.d.ts +11 -3
- package/dist/lib/config-keys.js +22 -3
- package/dist/lib/config-machine-keys.js +1 -0
- package/dist/lib/device-config.d.ts +34 -0
- package/dist/lib/device-config.js +96 -0
- package/dist/lib/devices/pool.d.ts +56 -0
- package/dist/lib/devices/pool.js +85 -0
- package/dist/lib/exec.d.ts +4 -1
- package/dist/lib/exec.js +8 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +36 -0
- package/dist/lib/migrate.js +107 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/smart-launch.d.ts +33 -3
- package/dist/lib/smart-launch.js +61 -6
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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');
|
|
Binary file
|
|
Binary file
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/migrate.js
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
/**
|
|
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
|
|
35
|
-
*
|
|
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. */
|