@phnx-labs/agents-cli 1.22.7 → 1.22.9

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 (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +61 -0
  5. package/dist/commands/exec.js +89 -21
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -2
  8. package/dist/commands/harness.js +316 -20
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/profiles.d.ts +42 -0
  11. package/dist/commands/profiles.js +91 -3
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/run-account-picker.js +2 -0
  14. package/dist/commands/sessions-picker.js +3 -3
  15. package/dist/commands/sessions-render.d.ts +12 -0
  16. package/dist/commands/sessions-render.js +124 -0
  17. package/dist/commands/sessions.js +28 -1
  18. package/dist/commands/snapshot.d.ts +11 -0
  19. package/dist/commands/snapshot.js +107 -0
  20. package/dist/commands/ssh.js +26 -3
  21. package/dist/commands/teams.js +14 -2
  22. package/dist/commands/view.d.ts +7 -0
  23. package/dist/commands/view.js +1 -1
  24. package/dist/index.js +11 -1
  25. package/dist/lib/browser/ipc.js +2 -0
  26. package/dist/lib/browser/remote-control.d.ts +35 -0
  27. package/dist/lib/browser/remote-control.js +48 -0
  28. package/dist/lib/browser/service.d.ts +19 -0
  29. package/dist/lib/browser/service.js +19 -1
  30. package/dist/lib/browser/types.d.ts +14 -2
  31. package/dist/lib/crabbox/cli.d.ts +35 -0
  32. package/dist/lib/crabbox/cli.js +46 -0
  33. package/dist/lib/crabbox/config.d.ts +21 -0
  34. package/dist/lib/crabbox/config.js +43 -0
  35. package/dist/lib/crabbox/lease.d.ts +15 -5
  36. package/dist/lib/crabbox/lease.js +57 -17
  37. package/dist/lib/daemon.js +12 -11
  38. package/dist/lib/device-config.js +8 -0
  39. package/dist/lib/devices/resolve-target.d.ts +4 -3
  40. package/dist/lib/devices/resolve-target.js +4 -3
  41. package/dist/lib/hosts/passthrough.d.ts +10 -1
  42. package/dist/lib/hosts/passthrough.js +41 -3
  43. package/dist/lib/hosts/registry.d.ts +4 -0
  44. package/dist/lib/hosts/registry.js +16 -0
  45. package/dist/lib/hosts/remote-cmd.js +2 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  48. package/dist/lib/observe-aliases.d.ts +30 -0
  49. package/dist/lib/observe-aliases.js +56 -0
  50. package/dist/lib/placement.d.ts +82 -0
  51. package/dist/lib/placement.js +188 -0
  52. package/dist/lib/profiles.d.ts +31 -9
  53. package/dist/lib/profiles.js +83 -13
  54. package/dist/lib/redact.js +4 -0
  55. package/dist/lib/rotate.d.ts +19 -4
  56. package/dist/lib/rotate.js +24 -1
  57. package/dist/lib/routines.d.ts +2 -0
  58. package/dist/lib/runner.d.ts +3 -0
  59. package/dist/lib/runner.js +90 -7
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/session/parse.d.ts +5 -1
  63. package/dist/lib/session/parse.js +23 -13
  64. package/dist/lib/session/prompt.js +5 -0
  65. package/dist/lib/session/render.d.ts +3 -0
  66. package/dist/lib/session/render.js +33 -10
  67. package/dist/lib/snapshot.d.ts +103 -0
  68. package/dist/lib/snapshot.js +99 -0
  69. package/dist/lib/startup/command-registry.d.ts +1 -0
  70. package/dist/lib/startup/command-registry.js +17 -1
  71. package/dist/lib/usage-refresh.d.ts +19 -11
  72. package/dist/lib/usage-refresh.js +75 -43
  73. package/dist/lib/usage.d.ts +12 -0
  74. package/dist/lib/usage.js +37 -6
  75. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.9
4
+
5
+ - **`agents ssh auto` and `agents teams add --device auto` no longer reject with "Unknown device 'auto'" (RUSH-2185).** The `auto` affinity sentinel was a `run`-only preprocessing step (`applyDeviceAutoToOptions` in `smart-launch.ts`, wired only from `agents run`'s exec path) — every other `--host`/`--device` caller went straight to the shared resolver, which had no idea what `auto` meant and reported it as an unregistered device. `matchHost` (the one core every `--host`/`--device` caller shares) now resolves `auto` directly via the same `resolveDeviceAffinity` engine `run` uses, so `agents ssh`, `agents teams add`, and anything else routed through `matchHost`/`resolveHost` (including the generic `--host`/`--device` passthrough) pick a device the same way. `agents teams add --device auto` landing on the local machine now just runs the teammate locally, matching `run`'s "null pick = local" outcome; `agents ssh auto` refuses a local pick with a clear message instead of self-SSHing, since `agents ssh` exists to dial OUT to a remote box. Source: `apps/cli/src/lib/hosts/registry.ts`, `apps/cli/src/lib/devices/resolve-target.ts`, `apps/cli/src/commands/ssh.ts`, `apps/cli/src/commands/teams.ts`, `apps/cli/docs/00-concepts.md`, `apps/cli/docs/hosts.md`, `apps/cli/docs/teams.md`.
6
+
7
+ - **`agents harness edit` gains `--auth-provider`, `--fallback-model`, and `--from-secrets`; `add`/`fork` gain an interactive wizard and `--from-secrets`.** `edit` (already shipped with `--model`/`--base-url`/`--version`/`--description`) now also repoints auth at a different keychain-backed provider, sets or clears (`--fallback-model ""`) the same-host fallback model retried on a rate limit, and — like `add`/`fork` — accepts `--from-secrets <bundle>[:<key>]` to copy a value out of an existing `agents secrets` bundle into the harness's own keychain item once, instead of retyping a key already stored elsewhere (the item it writes to, `agents-cli.<provider>.token`, is never gated behind the biometry-required prefixes, so later reads stay silent). `agents harness add`/`fork` now accept `[name]`/`[source] [name]` as optional positionals — run either with insufficient flags in an interactive terminal and a picker (fork from a native host or existing harness → a built-in preset or "build custom" → the harness's name, pre-filled with the preset's own name → how to get the key) replaces the old hard error; flags remain fully supported for scripts, and a non-interactive shell still gets the original error. Source: `apps/cli/src/commands/harness.ts`, `apps/cli/src/commands/profiles.ts`, `apps/cli/docs/profiles.md`.
8
+
9
+ - **`agents run --lease` is reuse-first against the crabbox profile pool, with a
10
+ `--fresh` opt-out.** A bare `--lease` used to always lease a brand-new box, so
11
+ bursts of runs (e.g. resumed sessions) stacked up idle `keep=true` boxes at
12
+ full monthly cost. Now, before warming a new box, the run looks for a warm box
13
+ carrying the same `profile` label the warmup would use (read from the repo's
14
+ `.crabbox.yaml`, matching `scripts/sandbox.sh`'s `pick_ready_box`) and the same
15
+ network mode — a tailnet box is never handed to a public run or vice versa —
16
+ and reuses the first one `crabbox status` reports SSH-ready, keeping it after
17
+ the run. A not-ready pool box is skipped, never stopped. `--fresh` forces the
18
+ old behavior (brand-new box, torn down after the run); `--box <slug>` is
19
+ unchanged. Source: `apps/cli/src/lib/crabbox/lease.ts`,
20
+ `apps/cli/src/lib/crabbox/cli.ts`, `apps/cli/src/lib/crabbox/config.ts`,
21
+ `apps/cli/src/commands/exec.ts`.
22
+
23
+ - **The menu bar now notices and reports when the scheduler dies — instead of
24
+ staying silent forever.** The only proactive "routines overdue / scheduler
25
+ down" signal was `notifyOverdue` (`src/lib/overdue.ts`), fired from inside
26
+ `runDaemon()` — so it could never fire while the daemon itself was down, the
27
+ exact outage it exists to report. `MenubarHelper` is a separate launchd
28
+ KeepAlive service that stays alive when the daemon dies, so its 10s tick now
29
+ polls daemon liveness independently of the dropdown ever being opened; once
30
+ it has been continuously unreachable for ~30s (debounced past a routine
31
+ restart blip), it fires one native notification ("Scheduler stopped —
32
+ routines won't run") through its own `NSUserNotificationCenter` delivery —
33
+ no daemon, no CLI spawn required — and lights the always-visible menu-bar
34
+ badge (`⏻`) until the scheduler comes back. Source:
35
+ `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`.
36
+
37
+ - **Observe umbrella aliases: `inbox`, `timeline`, `roster` (Phase 3 surface consolidation).**
38
+ Thin doors onto existing readers (no store merge): `agents inbox` ≡ `feed`,
39
+ `agents timeline` ≡ `feed --filter updates`, `agents roster` ≡ `sessions --active`.
40
+ Root help gains an Observe section; `agents audit` stays the tamper-evident run
41
+ log (not an events alias). Source: `apps/cli/src/lib/observe-aliases.ts`,
42
+ `apps/cli/src/commands/feed.ts`, `apps/cli/src/commands/sessions.ts`.
43
+
44
+ - **Daemon usage refresh is a fixed 5-minute per-host schedule, concurrency-safe, and Touch-ID-free.**
45
+ Each machine's daemon still owns its own usage cache (no fleet-wide store). Account live
46
+ fetches are now scheduled every **5 minutes** (was adaptive 90s–15m), with a 60s wake to
47
+ notice due accounts after backoff ends. Cache writes use a file lock + atomic rename so a
48
+ concurrent `agents view` background refresh cannot tear or drop rows. The daemon path
49
+ loads Claude credentials with **`fileOnly`** (setup-token / no-ACL cache / `.credentials.json`
50
+ only) and never opens the ACL-bound macOS keychain item, so a background tick cannot pop
51
+ Touch ID. Refresh still skips a provider under 429 backoff and still never rotates
52
+ single-use Claude refresh tokens.
53
+
54
+ ## 1.22.8
55
+
56
+ - **`agents browser` now gates cross-machine drives behind per-device consent.** `agents browser <cmd> --host <device>` already routes a browser command to another fleet machine over SSH and drives its browser — but nothing asked that machine's permission, so any box you could SSH to, you could drive. A new device-local `browser.remote-control` setting (off by default, never synced) fixes that: a fleet-remote `browser --host <this-machine> start` is refused with an actionable message until the owner runs `agents browser remote-control on` here. Local starts (no `--host`) are never gated. The fleet passthrough marks every `--host` dispatch with `AGENTS_FLEET_REMOTE` so the far side can tell a cross-machine drive from a local one. New command: `agents browser remote-control [on|off]` (no arg prints status; `--json` supported). Source: `apps/cli/src/lib/browser/remote-control.ts`, `apps/cli/src/commands/browser.ts`, `apps/cli/src/lib/hosts/passthrough.ts`, `apps/cli/src/lib/device-config.ts`, `apps/cli/docs/browser.md`.
57
+
58
+ - **`agents browser` tasks are now attributed to the caller that ran `start`, not
59
+ to the browser daemon.** `Task.owner` (RUSH-2020) was resolved with
60
+ `resolveActor()` *inside* the shared, long-lived browser daemon, so every task —
61
+ no matter which agent or person opened it — was stamped with the identity of
62
+ whoever happened to start the daemon. The caller's identity is now forwarded over
63
+ IPC: the CLI (the caller's own process) puts `actor` (`resolveActor().id`) and
64
+ `launchId` (`$AGENT_LAUNCH_ID`, the per-run id `exec.ts` injects for every harness)
65
+ on the `start` request, and the daemon stamps exactly those. Adds `Task.launchId` —
66
+ which run created a task — the scope a later `browser status --mine` and the
67
+ no-flag current-task default will filter on. Source:
68
+ `apps/cli/src/lib/browser/types.ts`, `apps/cli/src/lib/browser/service.ts`,
69
+ `apps/cli/src/lib/browser/ipc.ts`, `apps/cli/src/commands/browser.ts`.
70
+
71
+ - **`agents harness edit` and `agents harness rename` are now real commands.** `editProfile` and `renameProfile` already existed in `lib/profiles.ts` but nothing on the CLI surface reached them, so changing a custom harness meant hand-editing its YAML. `agents harness edit <name>` applies `--model`, `--base-url`, `--version`, and `--description` in place, preserving fork lineage (an edit never marks a harness as forked from itself). `agents harness rename <name> <new-name>` renames the YAML file and its `name` field, and rewrites `forkedFrom` on every harness that pointed at the old name so the fork graph stays accurate. There is deliberately no `--label`: the header `agents view` prints is derived from the harness name, so renaming is how you change it.
72
+
73
+ - **Run-time messages call a custom harness a "custom harness", not a "profile".** When you `agents run <name>` a custom harness (created with `agents harness add`), the CLI now says `Resolved custom harness '<name>'` and, for a discarded cost tier, `cost tiers don't apply to custom harness '<name>'` — instead of the legacy internal noun "profile". The `--strategy` and account-picker notices on a custom-harness run are aligned too. Behavior is unchanged; the legacy `agents profiles` alias still works. Source: `apps/cli/src/commands/exec.ts`.
74
+
75
+ - **Placement model + `agents run --where` (Phase 2 surface consolidation).**
76
+ "Where does the body run?" is one shared object (`local | device | fleet | cloud | lease`)
77
+ in `src/lib/placement.ts`. `agents run --where device:<name>|auto|lease[:backend]|local`
78
+ expands into the existing `--host` / `--lease` paths; mixing doors fails loud. Docs
79
+ (`00-concepts.md` § Placement, `hosts.md`) and help on run / routines / monitors teach
80
+ the matrix — including that monitors `--device` is **owner**, not body placement.
81
+ Old flags remain aliases. Source: `apps/cli/src/lib/placement.ts`, `apps/cli/src/commands/exec.ts`.
82
+
83
+ - **`agents harness fork` no longer accepts `--label` (breaking change).** The `--label` flag was used to set a human-facing display name for a custom harness. Display names are now always derived from the profile's `name` via a curated vendor/brand table (`deepseek-flash` → `DeepSeek Flash`, `spark` → `Spark`), so the flag is superfluous. Any script that passes `--label` to `agents harness fork` will receive a CLI error; remove the flag to migrate.
84
+
85
+ - **`agents run` no longer auto-picks an account whose token the server has already rejected.** Account rotation judged an account "signed in" from a local heuristic — a credential file is present and its email decodes — which cannot tell a good token from a revoked-but-unexpired one, so `balanced`/`available`/`run auto` could route into a `revoked` account and die at spawn ("session expired"). Eligibility now also reads the daemon's live auth-health probe (`auth-health.ts`): a `revoked` (401/403) account is excluded from the pick, reported as `revoked` by the pre-flight readiness check, shown as "needs re-login" in the account picker, and named in the teams throttle warning. Fail-open: a missing probe or any non-revoked verdict never blocks a launch (a cached `revoked` keeps gating until the daemon's next probe clears it). Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/commands/run-account-picker.ts`, `apps/cli/src/commands/teams.ts`, `apps/cli/docs/hosts.md`.
86
+
87
+ - **Scheduled routines no longer overlap or outlive their configured timeout (RUSH-2186).** Detached cron, catchup, and monitor launches now take a cross-process per-routine claim and refuse a second fire while the prior run is alive. The configured deadline is persisted in run metadata; both the live runner and the restart-recovery monitor kill the owned process tree and record `timeout` when it expires. Source: `apps/cli/src/lib/runner.ts`, `apps/cli/src/lib/routines.ts`, `apps/cli/docs/03-routines.md`.
88
+
89
+ - **`agents snapshot` — one-process poll for inventory + active sessions (Phase 4 surface consolidation).**
90
+ Consumers (Factory, scripts, menubar) were forking `view --json` × N harnesses plus
91
+ `sessions --active --json` (and sometimes feed) on every tick. `agents snapshot --json`
92
+ returns the same shapes in one invocation: `inventory` (view), `sessions` (active rows),
93
+ optional `--with-feed` / `--with-sync`. Default sessions scope is this machine; `--all-hosts`
94
+ matches full `sessions --active` fan-out. Does **not** redefine `agents status`, which stays
95
+ the UnifiedSyncStatus sync contract. Source: `apps/cli/src/commands/snapshot.ts`,
96
+ `apps/cli/src/lib/snapshot.ts`.
97
+
3
98
  ## 1.22.7
4
99
 
5
100
  - **`agents feed --project <name>` scopes the whole feed to one project.** Open
@@ -939,6 +1034,8 @@
939
1034
 
940
1035
  ## 1.20.92
941
1036
 
1037
+ - **`agents sessions render <id...>` produces shareable, redacted Markdown instead of raw harness JSONL.** Claude, Codex, Kimi, Grok, Cursor, and Droid transcripts flow through the existing normalized `SessionEvent[]` parsers, then render with the same session-browser preview at the top, ordered user/assistant turns, fenced shell commands, JSON tool arguments, and explicitly truncated tool results. Redaction remains on by default through the canonical redactor, now also masking Unix/macOS/Windows home-directory identities and live secret values; `--no-redact` is local-only. Reasoning defaults to omitted and can be folded or included explicitly. One or several selected sessions can be written to a mode-`0600` Markdown file, and `--json` exposes the rendered documents for machine callers. The repo session skill and transcript-sharing guidance now require rendering `.md` before creating a confidential gist. Source: `apps/cli/src/commands/sessions-render.ts`, `apps/cli/src/lib/session/render.ts`, `apps/cli/src/lib/redact.ts`, `.agents/skills/sessions/SKILL.md`.
1038
+
942
1039
  - **`agents sessions` rows show creation time as well as last activity (RUSH-2107).**
943
1040
  The trailing time cell used to carry one unlabeled "X ago" — last activity — so a
944
1041
  row could not say when the session began or how long it had been alive. It now
package/README.md CHANGED
@@ -255,6 +255,9 @@ agents sessions --project my-app
255
255
  # Read a full conversation
256
256
  agents sessions a1b2c3d4 --markdown
257
257
 
258
+ # Render a shareable, redacted Markdown transcript with the session preview on top
259
+ agents sessions render a1b2c3d4 -o session.md
260
+
258
261
  # Just the last 3 turns, user messages only
259
262
  agents sessions a1b2c3d4 --last 3 --include user
260
263
 
@@ -323,6 +326,8 @@ Each live session resolves to `working`, `waiting_input` (with why -- a question
323
326
 
324
327
  Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work (including parsed Bash commands -- `git`, `npm`, `ffmpeg`, `ssh`, and so on), and the last test verdict -- the signals to reload a task in seconds.
325
328
 
329
+ Sharing a session uses `agents sessions render <id> -o session.md`, not the raw harness JSONL. The document starts with that same preview, then presents user and assistant turns, fenced commands, structured tool arguments, and bounded tool output. Credential-shaped values and local home paths are redacted by default; `--no-redact` is for local-only inspection.
330
+
326
331
  ### Resume anywhere — and stay resumed
327
332
 
328
333
  Pick up any past conversation and drop it back into a terminal:
package/dist/bin/agents CHANGED
Binary file
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { listProfiles, getProfile, createProfile, deleteProfile, ensureDefaultBrowserProfile, getConfiguredDefaultProfileName, DEFAULT_BROWSER_PROFILE_NAME, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, } from '../lib/browser/profiles.js';
4
4
  import { updateMeta } from '../lib/state.js';
5
+ import { resolveActor } from '../lib/actor.js';
5
6
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
6
7
  import { parseSecretRef } from '../lib/browser/secret-ref.js';
7
8
  import { readAndResolveBundleEnv, isHeadlessSecretsContext, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
@@ -13,6 +14,8 @@ import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.
13
14
  import { parseTargetFilter } from '../lib/browser/service.js';
14
15
  import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest, } from '../lib/browser/ipc.js';
15
16
  import { browserTaskPicker } from './browser-picker.js';
17
+ import { assertRemoteControlAllowed } from '../lib/browser/remote-control.js';
18
+ import { getConfigValue, setConfigValue } from '../lib/device-config.js';
16
19
  import { isInteractiveTerminal } from './utils.js';
17
20
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
18
21
  import { buildHar } from '../lib/browser/har.js';
@@ -76,6 +79,12 @@ export function registerBrowserCommand(program) {
76
79
  agents browser navigate https://example.com
77
80
  agents browser screenshot
78
81
 
82
+ # Drive another machine's browser (needs its consent — see remote-control)
83
+ agents browser start --host zion
84
+
85
+ # Allow / deny other fleet machines driving THIS machine's browser
86
+ agents browser remote-control on
87
+
79
88
  # End the session when done
80
89
  agents browser done
81
90
  `,
@@ -609,6 +618,42 @@ function registerProfilesCommands(browser) {
609
618
  });
610
619
  }
611
620
  function registerTaskCommands(browser) {
621
+ browser
622
+ .command('remote-control [state]')
623
+ .description("Allow or deny other fleet machines driving THIS machine's browser over `browser --host`. " +
624
+ '`on`/`off` to set (device-local, never synced); no argument prints the current value. Default off.')
625
+ .option('--json', 'Output as JSON')
626
+ .action((state, opts) => {
627
+ const KEY = 'browser.remote-control';
628
+ if (state === undefined) {
629
+ const cur = getConfigValue(KEY).value === true;
630
+ if (opts.json) {
631
+ console.log(JSON.stringify({ remoteControl: cur }));
632
+ return;
633
+ }
634
+ console.log(`Remote browser control (this machine): ${cur ? 'on' : 'off'}`);
635
+ if (!cur)
636
+ console.log('Enable with: agents browser remote-control on');
637
+ return;
638
+ }
639
+ const norm = state.toLowerCase();
640
+ const onWords = ['on', 'true', 'yes', 'allow', 'enable'];
641
+ const offWords = ['off', 'false', 'no', 'deny', 'disable'];
642
+ if (!onWords.includes(norm) && !offWords.includes(norm)) {
643
+ console.error(`Expected "on" or "off", got "${state}".`);
644
+ process.exit(1);
645
+ }
646
+ const value = onWords.includes(norm);
647
+ setConfigValue(KEY, value);
648
+ if (opts.json) {
649
+ console.log(JSON.stringify({ remoteControl: value }));
650
+ return;
651
+ }
652
+ console.log(`Remote browser control (this machine) is now ${value ? 'on' : 'off'}.`);
653
+ console.log(value
654
+ ? 'Other fleet machines can now drive this browser via `browser --host <this-device>`.'
655
+ : 'Cross-machine `browser --host` drives to this machine are refused.');
656
+ });
612
657
  browser
613
658
  .command('start')
614
659
  .description('Start a browser task. Pass --profile <name>; omit to use your configured default (`agents browser profiles set-default`), else auto-pick an installed Chromium-family browser.')
@@ -622,6 +667,16 @@ function registerTaskCommands(browser) {
622
667
  .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
623
668
  .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
624
669
  .action(async (opts) => {
670
+ // Consent gate: a fleet-remote `browser --host <this-machine> start` may
671
+ // only open a browser here if the owner opted in. Refuse before we resolve
672
+ // or auto-create any profile. Local starts are never gated.
673
+ try {
674
+ assertRemoteControlAllowed();
675
+ }
676
+ catch (err) {
677
+ console.error(err instanceof Error ? err.message : String(err));
678
+ process.exit(1);
679
+ }
625
680
  let profileName = opts.profile;
626
681
  if (!profileName) {
627
682
  try {
@@ -698,6 +753,12 @@ function registerTaskCommands(browser) {
698
753
  url: opts.url,
699
754
  endpoint: opts.endpoint,
700
755
  skipDomainSkill: opts.skills === false,
756
+ // Forward the caller's identity: the browser daemon is shared, so it
757
+ // cannot resolve who/which-run called `start`. `resolveActor()` runs
758
+ // here in the CLI (the caller's process); `$AGENT_LAUNCH_ID` is the
759
+ // per-run id exec.ts injects for every harness.
760
+ actor: resolveActor().id,
761
+ launchId: process.env.AGENT_LAUNCH_ID,
701
762
  });
702
763
  if (!response.ok) {
703
764
  console.error(response.error);
@@ -553,15 +553,17 @@ export function registerRunCommand(program) {
553
553
  .option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
554
554
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
555
555
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
556
- .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick from 14d usage affinity (most-used online device has highest probability). See `agents devices`.')
557
- .option('--device <name>', 'Alias of --host. Pass "auto" for affinity-based device pick (same as --host auto).')
556
+ .option('--where <spec>', 'Where this run\'s body executes (one placement door): local | device:<name> | auto | lease[:backend]. Expands to --host/--lease. Do not combine with those flags. See docs/00-concepts.md#placement.')
557
+ .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick from 14d usage affinity (most-used online device has highest probability). Same as --where device:<name>. See `agents devices`.')
558
+ .option('--device <name>', 'Alias of --host. Pass "auto" for affinity-based device pick (same as --where auto).')
558
559
  .option('--remote-cwd <dir>', "Explicit host working directory for --host runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
559
560
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
560
561
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
561
562
  .option('--copy-creds', 'With --host, copy the picked runtime credentials (and Claude OAuth token) to the host, then shred them after the run. Opt-in per run.')
562
- .option('--lease [backend]', 'Invent a disposable cloud box for this run and tear it down after (via crabbox). Optional backend selects the cloud (hetzner/aws/do). Unlike --host, no machine is registered.')
563
+ .option('--lease [backend]', "Run on a cloud box (via crabbox) and tear it down after — reuses a warm box from the repo's profile pool when one is ready (--fresh forces a new box). Optional backend selects the cloud (hetzner/aws/do). Same as --where lease[:backend]. Unlike --host, no machine is registered.")
563
564
  .option('--box <slug>', 'Reuse an existing warm crabbox box for this run instead of provisioning a disposable --lease box.')
564
565
  .option('--keep-box', 'With --lease, keep the box after the run instead of stopping it.')
566
+ .option('--fresh', "With --lease, always provision a brand-new box (skip the warm profile-pool reuse) and tear it down after the run.")
565
567
  .option('--reuse', 'With --lease, reuse the most-recently-used warm box if one exists (else provision fresh). The scriptable form of the interactive reuse picker.')
566
568
  .option('--bare', 'With --lease, skip copying your local ~/.agents setup (skills/hooks/commands/MCP) onto the box.')
567
569
  .option('--tailscale', 'Lease the box onto your tailnet (reachable only over Tailscale) rather than a public IP.')
@@ -601,6 +603,11 @@ export function registerRunCommand(program) {
601
603
  agents run auto "fix the flaky test" --mode edit
602
604
  agents run auto --host yosemite-s0 "fix the flaky test" # pin the host
603
605
 
606
+ # Placement (one door — where the body runs). Old flags still work.
607
+ agents run claude "…" --where device:yosemite-s0 # = --host yosemite-s0
608
+ agents run claude "…" --where auto # = --device auto
609
+ agents run claude "fix CI" --where lease --mode edit
610
+
604
611
  # Open the session in a terminal tab — detected from where your sessions
605
612
  # already run (Ghostty / iTerm / Terminal.app); force one with a value
606
613
  agents run claude --terminal
@@ -615,6 +622,14 @@ export function registerRunCommand(program) {
615
622
  # Inject a keychain-backed secrets bundle
616
623
  agents run claude "deploy the worker" --secrets prod --mode edit
617
624
 
625
+ # Run on a cloud box — reuses a warm box from the repo's profile pool when
626
+ # one is ready (kept after the run), else leases a fresh box, torn down after
627
+ agents run claude "fix the failing tests" --lease
628
+
629
+ # Force a brand-new box (destroyed after), or target a warm box by slug
630
+ agents run claude "fix the failing tests" --lease --fresh
631
+ agents run claude "fix the failing tests" --box warm-one
632
+
618
633
  # Pass arbitrary native flags to the underlying CLI via -- separator
619
634
  agents run kimi -- --plan --some-kimi-option value
620
635
  agents run claude "fix the bug" -- --custom-flag
@@ -688,6 +703,36 @@ export function registerRunCommand(program) {
688
703
  await handleTerminalHandoff(agentSpec, options, prompt);
689
704
  return;
690
705
  }
706
+ // Placement: --where expands into --host / --lease before any dispatch.
707
+ // One door for "where does the body run?" — old flags remain aliases.
708
+ // See lib/placement.ts and docs/00-concepts.md#placement.
709
+ {
710
+ const { placementFromRunFlags, expandPlacementToRunFlags, PlacementError } = await import('../lib/placement.js');
711
+ try {
712
+ const placement = placementFromRunFlags(options);
713
+ if (options.where) {
714
+ const expanded = expandPlacementToRunFlags(placement);
715
+ if (expanded.host !== undefined)
716
+ options.host = expanded.host;
717
+ if (expanded.device !== undefined)
718
+ options.device = expanded.device;
719
+ if (expanded.lease !== undefined)
720
+ options.lease = expanded.lease;
721
+ if (expanded.box !== undefined)
722
+ options.box = expanded.box;
723
+ // Clear the where flag so remote re-entry (host dispatch) does not
724
+ // re-expand and conflict with the concrete host we just set.
725
+ options.where = undefined;
726
+ }
727
+ }
728
+ catch (err) {
729
+ if (err instanceof PlacementError) {
730
+ console.error(chalk.red(err.message));
731
+ process.exit(1);
732
+ }
733
+ throw err;
734
+ }
735
+ }
691
736
  // --notify: post a desktop notification when this run finishes. Armed on
692
737
  // process exit so it covers EVERY dispatch path below (local, --host,
693
738
  // --lease, the error path) instead of one branch. Only for headless runs
@@ -788,6 +833,14 @@ export function registerRunCommand(program) {
788
833
  console.error(chalk.red('Pass either --lease to provision a disposable box, or --box <slug> to reuse a warm box — not both.'));
789
834
  process.exit(1);
790
835
  }
836
+ if (options.fresh && options.box) {
837
+ console.error(chalk.red('--fresh forces a brand-new box; it cannot be combined with --box <slug> (which reuses one).'));
838
+ process.exit(1);
839
+ }
840
+ if (options.fresh && options.reuse) {
841
+ console.error(chalk.red('--fresh forces a brand-new box; it cannot be combined with --reuse.'));
842
+ process.exit(1);
843
+ }
791
844
  const backend = typeof options.lease === 'string' ? options.lease : undefined;
792
845
  // crabbox syncs this directory to the box via `git ls-files`; outside a
793
846
  // git repo that fails with "build sync file list: exit status 128" — but
@@ -812,12 +865,21 @@ export function registerRunCommand(program) {
812
865
  // ── F3 reuse (RUSH-1922) + F5 net-mode (RUSH-1924) ───────────────────
813
866
  // Resolve which box this run targets and how it is networked BEFORE any
814
867
  // provisioning. `--box` is an explicit reuse; otherwise, on an
815
- // interactive tty, offer the warm boxes as a reuse picker (headless /
816
- // --json never blocks — it provisions fresh unless --reuse/--box).
868
+ // interactive tty, offer the warm boxes as a reuse picker. Headless runs
869
+ // never block: leaseAndRun itself is reuse-first against the profile
870
+ // pool (a ready pool box is reused; none ready → warm a fresh one).
871
+ // `--fresh` opts out of every reuse path.
817
872
  const leaseSecretsBundle = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
818
873
  const nowSecs = Math.floor(Date.now() / 1000);
819
874
  let reuseSlug = options.box;
820
- if (options.lease && !reuseSlug) {
875
+ // The profile this run's pool/box carries: the repo's .crabbox.yaml
876
+ // `profile:` when declared (what crabbox warmup would label a fresh box
877
+ // with), else crabbox's default. Passing it to leaseAndRun makes the
878
+ // pool-reuse match interchangeable with a fresh warmup.
879
+ const repoRoot = gitToplevel(leaseCwd);
880
+ const { readCrabboxRepoProfile } = await import('../lib/crabbox/config.js');
881
+ const poolProfile = repoRoot ? readCrabboxRepoProfile(repoRoot) : undefined;
882
+ if (options.lease && !reuseSlug && !options.fresh) {
821
883
  const { crabboxList } = await import('../lib/crabbox/cli.js');
822
884
  const { reusableBoxes, formatBoxRow } = await import('./lease.js');
823
885
  let warm = [];
@@ -825,9 +887,8 @@ export function registerRunCommand(program) {
825
887
  warm = reusableBoxes(crabboxList({ secretsBundle: leaseSecretsBundle }), nowSecs);
826
888
  }
827
889
  catch {
828
- warm = []; // crabbox unavailable / no creds → just provision fresh
890
+ warm = []; // crabbox unavailable / no creds → the pool check in leaseAndRun decides
829
891
  }
830
- const repoRoot = gitToplevel(leaseCwd);
831
892
  const alwaysFresh = repoRoot ? isAlwaysFreshRepo(readAlwaysFreshRepos(), repoRoot) : false;
832
893
  if (warm.length > 0 && !alwaysFresh) {
833
894
  if (options.reuse) {
@@ -860,7 +921,8 @@ export function registerRunCommand(program) {
860
921
  console.error(chalk.yellow('Selection cancelled — provisioning a fresh box.'));
861
922
  }
862
923
  }
863
- // Headless with no --reuse falls through here → provision fresh.
924
+ // Headless with no --reuse falls through here → leaseAndRun's
925
+ // profile-pool check decides (reuse a ready pool box, else warm fresh).
864
926
  }
865
927
  else if (options.reuse && warm.length > 0) {
866
928
  // --reuse still honors a warm box even when the picker is suppressed.
@@ -979,12 +1041,16 @@ export function registerRunCommand(program) {
979
1041
  : `${runtime} credentials`;
980
1042
  const boxLifecycle = reuseSlug
981
1043
  ? `Reusing crabbox box ${reuseSlug}`
982
- : `Leasing a ${backend ?? 'hetzner'} box${netMode === 'tailscale' ? ' on your tailnet' : ''}`;
1044
+ : options.fresh
1045
+ ? `Leasing a fresh ${backend ?? 'hetzner'} box${netMode === 'tailscale' ? ' on your tailnet' : ''}`
1046
+ : `Leasing a ${backend ?? 'hetzner'} box${netMode === 'tailscale' ? ' on your tailnet' : ''} (a ready box from the '${poolProfile ?? 'default'}' pool is reused when one exists)`;
983
1047
  const boxAfterRun = reuseSlug
984
1048
  ? 'the box is kept after the run'
985
1049
  : options.keepBox
986
1050
  ? 'the box is kept after the run'
987
- : 'the box is destroyed after the run';
1051
+ : options.fresh
1052
+ ? 'the box is destroyed after the run'
1053
+ : 'a fresh box is destroyed after the run; a reused pool box is kept';
988
1054
  console.error(chalk.gray(`${boxLifecycle} · shipping ${whatShips}${credentialRuntimes.length > 0 && leaseEmail ? ` (${leaseEmail})` : ''}; ${boxAfterRun}.`));
989
1055
  // Read the Claude OAuth token from the local Keychain (silent) so it can be
990
1056
  // written to ~/.claude/.credentials.json on the box — otherwise Claude boots
@@ -1064,6 +1130,8 @@ export function registerRunCommand(program) {
1064
1130
  secretsBundle: leaseSecretsBundle,
1065
1131
  keep: options.keepBox,
1066
1132
  reuseBox: reuseSlug,
1133
+ fresh: options.fresh,
1134
+ profile: poolProfile,
1067
1135
  copySetup,
1068
1136
  netMode,
1069
1137
  onData: (chunk) => router.push(chunk),
@@ -1615,7 +1683,7 @@ export function registerRunCommand(program) {
1615
1683
  const cwd = options.cwd ?? process.cwd();
1616
1684
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1617
1685
  if (profileExists(rawAgent)) {
1618
- console.error(chalk.red(`Account selection is not available for profile '${rawAgent}'. Run its concrete host agent with @ instead.`));
1686
+ console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with @ instead.`));
1619
1687
  process.exit(1);
1620
1688
  }
1621
1689
  if (resolveWorkflowRef(rawAgent, cwd)) {
@@ -1675,7 +1743,7 @@ export function registerRunCommand(program) {
1675
1743
  profileEnv = resolved.env;
1676
1744
  profileFallbackModel = resolved.fallbackModel;
1677
1745
  fromProfile = true;
1678
- process.stderr.write(chalk.gray(`Resolved profile '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1746
+ process.stderr.write(chalk.gray(`Resolved custom harness '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1679
1747
  if (resolved.tierNote) {
1680
1748
  process.stderr.write(chalk.gray(`[agents] ${resolved.tierNote}\n`));
1681
1749
  }
@@ -1685,7 +1753,7 @@ export function registerRunCommand(program) {
1685
1753
  // native, HOST-catalog tier block below. When the profile has no
1686
1754
  // `models:` opt-in at all, resolvedModel stays undefined and
1687
1755
  // options.model is left as the raw tier token on purpose — the
1688
- // "cost tiers don't apply to profile ..." discard guard further
1756
+ // "cost tiers don't apply to custom harness ..." discard guard further
1689
1757
  // down this function is the canonical fallback for that case, and
1690
1758
  // this block must not race it with a second, differently-worded
1691
1759
  // message.
@@ -1894,7 +1962,7 @@ export function registerRunCommand(program) {
1894
1962
  else {
1895
1963
  console.error(chalk.red(`Unknown agent: ${rawAgent}`));
1896
1964
  console.error(chalk.gray(`Available agents: ${ALL_AGENT_IDS.join(', ')}`));
1897
- console.error(chalk.gray(`Or add a profile: agents profiles add <name>`));
1965
+ console.error(chalk.gray(`Or add a custom harness: agents harness add <name>`));
1898
1966
  process.exit(1);
1899
1967
  }
1900
1968
  }
@@ -2062,7 +2130,7 @@ export function registerRunCommand(program) {
2062
2130
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2063
2131
  }
2064
2132
  else if (fromProfile) {
2065
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: profile pins its own version/auth\n`));
2133
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2066
2134
  }
2067
2135
  else {
2068
2136
  try {
@@ -2312,12 +2380,12 @@ export function registerRunCommand(program) {
2312
2380
  ? (workflowModel ?? (options.fallback ? undefined : runDefaults.model))
2313
2381
  : undefined);
2314
2382
  // Cost tiers (cheap|default|best|ultra) resolve against a harness's own model
2315
- // catalog. A profile's model comes from its endpoint, not the host harness, so a
2316
- // tier here would forward an incompatible host-harness model to a different API.
2317
- // Discard it loudly and let the profile's configured model stand.
2383
+ // catalog. A custom harness's model comes from its endpoint, not the host
2384
+ // harness, so a tier here would forward an incompatible host-harness model to a
2385
+ // different API. Discard it loudly and let the custom harness's own model stand.
2318
2386
  if (fromProfile && model && isTierToken(model)) {
2319
- process.stderr.write(chalk.yellow(`[agents] --model ${model}: cost tiers don't apply to profile '${rawAgent}' ` +
2320
- `(its model comes from the endpoint) — ignoring the tier, using the profile's configured model\n`));
2387
+ process.stderr.write(chalk.yellow(`[agents] --model ${model}: cost tiers don't apply to custom harness '${rawAgent}' ` +
2388
+ `(its model comes from the endpoint) — ignoring the tier, using the custom harness's configured model\n`));
2321
2389
  model = undefined;
2322
2390
  }
2323
2391
  const execOptions = {
@@ -222,7 +222,7 @@ export function sessionHintsFromActive(sessions) {
222
222
  export function registerFeedCommand(program) {
223
223
  const feed = program
224
224
  .command('feed')
225
- .description('Open blocks (needs you) + agent status posts (feed post)')
225
+ .description('Operator inbox + agent status posts (aliases: inbox = needs-you; timeline = --filter updates)')
226
226
  .option('--json', 'Output as JSON (each block stamped with its outcome + ask class)')
227
227
  .option('--filter <view>', 'What to show: needs (default) · updates · all', 'needs')
228
228
  .option('--flat', 'List one block per agent instead of grouping by outcome')
@@ -572,6 +572,43 @@ docs/06-observability.md.
572
572
  renderOutcomeGroup(g, self);
573
573
  await renderTrailingActivity();
574
574
  });
575
+ // Observe-umbrella aliases (Phase 3): intentional second names, not deprecations.
576
+ // Re-parse into `feed` so flags/help stay single-sourced. See lib/observe-aliases.ts.
577
+ registerFeedObserveAliases(program);
578
+ }
579
+ /**
580
+ * `inbox` / `timeline` → feed. Loaded with the feed module so lazy COMMAND_LOADERS
581
+ * for those names also get the real `feed` command registered for re-parse.
582
+ */
583
+ function registerFeedObserveAliases(program) {
584
+ const reparse = async (alias) => {
585
+ const { expandObserveAlias } = await import('../lib/observe-aliases.js');
586
+ const rest = process.argv.slice(3);
587
+ const expanded = expandObserveAlias(alias, rest);
588
+ if (!expanded) {
589
+ console.error(chalk.red(`Unknown observe alias: ${alias}`));
590
+ process.exit(1);
591
+ }
592
+ if (process.stderr.isTTY)
593
+ process.stderr.write(chalk.gray(`${expanded.note}\n`));
594
+ await program.parseAsync(['node', 'agents', ...expanded.argv]);
595
+ };
596
+ program
597
+ .command('inbox')
598
+ .description('Needs-you inbox (alias of `agents feed`). Open blocks waiting on you.')
599
+ .allowUnknownOption()
600
+ .allowExcessArguments()
601
+ .action(async () => {
602
+ await reparse('inbox');
603
+ });
604
+ program
605
+ .command('timeline')
606
+ .description('Agent progress stream (alias of `agents feed --filter updates`). What agents posted recently.')
607
+ .allowUnknownOption()
608
+ .allowExcessArguments()
609
+ .action(async () => {
610
+ await reparse('timeline');
611
+ });
575
612
  }
576
613
  /**
577
614
  * Mirror a written post to the configured sinks (`feed.broadcast` in
@@ -11,7 +11,8 @@
11
11
  * native harness registry. The `agents profiles` tree stays unchanged.
12
12
  */
13
13
  import type { Command } from 'commander';
14
- import { type Profile } from '../lib/profiles.js';
14
+ import { type AddProfileOptions } from './profiles.js';
15
+ import { type Profile, type ForkProfileOptions } from '../lib/profiles.js';
15
16
  /**
16
17
  * Print one custom harness. Shared by `agents harness view <name>` and by
17
18
  * `agents view <name>` — a custom harness resolves as an agent type there, so
@@ -24,11 +25,26 @@ export interface ForkOptions {
24
25
  baseUrl?: string;
25
26
  authProvider?: string;
26
27
  version?: string;
27
- label?: string;
28
28
  description?: string;
29
+ /** `<bundle>` or `<bundle>:<key>` — see {@link applyFromSecrets} in ./profiles.js. */
30
+ fromSecrets?: string;
29
31
  keyStdin?: boolean;
30
32
  force?: boolean;
31
33
  }
34
+ /** Options accepted by `agents harness edit`. */
35
+ export interface EditOptions {
36
+ model?: string;
37
+ baseUrl?: string;
38
+ authProvider?: string;
39
+ /** Empty string ('') unpins the host CLI version. */
40
+ version?: string;
41
+ description?: string;
42
+ /** Empty string ('') clears the fallback model. */
43
+ fallbackModel?: string;
44
+ /** `<bundle>` or `<bundle>:<key>` — see {@link applyFromSecrets} in ./profiles.js. */
45
+ fromSecrets?: string;
46
+ keyStdin?: boolean;
47
+ }
32
48
  /**
33
49
  * Build the new harness for `agents harness fork <source> <name>`.
34
50
  *
@@ -38,4 +54,26 @@ export interface ForkOptions {
38
54
  * copy a model from.
39
55
  */
40
56
  export declare function buildFork(source: string, name: string, opts: ForkOptions): Profile;
57
+ /** Map `agents harness edit` flags onto {@link ForkProfileOptions} — the same
58
+ * override shape `editProfile`/`forkProfile` apply. `--version ''` (unpin) is
59
+ * handled by the caller ({@link buildEdit}), not here: `forkProfile`'s own
60
+ * ternary treats an empty string as "no override" and would otherwise inherit
61
+ * the source's version instead of clearing it. */
62
+ export declare function buildEditOverrides(opts: EditOptions): ForkProfileOptions;
63
+ /** True when at least one recognized edit flag was given. */
64
+ export declare function hasEditFlags(opts: EditOptions): boolean;
65
+ /**
66
+ * Build the edited harness for `agents harness edit <name>` — the profile-shape
67
+ * transform only; the caller applies `--from-secrets`/`--auth-provider` (async
68
+ * keychain side effects) and persists with `writeProfile`. Mirrors
69
+ * {@link buildFork}'s split between a pure builder and the action's IO.
70
+ */
71
+ export declare function buildEdit(name: string, opts: EditOptions): Profile;
72
+ /** True when `agents harness fork` was given too little to proceed without the wizard. */
73
+ export declare function forkNeedsWizard(source: string | undefined, name: string | undefined, opts: ForkOptions): boolean;
74
+ /** True when `agents harness add` was given too little to proceed without the wizard.
75
+ * Mirrors {@link addProfile}'s own error condition in ./profiles.js so a bare
76
+ * `agents harness add <preset-name>` (no flags) still resolves via the preset
77
+ * fallback instead of being routed into the wizard. */
78
+ export declare function addNeedsWizard(name: string | undefined, opts: AddProfileOptions): boolean;
41
79
  export declare function registerHarnessCommands(program: Command): void;