@phnx-labs/agents-cli 1.22.8 → 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 (47) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +41 -9
  5. package/dist/commands/feed.js +38 -1
  6. package/dist/commands/harness.d.ts +40 -1
  7. package/dist/commands/harness.js +316 -76
  8. package/dist/commands/profiles.d.ts +42 -0
  9. package/dist/commands/profiles.js +91 -3
  10. package/dist/commands/sessions-picker.js +3 -3
  11. package/dist/commands/sessions-render.d.ts +12 -0
  12. package/dist/commands/sessions-render.js +124 -0
  13. package/dist/commands/sessions.js +28 -1
  14. package/dist/commands/ssh.js +26 -3
  15. package/dist/commands/teams.js +12 -1
  16. package/dist/index.js +9 -2
  17. package/dist/lib/crabbox/cli.d.ts +35 -0
  18. package/dist/lib/crabbox/cli.js +46 -0
  19. package/dist/lib/crabbox/config.d.ts +21 -0
  20. package/dist/lib/crabbox/config.js +43 -0
  21. package/dist/lib/crabbox/lease.d.ts +15 -5
  22. package/dist/lib/crabbox/lease.js +57 -17
  23. package/dist/lib/daemon.js +12 -11
  24. package/dist/lib/devices/resolve-target.d.ts +4 -3
  25. package/dist/lib/devices/resolve-target.js +4 -3
  26. package/dist/lib/hosts/passthrough.js +18 -1
  27. package/dist/lib/hosts/registry.d.ts +4 -0
  28. package/dist/lib/hosts/registry.js +16 -0
  29. package/dist/lib/hosts/remote-cmd.js +1 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  31. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  32. package/dist/lib/observe-aliases.d.ts +30 -0
  33. package/dist/lib/observe-aliases.js +56 -0
  34. package/dist/lib/redact.js +4 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/parse.d.ts +5 -1
  38. package/dist/lib/session/parse.js +23 -13
  39. package/dist/lib/session/prompt.js +5 -0
  40. package/dist/lib/session/render.d.ts +3 -0
  41. package/dist/lib/session/render.js +33 -10
  42. package/dist/lib/startup/command-registry.js +15 -1
  43. package/dist/lib/usage-refresh.d.ts +19 -11
  44. package/dist/lib/usage-refresh.js +75 -43
  45. package/dist/lib/usage.d.ts +12 -0
  46. package/dist/lib/usage.js +37 -6
  47. package/package.json +1 -1
@@ -0,0 +1,12 @@
1
+ import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
3
+ export declare const MARKDOWN_RENDER_AGENTS: readonly ["claude", "codex", "gemini", "antigravity", "opencode", "grok", "rush", "hermes", "kimi", "droid", "cursor"];
4
+ export type ReasoningMode = 'omit' | 'fold' | 'include';
5
+ /** Build one shareable Markdown document from the canonical preview and event model. */
6
+ export declare function renderSessionMarkdownDocument(session: SessionMeta, options?: {
7
+ redact?: boolean;
8
+ reasoning?: ReasoningMode;
9
+ knownSecrets?: readonly string[];
10
+ maxToolOutputChars?: number;
11
+ }): string;
12
+ export declare function registerSessionsRenderCommand(sessionsCmd: Command): void;
@@ -0,0 +1,124 @@
1
+ /** Render selected normalized session transcripts as redacted Markdown documents. */
2
+ import * as fs from 'fs';
3
+ import { stripVTControlCharacters } from 'node:util';
4
+ import chalk from 'chalk';
5
+ import { setHelpSections } from '../lib/help.js';
6
+ import { knownSecretValuesFromEnv, redactSecrets } from '../lib/redact.js';
7
+ import { discoverSessions } from '../lib/session/discover.js';
8
+ import { parseSession } from '../lib/session/parse.js';
9
+ import { extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
10
+ import { renderConversationMarkdown } from '../lib/session/render.js';
11
+ import { buildPreview } from './sessions-picker.js';
12
+ import { parseAgentFilter } from './sessions.js';
13
+ import { selectSessions } from './sessions-export.js';
14
+ export const MARKDOWN_RENDER_AGENTS = [
15
+ 'claude',
16
+ 'codex',
17
+ 'gemini',
18
+ 'antigravity',
19
+ 'opencode',
20
+ 'grok',
21
+ 'rush',
22
+ 'hermes',
23
+ 'kimi',
24
+ 'droid',
25
+ 'cursor',
26
+ ];
27
+ function parseReasoning(value) {
28
+ if (value === 'omit' || value === 'fold' || value === 'include')
29
+ return value;
30
+ throw new Error(`Unknown reasoning mode "${value}". Expected omit, fold, or include.`);
31
+ }
32
+ function quotePreview(preview) {
33
+ return preview.split('\n').map((line) => `> ${line}`).join('\n');
34
+ }
35
+ /** Build one shareable Markdown document from the canonical preview and event model. */
36
+ export function renderSessionMarkdownDocument(session, options = {}) {
37
+ if (!MARKDOWN_RENDER_AGENTS.includes(session.agent)) {
38
+ throw new Error(`Cannot render ${session.agent} session ${session.shortId || session.id}: Markdown rendering supports ${MARKDOWN_RENDER_AGENTS.join(', ')}.`);
39
+ }
40
+ // Preserve normalized tool output here so the Markdown renderer owns the
41
+ // visible cap and can report exactly how much it omitted.
42
+ const events = parseSession(session.filePath, session.agent, { maxToolOutputChars: Infinity });
43
+ if (events.length === 0) {
44
+ throw new Error(`Cannot render ${session.agent} session ${session.shortId || session.id}: transcript produced no normalized events.`);
45
+ }
46
+ const shouldRedact = options.redact !== false;
47
+ const sanitize = (text) => shouldRedact ? redactSecrets(text, options.knownSecrets) : text;
48
+ const preview = sanitize(stripVTControlCharacters(buildPreview(session)));
49
+ const shareEvents = events.filter((event) => !(event.type === 'message' && event._synthetic));
50
+ const firstPrompt = shareEvents.find((event) => event.type === 'message' && event.role === 'user')?.content;
51
+ const title = sanitize(session.label || (firstPrompt ? extractSessionTopic(firstPrompt) : undefined) ||
52
+ (session.topic && !isSyntheticUserMessage(session.topic) ? session.topic : undefined) ||
53
+ `${session.agent} session ${session.shortId || session.id}`);
54
+ const conversation = renderConversationMarkdown(shareEvents, {
55
+ redact: shouldRedact,
56
+ knownSecrets: options.knownSecrets,
57
+ reasoning: options.reasoning ?? 'omit',
58
+ maxToolOutputChars: options.maxToolOutputChars,
59
+ });
60
+ return `# ${title}\n\n## Session preview\n\n${quotePreview(preview)}\n\n## Conversation\n\n${conversation}\n`;
61
+ }
62
+ export function registerSessionsRenderCommand(sessionsCmd) {
63
+ const cmd = sessionsCmd
64
+ .command('render <selectors...>')
65
+ .description('Render one or more sessions as readable, redacted Markdown for review or sharing.')
66
+ .option('--format <format>', 'Output format (md or markdown)', 'md')
67
+ .option('-o, --output <path>', 'Write the rendered document to a file instead of stdout')
68
+ .option('--reasoning <mode>', 'Reasoning visibility: omit, fold, or include', 'omit');
69
+ setHelpSections(cmd, {
70
+ examples: `# Render one redacted session for a confidential gist
71
+ agents sessions render a1b2c3d4 -o session.md
72
+
73
+ # Combine several sessions into one Markdown document
74
+ agents sessions render a1b2c3d4 d4c3b2a1 -o delivery-sessions.md
75
+
76
+ # Keep reasoning locally in collapsible sections and opt out of redaction
77
+ agents sessions render a1b2c3d4 --reasoning fold --no-redact`,
78
+ notes: `Markdown is redacted by default, including credential-shaped values and local home paths.
79
+ The preview at the top is the same preview shown by 'agents sessions'. Tool output is truncated
80
+ with an explicit note. Use --no-redact only for local output that will not be shared.`,
81
+ });
82
+ cmd.action(async (selectors, options, command) => {
83
+ const globals = command.optsWithGlobals();
84
+ if (!['md', 'markdown'].includes(options.format.toLowerCase())) {
85
+ throw new Error(`Unknown format "${options.format}". Expected md or markdown.`);
86
+ }
87
+ const reasoning = parseReasoning(options.reasoning);
88
+ const limit = Math.max(1, Number.parseInt(globals.limit || '100', 10) || 100);
89
+ const agent = parseAgentFilter(globals.agent).agent;
90
+ const sessions = selectSessions(await discoverSessions({
91
+ all: globals.all !== false,
92
+ since: globals.since,
93
+ limit,
94
+ agent: agent ?? undefined,
95
+ }), selectors);
96
+ if (sessions.length === 0) {
97
+ process.stderr.write(chalk.yellow('No sessions matched the selection.\n'));
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ const knownSecrets = globals.redact === false ? undefined : knownSecretValuesFromEnv();
102
+ const rendered = sessions.map((session) => ({
103
+ id: session.id,
104
+ agent: session.agent,
105
+ markdown: renderSessionMarkdownDocument(session, {
106
+ redact: globals.redact !== false,
107
+ reasoning,
108
+ knownSecrets,
109
+ }),
110
+ }));
111
+ const markdown = rendered.map((item) => item.markdown.trimEnd()).join('\n\n---\n\n') + '\n';
112
+ if (options.output)
113
+ fs.writeFileSync(options.output, markdown, { mode: 0o600 });
114
+ if (globals.json) {
115
+ process.stdout.write(JSON.stringify({ redacted: globals.redact !== false, reasoning, sessions: rendered }, null, 2) + '\n');
116
+ }
117
+ else if (!options.output) {
118
+ process.stdout.write(markdown);
119
+ }
120
+ else {
121
+ process.stderr.write(chalk.green(`Rendered ${rendered.length} session${rendered.length === 1 ? '' : 's'} to ${options.output}\n`));
122
+ }
123
+ });
124
+ }
@@ -56,6 +56,7 @@ import { registerDetachCommand } from './detach.js';
56
56
  import { registerAttachCommand } from './attach.js';
57
57
  import { registerSessionsInjectCommand } from './sessions-inject.js';
58
58
  import { registerSessionsExportCommand } from './sessions-export.js';
59
+ import { registerSessionsRenderCommand } from './sessions-render.js';
59
60
  import { registerSessionsImportCommand } from './sessions-import.js';
60
61
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
61
62
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
@@ -3584,7 +3585,7 @@ export function registerSessionsCommands(program) {
3584
3585
  .option('--query <clause>', 'Search text; repeat with --include tools to require distinct matching calls', collectQueryClause, [])
3585
3586
  .option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
3586
3587
  .addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
3587
- .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
3588
+ .description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active` (alias: `agents roster`).')
3588
3589
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
3589
3590
  .option('--claude', 'Shorthand for --agent claude')
3590
3591
  .option('--codex', 'Shorthand for --agent codex')
@@ -3735,11 +3736,37 @@ export function registerSessionsCommands(program) {
3735
3736
  registerAttachCommand(sessionsCmd);
3736
3737
  registerSessionsInjectCommand(sessionsCmd);
3737
3738
  registerSessionsExportCommand(sessionsCmd);
3739
+ registerSessionsRenderCommand(sessionsCmd);
3738
3740
  registerSessionsImportCommand(sessionsCmd);
3739
3741
  registerSessionsMigrateCommand(sessionsCmd);
3740
3742
  registerSessionsMigrationsCommand(sessionsCmd);
3741
3743
  registerSessionsBackfillCommand(sessionsCmd);
3742
3744
  registerSessionsStatsCommand(sessionsCmd);
3745
+ // Observe-umbrella alias (Phase 3): roster → sessions --active.
3746
+ registerSessionsObserveAliases(program);
3747
+ }
3748
+ /**
3749
+ * `roster` → sessions --active. Registered with the sessions module so the
3750
+ * lazy loader for `roster` also registers the real `sessions` command for re-parse.
3751
+ */
3752
+ function registerSessionsObserveAliases(program) {
3753
+ program
3754
+ .command('roster')
3755
+ .description('Live agent roster (alias of `agents sessions --active`). Who is running right now.')
3756
+ .allowUnknownOption()
3757
+ .allowExcessArguments()
3758
+ .action(async () => {
3759
+ const { expandObserveAlias } = await import('../lib/observe-aliases.js');
3760
+ const rest = process.argv.slice(3);
3761
+ const expanded = expandObserveAlias('roster', rest);
3762
+ if (!expanded) {
3763
+ console.error(chalk.red('Unknown observe alias: roster'));
3764
+ process.exit(1);
3765
+ }
3766
+ if (process.stderr.isTTY)
3767
+ process.stderr.write(chalk.gray(`${expanded.note}\n`));
3768
+ await program.parseAsync(['node', 'agents', ...expanded.argv]);
3769
+ });
3743
3770
  }
3744
3771
  function formatNoSessionsMessage(showAll, project) {
3745
3772
  const projectQuery = project?.trim();
@@ -18,6 +18,7 @@ import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
19
  import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
+ import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
21
22
  import { registerFleetCaptureCommand } from './fleet-capture.js';
22
23
  import { registerFleetApplyAlias } from './apply.js';
23
24
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, setAutoLaunchEnabled, setAutoLaunchPreferred, upsertDevice, writeReachability, } from '../lib/devices/registry.js';
@@ -1413,9 +1414,12 @@ Examples:
1413
1414
  agents ssh win-mini # interactive login
1414
1415
  agents ssh win-mini hostname # run a command (PowerShell on Windows)
1415
1416
  agents ssh yosemite-s0 uptime # run a command (POSIX)
1417
+ agents ssh auto # affinity-pick a device (same engine as 'agents run --device auto')
1416
1418
 
1417
1419
  Devices come from 'agents devices'. Password auth pulls the secret from a
1418
1420
  secrets bundle via an askpass shim — the password never touches argv.
1421
+ 'auto' picks a remote device by 14-day usage; a pick landing on this machine
1422
+ is refused with a clear message instead of self-dialing.
1419
1423
  `)
1420
1424
  .action(async (name, cmd) => {
1421
1425
  // Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
@@ -1423,16 +1427,35 @@ secrets bundle via an askpass shim — the password never touches argv.
1423
1427
  await runAskpass();
1424
1428
  return;
1425
1429
  }
1430
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
1431
+ // (RUSH-2185) — pick the concrete device up front via the SAME engine
1432
+ // (resolveDeviceAffinity), rather than leaning on matchHost's generic
1433
+ // self-resolution (../lib/hosts/registry.js): `agents ssh` connects OUT to
1434
+ // a remote device, so a pick that lands on THIS machine is refused with a
1435
+ // clear message instead of self-SSHing — which also holds when this
1436
+ // machine was never itself enrolled as a device (matchHost would have
1437
+ // nothing to resolve "self" to, and mis-report the pick as "Unknown
1438
+ // device").
1439
+ let target = name;
1440
+ if (isDeviceAuto(name)) {
1441
+ const plan = resolveDeviceAffinity({});
1442
+ if (!plan.host) {
1443
+ console.error(chalk.red(`'auto' picked this machine — 'agents ssh' connects to a remote device. Pass a device name; see 'agents devices list'.`));
1444
+ process.exit(1);
1445
+ }
1446
+ process.stderr.write(chalk.gray(`[agents] device=auto → ${plan.host}\n`));
1447
+ target = plan.host;
1448
+ }
1426
1449
  // Accept the full fleet target grammar: a registered `name`, a
1427
1450
  // `user@device` (same device, login user overridden — dialed via its
1428
1451
  // Tailscale route, not LAN DNS), or an ad-hoc `user@host`/`host` literal.
1429
1452
  // A bare unregistered alias still errors as "Unknown device".
1430
- const device = await resolveDeviceTarget(name);
1453
+ const device = await resolveDeviceTarget(target);
1431
1454
  if (!device) {
1432
1455
  // Not a registered device — it may be a leased crabbox box slug. ssh into
1433
1456
  // it directly (crabbox@<tailnet|ip>:2222) before giving up.
1434
- trySshLeasedBox(name, cmd); // exits the process on a match
1435
- console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
1457
+ trySshLeasedBox(target, cmd); // exits the process on a match
1458
+ console.error(chalk.red(`Unknown device '${target}'. See 'agents devices list'.`));
1436
1459
  process.exit(1);
1437
1460
  }
1438
1461
  // Preflight: a device Tailscale last saw offline would otherwise hang
@@ -17,6 +17,7 @@ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } f
17
17
  import { setHelpSections } from '../lib/help.js';
18
18
  import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
19
19
  import { resolveHost } from '../lib/hosts/registry.js';
20
+ import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
20
21
  import { sshTargetFor } from '../lib/hosts/types.js';
21
22
  import { ensureHostReady } from '../lib/hosts/ready.js';
22
23
  import { remoteShellFor } from '../lib/hosts/remote-cmd.js';
@@ -1330,7 +1331,7 @@ export function registerTeamsCommands(program) {
1330
1331
  // `--device`/`--host` are aliases (addHostOption registers both). For `teams
1331
1332
  // add` the passthrough special-cases them as PLACEMENT, not routing, so the
1332
1333
  // local action reads them here. Reject a conflicting pair.
1333
- const explicitDevice = (() => {
1334
+ let explicitDevice = (() => {
1334
1335
  const h = opts.host;
1335
1336
  const d = opts.device;
1336
1337
  if (h && d && h !== d) {
@@ -1338,6 +1339,16 @@ export function registerTeamsCommands(program) {
1338
1339
  }
1339
1340
  return h ?? d ?? null;
1340
1341
  })();
1342
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
1343
+ // (RUSH-2185) — pick the concrete device name up front so the local-machine
1344
+ // check right below (and every dieFriction message further down) sees the
1345
+ // real target instead of the literal string "auto".
1346
+ if (explicitDevice && isDeviceAuto(explicitDevice)) {
1347
+ const plan = resolveDeviceAffinity({});
1348
+ const picked = plan.host ?? machineId();
1349
+ process.stderr.write(chalk.gray(`[teams] device=auto → ${picked === machineId() ? 'local' : picked}\n`));
1350
+ explicitDevice = picked;
1351
+ }
1341
1352
  // Distributed teams: --device <name> PINS this teammate to a machine over
1342
1353
  // SSH. Resolve + validate the placement here so a bad target fails at `add`
1343
1354
  // time, not silently at launch. Persisted (hostName/hostTarget/repoPath) so
package/dist/index.js CHANGED
@@ -310,6 +310,15 @@ Run and dispatch:
310
310
  browser Automate a browser — navigate, click, screenshot, console, network
311
311
  pty Drive interactive terminal programs (REPLs, TUIs) via a persistent PTY session
312
312
 
313
+ Observe (read the fleet — no store merge; aliases point at the real readers):
314
+ feed / inbox Needs-you inbox (open blocks waiting on you)
315
+ timeline Agent progress stream (= feed --filter updates)
316
+ roster Live agents (= sessions --active)
317
+ events Unified ops + activity event trail
318
+ audit Tamper-evident run-dispatch log (not events)
319
+ snapshot One-process inventory + active sessions poll
320
+ status Sync/drift only (not the live fleet snapshot)
321
+
313
322
  Credentials and profiles:
314
323
  profile Activate resource profiles across skills, MCP, permissions, and secrets
315
324
  profiles Bundles of (host CLI, endpoint, model, auth)
@@ -317,8 +326,6 @@ Credentials and profiles:
317
326
 
318
327
  Diagnostics:
319
328
  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)
322
329
  usage [agent] Show rate-limit and quota usage per agent
323
330
  perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
324
331
 
@@ -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
  }