@phnx-labs/agents-cli 1.22.7 → 1.22.8

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 (43) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/browser.js +61 -0
  4. package/dist/commands/exec.js +49 -13
  5. package/dist/commands/harness.d.ts +0 -1
  6. package/dist/commands/harness.js +60 -4
  7. package/dist/commands/monitors.js +2 -2
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/run-account-picker.js +2 -0
  10. package/dist/commands/snapshot.d.ts +11 -0
  11. package/dist/commands/snapshot.js +107 -0
  12. package/dist/commands/teams.js +2 -1
  13. package/dist/commands/view.d.ts +7 -0
  14. package/dist/commands/view.js +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/lib/browser/ipc.js +2 -0
  17. package/dist/lib/browser/remote-control.d.ts +35 -0
  18. package/dist/lib/browser/remote-control.js +48 -0
  19. package/dist/lib/browser/service.d.ts +19 -0
  20. package/dist/lib/browser/service.js +19 -1
  21. package/dist/lib/browser/types.d.ts +14 -2
  22. package/dist/lib/device-config.js +8 -0
  23. package/dist/lib/hosts/passthrough.d.ts +10 -1
  24. package/dist/lib/hosts/passthrough.js +23 -2
  25. package/dist/lib/hosts/remote-cmd.js +1 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/placement.d.ts +82 -0
  29. package/dist/lib/placement.js +188 -0
  30. package/dist/lib/profiles.d.ts +31 -9
  31. package/dist/lib/profiles.js +83 -13
  32. package/dist/lib/rotate.d.ts +19 -4
  33. package/dist/lib/rotate.js +24 -1
  34. package/dist/lib/routines.d.ts +2 -0
  35. package/dist/lib/runner.d.ts +3 -0
  36. package/dist/lib/runner.js +90 -7
  37. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  38. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  39. package/dist/lib/snapshot.d.ts +103 -0
  40. package/dist/lib/snapshot.js +99 -0
  41. package/dist/lib/startup/command-registry.d.ts +1 -0
  42. package/dist/lib/startup/command-registry.js +2 -0
  43. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Consent gate for driving THIS machine's browser from another fleet machine.
3
+ *
4
+ * `browser --host <device>` routes a browser command to `<device>` over SSH (the
5
+ * fleet passthrough) and runs `agents browser ...` there. Every such remote
6
+ * invocation carries the {@link FLEET_REMOTE_ENV} marker, set once at the fleet
7
+ * dispatch site (`maybeRunOnHost`). A machine only accepts being driven when its
8
+ * owner has opted in via `agents browser remote-control on` (the device-scope
9
+ * `browser.remote-control` config key, never synced).
10
+ *
11
+ * Local invocations (no marker) are never gated — this only governs cross-machine
12
+ * drives. Read-only queries are not gated here; the gate sits at the drive entry
13
+ * point (`browser start`), the one command that opens/attaches a browser.
14
+ */
15
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
16
+ export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
17
+ /** True when this process was dispatched to this machine by a fleet `--host` run. */
18
+ export declare function isFleetRemoteInvocation(env?: NodeJS.ProcessEnv): boolean;
19
+ /**
20
+ * Whether this machine allows other fleet machines to drive its browser. Reads
21
+ * the device-scope `browser.remote-control` config key. Unset = off (deny).
22
+ */
23
+ export declare function remoteControlEnabled(): boolean;
24
+ /**
25
+ * Guard the browser-drive entry point. A fleet-remote invocation may only drive
26
+ * this machine's browser when the owner opted in; otherwise throw a clear,
27
+ * actionable error. A local invocation is never gated.
28
+ *
29
+ * `env` and `enabled` are injectable so the decision is unit-testable without a
30
+ * real remote hop or a real config store.
31
+ */
32
+ export declare function assertRemoteControlAllowed(opts?: {
33
+ env?: NodeJS.ProcessEnv;
34
+ enabled?: boolean;
35
+ }): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Consent gate for driving THIS machine's browser from another fleet machine.
3
+ *
4
+ * `browser --host <device>` routes a browser command to `<device>` over SSH (the
5
+ * fleet passthrough) and runs `agents browser ...` there. Every such remote
6
+ * invocation carries the {@link FLEET_REMOTE_ENV} marker, set once at the fleet
7
+ * dispatch site (`maybeRunOnHost`). A machine only accepts being driven when its
8
+ * owner has opted in via `agents browser remote-control on` (the device-scope
9
+ * `browser.remote-control` config key, never synced).
10
+ *
11
+ * Local invocations (no marker) are never gated — this only governs cross-machine
12
+ * drives. Read-only queries are not gated here; the gate sits at the drive entry
13
+ * point (`browser start`), the one command that opens/attaches a browser.
14
+ */
15
+ import { getConfigValue } from '../device-config.js';
16
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
17
+ export const FLEET_REMOTE_ENV = 'AGENTS_FLEET_REMOTE';
18
+ /** True when this process was dispatched to this machine by a fleet `--host` run. */
19
+ export function isFleetRemoteInvocation(env = process.env) {
20
+ return env[FLEET_REMOTE_ENV] === '1';
21
+ }
22
+ /**
23
+ * Whether this machine allows other fleet machines to drive its browser. Reads
24
+ * the device-scope `browser.remote-control` config key. Unset = off (deny).
25
+ */
26
+ export function remoteControlEnabled() {
27
+ return getConfigValue('browser.remote-control').value === true;
28
+ }
29
+ /**
30
+ * Guard the browser-drive entry point. A fleet-remote invocation may only drive
31
+ * this machine's browser when the owner opted in; otherwise throw a clear,
32
+ * actionable error. A local invocation is never gated.
33
+ *
34
+ * `env` and `enabled` are injectable so the decision is unit-testable without a
35
+ * real remote hop or a real config store.
36
+ */
37
+ export function assertRemoteControlAllowed(opts) {
38
+ const env = opts?.env ?? process.env;
39
+ if (!isFleetRemoteInvocation(env))
40
+ return;
41
+ const enabled = opts?.enabled ?? remoteControlEnabled();
42
+ if (enabled)
43
+ return;
44
+ const who = env.AGENTS_ACTOR_HOST || env.AGENTS_ACTOR || 'A fleet machine';
45
+ throw new Error(`${who} tried to drive this machine's browser over \`browser --host\`, but remote ` +
46
+ `browser control is off here. To allow it, run on THIS machine:\n` +
47
+ ` agents browser remote-control on`);
48
+ }
@@ -50,6 +50,22 @@ export interface HealInfo {
50
50
  role: string;
51
51
  name: string;
52
52
  }
53
+ /**
54
+ * Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
55
+ * (`launchId`). The forwarded values come from the caller's own CLI process and
56
+ * are authoritative — the browser daemon is shared and long-lived, so resolving
57
+ * the actor daemon-side (the RUSH-2020 bug) mis-attributes every task to the
58
+ * daemon's owner. `resolveLocalActor` is consulted ONLY when no actor was
59
+ * forwarded (a CLI that predates the field, mid-rollout) — never to override a
60
+ * forwarded one.
61
+ */
62
+ export declare function resolveTaskIdentity(forwarded: {
63
+ actor?: string;
64
+ launchId?: string;
65
+ }, resolveLocalActor: () => string): {
66
+ owner: string;
67
+ launchId?: string;
68
+ };
53
69
  export declare class BrowserService {
54
70
  private static readonly SOURCE_PREFIX;
55
71
  private connections;
@@ -64,6 +80,9 @@ export declare class BrowserService {
64
80
  url?: string;
65
81
  endpointName?: string;
66
82
  skipDomainSkill?: boolean;
83
+ /** Caller identity, forwarded from the CLI (see IPCRequest.actor/launchId). */
84
+ actor?: string;
85
+ launchId?: string;
67
86
  }): Promise<{
68
87
  task: string;
69
88
  name: string;
@@ -222,6 +222,21 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
222
222
  return false;
223
223
  }
224
224
  }
225
+ /**
226
+ * Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
227
+ * (`launchId`). The forwarded values come from the caller's own CLI process and
228
+ * are authoritative — the browser daemon is shared and long-lived, so resolving
229
+ * the actor daemon-side (the RUSH-2020 bug) mis-attributes every task to the
230
+ * daemon's owner. `resolveLocalActor` is consulted ONLY when no actor was
231
+ * forwarded (a CLI that predates the field, mid-rollout) — never to override a
232
+ * forwarded one.
233
+ */
234
+ export function resolveTaskIdentity(forwarded, resolveLocalActor) {
235
+ return {
236
+ owner: forwarded.actor ?? resolveLocalActor(),
237
+ launchId: forwarded.launchId,
238
+ };
239
+ }
225
240
  export class BrowserService {
226
241
  static SOURCE_PREFIX = {
227
242
  'rush-app': 'rush-app-',
@@ -332,7 +347,10 @@ export class BrowserService {
332
347
  currentTabId: undefined,
333
348
  createdAt: Date.now(),
334
349
  pid: conn.pid,
335
- owner: resolveActor().id, // who launched this browser task (RUSH-2020)
350
+ // Identity is forwarded from the caller (see resolveTaskIdentity): WHO
351
+ // (owner) and WHICH run (launchId). Resolving daemon-side would attribute
352
+ // every task to the shared daemon's actor (the RUSH-2020 bug).
353
+ ...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId }, () => resolveActor().id),
336
354
  };
337
355
  // For Electron, get the existing window as the tab
338
356
  if (conn.electron) {
@@ -79,10 +79,20 @@ export interface Task {
79
79
  createdAt: number;
80
80
  pid: number;
81
81
  /**
82
- * Resolved actor id (`resolveActor().id`) stamped at task start — who launched
83
- * this browser task. Optional: tasks persisted before RUSH-2020 carry none.
82
+ * Resolved actor id (`resolveActor().id`) stamped at task start — WHO launched
83
+ * this browser task (a person/agent identity, stable across runs). Forwarded
84
+ * from the caller over IPC — the daemon is shared, so it cannot resolve the
85
+ * caller's actor itself. Optional: tasks persisted before RUSH-2020 carry none.
84
86
  */
85
87
  owner?: string;
88
+ /**
89
+ * The caller's per-run launch id (`$AGENT_LAUNCH_ID`, minted by exec.ts for
90
+ * every harness) stamped at task start — WHICH run created this task. Distinct
91
+ * from `owner`: two runs by the same actor get different launchIds, which is
92
+ * the scope the current-task default and `status --mine` filter on. Forwarded
93
+ * from the caller over IPC. Optional: tasks from before this shipped carry none.
94
+ */
95
+ launchId?: string;
86
96
  /**
87
97
  * Per-tab snapshot of the last ref listing captured for that tab
88
98
  * (shortId -> {descriptors, opts}). Persisted to tasks.json so a later
@@ -184,6 +194,8 @@ export interface IPCRequest {
184
194
  until?: string;
185
195
  appLevel?: string;
186
196
  skipDomainSkill?: boolean;
197
+ actor?: string;
198
+ launchId?: string;
187
199
  }
188
200
  /** Subset of IPCResponse describing a recording start result. */
189
201
  export interface RecordStartFields {
@@ -67,6 +67,14 @@ export const CONFIG_KEYS = [
67
67
  type: 'bool',
68
68
  description: 'Whether the routines scheduler (daemon) may fire on this device.',
69
69
  },
70
+ {
71
+ name: 'browser.remote-control',
72
+ yamlKey: 'browserRemoteControl',
73
+ scope: 'device',
74
+ type: 'bool',
75
+ description: "Whether other fleet machines may drive THIS device's browser over `browser --host <this-device>`. " +
76
+ 'Default off — a fleet-remote drive is refused until the owner runs `agents browser remote-control on`.',
77
+ },
70
78
  {
71
79
  name: 'notes',
72
80
  yamlKey: 'notes',
@@ -16,7 +16,7 @@
16
16
  * table or, when `--host`/`--device` is present, exits with a clear
17
17
  * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
- import { type DeviceRegistry } from '../devices/registry.js';
19
+ import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
20
20
  import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
21
21
  /** Per-command remote behaviour. Absence from this map = not host-routable here. */
22
22
  interface RemoteSpec {
@@ -36,6 +36,15 @@ export interface FleetPassthroughOptions {
36
36
  /** Override this machine's id (tests). Defaults to `machineId()`. */
37
37
  self?: string;
38
38
  }
39
+ /**
40
+ * Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
41
+ * the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
42
+ * joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
43
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
44
+ * `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
45
+ * self target runs locally and must stay ungated.
46
+ */
47
+ export declare function markFleetRemote(cmd: string[], device: DeviceProfile): string[];
39
48
  /** Run `agents <command> …` across every registered device and render the roster. */
40
49
  export declare function runFleetPassthrough(command: string, allArgs: string[], spec: RemoteSpec, opts?: FleetPassthroughOptions): Promise<boolean>;
41
50
  /**
@@ -319,6 +319,19 @@ function renderFleetRoster(command, forwarded, results, self) {
319
319
  console.log(chalk.gray(summaryParts.join(' · ')));
320
320
  }
321
321
  }
322
+ /**
323
+ * Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
324
+ * the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
325
+ * joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
326
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
327
+ * `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
328
+ * self target runs locally and must stay ungated.
329
+ */
330
+ export function markFleetRemote(cmd, device) {
331
+ return device.shell === 'powershell'
332
+ ? [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd]
333
+ : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
334
+ }
322
335
  /** Run `agents <command> …` across every registered device and render the roster. */
323
336
  export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
324
337
  const self = opts.self ?? machineId();
@@ -335,7 +348,12 @@ export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
335
348
  const results = await fanOutDevices(targets, async (target) => {
336
349
  const cmd = ['agents', ...forwarded];
337
350
  const isSelf = target.device.name.toLowerCase() === self.toLowerCase() || isSelfHost(target.device.name);
338
- const res = isSelf ? localRunner(cmd) : runner(target.device, cmd);
351
+ // Only `browser` consults the fleet-remote marker (its consent gate), and
352
+ // the fan-out has no separate env channel — the marker must ride the argv.
353
+ // So scope the env-prefix to a REMOTE browser drive: every other command's
354
+ // remote argv stays byte-identical, and the self target is never gated.
355
+ const remoteCmd = !isSelf && command === 'browser' ? markFleetRemote(cmd, target.device) : cmd;
356
+ const res = isSelf ? localRunner(cmd) : runner(target.device, remoteCmd);
339
357
  if (res.code !== 0) {
340
358
  const detail = (res.stderr || res.stdout || 'unreachable').trim().slice(0, 200);
341
359
  throw new Error(detail || 'unreachable');
@@ -500,7 +518,10 @@ export async function maybeRunOnHost(command, allArgs, opts) {
500
518
  // UNDER the doctor PATH so that PATH still wins — without this the remote
501
519
  // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
502
520
  // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
503
- const env = withActorEnv(doctorPath);
521
+ // AGENTS_FLEET_REMOTE marks this as a fleet-dispatched `--host` run so the far
522
+ // side can gate consent-sensitive actions — the browser consent gate
523
+ // (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
524
+ const env = withActorEnv({ ...doctorPath, AGENTS_FLEET_REMOTE: '1' });
504
525
  const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
505
526
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
506
527
  if (code === 255) {
@@ -105,6 +105,7 @@ export const RUN_OPTION_FORWARDING = {
105
105
  disableTmux: 'local-only',
106
106
  host: 'local-only',
107
107
  device: 'local-only',
108
+ where: 'local-only', // expands into host/lease before dispatch; never re-forwarded
108
109
  on: 'local-only',
109
110
  computer: 'local-only',
110
111
  any: 'local-only',
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Placement — one model for "where does the body run?"
3
+ *
4
+ * The CLI grew several doors that all mean execution target:
5
+ * run --host / --device / --lease / --box
6
+ * routines --placement / --run-on / hostStrategy
7
+ * monitors --run-on (body) vs --device (owner — NOT placement)
8
+ * teams --device (teammate pin)
9
+ * cloud run --provider host
10
+ *
11
+ * This module is the shared vocabulary. Old flags remain; --where is a
12
+ * thin alias on `agents run` that expands into them. Docs and help teach
13
+ * the matrix; stores stay separate (device registry, lease boxes, cloud).
14
+ *
15
+ * Owner (who may fire / evaluate) is NOT placement — see monitors.
16
+ */
17
+ /** Where a job body executes. */
18
+ export type PlacementKind = 'local' | 'device' | 'fleet' | 'cloud' | 'lease';
19
+ /**
20
+ * Canonical placement object.
21
+ *
22
+ * kind: local — this machine
23
+ * kind: device — named box or affinity pick (target: name | "auto")
24
+ * kind: fleet — pick one online device at fire time (routines)
25
+ * kind: cloud — vendor cloud dispatch
26
+ * kind: lease — disposable crabbox (target: optional backend)
27
+ */
28
+ export interface Placement {
29
+ kind: PlacementKind;
30
+ /** Device/host name, "auto", lease backend, or undefined. */
31
+ target?: string;
32
+ /** Flag or path that produced this (errors / diagnostics). */
33
+ source: string;
34
+ }
35
+ /** Run-flag bag the placement parser understands. */
36
+ export interface RunPlacementFlags {
37
+ where?: string;
38
+ host?: string;
39
+ device?: string;
40
+ on?: string;
41
+ computer?: string;
42
+ lease?: string | boolean;
43
+ box?: string;
44
+ }
45
+ export declare class PlacementError extends Error {
46
+ constructor(message: string);
47
+ }
48
+ /**
49
+ * Parse a `--where` / placement spec string.
50
+ *
51
+ * Accepted forms:
52
+ * local
53
+ * device[:name] | host[:name] (bare name → device:<name>)
54
+ * device:auto | auto | host:auto
55
+ * fleet
56
+ * cloud
57
+ * lease[:backend]
58
+ */
59
+ export declare function parseWhereSpec(raw: string, source?: string): Placement;
60
+ /** First non-empty host-family flag value (host / device / on / computer). */
61
+ export declare function hostFamilyTarget(flags: RunPlacementFlags): string | undefined;
62
+ /**
63
+ * Resolve placement from run flags. `--where` wins only when no other
64
+ * placement flag is set; mixing is a PlacementError.
65
+ */
66
+ export declare function placementFromRunFlags(flags: RunPlacementFlags): Placement;
67
+ /**
68
+ * Expand a resolved placement into the concrete run option fields the
69
+ * existing dispatch paths already understand. Pure — does not mutate input.
70
+ *
71
+ * `cloud` and `fleet` are not valid for a bare `agents run` (use `cloud run`
72
+ * / routines); they throw so callers fail loud.
73
+ */
74
+ export declare function expandPlacementToRunFlags(placement: Placement): Pick<RunPlacementFlags, 'host' | 'device' | 'lease' | 'box'>;
75
+ /** Map routines hostStrategy (+ optional host) onto the shared Placement. */
76
+ export declare function placementFromHostStrategy(strategy: 'local' | 'host' | 'fleet' | 'cloud', host?: string): Placement;
77
+ /** One-line human form for logs / help. */
78
+ export declare function formatPlacement(p: Placement): string;
79
+ /**
80
+ * Short matrix for help footers and docs. Keep in sync with 00-concepts.md.
81
+ */
82
+ export declare const PLACEMENT_MATRIX: string;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Placement — one model for "where does the body run?"
3
+ *
4
+ * The CLI grew several doors that all mean execution target:
5
+ * run --host / --device / --lease / --box
6
+ * routines --placement / --run-on / hostStrategy
7
+ * monitors --run-on (body) vs --device (owner — NOT placement)
8
+ * teams --device (teammate pin)
9
+ * cloud run --provider host
10
+ *
11
+ * This module is the shared vocabulary. Old flags remain; --where is a
12
+ * thin alias on `agents run` that expands into them. Docs and help teach
13
+ * the matrix; stores stay separate (device registry, lease boxes, cloud).
14
+ *
15
+ * Owner (who may fire / evaluate) is NOT placement — see monitors.
16
+ */
17
+ export class PlacementError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'PlacementError';
21
+ }
22
+ }
23
+ const KINDS = new Set(['local', 'device', 'fleet', 'cloud', 'lease', 'host']);
24
+ /**
25
+ * Parse a `--where` / placement spec string.
26
+ *
27
+ * Accepted forms:
28
+ * local
29
+ * device[:name] | host[:name] (bare name → device:<name>)
30
+ * device:auto | auto | host:auto
31
+ * fleet
32
+ * cloud
33
+ * lease[:backend]
34
+ */
35
+ export function parseWhereSpec(raw, source = '--where') {
36
+ const spec = raw.trim();
37
+ if (!spec) {
38
+ throw new PlacementError(`${source} requires a value (local | device:<name> | auto | lease | cloud | fleet)`);
39
+ }
40
+ const lower = spec.toLowerCase();
41
+ if (lower === 'local')
42
+ return { kind: 'local', source };
43
+ if (lower === 'auto')
44
+ return { kind: 'device', target: 'auto', source };
45
+ if (lower === 'fleet')
46
+ return { kind: 'fleet', source };
47
+ if (lower === 'cloud')
48
+ return { kind: 'cloud', source };
49
+ if (lower === 'lease')
50
+ return { kind: 'lease', source };
51
+ const colon = spec.indexOf(':');
52
+ if (colon === -1) {
53
+ // Bare token that is not a reserved kind → device target.
54
+ if (KINDS.has(lower)) {
55
+ // "device" / "host" alone means device with no pin (invalid for run).
56
+ throw new PlacementError(`${source} ${spec}: name a target (device:<name>, device:auto) or use local|lease|cloud|fleet`);
57
+ }
58
+ return { kind: 'device', target: spec, source };
59
+ }
60
+ const head = spec.slice(0, colon).toLowerCase();
61
+ const tail = spec.slice(colon + 1).trim();
62
+ if (!tail) {
63
+ throw new PlacementError(`${source} ${spec}: missing target after ':'`);
64
+ }
65
+ if (head === 'device' || head === 'host') {
66
+ return { kind: 'device', target: tail, source };
67
+ }
68
+ if (head === 'lease') {
69
+ return { kind: 'lease', target: tail, source };
70
+ }
71
+ if (head === 'cloud') {
72
+ return { kind: 'cloud', target: tail, source };
73
+ }
74
+ if (head === 'fleet') {
75
+ return { kind: 'fleet', target: tail, source };
76
+ }
77
+ throw new PlacementError(`${source} ${spec}: unknown kind '${head}' (use local | device:<name> | auto | lease[:backend] | cloud | fleet)`);
78
+ }
79
+ /** First non-empty host-family flag value (host / device / on / computer). */
80
+ export function hostFamilyTarget(flags) {
81
+ for (const v of [flags.host, flags.device, flags.on, flags.computer]) {
82
+ if (v)
83
+ return v;
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * Resolve placement from run flags. `--where` wins only when no other
89
+ * placement flag is set; mixing is a PlacementError.
90
+ */
91
+ export function placementFromRunFlags(flags) {
92
+ const where = flags.where?.trim();
93
+ const hostT = hostFamilyTarget(flags);
94
+ const hasLease = flags.lease !== undefined && flags.lease !== false;
95
+ const hasBox = !!flags.box;
96
+ const placementFlags = [];
97
+ if (where)
98
+ placementFlags.push('--where');
99
+ if (hostT)
100
+ placementFlags.push('--host/--device');
101
+ if (hasLease)
102
+ placementFlags.push('--lease');
103
+ if (hasBox)
104
+ placementFlags.push('--box');
105
+ if (placementFlags.length > 1) {
106
+ throw new PlacementError(`Conflicting placement flags: ${placementFlags.join(' + ')}. ` +
107
+ `Use one door — prefer --where (device:<name> | auto | lease | local).`);
108
+ }
109
+ if (where)
110
+ return parseWhereSpec(where, '--where');
111
+ if (hasBox)
112
+ return { kind: 'lease', target: flags.box, source: '--box' };
113
+ if (hasLease) {
114
+ const backend = typeof flags.lease === 'string' ? flags.lease : undefined;
115
+ return { kind: 'lease', target: backend, source: '--lease' };
116
+ }
117
+ if (hostT)
118
+ return { kind: 'device', target: hostT, source: '--host/--device' };
119
+ return { kind: 'local', source: 'default' };
120
+ }
121
+ /**
122
+ * Expand a resolved placement into the concrete run option fields the
123
+ * existing dispatch paths already understand. Pure — does not mutate input.
124
+ *
125
+ * `cloud` and `fleet` are not valid for a bare `agents run` (use `cloud run`
126
+ * / routines); they throw so callers fail loud.
127
+ */
128
+ export function expandPlacementToRunFlags(placement) {
129
+ switch (placement.kind) {
130
+ case 'local':
131
+ return {};
132
+ case 'device':
133
+ if (!placement.target) {
134
+ throw new PlacementError(`${placement.source}: device placement needs a target (name or auto)`);
135
+ }
136
+ // Canonical host flag; --device is an alias of the same path.
137
+ return { host: placement.target };
138
+ case 'lease':
139
+ // --box reuses a warm slug; --where lease[:backend] / --lease provisions.
140
+ if (placement.source === '--box')
141
+ return { box: placement.target };
142
+ return placement.target ? { lease: placement.target } : { lease: true };
143
+ case 'fleet':
144
+ throw new PlacementError(`fleet placement is for routines (agents routines add … --placement fleet), not agents run. ` +
145
+ `Use --where device:auto for an affinity pick, or --where device:<name>.`);
146
+ case 'cloud':
147
+ throw new PlacementError(`cloud placement is agents cloud run (vendor cloud), not agents run. ` +
148
+ `For a disposable box use --where lease; for your fleet use --where device:<name>.`);
149
+ }
150
+ }
151
+ /** Map routines hostStrategy (+ optional host) onto the shared Placement. */
152
+ export function placementFromHostStrategy(strategy, host) {
153
+ switch (strategy) {
154
+ case 'local':
155
+ return { kind: 'local', source: 'hostStrategy:local' };
156
+ case 'host':
157
+ return { kind: 'device', target: host, source: 'hostStrategy:host' };
158
+ case 'fleet':
159
+ return { kind: 'fleet', source: 'hostStrategy:fleet' };
160
+ case 'cloud':
161
+ return { kind: 'cloud', source: 'hostStrategy:cloud' };
162
+ }
163
+ }
164
+ /** One-line human form for logs / help. */
165
+ export function formatPlacement(p) {
166
+ if (p.kind === 'local')
167
+ return 'local';
168
+ if (p.target)
169
+ return `${p.kind}:${p.target}`;
170
+ return p.kind;
171
+ }
172
+ /**
173
+ * Short matrix for help footers and docs. Keep in sync with 00-concepts.md.
174
+ */
175
+ export const PLACEMENT_MATRIX = `
176
+ Intent Flag / path
177
+ ───────────────────────────── ──────────────────────────────────────────
178
+ This machine (default) or --where local
179
+ Named fleet box --where device:<name> (= --host / --device)
180
+ Affinity pick (14d usage) --where auto (= --device auto)
181
+ Disposable cloud box --where lease (= --lease)
182
+ Reuse warm crabbox --box <slug>
183
+ Routines: body on one box --run-on <name> / --placement host
184
+ Routines: pick any online --placement fleet
185
+ Vendor cloud task agents cloud run …
186
+ Monitors: who evaluates --device <owner> (NOT body placement)
187
+ Monitors: where action runs --run-on <host>
188
+ `.trim();
@@ -31,9 +31,10 @@ export interface Profile {
31
31
  preset?: string;
32
32
  provider?: string;
33
33
  /**
34
- * Human-facing label for the harness — what `agents view` prints as the
35
- * agent-type header, the same slot `AGENTS[id].name` fills for a native
36
- * harness. Defaults to the profile name when unset.
34
+ * Stored for backward-compatible YAML parsing only — no longer read for
35
+ * display. `profileLabel()` always derives the display name from `name` via
36
+ * the vendor/brand table. Old YAML files that carry this key still parse
37
+ * correctly; it is simply ignored.
37
38
  */
38
39
  label?: string;
39
40
  /**
@@ -71,7 +72,7 @@ export interface Profile {
71
72
  */
72
73
  export interface ProfileSummary {
73
74
  name: string;
74
- /** Human-facing header label — `label` when set, else the profile name. */
75
+ /** Human-facing header label — always derived from `name` via the vendor/brand table. */
75
76
  label: string;
76
77
  agent: AgentId;
77
78
  host: string;
@@ -125,8 +126,12 @@ export declare function profileModelEnvKey(profile: Profile): string | null;
125
126
  */
126
127
  export declare function profileAuthLabel(profile: Profile): string;
127
128
  /**
128
- * Header label for the harness — the slot `AGENTS[id].name` fills for a native
129
- * harness, so `agents view` can print custom and native harnesses the same way.
129
+ * Header label for the harness — derived from `profile.name` by splitting on
130
+ * `[-_]` and mapping each token through the vendor/brand table. Never reads
131
+ * the stored `label` field; old YAML files with a `label:` key are unaffected.
132
+ *
133
+ * Examples: `deepseek-flash` → `'DeepSeek Flash'`, `spark` → `'Spark'`,
134
+ * `deepseek_chat_v3` → `'DeepSeek Chat V3'`.
130
135
  */
131
136
  export declare function profileLabel(profile: Profile): string;
132
137
  /** Build a stable, machine-readable summary for list and view surfaces. */
@@ -153,8 +158,6 @@ export interface HostModelOptions {
153
158
  /** Env var the host reads its auth token from; pair with `provider` to attach keychain auth. */
154
159
  authEnvVar?: string;
155
160
  description?: string;
156
- /** Human-facing header label; defaults to the harness name. */
157
- label?: string;
158
161
  }
159
162
  /**
160
163
  * Build a custom-harness profile from a host CLI + model in one shot, without a
@@ -176,7 +179,6 @@ export interface ForkProfileOptions {
176
179
  authEnvVar?: string;
177
180
  /** Re-pin (or unpin, with an empty string) the host CLI version. */
178
181
  version?: string;
179
- label?: string;
180
182
  description?: string;
181
183
  }
182
184
  /**
@@ -185,6 +187,26 @@ export interface ForkProfileOptions {
185
187
  * diverge from here and deleting the source never affects the fork.
186
188
  */
187
189
  export declare function forkProfile(source: Profile, name: string, opts?: ForkProfileOptions): Profile;
190
+ /**
191
+ * Edit an existing profile in-place, applying overrides without changing its
192
+ * name or lineage. Reuses {@link forkProfile}'s validation and override logic
193
+ * (model swap, base-URL validation, auth repoint), then restores the original
194
+ * `forkedFrom` so an edit never self-references the profile.
195
+ *
196
+ * Note: this returns the updated `Profile` object but does NOT write it to
197
+ * disk — callers should follow up with `writeProfile(result)` if persistence
198
+ * is needed.
199
+ */
200
+ export declare function editProfile(source: Profile, opts?: ForkProfileOptions): Profile;
201
+ /**
202
+ * Rename a profile on disk, then rewrite `forkedFrom` in every other profile
203
+ * that pointed at the old name so lineage display never goes stale.
204
+ *
205
+ * Throws if `oldName` does not exist or `newName` already exists. There is no
206
+ * `--force` / overwrite path — a collision is a hard error directing the user
207
+ * to remove the target first.
208
+ */
209
+ export declare function renameProfile(oldName: string, newName: string): void;
188
210
  /**
189
211
  * Resolve a profile into the env block that should be injected into the
190
212
  * spawned agent process. Reads the token from keychain at exec time so the