@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
@@ -79,10 +79,20 @@ export interface Task {
79
79
  createdAt: number;
80
80
  pid: number;
81
81
  /**
82
- * Resolved actor id (`resolveActor().id`) stamped at task start — who launched
83
- * this browser task. Optional: tasks persisted before RUSH-2020 carry none.
82
+ * Resolved actor id (`resolveActor().id`) stamped at task start — WHO launched
83
+ * this browser task (a person/agent identity, stable across runs). Forwarded
84
+ * from the caller over IPC — the daemon is shared, so it cannot resolve the
85
+ * caller's actor itself. Optional: tasks persisted before RUSH-2020 carry none.
84
86
  */
85
87
  owner?: string;
88
+ /**
89
+ * The caller's per-run launch id (`$AGENT_LAUNCH_ID`, minted by exec.ts for
90
+ * every harness) stamped at task start — WHICH run created this task. Distinct
91
+ * from `owner`: two runs by the same actor get different launchIds, which is
92
+ * the scope the current-task default and `status --mine` filter on. Forwarded
93
+ * from the caller over IPC. Optional: tasks from before this shipped carry none.
94
+ */
95
+ launchId?: string;
86
96
  /**
87
97
  * Per-tab snapshot of the last ref listing captured for that tab
88
98
  * (shortId -> {descriptors, opts}). Persisted to tasks.json so a later
@@ -184,6 +194,8 @@ export interface IPCRequest {
184
194
  until?: string;
185
195
  appLevel?: string;
186
196
  skipDomainSkill?: boolean;
197
+ actor?: string;
198
+ launchId?: string;
187
199
  }
188
200
  /** Subset of IPCResponse describing a recording start result. */
189
201
  export interface RecordStartFields {
@@ -114,6 +114,41 @@ export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
114
114
  export declare function crabboxList(opts?: CrabboxOptions): CrabboxBox[];
115
115
  /** Find one box by slug, or null. */
116
116
  export declare function crabboxFind(slug: string, opts?: CrabboxOptions): CrabboxBox | null;
117
+ /**
118
+ * Whether `crabbox status` reports the box SSH-ready (`ready=true`). A box whose
119
+ * cloud-init bootstrap failed still LISTS as `running` but never becomes ready —
120
+ * selecting it burns the full SSH wait before hard-failing. `crabbox status`
121
+ * flips ready=true only once sshd answers, so warm-pool reuse gates on it
122
+ * (mirrors scripts/sandbox.sh's `box_ready`).
123
+ */
124
+ export declare function crabboxStatusReady(slug: string, opts?: CrabboxOptions): boolean;
125
+ export interface PoolMatchOptions {
126
+ /**
127
+ * Profile label this run would warm a box with (from the repo's
128
+ * `.crabbox.yaml`; see config.ts). Both sides normalize an unset profile to
129
+ * DEFAULT_CRABBOX_PROFILE, so profile-less runs match unlabeled boxes.
130
+ */
131
+ profile?: string;
132
+ /**
133
+ * Network mode of the run (default 'public'). A tailnet-joined box is never
134
+ * handed to a public run, nor a public box to a tailnet run — reachability and
135
+ * exposure differ, so the pool is partitioned by it.
136
+ */
137
+ netMode?: 'public' | 'tailscale';
138
+ /** Injectable clock (unix seconds) for the expiry check. */
139
+ nowSecs?: number;
140
+ }
141
+ /**
142
+ * Warm boxes in the profile pool this run could reuse: `running`, same profile
143
+ * label, same network mode, lease unexpired — most-recently-touched first.
144
+ *
145
+ * Readiness is NOT required here (deliberately mirrors sandbox.sh's
146
+ * `running_slugs_for_profile`, which filters on `status` only): the list `state`
147
+ * label can lag, so the caller gates each candidate on `crabboxStatusReady`
148
+ * before committing. A not-ready box is skipped, never stopped — a concurrent
149
+ * run may be mid-boot on it, and crabbox's idle timeout reaps genuine duds.
150
+ */
151
+ export declare function poolReusableBoxes(boxes: CrabboxBox[], opts?: PoolMatchOptions): CrabboxBox[];
117
152
  export interface WarmupOptions extends CrabboxOptions {
118
153
  class?: string;
119
154
  profile?: string;
@@ -14,6 +14,7 @@
14
14
  import { spawn, spawnSync } from 'child_process';
15
15
  import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
16
16
  import { readMeta, writeMeta } from '../state.js';
17
+ import { DEFAULT_CRABBOX_PROFILE } from './config.js';
17
18
  /** Locate the crabbox binary, or throw an actionable error. */
18
19
  export function findCrabbox() {
19
20
  const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
@@ -301,6 +302,51 @@ export function crabboxList(opts = {}) {
301
302
  export function crabboxFind(slug, opts = {}) {
302
303
  return crabboxList(opts).find((b) => b.slug === slug) ?? null;
303
304
  }
305
+ /**
306
+ * Whether `crabbox status` reports the box SSH-ready (`ready=true`). A box whose
307
+ * cloud-init bootstrap failed still LISTS as `running` but never becomes ready —
308
+ * selecting it burns the full SSH wait before hard-failing. `crabbox status`
309
+ * flips ready=true only once sshd answers, so warm-pool reuse gates on it
310
+ * (mirrors scripts/sandbox.sh's `box_ready`).
311
+ */
312
+ export function crabboxStatusReady(slug, opts = {}) {
313
+ findCrabbox();
314
+ const r = spawnSync('crabbox', ['status', '--id', slug], {
315
+ encoding: 'utf-8',
316
+ env: crabboxEnv(opts),
317
+ timeout: opts.timeoutMs ?? 15000,
318
+ });
319
+ if (r.status !== 0 || !r.stdout)
320
+ return false;
321
+ return /(^|\s)ready=true(\s|$)/m.test(r.stdout);
322
+ }
323
+ /**
324
+ * Warm boxes in the profile pool this run could reuse: `running`, same profile
325
+ * label, same network mode, lease unexpired — most-recently-touched first.
326
+ *
327
+ * Readiness is NOT required here (deliberately mirrors sandbox.sh's
328
+ * `running_slugs_for_profile`, which filters on `status` only): the list `state`
329
+ * label can lag, so the caller gates each candidate on `crabboxStatusReady`
330
+ * before committing. A not-ready box is skipped, never stopped — a concurrent
331
+ * run may be mid-boot on it, and crabbox's idle timeout reaps genuine duds.
332
+ */
333
+ export function poolReusableBoxes(boxes, opts = {}) {
334
+ const profile = opts.profile ?? DEFAULT_CRABBOX_PROFILE;
335
+ const netMode = opts.netMode ?? 'public';
336
+ const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
337
+ return boxes
338
+ .filter((b) => {
339
+ if (b.status !== 'running')
340
+ return false;
341
+ if ((b.profile ?? DEFAULT_CRABBOX_PROFILE) !== profile)
342
+ return false;
343
+ const boxNet = b.tailscaleIPv4 || b.tailscaleFQDN ? 'tailscale' : 'public';
344
+ if (boxNet !== netMode)
345
+ return false;
346
+ return b.expiresAt === null || b.expiresAt > nowSecs;
347
+ })
348
+ .sort((a, b) => (b.lastTouchedAt ?? 0) - (a.lastTouchedAt ?? 0));
349
+ }
304
350
  /**
305
351
  * Lease a box and block until it is ready. Returns the leased box.
306
352
  *
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
+ *
4
+ * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
+ * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
+ * reuse check matches on the SAME profile the warmup would have used — a reused
7
+ * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
+ * `pick_ready_box` resolves the pool the same way).
9
+ */
10
+ /**
11
+ * The profile label a box warm pool shares. Matches sandbox.sh's
12
+ * `PROFILE="${PROFILE:-default}"`: a run with no configured profile and a box
13
+ * with no `profile` label both normalize here, so they still match each other.
14
+ */
15
+ export declare const DEFAULT_CRABBOX_PROFILE = "default";
16
+ /**
17
+ * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
18
+ * file is missing, unreadable, or has no profile key (crabbox then applies its
19
+ * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
20
+ */
21
+ export declare function readCrabboxRepoProfile(repoRoot: string): string | undefined;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
+ *
4
+ * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
+ * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
+ * reuse check matches on the SAME profile the warmup would have used — a reused
7
+ * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
+ * `pick_ready_box` resolves the pool the same way).
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as yaml from 'yaml';
13
+ /**
14
+ * The profile label a box warm pool shares. Matches sandbox.sh's
15
+ * `PROFILE="${PROFILE:-default}"`: a run with no configured profile and a box
16
+ * with no `profile` label both normalize here, so they still match each other.
17
+ */
18
+ export const DEFAULT_CRABBOX_PROFILE = 'default';
19
+ /**
20
+ * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
21
+ * file is missing, unreadable, or has no profile key (crabbox then applies its
22
+ * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
23
+ */
24
+ export function readCrabboxRepoProfile(repoRoot) {
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(path.join(repoRoot, '.crabbox.yaml'), 'utf-8');
28
+ }
29
+ catch {
30
+ return undefined; // no repo crabbox config — crabbox's own default applies
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = yaml.parse(raw);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ if (!parsed || typeof parsed !== 'object')
40
+ return undefined;
41
+ const profile = parsed.profile;
42
+ return typeof profile === 'string' && profile.length > 0 ? profile : undefined;
43
+ }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `agents run --lease` orchestrator.
3
3
  *
4
- * Lease an ephemeral crabbox → provision the picked runtime(s) + their
5
- * credentials → run the agent on the box (via `crabbox run`, which owns the
6
- * SSH) → tear the box down. The whole box-side sequence rides a single
7
- * `--script-stdin` body so the token contents never touch argv.
4
+ * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
+ * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
+ * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
+ * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). The whole box-side sequence rides a
9
+ * single `--script-stdin` body so the token contents never touch argv.
8
10
  *
9
11
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
12
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -12,7 +14,9 @@
12
14
  * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
15
  * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
16
  * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
- * `leaseAndRun` (netMode → `crabboxWarmup`).
17
+ * `leaseAndRun` (netMode → `crabboxWarmup`). `LeaseRunOptions.fresh`
18
+ * (`--fresh`) skips the warm profile-pool reuse check and always leases a
19
+ * new box, torn down after the run.
16
20
  * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
21
  * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
22
  * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
@@ -80,6 +84,12 @@ export interface LeaseRunOptions {
80
84
  keep?: boolean;
81
85
  /** Existing warm crabbox slug to reuse instead of provisioning a new lease. */
82
86
  reuseBox?: string;
87
+ /**
88
+ * Force a brand-new box: skip the warm profile-pool reuse check and tear the
89
+ * box down after the run (the pre-pool `--lease` behavior). `--fresh` at the
90
+ * command layer.
91
+ */
92
+ fresh?: boolean;
83
93
  /**
84
94
  * Raw wrapped Claude OAuth payload (from `resolveClaudeCredentialsBlob`), written
85
95
  * to `~/.claude/.credentials.json` on the box. The command layer resolves it
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `agents run --lease` orchestrator.
3
3
  *
4
- * Lease an ephemeral crabbox → provision the picked runtime(s) + their
5
- * credentials → run the agent on the box (via `crabbox run`, which owns the
6
- * SSH) → tear the box down. The whole box-side sequence rides a single
7
- * `--script-stdin` body so the token contents never touch argv.
4
+ * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
+ * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
+ * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
+ * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). The whole box-side sequence rides a
9
+ * single `--script-stdin` body so the token contents never touch argv.
8
10
  *
9
11
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
12
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -12,7 +14,9 @@
12
14
  * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
15
  * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
16
  * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
- * `leaseAndRun` (netMode → `crabboxWarmup`).
17
+ * `leaseAndRun` (netMode → `crabboxWarmup`). `LeaseRunOptions.fresh`
18
+ * (`--fresh`) skips the warm profile-pool reuse check and always leases a
19
+ * new box, torn down after the run.
16
20
  * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
21
  * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
22
  * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
@@ -24,7 +28,7 @@
24
28
  * secretsBundle?, userAgentsDir?, onData? }): Promise<CopySetupResult>` — the
25
29
  * push-from-local the command layer runs before the box run.
26
30
  */
27
- import { crabboxFind, crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
31
+ import { crabboxFind, crabboxList, crabboxStatusReady, crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop, poolReusableBoxes } from './cli.js';
28
32
  import * as yaml from 'yaml';
29
33
  import { buildCredentialScript, buildHomeFileWriteScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
30
34
  import { LEASE_AGENT_MARKER, leasePhaseSentinel } from './progress.js';
@@ -149,9 +153,32 @@ export function buildBootstrapScript(opts) {
149
153
  .filter((l) => l.length > 0)
150
154
  .join('\n');
151
155
  }
156
+ /**
157
+ * The first warm box in this run's profile pool that is actually SSH-ready, or
158
+ * null. Mirrors scripts/sandbox.sh's `pick_ready_box`: list the running boxes
159
+ * for the run's profile + netMode, then gate each on `crabbox status`
160
+ * ready=true — a box whose bootstrap failed still lists as `running` and would
161
+ * burn the whole SSH wait before hard-failing. A skipped box is left alone
162
+ * (never stopped): a concurrent run may be mid-boot on it, and crabbox's idle
163
+ * timeout reaps genuine duds.
164
+ */
165
+ function pickReadyPoolBox(opts) {
166
+ const candidates = poolReusableBoxes(crabboxList({ secretsBundle: opts.secretsBundle }), {
167
+ profile: opts.profile,
168
+ netMode: opts.netMode,
169
+ });
170
+ for (const b of candidates) {
171
+ if (crabboxStatusReady(b.slug, { secretsBundle: opts.secretsBundle }))
172
+ return b;
173
+ }
174
+ return null;
175
+ }
152
176
  export async function leaseAndRun(opts) {
153
177
  const startedAt = Date.now();
154
178
  let box;
179
+ // A box this run did NOT provision — either the caller named it (`--box`) or
180
+ // it came out of the warm profile pool. Reused boxes are never torn down.
181
+ let reused = false;
155
182
  if (opts.reuseBox) {
156
183
  opts.onPhase?.({ kind: 'reuse', slug: opts.reuseBox });
157
184
  const found = crabboxFind(opts.reuseBox, { secretsBundle: opts.secretsBundle });
@@ -160,17 +187,29 @@ export async function leaseAndRun(opts) {
160
187
  box = found.ready
161
188
  ? found
162
189
  : await crabboxWaitReady(opts.reuseBox, { secretsBundle: opts.secretsBundle });
190
+ reused = true;
163
191
  }
164
192
  else {
165
- opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
166
- box = await crabboxWarmup({
167
- class: opts.boxClass,
168
- profile: opts.profile,
169
- provider: opts.backend,
170
- secretsBundle: opts.secretsBundle,
171
- netMode: opts.netMode,
172
- });
173
- await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
193
+ // Reuse-first: before paying for a fresh lease, look for a warm box in this
194
+ // run's profile pool (same profile label the warmup would use, same netMode).
195
+ // `--fresh` opts out and always provisions.
196
+ const pooled = opts.fresh ? null : pickReadyPoolBox(opts);
197
+ if (pooled) {
198
+ opts.onPhase?.({ kind: 'reuse', slug: pooled.slug });
199
+ box = pooled;
200
+ reused = true;
201
+ }
202
+ else {
203
+ opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
204
+ box = await crabboxWarmup({
205
+ class: opts.boxClass,
206
+ profile: opts.profile,
207
+ provider: opts.backend,
208
+ secretsBundle: opts.secretsBundle,
209
+ netMode: opts.netMode,
210
+ });
211
+ await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
212
+ }
174
213
  }
175
214
  opts.onPhase?.({ kind: 'ready', box, elapsedMs: Date.now() - startedAt });
176
215
  // Setup-copy (F1, RUSH-1920): push the git-tracked ~/.agents config onto the
@@ -203,8 +242,9 @@ export async function leaseAndRun(opts) {
203
242
  }
204
243
  finally {
205
244
  // Always attempt teardown (bounds credential lifetime to the run) unless the
206
- // caller explicitly asked to keep the box or targeted an existing warm box.
207
- if (!opts.keep && !opts.reuseBox) {
245
+ // caller explicitly asked to keep the box or the box was reused (an explicit
246
+ // --box target or a warm pool box — both outlive this run).
247
+ if (!opts.keep && !reused) {
208
248
  opts.onPhase?.({ kind: 'teardown' });
209
249
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
210
250
  }
@@ -888,14 +888,14 @@ export async function runDaemon() {
888
888
  };
889
889
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
890
890
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
891
- // Adaptive usage refresh: keep the usage cache the `agents run` router reads
891
+ // Usage refresh: keep the usage cache the `agents run` router reads
892
892
  // (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
893
- // This host is the sole writer for its own local accounts. The tick wakes at
894
- // the 90s floor, but per-account cadence gates the actual live fetches: an
895
- // account racing toward its 5h cap is polled sooner (down to 90s), an idle one
896
- // rarely (up to 15min), capped at ~6 provider calls/account/hour and skipped
897
- // entirely while its provider is under a 429 backoff. Overlap-guarded like the
898
- // probes above; a box signed into no networked-usage account is a clean no-op.
893
+ // This host is the sole writer for its own local accounts. The tick wakes
894
+ // every 60s (USAGE_REFRESH_TICK_MS) to consider due accounts; each account
895
+ // is scheduled at a fixed 5-minute cadence (REFRESH_INTERVAL_MS), capped at
896
+ // ~12 provider calls/account/hour, skipped under 429 backoff, and fetched
897
+ // with fileOnly credentials so a background tick never pops macOS Touch ID.
898
+ // Overlap-guarded: a slow pass cannot stack concurrent refresh loops.
899
899
  let refreshingUsage = false;
900
900
  const runUsageRefreshTick = async () => {
901
901
  if (refreshingUsage)
@@ -910,9 +910,9 @@ export async function runDaemon() {
910
910
  writeUsageCache: writeClaudeUsageCache,
911
911
  backoffUntil: usageRateLimitedUntil,
912
912
  });
913
- if (r.refreshed > 0 || r.failed > 0) {
914
- log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
915
- }
913
+ // Always log a compact summary so "is refresh working?" is greppable even
914
+ // when every account was not-due (proves the tick ran).
915
+ log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
916
916
  }
917
917
  catch (err) {
918
918
  log('ERROR', `usage refresh failed: ${err.message}`);
@@ -921,7 +921,8 @@ export async function runDaemon() {
921
921
  refreshingUsage = false;
922
922
  }
923
923
  };
924
- const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 90_000);
924
+ // 60s wake matches USAGE_REFRESH_TICK_MS in usage-refresh.ts (keep in sync).
925
+ const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 60_000);
925
926
  const usageRefreshKickoff = setTimeout(() => { void runUsageRefreshTick(); }, 30_000);
926
927
  // RUSH-1817: the startup host decision above is one-shot. If a standalone
927
928
  // broker answered agentPing() at daemon start, the daemon declined to host —
@@ -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',
@@ -1,4 +1,4 @@
1
- import { splitUserHost } from '../hosts/registry.js';
1
+ import { splitUserHost, type MatchHostOptions } from '../hosts/registry.js';
2
2
  import { type DeviceProfile } from './registry.js';
3
3
  export { splitUserHost };
4
4
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
@@ -21,9 +21,10 @@ export interface ResolvedExplicitTargetSet {
21
21
  * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
22
22
  * unregistered alias (no `@`/dot) — or an ssh_config-only alias, which `agents
23
23
  * ssh` has never dialed — returns undefined so the caller reports "Unknown
24
- * device" rather than dialing a literal.
24
+ * device" rather than dialing a literal. `token` may also be the `auto`
25
+ * affinity sentinel (RUSH-2185); `opts.resolveAuto` overrides the pick (tests).
25
26
  */
26
- export declare function resolveDeviceTarget(token: string): Promise<DeviceProfile | undefined>;
27
+ export declare function resolveDeviceTarget(token: string, opts?: Pick<MatchHostOptions, 'resolveAuto'>): Promise<DeviceProfile | undefined>;
27
28
  /**
28
29
  * Resolve an explicit `--host`/`--device` list to dialable targets. A token that
29
30
  * fails the injection guard or names nothing reachable is skipped with a stderr
@@ -71,10 +71,11 @@ async function toResolvedTarget(token) {
71
71
  * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
72
72
  * unregistered alias (no `@`/dot) — or an ssh_config-only alias, which `agents
73
73
  * ssh` has never dialed — returns undefined so the caller reports "Unknown
74
- * device" rather than dialing a literal.
74
+ * device" rather than dialing a literal. `token` may also be the `auto`
75
+ * affinity sentinel (RUSH-2185); `opts.resolveAuto` overrides the pick (tests).
75
76
  */
76
- export async function resolveDeviceTarget(token) {
77
- const host = await matchHost(token, { allowBareLiteral: true });
77
+ export async function resolveDeviceTarget(token, opts = {}) {
78
+ const host = await matchHost(token, { allowBareLiteral: true, ...opts });
78
79
  if (!host)
79
80
  return undefined;
80
81
  if (host.device) {
@@ -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
  /**
@@ -24,6 +24,7 @@ import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
27
+ import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
27
28
  import { loadDevices } from '../devices/registry.js';
28
29
  import { isSelfHost } from '../devices/self-host.js';
29
30
  import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
@@ -319,6 +320,19 @@ function renderFleetRoster(command, forwarded, results, self) {
319
320
  console.log(chalk.gray(summaryParts.join(' · ')));
320
321
  }
321
322
  }
323
+ /**
324
+ * Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
325
+ * the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
326
+ * joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
327
+ * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
328
+ * `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
329
+ * self target runs locally and must stay ungated.
330
+ */
331
+ export function markFleetRemote(cmd, device) {
332
+ return device.shell === 'powershell'
333
+ ? [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd]
334
+ : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
335
+ }
322
336
  /** Run `agents <command> …` across every registered device and render the roster. */
323
337
  export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
324
338
  const self = opts.self ?? machineId();
@@ -335,7 +349,12 @@ export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
335
349
  const results = await fanOutDevices(targets, async (target) => {
336
350
  const cmd = ['agents', ...forwarded];
337
351
  const isSelf = target.device.name.toLowerCase() === self.toLowerCase() || isSelfHost(target.device.name);
338
- const res = isSelf ? localRunner(cmd) : runner(target.device, cmd);
352
+ // Only `browser` consults the fleet-remote marker (its consent gate), and
353
+ // the fan-out has no separate env channel — the marker must ride the argv.
354
+ // So scope the env-prefix to a REMOTE browser drive: every other command's
355
+ // remote argv stays byte-identical, and the self target is never gated.
356
+ const remoteCmd = !isSelf && command === 'browser' ? markFleetRemote(cmd, target.device) : cmd;
357
+ const res = isSelf ? localRunner(cmd) : runner(target.device, remoteCmd);
339
358
  if (res.code !== 0) {
340
359
  const detail = (res.stderr || res.stdout || 'unreachable').trim().slice(0, 200);
341
360
  throw new Error(detail || 'unreachable');
@@ -378,7 +397,7 @@ export async function maybeRunOnHost(command, allArgs, opts) {
378
397
  const deviceFlag = flagValue(allArgs, 'device');
379
398
  const hostsFlag = flagValue(allArgs, 'hosts');
380
399
  const devicesFlag = flagValue(allArgs, 'devices');
381
- const hostName = hostFlag ?? deviceFlag;
400
+ let hostName = hostFlag ?? deviceFlag;
382
401
  const fleetAll = isFleetAllSentinel(hostFlag, deviceFlag, hostsFlag, devicesFlag);
383
402
  // Proceed when any routing flag is present, including the plural fleet flags
384
403
  // that may carry the `all` sentinel.
@@ -442,6 +461,22 @@ export async function maybeRunOnHost(command, allArgs, opts) {
442
461
  // flags and bare flags were already handled.
443
462
  if (!hostName)
444
463
  return false;
464
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
465
+ // (RUSH-2185) — pick the concrete target up front via resolveDeviceAffinity so
466
+ // the isSelfHost check right below (which compares a literal name, not "auto")
467
+ // still catches a local pick and runs the command locally rather than
468
+ // resolving to a real Host and self-SSHing (or, if this box isn't itself a
469
+ // registered device, dialing a literal, nonexistent host named "auto").
470
+ if (isDeviceAuto(hostName)) {
471
+ const plan = resolveDeviceAffinity({});
472
+ if (!plan.host) {
473
+ const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
474
+ process.argv = [process.argv[0], process.argv[1], ...stripped];
475
+ return false;
476
+ }
477
+ process.stderr.write(chalk.gray(`[agents] device=auto → ${plan.host}\n`));
478
+ hostName = plan.host;
479
+ }
445
480
  // Running against your own machine is just a local run — skip the SSH round-trip.
446
481
  // Match EVERY identity the box answers to (short id, loopback, tailscale
447
482
  // dnsName), not just machineId() — a `--host <self-dnsName>` used to slip past a
@@ -500,7 +535,10 @@ export async function maybeRunOnHost(command, allArgs, opts) {
500
535
  // UNDER the doctor PATH so that PATH still wins — without this the remote
501
536
  // re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
502
537
  // (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
503
- const env = withActorEnv(doctorPath);
538
+ // AGENTS_FLEET_REMOTE marks this as a fleet-dispatched `--host` run so the far
539
+ // side can gate consent-sensitive actions — the browser consent gate
540
+ // (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
541
+ const env = withActorEnv({ ...doctorPath, AGENTS_FLEET_REMOTE: '1' });
504
542
  const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
505
543
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
506
544
  if (code === 255) {
@@ -19,6 +19,7 @@
19
19
  import type { Host, HostProvider, HostProviderId } from './types.js';
20
20
  import { DeviceOffloadUnsupportedError } from './types.js';
21
21
  import { type DeviceProfile } from '../devices/registry.js';
22
+ import { type DeviceAffinityPlan } from '../smart-launch.js';
22
23
  export { DeviceOffloadUnsupportedError };
23
24
  export declare function getProvider(id: HostProviderId): HostProvider;
24
25
  export declare function getAllProviders(): HostProvider[];
@@ -52,6 +53,9 @@ export interface MatchHostOptions {
52
53
  * "Unknown device" verdict reachable).
53
54
  */
54
55
  allowBareLiteral?: boolean;
56
+ /** Override the affinity pick for the `auto` sentinel (tests). Defaults to
57
+ * `resolveDeviceAffinity({})` — the same engine `agents run --device auto` uses. */
58
+ resolveAuto?: () => DeviceAffinityPlan;
55
59
  }
56
60
  /**
57
61
  * The one place a `--host` / `--device` token becomes a resolved host. Reads the
@@ -25,6 +25,8 @@ import { readMeta } from '../state.js';
25
25
  import { isSshConfigHost } from './ssh-config.js';
26
26
  import { resolveRemoteOsSync } from './remote-os.js';
27
27
  import { loadDevices, isControlDevice } from '../devices/registry.js';
28
+ import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
29
+ import { localMachineId } from '../session/origin-machine.js';
28
30
  // Re-export so existing importers (tests, commands) keep their path; the class
29
31
  // itself lives in types.ts so providers can throw it without a circular import.
30
32
  export { DeviceOffloadUnsupportedError };
@@ -146,6 +148,20 @@ function literalHost(token, host, user) {
146
148
  * into a dispatch verdict.
147
149
  */
148
150
  export async function matchHost(name, opts = {}) {
151
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
152
+ // (isDeviceAuto / resolveDeviceAffinity in ../smart-launch.js) — shared here so
153
+ // every caller through this one core (ssh, teams, dispatch/passthrough) picks a
154
+ // device the SAME way `run` does instead of rejecting it as "Unknown device
155
+ // 'auto'" (RUSH-2185). A `null` plan.host means the affinity engine picked this
156
+ // very machine; resolve that as the local device/host entry (if any) rather than
157
+ // returning nothing — callers that already special-case "target is this
158
+ // machine" (teams add/create, the passthrough self-host check) then treat it as
159
+ // local exactly as they would if the user had typed the local name.
160
+ if (isDeviceAuto(name)) {
161
+ const plan = (opts.resolveAuto ?? (() => resolveDeviceAffinity({})))();
162
+ const picked = plan.host ?? normalizeHost(localMachineId());
163
+ return matchHost(picked, opts);
164
+ }
149
165
  try {
150
166
  assertValidSshTarget(name);
151
167
  }
@@ -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',
@@ -112,6 +113,7 @@ export const RUN_OPTION_FORWARDING = {
112
113
  lease: 'local-only',
113
114
  box: 'local-only',
114
115
  keepBox: 'local-only',
116
+ fresh: 'local-only', // skips the warm-pool reuse for --lease; the lease path is always local
115
117
  reuse: 'local-only', // reuse-picker choice for --lease; the lease path is always local
116
118
  bare: 'local-only', // skips the local setup-copy push; lease-only concern
117
119
  tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded