@phnx-labs/agents-cli 1.22.37 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +3 -2
  5. package/dist/commands/artifacts-setup.d.ts +53 -0
  6. package/dist/commands/{setup-share.js → artifacts-setup.js} +59 -13
  7. package/dist/commands/artifacts.d.ts +18 -0
  8. package/dist/commands/artifacts.js +58 -0
  9. package/dist/commands/browser.js +2 -0
  10. package/dist/commands/config.js +31 -1
  11. package/dist/commands/exec.js +2 -2
  12. package/dist/commands/models.js +67 -0
  13. package/dist/commands/setup.js +5 -5
  14. package/dist/commands/share.d.ts +20 -7
  15. package/dist/commands/share.js +74 -75
  16. package/dist/commands/ssh.js +156 -8
  17. package/dist/lib/browser/hygiene.d.ts +90 -0
  18. package/dist/lib/browser/hygiene.js +146 -0
  19. package/dist/lib/browser/ipc.js +12 -0
  20. package/dist/lib/browser/service.d.ts +75 -1
  21. package/dist/lib/browser/service.js +201 -11
  22. package/dist/lib/browser/types.d.ts +44 -1
  23. package/dist/lib/config-keys.d.ts +11 -3
  24. package/dist/lib/config-keys.js +22 -3
  25. package/dist/lib/config-machine-keys.js +1 -0
  26. package/dist/lib/device-config.d.ts +34 -0
  27. package/dist/lib/device-config.js +96 -0
  28. package/dist/lib/devices/pool.d.ts +56 -0
  29. package/dist/lib/devices/pool.js +85 -0
  30. package/dist/lib/exec.d.ts +4 -1
  31. package/dist/lib/exec.js +8 -2
  32. package/dist/lib/git.d.ts +1 -1
  33. package/dist/lib/git.js +1 -1
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  36. package/dist/lib/migrate.d.ts +36 -0
  37. package/dist/lib/migrate.js +107 -0
  38. package/dist/lib/routines.d.ts +3 -1
  39. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  41. package/dist/lib/share/analytics.js +1 -1
  42. package/dist/lib/share/capture.d.ts +1 -1
  43. package/dist/lib/share/capture.js +3 -3
  44. package/dist/lib/share/config.d.ts +2 -2
  45. package/dist/lib/share/config.js +5 -5
  46. package/dist/lib/share/delete.js +3 -3
  47. package/dist/lib/share/provision.js +3 -3
  48. package/dist/lib/share/publish.d.ts +1 -1
  49. package/dist/lib/share/publish.js +3 -3
  50. package/dist/lib/share/worker-template.d.ts +12 -1
  51. package/dist/lib/share/worker-template.js +13 -2
  52. package/dist/lib/smart-launch.d.ts +33 -3
  53. package/dist/lib/smart-launch.js +61 -6
  54. package/dist/lib/startup/command-registry.d.ts +10 -2
  55. package/dist/lib/startup/command-registry.js +16 -7
  56. package/dist/lib/tmux/orphan-reap.d.ts +15 -19
  57. package/dist/lib/tmux/orphan-reap.js +15 -21
  58. package/dist/lib/tmux/session.js +4 -3
  59. package/dist/lib/triggers/handlers.js +10 -0
  60. package/dist/lib/triggers/webhook.js +10 -0
  61. package/dist/lib/types.d.ts +2 -2
  62. package/package.json +1 -1
  63. package/dist/commands/set.d.ts +0 -15
  64. package/dist/commands/set.js +0 -79
  65. package/dist/commands/setup-share.d.ts +0 -17
@@ -77,6 +77,20 @@ export interface Task {
77
77
  tabs: Record<string, string>;
78
78
  currentTabId?: string;
79
79
  createdAt: number;
80
+ /**
81
+ * When this task last did anything. Stamped at creation (equal to
82
+ * `createdAt`) and refreshed by every task-scoped action, because every one
83
+ * of them resolves the task through `BrowserService.findTask`.
84
+ *
85
+ * Read by the idle half of the abandoned-task reaper (`hygiene.ts`): a task
86
+ * nobody has driven for `idleMs` is stopped so its tabs stop piling up in the
87
+ * profile window (RUSH-2622).
88
+ *
89
+ * Persisted in tasks.json. Tasks written before RUSH-2622 carry none;
90
+ * `loadTaskState` normalizes those to `createdAt` on read, so every task in
91
+ * memory has one and the reaper never has to guess.
92
+ */
93
+ lastActionAt: number;
80
94
  pid: number;
81
95
  /**
82
96
  * Resolved actor id (`resolveActor().id`) stamped at task start — WHO launched
@@ -151,7 +165,24 @@ export interface HistoricalTask {
151
165
  /** Actor id who launched the task (RUSH-2020); absent for pre-RUSH-2020 history. */
152
166
  owner?: string;
153
167
  }
154
- export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
168
+ /**
169
+ * Why the reaper stopped a task. `session-dead` — the agent session (or run)
170
+ * that started it is provably gone; `idle` — nothing has driven it for the
171
+ * idle window. See `hygiene.ts`.
172
+ */
173
+ export type ReapReason = 'session-dead' | 'idle';
174
+ /** One task the reaper stopped (or, under `dryRun`, would have stopped). */
175
+ export interface ReapedTask {
176
+ task: string;
177
+ profile: string;
178
+ reason: ReapReason;
179
+ }
180
+ export interface ReapResult {
181
+ closed: ReapedTask[];
182
+ /** Tasks left alone this pass: still live, still inside the idle window, or recording. */
183
+ skipped: number;
184
+ }
185
+ export type IPCAction = 'start' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
155
186
  export interface IPCRequest {
156
187
  action: IPCAction;
157
188
  task?: string;
@@ -201,6 +232,16 @@ export interface IPCRequest {
201
232
  until?: string;
202
233
  appLevel?: string;
203
234
  skipDomainSkill?: boolean;
235
+ /**
236
+ * Browser start: always open a new tab. Without it, `start --url` reclaims a
237
+ * tab that an ABANDONED task is still holding on this exact URL rather than
238
+ * opening a duplicate (RUSH-2622). A tab held by a live task, or one nobody's
239
+ * task owns (the user's own), is never taken either way — set this when the
240
+ * caller wants its own tab regardless.
241
+ */
242
+ fresh?: boolean;
243
+ idleMinutes?: number;
244
+ dryRun?: boolean;
204
245
  actor?: string;
205
246
  launchId?: string;
206
247
  /**
@@ -236,6 +277,8 @@ export interface IPCResponse {
236
277
  tabs?: TabInfo[];
237
278
  profiles?: ProfileStatus[];
238
279
  history?: HistoricalTask[];
280
+ /** `gc`: what the abandoned-task reaper closed (or would close under dryRun). */
281
+ reaped?: ReapResult;
239
282
  result?: unknown;
240
283
  path?: string;
241
284
  bytes?: number;
@@ -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
  /**
@@ -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');
package/dist/lib/git.d.ts CHANGED
@@ -123,7 +123,7 @@ export declare function _resetSnapshotShaCacheForTest(): void;
123
123
  */
124
124
  export declare function getGitHubUsername(): Promise<string | null>;
125
125
  /**
126
- * Resolve the GitHub username synchronously for `agents share`. Order:
126
+ * Resolve the GitHub username synchronously for `agents artifacts share`. Order:
127
127
  * 1. `AGENTS_SHARE_GITHUB_USER` env override
128
128
  * 2. `git config --global github.user`
129
129
  * Falls back to null so callers can decide whether to require auth or proceed
package/dist/lib/git.js CHANGED
@@ -437,7 +437,7 @@ function readGitConfigUser() {
437
437
  }
438
438
  }
439
439
  /**
440
- * Resolve the GitHub username synchronously for `agents share`. Order:
440
+ * Resolve the GitHub username synchronously for `agents artifacts share`. Order:
441
441
  * 1. `AGENTS_SHARE_GITHUB_USER` env override
442
442
  * 2. `git config --global github.user`
443
443
  * Falls back to null so callers can decide whether to require auth or proceed