@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.8
4
+
5
+ - **`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`.
6
+
7
+ - **`agents browser` tasks are now attributed to the caller that ran `start`, not
8
+ to the browser daemon.** `Task.owner` (RUSH-2020) was resolved with
9
+ `resolveActor()` *inside* the shared, long-lived browser daemon, so every task —
10
+ no matter which agent or person opened it — was stamped with the identity of
11
+ whoever happened to start the daemon. The caller's identity is now forwarded over
12
+ IPC: the CLI (the caller's own process) puts `actor` (`resolveActor().id`) and
13
+ `launchId` (`$AGENT_LAUNCH_ID`, the per-run id `exec.ts` injects for every harness)
14
+ on the `start` request, and the daemon stamps exactly those. Adds `Task.launchId` —
15
+ which run created a task — the scope a later `browser status --mine` and the
16
+ no-flag current-task default will filter on. Source:
17
+ `apps/cli/src/lib/browser/types.ts`, `apps/cli/src/lib/browser/service.ts`,
18
+ `apps/cli/src/lib/browser/ipc.ts`, `apps/cli/src/commands/browser.ts`.
19
+
20
+ - **`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.
21
+
22
+ - **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`.
23
+
24
+ - **Placement model + `agents run --where` (Phase 2 surface consolidation).**
25
+ "Where does the body run?" is one shared object (`local | device | fleet | cloud | lease`)
26
+ in `src/lib/placement.ts`. `agents run --where device:<name>|auto|lease[:backend]|local`
27
+ expands into the existing `--host` / `--lease` paths; mixing doors fails loud. Docs
28
+ (`00-concepts.md` § Placement, `hosts.md`) and help on run / routines / monitors teach
29
+ the matrix — including that monitors `--device` is **owner**, not body placement.
30
+ Old flags remain aliases. Source: `apps/cli/src/lib/placement.ts`, `apps/cli/src/commands/exec.ts`.
31
+
32
+ - **`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.
33
+
34
+ - **`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`.
35
+
36
+ - **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`.
37
+
38
+ - **`agents snapshot` — one-process poll for inventory + active sessions (Phase 4 surface consolidation).**
39
+ Consumers (Factory, scripts, menubar) were forking `view --json` × N harnesses plus
40
+ `sessions --active --json` (and sometimes feed) on every tick. `agents snapshot --json`
41
+ returns the same shapes in one invocation: `inventory` (view), `sessions` (active rows),
42
+ optional `--with-feed` / `--with-sync`. Default sessions scope is this machine; `--all-hosts`
43
+ matches full `sessions --active` fan-out. Does **not** redefine `agents status`, which stays
44
+ the UnifiedSyncStatus sync contract. Source: `apps/cli/src/commands/snapshot.ts`,
45
+ `apps/cli/src/lib/snapshot.ts`.
46
+
3
47
  ## 1.22.7
4
48
 
5
49
  - **`agents feed --project <name>` scopes the whole feed to one project.** Open
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,13 +553,14 @@ 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]', 'Invent a disposable cloud box for this run and tear it down after (via crabbox). 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.')
565
566
  .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.')
@@ -601,6 +602,11 @@ export function registerRunCommand(program) {
601
602
  agents run auto "fix the flaky test" --mode edit
602
603
  agents run auto --host yosemite-s0 "fix the flaky test" # pin the host
603
604
 
605
+ # Placement (one door — where the body runs). Old flags still work.
606
+ agents run claude "…" --where device:yosemite-s0 # = --host yosemite-s0
607
+ agents run claude "…" --where auto # = --device auto
608
+ agents run claude "fix CI" --where lease --mode edit
609
+
604
610
  # Open the session in a terminal tab — detected from where your sessions
605
611
  # already run (Ghostty / iTerm / Terminal.app); force one with a value
606
612
  agents run claude --terminal
@@ -688,6 +694,36 @@ export function registerRunCommand(program) {
688
694
  await handleTerminalHandoff(agentSpec, options, prompt);
689
695
  return;
690
696
  }
697
+ // Placement: --where expands into --host / --lease before any dispatch.
698
+ // One door for "where does the body run?" — old flags remain aliases.
699
+ // See lib/placement.ts and docs/00-concepts.md#placement.
700
+ {
701
+ const { placementFromRunFlags, expandPlacementToRunFlags, PlacementError } = await import('../lib/placement.js');
702
+ try {
703
+ const placement = placementFromRunFlags(options);
704
+ if (options.where) {
705
+ const expanded = expandPlacementToRunFlags(placement);
706
+ if (expanded.host !== undefined)
707
+ options.host = expanded.host;
708
+ if (expanded.device !== undefined)
709
+ options.device = expanded.device;
710
+ if (expanded.lease !== undefined)
711
+ options.lease = expanded.lease;
712
+ if (expanded.box !== undefined)
713
+ options.box = expanded.box;
714
+ // Clear the where flag so remote re-entry (host dispatch) does not
715
+ // re-expand and conflict with the concrete host we just set.
716
+ options.where = undefined;
717
+ }
718
+ }
719
+ catch (err) {
720
+ if (err instanceof PlacementError) {
721
+ console.error(chalk.red(err.message));
722
+ process.exit(1);
723
+ }
724
+ throw err;
725
+ }
726
+ }
691
727
  // --notify: post a desktop notification when this run finishes. Armed on
692
728
  // process exit so it covers EVERY dispatch path below (local, --host,
693
729
  // --lease, the error path) instead of one branch. Only for headless runs
@@ -1615,7 +1651,7 @@ export function registerRunCommand(program) {
1615
1651
  const cwd = options.cwd ?? process.cwd();
1616
1652
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1617
1653
  if (profileExists(rawAgent)) {
1618
- console.error(chalk.red(`Account selection is not available for profile '${rawAgent}'. Run its concrete host agent with @ instead.`));
1654
+ console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with @ instead.`));
1619
1655
  process.exit(1);
1620
1656
  }
1621
1657
  if (resolveWorkflowRef(rawAgent, cwd)) {
@@ -1675,7 +1711,7 @@ export function registerRunCommand(program) {
1675
1711
  profileEnv = resolved.env;
1676
1712
  profileFallbackModel = resolved.fallbackModel;
1677
1713
  fromProfile = true;
1678
- process.stderr.write(chalk.gray(`Resolved profile '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1714
+ process.stderr.write(chalk.gray(`Resolved custom harness '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1679
1715
  if (resolved.tierNote) {
1680
1716
  process.stderr.write(chalk.gray(`[agents] ${resolved.tierNote}\n`));
1681
1717
  }
@@ -1685,7 +1721,7 @@ export function registerRunCommand(program) {
1685
1721
  // native, HOST-catalog tier block below. When the profile has no
1686
1722
  // `models:` opt-in at all, resolvedModel stays undefined and
1687
1723
  // options.model is left as the raw tier token on purpose — the
1688
- // "cost tiers don't apply to profile ..." discard guard further
1724
+ // "cost tiers don't apply to custom harness ..." discard guard further
1689
1725
  // down this function is the canonical fallback for that case, and
1690
1726
  // this block must not race it with a second, differently-worded
1691
1727
  // message.
@@ -1894,7 +1930,7 @@ export function registerRunCommand(program) {
1894
1930
  else {
1895
1931
  console.error(chalk.red(`Unknown agent: ${rawAgent}`));
1896
1932
  console.error(chalk.gray(`Available agents: ${ALL_AGENT_IDS.join(', ')}`));
1897
- console.error(chalk.gray(`Or add a profile: agents profiles add <name>`));
1933
+ console.error(chalk.gray(`Or add a custom harness: agents harness add <name>`));
1898
1934
  process.exit(1);
1899
1935
  }
1900
1936
  }
@@ -2062,7 +2098,7 @@ export function registerRunCommand(program) {
2062
2098
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2063
2099
  }
2064
2100
  else if (fromProfile) {
2065
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: profile pins its own version/auth\n`));
2101
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2066
2102
  }
2067
2103
  else {
2068
2104
  try {
@@ -2312,12 +2348,12 @@ export function registerRunCommand(program) {
2312
2348
  ? (workflowModel ?? (options.fallback ? undefined : runDefaults.model))
2313
2349
  : undefined);
2314
2350
  // 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.
2351
+ // catalog. A custom harness's model comes from its endpoint, not the host
2352
+ // harness, so a tier here would forward an incompatible host-harness model to a
2353
+ // different API. Discard it loudly and let the custom harness's own model stand.
2318
2354
  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`));
2355
+ process.stderr.write(chalk.yellow(`[agents] --model ${model}: cost tiers don't apply to custom harness '${rawAgent}' ` +
2356
+ `(its model comes from the endpoint) — ignoring the tier, using the custom harness's configured model\n`));
2321
2357
  model = undefined;
2322
2358
  }
2323
2359
  const execOptions = {
@@ -24,7 +24,6 @@ export interface ForkOptions {
24
24
  baseUrl?: string;
25
25
  authProvider?: string;
26
26
  version?: string;
27
- label?: string;
28
27
  description?: string;
29
28
  keyStdin?: boolean;
30
29
  force?: boolean;
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import chalk from 'chalk';
14
14
  import { addProfile, ensureProviderToken } from './profiles.js';
15
- import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, } from '../lib/profiles.js';
15
+ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileHostLabel, profileProviderLabel, profileModelLabel, profileAuthLabel, profileLabel, forkProfile, profileFromHostModel, authEnvKeyForHost, getProfilePath, validateProfileName, editProfile, renameProfile, } from '../lib/profiles.js';
16
16
  import { listPresets } from '../lib/profiles-presets.js';
17
17
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName } from '../lib/agents.js';
18
18
  /** Short capability summary for a native harness — its supported run modes. */
@@ -58,7 +58,6 @@ export function buildFork(source, name, opts) {
58
58
  baseUrl: opts.baseUrl,
59
59
  provider: opts.authProvider,
60
60
  version: opts.version,
61
- label: opts.label,
62
61
  description: opts.description,
63
62
  });
64
63
  }
@@ -75,7 +74,6 @@ export function buildFork(source, name, opts) {
75
74
  baseUrl: opts.baseUrl,
76
75
  provider: opts.authProvider,
77
76
  authEnvVar: opts.authProvider ? authEnvKeyForHostOrThrow(host) : undefined,
78
- label: opts.label,
79
77
  description: opts.description ?? `Forked from ${host}: ${opts.model}`,
80
78
  });
81
79
  }
@@ -147,7 +145,6 @@ Examples:
147
145
  .option('--base-url <url>', 'Custom endpoint base URL (claude/codex hosts)')
148
146
  .option('--auth-provider <provider>', 'Attach a keychain-backed API key under this provider')
149
147
  .option('--version <version>', 'Pin the host CLI version (e.g., 1.16.0)')
150
- .option('--label <text>', 'Human-facing name shown by `agents view` (defaults to <name>)')
151
148
  .option('--description <text>', 'One-line description')
152
149
  .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
153
150
  .option('--force', 'Overwrite an existing harness with the same name')
@@ -249,4 +246,63 @@ Examples:
249
246
  }
250
247
  console.log(chalk.green(`Harness '${name}' removed.`));
251
248
  });
249
+ cmd
250
+ .command('edit <name>')
251
+ .description('Edit a custom harness in place (model, endpoint, host version, description).')
252
+ // Flags mirror ForkProfileOptions exactly — `editProfile` reuses
253
+ // `forkProfile`'s override logic, so anything it does not accept would be a
254
+ // flag that parses and does nothing. There is deliberately no --label: the
255
+ // header `agents view` prints is derived from the harness name.
256
+ .option('--model <id>', 'Swap the pinned model (e.g., meta/muse-spark-2.0)')
257
+ .option('--base-url <url>', 'Set or replace the endpoint base URL (claude/codex hosts)')
258
+ .option('--version <ver>', 'Re-pin the host CLI version. Empty string unpins (tracks latest).')
259
+ .option('--description <text>', 'Set the one-line description')
260
+ .addHelpText('after', `
261
+ Examples:
262
+ # Upgrade the pinned model
263
+ agents harness edit spark --model meta/muse-spark-2.0
264
+
265
+ # Point to a new private endpoint
266
+ agents harness edit corp --base-url https://gw.corp/v2
267
+
268
+ # Track the host's latest instead of a pinned version
269
+ agents harness edit spark --version ""
270
+
271
+ # The display name is derived from the harness name, so to change it, rename:
272
+ agents harness rename spark muse-spark
273
+ `)
274
+ .action((name, opts) => {
275
+ try {
276
+ const edited = editProfile(readProfile(name), opts);
277
+ writeProfile(edited);
278
+ console.log(chalk.green(`Harness '${name}' updated.`));
279
+ console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
280
+ }
281
+ catch (err) {
282
+ console.error(chalk.red(err.message));
283
+ process.exit(1);
284
+ }
285
+ });
286
+ cmd
287
+ .command('rename <name> <new-name>')
288
+ .description('Rename a custom harness — updates the file name and its internal name field.')
289
+ .addHelpText('after', `
290
+ Examples:
291
+ # Rename 'spark' to 'muse'
292
+ agents harness rename spark muse
293
+
294
+ # Rename then run under the new name
295
+ agents harness rename deepseek ds && agents run ds "hello"
296
+ `)
297
+ .action((name, newName) => {
298
+ try {
299
+ renameProfile(name, newName);
300
+ console.log(chalk.green(`Harness '${name}' renamed to '${newName}'.`));
301
+ console.log(chalk.gray(`Run: agents run ${newName} "hello"`));
302
+ }
303
+ catch (err) {
304
+ console.error(chalk.red(err.message));
305
+ process.exit(1);
306
+ }
307
+ });
252
308
  }
@@ -315,9 +315,9 @@ export function registerMonitorsCommands(program) {
315
315
  .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
316
316
  .option('--webhook-out <url>', 'POST the event to this URL')
317
317
  // PLACEMENT / hygiene
318
- .option('--device <name>', 'OWNER device — the single machine that evaluates + fires (exactly-once)')
318
+ .option('--device <name>', 'OWNER (not body placement) — the single machine that evaluates + fires (exactly-once). See docs/00-concepts.md#placement.')
319
319
  .option('--devices <list>', 'Allowlist (comma-separated): each device fires independently')
320
- .option('--run-on <host>', 'Execute the ACTION on this machine over SSH (placement)')
320
+ .option('--run-on <host>', 'BODY placement — execute the ACTION on this machine over SSH (same idea as run --where device:<host>)')
321
321
  .option('--rate-limit <spec>', 'Auto-pause if it fires more than N/<interval> (e.g. 5/1m)')
322
322
  .option('--disabled', 'Create the monitor paused (enable later with resume)')
323
323
  .action(async (nameOrPath, options) => {
@@ -628,8 +628,8 @@ export function registerRoutinesCommands(program) {
628
628
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
629
629
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
630
630
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
631
- .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Sets hostStrategy=host. Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
632
- .option('--placement <strategy>', `Where the job body runs: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Not the same as --host (which manages routines on a remote machine).`)
631
+ .option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host. Not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given. Same model as agents run --where device:<name> (docs/00-concepts.md#placement).')
632
+ .option('--placement <strategy>', `BODY placement strategy: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Maps to the shared Placement model (local|device|fleet|cloud). Not the same as --host (which manages routines on a remote machine).`)
633
633
  .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
634
634
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
635
635
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
@@ -36,6 +36,8 @@ function disabledReason(candidate) {
36
36
  return undefined;
37
37
  if (readiness.reason === 'signed_out')
38
38
  return 'logged out';
39
+ if (readiness.reason === 'revoked')
40
+ return 'needs re-login';
39
41
  if (readiness.reason === 'out_of_credits')
40
42
  return 'out of credits';
41
43
  const windows = candidate.usageSnapshot?.windows ?? [];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `agents snapshot` — one-process consumer snapshot for pollers.
3
+ *
4
+ * Replaces the N× `view --json` + `sessions --active --json` (+ optional feed)
5
+ * fork storm with a single command. Does NOT replace `agents status`, which
6
+ * remains the UnifiedSyncStatus sync contract for menubar / Agency drift.
7
+ *
8
+ * JSON shape: {@link FleetSnapshot} in `lib/snapshot.ts` (version: 1).
9
+ */
10
+ import type { Command } from 'commander';
11
+ export declare function registerSnapshotCommand(program: Command): void;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `agents snapshot` — one-process consumer snapshot for pollers.
3
+ *
4
+ * Replaces the N× `view --json` + `sessions --active --json` (+ optional feed)
5
+ * fork storm with a single command. Does NOT replace `agents status`, which
6
+ * remains the UnifiedSyncStatus sync contract for menubar / Agency drift.
7
+ *
8
+ * JSON shape: {@link FleetSnapshot} in `lib/snapshot.ts` (version: 1).
9
+ */
10
+ import chalk from 'chalk';
11
+ import { addHostOption } from '../lib/hosts/option.js';
12
+ import { setHelpSections } from '../lib/help.js';
13
+ import { resolveAgentName, AGENTS } from '../lib/agents.js';
14
+ import { computeSnapshot } from '../lib/snapshot.js';
15
+ import { resolveSurface } from './utils.js';
16
+ function resolveAgentFilter(raw) {
17
+ if (!raw)
18
+ return undefined;
19
+ const name = resolveAgentName(raw.split('@')[0]);
20
+ if (!name) {
21
+ console.error(chalk.red(`Unknown agent "${raw}".`));
22
+ process.exit(1);
23
+ }
24
+ return name;
25
+ }
26
+ /** Compact human summary — the JSON path is the real contract. */
27
+ function renderHuman(snap) {
28
+ console.log(chalk.bold('Fleet snapshot') + chalk.gray(` · ${snap.host} · ${snap.capturedAt}`));
29
+ const invParts = [];
30
+ for (const a of snap.inventory) {
31
+ if (a.versions.length === 0)
32
+ continue;
33
+ const def = a.versions.find((v) => v.isDefault) ?? a.versions[0];
34
+ const label = AGENTS[a.agent]?.name ?? a.agent;
35
+ const signed = def.signedIn ? chalk.green('in') : chalk.gray('out');
36
+ invParts.push(`${label}@${def.version} (${signed})`);
37
+ }
38
+ console.log(` ${'Inventory'.padEnd(12)} ${invParts.length ? invParts.join(chalk.gray(' · ')) : chalk.gray('(none installed)')}`);
39
+ const { running, live, byContext, byAgent } = snap.agents;
40
+ const ctx = Object.entries(byContext)
41
+ .map(([k, n]) => `${k}:${n}`)
42
+ .join(' ');
43
+ const ag = Object.entries(byAgent)
44
+ .map(([k, n]) => `${k}:${n}`)
45
+ .join(' ');
46
+ console.log(` ${'Sessions'.padEnd(12)} ${live} live · ${running} running` +
47
+ (snap.remoteDeviceCount > 0 ? chalk.gray(` · ${snap.remoteDeviceCount} remote device(s)`) : '') +
48
+ (ctx ? chalk.gray(` · ${ctx}`) : '') +
49
+ (ag ? chalk.gray(` · ${ag}`) : ''));
50
+ if (snap.feed) {
51
+ console.log(` ${'Feed'.padEnd(12)} ${snap.feed.openBlocks} open block${snap.feed.openBlocks === 1 ? '' : 's'}`);
52
+ }
53
+ if (snap.sync) {
54
+ const need = snap.sync.agents.filter((a) => a.needsSync).length;
55
+ const sys = snap.sync.system.unknown
56
+ ? 'system unknown'
57
+ : snap.sync.system.behind > 0
58
+ ? `system ${snap.sync.system.behind} behind`
59
+ : 'system ok';
60
+ console.log(` ${'Sync'.padEnd(12)} ${sys} · ${need} version${need === 1 ? '' : 's'} need sync`);
61
+ }
62
+ console.log(chalk.gray('\n Machine-readable: agents snapshot --json\n' +
63
+ ' Sync-only (unchanged): agents status --json'));
64
+ }
65
+ export function registerSnapshotCommand(program) {
66
+ const cmd = addHostOption(program
67
+ .command('snapshot')
68
+ .description('One-process poll snapshot: install inventory + active sessions (optional feed/sync). Not the sync-status command — use `agents status` for drift.'))
69
+ .option('--json', 'Emit the machine-readable FleetSnapshot contract (version 1)')
70
+ .option('--all-hosts', 'Include remote devices in the sessions gather (same fan-out as `sessions --active`; default is this machine only)')
71
+ .option('--with-feed', 'Include open feed-block summary (needs-you count + compact rows)')
72
+ .option('--with-sync', 'Include UnifiedSyncStatus (same engine as `agents status`; opt-in)')
73
+ .option('--agent <name>', 'Restrict inventory to one agent (e.g. claude)');
74
+ setHelpSections(cmd, {
75
+ examples: `
76
+ # One-shot inventory + local active sessions (JSON)
77
+ agents snapshot --json
78
+
79
+ # Same, plus open feed blocks for needs-you polls
80
+ agents snapshot --json --with-feed
81
+
82
+ # Fleet-wide active sessions (matches sessions --active scope)
83
+ agents snapshot --json --all-hosts
84
+
85
+ # Inventory for one harness only (Factory-style usage poll)
86
+ agents snapshot --json --agent claude
87
+
88
+ # Run the whole snapshot on another device
89
+ agents snapshot --json --host yosemite-s0
90
+ `,
91
+ });
92
+ cmd.action(async (opts, command) => {
93
+ const surface = resolveSurface(command);
94
+ const agent = resolveAgentFilter(opts.agent);
95
+ const snap = await computeSnapshot({
96
+ agent,
97
+ local: opts.allHosts !== true,
98
+ withFeed: opts.withFeed === true,
99
+ withSync: opts.withSync === true,
100
+ });
101
+ if (surface.json || opts.json) {
102
+ console.log(JSON.stringify(snap, null, 2));
103
+ return;
104
+ }
105
+ renderHuman(snap);
106
+ });
107
+ }
@@ -275,7 +275,8 @@ function throttleWarningLine(agent, version, r) {
275
275
  const acct = r.email ? ` (${r.email})` : '';
276
276
  const reason = r.reason === 'out_of_credits' ? 'is out of credits'
277
277
  : r.reason === 'signed_out' ? 'is not signed in'
278
- : 'is rate-limited right now';
278
+ : r.reason === 'revoked' ? 'needs re-login (its token was revoked)'
279
+ : 'is rate-limited right now';
279
280
  return (chalk.yellow(`⚠ ${who}${acct} ${reason}.`) +
280
281
  chalk.gray(`\n A pinned version skips account rotation, so it will launch on this account and may immediately hit its limit.` +
281
282
  `\n Use a bare \`${agent}\` teammate to let the team pick a healthy account, or pass --force to silence this.`));
@@ -129,6 +129,13 @@ export declare function parseResourceSections(options: {
129
129
  resources?: string | boolean;
130
130
  detailed?: boolean;
131
131
  } & ViewSectionFilter, jsonMode: boolean): Set<ResourceSection>;
132
+ /**
133
+ * Collect structured info for one or more agents without rendering to the
134
+ * terminal. Used by `--json` output and any programmatic consumer (e.g. the
135
+ * agents-cli extension's "resume current session in best available version"
136
+ * command).
137
+ */
138
+ export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>): Promise<ViewJsonAgent[]>;
132
139
  /**
133
140
  * Identity key for duplicate-install detection. Prefers accountKey — which
134
141
  * encodes account AND org — over the bare email: two installs can share an
@@ -1242,7 +1242,7 @@ export function parseResourceSections(options, jsonMode) {
1242
1242
  * agents-cli extension's "resume current session in best available version"
1243
1243
  * command).
1244
1244
  */
1245
- async function collectAgentsJson(filterAgentId, resourceSections) {
1245
+ export async function collectAgentsJson(filterAgentId, resourceSections) {
1246
1246
  const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
1247
1247
  const wantResources = !!resourceSections && resourceSections.size > 0;
1248
1248
  // Only pay for the git-status + resource scans when resources were requested.
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -317,6 +317,8 @@ Credentials and profiles:
317
317
 
318
318
  Diagnostics:
319
319
  doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
320
+ status Unified sync status (drift/missing) — not the live fleet snapshot
321
+ snapshot One-process poll: inventory + active sessions (+ optional feed/sync)
320
322
  usage [agent] Show rate-limit and quota usage per agent
321
323
  perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
322
324
 
@@ -938,6 +940,7 @@ async function registerAllEagerCommands() {
938
940
  registerCheckTombstoneCommand(program);
939
941
  await reg(loadApply);
940
942
  await reg(loadStatus);
943
+ await reg(loadSnapshot);
941
944
  registerExecAliasCommand(program);
942
945
  await reg(loadProfiles);
943
946
  await reg(loadHarness);
@@ -178,6 +178,8 @@ export class BrowserIPCServer {
178
178
  url: request.url,
179
179
  endpointName: request.endpoint,
180
180
  skipDomainSkill: request.skipDomainSkill,
181
+ actor: request.actor,
182
+ launchId: request.launchId,
181
183
  });
182
184
  return {
183
185
  ok: true,