@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -11,6 +11,7 @@ import { RushCloudProvider } from './rush.js';
11
11
  import { CodexCloudProvider } from './codex.js';
12
12
  import { FactoryCloudProvider } from './factory.js';
13
13
  import { AntigravityCloudProvider } from './antigravity.js';
14
+ import { HostCloudProvider } from './host.js';
14
15
  import { getUserAgentsDir } from '../state.js';
15
16
  import { AGENTS } from '../agents.js';
16
17
  const META_FILE = path.join(getUserAgentsDir(), 'agents.yaml');
@@ -43,6 +44,9 @@ function initProviders() {
43
44
  providers.set('codex', new CodexCloudProvider(config.providers?.codex));
44
45
  providers.set('factory', new FactoryCloudProvider(config.providers?.factory));
45
46
  providers.set('antigravity', new AntigravityCloudProvider(config.providers?.antigravity));
47
+ // Your own machines (agents hosts / agents devices) over SSH. No agent
48
+ // auto-routes here — it's always an explicit --provider host / --host choice.
49
+ providers.set('host', new HostCloudProvider());
46
50
  }
47
51
  /**
48
52
  * The cloud provider an agent dispatches to by default. Reads the canonical
@@ -9,16 +9,18 @@ import type { JobTrigger } from '../routines.js';
9
9
  /**
10
10
  * Identifier for a supported cloud dispatch backend.
11
11
  *
12
- * Each id is one agent's *own* cloud:
12
+ * Each id is one agent's *own* cloud — plus your own machines:
13
13
  * - `rush` — Rush Cloud (runs Claude against a GitHub repo → PR)
14
14
  * - `codex` — OpenAI Codex Cloud (`codex cloud exec`)
15
15
  * - `factory` — Factory Droid Computers (`droid computer ssh` + remote `droid exec`)
16
16
  * - `antigravity` — Google Gemini Managed Agents (Interactions API)
17
+ * - `host` — a machine you own (`agents hosts` / `agents devices`), over SSH
17
18
  *
18
19
  * Agents route to their native cloud automatically (see `cloudProvider` in the
19
- * agent registry); `--provider` overrides.
20
+ * agent registry); `--provider` overrides. Nothing auto-routes to `host` — it
21
+ * is always an explicit `--provider host` (or `--host <name>`) choice.
20
22
  */
21
- export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity';
23
+ export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity' | 'host';
22
24
  /**
23
25
  * Lifecycle state of a cloud-dispatched task.
24
26
  *
@@ -219,7 +221,7 @@ export interface CloudTarget {
219
221
  kind: TargetKind;
220
222
  }
221
223
  /** Which dispatch option a provider's pre-provisioned target maps to. */
222
- export type TargetKind = 'env' | 'computer';
224
+ export type TargetKind = 'env' | 'computer' | 'host';
223
225
  /**
224
226
  * Thrown by a provider's `dispatch()` when it needs a pre-provisioned target
225
227
  * (Codex env / Factory computer) and none was supplied. The CLI catches this
@@ -0,0 +1,37 @@
1
+ import type { CommsMsg } from './mailbox.js';
2
+ export type { CommsMsg } from './mailbox.js';
3
+ export declare const GLYPH: {
4
+ readonly live: "●";
5
+ readonly idle: "○";
6
+ readonly ask: "▲";
7
+ readonly delivered: "✓";
8
+ readonly pending: "⏳";
9
+ readonly route: "→";
10
+ readonly stream: "─→";
11
+ readonly thread: "⇄";
12
+ };
13
+ export type Accent = 'cyan' | 'amber';
14
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
15
+ export declare function masthead(o: {
16
+ title: string;
17
+ host: string;
18
+ accent: Accent;
19
+ right?: string;
20
+ stats?: string[];
21
+ }): string;
22
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
23
+ export declare function sparkline(counts: number[]): string;
24
+ /** Flatten mailbox histories into one newest-first communication stream. */
25
+ export declare function aggregate(boxes: {
26
+ id: string;
27
+ label: string;
28
+ messages: import('./mailbox.js').StoredMessage[];
29
+ }[]): CommsMsg[];
30
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
31
+ export declare function hourlyCounts(msgs: CommsMsg[], hours: number, now?: Date): number[];
32
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
33
+ export declare function graphEdges(msgs: CommsMsg[]): {
34
+ from: string;
35
+ to: string;
36
+ count: number;
37
+ }[];
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+ import { stringWidth, terminalWidth } from './session/width.js';
3
+ export const GLYPH = {
4
+ live: '●',
5
+ idle: '○',
6
+ ask: '▲',
7
+ delivered: '✓',
8
+ pending: '⏳',
9
+ route: '→',
10
+ stream: '─→',
11
+ thread: '⇄',
12
+ };
13
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
14
+ export function masthead(o) {
15
+ const accent = o.accent === 'cyan' ? chalk.cyan : chalk.yellow;
16
+ const title = `${accent('⌁')} ${chalk.bold(accent(o.title))}`;
17
+ const host = chalk.dim(` · ${o.host}`);
18
+ const stats = o.stats?.length ? ` ${o.stats.join(chalk.dim(' · '))}` : '';
19
+ const left = `${title}${host}${stats}`;
20
+ if (!o.right)
21
+ return left;
22
+ const right = chalk.dim(o.right);
23
+ const gap = Math.max(1, terminalWidth() - stringWidth(left) - stringWidth(right));
24
+ return `${left}${' '.repeat(gap)}${right}`;
25
+ }
26
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█';
27
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
28
+ export function sparkline(counts) {
29
+ if (counts.length === 0)
30
+ return ' ';
31
+ const normalized = counts.map((count) => Number.isFinite(count) ? Math.max(0, count) : 0);
32
+ const max = Math.max(...normalized);
33
+ if (max === 0)
34
+ return ' '.repeat(counts.length);
35
+ return normalized
36
+ .map((count) => SPARK_LEVELS[Math.round((count / max) * (SPARK_LEVELS.length - 1))])
37
+ .join('');
38
+ }
39
+ /** Flatten mailbox histories into one newest-first communication stream. */
40
+ export function aggregate(boxes) {
41
+ return boxes
42
+ .flatMap((box) => {
43
+ const toLabel = box.label || box.id.slice(0, 8);
44
+ return box.messages.map((message) => ({
45
+ from: message.from || 'operator',
46
+ to: message.to,
47
+ toLabel,
48
+ ts: message.ts,
49
+ text: message.text,
50
+ state: message.state,
51
+ box: box.id,
52
+ }));
53
+ })
54
+ .sort((a, b) => compareText(b.ts, a.ts));
55
+ }
56
+ const HOUR_MS = 60 * 60 * 1_000;
57
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
58
+ export function hourlyCounts(msgs, hours, now = new Date()) {
59
+ const bucketCount = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
60
+ const counts = Array.from({ length: bucketCount }, () => 0);
61
+ const nowMs = now.getTime();
62
+ if (bucketCount === 0 || !Number.isFinite(nowMs))
63
+ return counts;
64
+ for (const message of msgs) {
65
+ const timestamp = Date.parse(message.ts);
66
+ const age = nowMs - timestamp;
67
+ if (!Number.isFinite(timestamp) || age < 0 || age >= bucketCount * HOUR_MS)
68
+ continue;
69
+ const bucket = bucketCount - 1 - Math.floor(age / HOUR_MS);
70
+ counts[bucket]++;
71
+ }
72
+ return counts;
73
+ }
74
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
75
+ export function graphEdges(msgs) {
76
+ const edges = new Map();
77
+ for (const message of msgs) {
78
+ const key = JSON.stringify([message.from, message.toLabel]);
79
+ const existing = edges.get(key);
80
+ if (existing)
81
+ existing.count++;
82
+ else
83
+ edges.set(key, { from: message.from, to: message.toLabel, count: 1 });
84
+ }
85
+ return [...edges.values()].sort((a, b) => b.count - a.count || compareText(a.from, b.from) || compareText(a.to, b.to));
86
+ }
87
+ function compareText(a, b) {
88
+ return a < b ? -1 : a > b ? 1 : 0;
89
+ }
@@ -341,7 +341,9 @@ class TcpClient extends BaseClient {
341
341
  this.failPending('helper_exited', 'tcp connection closed before reply');
342
342
  });
343
343
  // Kick off the auth handshake synchronously so its frame (id 1) is the
344
- // first thing written. No token daemon is open (tunnel-gated).
344
+ // first thing written. The Windows daemon now REQUIRES a token (it refuses to
345
+ // start without one); a null token here means no auth frame is sent, the
346
+ // daemon drops the connection, and the caller is told to re-run `setup`.
345
347
  this.authReady = token ? this.authenticate(token) : Promise.resolve();
346
348
  }
347
349
  async authenticate(token) {
@@ -11,6 +11,7 @@
11
11
  * needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
+ import { type SecretsBundle } from '../secrets/bundles.js';
14
15
  /** A crabbox machine as reported by `crabbox list --json`. */
15
16
  export interface CrabboxBox {
16
17
  /** Provider machine name, e.g. `crabbox-blue-hermit-1039689b`. */
@@ -29,6 +30,16 @@ export interface CrabboxBox {
29
30
  class?: string;
30
31
  /** True when running + bootstrap-complete. */
31
32
  ready: boolean;
33
+ /** crabbox `keep` label — a kept box survives `crabbox cleanup` past its TTL. */
34
+ keep: boolean;
35
+ /** Unix seconds the box was created, or null when the label is absent. */
36
+ createdAt: number | null;
37
+ /** Unix seconds the lease expires, or null. */
38
+ expiresAt: number | null;
39
+ /** Unix seconds the box was last touched (reused / run against), or null. */
40
+ lastTouchedAt: number | null;
41
+ /** Idle-timeout window in seconds, or null. */
42
+ idleTimeoutSecs: number | null;
32
43
  }
33
44
  export interface CrabboxOptions {
34
45
  /**
@@ -41,6 +52,38 @@ export interface CrabboxOptions {
41
52
  }
42
53
  /** Locate the crabbox binary, or throw an actionable error. */
43
54
  export declare function findCrabbox(): string;
55
+ /**
56
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
57
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
58
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
59
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
60
+ */
61
+ export declare const LEASE_PROVIDER_TOKEN_KEYS: string[];
62
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
63
+ export declare function pickLeaseBundleFromList(bundles: SecretsBundle[]): string | undefined;
64
+ /** A resolved lease bundle: its name, plus (auto-detect only) the exact keys to inject. */
65
+ export interface ResolvedLeaseBundle {
66
+ name: string;
67
+ /** When set (auto-detect), inject ONLY these keys — not the whole bundle. */
68
+ keys?: string[];
69
+ }
70
+ /**
71
+ * The secrets bundle to feed crabbox, resolved in priority order:
72
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
73
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
74
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
75
+ *
76
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
77
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
78
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
79
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
80
+ * process (it is called several times per lease, and we don't want a scan each
81
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
82
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
83
+ */
84
+ export declare function resolveLeaseBundle(): ResolvedLeaseBundle | undefined;
85
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
86
+ export declare function setLeaseSecretsBundle(name: string): void;
44
87
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
45
88
  export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
46
89
  /** All crabbox machines the broker knows about. */
@@ -96,3 +139,32 @@ export declare function crabboxRun(slug: string, remoteCmd: string, opts?: Crabb
96
139
  export declare function crabboxRunScript(slug: string, script: string, opts?: CrabboxRunOptions): Promise<number | null>;
97
140
  /** Release the lease / delete the box. Best-effort; never throws. */
98
141
  export declare function crabboxStop(slug: string, opts?: CrabboxOptions): boolean;
142
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
143
+ export declare const REAP_MIN_IDLE_SECS = 3600;
144
+ /**
145
+ * Whether a box is a genuine orphan that is safe to reap.
146
+ *
147
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
148
+ * past) AND the box has not been touched for a safety window of
149
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
150
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
151
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
152
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
153
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
154
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
155
+ */
156
+ export declare function isReapSafe(box: CrabboxBox, nowSecs: number): boolean;
157
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
158
+ export declare function reapSafeOrphans(boxes: CrabboxBox[], nowSecs: number): CrabboxBox[];
159
+ /**
160
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
161
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
162
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
163
+ */
164
+ export declare function reapOrphans(opts?: CrabboxOptions & {
165
+ nowSecs?: number;
166
+ dryRun?: boolean;
167
+ }): {
168
+ candidates: CrabboxBox[];
169
+ reaped: string[];
170
+ };
@@ -12,7 +12,8 @@
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
14
  import { spawn, spawnSync } from 'child_process';
15
- import { readAndResolveBundleEnv } from '../secrets/bundles.js';
15
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext, listBundles, bundleExists } from '../secrets/bundles.js';
16
+ import { readMeta, writeMeta } from '../state.js';
16
17
  /** Locate the crabbox binary, or throw an actionable error. */
17
18
  export function findCrabbox() {
18
19
  const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
@@ -21,21 +22,99 @@ export function findCrabbox() {
21
22
  }
22
23
  return 'crabbox';
23
24
  }
25
+ /**
26
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
27
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
28
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
29
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
30
+ */
31
+ export const LEASE_PROVIDER_TOKEN_KEYS = ['HCLOUD_TOKEN', 'AWS_ACCESS_KEY_ID', 'DIGITALOCEAN_TOKEN', 'DO_TOKEN'];
32
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
33
+ export function pickLeaseBundleFromList(bundles) {
34
+ for (const b of bundles) {
35
+ if (Object.keys(b.vars ?? {}).some((k) => LEASE_PROVIDER_TOKEN_KEYS.includes(k)))
36
+ return b.name;
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * The secrets bundle to feed crabbox, resolved in priority order:
42
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
43
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
44
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
45
+ *
46
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
47
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
48
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
49
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
50
+ * process (it is called several times per lease, and we don't want a scan each
51
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
52
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
53
+ */
54
+ export function resolveLeaseBundle() {
55
+ const env = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
56
+ if (env)
57
+ return { name: env };
58
+ try {
59
+ const configured = readMeta().lease?.secretsBundle;
60
+ if (configured && bundleExists(configured))
61
+ return { name: configured };
62
+ }
63
+ catch {
64
+ /* config unreadable — fall through to auto-detect */
65
+ }
66
+ try {
67
+ const bundles = listBundles();
68
+ const name = pickLeaseBundleFromList(bundles);
69
+ if (name) {
70
+ const b = bundles.find((x) => x.name === name);
71
+ const keys = LEASE_PROVIDER_TOKEN_KEYS.filter((k) => !!b && k in (b.vars ?? {}));
72
+ return { name, keys };
73
+ }
74
+ }
75
+ catch {
76
+ /* secrets unreadable — no auto-detect */
77
+ }
78
+ return undefined;
79
+ }
80
+ /** Process-lifetime memo so the tier-3 `listBundles()` scan runs at most once. */
81
+ let leaseBundleMemo;
82
+ function resolveLeaseBundleMemo() {
83
+ if (!leaseBundleMemo)
84
+ leaseBundleMemo = { value: resolveLeaseBundle() };
85
+ return leaseBundleMemo.value;
86
+ }
87
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
88
+ export function setLeaseSecretsBundle(name) {
89
+ const meta = readMeta();
90
+ writeMeta({ ...meta, lease: { ...meta.lease, secretsBundle: name } });
91
+ leaseBundleMemo = undefined; // invalidate so the next resolve sees the new config
92
+ }
24
93
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
25
94
  export function crabboxEnv(opts) {
26
- const bundle = opts.secretsBundle ?? process.env.AGENTS_LEASE_SECRETS_BUNDLE;
27
- if (!bundle)
95
+ const resolved = opts.secretsBundle
96
+ ? { name: opts.secretsBundle }
97
+ : resolveLeaseBundleMemo();
98
+ if (!resolved)
28
99
  return process.env;
29
100
  try {
30
- // Reuse the same resolver `agents secrets exec` uses so a keychain-backed
31
- // bundle (e.g. hetzner.com HCLOUD_TOKEN) reaches crabbox without ever
32
- // touching disk.
33
- const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents run --lease (crabbox)' });
101
+ // Auto-detected bundle inject ONLY the provider token key(s) (least
102
+ // privilege; an unrelated bundle can't leak its other secrets into crabbox).
103
+ // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
104
+ // whole the user chose it. Same resolver `agents secrets exec` uses.
105
+ const { env } = readAndResolveBundleEnv(resolved.name, {
106
+ caller: 'agents run --lease (crabbox)',
107
+ keys: resolved.keys,
108
+ // --lease is headless by contract and crabboxEnv is called several times
109
+ // per run (list/wait/spawn/stop) — resolve broker-only so a keychain bundle
110
+ // can't pop repeated unwatched Touch ID sheets mid-lease.
111
+ agentOnly: isHeadlessSecretsContext(),
112
+ });
34
113
  return { ...process.env, ...env };
35
114
  }
36
115
  catch (e) {
37
- throw new Error(`Could not load secrets bundle "${bundle}" for crabbox: ${e.message}. ` +
38
- `Fix the bundle (agents secrets view ${bundle}) or unset lease.secretsBundle to use crabbox's own login.`);
116
+ throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
117
+ `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
39
118
  }
40
119
  }
41
120
  function normalizeBox(raw) {
@@ -46,6 +125,12 @@ function normalizeBox(raw) {
46
125
  const status = String(raw.status ?? '');
47
126
  const state = String(labels.state ?? '');
48
127
  const publicNet = (raw.public_net ?? {});
128
+ const num = (v) => {
129
+ if (v === undefined || v === '')
130
+ return null;
131
+ const n = Number(v);
132
+ return Number.isFinite(n) ? n : null;
133
+ };
49
134
  return {
50
135
  name: String(raw.name ?? ''),
51
136
  status,
@@ -56,6 +141,11 @@ function normalizeBox(raw) {
56
141
  profile: labels.profile,
57
142
  class: labels.class,
58
143
  ready: status === 'running' && state === 'ready',
144
+ keep: labels.keep === 'true',
145
+ createdAt: num(labels.created_at),
146
+ expiresAt: num(labels.expires_at),
147
+ lastTouchedAt: num(labels.last_touched_at),
148
+ idleTimeoutSecs: num(labels.idle_timeout_secs ?? labels.idle_timeout),
59
149
  };
60
150
  }
61
151
  /** All crabbox machines the broker knows about. */
@@ -113,6 +203,24 @@ export async function crabboxWarmup(opts = {}) {
113
203
  });
114
204
  if (r.status !== 0) {
115
205
  const detail = (r.stderr || r.stdout || '').trim();
206
+ // A provider `server_limit` / `resource_limit_exceeded` 403 means the account's
207
+ // box quota is full. Turn the raw 403 into an actionable message that names the
208
+ // reap-safe orphans + the one-command fix, instead of a generic failure.
209
+ if (/server_limit|resource_limit_exceeded/i.test(detail)) {
210
+ let hint = ' Stop unused boxes (`crabbox list`) or raise your provider server limit.';
211
+ try {
212
+ const orphans = reapSafeOrphans(crabboxList(opts), Math.floor(Date.now() / 1000));
213
+ if (orphans.length) {
214
+ hint =
215
+ ` ${orphans.length} expired, idle box(es) are holding the quota — free them with ` +
216
+ `\`agents lease gc\` (or \`crabbox stop ${orphans[0].slug}\`).`;
217
+ }
218
+ }
219
+ catch {
220
+ /* best-effort hint; fall back to the generic guidance above */
221
+ }
222
+ throw new Error(`crabbox warmup failed: provider server limit reached.${hint}`);
223
+ }
116
224
  throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
117
225
  `Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
118
226
  }
@@ -229,3 +337,48 @@ export function crabboxStop(slug, opts = {}) {
229
337
  return false;
230
338
  }
231
339
  }
340
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
341
+ export const REAP_MIN_IDLE_SECS = 3600;
342
+ /**
343
+ * Whether a box is a genuine orphan that is safe to reap.
344
+ *
345
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
346
+ * past) AND the box has not been touched for a safety window of
347
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
348
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
349
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
350
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
351
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
352
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
353
+ */
354
+ export function isReapSafe(box, nowSecs) {
355
+ if (box.expiresAt === null || box.lastTouchedAt === null)
356
+ return false;
357
+ if (box.expiresAt > nowSecs)
358
+ return false;
359
+ const window = Math.max((box.idleTimeoutSecs ?? 0) * 2, REAP_MIN_IDLE_SECS);
360
+ return nowSecs - box.lastTouchedAt >= window;
361
+ }
362
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
363
+ export function reapSafeOrphans(boxes, nowSecs) {
364
+ return boxes
365
+ .filter((b) => isReapSafe(b, nowSecs))
366
+ .sort((a, b) => (a.lastTouchedAt ?? 0) - (b.lastTouchedAt ?? 0));
367
+ }
368
+ /**
369
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
370
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
371
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
372
+ */
373
+ export function reapOrphans(opts = {}) {
374
+ const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
375
+ const candidates = reapSafeOrphans(crabboxList(opts), nowSecs);
376
+ if (opts.dryRun)
377
+ return { candidates, reaped: [] };
378
+ const reaped = [];
379
+ for (const b of candidates) {
380
+ if (crabboxStop(b.slug, opts))
381
+ reaped.push(b.slug);
382
+ }
383
+ return { candidates, reaped };
384
+ }
@@ -47,6 +47,19 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
47
47
  checked: boolean;
48
48
  disabled: boolean | string;
49
49
  }[]) => Promise<AgentId[]>): Promise<AgentId[]>;
50
+ /**
51
+ * The lease runtime to provision for a headless run of `agentName`.
52
+ *
53
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
54
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
55
+ * lease runtime (preferring claude), or null when none is signed in. This is the
56
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
57
+ * a prompt, so it is headless by contract and must never block on a TTY.
58
+ *
59
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
60
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
61
+ */
62
+ export declare function inferLeaseRuntime(agentName: string, detected: DetectedRuntime[]): AgentId | null;
50
63
  /**
51
64
  * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
52
65
  * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
@@ -79,6 +79,30 @@ export async function pickRuntimes(detected, prompt) {
79
79
  const { checkbox } = await import('@inquirer/prompts');
80
80
  return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
81
81
  }
82
+ /**
83
+ * The lease runtime to provision for a headless run of `agentName`.
84
+ *
85
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
86
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
87
+ * lease runtime (preferring claude), or null when none is signed in. This is the
88
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
89
+ * a prompt, so it is headless by contract and must never block on a TTY.
90
+ *
91
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
92
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
93
+ */
94
+ export function inferLeaseRuntime(agentName, detected) {
95
+ const signedIn = detected.filter((d) => d.signedIn && d.credPath);
96
+ // The agent names a lease runtime directly: require that runtime to be signed
97
+ // in — never silently substitute a different one for an explicit `run <runtime>`
98
+ // (that would lease a billable box only to boot it "Not logged in"). Not signed
99
+ // in → null, so the caller exits with "sign into it locally first".
100
+ if (LEASE_RUNTIMES.some((c) => c.id === agentName)) {
101
+ return signedIn.find((d) => d.id === agentName)?.id ?? null;
102
+ }
103
+ // Custom/workflow agent: fall back to the signed-in runtime (preferring claude).
104
+ return signedIn.find((d) => d.id === 'claude')?.id ?? signedIn[0]?.id ?? null;
105
+ }
82
106
  // A long random sentinel makes an accidental (or malicious) collision with a
83
107
  // token's contents effectively impossible, so the quoted heredoc can never be
84
108
  // closed early by the credential body.
@@ -134,8 +158,17 @@ export async function resolveClaudeCredentialsBlob(opts) {
134
158
  if (process.platform === 'darwin') {
135
159
  // 1) Bare service — the default native (non-managed) install.
136
160
  const bare = tryRead(service(undefined));
137
- if (bare)
138
- return bare;
161
+ if (bare) {
162
+ if (!opts?.preferEmail)
163
+ return bare;
164
+ // preferEmail is set — verify the bare service belongs to the right account
165
+ // before handing it back; on mismatch fall through to managed installs.
166
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
167
+ const bareEmail = await accountEmail(realHome).catch(() => null);
168
+ if (bareEmail === opts.preferEmail)
169
+ return bare;
170
+ // email mismatch — fall through
171
+ }
139
172
  // 2) Managed installs — hash-suffixed service keyed to each version home.
140
173
  // Prefer the version whose account email matches the copied config.
141
174
  let homes;
@@ -81,10 +81,26 @@ export declare function readDaemonClaudeOAuthToken(opts?: {
81
81
  * embed long-lived credentials.
82
82
  */
83
83
  export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
84
- /** Generate a macOS launchd plist for auto-starting the daemon. */
85
- export declare function generateLaunchdPlist(oauthToken?: string | null, agentsBin?: string): string;
86
- /** Generate a Linux systemd user unit for auto-starting the daemon. */
87
- export declare function generateSystemdUnit(oauthToken?: string | null, agentsBin?: string): string;
84
+ /**
85
+ * Generate a macOS launchd plist for auto-starting the daemon.
86
+ *
87
+ * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
88
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
89
+ * obtains the token at startup from the `claude` secrets bundle
90
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
91
+ * Keychain-backed secure store and never touches the unit file.
92
+ */
93
+ export declare function generateLaunchdPlist(agentsBin?: string): string;
94
+ /**
95
+ * Generate a Linux systemd user unit for auto-starting the daemon.
96
+ *
97
+ * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
98
+ * manifest is a plaintext credential on disk even at 0600. The daemon instead
99
+ * obtains the token at startup from the `claude` secrets bundle
100
+ * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
101
+ * store and never touches the unit file.
102
+ */
103
+ export declare function generateSystemdUnit(agentsBin?: string): string;
88
104
  export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
89
105
  /** Start the daemon via launchd, systemd, or as a detached process. */
90
106
  export declare function startDaemon(agentsBin?: string): {