@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.
- package/CHANGELOG.md +44 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +61 -0
- package/dist/commands/exec.js +49 -13
- package/dist/commands/harness.d.ts +0 -1
- package/dist/commands/harness.js +60 -4
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/routines.js +2 -2
- package/dist/commands/run-account-picker.js +2 -0
- package/dist/commands/snapshot.d.ts +11 -0
- package/dist/commands/snapshot.js +107 -0
- package/dist/commands/teams.js +2 -1
- package/dist/commands/view.d.ts +7 -0
- package/dist/commands/view.js +1 -1
- package/dist/index.js +4 -1
- package/dist/lib/browser/ipc.js +2 -0
- package/dist/lib/browser/remote-control.d.ts +35 -0
- package/dist/lib/browser/remote-control.js +48 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +19 -1
- package/dist/lib/browser/types.d.ts +14 -2
- package/dist/lib/device-config.js +8 -0
- package/dist/lib/hosts/passthrough.d.ts +10 -1
- package/dist/lib/hosts/passthrough.js +23 -2
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/placement.d.ts +82 -0
- package/dist/lib/placement.js +188 -0
- package/dist/lib/profiles.d.ts +31 -9
- package/dist/lib/profiles.js +83 -13
- package/dist/lib/rotate.d.ts +19 -4
- package/dist/lib/rotate.js +24 -1
- package/dist/lib/routines.d.ts +2 -0
- package/dist/lib/runner.d.ts +3 -0
- package/dist/lib/runner.js +90 -7
- 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/snapshot.d.ts +103 -0
- package/dist/lib/snapshot.js +99 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- 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
|
-
|
|
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 —
|
|
83
|
-
* this browser task
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
Binary file
|
|
Binary file
|
|
@@ -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();
|
package/dist/lib/profiles.d.ts
CHANGED
|
@@ -31,9 +31,10 @@ export interface Profile {
|
|
|
31
31
|
preset?: string;
|
|
32
32
|
provider?: string;
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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 — `
|
|
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 —
|
|
129
|
-
*
|
|
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
|