@madarco/agentbox 0.4.1 → 0.6.0

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 (40) hide show
  1. package/dist/{chunk-J35IH7W5.js → chunk-BBZMA2K6.js} +61 -23
  2. package/dist/chunk-BBZMA2K6.js.map +1 -0
  3. package/dist/{chunk-SOMIKEN2.js → chunk-HHMWQNLF.js} +272 -214
  4. package/dist/chunk-HHMWQNLF.js.map +1 -0
  5. package/dist/{chunk-IDR4HVIC.js → chunk-HPZMD5DE.js} +2 -2
  6. package/dist/chunk-HPZMD5DE.js.map +1 -0
  7. package/dist/{chunk-NSIECUCS.js → chunk-HTTKML3C.js} +705 -289
  8. package/dist/chunk-HTTKML3C.js.map +1 -0
  9. package/dist/{chunk-WR5FFGE5.js → chunk-KJNZP6I3.js} +218 -128
  10. package/dist/chunk-KJNZP6I3.js.map +1 -0
  11. package/dist/{chunk-FQD6ZWYW.js → chunk-M7I247BK.js} +68 -65
  12. package/dist/chunk-M7I247BK.js.map +1 -0
  13. package/dist/create-6PWXI6HO-OWAMHBAK.js +15 -0
  14. package/dist/index.js +2394 -1283
  15. package/dist/index.js.map +1 -1
  16. package/dist/{lifecycle-LURNDNYO-UWQYPNPX.js → lifecycle-EMXR46DI-DUVBXNTV.js} +5 -5
  17. package/dist/{state-ZSP3ORXW-WI6KOIG3.js → state-KD7M46ZP-KHFTHFUS.js} +2 -2
  18. package/dist/stats-SZXOJE3D-N7OODCHW.js +19 -0
  19. package/package.json +3 -2
  20. package/runtime/docker/Dockerfile.box +65 -25
  21. package/runtime/docker/apps/cli/share/agentbox-setup/SKILL.md +52 -55
  22. package/runtime/docker/packages/ctl/dist/bin.cjs +272 -160
  23. package/runtime/docker/packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup +52 -0
  24. package/runtime/docker/packages/sandbox-docker/scripts/agentbox-dockerd-start +87 -7
  25. package/runtime/docker/packages/sandbox-docker/scripts/agentbox-open +28 -0
  26. package/runtime/docker/packages/sandbox-docker/scripts/custom-system-CLAUDE.md +21 -15
  27. package/runtime/relay/bin.cjs +407 -12
  28. package/share/agentbox-setup/SKILL.md +52 -55
  29. package/dist/chunk-FQD6ZWYW.js.map +0 -1
  30. package/dist/chunk-IDR4HVIC.js.map +0 -1
  31. package/dist/chunk-J35IH7W5.js.map +0 -1
  32. package/dist/chunk-NSIECUCS.js.map +0 -1
  33. package/dist/chunk-SOMIKEN2.js.map +0 -1
  34. package/dist/chunk-WR5FFGE5.js.map +0 -1
  35. package/dist/create-4BQY2UYU-CGSW3RGE.js +0 -15
  36. package/dist/stats-GZFLPYTU-DBJ2DVBJ.js +0 -19
  37. /package/dist/{create-4BQY2UYU-CGSW3RGE.js.map → create-6PWXI6HO-OWAMHBAK.js.map} +0 -0
  38. /package/dist/{lifecycle-LURNDNYO-UWQYPNPX.js.map → lifecycle-EMXR46DI-DUVBXNTV.js.map} +0 -0
  39. /package/dist/{state-ZSP3ORXW-WI6KOIG3.js.map → state-KD7M46ZP-KHFTHFUS.js.map} +0 -0
  40. /package/dist/{stats-GZFLPYTU-DBJ2DVBJ.js.map → stats-SZXOJE3D-N7OODCHW.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- createBox
4
- } from "./chunk-WR5FFGE5.js";
3
+ createBox,
4
+ hostBackupHasCredentials,
5
+ syncClaudeCredentials
6
+ } from "./chunk-KJNZP6I3.js";
5
7
  import {
6
8
  AmbiguousBoxError,
7
9
  BoxNotFoundError,
8
10
  destroyBox,
9
- getBoxEndpoints,
10
11
  getBoxHostPaths,
11
12
  inspectBox,
12
13
  listBoxes,
@@ -16,19 +17,24 @@ import {
16
17
  startBox,
17
18
  stopBox,
18
19
  unpauseBox
19
- } from "./chunk-FQD6ZWYW.js";
20
+ } from "./chunk-M7I247BK.js";
20
21
  import {
21
22
  ClaudeSessionError,
23
+ DEFAULT_RELAY_PORT,
22
24
  SHARED_CLAUDE_VOLUME,
23
- attachClaudeSession,
25
+ buildClaudeAttachArgv,
24
26
  buildClaudeDashboardAttachArgv,
27
+ buildClaudeLoginRunArgv,
25
28
  buildShellArgv,
26
29
  buildVncUrls,
27
30
  claudeSessionInfo,
31
+ clearRelayNotice,
28
32
  containerHex,
29
33
  ensureAgentboxTasksFile,
30
34
  ensureClaudeVolume,
31
35
  ensureRelay,
36
+ formatDetachNotice,
37
+ getRelayStatus,
32
38
  ideProfile,
33
39
  pullClaudeExtras,
34
40
  rebuildPluginNativeDeps,
@@ -36,20 +42,24 @@ import {
36
42
  renderStatusTable,
37
43
  renderTaskTable,
38
44
  resolveClaudeVolume,
45
+ runInteractiveClaudeLogin,
46
+ seedSetupSkillIntoVolume,
47
+ setRelayNotice,
39
48
  startClaudeSession,
40
- stopRelay
41
- } from "./chunk-NSIECUCS.js";
49
+ stopRelay,
50
+ warmUpClaudeCredentials
51
+ } from "./chunk-HTTKML3C.js";
42
52
  import {
43
53
  STATE_DIR,
44
54
  readState,
45
55
  resolveBoxRef
46
- } from "./chunk-IDR4HVIC.js";
56
+ } from "./chunk-HPZMD5DE.js";
47
57
  import {
48
58
  agentboxHomeBytes,
49
- allCheckpointVolumesBytes,
59
+ allCheckpointImagesBytes,
50
60
  boxResourceStats,
51
- projectCheckpointVolumeBytes
52
- } from "./chunk-J35IH7W5.js";
61
+ projectCheckpointImageBytes
62
+ } from "./chunk-BBZMA2K6.js";
53
63
  import {
54
64
  DEFAULT_BOX_IMAGE,
55
65
  DEFAULT_ENV_PATTERNS,
@@ -59,6 +69,7 @@ import {
59
69
  configPathFor,
60
70
  createCheckpoint,
61
71
  detectEngine,
72
+ ensureImage,
62
73
  execInBox,
63
74
  findProjectRoot,
64
75
  listCheckpoints,
@@ -71,19 +82,20 @@ import {
71
82
  refreshExport,
72
83
  removeCheckpoint,
73
84
  removeImage,
85
+ scanHostEnvFiles,
74
86
  setConfigValue,
75
87
  setEngineOverride,
76
88
  unsetConfigValue
77
- } from "./chunk-SOMIKEN2.js";
89
+ } from "./chunk-HHMWQNLF.js";
78
90
 
79
91
  // src/index.ts
80
- import { Command as Command27 } from "commander";
92
+ import { Command as Command29 } from "commander";
81
93
 
82
94
  // ../../packages/sandbox-docker/dist/index.js
83
95
  function browserSessionActive(stdout, exitCode) {
84
96
  return exitCode === 0 && !/no active sessions/i.test(stdout);
85
97
  }
86
- async function ensureBoxBrowser(container, timeoutMs = 8e3) {
98
+ async function ensureBoxBrowser(container, timeoutMs = 8e3, targetUrl = "about:blank") {
87
99
  const list = await execInBox(container, ["agent-browser", "session", "list"], {
88
100
  user: "vscode",
89
101
  timeoutMs
@@ -91,7 +103,7 @@ async function ensureBoxBrowser(container, timeoutMs = 8e3) {
91
103
  if (browserSessionActive(list.stdout, list.exitCode)) {
92
104
  return { up: true, alreadyRunning: true };
93
105
  }
94
- const open = await execInBox(container, ["agent-browser", "open", "--headed", "about:blank"], {
106
+ const open = await execInBox(container, ["agent-browser", "open", "--headed", targetUrl], {
95
107
  user: "vscode",
96
108
  timeoutMs
97
109
  });
@@ -122,10 +134,10 @@ var HELP_GROUPS = [
122
134
  },
123
135
  { title: "Inspect", commands: ["list", "status", "top"] },
124
136
  { title: "Lifecycle", commands: ["start", "stop", "destroy", "pause", "unpause"] },
125
- { title: "Sync & state", commands: ["pull", "checkpoint"] },
137
+ { title: "Sync & state", commands: ["download", "cp", "checkpoint"] },
126
138
  {
127
139
  title: "Advanced",
128
- commands: ["wait", "prune", "self-update", "config"]
140
+ commands: ["wait", "prune", "self-update", "config", "relay"]
129
141
  }
130
142
  ];
131
143
  function term(cmd) {
@@ -258,7 +270,7 @@ var browserCommand = new Command("browser").description(
258
270
  log3.info("box is paused; unpausing");
259
271
  await unpauseBox(box.id);
260
272
  } else if (insp.state === "stopped") {
261
- log3.info("box is stopped; starting (remounting overlay)");
273
+ log3.info("box is stopped; starting");
262
274
  await startBox(box.id);
263
275
  } else if (insp.state === "missing") {
264
276
  throw new Error(`box ${box.name} has no container; was it destroyed?`);
@@ -298,13 +310,12 @@ var browserCommand = new Command("browser").description(
298
310
  });
299
311
 
300
312
  // src/commands/claude.ts
301
- import { confirm as confirm2, intro, isCancel as isCancel2, log as log5, outro, password, spinner } from "@clack/prompts";
313
+ import { confirm as confirm2, intro, isCancel as isCancel2, log as log5, outro, spinner } from "@clack/prompts";
302
314
  import { Command as Command2 } from "commander";
303
315
 
304
316
  // src/auth.ts
305
- import { spawnSync as spawnSync2 } from "child_process";
306
- import { mkdir, readFile, writeFile } from "fs/promises";
307
- import { dirname, join } from "path";
317
+ import { readFile } from "fs/promises";
318
+ import { join } from "path";
308
319
  var AUTH_FILE = join(STATE_DIR, "auth.json");
309
320
  async function resolveClaudeAuth(processEnv, opts = {}) {
310
321
  const env = {};
@@ -322,9 +333,9 @@ async function resolveClaudeAuth(processEnv, opts = {}) {
322
333
  }
323
334
  return { env: {}, source: "none" };
324
335
  }
325
- async function readAuthFile(path = AUTH_FILE) {
336
+ async function readAuthFile(path2 = AUTH_FILE) {
326
337
  try {
327
- const raw = await readFile(path, "utf8");
338
+ const raw = await readFile(path2, "utf8");
328
339
  const parsed = JSON.parse(raw);
329
340
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
330
341
  const t = parsed.claudeCodeOauthToken;
@@ -336,22 +347,6 @@ async function readAuthFile(path = AUTH_FILE) {
336
347
  return {};
337
348
  }
338
349
  }
339
- async function writeAuthFile(next, path = AUTH_FILE) {
340
- await mkdir(dirname(path), { recursive: true });
341
- await writeFile(path, JSON.stringify(next, null, 2) + "\n", { mode: 384, flag: "w" });
342
- }
343
- function hostClaudeAvailable() {
344
- const r = spawnSync2("which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] });
345
- return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
346
- }
347
- function runHostSetupToken() {
348
- const child = spawnSync2("claude", ["setup-token"], { stdio: "inherit" });
349
- return { exitCode: child.status ?? -1 };
350
- }
351
- function isPlausibleOauthToken(s) {
352
- const t = s.trim();
353
- return t.startsWith("sk-ant-oat") && t.length >= 40;
354
- }
355
350
 
356
351
  // ../../packages/core/dist/index.js
357
352
  var claudeCodeLauncher = {
@@ -429,51 +424,27 @@ function resolveLimits(box, flags) {
429
424
  }
430
425
 
431
426
  // src/wizard.ts
432
- import { confirm, isCancel, log as log4 } from "@clack/prompts";
433
- import { copyFile, mkdir as mkdir2, stat } from "fs/promises";
434
- import { homedir } from "os";
435
- import { basename, join as join2 } from "path";
436
- import { fileURLToPath } from "url";
427
+ import { confirm, isCancel, log as log4, multiselect } from "@clack/prompts";
428
+ import { basename } from "path";
437
429
  var IN_BOX_SETUP_GUIDE_PATH = "/usr/local/share/agentbox/setup-guide.md";
438
- var HOST_SKILLS_DIR = join2(homedir(), ".claude", "skills", "agentbox-setup");
439
- var HOST_SKILL_FILE = join2(HOST_SKILLS_DIR, "SKILL.md");
440
- function bundledSkillPath() {
441
- return fileURLToPath(new URL("../share/agentbox-setup/SKILL.md", import.meta.url));
442
- }
443
- async function fileExists(p) {
444
- try {
445
- const st = await stat(p);
446
- return st.isFile();
447
- } catch {
448
- return false;
449
- }
450
- }
451
- async function installAgentboxSetupSkill(opts = {}) {
452
- const targetFile = opts.targetFile ?? HOST_SKILL_FILE;
453
- const targetDir = join2(targetFile, "..");
454
- if (await fileExists(targetFile)) return { installed: false, targetFile };
455
- const src = opts.sourceFile ?? bundledSkillPath();
456
- if (!await fileExists(src)) {
457
- return { installed: false, targetFile };
458
- }
459
- await mkdir2(targetDir, { recursive: true, mode: 448 });
460
- await copyFile(src, targetFile);
461
- return { installed: true, targetFile };
462
- }
463
430
  function buildSetupInitialPrompt(workspace) {
464
431
  const name = basename(workspace);
465
- return `The user just opened a new agentbox sandbox for "${name}" but the workspace has no agentbox.yaml yet. Please run the /agentbox-setup skill (or read ${IN_BOX_SETUP_GUIDE_PATH} if the skill is not loaded), then explore /workspace and propose an agentbox.yaml. Save the file to /workspace/agentbox.yaml. Then run \`agentbox-ctl reload\` from inside the box so the already-running supervisor applies the new config and immediately runs the declared tasks and autostarts the services (no box restart needed). When done, summarise what services and tasks you declared, and remind the user how to land the file on the host (commit through the bind-mounted .git, or "agentbox pull env" on the host).`;
432
+ return `The user just opened a new agentbox sandbox for "${name}" but the workspace has no agentbox.yaml yet. Please run the /agentbox-setup skill (or read ${IN_BOX_SETUP_GUIDE_PATH} if the skill is not loaded), then explore /workspace and propose an agentbox.yaml. Save the file to /workspace/agentbox.yaml. Then run \`agentbox-ctl reload\` from inside the box so the already-running supervisor applies the new config and immediately runs the declared tasks and autostarts the services (no box restart needed). When done, summarise what services and tasks you declared, and remind the user how to land the file on the host (commit through the bind-mounted .git, or "agentbox download env" on the host).`;
466
433
  }
467
434
  var WIZARD_AUTOLAUNCH_ENV = "AGENTBOX_WIZARD_AUTOLAUNCH";
435
+ var WIZARD_ENV_FILES_ENV = "AGENTBOX_WIZARD_ENV_FILES";
436
+ var WIZARD_ENV_SCAN_PATTERNS = DEFAULT_ENV_PATTERNS.filter((p) => p !== "agentbox.yaml");
468
437
  async function maybeRunSetupWizard(args) {
469
438
  if (process.env[WIZARD_AUTOLAUNCH_ENV] === "1") {
470
439
  if (args.command !== "claude") return { action: "proceed" };
471
- if (args.checkpointRef) return { action: "proceed" };
440
+ const envFiles = parseEnvFilesFromEnv(process.env[WIZARD_ENV_FILES_ENV]);
441
+ if (args.checkpointRef) return { action: "proceed", envFilesToImport: envFiles };
472
442
  const proj2 = await findProjectRoot(args.workspace);
473
- if (proj2.hasAgentboxYaml) return { action: "proceed" };
443
+ if (proj2.hasAgentboxYaml) return { action: "proceed", envFilesToImport: envFiles };
474
444
  return {
475
445
  action: "launch-with-prompt",
476
- initialPrompt: buildSetupInitialPrompt(proj2.root)
446
+ initialPrompt: buildSetupInitialPrompt(proj2.root),
447
+ envFilesToImport: envFiles
477
448
  };
478
449
  }
479
450
  if (args.yes) return { action: "proceed" };
@@ -484,25 +455,42 @@ async function maybeRunSetupWizard(args) {
484
455
  log4.info(`starting from checkpoint "${args.checkpointRef}"; skipping agentbox.yaml setup`);
485
456
  return { action: "proceed" };
486
457
  }
458
+ let envFilesToImport;
459
+ if (!args.withEnv) {
460
+ const found = await scanHostEnvFiles(proj.root, WIZARD_ENV_SCAN_PATTERNS);
461
+ if (found.length > 0) {
462
+ const picked = await multiselect({
463
+ message: "Import host env/secret files into the box? (space to toggle, enter to confirm)",
464
+ options: found.map((p) => ({ value: p, label: p })),
465
+ initialValues: found,
466
+ required: false
467
+ });
468
+ if (!isCancel(picked) && Array.isArray(picked) && picked.length > 0) {
469
+ envFilesToImport = picked;
470
+ }
471
+ }
472
+ }
487
473
  const go = await confirm({
488
- message: "Set up a new Agentbox environment?",
474
+ message: "New project detected, run setup wizard?",
489
475
  initialValue: true
490
476
  });
491
- if (isCancel(go) || !go) return { action: "proceed" };
492
- try {
493
- const r = await installAgentboxSetupSkill();
494
- if (r.installed) {
495
- log4.success(`installed /agentbox-setup skill at ${r.targetFile}`);
496
- }
497
- } catch (err) {
498
- log4.warn(`could not install /agentbox-setup skill: ${err.message}`);
499
- }
500
- if (args.command === "create") return { action: "switch-to-claude" };
477
+ if (isCancel(go) || !go) return { action: "proceed", envFilesToImport };
478
+ if (args.command === "create") return { action: "switch-to-claude", envFilesToImport };
501
479
  return {
502
480
  action: "launch-with-prompt",
503
- initialPrompt: buildSetupInitialPrompt(proj.root)
481
+ initialPrompt: buildSetupInitialPrompt(proj.root),
482
+ envFilesToImport
504
483
  };
505
484
  }
485
+ function serializeEnvFilesForEnv(files) {
486
+ if (!files || files.length === 0) return void 0;
487
+ return files.join("\0");
488
+ }
489
+ function parseEnvFilesFromEnv(raw) {
490
+ if (!raw) return void 0;
491
+ const out = raw.split("\0").filter((p) => p.length > 0);
492
+ return out.length > 0 ? out : void 0;
493
+ }
506
494
  function passthroughFlags(opts) {
507
495
  const out = [];
508
496
  if (opts.workspace) out.push("--workspace", opts.workspace);
@@ -517,237 +505,1035 @@ function passthroughFlags(opts) {
517
505
  return out;
518
506
  }
519
507
 
520
- // src/commands/claude.ts
521
- function reattachRef(r) {
522
- return typeof r.projectIndex === "number" ? String(r.projectIndex) : r.name;
523
- }
524
- function buildClaudeCliOverrides(opts) {
525
- const box = {};
526
- if (opts.hostSnapshot !== void 0) box.hostSnapshot = opts.hostSnapshot;
527
- if (opts.image !== void 0) box.image = opts.image;
528
- if (opts.withPlaywright === true) box.withPlaywright = true;
529
- if (opts.withEnv === true) box.withEnv = true;
530
- if (opts.vnc === false) box.vnc = false;
531
- if (opts.isolateClaudeConfig === true) box.isolateClaudeConfig = true;
532
- if (opts.sharedDockerCache === true) box.dockerCacheShared = true;
533
- const claude = {};
534
- if (opts.sessionName !== void 0) claude.sessionName = opts.sessionName;
535
- const out = {};
536
- if (Object.keys(box).length > 0) out.box = box;
537
- if (Object.keys(claude).length > 0) out.claude = claude;
538
- return out;
539
- }
540
- async function offerSetupToken() {
541
- log5.info("first time setup: setup token for Claude Code");
542
- const canRun = hostClaudeAvailable();
543
- if (canRun) {
544
- const yes = await confirm2({
545
- message: "Run `claude setup-token` now to save a token?",
546
- initialValue: true
547
- });
548
- if (isCancel2(yes) || !yes) {
549
- log5.info("ok, continuing without a saved token; /login inside the box once and it persists in the shared volume.");
508
+ // src/wrapped-pty/run.ts
509
+ import { spawnSync as spawnSync2 } from "child_process";
510
+
511
+ // src/pty/pty-backend.ts
512
+ async function loadPtyBackend() {
513
+ try {
514
+ const ptyMod = await import("@homebridge/node-pty-prebuilt-multiarch");
515
+ const xtermMod = await import("@xterm/headless");
516
+ const spawn5 = ptyMod["spawn"] ?? ptyMod["default"]?.["spawn"];
517
+ const Terminal = xtermMod["Terminal"] ?? xtermMod["default"]?.["Terminal"];
518
+ if (typeof spawn5 !== "function" || typeof Terminal !== "function") {
550
519
  return null;
551
520
  }
552
- const { exitCode } = runHostSetupToken();
553
- if (exitCode !== 0) {
554
- log5.warn(`\`claude setup-token\` exited with code ${String(exitCode)}; you can still paste a token below if you have one.`);
555
- }
556
- } else {
557
- log5.warn(
558
- "Claude Code is not installed on the host, so I cannot run `claude setup-token` for you. Run it on a machine that has Claude Code installed, then paste the token below \u2014 or skip and /login inside the box."
559
- );
560
- }
561
- const pasted = await password({ message: "Paste OAuth token (or empty to skip):" });
562
- if (isCancel2(pasted) || !pasted) {
563
- log5.info("ok, continuing without a saved token; /login inside the box once and it persists in the shared volume.");
521
+ return {
522
+ ptySpawn: spawn5,
523
+ termCtor: Terminal
524
+ };
525
+ } catch {
564
526
  return null;
565
527
  }
566
- const token = pasted.trim();
567
- if (!isPlausibleOauthToken(token)) {
568
- log5.warn("That doesn't look like an OAuth token (expected `sk-ant-oat\u2026`); saving anyway \u2014 verify inside the box.");
569
- }
570
- await writeAuthFile({ claudeCodeOauthToken: token });
571
- log5.success(`saved to ${AUTH_FILE} (mode 0600)`);
572
- return { env: { CLAUDE_CODE_OAUTH_TOKEN: token }, source: "auth-file" };
573
528
  }
574
- var claudeCommand = new Command2("claude").description("Create a sandboxed box and launch Claude Code in a detachable tmux session").option("-w, --workspace <path>", "host workspace to mount", process.cwd()).option("-n, --name <name>", "friendly box name (default: <workspace-basename>-<id>)").option("--host-snapshot", "use a frozen APFS clone of the host workspace as the overlay lower").option("--no-host-snapshot", "bind the live workspace directly (host edits leak into reads)").option(
575
- "--snapshot <ref>",
576
- "start from a project checkpoint (see `agentbox checkpoint`); overrides box.defaultCheckpoint"
577
- ).option("--image <ref>", "override the box image").option("-y, --yes", "skip prompts, accept defaults (host-snapshot=on)").option(
578
- "--isolate-claude-config",
579
- "use a per-box ~/.claude volume instead of the shared agentbox-claude-config"
580
- ).option("--with-playwright", "also install @playwright/cli@latest globally inside the box").option(
581
- "--with-env",
582
- "copy host env/config files (.env*, secrets.toml, agentbox.yaml, ...) into /workspace at create time (gitignore-bypassing)"
583
- ).option("--no-vnc", "disable the per-box Xvnc + noVNC web client (on by default)").option(
584
- "--shared-docker-cache",
585
- "use the shared 'agentbox-docker-cache' volume for in-box docker images (preserved on destroy; only one box can run at a time when set)"
586
- ).option("--session-name <name>", "tmux session name (default from config; built-in: claude)").option("--memory <size>", "memory ceiling (e.g. 512m, 2g); unset = unlimited").option("--cpus <n>", "CPU count cap (fractional ok, e.g. 1.5); unset = unlimited").option("--pids-limit <n>", "max process count (PIDs cgroup); unset = unlimited").option("--disk <size>", "best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS").argument(
587
- "[claude-args...]",
588
- "extra args passed to claude inside the box; place after `--`, e.g. `agentbox claude -- --model sonnet`"
589
- ).action(async (claudeArgs, opts) => {
590
- intro("agentbox claude");
591
- const cfg = await loadEffectiveConfig(opts.workspace, {
592
- cliOverrides: buildClaudeCliOverrides(opts)
593
- });
594
- const projectRoot = (await findProjectRoot(opts.workspace)).root;
595
- const checkpointRef = opts.snapshot && opts.snapshot.length > 0 ? opts.snapshot : cfg.effective.box.defaultCheckpoint.length > 0 ? cfg.effective.box.defaultCheckpoint : void 0;
596
- const wiz = await maybeRunSetupWizard({
597
- workspace: opts.workspace,
598
- yes: !!opts.yes,
599
- command: "claude",
600
- checkpointRef
601
- });
602
- let effectiveClaudeArgs = claudeArgs;
603
- if (wiz.action === "launch-with-prompt" && wiz.initialPrompt) {
604
- effectiveClaudeArgs = resolveAgentLauncher("claude-code").buildArgs(
605
- wiz.initialPrompt,
606
- claudeArgs
607
- );
608
- }
609
- const useSnapshot = opts.hostSnapshot === false ? false : opts.hostSnapshot === true ? true : cfg.effective.box.hostSnapshot ?? true;
610
- const sessionName = cfg.effective.claude.sessionName;
611
- let resolved = await resolveClaudeAuth(process.env);
612
- if (resolved.source === "none" && process.stdin.isTTY && !opts.yes) {
613
- const next = await offerSetupToken();
614
- if (next) resolved = next;
615
- }
616
- const s = spinner();
617
- s.start("creating box");
618
- let containerName = "";
619
- try {
620
- const withPlaywright = cfg.effective.box.withPlaywright || cfg.effective.browser.default !== "agent-browser";
621
- const result = await createBox({
622
- workspacePath: opts.workspace,
623
- name: opts.name,
624
- useSnapshot,
625
- checkpointRef,
626
- image: cfg.effective.box.image,
627
- claudeConfig: { isolate: cfg.effective.box.isolateClaudeConfig },
628
- claudeEnv: resolved.env,
629
- withPlaywright,
630
- withEnv: cfg.effective.box.withEnv,
631
- vnc: { enabled: cfg.effective.box.vnc },
632
- docker: { sharedCache: cfg.effective.box.dockerCacheShared },
633
- limits: resolveLimits(cfg.effective.box, opts),
634
- projectRoot,
635
- onLog: (line) => s.message(clampSpinnerLine(line))
636
- });
637
- containerName = result.record.container;
638
- s.message("checking plugin native deps");
639
- const rebuild = await rebuildPluginNativeDeps(result.record.container, {
640
- volume: result.record.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME,
641
- onProgress: (line) => s.message(clampSpinnerLine(line))
642
- });
643
- s.message("starting claude session");
644
- await startClaudeSession({
645
- container: result.record.container,
646
- claudeArgs: effectiveClaudeArgs,
647
- sessionName,
648
- boxName: result.record.name
649
- });
650
- const nSuffix = typeof result.record.projectIndex === "number" ? ` \xB7 n ${String(result.record.projectIndex)}` : "";
651
- s.stop(`box ${result.record.container} ready${nSuffix}`);
652
- for (const f of rebuild.failed) {
653
- log5.warn(`plugin install failed for ${f.dir}; claude may still load it. stderr:
654
- ${f.stderr.trim()}`);
529
+
530
+ // src/wrapped-pty/input-router.ts
531
+ var KEY_ENTER = 13;
532
+ var KEY_LF = 10;
533
+ var KEY_ESC = 27;
534
+ var KEY_CTRL_C = 3;
535
+ var KEY_Y_LOW = 121;
536
+ var KEY_Y_UP = 89;
537
+ var KEY_N_LOW = 110;
538
+ var KEY_N_UP = 78;
539
+ function createInputRouter(opts) {
540
+ let active = null;
541
+ let disposed = false;
542
+ const settle = (answer, cancelled) => {
543
+ if (!active) return;
544
+ const body = {
545
+ id: active.ev.id,
546
+ answer,
547
+ ...cancelled ? { cancelled: true } : {}
548
+ };
549
+ const p = active;
550
+ active = null;
551
+ p.resolve(body);
552
+ opts.onAnswer(body);
553
+ };
554
+ const handleCapturedByte = (b) => {
555
+ if (!active) return;
556
+ if (b === KEY_Y_LOW || b === KEY_Y_UP) {
557
+ settle("y");
558
+ return;
655
559
  }
656
- outro("attaching \u2014 Control+a q to detach, leaves claude running");
657
- attachClaudeSession(result.record.container, sessionName, reattachRef(result.record));
658
- } catch (err) {
659
- s.stop("failed");
660
- if (err instanceof ClaudeSessionError) {
661
- log5.error(err.message);
662
- if (containerName) {
663
- log5.info(`The box ${containerName} is still running. Destroy it with:`);
664
- log5.info(` agentbox destroy ${containerName} -y`);
665
- }
666
- process.exit(1);
560
+ if (b === KEY_N_LOW || b === KEY_N_UP) {
561
+ settle("n");
562
+ return;
667
563
  }
668
- handleLifecycleError(err);
669
- }
670
- });
671
- async function startOrAttachClaude(box, claudeArgs, opts) {
672
- const cfg = await loadEffectiveConfig(box.workspacePath, {
673
- cliOverrides: opts.sessionName ? { claude: { sessionName: opts.sessionName } } : {}
674
- });
675
- const sessionName = cfg.effective.claude.sessionName;
676
- const insp = await inspectBox(box.id);
677
- if (insp.state === "missing") {
678
- throw new Error(`box ${box.name} has no container; was it destroyed?`);
679
- }
680
- const existing = await claudeSessionInfo(box.container, sessionName);
681
- if (existing.running) {
682
- outro(`session "${sessionName}" already running \u2014 attaching (Control+a q to detach)`);
683
- attachClaudeSession(box.container, sessionName, reattachRef(box));
684
- return;
685
- }
686
- const s = spinner();
687
- s.start("preparing box");
688
- if (insp.state === "paused") {
689
- s.message("unpausing box");
690
- await unpauseBox(box.id);
691
- } else if (insp.state === "stopped") {
692
- s.message("starting box (remounting overlay)");
693
- await startBox(box.id);
694
- }
695
- const syncConfig = opts.syncConfig !== false;
696
- if (syncConfig) {
697
- s.message("syncing ~/.claude into box volume");
698
- const volume = box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME;
699
- await ensureClaudeVolume(
700
- { volume },
701
- {
702
- syncFromHost: true,
703
- image: box.image,
704
- hostWorkspace: box.workspacePath
564
+ if (b === KEY_ESC || b === KEY_CTRL_C) {
565
+ settle("n", true);
566
+ return;
567
+ }
568
+ if (b === KEY_ENTER || b === KEY_LF) {
569
+ const def = active.ev.defaultAnswer ?? "n";
570
+ settle(def);
571
+ return;
572
+ }
573
+ };
574
+ return {
575
+ get capturing() {
576
+ return active !== null;
577
+ },
578
+ feed(buf) {
579
+ if (disposed) return;
580
+ if (active) {
581
+ if (buf.length > 1 && buf[0] === KEY_ESC) return;
582
+ for (let i = 0; i < buf.length; i++) {
583
+ const byte = buf[i];
584
+ if (byte === void 0) continue;
585
+ if (active) {
586
+ handleCapturedByte(byte);
587
+ } else {
588
+ opts.onForward(buf.subarray(i));
589
+ return;
590
+ }
591
+ }
592
+ return;
593
+ }
594
+ opts.onForward(buf);
595
+ },
596
+ capture(ev) {
597
+ return new Promise((resolve2, reject) => {
598
+ if (active) {
599
+ settle("n", true);
600
+ }
601
+ active = { ev, resolve: resolve2, reject };
602
+ });
603
+ },
604
+ abort(reason) {
605
+ if (!active) return;
606
+ const p = active;
607
+ active = null;
608
+ const msg = reason === "pty-exit" ? "pty exited" : "resolved by sibling wrapper";
609
+ p.reject(new Error(msg));
610
+ },
611
+ dispose() {
612
+ if (disposed) return;
613
+ disposed = true;
614
+ if (active) {
615
+ const p = active;
616
+ active = null;
617
+ p.reject(new Error("input router disposed"));
705
618
  }
706
- );
707
- }
708
- s.message("checking plugin native deps");
709
- const rebuild = await rebuildPluginNativeDeps(box.container, {
710
- volume: box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME,
711
- onProgress: (line) => s.message(clampSpinnerLine(line))
712
- });
713
- s.message("starting claude session");
714
- await startClaudeSession({
715
- container: box.container,
716
- claudeArgs,
717
- sessionName,
718
- boxName: box.name
719
- });
720
- s.stop(`box ${box.container} ready`);
721
- for (const f of rebuild.failed) {
722
- log5.warn(`plugin install failed for ${f.dir}; claude may still load it. stderr:
723
- ${f.stderr.trim()}`);
724
- }
725
- outro("attaching \u2014 Control+a q to detach, leaves claude running");
726
- attachClaudeSession(box.container, sessionName, reattachRef(box));
727
- }
728
- var claudeAttachCommand = new Command2("attach").description(
729
- "Attach to a Claude Code tmux session in a box, starting one if none is running (auto-unpause/start)"
730
- ).argument(
731
- "[box]",
732
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
733
- ).option("--session-name <name>", "tmux session name (default from config; built-in: claude)").option(
734
- "--no-sync-config",
735
- "when starting a fresh session, skip rsyncing the host's ~/.claude into the box's volume (faster)"
736
- ).action(async function(idOrName) {
737
- const opts = this.optsWithGlobals();
738
- intro("agentbox claude attach");
739
- try {
740
- const box = await resolveBoxOrExit(idOrName);
741
- await startOrAttachClaude(box, [], opts);
742
- } catch (err) {
743
- if (err instanceof ClaudeSessionError) {
744
- log5.error(err.message);
745
- process.exit(1);
746
619
  }
747
- handleLifecycleError(err);
620
+ };
621
+ }
622
+
623
+ // src/dashboard/sidebar.ts
624
+ function ellipsize(s, max) {
625
+ if (max <= 0) return "";
626
+ if (s.length <= max) return s;
627
+ if (max === 1) return "\u2026";
628
+ return s.slice(0, max - 1) + "\u2026";
629
+ }
630
+ function ellipsizeHead(s, max) {
631
+ if (max <= 0) return "";
632
+ if (s.length <= max) return s;
633
+ if (max === 1) return "\u2026";
634
+ return "\u2026" + s.slice(s.length - (max - 1));
635
+ }
636
+ function activityCell(b) {
637
+ if (b.pendingPrompt) return "\u25B2 prompt";
638
+ if (b.checkpointing) return "\u25C6 checkpoint";
639
+ if (b.state !== "running") return `[${b.state}]`;
640
+ switch (b.claudeActivity) {
641
+ case "working":
642
+ return "\u25CF working";
643
+ case "idle":
644
+ return "\u25CB idle";
645
+ case "waiting":
646
+ return "\u25D0 waiting";
647
+ default:
648
+ return "? unknown";
748
649
  }
749
- });
750
- var claudeStartCommand = new Command2("start").description(
650
+ }
651
+ var NEW_BOX_ID = "__agentbox_new__";
652
+ var NEW_BOX_LABEL = "+ New box";
653
+ var SIDEBAR_HEADER = "AgentBox";
654
+ function topBorder(label, w) {
655
+ const lead = `\u256D\u2500\u2500\u2500 ${label} `;
656
+ if (lead.length >= w) return lead.slice(0, w);
657
+ return lead + "\u2500".repeat(w - lead.length);
658
+ }
659
+ function fit(s, w) {
660
+ if (s.length === w) return s;
661
+ if (s.length > w) return s.slice(0, w);
662
+ return s + " ".repeat(w - s.length);
663
+ }
664
+ function center(s, w) {
665
+ if (s.length >= w) return s.slice(0, w);
666
+ const pad = w - s.length;
667
+ const leftPad = Math.floor(pad / 2);
668
+ return " ".repeat(leftPad) + s + " ".repeat(pad - leftPad);
669
+ }
670
+ function projectLabel(project) {
671
+ if (!project) return "(no project)";
672
+ const parts = project.split("/").filter(Boolean);
673
+ return parts[parts.length - 1] ?? project;
674
+ }
675
+ function stripTitleGlyph(s) {
676
+ const t = s.replace(/^[\s\p{S}*·]+/u, "");
677
+ return t.length > 0 ? t : s.trim();
678
+ }
679
+ function boxRow(b, marker, w) {
680
+ const numStr = b.index != null ? `${b.index} ` : "";
681
+ const status = activityCell(b);
682
+ const left = `${marker}${numStr}`;
683
+ const room = w - left.length - status.length - 1;
684
+ if (room <= 0) return fit(`${left}${status}`, w);
685
+ const middle = b.state === "running" && b.sessionTitle ? ellipsize(stripTitleGlyph(b.sessionTitle), room) : ellipsizeHead(b.name, room);
686
+ return fit(`${left}${middle}`, w - status.length) + status;
687
+ }
688
+ function sidebarLines(boxes, selectedId, w, h) {
689
+ const lines = [topBorder(SIDEBAR_HEADER, w), fit("", w)];
690
+ const rowOwner = [null, null];
691
+ const headerRows = [true, false];
692
+ const push = (line, owner, header) => {
693
+ lines.push(fit(line, w));
694
+ rowOwner.push(owner);
695
+ headerRows.push(header);
696
+ };
697
+ let prevProject;
698
+ let seenGroup = false;
699
+ for (const b of boxes) {
700
+ const marker = b.id === selectedId ? "\u25B8" : " ";
701
+ if (b.id === NEW_BOX_ID) {
702
+ push(`${marker}${NEW_BOX_LABEL}`, b.id, false);
703
+ continue;
704
+ }
705
+ if (!seenGroup || b.project !== prevProject) {
706
+ push(center(` \u2500\u2500 ${projectLabel(b.project)} \u2500\u2500 `, w), null, true);
707
+ prevProject = b.project;
708
+ seenGroup = true;
709
+ }
710
+ push(boxRow(b, marker, w), b.id, false);
711
+ }
712
+ if (boxes.length === 0) push(" (no boxes)", null, false);
713
+ while (lines.length < h) push("", null, false);
714
+ return {
715
+ lines: lines.slice(0, h),
716
+ rowOwner: rowOwner.slice(0, h),
717
+ headerRows: headerRows.slice(0, h)
718
+ };
719
+ }
720
+ function menuLines(boxName, w, h) {
721
+ const body = [
722
+ "",
723
+ ` No Claude session in ${boxName}.`,
724
+ "",
725
+ " [c] Start Claude here",
726
+ " [s] Open a shell",
727
+ "",
728
+ " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then v/c/w/q (vnc/code/web/quit)"
729
+ ];
730
+ const top = Math.max(0, Math.floor((h - body.length) / 2));
731
+ const out = [];
732
+ for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
733
+ return out;
734
+ }
735
+ function lifecycleMenuLines(boxName, state, confirmDestroy, w, h) {
736
+ const body = confirmDestroy ? [
737
+ "",
738
+ ` Destroy ${boxName}?`,
739
+ " This removes the container and its volumes.",
740
+ "",
741
+ " [y] Yes, destroy",
742
+ " [any other key] Cancel"
743
+ ] : [
744
+ "",
745
+ ` Box ${boxName} is ${state}.`,
746
+ "",
747
+ state === "paused" ? " [u] Unpause" : " [s] Start",
748
+ " [d] Destroy",
749
+ "",
750
+ " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then q quit"
751
+ ];
752
+ const top = Math.max(0, Math.floor((h - body.length) / 2));
753
+ const out = [];
754
+ for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
755
+ return out;
756
+ }
757
+ function createMenuLines(where, w, h) {
758
+ const body = [
759
+ "",
760
+ " Create a new box",
761
+ "",
762
+ " [c] Create + launch Claude",
763
+ " [n] Create only",
764
+ "",
765
+ ` in ${where}`,
766
+ "",
767
+ " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then q quit"
768
+ ];
769
+ const top = Math.max(0, Math.floor((h - body.length) / 2));
770
+ const out = [];
771
+ for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
772
+ return out;
773
+ }
774
+ var BAR_BG = "\x1B[48;2;48;48;48m";
775
+ var BAR_BASE = BAR_BG + "\x1B[38;5;250m";
776
+ var BAR_BRAND = "\x1B[48;5;39m\x1B[38;5;16m";
777
+ var BRAND_BOLD = "\x1B[1m";
778
+ var BRAND_NOBOLD = "\x1B[22m";
779
+ var HINT_KEY = "\x1B[38;5;255m";
780
+ var HINT_TXT = "\x1B[38;5;245m";
781
+ var BAR_RESET = "\x1B[0m";
782
+ var SWITCH_HINT = ["Control+Option+\u2191/\u2193", "switch"];
783
+ var HINT_GROUPS = [
784
+ SWITCH_HINT,
785
+ ["Control+a c", "code"],
786
+ ["Control+a v", "vnc"],
787
+ ["Control+a w", "web"],
788
+ ["Control+a q", "quit"]
789
+ ];
790
+ var COLLAPSED_HINT_GROUPS = [
791
+ SWITCH_HINT,
792
+ ["Control+a", "more"]
793
+ ];
794
+ var ADVANCED_HINT_GROUPS = [
795
+ ["c", "code"],
796
+ ["v", "vnc"],
797
+ ["w", "web"],
798
+ ["s", "stop"],
799
+ ["p", "pause"],
800
+ ["d", "destroy"],
801
+ ["q", "quit"]
802
+ ];
803
+ function statusLine(box, w, stateLabel, groups = HINT_GROUPS) {
804
+ const state = stateLabel ?? (box ? box.state === "running" ? box.claudeActivity ?? "unknown" : box.state : "");
805
+ const brandPrefix = box ? " agentbox \u25B8 " : " agentbox ";
806
+ const base = box ? `${box.name} (${state})` : "";
807
+ const coreMain = box ? `${base} ` : "";
808
+ const corePlain = brandPrefix + coreMain;
809
+ const SEP = " \u2502 ";
810
+ const renderHints = (g) => ({
811
+ plain: g.map(([k, l]) => `${k}: ${l}`).join(SEP) + " ",
812
+ styled: g.map(([k, l]) => `${HINT_KEY}${k}${HINT_TXT}: ${l}`).join(`${HINT_TXT}${SEP}`) + " "
813
+ });
814
+ let hints = null;
815
+ for (const g of [groups, COLLAPSED_HINT_GROUPS]) {
816
+ const h = renderHints(g);
817
+ if (corePlain.length + h.plain.length + 1 <= w) {
818
+ hints = h;
819
+ break;
820
+ }
821
+ }
822
+ if (!hints) {
823
+ return BAR_BASE + BAR_BRAND + fit(corePlain, w) + BAR_RESET;
824
+ }
825
+ const room = w - corePlain.length - hints.plain.length - 1;
826
+ let titleSeg = "";
827
+ if (box?.sessionTitle && room >= 7) {
828
+ titleSeg = ` \u2014 ${ellipsize(box.sessionTitle, Math.min(40, room - 3))}`;
829
+ }
830
+ const leftPlain = brandPrefix + base + titleSeg + (box ? " " : "");
831
+ const leftStyled = BAR_BRAND + brandPrefix + BRAND_BOLD + base + titleSeg + (box ? " " : "") + BRAND_NOBOLD;
832
+ const gap = w - leftPlain.length - hints.plain.length;
833
+ return BAR_BASE + leftStyled + BAR_BASE + " ".repeat(gap) + hints.styled + BAR_RESET;
834
+ }
835
+
836
+ // src/wrapped-pty/footer.ts
837
+ var SPINNER_FRAMES = ["\u25D0", "\u25D3", "\u25D1", "\u25D2"];
838
+ var URGENT = "\x1B[38;5;220m\x1B[1m";
839
+ var TXT = "\x1B[38;5;250m";
840
+ var SUBTLE = "\x1B[38;5;245m";
841
+ var RESET = "\x1B[0m";
842
+ var NOTICE_BG = "\x1B[48;5;220m";
843
+ var NOTICE_FG = "\x1B[38;5;16m\x1B[1m";
844
+ var CLAUDE_IDLE_HINTS = [
845
+ ["Control+a q", "detach"]
846
+ ];
847
+ var SHELL_IDLE_HINTS = [];
848
+ function padTo(visible, width) {
849
+ if (visible.length === width) return visible;
850
+ if (visible.length > width) {
851
+ if (width <= 1) return visible.slice(0, width);
852
+ return visible.slice(0, width - 1) + "\u2026";
853
+ }
854
+ return visible + " ".repeat(width - visible.length);
855
+ }
856
+ function renderFooter(state, cols) {
857
+ if (cols <= 0) return "";
858
+ if (state.kind === "idle") {
859
+ const sidebarBox = {
860
+ id: "",
861
+ // unused by statusLine
862
+ name: state.boxName,
863
+ state: "running",
864
+ // we're attached, so the container is up
865
+ claudeActivity: state.claudeActivity,
866
+ sessionTitle: state.sessionTitle
867
+ };
868
+ const hints = state.mode === "claude" ? CLAUDE_IDLE_HINTS : SHELL_IDLE_HINTS;
869
+ const stateLabel = state.mode === "shell" ? "shell" : void 0;
870
+ return statusLine(sidebarBox, cols, stateLabel, hints);
871
+ }
872
+ if (state.kind === "notice") {
873
+ const spinner5 = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
874
+ const prefix = ` ${spinner5} `;
875
+ const inner2 = Math.max(0, cols - prefix.length);
876
+ const message2 = padTo(state.message, inner2);
877
+ return `${NOTICE_BG}${NOTICE_FG}${prefix}${message2}${RESET}`;
878
+ }
879
+ const def = state.prompt.defaultAnswer ?? "n";
880
+ const yn = def === "y" ? "[Y/n]" : "[y/N]";
881
+ const tag2 = " [!] ";
882
+ const sep = " ";
883
+ const hintW = ` ${yn} `.length;
884
+ const inner = Math.max(0, cols - tag2.length - hintW);
885
+ const detailRaw = state.prompt.detail ?? "";
886
+ let message = state.prompt.message;
887
+ let detail = detailRaw;
888
+ const messageBudget = Math.max(8, inner - (detail.length > 0 ? sep.length + 8 : 0));
889
+ if (message.length > messageBudget) {
890
+ message = message.slice(0, Math.max(0, messageBudget - 1)) + "\u2026";
891
+ }
892
+ const usedByMessage = message.length;
893
+ const detailBudget = Math.max(0, inner - usedByMessage - sep.length);
894
+ if (detail.length > detailBudget) {
895
+ detail = detailBudget <= 1 ? "" : detail.slice(0, detailBudget - 1) + "\u2026";
896
+ }
897
+ const middlePlain = detail.length > 0 ? `${message}${sep}${detail}` : message;
898
+ const padded = padTo(middlePlain, inner);
899
+ return `${BAR_BG}${URGENT}${tag2}${TXT}${padded}${SUBTLE} ${yn} ${RESET}`;
900
+ }
901
+ function cursorMoveTo(row2, col) {
902
+ return `\x1B[${String(row2)};${String(col)}H`;
903
+ }
904
+ var CURSOR_SAVE = "\x1B7";
905
+ var CURSOR_RESTORE = "\x1B8";
906
+ var SYNC_BEGIN = "\x1B[?2026h";
907
+ var SYNC_END = "\x1B[?2026l";
908
+
909
+ // src/wrapped-pty/prompt-client.ts
910
+ import { request as httpRequest } from "http";
911
+ import { request as httpsRequest } from "https";
912
+ var INITIAL_BACKOFF_MS = 200;
913
+ var MAX_BACKOFF_MS = 5e3;
914
+ function subscribePrompts(opts) {
915
+ let closed = false;
916
+ let req = null;
917
+ let res = null;
918
+ let reconnectTimer = null;
919
+ let backoffMs = INITIAL_BACKOFF_MS;
920
+ let url;
921
+ try {
922
+ url = new URL(opts.relayBaseUrl);
923
+ } catch (err) {
924
+ if (opts.onError) opts.onError(err instanceof Error ? err : new Error(String(err)));
925
+ return { close: () => {
926
+ } };
927
+ }
928
+ const isHttps = url.protocol === "https:";
929
+ const transport = isHttps ? httpsRequest : httpRequest;
930
+ const port = url.port.length > 0 ? Number.parseInt(url.port, 10) : isHttps ? 443 : 80;
931
+ function scheduleReconnect() {
932
+ if (closed) return;
933
+ const delay = backoffMs;
934
+ backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs * 2);
935
+ reconnectTimer = setTimeout(() => {
936
+ reconnectTimer = null;
937
+ connect();
938
+ }, delay);
939
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
940
+ }
941
+ let buffer = "";
942
+ function consumeMessages() {
943
+ let idx = buffer.indexOf("\n\n");
944
+ while (idx !== -1) {
945
+ const raw = buffer.slice(0, idx);
946
+ buffer = buffer.slice(idx + 2);
947
+ idx = buffer.indexOf("\n\n");
948
+ if (raw.startsWith(":")) continue;
949
+ let event = "";
950
+ let dataLine = "";
951
+ for (const line of raw.split("\n")) {
952
+ if (line.startsWith("event:")) event = line.slice("event:".length).trim();
953
+ else if (line.startsWith("data:")) dataLine = line.slice("data:".length).trim();
954
+ }
955
+ if (event === "prompt-ask" && dataLine.length > 0) {
956
+ try {
957
+ const ev = JSON.parse(dataLine);
958
+ if (ev && typeof ev.id === "string") opts.onPrompt(ev);
959
+ } catch {
960
+ }
961
+ } else if (event === "prompt-resolved" && dataLine.length > 0) {
962
+ try {
963
+ const payload = JSON.parse(dataLine);
964
+ if (payload && typeof payload.id === "string") opts.onResolved(payload.id);
965
+ } catch {
966
+ }
967
+ } else if (event === "notice-set" && dataLine.length > 0) {
968
+ try {
969
+ const ev = JSON.parse(dataLine);
970
+ if (ev && typeof ev.id === "string") opts.onNotice?.(ev);
971
+ } catch {
972
+ }
973
+ } else if (event === "notice-clear" && dataLine.length > 0) {
974
+ try {
975
+ const payload = JSON.parse(dataLine);
976
+ if (payload && typeof payload.id === "string") opts.onNoticeCleared?.(payload.id);
977
+ } catch {
978
+ }
979
+ }
980
+ }
981
+ }
982
+ function connect() {
983
+ if (closed) return;
984
+ req = transport({
985
+ host: url.hostname,
986
+ port,
987
+ method: "GET",
988
+ path: `${url.pathname.replace(/\/$/, "")}/admin/prompts/stream?boxId=${encodeURIComponent(opts.boxId)}`,
989
+ headers: { Accept: "text/event-stream" }
990
+ });
991
+ req.on("response", (r) => {
992
+ res = r;
993
+ if (r.statusCode !== 200) {
994
+ if (opts.onError) opts.onError(new Error(`SSE stream returned ${String(r.statusCode)}`));
995
+ r.resume();
996
+ close();
997
+ return;
998
+ }
999
+ backoffMs = INITIAL_BACKOFF_MS;
1000
+ r.setEncoding("utf8");
1001
+ r.on("data", (chunk) => {
1002
+ buffer += chunk;
1003
+ consumeMessages();
1004
+ });
1005
+ r.on("end", () => {
1006
+ if (!closed) scheduleReconnect();
1007
+ });
1008
+ r.on("error", () => {
1009
+ if (!closed) scheduleReconnect();
1010
+ });
1011
+ });
1012
+ req.on("error", () => {
1013
+ if (!closed) scheduleReconnect();
1014
+ });
1015
+ req.end();
1016
+ }
1017
+ function close() {
1018
+ if (closed) return;
1019
+ closed = true;
1020
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1021
+ try {
1022
+ res?.destroy();
1023
+ } catch {
1024
+ }
1025
+ try {
1026
+ req?.destroy();
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ connect();
1031
+ return { close };
1032
+ }
1033
+ function postAnswer(opts) {
1034
+ return new Promise((resolve2) => {
1035
+ let url;
1036
+ try {
1037
+ url = new URL(opts.relayBaseUrl);
1038
+ } catch {
1039
+ resolve2({ ok: false, status: 0 });
1040
+ return;
1041
+ }
1042
+ const isHttps = url.protocol === "https:";
1043
+ const transport = isHttps ? httpsRequest : httpRequest;
1044
+ const port = url.port.length > 0 ? Number.parseInt(url.port, 10) : isHttps ? 443 : 80;
1045
+ const json = JSON.stringify(opts.body);
1046
+ const req = transport(
1047
+ {
1048
+ host: url.hostname,
1049
+ port,
1050
+ method: "POST",
1051
+ path: `${url.pathname.replace(/\/$/, "")}/admin/prompts/answer`,
1052
+ headers: {
1053
+ "Content-Type": "application/json",
1054
+ "Content-Length": Buffer.byteLength(json).toString()
1055
+ },
1056
+ timeout: 3e3
1057
+ },
1058
+ (res) => {
1059
+ res.resume();
1060
+ const status = res.statusCode ?? 0;
1061
+ resolve2({ ok: status === 204 || status === 404, status });
1062
+ }
1063
+ );
1064
+ req.on("error", () => resolve2({ ok: false, status: 0 }));
1065
+ req.on("timeout", () => {
1066
+ req.destroy();
1067
+ resolve2({ ok: false, status: 0 });
1068
+ });
1069
+ req.write(json);
1070
+ req.end();
1071
+ });
1072
+ }
1073
+
1074
+ // src/wrapped-pty/run.ts
1075
+ var FOOTER_ROWS = 1;
1076
+ var STATUS_POLL_INTERVAL_MS = 3e3;
1077
+ var SPINNER_INTERVAL_MS = 120;
1078
+ async function runWrappedAttach(opts) {
1079
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
1080
+ return runFallback(opts.dockerArgv);
1081
+ }
1082
+ const backend = await loadPtyBackend();
1083
+ if (!backend) {
1084
+ process.stderr.write(
1085
+ "agentbox: permission prompts disabled (node-pty backend unavailable)\n"
1086
+ );
1087
+ return runFallback(opts.dockerArgv);
1088
+ }
1089
+ const cols = process.stdout.columns ?? 80;
1090
+ const rows = process.stdout.rows ?? 24;
1091
+ const innerRows = Math.max(1, rows - FOOTER_ROWS);
1092
+ const pty = backend.ptySpawn("docker", opts.dockerArgv, {
1093
+ name: "xterm-256color",
1094
+ cols,
1095
+ rows: innerRows,
1096
+ env: process.env
1097
+ });
1098
+ const buildIdle = (sessionTitle, claudeActivity) => ({
1099
+ kind: "idle",
1100
+ boxName: opts.boxName,
1101
+ sessionTitle,
1102
+ claudeActivity,
1103
+ mode: opts.mode
1104
+ });
1105
+ let footerState = buildIdle();
1106
+ let lastSessionTitle;
1107
+ let lastActivity;
1108
+ let capturingPrompt = null;
1109
+ let activeNotice = null;
1110
+ let noticeFrame = 0;
1111
+ let spinnerTimer = null;
1112
+ const redrawFooter = () => {
1113
+ const cs = process.stdout.columns ?? cols;
1114
+ const rs = process.stdout.rows ?? rows;
1115
+ const line = renderFooter(footerState, cs);
1116
+ const payload = SYNC_BEGIN + CURSOR_SAVE + cursorMoveTo(rs, 1) + line + CURSOR_RESTORE + SYNC_END;
1117
+ process.stdout.write(payload);
1118
+ };
1119
+ const recomputeFooter = () => {
1120
+ if (capturingPrompt) {
1121
+ footerState = { kind: "prompt", prompt: capturingPrompt };
1122
+ } else if (activeNotice) {
1123
+ footerState = { kind: "notice", message: activeNotice.message, frame: noticeFrame };
1124
+ } else {
1125
+ footerState = buildIdle(lastSessionTitle, lastActivity);
1126
+ }
1127
+ };
1128
+ const startSpinner = () => {
1129
+ if (spinnerTimer) return;
1130
+ spinnerTimer = setInterval(() => {
1131
+ noticeFrame++;
1132
+ if (footerState.kind === "notice") {
1133
+ recomputeFooter();
1134
+ redrawFooter();
1135
+ }
1136
+ }, SPINNER_INTERVAL_MS);
1137
+ if (typeof spinnerTimer.unref === "function") spinnerTimer.unref();
1138
+ };
1139
+ const stopSpinner = () => {
1140
+ if (spinnerTimer) {
1141
+ clearInterval(spinnerTimer);
1142
+ spinnerTimer = null;
1143
+ }
1144
+ };
1145
+ pty.onData((d) => {
1146
+ process.stdout.write(d);
1147
+ redrawFooter();
1148
+ });
1149
+ const router = createInputRouter({
1150
+ onForward: (b) => {
1151
+ pty.write(b.toString("utf8"));
1152
+ },
1153
+ onAnswer: (body) => {
1154
+ void postAnswer({ relayBaseUrl: opts.relayBaseUrl, body });
1155
+ capturingPrompt = null;
1156
+ recomputeFooter();
1157
+ redrawFooter();
1158
+ }
1159
+ });
1160
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
1161
+ process.stdin.resume();
1162
+ const onStdinData = (chunk) => {
1163
+ router.feed(chunk);
1164
+ };
1165
+ process.stdin.on("data", onStdinData);
1166
+ const onResize = () => {
1167
+ const cs = process.stdout.columns ?? cols;
1168
+ const rs = process.stdout.rows ?? rows;
1169
+ const inner = Math.max(1, rs - FOOTER_ROWS);
1170
+ pty.resize(cs, inner);
1171
+ process.stdout.write(`\x1B[1;${String(inner)}r`);
1172
+ redrawFooter();
1173
+ };
1174
+ process.stdout.on("resize", onResize);
1175
+ const stream = subscribePrompts({
1176
+ relayBaseUrl: opts.relayBaseUrl,
1177
+ boxId: opts.boxId,
1178
+ onPrompt: (ev) => {
1179
+ capturingPrompt = ev;
1180
+ recomputeFooter();
1181
+ redrawFooter();
1182
+ router.capture(ev).catch(() => {
1183
+ });
1184
+ },
1185
+ onResolved: (id) => {
1186
+ if (capturingPrompt && capturingPrompt.id === id) {
1187
+ capturingPrompt = null;
1188
+ router.abort("resolved-elsewhere");
1189
+ recomputeFooter();
1190
+ redrawFooter();
1191
+ }
1192
+ },
1193
+ onNotice: (ev) => {
1194
+ activeNotice = ev;
1195
+ startSpinner();
1196
+ recomputeFooter();
1197
+ redrawFooter();
1198
+ },
1199
+ onNoticeCleared: (id) => {
1200
+ if (activeNotice && activeNotice.id === id) {
1201
+ activeNotice = null;
1202
+ stopSpinner();
1203
+ recomputeFooter();
1204
+ redrawFooter();
1205
+ }
1206
+ }
1207
+ });
1208
+ const pollStatus = async () => {
1209
+ try {
1210
+ const status = await readBoxStatus({
1211
+ id: opts.boxId,
1212
+ name: opts.boxName,
1213
+ projectIndex: opts.projectIndex
1214
+ });
1215
+ const nextTitle = status?.claude?.sessionTitle?.trim() || void 0;
1216
+ const nextActivity = status?.claude?.state || void 0;
1217
+ if (nextTitle === lastSessionTitle && nextActivity === lastActivity) return;
1218
+ lastSessionTitle = nextTitle;
1219
+ lastActivity = nextActivity;
1220
+ if (footerState.kind === "idle") {
1221
+ recomputeFooter();
1222
+ redrawFooter();
1223
+ }
1224
+ } catch {
1225
+ }
1226
+ };
1227
+ void pollStatus();
1228
+ const statusTimer = setInterval(() => {
1229
+ void pollStatus();
1230
+ }, STATUS_POLL_INTERVAL_MS);
1231
+ if (typeof statusTimer.unref === "function") statusTimer.unref();
1232
+ process.stdout.write(`\x1B[1;${String(innerRows)}r`);
1233
+ if (opts.mode === "shell") {
1234
+ process.stdout.write("\x1B[H\x1B[2J");
1235
+ }
1236
+ redrawFooter();
1237
+ const exitCode = await new Promise((resolve2) => {
1238
+ pty.onExit(({ exitCode: exitCode2 }) => resolve2(exitCode2));
1239
+ });
1240
+ process.stdin.off("data", onStdinData);
1241
+ process.stdout.off("resize", onResize);
1242
+ clearInterval(statusTimer);
1243
+ stopSpinner();
1244
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
1245
+ process.stdin.pause();
1246
+ stream.close();
1247
+ router.dispose();
1248
+ const rsFinal = process.stdout.rows ?? rows;
1249
+ const csFinal = process.stdout.columns ?? cols;
1250
+ process.stdout.write(
1251
+ "\x1B[r" + cursorMoveTo(rsFinal, 1) + `\x1B[2K` + cursorMoveTo(rsFinal, csFinal)
1252
+ );
1253
+ if (exitCode === 0 && opts.detachNotice) {
1254
+ process.stdout.write("\x1B[1A\x1B[2K\r" + opts.detachNotice + "\n");
1255
+ }
1256
+ return exitCode;
1257
+ }
1258
+ function runFallback(argv) {
1259
+ const child = spawnSync2("docker", argv, { stdio: "inherit" });
1260
+ return child.status ?? 0;
1261
+ }
1262
+
1263
+ // src/commands/claude.ts
1264
+ function reattachRef(r) {
1265
+ return typeof r.projectIndex === "number" ? String(r.projectIndex) : r.name;
1266
+ }
1267
+ function logPrune(rebuild) {
1268
+ if (rebuild.prunedBytes <= 0) return;
1269
+ const mb = Math.round(rebuild.prunedBytes / 1024 / 1024);
1270
+ const n = rebuild.pruned.length;
1271
+ log5.info(`pruned ${String(n)} stale plugin cache${n === 1 ? "" : "s"} (${String(mb)} MB freed)`);
1272
+ }
1273
+ var RELAY_HOST_URL = `http://127.0.0.1:${String(DEFAULT_RELAY_PORT)}`;
1274
+ async function attachClaudeWrapped(box, sessionName, reattach) {
1275
+ const code = await runWrappedAttach({
1276
+ container: box.container,
1277
+ dockerArgv: buildClaudeAttachArgv(box.container, sessionName),
1278
+ relayBaseUrl: RELAY_HOST_URL,
1279
+ boxId: box.id,
1280
+ boxName: box.name,
1281
+ projectIndex: box.projectIndex,
1282
+ mode: "claude",
1283
+ detachNotice: formatDetachNotice(reattach)
1284
+ });
1285
+ process.exit(code);
1286
+ }
1287
+ function buildClaudeCliOverrides(opts) {
1288
+ const box = {};
1289
+ if (opts.hostSnapshot !== void 0) box.hostSnapshot = opts.hostSnapshot;
1290
+ if (opts.image !== void 0) box.image = opts.image;
1291
+ if (opts.withPlaywright === true) box.withPlaywright = true;
1292
+ if (opts.withEnv === true) box.withEnv = true;
1293
+ if (opts.vnc === false) box.vnc = false;
1294
+ if (opts.isolateClaudeConfig === true) box.isolateClaudeConfig = true;
1295
+ if (opts.sharedDockerCache === true) box.dockerCacheShared = true;
1296
+ const claude = {};
1297
+ if (opts.sessionName !== void 0) claude.sessionName = opts.sessionName;
1298
+ const out = {};
1299
+ if (Object.keys(box).length > 0) out.box = box;
1300
+ if (Object.keys(claude).length > 0) out.claude = claude;
1301
+ return out;
1302
+ }
1303
+ async function runClaudeLoginContainer(image, extraArgs) {
1304
+ const { exitCode } = runInteractiveClaudeLogin(
1305
+ buildClaudeLoginRunArgv({ volume: SHARED_CLAUDE_VOLUME, image, extraArgs })
1306
+ );
1307
+ if (exitCode === 0) {
1308
+ const s = spinner();
1309
+ s.start("checking credentials");
1310
+ const warm = await warmUpClaudeCredentials(SHARED_CLAUDE_VOLUME, image, {
1311
+ onProgress: (line) => s.message(clampSpinnerLine(line))
1312
+ });
1313
+ s.stop(warm.warmed ? "credentials ready" : "credentials check incomplete \u2014 continuing");
1314
+ await syncClaudeCredentials({ volume: SHARED_CLAUDE_VOLUME }, { image, isolate: false });
1315
+ }
1316
+ return exitCode;
1317
+ }
1318
+ async function maybeRunClaudeLogin(args) {
1319
+ if (!process.stdin.isTTY || args.yes) return;
1320
+ if (args.authSource === "host-env") return;
1321
+ if (await hostBackupHasCredentials()) return;
1322
+ const message = args.authSource === "auth-file" ? "You're on a legacy API token (shows as 'Claude API'). Sign in with your Claude subscription instead?" : "Sign in with your Claude subscription? (saved and reused by every box)";
1323
+ const answer = await confirm2({ message, initialValue: true });
1324
+ if (isCancel2(answer) || !answer) {
1325
+ log5.info("Skipped sign-in \u2014 claude will prompt you to /login inside the box.");
1326
+ return;
1327
+ }
1328
+ const s = spinner();
1329
+ s.start("preparing sandbox image");
1330
+ await ensureImage(args.image, { onProgress: (line) => s.message(clampSpinnerLine(line)) });
1331
+ s.message("preparing claude config");
1332
+ await ensureClaudeVolume(
1333
+ { volume: SHARED_CLAUDE_VOLUME },
1334
+ { syncFromHost: true, image: args.image, hostWorkspace: args.hostWorkspace }
1335
+ );
1336
+ s.stop("image ready");
1337
+ const exitCode = await runClaudeLoginContainer(args.image, ["--claudeai"]);
1338
+ if (exitCode !== 0) {
1339
+ log5.warn("Claude login did not complete; continuing \u2014 run `agentbox claude login` to retry.");
1340
+ return;
1341
+ }
1342
+ log5.success("Signed in with your Claude subscription \u2014 saved for future boxes.");
1343
+ }
1344
+ var claudeCommand = new Command2("claude").description("Create a sandboxed box and launch Claude Code in a detachable tmux session").option("-w, --workspace <path>", "host workspace to mount", process.cwd()).option("-n, --name <name>", "friendly box name (default: <workspace-basename>-<id>)").option("--host-snapshot", "APFS-clone the host workspace into a per-box scratch dir before seeding /workspace (stabilizes the tar-pipe source)").option("--no-host-snapshot", "tar-pipe directly from the live host workspace at create time").option(
1345
+ "--snapshot <ref>",
1346
+ "start from a project checkpoint (see `agentbox checkpoint`); overrides box.defaultCheckpoint"
1347
+ ).option("--image <ref>", "override the box image").option("-y, --yes", "skip prompts, accept defaults").option(
1348
+ "--isolate-claude-config",
1349
+ "use a per-box ~/.claude volume instead of the shared agentbox-claude-config"
1350
+ ).option("--with-playwright", "also install @playwright/cli@latest globally inside the box").option(
1351
+ "--with-env",
1352
+ "copy host env/config files (.env*, secrets.toml, agentbox.yaml, ...) into /workspace at create time (gitignore-bypassing)"
1353
+ ).option("--no-vnc", "disable the per-box Xvnc + noVNC web client (on by default)").option(
1354
+ "--shared-docker-cache",
1355
+ "use the shared 'agentbox-docker-cache' volume for in-box docker images (preserved on destroy; only one box can run at a time when set)"
1356
+ ).option("--session-name <name>", "tmux session name (default from config; built-in: claude)").option("--memory <size>", "memory ceiling (e.g. 512m, 2g); unset = unlimited").option("--cpus <n>", "CPU count cap (fractional ok, e.g. 1.5); unset = unlimited").option("--pids-limit <n>", "max process count (PIDs cgroup); unset = unlimited").option("--disk <size>", "best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS").argument(
1357
+ "[claude-args...]",
1358
+ "extra args passed to claude inside the box; place after `--`, e.g. `agentbox claude -- --model sonnet`"
1359
+ ).action(async (claudeArgs, opts) => {
1360
+ intro("Starting Claude in a box...");
1361
+ const cfg = await loadEffectiveConfig(opts.workspace, {
1362
+ cliOverrides: buildClaudeCliOverrides(opts)
1363
+ });
1364
+ const projectRoot = (await findProjectRoot(opts.workspace)).root;
1365
+ const checkpointRef = opts.snapshot && opts.snapshot.length > 0 ? opts.snapshot : cfg.effective.box.defaultCheckpoint.length > 0 ? cfg.effective.box.defaultCheckpoint : void 0;
1366
+ const resolved = await resolveClaudeAuth(process.env);
1367
+ await maybeRunClaudeLogin({
1368
+ image: cfg.effective.box.image,
1369
+ authSource: resolved.source,
1370
+ yes: !!opts.yes,
1371
+ hostWorkspace: opts.workspace
1372
+ });
1373
+ const wiz = await maybeRunSetupWizard({
1374
+ workspace: opts.workspace,
1375
+ yes: !!opts.yes,
1376
+ command: "claude",
1377
+ checkpointRef,
1378
+ withEnv: cfg.effective.box.withEnv
1379
+ });
1380
+ let effectiveClaudeArgs = claudeArgs;
1381
+ if (wiz.action === "launch-with-prompt" && wiz.initialPrompt) {
1382
+ effectiveClaudeArgs = resolveAgentLauncher("claude-code").buildArgs(
1383
+ wiz.initialPrompt,
1384
+ claudeArgs
1385
+ );
1386
+ }
1387
+ const useSnapshot = opts.hostSnapshot === false ? false : opts.hostSnapshot === true ? true : cfg.effective.box.hostSnapshot ?? false;
1388
+ const sessionName = cfg.effective.claude.sessionName;
1389
+ const s = spinner();
1390
+ s.start("creating box");
1391
+ let containerName = "";
1392
+ try {
1393
+ const withPlaywright = cfg.effective.box.withPlaywright || cfg.effective.browser.default !== "agent-browser";
1394
+ const result = await createBox({
1395
+ workspacePath: opts.workspace,
1396
+ name: opts.name,
1397
+ useSnapshot,
1398
+ checkpointRef,
1399
+ image: cfg.effective.box.image,
1400
+ claudeConfig: { isolate: cfg.effective.box.isolateClaudeConfig },
1401
+ claudeEnv: resolved.env,
1402
+ withPlaywright,
1403
+ withEnv: cfg.effective.box.withEnv,
1404
+ envFilesToImport: wiz.envFilesToImport,
1405
+ vnc: { enabled: cfg.effective.box.vnc },
1406
+ docker: { sharedCache: cfg.effective.box.dockerCacheShared },
1407
+ limits: resolveLimits(cfg.effective.box, opts),
1408
+ projectRoot,
1409
+ onLog: (line) => s.message(clampSpinnerLine(line))
1410
+ });
1411
+ containerName = result.record.container;
1412
+ s.message("checking plugin native deps");
1413
+ const rebuild = await rebuildPluginNativeDeps(result.record.container, {
1414
+ volume: result.record.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME,
1415
+ onProgress: (line) => s.message(clampSpinnerLine(line))
1416
+ });
1417
+ s.message("starting claude session");
1418
+ await startClaudeSession({
1419
+ container: result.record.container,
1420
+ claudeArgs: effectiveClaudeArgs,
1421
+ sessionName,
1422
+ boxName: result.record.name
1423
+ });
1424
+ const nSuffix = typeof result.record.projectIndex === "number" ? ` \xB7 n ${String(result.record.projectIndex)}` : "";
1425
+ s.stop(`box ${result.record.container} ready${nSuffix}`);
1426
+ logPrune(rebuild);
1427
+ for (const f of rebuild.failed) {
1428
+ log5.warn(`plugin install failed for ${f.dir}; claude may still load it. stderr:
1429
+ ${f.stderr.trim()}`);
1430
+ }
1431
+ outro("attaching \u2014 Control+a q to detach, leaves claude running");
1432
+ await attachClaudeWrapped(result.record, sessionName, reattachRef(result.record));
1433
+ } catch (err) {
1434
+ s.stop("failed");
1435
+ if (err instanceof ClaudeSessionError) {
1436
+ log5.error(err.message);
1437
+ if (containerName) {
1438
+ log5.info(`The box ${containerName} is still running. Destroy it with:`);
1439
+ log5.info(` agentbox destroy ${containerName} -y`);
1440
+ }
1441
+ process.exit(1);
1442
+ }
1443
+ handleLifecycleError(err);
1444
+ }
1445
+ });
1446
+ async function startOrAttachClaude(box, claudeArgs, opts) {
1447
+ const cfg = await loadEffectiveConfig(box.workspacePath, {
1448
+ cliOverrides: opts.sessionName ? { claude: { sessionName: opts.sessionName } } : {}
1449
+ });
1450
+ const sessionName = cfg.effective.claude.sessionName;
1451
+ const resolved = await resolveClaudeAuth(process.env);
1452
+ const insp = await inspectBox(box.id);
1453
+ if (insp.state === "missing") {
1454
+ throw new Error(`box ${box.name} has no container; was it destroyed?`);
1455
+ }
1456
+ const existing = await claudeSessionInfo(box.container, sessionName);
1457
+ if (existing.running) {
1458
+ outro(`session "${sessionName}" already running \u2014 attaching (Control+a q to detach)`);
1459
+ await attachClaudeWrapped(box, sessionName, reattachRef(box));
1460
+ return;
1461
+ }
1462
+ await maybeRunClaudeLogin({
1463
+ image: box.image,
1464
+ authSource: resolved.source,
1465
+ yes: false,
1466
+ hostWorkspace: box.workspacePath
1467
+ });
1468
+ const s = spinner();
1469
+ s.start("preparing box");
1470
+ if (insp.state === "paused") {
1471
+ s.message("unpausing box");
1472
+ await unpauseBox(box.id);
1473
+ } else if (insp.state === "stopped") {
1474
+ s.message("starting box");
1475
+ await startBox(box.id);
1476
+ }
1477
+ const syncConfig = opts.syncConfig !== false;
1478
+ if (syncConfig) {
1479
+ s.message("syncing ~/.claude into box volume");
1480
+ const volume = box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME;
1481
+ await ensureClaudeVolume(
1482
+ { volume },
1483
+ {
1484
+ syncFromHost: true,
1485
+ image: box.image,
1486
+ hostWorkspace: box.workspacePath
1487
+ }
1488
+ );
1489
+ }
1490
+ const claudeVolume = box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME;
1491
+ await seedSetupSkillIntoVolume(claudeVolume, box.image);
1492
+ await syncClaudeCredentials(
1493
+ { volume: claudeVolume },
1494
+ { image: box.image, isolate: claudeVolume !== SHARED_CLAUDE_VOLUME }
1495
+ );
1496
+ s.message("checking plugin native deps");
1497
+ const rebuild = await rebuildPluginNativeDeps(box.container, {
1498
+ volume: box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME,
1499
+ onProgress: (line) => s.message(clampSpinnerLine(line))
1500
+ });
1501
+ s.message("starting claude session");
1502
+ await startClaudeSession({
1503
+ container: box.container,
1504
+ claudeArgs,
1505
+ sessionName,
1506
+ boxName: box.name
1507
+ });
1508
+ s.stop(`box ${box.container} ready`);
1509
+ logPrune(rebuild);
1510
+ for (const f of rebuild.failed) {
1511
+ log5.warn(`plugin install failed for ${f.dir}; claude may still load it. stderr:
1512
+ ${f.stderr.trim()}`);
1513
+ }
1514
+ outro("attaching \u2014 Control+a q to detach, leaves claude running");
1515
+ await attachClaudeWrapped(box, sessionName, reattachRef(box));
1516
+ }
1517
+ var claudeAttachCommand = new Command2("attach").description(
1518
+ "Attach to a Claude Code tmux session in a box, starting one if none is running (auto-unpause/start; never re-syncs ~/.claude \u2014 use `claude start` for that)"
1519
+ ).argument(
1520
+ "[box]",
1521
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
1522
+ ).option("--session-name <name>", "tmux session name (default from config; built-in: claude)").action(async function(idOrName) {
1523
+ const opts = this.optsWithGlobals();
1524
+ intro("Attaching to Claude session...");
1525
+ try {
1526
+ const box = await resolveBoxOrExit(idOrName);
1527
+ await startOrAttachClaude(box, [], { ...opts, syncConfig: false });
1528
+ } catch (err) {
1529
+ if (err instanceof ClaudeSessionError) {
1530
+ log5.error(err.message);
1531
+ process.exit(1);
1532
+ }
1533
+ handleLifecycleError(err);
1534
+ }
1535
+ });
1536
+ var claudeStartCommand = new Command2("start").description(
751
1537
  "Start a Claude Code tmux session in an already-existing box (auto-unpause/start). If a session is already running, just attach."
752
1538
  ).argument(
753
1539
  "[box]",
@@ -760,7 +1546,7 @@ var claudeStartCommand = new Command2("start").description(
760
1546
  "extra args passed to claude when starting a new session; ignored if a session is already running. Place after `--`, e.g. `agentbox claude start 1 -- --model sonnet`"
761
1547
  ).action(async function(idOrName, claudeArgs) {
762
1548
  const opts = this.optsWithGlobals();
763
- intro("agentbox claude start");
1549
+ intro("Starting Claude in a box...");
764
1550
  try {
765
1551
  const { box, shifted } = await resolveBoxOrShift(idOrName);
766
1552
  const effectiveClaudeArgs = shifted && idOrName ? [idOrName, ...claudeArgs] : claudeArgs;
@@ -773,19 +1559,53 @@ var claudeStartCommand = new Command2("start").description(
773
1559
  handleLifecycleError(err);
774
1560
  }
775
1561
  });
1562
+ var claudeLoginCommand = new Command2("login").description(
1563
+ "Sign in to Claude for use in sandboxes (forwards args to `claude auth login`, e.g. --sso, --console). Runs in a throwaway container against the shared claude-config volume \u2014 usable before the first `agentbox claude`."
1564
+ ).argument(
1565
+ "[args...]",
1566
+ "extra args forwarded to `claude auth login`; place after `--`, e.g. `agentbox claude login -- --sso`"
1567
+ ).action(async (args) => {
1568
+ intro("Signing in to Claude...");
1569
+ if (!process.stdin.isTTY) {
1570
+ log5.error("`agentbox claude login` needs an interactive terminal.");
1571
+ process.exit(1);
1572
+ }
1573
+ try {
1574
+ const cfg = await loadEffectiveConfig(process.cwd());
1575
+ const image = cfg.effective.box.image;
1576
+ const s = spinner();
1577
+ s.start("preparing sandbox image");
1578
+ await ensureImage(image, { onProgress: (line) => s.message(clampSpinnerLine(line)) });
1579
+ s.stop("image ready");
1580
+ const exitCode = await runClaudeLoginContainer(image, args);
1581
+ if (exitCode !== 0) {
1582
+ log5.warn(`\`claude auth login\` exited with code ${String(exitCode)}`);
1583
+ process.exit(exitCode);
1584
+ }
1585
+ outro("signed in \u2014 credentials saved for future boxes");
1586
+ } catch (err) {
1587
+ handleLifecycleError(err);
1588
+ }
1589
+ });
776
1590
  claudeCommand.addCommand(claudeAttachCommand);
777
1591
  claudeCommand.addCommand(claudeStartCommand);
1592
+ claudeCommand.addCommand(claudeLoginCommand);
778
1593
 
779
1594
  // src/commands/checkpoint.ts
780
1595
  import { confirm as confirm3, isCancel as isCancel3, log as log6 } from "@clack/prompts";
781
1596
  import { Command as Command3 } from "commander";
1597
+ var CHECKPOINT_NOTICE = "Checkpoint in progress \u2014 the box will be unresponsive for a moment";
1598
+ var CHECKPOINT_NOTICE_TTL_MS = 66e4;
782
1599
  async function projectRootFor(cwd, recordRoot) {
783
1600
  return recordRoot ?? (await findProjectRoot(cwd)).root;
784
1601
  }
785
1602
  var createSub = new Command3("create").description("Capture a box state as a project checkpoint (<box-name>-<n>)").argument(
786
1603
  "[box]",
787
1604
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
788
- ).option("--name <name>", "checkpoint name (default: <box-name>-<next>)").option("--merged", "flatten lower+upper into one tree instead of a layered delta").option("--set-default", "mark this checkpoint as the project default for new boxes").action(async (idOrName, opts) => {
1605
+ ).option("--name <name>", "checkpoint name (default: <box-name>-<next>)").option("--merged", "flatten lower+upper into one tree instead of a layered delta").option("--set-default", "mark this checkpoint as the project default for new boxes").option(
1606
+ "--replace",
1607
+ "if a checkpoint with the same name exists, rm it first (idempotent recapture; safe to retry when the previous run's stdout was lost)"
1608
+ ).action(async (idOrName, opts) => {
789
1609
  try {
790
1610
  const box = await resolveBoxOrExit(idOrName);
791
1611
  const insp = await inspectBox(box.id);
@@ -793,27 +1613,57 @@ var createSub = new Command3("create").description("Capture a box state as a pro
793
1613
  log6.info("box is paused; unpausing");
794
1614
  await unpauseBox(box.id);
795
1615
  } else if (insp.state === "stopped") {
796
- log6.info("box is stopped; starting (remounting overlay)");
1616
+ log6.info("box is stopped; starting");
797
1617
  await startBox(box.id);
798
1618
  } else if (insp.state === "missing") {
799
1619
  throw new Error(`box ${box.name} has no container; was it destroyed?`);
800
1620
  }
801
1621
  const projectRoot = await projectRootFor(box.workspacePath, box.projectRoot);
802
1622
  const cfg = await loadEffectiveConfig(projectRoot);
803
- const info = await createCheckpoint({
804
- box,
805
- projectRoot,
806
- name: opts.name,
807
- merged: opts.merged === true,
808
- setDefault: opts.setDefault === true,
809
- maxLayers: cfg.effective.checkpoint.maxLayers,
810
- onLog: (line) => log6.info(line)
811
- });
812
- log6.success(
813
- `checkpoint ${info.name} (${info.manifest.type}) -> ${info.dir}` + (opts.setDefault ? " [project default]" : "")
1623
+ const noticeId = await setRelayNotice(
1624
+ box.id,
1625
+ "checkpoint",
1626
+ CHECKPOINT_NOTICE,
1627
+ CHECKPOINT_NOTICE_TTL_MS
814
1628
  );
815
- if (!opts.setDefault) {
816
- log6.info(`make it the default for new boxes: agentbox checkpoint set-default ${info.name}`);
1629
+ let signalled = false;
1630
+ const onSignal = () => {
1631
+ if (signalled) return;
1632
+ signalled = true;
1633
+ void (async () => {
1634
+ if (noticeId) await clearRelayNotice(box.id, noticeId);
1635
+ process.exit(130);
1636
+ })();
1637
+ };
1638
+ if (noticeId) {
1639
+ process.once("SIGINT", onSignal);
1640
+ process.once("SIGTERM", onSignal);
1641
+ }
1642
+ try {
1643
+ const info = await createCheckpoint({
1644
+ box,
1645
+ projectRoot,
1646
+ name: opts.name,
1647
+ merged: opts.merged === true,
1648
+ setDefault: opts.setDefault === true,
1649
+ replace: opts.replace === true,
1650
+ maxLayers: cfg.effective.checkpoint.maxLayers,
1651
+ onLog: (line) => log6.info(line)
1652
+ });
1653
+ log6.success(
1654
+ `checkpoint ${info.name} (${info.manifest.type}) -> ${info.dir}` + (opts.setDefault ? " [project default]" : "")
1655
+ );
1656
+ if (!opts.setDefault) {
1657
+ log6.info(
1658
+ `make it the default for new boxes: agentbox checkpoint set-default ${info.name}`
1659
+ );
1660
+ }
1661
+ } finally {
1662
+ if (noticeId) {
1663
+ await clearRelayNotice(box.id, noticeId);
1664
+ process.removeListener("SIGINT", onSignal);
1665
+ process.removeListener("SIGTERM", onSignal);
1666
+ }
817
1667
  }
818
1668
  } catch (err) {
819
1669
  handleLifecycleError(err);
@@ -897,7 +1747,7 @@ var rmSub = new Command3("rm").description("Delete a checkpoint").argument("<ref
897
1747
  handleLifecycleError(err);
898
1748
  }
899
1749
  });
900
- var checkpointCommand = new Command3("checkpoint").description("Capture and manage project checkpoints (warm box state new boxes can start from)").addCommand(createSub, { isDefault: true }).addCommand(lsSub).addCommand(setDefaultSub).addCommand(rmSub);
1750
+ var checkpointCommand = new Command3("checkpoint").alias("checkpoints").description("List and manage project checkpoints (warm box state new boxes can start from)").addCommand(createSub).addCommand(lsSub, { isDefault: true }).addCommand(setDefaultSub).addCommand(rmSub);
901
1751
 
902
1752
  // src/commands/code.ts
903
1753
  import { spawn } from "child_process";
@@ -944,7 +1794,7 @@ var codeCommand = new Command4("code").description("Open a box in VS Code or Cur
944
1794
  log7.info(`box is paused; unpausing`);
945
1795
  await unpauseBox(box.id);
946
1796
  } else if (insp.state === "stopped") {
947
- log7.info(`box is stopped; starting (remounting overlay)`);
1797
+ log7.info(`box is stopped; starting`);
948
1798
  await startBox(box.id);
949
1799
  } else if (insp.state === "missing") {
950
1800
  throw new Error(`box ${box.name} has no container; was it destroyed?`);
@@ -1040,10 +1890,10 @@ async function launchOne(flavor, folderUri) {
1040
1890
  return { code: fallback, flavor, via: "open" };
1041
1891
  }
1042
1892
  function spawnCommand(cmd, args) {
1043
- return new Promise((resolve) => {
1893
+ return new Promise((resolve2) => {
1044
1894
  const child = spawn(cmd, args, { stdio: "ignore" });
1045
- child.once("error", () => resolve(127));
1046
- child.once("exit", (code) => resolve(code ?? -1));
1895
+ child.once("error", () => resolve2(127));
1896
+ child.once("exit", (code) => resolve2(code ?? -1));
1047
1897
  });
1048
1898
  }
1049
1899
  async function fetchServiceNames(container) {
@@ -1121,9 +1971,9 @@ var getCommand = new Command5("get").description("Print the effective value of a
1121
1971
  const value = leafValue(loaded, key);
1122
1972
  const source = loaded.sources[key] ?? "default";
1123
1973
  if (opts.json) {
1124
- const layerView = (values, path) => ({
1974
+ const layerView = (values, path2) => ({
1125
1975
  value: rawLeafFromValues(values, key) ?? null,
1126
- path
1976
+ path: path2
1127
1977
  });
1128
1978
  process.stdout.write(
1129
1979
  JSON.stringify(
@@ -1272,9 +2122,9 @@ function pickFromScope(loaded, scope, key) {
1272
2122
  var pathCommand = new Command5("path").description("Print the file path for a config scope (default: --project)").option("--global", "~/.agentbox/config.yaml").option("--project", "~/.agentbox/projects/<hash>/config.yaml (default)").option("--workspace", "./agentbox.yaml (resolved by walking up to the nearest one)").option("--json", "machine-readable output").action(async (opts) => {
1273
2123
  try {
1274
2124
  const scope = resolveEditScope(opts);
1275
- const path = await configPathFor(scope, process.cwd());
1276
- if (opts.json) process.stdout.write(JSON.stringify({ scope, path }, null, 2) + "\n");
1277
- else process.stdout.write(`${path}
2125
+ const path2 = await configPathFor(scope, process.cwd());
2126
+ if (opts.json) process.stdout.write(JSON.stringify({ scope, path: path2 }, null, 2) + "\n");
2127
+ else process.stdout.write(`${path2}
1278
2128
  `);
1279
2129
  } catch (err) {
1280
2130
  handleError(err);
@@ -1283,9 +2133,9 @@ var pathCommand = new Command5("path").description("Print the file path for a co
1283
2133
  var editCommand = new Command5("edit").description("Open a config file in $EDITOR (default: --project)").option("--global", "edit ~/.agentbox/config.yaml").option("--project", "edit ~/.agentbox/projects/<hash>/config.yaml (default)").option("--workspace", "edit ./agentbox.yaml (the resolved one \u2014 and remember to fill in the `defaults:` block)").action(async (opts) => {
1284
2134
  try {
1285
2135
  const scope = resolveEditScope(opts);
1286
- const path = await configPathFor(scope, process.cwd());
2136
+ const path2 = await configPathFor(scope, process.cwd());
1287
2137
  const editor = process.env["EDITOR"] || process.env["VISUAL"] || "vi";
1288
- const child = spawnSync3(editor, [path], { stdio: "inherit" });
2138
+ const child = spawnSync3(editor, [path2], { stdio: "inherit" });
1289
2139
  process.exit(child.status ?? 0);
1290
2140
  } catch (err) {
1291
2141
  handleError(err);
@@ -1309,25 +2159,236 @@ var listProjectsCommand = new Command5("list-projects").description("List direct
1309
2159
  );
1310
2160
  }
1311
2161
  } catch (err) {
1312
- handleError(err);
2162
+ handleError(err);
2163
+ }
2164
+ });
2165
+ function handleError(err) {
2166
+ if (err instanceof UserConfigError) {
2167
+ process.stderr.write(`error: ${err.message}
2168
+ `);
2169
+ process.exit(2);
2170
+ }
2171
+ const msg = err instanceof Error ? err.message : String(err);
2172
+ process.stderr.write(`error: ${msg}
2173
+ `);
2174
+ process.exit(1);
2175
+ }
2176
+ var configCommand = new Command5("config").description("Read / write layered config (global, per-project, workspace `defaults:` block)").addCommand(getCommand).addCommand(setCommand).addCommand(unsetCommand).addCommand(listCommand).addCommand(pathCommand).addCommand(editCommand).addCommand(listProjectsCommand);
2177
+
2178
+ // src/commands/cp.ts
2179
+ import { existsSync, mkdirSync, renameSync, statSync } from "fs";
2180
+ import * as path from "path";
2181
+ import { log as log8 } from "@clack/prompts";
2182
+ import { Command as Command6 } from "commander";
2183
+ import { execa } from "execa";
2184
+ function parseBoxArg(arg) {
2185
+ const idx = arg.indexOf(":");
2186
+ if (idx === -1) return null;
2187
+ const prefix = arg.slice(0, idx);
2188
+ if (prefix.includes("/")) return null;
2189
+ if (prefix.length === 0) return null;
2190
+ const p = arg.slice(idx + 1);
2191
+ if (p.length === 0) return null;
2192
+ return { boxRef: prefix, path: p };
2193
+ }
2194
+ function parseArgs(src, dst) {
2195
+ const srcBox = parseBoxArg(src);
2196
+ const dstBox = dst === void 0 ? null : parseBoxArg(dst);
2197
+ if (srcBox && dstBox) {
2198
+ throw new Error(
2199
+ "box-to-box copy is not supported; both arguments look like box paths (`name:/path`)."
2200
+ );
2201
+ }
2202
+ if (!srcBox && !dstBox) {
2203
+ throw new Error(
2204
+ "one argument must be a box path of the form `<box>:/path` (e.g. `mybox:/workspace/foo`)."
2205
+ );
2206
+ }
2207
+ if (srcBox) {
2208
+ return {
2209
+ direction: "download",
2210
+ boxRef: srcBox.boxRef,
2211
+ boxPath: srcBox.path,
2212
+ hostPath: dst
2213
+ };
2214
+ }
2215
+ if (dst === void 0) {
2216
+ throw new Error("host -> box copy requires a destination, e.g. `agentbox cp ./foo box:/dst`.");
2217
+ }
2218
+ return {
2219
+ direction: "upload",
2220
+ boxRef: dstBox.boxRef,
2221
+ boxPath: dstBox.path,
2222
+ hostPath: src
2223
+ };
2224
+ }
2225
+ function posixDirname(p) {
2226
+ return path.posix.dirname(p) || "/";
2227
+ }
2228
+ function asText(s) {
2229
+ if (s === void 0) return "";
2230
+ if (typeof s === "string") return s;
2231
+ return Buffer.from(s).toString("utf8");
2232
+ }
2233
+ async function uploadToBox(box, hostSrc, boxDst) {
2234
+ const srcAbs = path.resolve(hostSrc);
2235
+ if (!existsSync(srcAbs)) throw new Error(`source not found: ${hostSrc}`);
2236
+ const srcBasename = path.basename(srcAbs);
2237
+ const srcParent = path.dirname(srcAbs);
2238
+ let boxParent;
2239
+ let finalName;
2240
+ if (boxDst.endsWith("/")) {
2241
+ boxParent = boxDst.replace(/\/+$/, "") || "/";
2242
+ finalName = srcBasename;
2243
+ } else {
2244
+ const isDir = await execa(
2245
+ "docker",
2246
+ ["exec", box.container, "test", "-d", boxDst],
2247
+ { reject: false }
2248
+ );
2249
+ if (isDir.exitCode === 0) {
2250
+ boxParent = boxDst.replace(/\/+$/, "") || "/";
2251
+ finalName = srcBasename;
2252
+ } else {
2253
+ boxParent = posixDirname(boxDst);
2254
+ finalName = path.posix.basename(boxDst);
2255
+ }
2256
+ }
2257
+ const finalPath = boxParent === "/" ? `/${finalName}` : `${boxParent}/${finalName}`;
2258
+ const mk = await execa(
2259
+ "docker",
2260
+ ["exec", "--user", "root", box.container, "mkdir", "-p", boxParent],
2261
+ { reject: false }
2262
+ );
2263
+ if (mk.exitCode !== 0) {
2264
+ throw new Error(`mkdir -p ${boxParent} in box failed: ${asText(mk.stderr).slice(0, 300)}`);
2265
+ }
2266
+ const packed = await execa("tar", ["-C", srcParent, "-cf", "-", srcBasename], {
2267
+ encoding: "buffer",
2268
+ reject: false,
2269
+ env: { ...process.env, COPYFILE_DISABLE: "1" }
2270
+ });
2271
+ if (packed.exitCode !== 0) {
2272
+ throw new Error(`tar pack failed: ${asText(packed.stderr).slice(0, 300)}`);
2273
+ }
2274
+ const extract = await execa(
2275
+ "docker",
2276
+ ["exec", "-i", "--user", "root", box.container, "tar", "-xf", "-", "-C", boxParent],
2277
+ { input: packed.stdout, reject: false }
2278
+ );
2279
+ if (extract.exitCode !== 0) {
2280
+ throw new Error(`tar extract in box failed: ${asText(extract.stderr).slice(0, 300)}`);
2281
+ }
2282
+ if (finalName !== srcBasename) {
2283
+ const initial = boxParent === "/" ? `/${srcBasename}` : `${boxParent}/${srcBasename}`;
2284
+ const mv = await execa(
2285
+ "docker",
2286
+ ["exec", "--user", "root", box.container, "mv", initial, finalPath],
2287
+ { reject: false }
2288
+ );
2289
+ if (mv.exitCode !== 0) {
2290
+ throw new Error(
2291
+ `rename ${initial} -> ${finalPath} in box failed: ${asText(mv.stderr).slice(0, 300)}`
2292
+ );
2293
+ }
2294
+ }
2295
+ const chown = await execa(
2296
+ "docker",
2297
+ ["exec", "--user", "root", box.container, "chown", "-R", "1000:1000", finalPath],
2298
+ { reject: false }
2299
+ );
2300
+ if (chown.exitCode !== 0) {
2301
+ return {
2302
+ finalPath,
2303
+ warn: `chown ${finalPath} to vscode (uid 1000) failed; ownership inside the box may be root.`
2304
+ };
2305
+ }
2306
+ return { finalPath, warn: null };
2307
+ }
2308
+ async function downloadFromBox(box, boxSrc, hostDst) {
2309
+ const srcBasename = path.posix.basename(boxSrc);
2310
+ const srcParent = posixDirname(boxSrc);
2311
+ const dstAbs = path.resolve(hostDst);
2312
+ let hostParent;
2313
+ let finalName;
2314
+ const dstExists = existsSync(dstAbs);
2315
+ if (hostDst.endsWith("/") || dstExists && statSync(dstAbs).isDirectory()) {
2316
+ hostParent = dstAbs;
2317
+ finalName = srcBasename;
2318
+ } else {
2319
+ hostParent = path.dirname(dstAbs);
2320
+ finalName = path.basename(dstAbs);
2321
+ }
2322
+ mkdirSync(hostParent, { recursive: true });
2323
+ const finalPath = path.join(hostParent, finalName);
2324
+ const packed = await execa(
2325
+ "docker",
2326
+ ["exec", box.container, "tar", "-C", srcParent, "-cf", "-", srcBasename],
2327
+ { encoding: "buffer", reject: false }
2328
+ );
2329
+ if (packed.exitCode !== 0) {
2330
+ throw new Error(`tar pack in box failed: ${asText(packed.stderr).slice(0, 300)}`);
2331
+ }
2332
+ const extract = await execa("tar", ["-xf", "-", "-C", hostParent], {
2333
+ input: packed.stdout,
2334
+ reject: false
2335
+ });
2336
+ if (extract.exitCode !== 0) {
2337
+ throw new Error(`tar extract on host failed: ${asText(extract.stderr).slice(0, 300)}`);
2338
+ }
2339
+ if (finalName !== srcBasename) {
2340
+ renameSync(path.join(hostParent, srcBasename), finalPath);
2341
+ }
2342
+ return { finalPath };
2343
+ }
2344
+ var cpCommand = new Command6("cp").description("Copy files between host and box (like `docker cp`; direction picked by `name:` prefix)").argument("<src>", "`box:/path` (download) or host path (upload)").argument(
2345
+ "[dst]",
2346
+ "`box:/path` (upload) or host path (download); defaults to cwd when downloading"
2347
+ ).addHelpText(
2348
+ "after",
2349
+ [
2350
+ "",
2351
+ "Examples:",
2352
+ " agentbox cp mybox:/etc/foo ./foo # download (host path optional)",
2353
+ " agentbox cp mybox:/workspace/.env # download into cwd",
2354
+ " agentbox cp ./local.txt mybox:/workspace/ # upload (host path required)",
2355
+ " agentbox cp ./dir mybox:/workspace/ # upload directory (recursive)"
2356
+ ].join("\n")
2357
+ ).action(async (src, dst) => {
2358
+ try {
2359
+ const parsed = parseArgs(src, dst);
2360
+ const box = await resolveBoxOrExit(parsed.boxRef);
2361
+ const insp = await inspectBox(box.id);
2362
+ if (insp.state === "paused") {
2363
+ log8.info("box is paused; unpausing");
2364
+ await unpauseBox(box.id);
2365
+ } else if (insp.state === "stopped") {
2366
+ log8.info("box is stopped; starting");
2367
+ await startBox(box.id);
2368
+ } else if (insp.state === "missing") {
2369
+ throw new Error(`box ${box.name} has no container; was it destroyed?`);
2370
+ }
2371
+ if (parsed.direction === "upload") {
2372
+ const result = await uploadToBox(box, parsed.hostPath, parsed.boxPath);
2373
+ if (result.warn) {
2374
+ log8.warn(`copied to ${box.name}:${result.finalPath}, but ${result.warn}`);
2375
+ } else {
2376
+ process.stdout.write(`copied to ${box.name}:${result.finalPath}
2377
+ `);
2378
+ }
2379
+ } else {
2380
+ const result = await downloadFromBox(box, parsed.boxPath, parsed.hostPath ?? process.cwd());
2381
+ process.stdout.write(`copied to ${result.finalPath}
2382
+ `);
2383
+ }
2384
+ } catch (err) {
2385
+ handleLifecycleError(err);
1313
2386
  }
1314
2387
  });
1315
- function handleError(err) {
1316
- if (err instanceof UserConfigError) {
1317
- process.stderr.write(`error: ${err.message}
1318
- `);
1319
- process.exit(2);
1320
- }
1321
- const msg = err instanceof Error ? err.message : String(err);
1322
- process.stderr.write(`error: ${msg}
1323
- `);
1324
- process.exit(1);
1325
- }
1326
- var configCommand = new Command5("config").description("Read / write layered config (global, per-project, workspace `defaults:` block)").addCommand(getCommand).addCommand(setCommand).addCommand(unsetCommand).addCommand(listCommand).addCommand(pathCommand).addCommand(editCommand).addCommand(listProjectsCommand);
1327
2388
 
1328
2389
  // src/commands/create.ts
1329
- import { intro as intro2, log as log8, outro as outro2, spinner as spinner2 } from "@clack/prompts";
1330
- import { Command as Command6 } from "commander";
2390
+ import { intro as intro2, log as log9, outro as outro2, spinner as spinner2 } from "@clack/prompts";
2391
+ import { Command as Command7 } from "commander";
1331
2392
  import { execSync, spawnSync as spawnSync4 } from "child_process";
1332
2393
  function buildCliOverrides(opts) {
1333
2394
  const box = {};
@@ -1342,19 +2403,31 @@ function buildCliOverrides(opts) {
1342
2403
  function resolveUseSnapshot(opts, configDefault) {
1343
2404
  if (opts.hostSnapshot === false) return false;
1344
2405
  if (opts.hostSnapshot === true) return true;
1345
- return configDefault ?? true;
2406
+ return configDefault ?? false;
1346
2407
  }
1347
2408
  function resolveCheckpointRef(opts, configDefault) {
1348
2409
  if (opts.snapshot && opts.snapshot.length > 0) return opts.snapshot;
1349
2410
  return configDefault.length > 0 ? configDefault : void 0;
1350
2411
  }
1351
- function attachShell(container) {
1352
- const child = spawnSync4("docker", ["exec", "-it", container, "bash"], {
1353
- stdio: "inherit"
2412
+ var RELAY_HOST_URL2 = `http://127.0.0.1:${String(DEFAULT_RELAY_PORT)}`;
2413
+ async function attachShell(record) {
2414
+ const dockerArgv = ["exec", "-it", record.container, "bash"];
2415
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
2416
+ const child = spawnSync4("docker", dockerArgv, { stdio: "inherit" });
2417
+ process.exit(child.status ?? 0);
2418
+ }
2419
+ const code = await runWrappedAttach({
2420
+ container: record.container,
2421
+ dockerArgv,
2422
+ relayBaseUrl: RELAY_HOST_URL2,
2423
+ boxId: record.id,
2424
+ boxName: record.name,
2425
+ projectIndex: record.projectIndex,
2426
+ mode: "shell"
1354
2427
  });
1355
- process.exit(child.status ?? 0);
2428
+ process.exit(code);
1356
2429
  }
1357
- var createCommand = new Command6("create").description("Create and start a new agent box (Docker container with FUSE overlay)").option("-w, --workspace <path>", "host workspace to mount", process.cwd()).option("-n, --name <name>", "friendly box name (default: <workspace-basename>-<id>)").option("--host-snapshot", "use a frozen APFS clone of the host workspace as the overlay lower").option("--no-host-snapshot", "bind the live workspace directly (host edits leak into reads)").option(
2430
+ var createCommand = new Command7("create").description("Create and start a new agent box (Docker container with /workspace seeded via in-container git worktree)").option("-w, --workspace <path>", "host workspace to mount", process.cwd()).option("-n, --name <name>", "friendly box name (default: <workspace-basename>-<id>)").option("--host-snapshot", "APFS-clone the host workspace into a per-box scratch dir before seeding /workspace (stabilizes the tar-pipe source)").option("--no-host-snapshot", "bind the live workspace directly (host edits leak into reads)").option(
1358
2431
  "--snapshot <ref>",
1359
2432
  "start from a project checkpoint (see `agentbox checkpoint`); overrides box.defaultCheckpoint"
1360
2433
  ).option("--image <ref>", "override the box image", void 0).option("--attach", "drop into a shell inside the box after it is ready").option("--with-playwright", "also install @playwright/cli@latest globally inside the box").option(
@@ -1363,8 +2436,8 @@ var createCommand = new Command6("create").description("Create and start a new a
1363
2436
  ).option("--no-vnc", "disable the per-box Xvnc + noVNC web client (on by default)").option(
1364
2437
  "--shared-docker-cache",
1365
2438
  "use the shared 'agentbox-docker-cache' volume for in-box docker images (preserved on destroy; only one box can run at a time when set)"
1366
- ).option("--memory <size>", "memory ceiling (e.g. 512m, 2g); unset = unlimited").option("--cpus <n>", "CPU count cap (fractional ok, e.g. 1.5); unset = unlimited").option("--pids-limit <n>", "max process count (PIDs cgroup); unset = unlimited").option("--disk <size>", "best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS").option("-y, --yes", "skip prompts, accept defaults (host-snapshot=on)").action(async (opts) => {
1367
- intro2("agentbox create");
2439
+ ).option("--memory <size>", "memory ceiling (e.g. 512m, 2g); unset = unlimited").option("--cpus <n>", "CPU count cap (fractional ok, e.g. 1.5); unset = unlimited").option("--pids-limit <n>", "max process count (PIDs cgroup); unset = unlimited").option("--disk <size>", "best-effort container writable-layer size (e.g. 10g); no-op on overlay2/macOS").option("-y, --yes", "skip prompts, accept defaults").action(async (opts) => {
2440
+ intro2("Setting up a new box...");
1368
2441
  const cfg = await loadEffectiveConfig(opts.workspace, {
1369
2442
  cliOverrides: buildCliOverrides(opts)
1370
2443
  });
@@ -1374,14 +2447,18 @@ var createCommand = new Command6("create").description("Create and start a new a
1374
2447
  workspace: opts.workspace,
1375
2448
  yes: !!opts.yes,
1376
2449
  command: "create",
1377
- checkpointRef
2450
+ checkpointRef,
2451
+ withEnv: cfg.effective.box.withEnv
1378
2452
  });
1379
2453
  if (wiz.action === "switch-to-claude") {
1380
2454
  process.env[WIZARD_AUTOLAUNCH_ENV] = "1";
2455
+ const serialized = serializeEnvFilesForEnv(wiz.envFilesToImport);
2456
+ if (serialized !== void 0) process.env[WIZARD_ENV_FILES_ENV] = serialized;
1381
2457
  try {
1382
2458
  await claudeCommand.parseAsync(passthroughFlags(opts), { from: "user" });
1383
2459
  } finally {
1384
2460
  delete process.env[WIZARD_AUTOLAUNCH_ENV];
2461
+ delete process.env[WIZARD_ENV_FILES_ENV];
1385
2462
  }
1386
2463
  return;
1387
2464
  }
@@ -1398,6 +2475,7 @@ var createCommand = new Command6("create").description("Create and start a new a
1398
2475
  image: cfg.effective.box.image,
1399
2476
  withPlaywright,
1400
2477
  withEnv: cfg.effective.box.withEnv,
2478
+ envFilesToImport: wiz.envFilesToImport,
1401
2479
  vnc: { enabled: cfg.effective.box.vnc },
1402
2480
  docker: { sharedCache: cfg.effective.box.dockerCacheShared },
1403
2481
  limits: resolveLimits(cfg.effective.box, opts),
@@ -1405,26 +2483,21 @@ var createCommand = new Command6("create").description("Create and start a new a
1405
2483
  onLog: (line) => s.message(clampSpinnerLine(line))
1406
2484
  });
1407
2485
  s.stop(`box ${result.record.container} ready`);
1408
- log8.info(`id: ${result.record.id}`);
2486
+ log9.info(`id: ${result.record.id}`);
1409
2487
  if (typeof result.record.projectIndex === "number") {
1410
- log8.info(`n: ${String(result.record.projectIndex)} (in ${projectRoot})`);
2488
+ log9.info(`n: ${String(result.record.projectIndex)} (in ${projectRoot})`);
1411
2489
  }
1412
- log8.info(`container: ${result.record.container}`);
1413
- log8.info(`image: ${result.record.image}${result.imageBuilt ? " (built just now)" : ""}`);
1414
- log8.info(`lower: ${result.record.lowerPath}`);
1415
- log8.info(`upper: ${result.record.upperVolume}`);
2490
+ log9.info(`container: ${result.record.container}`);
2491
+ log9.info(`image: ${result.record.image}${result.imageBuilt ? " (built just now)" : ""}`);
1416
2492
  if (result.record.snapshotDir) {
1417
- log8.info(`snapshot: ${result.record.snapshotDir}`);
2493
+ log9.info(`snapshot: ${result.record.snapshotDir}`);
1418
2494
  }
1419
2495
  if (result.record.checkpointSource) {
1420
- log8.info(
1421
- `checkpoint: ${result.record.checkpointSource.ref} (${result.record.checkpointSource.type})`
2496
+ log9.info(
2497
+ `checkpoint: ${result.record.checkpointSource.ref} (${result.record.checkpointSource.type}) \u2192 ${result.record.checkpointImage ?? "(missing)"}`
1422
2498
  );
1423
2499
  }
1424
- for (const check of result.overlayChecks) {
1425
- log8.success(`${check.name} \u2014 ${check.detail}`);
1426
- }
1427
- log8.message(
2500
+ log9.message(
1428
2501
  [
1429
2502
  "",
1430
2503
  "Try it:",
@@ -1432,8 +2505,7 @@ var createCommand = new Command6("create").description("Create and start a new a
1432
2505
  ` docker exec ${result.record.container} ls /workspace`,
1433
2506
  "",
1434
2507
  "Destroy:",
1435
- ` docker rm -f ${result.record.container}`,
1436
- ` docker volume rm ${result.record.upperVolume}`
2508
+ ` agentbox destroy ${result.record.name}`
1437
2509
  ].join("\n")
1438
2510
  );
1439
2511
  const m = cfg.effective.maintenance;
@@ -1445,7 +2517,7 @@ var createCommand = new Command6("create").description("Create and start a new a
1445
2517
  const protectedPaths = boxes.map((b) => b.projectRoot).filter((p) => typeof p === "string");
1446
2518
  const res = await pruneOrphanProjectConfigs({ protectedPaths });
1447
2519
  if (res.removed.length > 0) {
1448
- log8.info(
2520
+ log9.info(
1449
2521
  `cleaned ${String(res.removed.length)} orphan project config dir(s): ` + res.removed.map((r) => r.originalPath).join(", ")
1450
2522
  );
1451
2523
  }
@@ -1455,19 +2527,19 @@ var createCommand = new Command6("create").description("Create and start a new a
1455
2527
  }
1456
2528
  outro2("done");
1457
2529
  if (opts.attach) {
1458
- attachShell(result.record.container);
2530
+ await attachShell(result.record);
1459
2531
  }
1460
2532
  } catch (err) {
1461
2533
  s.stop("failed");
1462
2534
  const msg = err instanceof Error ? err.message : String(err);
1463
- log8.error(msg);
2535
+ log9.error(msg);
1464
2536
  try {
1465
2537
  const running = execSync('docker ps --format "{{.Names}}"', {
1466
2538
  stdio: ["ignore", "pipe", "ignore"]
1467
2539
  }).toString().split("\n").filter((n) => n.startsWith("agentbox-"));
1468
2540
  if (running.length > 0) {
1469
- log8.warn(`leftover containers: ${running.join(", ")}`);
1470
- log8.warn(`remove with: docker rm -f ${running.join(" ")}`);
2541
+ log9.warn(`leftover containers: ${running.join(", ")}`);
2542
+ log9.warn(`remove with: docker rm -f ${running.join(" ")}`);
1471
2543
  }
1472
2544
  } catch {
1473
2545
  }
@@ -1477,8 +2549,8 @@ var createCommand = new Command6("create").description("Create and start a new a
1477
2549
 
1478
2550
  // src/commands/dashboard.ts
1479
2551
  import { spawn as spawn2 } from "child_process";
1480
- import { log as log9 } from "@clack/prompts";
1481
- import { Command as Command7 } from "commander";
2552
+ import { log as log10 } from "@clack/prompts";
2553
+ import { Command as Command8 } from "commander";
1482
2554
 
1483
2555
  // src/dashboard/layout.ts
1484
2556
  var SIDEBAR_WIDTH = 32;
@@ -1503,7 +2575,7 @@ function computeLayout(cols, rows) {
1503
2575
  }
1504
2576
 
1505
2577
  // src/dashboard/renderer.ts
1506
- var RESET = "\x1B[0m";
2578
+ var RESET2 = "\x1B[0m";
1507
2579
  function fgParams(c) {
1508
2580
  if (c.kind === "default") return "39";
1509
2581
  if (c.kind === "palette") {
@@ -1548,7 +2620,7 @@ function composeRow(snap, y) {
1548
2620
  }
1549
2621
  out += cell.chars === "" ? " " : cell.chars;
1550
2622
  }
1551
- return out + RESET;
2623
+ return out + RESET2;
1552
2624
  }
1553
2625
  function cursorTo(row0, col0) {
1554
2626
  return `\x1B[${String(row0 + 1)};${String(col0 + 1)}H`;
@@ -1561,7 +2633,7 @@ function diffFrame(prev, snap, rect) {
1561
2633
  const payload = composeRow(snap, i);
1562
2634
  rows[i] = payload;
1563
2635
  if (prev && prev[i] === payload) continue;
1564
- out += cursorTo(rect.y + i, rect.x) + RESET + payload + RESET;
2636
+ out += cursorTo(rect.y + i, rect.x) + RESET2 + payload + RESET2;
1565
2637
  }
1566
2638
  if (snap.cursor.visible) {
1567
2639
  const cy = Math.min(Math.max(snap.cursor.y, 0), h - 1);
@@ -1868,228 +2940,20 @@ var PtySession = class {
1868
2940
  }
1869
2941
  };
1870
2942
 
1871
- // src/dashboard/sidebar.ts
1872
- function ellipsize(s, max) {
1873
- if (max <= 0) return "";
1874
- if (s.length <= max) return s;
1875
- if (max === 1) return "\u2026";
1876
- return s.slice(0, max - 1) + "\u2026";
1877
- }
1878
- function ellipsizeHead(s, max) {
1879
- if (max <= 0) return "";
1880
- if (s.length <= max) return s;
1881
- if (max === 1) return "\u2026";
1882
- return "\u2026" + s.slice(s.length - (max - 1));
1883
- }
1884
- function activityCell(b) {
1885
- if (b.state !== "running") return `[${b.state}]`;
1886
- switch (b.claudeActivity) {
1887
- case "working":
1888
- return "\u25CF working";
1889
- case "idle":
1890
- return "\u25CB idle";
1891
- case "waiting":
1892
- return "\u25D0 waiting";
1893
- default:
1894
- return "? unknown";
1895
- }
1896
- }
1897
- var NEW_BOX_ID = "__agentbox_new__";
1898
- var NEW_BOX_LABEL = "+ New box";
1899
- var SIDEBAR_HEADER = "AgentBox";
1900
- function topBorder(label, w) {
1901
- const lead = `\u256D\u2500\u2500\u2500 ${label} `;
1902
- if (lead.length >= w) return lead.slice(0, w);
1903
- return lead + "\u2500".repeat(w - lead.length);
1904
- }
1905
- function fit(s, w) {
1906
- if (s.length === w) return s;
1907
- if (s.length > w) return s.slice(0, w);
1908
- return s + " ".repeat(w - s.length);
1909
- }
1910
- function center(s, w) {
1911
- if (s.length >= w) return s.slice(0, w);
1912
- const pad = w - s.length;
1913
- const leftPad = Math.floor(pad / 2);
1914
- return " ".repeat(leftPad) + s + " ".repeat(pad - leftPad);
1915
- }
1916
- function projectLabel(project) {
1917
- if (!project) return "(no project)";
1918
- const parts = project.split("/").filter(Boolean);
1919
- return parts[parts.length - 1] ?? project;
1920
- }
1921
- function stripTitleGlyph(s) {
1922
- const t = s.replace(/^[\s\p{S}*·]+/u, "");
1923
- return t.length > 0 ? t : s.trim();
1924
- }
1925
- function boxRow(b, marker, w) {
1926
- const numStr = b.index != null ? `${b.index} ` : "";
1927
- const status = activityCell(b);
1928
- const left = `${marker}${numStr}`;
1929
- const room = w - left.length - status.length - 1;
1930
- if (room <= 0) return fit(`${left}${status}`, w);
1931
- const middle = b.state === "running" && b.sessionTitle ? ellipsize(stripTitleGlyph(b.sessionTitle), room) : ellipsizeHead(b.name, room);
1932
- return fit(`${left}${middle}`, w - status.length) + status;
1933
- }
1934
- function sidebarLines(boxes, selectedId, w, h) {
1935
- const lines = [topBorder(SIDEBAR_HEADER, w), fit("", w)];
1936
- const rowOwner = [null, null];
1937
- const headerRows = [true, false];
1938
- const push = (line, owner, header) => {
1939
- lines.push(fit(line, w));
1940
- rowOwner.push(owner);
1941
- headerRows.push(header);
1942
- };
1943
- let prevProject;
1944
- let seenGroup = false;
1945
- for (const b of boxes) {
1946
- const marker = b.id === selectedId ? "\u25B8" : " ";
1947
- if (b.id === NEW_BOX_ID) {
1948
- push(`${marker}${NEW_BOX_LABEL}`, b.id, false);
1949
- continue;
1950
- }
1951
- if (!seenGroup || b.project !== prevProject) {
1952
- push(center(` \u2500\u2500 ${projectLabel(b.project)} \u2500\u2500 `, w), null, true);
1953
- prevProject = b.project;
1954
- seenGroup = true;
1955
- }
1956
- push(boxRow(b, marker, w), b.id, false);
1957
- }
1958
- if (boxes.length === 0) push(" (no boxes)", null, false);
1959
- while (lines.length < h) push("", null, false);
1960
- return {
1961
- lines: lines.slice(0, h),
1962
- rowOwner: rowOwner.slice(0, h),
1963
- headerRows: headerRows.slice(0, h)
1964
- };
1965
- }
1966
- function menuLines(boxName, w, h) {
1967
- const body = [
1968
- "",
1969
- ` No Claude session in ${boxName}.`,
1970
- "",
1971
- " [c] Start Claude here",
1972
- " [s] Open a shell",
1973
- "",
1974
- " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then v/c/w/q (vnc/code/web/quit)"
1975
- ];
1976
- const top = Math.max(0, Math.floor((h - body.length) / 2));
1977
- const out = [];
1978
- for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
1979
- return out;
1980
- }
1981
- function lifecycleMenuLines(boxName, state, confirmDestroy, w, h) {
1982
- const body = confirmDestroy ? [
1983
- "",
1984
- ` Destroy ${boxName}?`,
1985
- " This removes the container and its volumes.",
1986
- "",
1987
- " [y] Yes, destroy",
1988
- " [any other key] Cancel"
1989
- ] : [
1990
- "",
1991
- ` Box ${boxName} is ${state}.`,
1992
- "",
1993
- state === "paused" ? " [u] Unpause" : " [s] Start",
1994
- " [d] Destroy",
1995
- "",
1996
- " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then q quit"
1997
- ];
1998
- const top = Math.max(0, Math.floor((h - body.length) / 2));
1999
- const out = [];
2000
- for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
2001
- return out;
2002
- }
2003
- function createMenuLines(where, w, h) {
2004
- const body = [
2005
- "",
2006
- " Create a new box",
2007
- "",
2008
- " [c] Create + launch Claude",
2009
- " [n] Create only",
2010
- "",
2011
- ` in ${where}`,
2012
- "",
2013
- " Ctrl+Option+\u2191/\u2193 switch \xB7 Ctrl-a then q quit"
2014
- ];
2015
- const top = Math.max(0, Math.floor((h - body.length) / 2));
2016
- const out = [];
2017
- for (let i = 0; i < h; i++) out.push(fit(body[i - top] ?? "", w));
2018
- return out;
2019
- }
2020
- var BAR_BG = "\x1B[48;2;48;48;48m";
2021
- var BAR_BASE = BAR_BG + "\x1B[38;5;250m";
2022
- var BAR_BRAND = "\x1B[48;5;39m\x1B[38;5;16m";
2023
- var BRAND_BOLD = "\x1B[1m";
2024
- var BRAND_NOBOLD = "\x1B[22m";
2025
- var HINT_KEY = "\x1B[38;5;255m";
2026
- var HINT_TXT = "\x1B[38;5;245m";
2027
- var BAR_RESET = "\x1B[0m";
2028
- var SWITCH_HINT = ["Control+Option+\u2191/\u2193", "switch"];
2029
- var HINT_GROUPS = [
2030
- SWITCH_HINT,
2031
- ["Control+a c", "code"],
2032
- ["Control+a v", "vnc"],
2033
- ["Control+a w", "web"],
2034
- ["Control+a q", "quit"]
2035
- ];
2036
- var COLLAPSED_HINT_GROUPS = [
2037
- SWITCH_HINT,
2038
- ["Control+a", "more"]
2039
- ];
2040
- var ADVANCED_HINT_GROUPS = [
2041
- ["c", "code"],
2042
- ["v", "vnc"],
2043
- ["w", "web"],
2044
- ["s", "stop"],
2045
- ["p", "pause"],
2046
- ["d", "destroy"],
2047
- ["q", "quit"]
2048
- ];
2049
- function statusLine(box, w, stateLabel, groups = HINT_GROUPS) {
2050
- const state = stateLabel ?? (box ? box.state === "running" ? box.claudeActivity ?? "unknown" : box.state : "");
2051
- const brandPrefix = box ? " agentbox \u25B8 " : " agentbox ";
2052
- const base = box ? `${box.name} (${state})` : "";
2053
- const coreMain = box ? `${base} ` : "";
2054
- const corePlain = brandPrefix + coreMain;
2055
- const SEP = " \u2502 ";
2056
- const renderHints = (g) => ({
2057
- plain: g.map(([k, l]) => `${k}: ${l}`).join(SEP) + " ",
2058
- styled: g.map(([k, l]) => `${HINT_KEY}${k}${HINT_TXT}: ${l}`).join(`${HINT_TXT}${SEP}`) + " "
2059
- });
2060
- let hints = null;
2061
- for (const g of [groups, COLLAPSED_HINT_GROUPS]) {
2062
- const h = renderHints(g);
2063
- if (corePlain.length + h.plain.length + 1 <= w) {
2064
- hints = h;
2065
- break;
2066
- }
2067
- }
2068
- if (!hints) {
2069
- return BAR_BASE + BAR_BRAND + fit(corePlain, w) + BAR_RESET;
2070
- }
2071
- const room = w - corePlain.length - hints.plain.length - 1;
2072
- let titleSeg = "";
2073
- if (box?.sessionTitle && room >= 7) {
2074
- titleSeg = ` \u2014 ${ellipsize(box.sessionTitle, Math.min(40, room - 3))}`;
2075
- }
2076
- const leftPlain = brandPrefix + base + titleSeg + (box ? " " : "");
2077
- const leftStyled = BAR_BRAND + brandPrefix + BRAND_BOLD + base + titleSeg + (box ? " " : "") + BRAND_NOBOLD;
2078
- const gap = w - leftPlain.length - hints.plain.length;
2079
- return BAR_BASE + leftStyled + BAR_BASE + " ".repeat(gap) + hints.styled + BAR_RESET;
2080
- }
2081
-
2082
2943
  // src/dashboard/compositor.ts
2083
2944
  var SB_BODY = BAR_BG + "\x1B[38;5;250m";
2084
2945
  var SB_HEADER = BAR_BG + "\x1B[38;5;39m\x1B[1m";
2085
2946
  var SB_SELECTED = BAR_BG + "\x1B[38;5;255m\x1B[1m";
2947
+ var SB_PROMPT = BAR_BG + "\x1B[38;5;220m\x1B[1m";
2948
+ var SB_AWAITING = BAR_BG + "\x1B[38;5;51m\x1B[1m";
2086
2949
  var SGR_RESET = "\x1B[0m";
2087
2950
  var POLL_MS = 1e3;
2088
2951
  var FRAME_MS = 16;
2089
2952
  var RESIZE_DEBOUNCE_MS = 120;
2090
2953
  var LEADER_LINGER_MS = 1500;
2091
- var SYNC_BEGIN = "\x1B[?2026h";
2092
- var SYNC_END = "\x1B[?2026l";
2954
+ var NOTICE_SPINNER_MS = 120;
2955
+ var SYNC_BEGIN2 = "\x1B[?2026h";
2956
+ var SYNC_END2 = "\x1B[?2026l";
2093
2957
  function cursorTo2(x, y) {
2094
2958
  return `\x1B[${String(y + 1)};${String(x + 1)}H`;
2095
2959
  }
@@ -2125,6 +2989,9 @@ var Compositor = class {
2125
2989
  this.pendingConfirm = null;
2126
2990
  this.drawChrome();
2127
2991
  }
2992
+ if (e.type === "forward" && this.activePrompts.has(this.selectedId)) {
2993
+ if (this.handlePromptKey(e.bytes)) return;
2994
+ }
2128
2995
  if (e.type === "quit") this.onSig();
2129
2996
  else if (e.type === "switch") this.switchBox(e.dir);
2130
2997
  else if (e.type === "action") {
@@ -2168,6 +3035,26 @@ var Compositor = class {
2168
3035
  leaderLingerTimer = null;
2169
3036
  /** Set while a destroy confirm is pending in the status bar. */
2170
3037
  pendingConfirm = null;
3038
+ /**
3039
+ * Per-box relay-prompt state. Populated by SSE `prompt-ask` events,
3040
+ * cleared by `prompt-resolved` events or by the local user answering.
3041
+ * The sidebar reads it to mark rows; drawChrome's status-line picker
3042
+ * reads it to swap to [!] mode when the SELECTED box is in this map.
3043
+ * Subscriptions are tracked separately in {@link promptStreams} so
3044
+ * we can dispose them when boxes disappear from the list.
3045
+ */
3046
+ activePrompts = /* @__PURE__ */ new Map();
3047
+ /**
3048
+ * Per-box active relay notice (currently: a checkpoint freezing the box).
3049
+ * Drives the `◆ checkpoint` sidebar cell and the animated status-bar
3050
+ * warning. Shares the SSE subscriptions in {@link promptStreams}.
3051
+ */
3052
+ activeNotices = /* @__PURE__ */ new Map();
3053
+ /** Monotonic spinner counter for the notice status bar. */
3054
+ noticeFrame = 0;
3055
+ /** Drives the spinner animation while {@link activeNotices} is non-empty. */
3056
+ noticeTimer = null;
3057
+ promptStreams = /* @__PURE__ */ new Map();
2171
3058
  activeMode = "claude";
2172
3059
  flashMsg = null;
2173
3060
  flashTimer = null;
@@ -2209,18 +3096,98 @@ var Compositor = class {
2209
3096
  if (!this.boxes.some((b) => b.id === this.selectedId) && this.boxes[0]) {
2210
3097
  this.selectedId = this.boxes[0].id;
2211
3098
  }
2212
- await this.spawnActive();
2213
- this.drawChrome();
2214
- this.scheduleRender();
2215
- this.pollTimer = setInterval(() => void this.poll(), POLL_MS);
2216
- await new Promise((resolve) => {
2217
- this.resolveDone = resolve;
2218
- });
3099
+ await this.spawnActive();
3100
+ this.drawChrome();
3101
+ this.scheduleRender();
3102
+ this.pollTimer = setInterval(() => void this.poll(), POLL_MS);
3103
+ await new Promise((resolve2) => {
3104
+ this.resolveDone = resolve2;
3105
+ });
3106
+ }
3107
+ async refreshBoxes() {
3108
+ try {
3109
+ this.boxes = await this.deps.listCandidates();
3110
+ } catch {
3111
+ }
3112
+ this.syncPromptSubscriptions();
3113
+ }
3114
+ /**
3115
+ * Diff the current box list against {@link promptStreams}: subscribe to
3116
+ * any newcomer (skipping the synthetic + New box entry and pre-relay
3117
+ * boxes), dispose any departed subscription. Idempotent — safe to call
3118
+ * after every poll. Disposed boxes also clear their {@link activePrompts}
3119
+ * entry so the sidebar marker doesn't linger.
3120
+ */
3121
+ syncPromptSubscriptions() {
3122
+ if (this.tornDown) return;
3123
+ const url = this.deps.relayBaseUrl;
3124
+ if (!url) return;
3125
+ const wanted = /* @__PURE__ */ new Set();
3126
+ for (const b of this.boxes) {
3127
+ if (b.id === NEW_BOX_ID) continue;
3128
+ wanted.add(b.id);
3129
+ }
3130
+ for (const [boxId, stream] of this.promptStreams) {
3131
+ if (!wanted.has(boxId)) {
3132
+ stream.close();
3133
+ this.promptStreams.delete(boxId);
3134
+ let changed = this.activePrompts.delete(boxId);
3135
+ if (this.activeNotices.delete(boxId)) changed = true;
3136
+ if (this.activeNotices.size === 0) this.stopNoticeSpinner();
3137
+ if (changed) this.drawChrome();
3138
+ }
3139
+ }
3140
+ for (const boxId of wanted) {
3141
+ if (this.promptStreams.has(boxId)) continue;
3142
+ const stream = subscribePrompts({
3143
+ relayBaseUrl: url,
3144
+ boxId,
3145
+ onPrompt: (ev) => {
3146
+ if (this.tornDown) return;
3147
+ this.activePrompts.set(boxId, ev);
3148
+ this.drawChrome();
3149
+ },
3150
+ onResolved: (id) => {
3151
+ if (this.tornDown) return;
3152
+ const current = this.activePrompts.get(boxId);
3153
+ if (current && current.id === id) {
3154
+ this.activePrompts.delete(boxId);
3155
+ this.drawChrome();
3156
+ }
3157
+ },
3158
+ onNotice: (ev) => {
3159
+ if (this.tornDown) return;
3160
+ this.activeNotices.set(boxId, ev);
3161
+ this.startNoticeSpinner();
3162
+ this.drawChrome();
3163
+ },
3164
+ onNoticeCleared: (id) => {
3165
+ if (this.tornDown) return;
3166
+ const current = this.activeNotices.get(boxId);
3167
+ if (current && current.id === id) {
3168
+ this.activeNotices.delete(boxId);
3169
+ if (this.activeNotices.size === 0) this.stopNoticeSpinner();
3170
+ this.drawChrome();
3171
+ }
3172
+ },
3173
+ onError: () => {
3174
+ }
3175
+ });
3176
+ this.promptStreams.set(boxId, stream);
3177
+ }
2219
3178
  }
2220
- async refreshBoxes() {
2221
- try {
2222
- this.boxes = await this.deps.listCandidates();
2223
- } catch {
3179
+ startNoticeSpinner() {
3180
+ if (this.noticeTimer) return;
3181
+ this.noticeTimer = setInterval(() => {
3182
+ this.noticeFrame++;
3183
+ this.drawChrome();
3184
+ }, NOTICE_SPINNER_MS);
3185
+ if (typeof this.noticeTimer.unref === "function") this.noticeTimer.unref();
3186
+ }
3187
+ stopNoticeSpinner() {
3188
+ if (this.noticeTimer) {
3189
+ clearInterval(this.noticeTimer);
3190
+ this.noticeTimer = null;
2224
3191
  }
2225
3192
  }
2226
3193
  selectedBox() {
@@ -2490,6 +3457,46 @@ var Compositor = class {
2490
3457
  this.busy = false;
2491
3458
  }
2492
3459
  }
3460
+ /**
3461
+ * Try to consume `bytes` as an answer to the selected box's active relay
3462
+ * prompt. Returns true when the bytes were a recognized answer key (the
3463
+ * caller stops further dispatch); false when the bytes should flow on to
3464
+ * the pty / other handlers.
3465
+ *
3466
+ * Single-byte chunks only: y/Y/Enter accept, n/N deny, Esc/Ctrl-c deny
3467
+ * with `cancelled: true`. Multi-byte chunks starting with ESC (mouse,
3468
+ * arrows, focus events, etc.) are passed through — exact same rule the
3469
+ * wrapped-pty input-router uses.
3470
+ */
3471
+ handlePromptKey(bytes) {
3472
+ if (bytes.length > 1 && bytes[0] === 27) return false;
3473
+ if (bytes.length === 0) return false;
3474
+ const b = bytes[0];
3475
+ let answer = null;
3476
+ let cancelled = false;
3477
+ if (b === 121 || b === 89) answer = "y";
3478
+ else if (b === 110 || b === 78) answer = "n";
3479
+ else if (b === 27 || b === 3) {
3480
+ answer = "n";
3481
+ cancelled = true;
3482
+ } else if (b === 13 || b === 10) {
3483
+ const ev2 = this.activePrompts.get(this.selectedId);
3484
+ answer = ev2?.defaultAnswer ?? "n";
3485
+ }
3486
+ if (answer === null) return false;
3487
+ const ev = this.activePrompts.get(this.selectedId);
3488
+ if (!ev) return false;
3489
+ this.activePrompts.delete(this.selectedId);
3490
+ this.drawChrome();
3491
+ const url = this.deps.relayBaseUrl;
3492
+ if (url) {
3493
+ void postAnswer({
3494
+ relayBaseUrl: url,
3495
+ body: { id: ev.id, answer, ...cancelled ? { cancelled: true } : {} }
3496
+ });
3497
+ }
3498
+ return true;
3499
+ }
2493
3500
  handleConfirmKey(bytes) {
2494
3501
  const c = this.pendingConfirm;
2495
3502
  if (!c) return;
@@ -2596,11 +3603,11 @@ var Compositor = class {
2596
3603
  /** Blank the right pane and drop the diff cache (next paint is full). */
2597
3604
  clearRightPane() {
2598
3605
  const r = this.layout.right;
2599
- let s = SYNC_BEGIN + "\x1B[?25l";
3606
+ let s = SYNC_BEGIN2 + "\x1B[?25l";
2600
3607
  for (let i = 0; i < r.h; i++) {
2601
3608
  s += cursorTo2(r.x, r.y + i) + "\x1B[0m" + " ".repeat(r.w);
2602
3609
  }
2603
- this.out.write(s + SYNC_END);
3610
+ this.out.write(s + SYNC_END2);
2604
3611
  this.prevRows = null;
2605
3612
  }
2606
3613
  scheduleRender() {
@@ -2620,12 +3627,12 @@ var Compositor = class {
2620
3627
  if (this.session) {
2621
3628
  const { out, rows } = diffFrame(this.prevRows, this.session.snapshot(), r);
2622
3629
  this.prevRows = rows;
2623
- if (out) this.out.write(SYNC_BEGIN + out + SYNC_END);
3630
+ if (out) this.out.write(SYNC_BEGIN2 + out + SYNC_END2);
2624
3631
  } else if (this.menu) {
2625
3632
  const lines = menuLines(this.menu.boxName, r.w, r.h);
2626
- let s = SYNC_BEGIN + "\x1B[?25l";
3633
+ let s = SYNC_BEGIN2 + "\x1B[?25l";
2627
3634
  for (let i = 0; i < r.h; i++) s += cursorTo2(r.x, r.y + i) + "\x1B[0m" + (lines[i] ?? "");
2628
- this.out.write(s + SYNC_END);
3635
+ this.out.write(s + SYNC_END2);
2629
3636
  } else if (this.lifecycleMenu) {
2630
3637
  const lines = lifecycleMenuLines(
2631
3638
  this.lifecycleMenu.boxName,
@@ -2634,40 +3641,52 @@ var Compositor = class {
2634
3641
  r.w,
2635
3642
  r.h
2636
3643
  );
2637
- let s = SYNC_BEGIN + "\x1B[?25l";
3644
+ let s = SYNC_BEGIN2 + "\x1B[?25l";
2638
3645
  for (let i = 0; i < r.h; i++) s += cursorTo2(r.x, r.y + i) + "\x1B[0m" + (lines[i] ?? "");
2639
- this.out.write(s + SYNC_END);
3646
+ this.out.write(s + SYNC_END2);
2640
3647
  } else if (this.createMenu) {
2641
3648
  const lines = createMenuLines(this.createMenu.where, r.w, r.h);
2642
- let s = SYNC_BEGIN + "\x1B[?25l";
3649
+ let s = SYNC_BEGIN2 + "\x1B[?25l";
2643
3650
  for (let i = 0; i < r.h; i++) s += cursorTo2(r.x, r.y + i) + "\x1B[0m" + (lines[i] ?? "");
2644
- this.out.write(s + SYNC_END);
3651
+ this.out.write(s + SYNC_END2);
2645
3652
  } else if (this.placeholder) {
2646
- let s = SYNC_BEGIN + "\x1B[?25l";
3653
+ let s = SYNC_BEGIN2 + "\x1B[?25l";
2647
3654
  for (let i = 0; i < r.h; i++) {
2648
3655
  const line = (this.placeholder[i] ?? "").slice(0, r.w);
2649
3656
  s += cursorTo2(r.x, r.y + i) + "\x1B[0m" + line + " ".repeat(Math.max(0, r.w - line.length));
2650
3657
  }
2651
- this.out.write(s + SYNC_END);
3658
+ this.out.write(s + SYNC_END2);
2652
3659
  }
2653
3660
  }
2654
3661
  drawChrome() {
2655
3662
  if (this.tornDown || this.layout.tooSmall) return;
2656
3663
  const { sidebar, sepX, statusY } = this.layout;
3664
+ const decorate = this.activePrompts.size > 0 || this.activeNotices.size > 0;
3665
+ const boxesWithPrompt = decorate ? this.boxes.map((b) => {
3666
+ const pendingPrompt = this.activePrompts.has(b.id);
3667
+ const checkpointing = this.activeNotices.has(b.id);
3668
+ return pendingPrompt || checkpointing ? { ...b, pendingPrompt, checkpointing } : b;
3669
+ }) : this.boxes;
2657
3670
  const { lines, rowOwner, headerRows } = sidebarLines(
2658
- this.boxes,
3671
+ boxesWithPrompt,
2659
3672
  this.selectedId,
2660
3673
  sidebar.w,
2661
3674
  sidebar.h
2662
3675
  );
2663
- let s = SYNC_BEGIN + "\x1B[0m";
3676
+ let s = SYNC_BEGIN2 + "\x1B[0m";
2664
3677
  for (let i = 0; i < lines.length; i++) {
2665
- const style = headerRows[i] ? SB_HEADER : rowOwner[i] === this.selectedId ? SB_SELECTED : SB_BODY;
3678
+ const owner = rowOwner[i] ?? null;
3679
+ const isSelected = owner === this.selectedId;
3680
+ const hasPrompt = owner !== null && this.activePrompts.has(owner);
3681
+ const ownerBox = owner !== null ? boxesWithPrompt.find((b) => b.id === owner) : void 0;
3682
+ const isAwaiting = ownerBox?.claudeActivity === "waiting";
3683
+ const style = headerRows[i] ? SB_HEADER : isSelected ? SB_SELECTED : hasPrompt ? SB_PROMPT : isAwaiting ? SB_AWAITING : SB_BODY;
2666
3684
  s += cursorTo2(0, i) + style + lines[i] + SGR_RESET;
2667
3685
  }
2668
3686
  for (let y = 0; y < sidebar.h; y++)
2669
3687
  s += cursorTo2(sepX, y) + SB_HEADER + (y === 0 ? "\u256E" : "\u2502") + SGR_RESET;
2670
3688
  let status;
3689
+ const activePromptForSelected = this.activePrompts.get(this.selectedId);
2671
3690
  if (this.pendingConfirm) {
2672
3691
  const w = this.layout.cols;
2673
3692
  const txt = ` Destroy ${this.pendingConfirm.name}? y = confirm \xB7 any other key = cancel `.slice(0, w).padEnd(w);
@@ -2676,6 +3695,17 @@ var Compositor = class {
2676
3695
  const w = this.layout.cols;
2677
3696
  const txt = ` ${this.flashMsg} `.slice(0, w).padEnd(w);
2678
3697
  status = `\x1B[7m${txt}\x1B[0m`;
3698
+ } else if (activePromptForSelected) {
3699
+ status = renderFooter(
3700
+ { kind: "prompt", prompt: activePromptForSelected },
3701
+ this.layout.cols
3702
+ );
3703
+ } else if (this.activeNotices.has(this.selectedId)) {
3704
+ const notice = this.activeNotices.get(this.selectedId);
3705
+ status = renderFooter(
3706
+ { kind: "notice", message: notice.message, frame: this.noticeFrame },
3707
+ this.layout.cols
3708
+ );
2679
3709
  } else {
2680
3710
  const stateLabel = this.selectedId === NEW_BOX_ID ? "create" : this.menu ? "menu" : this.session && this.activeMode === "shell" ? "shell" : void 0;
2681
3711
  status = statusLine(
@@ -2686,7 +3716,7 @@ var Compositor = class {
2686
3716
  );
2687
3717
  }
2688
3718
  s += cursorTo2(0, statusY) + status;
2689
- this.out.write(s + SYNC_END);
3719
+ this.out.write(s + SYNC_END2);
2690
3720
  }
2691
3721
  scheduleResize() {
2692
3722
  if (this.resizeTimer) clearTimeout(this.resizeTimer);
@@ -2698,7 +3728,7 @@ var Compositor = class {
2698
3728
  if (this.session && !this.layout.tooSmall) {
2699
3729
  this.session.resize(Math.max(1, r.w), Math.max(1, r.h));
2700
3730
  }
2701
- this.out.write(SYNC_BEGIN + "\x1B[2J" + SYNC_END);
3731
+ this.out.write(SYNC_BEGIN2 + "\x1B[2J" + SYNC_END2);
2702
3732
  this.drawChrome();
2703
3733
  this.render();
2704
3734
  }, RESIZE_DEBOUNCE_MS);
@@ -2711,6 +3741,11 @@ var Compositor = class {
2711
3741
  if (this.resizeTimer) clearTimeout(this.resizeTimer);
2712
3742
  if (this.flashTimer) clearTimeout(this.flashTimer);
2713
3743
  if (this.leaderLingerTimer) clearTimeout(this.leaderLingerTimer);
3744
+ if (this.noticeTimer) clearInterval(this.noticeTimer);
3745
+ for (const stream of this.promptStreams.values()) stream.close();
3746
+ this.promptStreams.clear();
3747
+ this.activePrompts.clear();
3748
+ this.activeNotices.clear();
2714
3749
  this.parser.dispose();
2715
3750
  this.disposeSession();
2716
3751
  this.inp.off("data", this.onData);
@@ -2748,29 +3783,23 @@ function toSidebar(b) {
2748
3783
  project: b.projectRoot
2749
3784
  };
2750
3785
  }
2751
- var dashboardCommand = new Command7("dashboard").description("Box list + the selected box live Agent session").argument("[box]", "initial box (default: first running box; -p restricts to the cwd project)").option("-p, --project", "only this project's boxes (default: all boxes globally)").action(async (idOrName, opts) => {
3786
+ var dashboardCommand = new Command8("dashboard").description("Box list + the selected box live Agent session").argument("[box]", "initial box (default: first running box; -p restricts to the cwd project)").option("-p, --project", "only this project's boxes (default: all boxes globally)").action(async (idOrName, opts) => {
2752
3787
  try {
2753
3788
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
2754
- log9.error("agentbox dashboard needs an interactive terminal");
3789
+ log10.error("agentbox dashboard needs an interactive terminal");
2755
3790
  process.exit(2);
2756
3791
  }
3792
+ const backend = await loadPtyBackend();
2757
3793
  let ptySpawn;
2758
3794
  let termCtor;
2759
- try {
2760
- const ptyMod = await import("@homebridge/node-pty-prebuilt-multiarch");
2761
- const xtermMod = await import("@xterm/headless");
2762
- const spawn5 = ptyMod["spawn"] ?? ptyMod["default"]?.["spawn"];
2763
- const Terminal = xtermMod["Terminal"] ?? xtermMod["default"]?.["Terminal"];
2764
- if (typeof spawn5 !== "function" || typeof Terminal !== "function") {
2765
- throw new Error("terminal backend missing expected exports");
2766
- }
2767
- ptySpawn = spawn5;
2768
- termCtor = Terminal;
2769
- } catch {
2770
- log9.error(
3795
+ if (backend) {
3796
+ ptySpawn = backend.ptySpawn;
3797
+ termCtor = backend.termCtor;
3798
+ } else {
3799
+ log10.error(
2771
3800
  "agentbox dashboard is unavailable here (native terminal backend failed to load)"
2772
3801
  );
2773
- log9.info("use `agentbox claude` / `agentbox claude attach` instead");
3802
+ log10.info("use `agentbox claude` / `agentbox claude attach` instead");
2774
3803
  process.exit(2);
2775
3804
  }
2776
3805
  const project = await findProjectRoot(process.cwd());
@@ -2829,6 +3858,11 @@ var dashboardCommand = new Command7("dashboard").description("Box list + the sel
2829
3858
  await rebuildPluginNativeDeps(box.container, {
2830
3859
  volume: box.claudeConfigVolume
2831
3860
  });
3861
+ const claudeVolume = box.claudeConfigVolume ?? SHARED_CLAUDE_VOLUME;
3862
+ await syncClaudeCredentials(
3863
+ { volume: claudeVolume },
3864
+ { image: box.image, isolate: claudeVolume !== SHARED_CLAUDE_VOLUME }
3865
+ );
2832
3866
  await startClaudeSession({ container: box.container, claudeArgs: [], boxName: box.name });
2833
3867
  const info = await claudeSessionInfo(box.container);
2834
3868
  return {
@@ -2945,6 +3979,10 @@ var dashboardCommand = new Command7("dashboard").description("Box list + the sel
2945
3979
  {
2946
3980
  ptySpawn,
2947
3981
  termCtor,
3982
+ // Host-side loopback URL the per-box SSE subscriptions connect to.
3983
+ // The relay binds 0.0.0.0; loopback is the admin/* path's required
3984
+ // source. Same constant the wrapped-pty wrappers use.
3985
+ relayBaseUrl: `http://127.0.0.1:${String(DEFAULT_RELAY_PORT)}`,
2948
3986
  listCandidates,
2949
3987
  resolveTarget,
2950
3988
  startClaude,
@@ -2968,48 +4006,369 @@ var dashboardCommand = new Command7("dashboard").description("Box list + the sel
2968
4006
  });
2969
4007
 
2970
4008
  // src/commands/destroy.ts
2971
- import { confirm as confirm4, isCancel as isCancel4, log as log10 } from "@clack/prompts";
2972
- import { Command as Command8 } from "commander";
2973
- var destroyCommand = new Command8("destroy").alias("rm").description("Destroy a box and discard its upper volume").argument(
4009
+ import { confirm as confirm4, isCancel as isCancel4, log as log11 } from "@clack/prompts";
4010
+ import { Command as Command9 } from "commander";
4011
+ var destroyCommand = new Command9("destroy").alias("rm").description("Destroy a box and discard its container writable layer (where /workspace lived)").argument(
2974
4012
  "[box]",
2975
4013
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
2976
4014
  ).option("-y, --yes", "skip the confirmation prompt").option("--keep-snapshot", "don't delete the snapshot dir under ~/.agentbox/snapshots/").action(async (idOrName, opts) => {
2977
4015
  try {
2978
4016
  const box = await resolveBoxOrExit(idOrName);
2979
4017
  if (!opts.yes) {
2980
- log10.warn(`This will discard the upper volume \u2014 agent work-in-progress is lost.`);
2981
- log10.info(`id: ${box.id}`);
2982
- log10.info(`container: ${box.container}`);
2983
- log10.info(`upper: ${box.upperVolume}`);
2984
- if (box.snapshotDir) {
2985
- log10.info(`snapshot: ${box.snapshotDir}${opts.keepSnapshot ? " (will be kept)" : ""}`);
2986
- }
2987
- const ok = await confirm4({
2988
- message: "Destroy this box?",
4018
+ log11.warn(
4019
+ "This will wipe the container writable layer \u2014 /workspace contents and agent work-in-progress are lost."
4020
+ );
4021
+ log11.info(`id: ${box.id}`);
4022
+ log11.info(`container: ${box.container}`);
4023
+ if (box.snapshotDir) {
4024
+ log11.info(`snapshot: ${box.snapshotDir}${opts.keepSnapshot ? " (will be kept)" : ""}`);
4025
+ }
4026
+ const ok = await confirm4({
4027
+ message: "Destroy this box?",
4028
+ initialValue: false
4029
+ });
4030
+ if (isCancel4(ok) || !ok) {
4031
+ log11.info("cancelled");
4032
+ return;
4033
+ }
4034
+ }
4035
+ const result = await destroyBox(box.id, { keepSnapshot: opts.keepSnapshot });
4036
+ const out = [`destroyed ${result.record.container}`];
4037
+ if (result.removedContainer) out.push(" \u2713 container removed");
4038
+ out.push(` \u2713 volumes removed: ${result.removedVolumes.join(", ")}`);
4039
+ if (result.removedSnapshot) out.push(` \u2713 snapshot removed: ${result.removedSnapshot}`);
4040
+ else if (box.snapshotDir && opts.keepSnapshot) {
4041
+ out.push(` \xB7 snapshot kept: ${box.snapshotDir}`);
4042
+ }
4043
+ process.stdout.write(out.join("\n") + "\n");
4044
+ } catch (err) {
4045
+ handleLifecycleError(err);
4046
+ }
4047
+ });
4048
+
4049
+ // src/commands/download.ts
4050
+ import { confirm as confirm8, isCancel as isCancel8, log as log15 } from "@clack/prompts";
4051
+ import { Command as Command13 } from "commander";
4052
+
4053
+ // src/commands/download-claude.ts
4054
+ import { confirm as confirm5, isCancel as isCancel5, log as log12 } from "@clack/prompts";
4055
+ import { Command as Command10 } from "commander";
4056
+ function tag(item) {
4057
+ const noun = item.category === "plugins" ? "plugin" : item.category.replace(/s$/, "");
4058
+ return ` ${item.category}/${item.name} (new ${noun})`;
4059
+ }
4060
+ var downloadClaudeCommand = new Command10("claude").description(
4061
+ "Download box-installed Claude skills/plugins/agents/commands back to host ~/.claude (additive)"
4062
+ ).argument(
4063
+ "[box]",
4064
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4065
+ ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list new items and exit; don't write").action(async (idOrName, opts) => {
4066
+ try {
4067
+ const box = await resolveBoxOrExit(idOrName);
4068
+ const volume = box.claudeConfigVolume ?? resolveClaudeVolume({ isolate: false, boxId: box.id }).volume;
4069
+ if (volume === SHARED_CLAUDE_VOLUME) {
4070
+ log12.warn(
4071
+ `Reading the shared ${SHARED_CLAUDE_VOLUME} volume \u2014 it aggregates Claude extensions installed in ANY box, not just ${box.name}.`
4072
+ );
4073
+ }
4074
+ const image = box.image || DEFAULT_BOX_IMAGE;
4075
+ const preview = await pullClaudeExtras({ volume }, { image, dryRun: true });
4076
+ if (preview.newItems.length === 0 && preview.mergedRegistries.length === 0) {
4077
+ process.stdout.write("no new Claude extensions to download into ~/.claude\n");
4078
+ return;
4079
+ }
4080
+ for (const item of preview.newItems) process.stdout.write(`${tag(item)}
4081
+ `);
4082
+ for (const reg of preview.mergedRegistries) {
4083
+ process.stdout.write(` plugins/${reg} (merge new entries)
4084
+ `);
4085
+ }
4086
+ if (opts.dryRun) {
4087
+ process.stdout.write(
4088
+ `
4089
+ [dry-run] ${preview.newItems.length} item(s)${preview.mergedRegistries.length > 0 ? ` + ${preview.mergedRegistries.length} registry merge(s)` : ""} would be downloaded into ~/.claude
4090
+ `
4091
+ );
4092
+ return;
4093
+ }
4094
+ if (!opts.yes) {
4095
+ const ok = await confirm5({
4096
+ message: `Download ${preview.newItems.length} new Claude extension(s) into ~/.claude? (existing items are never overwritten)`,
4097
+ initialValue: false
4098
+ });
4099
+ if (isCancel5(ok) || !ok) {
4100
+ log12.info("cancelled");
4101
+ return;
4102
+ }
4103
+ }
4104
+ const result = await pullClaudeExtras({ volume }, { image, dryRun: false });
4105
+ process.stdout.write(
4106
+ `downloaded ${result.newItems.length} extension(s)${result.mergedRegistries.length > 0 ? `, merged ${result.mergedRegistries.join(", ")}` : ""} into ~/.claude
4107
+ `
4108
+ );
4109
+ } catch (err) {
4110
+ handleLifecycleError(err);
4111
+ }
4112
+ });
4113
+
4114
+ // src/commands/download-config.ts
4115
+ import { confirm as confirm6, isCancel as isCancel6, log as log13 } from "@clack/prompts";
4116
+ import { Command as Command11 } from "commander";
4117
+ function tagChange(line) {
4118
+ const sp = line.indexOf(" ");
4119
+ const code = sp === -1 ? line : line.slice(0, sp);
4120
+ const path2 = sp === -1 ? "" : line.slice(sp + 1);
4121
+ const isNew = /^>f\++$/.test(code);
4122
+ return ` ${path2} ${isNew ? "(new)" : "(overwrites host)"}`;
4123
+ }
4124
+ var CONFIG_PATTERNS = ["agentbox.yaml"];
4125
+ var downloadConfigCommand = new Command11("config").description("Download agentbox.yaml box -> host").argument(
4126
+ "[box]",
4127
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4128
+ ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list matched files and exit; don't write").option("--no-refresh", "skip the box->scratch-dir rsync step").action(async (idOrName, opts) => {
4129
+ try {
4130
+ const box = await resolveBoxOrExit(idOrName);
4131
+ const insp = await inspectBox(box.id);
4132
+ if (insp.state === "paused") {
4133
+ log13.info("box is paused; unpausing");
4134
+ await unpauseBox(box.id);
4135
+ } else if (insp.state === "stopped") {
4136
+ log13.info("box is stopped; starting");
4137
+ await startBox(box.id);
4138
+ } else if (insp.state === "missing") {
4139
+ throw new Error(`box ${box.name} has no container; was it destroyed?`);
4140
+ }
4141
+ log13.info(`agentbox.yaml bypasses gitignore and copies directly into ${box.workspacePath}`);
4142
+ const preview = await pullToHost(box, {
4143
+ dryRun: true,
4144
+ respectGitignore: false,
4145
+ envPatterns: CONFIG_PATTERNS,
4146
+ noRefresh: !opts.refresh
4147
+ });
4148
+ if (preview.changes.length === 0) {
4149
+ process.stdout.write(`no config file to download into ${box.workspacePath}
4150
+ `);
4151
+ return;
4152
+ }
4153
+ for (const line of preview.changes) process.stdout.write(`${tagChange(line)}
4154
+ `);
4155
+ if (opts.dryRun) {
4156
+ process.stdout.write(
4157
+ `
4158
+ [dry-run] ${preview.changes.length} config file(s) would change in ${box.workspacePath}
4159
+ `
4160
+ );
4161
+ return;
4162
+ }
4163
+ if (!opts.yes) {
4164
+ const ok = await confirm6({
4165
+ message: `Download ${preview.changes.length} config file(s) into ${box.workspacePath}? (existing files will be overwritten)`,
4166
+ initialValue: false
4167
+ });
4168
+ if (isCancel6(ok) || !ok) {
4169
+ log13.info("cancelled");
4170
+ return;
4171
+ }
4172
+ }
4173
+ const result = await pullToHost(box, {
4174
+ dryRun: false,
4175
+ respectGitignore: false,
4176
+ envPatterns: CONFIG_PATTERNS,
4177
+ // The dry-run pass above already refreshed (or intentionally skipped)
4178
+ // the scratch dir — don't rsync box->scratch a second time.
4179
+ noRefresh: true
4180
+ });
4181
+ process.stdout.write(
4182
+ `downloaded ${result.changes.length} config file(s) into ${result.hostPath}
4183
+ `
4184
+ );
4185
+ } catch (err) {
4186
+ handleLifecycleError(err);
4187
+ }
4188
+ });
4189
+
4190
+ // src/commands/download-env.ts
4191
+ import { confirm as confirm7, isCancel as isCancel7, log as log14 } from "@clack/prompts";
4192
+ import { Command as Command12 } from "commander";
4193
+ function tagChange2(line) {
4194
+ const sp = line.indexOf(" ");
4195
+ const code = sp === -1 ? line : line.slice(0, sp);
4196
+ const path2 = sp === -1 ? "" : line.slice(sp + 1);
4197
+ const isNew = /^>f\++$/.test(code);
4198
+ return ` ${path2} ${isNew ? "(new)" : "(overwrites host)"}`;
4199
+ }
4200
+ var downloadEnvCommand = new Command12("env").description(
4201
+ "Download gitignored env/config files (.env*, .envrc, secrets.toml, agentbox.yaml, ...) box -> host"
4202
+ ).argument(
4203
+ "[box]",
4204
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4205
+ ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list matched files and exit; don't write").option(
4206
+ "--pattern <glob>",
4207
+ "extra basename glob to match (repeatable, adds to defaults)",
4208
+ (v, acc) => [...acc, v],
4209
+ []
4210
+ ).option("--no-refresh", "skip the box->scratch-dir rsync step").action(async (idOrName, opts) => {
4211
+ try {
4212
+ const box = await resolveBoxOrExit(idOrName);
4213
+ const insp = await inspectBox(box.id);
4214
+ if (insp.state === "paused") {
4215
+ log14.info("box is paused; unpausing");
4216
+ await unpauseBox(box.id);
4217
+ } else if (insp.state === "stopped") {
4218
+ log14.info("box is stopped; starting");
4219
+ await startBox(box.id);
4220
+ } else if (insp.state === "missing") {
4221
+ throw new Error(`box ${box.name} has no container; was it destroyed?`);
4222
+ }
4223
+ log14.info(
4224
+ `env/config files bypass gitignore and copy directly into ${box.workspacePath}`
4225
+ );
4226
+ const patterns = [...DEFAULT_ENV_PATTERNS, ...opts.pattern];
4227
+ const preview = await pullToHost(box, {
4228
+ dryRun: true,
4229
+ respectGitignore: false,
4230
+ envPatterns: patterns,
4231
+ noRefresh: !opts.refresh
4232
+ });
4233
+ if (preview.changes.length === 0) {
4234
+ process.stdout.write(`no env/config files to download into ${box.workspacePath}
4235
+ `);
4236
+ return;
4237
+ }
4238
+ for (const line of preview.changes) process.stdout.write(`${tagChange2(line)}
4239
+ `);
4240
+ if (opts.dryRun) {
4241
+ process.stdout.write(
4242
+ `
4243
+ [dry-run] ${preview.changes.length} env/config file(s) would change in ${box.workspacePath}
4244
+ `
4245
+ );
4246
+ return;
4247
+ }
4248
+ if (!opts.yes) {
4249
+ const ok = await confirm7({
4250
+ message: `Download ${preview.changes.length} env/config file(s) into ${box.workspacePath}? (existing files will be overwritten)`,
4251
+ initialValue: false
4252
+ });
4253
+ if (isCancel7(ok) || !ok) {
4254
+ log14.info("cancelled");
4255
+ return;
4256
+ }
4257
+ }
4258
+ const result = await pullToHost(box, {
4259
+ dryRun: false,
4260
+ respectGitignore: false,
4261
+ envPatterns: patterns,
4262
+ // The dry-run pass above already refreshed (or intentionally skipped)
4263
+ // the scratch dir — don't rsync box->scratch a second time.
4264
+ noRefresh: true
4265
+ });
4266
+ process.stdout.write(
4267
+ `downloaded ${result.changes.length} env/config file(s) into ${result.hostPath}
4268
+ `
4269
+ );
4270
+ } catch (err) {
4271
+ handleLifecycleError(err);
4272
+ }
4273
+ });
4274
+
4275
+ // src/commands/download.ts
4276
+ var downloadCommand = new Command13("download").enablePositionalOptions().description("Download a box's /workspace back into your host workspace dir (gitignore-aware)").argument(
4277
+ "[box]",
4278
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4279
+ ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "print the change list and exit; don't write").option(
4280
+ "--no-respect-gitignore",
4281
+ "disable git ls-files mode; use --exclude=node_modules,.git instead"
4282
+ ).option(
4283
+ "--include-node-modules",
4284
+ "do not exclude node_modules in fallback mode (no effect in gitignore mode)"
4285
+ ).option("--no-refresh", "skip the box->scratch-dir rsync step (use whatever's already there)").option(
4286
+ "--with-env",
4287
+ "also download env/config files (.env*, .envrc, secrets.toml, agentbox.yaml, ...) ignoring gitignore"
4288
+ ).option(
4289
+ "--pattern <glob>",
4290
+ "extra env basename glob; only effective with --with-env (repeatable)",
4291
+ (v, acc) => [...acc, v],
4292
+ []
4293
+ ).action(async (idOrName, opts) => {
4294
+ try {
4295
+ const box = await resolveBoxOrExit(idOrName);
4296
+ const insp = await inspectBox(box.id);
4297
+ if (insp.state === "paused") {
4298
+ log15.info("box is paused; unpausing");
4299
+ await unpauseBox(box.id);
4300
+ } else if (insp.state === "stopped") {
4301
+ log15.info("box is stopped; starting");
4302
+ await startBox(box.id);
4303
+ } else if (insp.state === "missing") {
4304
+ throw new Error(`box ${box.name} has no container; was it destroyed?`);
4305
+ }
4306
+ const rootWorktree = box.gitWorktrees?.find((w) => w.kind === "root");
4307
+ if (rootWorktree) {
4308
+ log15.warn(
4309
+ `This box has been committing to branch \`${rootWorktree.branch}\` in a separate worktree.
4310
+ For a git-aware merge instead of a file copy, run from your checkout:
4311
+ git merge ${rootWorktree.branch}
4312
+ Continuing with rsync into ${box.workspacePath}`
4313
+ );
4314
+ }
4315
+ const envPatterns = opts.withEnv ? [...DEFAULT_ENV_PATTERNS, ...opts.pattern] : void 0;
4316
+ const preview = await pullToHost(box, {
4317
+ dryRun: true,
4318
+ respectGitignore: opts.respectGitignore,
4319
+ includeNodeModules: opts.includeNodeModules,
4320
+ envPatterns,
4321
+ noRefresh: !opts.refresh
4322
+ });
4323
+ if (preview.changes.length === 0) {
4324
+ process.stdout.write(`no changes to download into ${box.workspacePath}
4325
+ `);
4326
+ return;
4327
+ }
4328
+ if (opts.dryRun) {
4329
+ for (const line of preview.changes) process.stdout.write(`${line}
4330
+ `);
4331
+ process.stdout.write(
4332
+ `
4333
+ [dry-run] ${preview.changes.length} file(s) would change in ${box.workspacePath}
4334
+ `
4335
+ );
4336
+ return;
4337
+ }
4338
+ if (!opts.yes) {
4339
+ const ok = await confirm8({
4340
+ message: `Download ${preview.changes.length} changed file(s)${opts.withEnv ? " (incl. env/config)" : ""} into ${box.workspacePath}?`,
2989
4341
  initialValue: false
2990
4342
  });
2991
- if (isCancel4(ok) || !ok) {
2992
- log10.info("cancelled");
4343
+ if (isCancel8(ok) || !ok) {
4344
+ log15.info("cancelled");
2993
4345
  return;
2994
4346
  }
2995
4347
  }
2996
- const result = await destroyBox(box.id, { keepSnapshot: opts.keepSnapshot });
2997
- const out = [`destroyed ${result.record.container}`];
2998
- if (result.removedContainer) out.push(" \u2713 container removed");
2999
- out.push(` \u2713 volumes removed: ${result.removedVolumes.join(", ")}`);
3000
- if (result.removedSnapshot) out.push(` \u2713 snapshot removed: ${result.removedSnapshot}`);
3001
- else if (box.snapshotDir && opts.keepSnapshot) {
3002
- out.push(` \xB7 snapshot kept: ${box.snapshotDir}`);
3003
- }
3004
- process.stdout.write(out.join("\n") + "\n");
4348
+ const result = await pullToHost(box, {
4349
+ dryRun: false,
4350
+ respectGitignore: opts.respectGitignore,
4351
+ includeNodeModules: opts.includeNodeModules,
4352
+ envPatterns,
4353
+ // The dry-run pass above already refreshed (or intentionally skipped)
4354
+ // the scratch dir — don't rsync box->scratch a second time.
4355
+ noRefresh: true
4356
+ });
4357
+ process.stdout.write(
4358
+ `updated ${result.changes.length} file(s) in ${result.hostPath}${result.usedGitignore ? "" : " (exclude-list mode)"}
4359
+ `
4360
+ );
3005
4361
  } catch (err) {
3006
4362
  handleLifecycleError(err);
3007
4363
  }
3008
4364
  });
4365
+ downloadCommand.addCommand(downloadEnvCommand);
4366
+ downloadCommand.addCommand(downloadClaudeCommand);
4367
+ downloadCommand.addCommand(downloadConfigCommand);
3009
4368
 
3010
4369
  // src/commands/list.ts
3011
- import { log as log11 } from "@clack/prompts";
3012
- import { Command as Command9 } from "commander";
4370
+ import { log as log16 } from "@clack/prompts";
4371
+ import { Command as Command14 } from "commander";
3013
4372
  import { pathToFileURL } from "url";
3014
4373
 
3015
4374
  // src/hyperlink.ts
@@ -3094,11 +4453,11 @@ function urlCell(box, stream) {
3094
4453
  width: parts.reduce((a, p) => a + p.width, 0) + sep.length * (parts.length - 1)
3095
4454
  };
3096
4455
  }
3097
- function workspaceCell(path, target, stream) {
3098
- const display = middleTruncate(path, target);
4456
+ function workspaceCell(path2, target, stream) {
4457
+ const display = middleTruncate(path2, target);
3099
4458
  let url;
3100
4459
  try {
3101
- url = pathToFileURL(path).href;
4460
+ url = pathToFileURL(path2).href;
3102
4461
  } catch {
3103
4462
  return { text: display, width: display.length };
3104
4463
  }
@@ -3136,579 +4495,332 @@ function renderTable(boxes, stream) {
3136
4495
  };
3137
4496
  return all.map(
3138
4497
  (row2) => row2.map((cell, i) => padCell(cell ?? plain(""), i)).join(" ").trimEnd()
3139
- ).join("\n");
3140
- }
3141
- async function scopedBoxes(all) {
3142
- const boxes = await listBoxes();
3143
- if (all) return { boxes, projectRoot: "", scoped: false };
3144
- const { root } = await findProjectRoot(process.cwd());
3145
- return { boxes: boxes.filter((b) => b.projectRoot === root), projectRoot: root, scoped: true };
3146
- }
3147
- async function buildListText(all) {
3148
- const { boxes, projectRoot, scoped: scoped2 } = await scopedBoxes(all);
3149
- if (boxes.length === 0) {
3150
- if (scoped2) {
3151
- return `no boxes in this project (${projectRoot}) \u2014 run \`agentbox create\`, or \`agentbox list --all\` to see all`;
3152
- }
3153
- return "no boxes \u2014 run `agentbox create` to make one";
3154
- }
3155
- return renderTable(boxes, process.stdout);
3156
- }
3157
- var listCommand2 = withWatchOptions(
3158
- new Command9("list").alias("ls").description("List agent boxes in the current project (-a for all)").option("-j, --json", "machine-readable JSON output").option("-a, --all", "include boxes from all projects")
3159
- ).action(async (opts) => {
3160
- if (opts.json && opts.watch) {
3161
- log11.error("cannot combine --json with --watch");
3162
- process.exit(2);
3163
- }
3164
- const all = opts.all ?? false;
3165
- if (opts.watch) {
3166
- await watchRender(() => buildListText(all), opts.interval);
3167
- return;
3168
- }
3169
- if (opts.json) {
3170
- const { boxes } = await scopedBoxes(all);
3171
- process.stdout.write(JSON.stringify(boxes, null, 2) + "\n");
3172
- return;
3173
- }
3174
- process.stdout.write(await buildListText(all) + "\n");
3175
- });
3176
-
3177
- // src/commands/logs.ts
3178
- import { log as log12 } from "@clack/prompts";
3179
- import { Command as Command10 } from "commander";
3180
- import { spawn as spawn3 } from "child_process";
3181
- var logsCommand = new Command10("logs").description("Print recent log lines from a box service; -f to stream").argument(
3182
- "[box]",
3183
- "box ref (optional when cwd has exactly 1 box): project index, id, id prefix, name, or container"
3184
- ).argument("[service]", "service name from agentbox.yaml").option("-n, --tail <n>", "how many recent lines to print first", "200").option("-f, --follow", "keep the connection open and stream new lines").action(async (boxArg, serviceArg, opts) => {
3185
- try {
3186
- let idOrName;
3187
- let service;
3188
- if (serviceArg !== void 0) {
3189
- idOrName = boxArg;
3190
- service = serviceArg;
3191
- } else {
3192
- idOrName = void 0;
3193
- service = boxArg;
3194
- }
3195
- if (!service) {
3196
- log12.error("missing <service> argument");
3197
- log12.info("usage: agentbox logs [box] <service> [-n N] [-f]");
3198
- process.exit(2);
3199
- }
3200
- const box = await resolveBoxOrExit(idOrName);
3201
- const tail = String(Number.parseInt(opts.tail, 10) || 200);
3202
- const args = ["agentbox-ctl", "logs", service, "--tail", tail];
3203
- if (opts.follow) args.push("--follow");
3204
- if (!opts.follow) {
3205
- const proc = await execInBox(box.container, args, { user: "vscode" });
3206
- if (proc.exitCode !== 0) {
3207
- log12.error(`agentbox-ctl logs failed: ${proc.stderr || proc.stdout}`);
3208
- process.exit(1);
3209
- }
3210
- process.stdout.write(proc.stdout);
3211
- if (!proc.stdout.endsWith("\n")) process.stdout.write("\n");
3212
- return;
3213
- }
3214
- const child = spawn3("docker", ["exec", "--user", "vscode", box.container, ...args], {
3215
- stdio: ["ignore", "inherit", "inherit"]
3216
- });
3217
- child.on("exit", (code) => process.exit(code ?? 0));
3218
- } catch (err) {
3219
- handleLifecycleError(err);
3220
- }
3221
- });
3222
-
3223
- // src/commands/open.ts
3224
- import { log as log13 } from "@clack/prompts";
3225
- import { Command as Command11 } from "commander";
3226
-
3227
- // src/commands/path.ts
3228
- async function runPath(box, opts) {
3229
- try {
3230
- const layer = opts.upper ? "upper" : "merged";
3231
- const { record, paths } = await getBoxHostPaths(box.id);
3232
- if (opts.refresh) {
3233
- const refreshed = await refreshExport(record, {
3234
- layer,
3235
- includeNodeModules: opts.includeNodeModules
3236
- });
3237
- process.stdout.write(`${refreshed.hostPath}
3238
- `);
3239
- return;
3240
- }
3241
- const path = layer === "upper" ? paths.upperLiveOnHost ?? paths.upperExport : paths.mergedExport;
3242
- process.stdout.write(`${path}
3243
- `);
3244
- } catch (err) {
3245
- handleLifecycleError(err);
3246
- }
3247
- }
3248
-
3249
- // src/commands/open.ts
3250
- var openCommand = new Command11("open").description("Open a box's merged workspace in Finder (snapshot of the agent's view)").argument(
3251
- "[box]",
3252
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3253
- ).option("--upper", "open just the writes layer (live on OrbStack, snapshot on Docker Desktop)").option("--no-refresh", "skip the rsync; open whatever's already on disk").option(
3254
- "--include-node-modules",
3255
- "include /workspace/node_modules in the merged export (off by default)"
3256
- ).option("--path", "print the host workspace path instead of launching Finder").option("--print", "alias of --path").action(async (idOrName, opts) => {
3257
- try {
3258
- const box = await resolveBoxOrExit(idOrName);
3259
- if (opts.path || opts.print) {
3260
- await runPath(box, {
3261
- upper: opts.upper,
3262
- refresh: opts.refresh,
3263
- // print refreshes by default; --no-refresh skips
3264
- includeNodeModules: opts.includeNodeModules
3265
- });
3266
- return;
3267
- }
3268
- const layer = opts.upper ? "upper" : "merged";
3269
- const result = await openBoxInFinder(box.id, {
3270
- layer,
3271
- includeNodeModules: opts.includeNodeModules,
3272
- noRefresh: !opts.refresh,
3273
- noOpen: false
3274
- });
3275
- const liveNote = !result.copied ? " (live)" : result.usedFallback ? " (tar fallback)" : "";
3276
- process.stdout.write(`opened ${result.hostPath}${liveNote}
3277
- `);
3278
- if (opts.upper && result.engine !== "orbstack" && result.copied) {
3279
- log13.info(
3280
- "Tip: live upper-layer browsing requires OrbStack. Re-run `agentbox open --upper` to refresh."
3281
- );
3282
- }
3283
- } catch (err) {
3284
- handleLifecycleError(err);
3285
- }
3286
- });
3287
-
3288
- // src/commands/pause.ts
3289
- import { Command as Command12 } from "commander";
3290
- var pauseCommand = new Command12("pause").description("Freeze a box (docker pause \u2014 0 CPU, RAM stays mapped)").argument(
3291
- "[box]",
3292
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3293
- ).action(async (idOrName) => {
3294
- try {
3295
- const box = await resolveBoxOrExit(idOrName);
3296
- const record = await pauseBox(box.id);
3297
- process.stdout.write(`paused ${record.container}
3298
- `);
3299
- } catch (err) {
3300
- handleLifecycleError(err);
3301
- }
3302
- });
3303
-
3304
- // src/commands/prune.ts
3305
- import { confirm as confirm5, isCancel as isCancel5, log as log14 } from "@clack/prompts";
3306
- import { Command as Command13 } from "commander";
3307
- function totalRemovals(r, projectConfigs) {
3308
- return r.removedRecords.length + r.removedContainers.length + r.removedVolumes.length + r.removedSnapshotDirs.length + r.removedBoxDirs.length + projectConfigs.length;
3309
- }
3310
- function summary(r, projectConfigs) {
3311
- const lines = [];
3312
- if (r.removedRecords.length > 0) {
3313
- lines.push(
3314
- ` state records (${String(r.removedRecords.length)}): ${r.removedRecords.join(", ")}`
3315
- );
3316
- }
3317
- if (r.removedContainers.length > 0) {
3318
- lines.push(
3319
- ` containers (${String(r.removedContainers.length)}): ${r.removedContainers.join(", ")}`
3320
- );
3321
- }
3322
- if (r.removedVolumes.length > 0) {
3323
- lines.push(
3324
- ` volumes (${String(r.removedVolumes.length)}): ${r.removedVolumes.join(", ")}`
3325
- );
3326
- }
3327
- if (r.removedSnapshotDirs.length > 0) {
3328
- lines.push(
3329
- ` snapshot dirs (${String(r.removedSnapshotDirs.length)}): ${r.removedSnapshotDirs.join(", ")}`
3330
- );
3331
- }
3332
- if (r.removedBoxDirs.length > 0) {
3333
- lines.push(
3334
- ` box dirs (${String(r.removedBoxDirs.length)}): ${r.removedBoxDirs.join(", ")}`
3335
- );
3336
- }
3337
- if (projectConfigs.length > 0) {
3338
- lines.push(
3339
- ` project configs (${String(projectConfigs.length)}): ${projectConfigs.join(", ")}`
3340
- );
3341
- }
3342
- return lines.length > 0 ? lines.join("\n") : " (nothing to remove)";
4498
+ ).join("\n");
3343
4499
  }
3344
- async function liveProjectRoots() {
3345
- try {
3346
- const boxes = await listBoxes();
3347
- return boxes.map((b) => b.projectRoot).filter((p) => typeof p === "string");
3348
- } catch {
3349
- return [];
3350
- }
4500
+ async function scopedBoxes(all) {
4501
+ const boxes = await listBoxes();
4502
+ if (all) return { boxes, projectRoot: "", scoped: false };
4503
+ const { root } = await findProjectRoot(process.cwd());
4504
+ return { boxes: boxes.filter((b) => b.projectRoot === root), projectRoot: root, scoped: true };
3351
4505
  }
3352
- var pruneCommand = new Command13("prune").description("Clean up orphan state.json records (and with --all, orphan docker resources)").option("--dry-run", "show what would be removed, don't change anything").option(
3353
- "--all",
3354
- "also remove orphan agentbox-* containers, volumes, snapshot dirs, and orphan per-project config dirs"
3355
- ).option("-y, --yes", "skip the confirmation prompt").action(async (opts) => {
3356
- try {
3357
- const dryRun = opts.dryRun ?? false;
3358
- const protectedPaths = opts.all ? await liveProjectRoots() : [];
3359
- const preview = await pruneBoxes({ dryRun: true, all: opts.all });
3360
- const previewProjects = opts.all ? (await pruneOrphanProjectConfigs({ dryRun: true, protectedPaths })).removed.map(
3361
- (r) => r.originalPath
3362
- ) : [];
3363
- if (totalRemovals(preview, previewProjects) === 0) {
3364
- process.stdout.write("nothing to prune\n");
3365
- return;
3366
- }
3367
- log14.info(`would remove:
3368
- ${summary(preview, previewProjects)}`);
3369
- if (dryRun) return;
3370
- if (!opts.yes) {
3371
- const ok = await confirm5({ message: "Proceed with prune?", initialValue: true });
3372
- if (isCancel5(ok) || !ok) {
3373
- log14.info("cancelled");
3374
- return;
3375
- }
4506
+ async function buildListText(all) {
4507
+ const { boxes, projectRoot, scoped: scoped2 } = await scopedBoxes(all);
4508
+ if (boxes.length === 0) {
4509
+ if (scoped2) {
4510
+ return `no boxes in this project (${projectRoot}) \u2014 run \`agentbox create\`, or \`agentbox list --all\` to see all`;
3376
4511
  }
3377
- const result = await pruneBoxes({ all: opts.all });
3378
- const removedProjects = opts.all ? (await pruneOrphanProjectConfigs({ protectedPaths })).removed.map((r) => r.originalPath) : [];
3379
- process.stdout.write(`pruned:
3380
- ${summary(result, removedProjects)}
3381
- `);
3382
- } catch (err) {
3383
- handleLifecycleError(err);
4512
+ return "no boxes \u2014 run `agentbox create` to make one";
3384
4513
  }
3385
- });
3386
-
3387
- // src/commands/pull.ts
3388
- import { confirm as confirm9, isCancel as isCancel9, log as log18 } from "@clack/prompts";
3389
- import { Command as Command17 } from "commander";
3390
-
3391
- // src/commands/pull-claude.ts
3392
- import { confirm as confirm6, isCancel as isCancel6, log as log15 } from "@clack/prompts";
3393
- import { Command as Command14 } from "commander";
3394
- function tag(item) {
3395
- const noun = item.category === "plugins" ? "plugin" : item.category.replace(/s$/, "");
3396
- return ` ${item.category}/${item.name} (new ${noun})`;
4514
+ const table = renderTable(boxes, process.stdout);
4515
+ if (!scoped2) return table;
4516
+ const name = projectRoot.split("/").filter(Boolean).pop() ?? projectRoot;
4517
+ return `Project: ${name}
4518
+ ${table}`;
3397
4519
  }
3398
- var pullClaudeCommand = new Command14("claude").description(
3399
- "Pull box-installed Claude skills/plugins/agents/commands back to host ~/.claude (additive)"
3400
- ).argument(
3401
- "[box]",
3402
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3403
- ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list new items and exit; don't write").action(async (idOrName, opts) => {
3404
- try {
3405
- const box = await resolveBoxOrExit(idOrName);
3406
- const volume = box.claudeConfigVolume ?? resolveClaudeVolume({ isolate: false, boxId: box.id }).volume;
3407
- if (volume === SHARED_CLAUDE_VOLUME) {
3408
- log15.warn(
3409
- `Reading the shared ${SHARED_CLAUDE_VOLUME} volume \u2014 it aggregates Claude extensions installed in ANY box, not just ${box.name}.`
3410
- );
3411
- }
3412
- const image = box.image || DEFAULT_BOX_IMAGE;
3413
- const preview = await pullClaudeExtras({ volume }, { image, dryRun: true });
3414
- if (preview.newItems.length === 0 && preview.mergedRegistries.length === 0) {
3415
- process.stdout.write("no new Claude extensions to pull into ~/.claude\n");
3416
- return;
3417
- }
3418
- for (const item of preview.newItems) process.stdout.write(`${tag(item)}
3419
- `);
3420
- for (const reg of preview.mergedRegistries) {
3421
- process.stdout.write(` plugins/${reg} (merge new entries)
3422
- `);
3423
- }
3424
- if (opts.dryRun) {
3425
- process.stdout.write(
3426
- `
3427
- [dry-run] ${preview.newItems.length} item(s)${preview.mergedRegistries.length > 0 ? ` + ${preview.mergedRegistries.length} registry merge(s)` : ""} would be pulled into ~/.claude
3428
- `
3429
- );
3430
- return;
3431
- }
3432
- if (!opts.yes) {
3433
- const ok = await confirm6({
3434
- message: `Pull ${preview.newItems.length} new Claude extension(s) into ~/.claude? (existing items are never overwritten)`,
3435
- initialValue: false
3436
- });
3437
- if (isCancel6(ok) || !ok) {
3438
- log15.info("cancelled");
3439
- return;
3440
- }
3441
- }
3442
- const result = await pullClaudeExtras({ volume }, { image, dryRun: false });
3443
- process.stdout.write(
3444
- `pulled ${result.newItems.length} extension(s)${result.mergedRegistries.length > 0 ? `, merged ${result.mergedRegistries.join(", ")}` : ""} into ~/.claude
3445
- `
3446
- );
3447
- } catch (err) {
3448
- handleLifecycleError(err);
4520
+ var listCommand2 = withWatchOptions(
4521
+ new Command14("list").alias("ls").description("List agent boxes in the current project (-a for all)").option("-j, --json", "machine-readable JSON output").option("-a, --all", "include boxes from all projects")
4522
+ ).action(async (opts) => {
4523
+ if (opts.json && opts.watch) {
4524
+ log16.error("cannot combine --json with --watch");
4525
+ process.exit(2);
4526
+ }
4527
+ const all = opts.all ?? false;
4528
+ if (opts.watch) {
4529
+ await watchRender(() => buildListText(all), opts.interval);
4530
+ return;
4531
+ }
4532
+ if (opts.json) {
4533
+ const { boxes } = await scopedBoxes(all);
4534
+ process.stdout.write(JSON.stringify(boxes, null, 2) + "\n");
4535
+ return;
3449
4536
  }
4537
+ process.stdout.write(await buildListText(all) + "\n");
3450
4538
  });
3451
4539
 
3452
- // src/commands/pull-config.ts
3453
- import { confirm as confirm7, isCancel as isCancel7, log as log16 } from "@clack/prompts";
4540
+ // src/commands/logs.ts
4541
+ import { log as log17 } from "@clack/prompts";
3454
4542
  import { Command as Command15 } from "commander";
3455
- function tagChange(line) {
3456
- const sp = line.indexOf(" ");
3457
- const code = sp === -1 ? line : line.slice(0, sp);
3458
- const path = sp === -1 ? "" : line.slice(sp + 1);
3459
- const isNew = /^>f\++$/.test(code);
3460
- return ` ${path} ${isNew ? "(new)" : "(overwrites host)"}`;
3461
- }
3462
- var CONFIG_PATTERNS = ["agentbox.yaml"];
3463
- var pullConfigCommand = new Command15("config").description("Pull agentbox.yaml box -> host").argument(
4543
+ import { spawn as spawn3 } from "child_process";
4544
+ var logsCommand = new Command15("logs").description("Print recent log lines from a box service; -f to stream").argument(
3464
4545
  "[box]",
3465
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3466
- ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list matched files and exit; don't write").option("--no-refresh", "skip the box->scratch-dir rsync step").action(async (idOrName, opts) => {
4546
+ "box ref (optional when cwd has exactly 1 box): project index, id, id prefix, name, or container"
4547
+ ).argument("[service]", "service name from agentbox.yaml").option("-n, --tail <n>", "how many recent lines to print first", "200").option("-f, --follow", "keep the connection open and stream new lines").action(async (boxArg, serviceArg, opts) => {
3467
4548
  try {
3468
- const box = await resolveBoxOrExit(idOrName);
3469
- const insp = await inspectBox(box.id);
3470
- if (insp.state === "paused") {
3471
- log16.info("box is paused; unpausing");
3472
- await unpauseBox(box.id);
3473
- } else if (insp.state === "stopped") {
3474
- log16.info("box is stopped; starting (remounting overlay)");
3475
- await startBox(box.id);
3476
- } else if (insp.state === "missing") {
3477
- throw new Error(`box ${box.name} has no container; was it destroyed?`);
3478
- }
3479
- log16.info(`agentbox.yaml bypasses gitignore and copies directly into ${box.workspacePath}`);
3480
- const preview = await pullToHost(box, {
3481
- dryRun: true,
3482
- respectGitignore: false,
3483
- envPatterns: CONFIG_PATTERNS,
3484
- noRefresh: !opts.refresh
3485
- });
3486
- if (preview.changes.length === 0) {
3487
- process.stdout.write(`no config file to pull into ${box.workspacePath}
3488
- `);
3489
- return;
4549
+ let idOrName;
4550
+ let service;
4551
+ if (serviceArg !== void 0) {
4552
+ idOrName = boxArg;
4553
+ service = serviceArg;
4554
+ } else {
4555
+ idOrName = void 0;
4556
+ service = boxArg;
3490
4557
  }
3491
- for (const line of preview.changes) process.stdout.write(`${tagChange(line)}
3492
- `);
3493
- if (opts.dryRun) {
3494
- process.stdout.write(
3495
- `
3496
- [dry-run] ${preview.changes.length} config file(s) would change in ${box.workspacePath}
3497
- `
3498
- );
3499
- return;
4558
+ if (!service) {
4559
+ log17.error("missing <service> argument");
4560
+ log17.info("usage: agentbox logs [box] <service> [-n N] [-f]");
4561
+ process.exit(2);
3500
4562
  }
3501
- if (!opts.yes) {
3502
- const ok = await confirm7({
3503
- message: `Pull ${preview.changes.length} config file(s) into ${box.workspacePath}? (existing files will be overwritten)`,
3504
- initialValue: false
3505
- });
3506
- if (isCancel7(ok) || !ok) {
3507
- log16.info("cancelled");
3508
- return;
4563
+ const box = await resolveBoxOrExit(idOrName);
4564
+ const tail = String(Number.parseInt(opts.tail, 10) || 200);
4565
+ const args = ["agentbox-ctl", "logs", service, "--tail", tail];
4566
+ if (opts.follow) args.push("--follow");
4567
+ if (!opts.follow) {
4568
+ const proc = await execInBox(box.container, args, { user: "vscode" });
4569
+ if (proc.exitCode !== 0) {
4570
+ log17.error(`agentbox-ctl logs failed: ${proc.stderr || proc.stdout}`);
4571
+ process.exit(1);
3509
4572
  }
4573
+ process.stdout.write(proc.stdout);
4574
+ if (!proc.stdout.endsWith("\n")) process.stdout.write("\n");
4575
+ return;
3510
4576
  }
3511
- const result = await pullToHost(box, {
3512
- dryRun: false,
3513
- respectGitignore: false,
3514
- envPatterns: CONFIG_PATTERNS,
3515
- // The dry-run pass above already refreshed (or intentionally skipped)
3516
- // the scratch dir — don't rsync box->scratch a second time.
3517
- noRefresh: true
4577
+ const child = spawn3("docker", ["exec", "--user", "vscode", box.container, ...args], {
4578
+ stdio: ["ignore", "inherit", "inherit"]
3518
4579
  });
3519
- process.stdout.write(
3520
- `pulled ${result.changes.length} config file(s) into ${result.hostPath}
3521
- `
3522
- );
4580
+ child.on("exit", (code) => process.exit(code ?? 0));
3523
4581
  } catch (err) {
3524
4582
  handleLifecycleError(err);
3525
4583
  }
3526
4584
  });
3527
4585
 
3528
- // src/commands/pull-env.ts
3529
- import { confirm as confirm8, isCancel as isCancel8, log as log17 } from "@clack/prompts";
4586
+ // src/commands/open.ts
3530
4587
  import { Command as Command16 } from "commander";
3531
- function tagChange2(line) {
3532
- const sp = line.indexOf(" ");
3533
- const code = sp === -1 ? line : line.slice(0, sp);
3534
- const path = sp === -1 ? "" : line.slice(sp + 1);
3535
- const isNew = /^>f\++$/.test(code);
3536
- return ` ${path} ${isNew ? "(new)" : "(overwrites host)"}`;
3537
- }
3538
- var pullEnvCommand = new Command16("env").description(
3539
- "Pull gitignored env/config files (.env*, .envrc, secrets.toml, agentbox.yaml, ...) box -> host"
3540
- ).argument(
3541
- "[box]",
3542
- "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3543
- ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "list matched files and exit; don't write").option(
3544
- "--pattern <glob>",
3545
- "extra basename glob to match (repeatable, adds to defaults)",
3546
- (v, acc) => [...acc, v],
3547
- []
3548
- ).option("--no-refresh", "skip the box->scratch-dir rsync step").action(async (idOrName, opts) => {
4588
+
4589
+ // src/commands/path.ts
4590
+ async function runPath(box, opts) {
3549
4591
  try {
3550
- const box = await resolveBoxOrExit(idOrName);
3551
- const insp = await inspectBox(box.id);
3552
- if (insp.state === "paused") {
3553
- log17.info("box is paused; unpausing");
3554
- await unpauseBox(box.id);
3555
- } else if (insp.state === "stopped") {
3556
- log17.info("box is stopped; starting (remounting overlay)");
3557
- await startBox(box.id);
3558
- } else if (insp.state === "missing") {
3559
- throw new Error(`box ${box.name} has no container; was it destroyed?`);
3560
- }
3561
- log17.info(
3562
- `env/config files bypass gitignore and copy directly into ${box.workspacePath}`
3563
- );
3564
- const patterns = [...DEFAULT_ENV_PATTERNS, ...opts.pattern];
3565
- const preview = await pullToHost(box, {
3566
- dryRun: true,
3567
- respectGitignore: false,
3568
- envPatterns: patterns,
3569
- noRefresh: !opts.refresh
3570
- });
3571
- if (preview.changes.length === 0) {
3572
- process.stdout.write(`no env/config files to pull into ${box.workspacePath}
3573
- `);
3574
- return;
3575
- }
3576
- for (const line of preview.changes) process.stdout.write(`${tagChange2(line)}
4592
+ const { record, paths } = await getBoxHostPaths(box.id);
4593
+ if (opts.refresh) {
4594
+ const refreshed = await refreshExport(record, {
4595
+ includeNodeModules: opts.includeNodeModules
4596
+ });
4597
+ process.stdout.write(`${refreshed.hostPath}
3577
4598
  `);
3578
- if (opts.dryRun) {
3579
- process.stdout.write(
3580
- `
3581
- [dry-run] ${preview.changes.length} env/config file(s) would change in ${box.workspacePath}
3582
- `
3583
- );
3584
4599
  return;
3585
4600
  }
3586
- if (!opts.yes) {
3587
- const ok = await confirm8({
3588
- message: `Pull ${preview.changes.length} env/config file(s) into ${box.workspacePath}? (existing files will be overwritten)`,
3589
- initialValue: false
3590
- });
3591
- if (isCancel8(ok) || !ok) {
3592
- log17.info("cancelled");
3593
- return;
3594
- }
3595
- }
3596
- const result = await pullToHost(box, {
3597
- dryRun: false,
3598
- respectGitignore: false,
3599
- envPatterns: patterns,
3600
- // The dry-run pass above already refreshed (or intentionally skipped)
3601
- // the scratch dir — don't rsync box->scratch a second time.
3602
- noRefresh: true
3603
- });
3604
- process.stdout.write(
3605
- `pulled ${result.changes.length} env/config file(s) into ${result.hostPath}
3606
- `
3607
- );
4601
+ process.stdout.write(`${paths.mergedExport}
4602
+ `);
3608
4603
  } catch (err) {
3609
4604
  handleLifecycleError(err);
3610
4605
  }
3611
- });
4606
+ }
3612
4607
 
3613
- // src/commands/pull.ts
3614
- var pullCommand = new Command17("pull").enablePositionalOptions().description("Pull a box's /workspace back into your host workspace dir (gitignore-aware)").argument(
4608
+ // src/commands/open.ts
4609
+ var openCommand = new Command16("open").description("Open a box's /workspace in Finder (rsync'd snapshot of the agent's view)").argument(
3615
4610
  "[box]",
3616
4611
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3617
- ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "print the change list and exit; don't write").option(
3618
- "--no-respect-gitignore",
3619
- "disable git ls-files mode; use --exclude=node_modules,.git instead"
3620
- ).option(
4612
+ ).option("--no-refresh", "skip the rsync; open whatever's already on disk").option(
3621
4613
  "--include-node-modules",
3622
- "do not exclude node_modules in fallback mode (no effect in gitignore mode)"
3623
- ).option("--no-refresh", "skip the box->scratch-dir rsync step (use whatever's already there)").option(
3624
- "--with-env",
3625
- "also pull env/config files (.env*, .envrc, secrets.toml, agentbox.yaml, ...) ignoring gitignore"
3626
- ).option(
3627
- "--pattern <glob>",
3628
- "extra env basename glob; only effective with --with-env (repeatable)",
3629
- (v, acc) => [...acc, v],
3630
- []
3631
- ).action(async (idOrName, opts) => {
4614
+ "include /workspace/node_modules in the merged export (off by default)"
4615
+ ).option("--path", "print the host workspace path instead of launching Finder").option("--print", "alias of --path").action(async (idOrName, opts) => {
3632
4616
  try {
3633
4617
  const box = await resolveBoxOrExit(idOrName);
3634
- const insp = await inspectBox(box.id);
3635
- if (insp.state === "paused") {
3636
- log18.info("box is paused; unpausing");
3637
- await unpauseBox(box.id);
3638
- } else if (insp.state === "stopped") {
3639
- log18.info("box is stopped; starting (remounting overlay)");
3640
- await startBox(box.id);
3641
- } else if (insp.state === "missing") {
3642
- throw new Error(`box ${box.name} has no container; was it destroyed?`);
3643
- }
3644
- const rootWorktree = box.gitWorktrees?.find((w) => w.kind === "root");
3645
- if (rootWorktree) {
3646
- log18.warn(
3647
- `This box has been committing to branch \`${rootWorktree.branch}\` in a separate worktree.
3648
- For a git-aware merge instead of a file copy, run from your checkout:
3649
- git merge ${rootWorktree.branch}
3650
- Continuing with rsync into ${box.workspacePath}`
3651
- );
4618
+ if (opts.path || opts.print) {
4619
+ await runPath(box, {
4620
+ refresh: opts.refresh,
4621
+ // print refreshes by default; --no-refresh skips
4622
+ includeNodeModules: opts.includeNodeModules
4623
+ });
4624
+ return;
3652
4625
  }
3653
- const envPatterns = opts.withEnv ? [...DEFAULT_ENV_PATTERNS, ...opts.pattern] : void 0;
3654
- const preview = await pullToHost(box, {
3655
- dryRun: true,
3656
- respectGitignore: opts.respectGitignore,
4626
+ const result = await openBoxInFinder(box.id, {
3657
4627
  includeNodeModules: opts.includeNodeModules,
3658
- envPatterns,
3659
- noRefresh: !opts.refresh
4628
+ noRefresh: !opts.refresh,
4629
+ noOpen: false
3660
4630
  });
3661
- if (preview.changes.length === 0) {
3662
- process.stdout.write(`no changes to pull into ${box.workspacePath}
4631
+ const liveNote = !result.copied ? " (live)" : result.usedFallback ? " (tar fallback)" : "";
4632
+ process.stdout.write(`opened ${result.hostPath}${liveNote}
3663
4633
  `);
3664
- return;
3665
- }
3666
- if (opts.dryRun) {
3667
- for (const line of preview.changes) process.stdout.write(`${line}
4634
+ } catch (err) {
4635
+ handleLifecycleError(err);
4636
+ }
4637
+ });
4638
+
4639
+ // src/commands/pause.ts
4640
+ import { Command as Command17 } from "commander";
4641
+ var pauseCommand = new Command17("pause").description("Freeze a box (docker pause \u2014 0 CPU, RAM stays mapped)").argument(
4642
+ "[box]",
4643
+ "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4644
+ ).action(async (idOrName) => {
4645
+ try {
4646
+ const box = await resolveBoxOrExit(idOrName);
4647
+ const record = await pauseBox(box.id);
4648
+ process.stdout.write(`paused ${record.container}
3668
4649
  `);
3669
- process.stdout.write(
3670
- `
3671
- [dry-run] ${preview.changes.length} file(s) would change in ${box.workspacePath}
3672
- `
3673
- );
4650
+ } catch (err) {
4651
+ handleLifecycleError(err);
4652
+ }
4653
+ });
4654
+
4655
+ // src/commands/prune.ts
4656
+ import { confirm as confirm9, isCancel as isCancel9, log as log18 } from "@clack/prompts";
4657
+ import { Command as Command18 } from "commander";
4658
+ function totalRemovals(r, projectConfigs) {
4659
+ return r.removedRecords.length + r.removedContainers.length + r.removedVolumes.length + r.removedSnapshotDirs.length + r.removedBoxDirs.length + projectConfigs.length;
4660
+ }
4661
+ function summary(r, projectConfigs) {
4662
+ const lines = [];
4663
+ if (r.removedRecords.length > 0) {
4664
+ lines.push(
4665
+ ` state records (${String(r.removedRecords.length)}): ${r.removedRecords.join(", ")}`
4666
+ );
4667
+ }
4668
+ if (r.removedContainers.length > 0) {
4669
+ lines.push(
4670
+ ` containers (${String(r.removedContainers.length)}): ${r.removedContainers.join(", ")}`
4671
+ );
4672
+ }
4673
+ if (r.removedVolumes.length > 0) {
4674
+ lines.push(
4675
+ ` volumes (${String(r.removedVolumes.length)}): ${r.removedVolumes.join(", ")}`
4676
+ );
4677
+ }
4678
+ if (r.removedSnapshotDirs.length > 0) {
4679
+ lines.push(
4680
+ ` snapshot dirs (${String(r.removedSnapshotDirs.length)}): ${r.removedSnapshotDirs.join(", ")}`
4681
+ );
4682
+ }
4683
+ if (r.removedBoxDirs.length > 0) {
4684
+ lines.push(
4685
+ ` box dirs (${String(r.removedBoxDirs.length)}): ${r.removedBoxDirs.join(", ")}`
4686
+ );
4687
+ }
4688
+ if (projectConfigs.length > 0) {
4689
+ lines.push(
4690
+ ` project configs (${String(projectConfigs.length)}): ${projectConfigs.join(", ")}`
4691
+ );
4692
+ }
4693
+ return lines.length > 0 ? lines.join("\n") : " (nothing to remove)";
4694
+ }
4695
+ async function liveProjectRoots() {
4696
+ try {
4697
+ const boxes = await listBoxes();
4698
+ return boxes.map((b) => b.projectRoot).filter((p) => typeof p === "string");
4699
+ } catch {
4700
+ return [];
4701
+ }
4702
+ }
4703
+ var pruneCommand = new Command18("prune").description("Clean up orphan state.json records (and with --all, orphan docker resources)").option("--dry-run", "show what would be removed, don't change anything").option(
4704
+ "--all",
4705
+ "also remove orphan agentbox-* containers, volumes, snapshot dirs, and orphan per-project config dirs"
4706
+ ).option("-y, --yes", "skip the confirmation prompt").action(async (opts) => {
4707
+ try {
4708
+ const dryRun = opts.dryRun ?? false;
4709
+ const protectedPaths = opts.all ? await liveProjectRoots() : [];
4710
+ const preview = await pruneBoxes({ dryRun: true, all: opts.all });
4711
+ const previewProjects = opts.all ? (await pruneOrphanProjectConfigs({ dryRun: true, protectedPaths })).removed.map(
4712
+ (r) => r.originalPath
4713
+ ) : [];
4714
+ if (totalRemovals(preview, previewProjects) === 0) {
4715
+ process.stdout.write("nothing to prune\n");
3674
4716
  return;
3675
4717
  }
4718
+ log18.info(`would remove:
4719
+ ${summary(preview, previewProjects)}`);
4720
+ if (dryRun) return;
3676
4721
  if (!opts.yes) {
3677
- const ok = await confirm9({
3678
- message: `Pull ${preview.changes.length} changed file(s)${opts.withEnv ? " (incl. env/config)" : ""} into ${box.workspacePath}?`,
3679
- initialValue: false
3680
- });
4722
+ const ok = await confirm9({ message: "Proceed with prune?", initialValue: true });
3681
4723
  if (isCancel9(ok) || !ok) {
3682
4724
  log18.info("cancelled");
3683
4725
  return;
3684
4726
  }
3685
4727
  }
3686
- const result = await pullToHost(box, {
3687
- dryRun: false,
3688
- respectGitignore: opts.respectGitignore,
3689
- includeNodeModules: opts.includeNodeModules,
3690
- envPatterns,
3691
- // The dry-run pass above already refreshed (or intentionally skipped)
3692
- // the scratch dir — don't rsync box->scratch a second time.
3693
- noRefresh: true
3694
- });
3695
- process.stdout.write(
3696
- `updated ${result.changes.length} file(s) in ${result.hostPath}${result.usedGitignore ? "" : " (exclude-list mode)"}
3697
- `
4728
+ const result = await pruneBoxes({ all: opts.all });
4729
+ const removedProjects = opts.all ? (await pruneOrphanProjectConfigs({ protectedPaths })).removed.map((r) => r.originalPath) : [];
4730
+ process.stdout.write(`pruned:
4731
+ ${summary(result, removedProjects)}
4732
+ `);
4733
+ } catch (err) {
4734
+ handleLifecycleError(err);
4735
+ }
4736
+ });
4737
+
4738
+ // src/commands/relay.ts
4739
+ import { log as log19, spinner as spinner3 } from "@clack/prompts";
4740
+ import { Command as Command19 } from "commander";
4741
+ function renderStatus(s) {
4742
+ if (s.running && s.health) {
4743
+ return [
4744
+ "relay: running",
4745
+ ` pid: ${s.pid === null ? "?" : String(s.pid)}`,
4746
+ ` port: ${String(s.port)}`,
4747
+ ` url: ${s.endpoint.hostUrl}`,
4748
+ ` boxes: ${String(s.health.boxes)}`,
4749
+ ` events: ${String(s.health.events)}`,
4750
+ ` log: ${s.logFile}`
4751
+ ].join("\n");
4752
+ }
4753
+ if (s.pidAlive) {
4754
+ return [
4755
+ `relay: not responding (pid ${String(s.pid)} alive but /healthz silent)`,
4756
+ ` log: ${s.logFile}`
4757
+ ].join("\n");
4758
+ }
4759
+ return ["relay: not running", ` log: ${s.logFile}`].join("\n");
4760
+ }
4761
+ var statusSub = new Command19("status").description("Show whether the host relay is running, with pid / port / box count").option("--json", "emit RelayStatus as JSON").action(async (opts) => {
4762
+ try {
4763
+ const s = await getRelayStatus();
4764
+ if (opts.json) {
4765
+ process.stdout.write(JSON.stringify(s, null, 2) + "\n");
4766
+ return;
4767
+ }
4768
+ process.stdout.write(renderStatus(s) + "\n");
4769
+ } catch (err) {
4770
+ handleLifecycleError(err);
4771
+ }
4772
+ });
4773
+ var stopSub = new Command19("stop").description("Stop the host relay process (idempotent)").action(async () => {
4774
+ try {
4775
+ const s = spinner3();
4776
+ s.start("stopping relay");
4777
+ const result = await stopRelay();
4778
+ s.stop(
4779
+ result.stopped ? `stopped relay (pid ${String(result.pid)})` : "relay was not running"
4780
+ );
4781
+ } catch (err) {
4782
+ handleLifecycleError(err);
4783
+ }
4784
+ });
4785
+ var startSub = new Command19("start").description("Start the host relay if not already running (idempotent)").action(async () => {
4786
+ try {
4787
+ const s = spinner3();
4788
+ s.start("starting relay");
4789
+ const ep = await ensureRelay();
4790
+ s.stop(`relay running on ${ep.hostUrl}`);
4791
+ } catch (err) {
4792
+ handleLifecycleError(err);
4793
+ }
4794
+ });
4795
+ var restartSub = new Command19("restart").description("Stop then start the host relay").action(async () => {
4796
+ try {
4797
+ const s = spinner3();
4798
+ s.start("stopping relay");
4799
+ const stopped = await stopRelay();
4800
+ s.stop(
4801
+ stopped.stopped ? `stopped relay (pid ${String(stopped.pid)})` : "relay was not running"
3698
4802
  );
4803
+ const s2 = spinner3();
4804
+ s2.start("starting relay");
4805
+ try {
4806
+ const ep = await ensureRelay();
4807
+ s2.stop(`relay running on ${ep.hostUrl}`);
4808
+ } catch (err) {
4809
+ s2.stop("relay start failed");
4810
+ log19.warn(err instanceof Error ? err.message : String(err));
4811
+ throw err;
4812
+ }
3699
4813
  } catch (err) {
3700
4814
  handleLifecycleError(err);
3701
4815
  }
3702
4816
  });
3703
- pullCommand.addCommand(pullEnvCommand);
3704
- pullCommand.addCommand(pullClaudeCommand);
3705
- pullCommand.addCommand(pullConfigCommand);
4817
+ var relayCommand = new Command19("relay").description("Manage the host relay process (status / stop / start / restart)").addCommand(statusSub, { isDefault: true }).addCommand(stopSub).addCommand(startSub).addCommand(restartSub);
3706
4818
 
3707
4819
  // src/commands/screen.ts
3708
4820
  import { spawnSync as spawnSync5 } from "child_process";
3709
- import { log as log19 } from "@clack/prompts";
3710
- import { Command as Command18 } from "commander";
3711
- var screenCommand = new Command18("screen").description("Open a box's VNC (noVNC) viewer in the browser (auto-unpause/start)").argument(
4821
+ import { log as log20 } from "@clack/prompts";
4822
+ import { Command as Command20 } from "commander";
4823
+ var screenCommand = new Command20("screen").description("Open a box's VNC (noVNC) viewer in the browser (auto-unpause/start)").argument(
3712
4824
  "[box]",
3713
4825
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3714
4826
  ).option("--print", "print the URL to stdout instead of launching the browser").option("--loopback", "use the 127.0.0.1 URL instead of the OrbStack .orb.local URL").action(async (idOrName, opts) => {
@@ -3719,17 +4831,27 @@ var screenCommand = new Command18("screen").description("Open a box's VNC (noVNC
3719
4831
  }
3720
4832
  const insp = await inspectBox(box.id);
3721
4833
  if (insp.state === "paused") {
3722
- log19.info("box is paused; unpausing");
4834
+ log20.info("box is paused; unpausing");
3723
4835
  await unpauseBox(box.id);
3724
4836
  } else if (insp.state === "stopped") {
3725
- log19.info("box is stopped; starting (remounting overlay)");
4837
+ log20.info("box is stopped; starting");
3726
4838
  await startBox(box.id);
3727
4839
  } else if (insp.state === "missing") {
3728
4840
  throw new Error(`box ${box.name} has no container; was it destroyed?`);
3729
4841
  }
3730
- const br = await ensureBoxBrowser(box.container);
3731
- if (br.up && !br.alreadyRunning) log19.info("started in-box browser");
3732
- else if (!br.up) log19.warn(`could not start in-box browser: ${br.reason ?? "unknown"}`);
4842
+ const persisted = await readBoxStatus(box);
4843
+ const exposePort = persisted?.services.find((s) => s.expose)?.expose?.port;
4844
+ const inBoxUrl = exposePort !== void 0 ? `http://localhost:${String(exposePort)}` : "about:blank";
4845
+ const br = await ensureBoxBrowser(box.container, void 0, inBoxUrl);
4846
+ if (br.up && !br.alreadyRunning) {
4847
+ log20.info(
4848
+ exposePort !== void 0 ? `opened ${inBoxUrl} in the in-box browser (visible in the VNC view)` : "started in-box browser"
4849
+ );
4850
+ } else if (br.alreadyRunning) {
4851
+ log20.info("in-box browser already running; left it untouched");
4852
+ } else {
4853
+ log20.warn(`could not start in-box browser: ${br.reason ?? "unknown"}`);
4854
+ }
3733
4855
  const engine = await detectEngine();
3734
4856
  const urls = buildVncUrls(box, engine);
3735
4857
  const url = opts.loopback ? urls.loopbackUrl : urls.orbUrl ?? urls.loopbackUrl;
@@ -3749,21 +4871,6 @@ var screenCommand = new Command18("screen").description("Open a box's VNC (noVNC
3749
4871
  }
3750
4872
  process.stdout.write(`opened ${url}
3751
4873
  `);
3752
- try {
3753
- const { record } = await getBoxHostPaths(box.id);
3754
- const persisted = await readBoxStatus(box.id);
3755
- const eps = await getBoxEndpoints(record, engine, persisted);
3756
- const webEp = eps.endpoints.find((e) => e.kind === "web");
3757
- if (webEp?.reachable && webEp.url) {
3758
- const webUrl = engine === "orbstack" && !opts.loopback ? `http://${record.container}.orb.local` : webEp.url;
3759
- const w = spawnSync5("open", [webUrl], { stdio: "inherit" });
3760
- if (w.status === 0) process.stdout.write(`also opened ${webUrl}
3761
- `);
3762
- else log19.warn(`could not open web app (${webUrl})`);
3763
- }
3764
- } catch (e) {
3765
- log19.warn(`could not open web app: ${e instanceof Error ? e.message : String(e)}`);
3766
- }
3767
4874
  } catch (err) {
3768
4875
  handleLifecycleError(err);
3769
4876
  }
@@ -3771,15 +4878,16 @@ var screenCommand = new Command18("screen").description("Open a box's VNC (noVNC
3771
4878
 
3772
4879
  // src/commands/shell.ts
3773
4880
  import { spawnSync as spawnSync6 } from "child_process";
3774
- import { log as log20 } from "@clack/prompts";
3775
- import { Command as Command19 } from "commander";
4881
+ import { log as log21 } from "@clack/prompts";
4882
+ import { Command as Command21 } from "commander";
4883
+ var RELAY_HOST_URL3 = `http://127.0.0.1:${String(DEFAULT_RELAY_PORT)}`;
3776
4884
  function buildShellCliOverrides(opts) {
3777
4885
  const shell = {};
3778
4886
  if (opts.user !== void 0) shell.user = opts.user;
3779
4887
  if (opts.login === false) shell.login = false;
3780
4888
  return Object.keys(shell).length > 0 ? { shell } : {};
3781
4889
  }
3782
- var shellCommand = new Command19("shell").description("Open an interactive bash shell in a box (auto-unpause/start)").argument(
4890
+ var shellCommand = new Command21("shell").description("Open an interactive bash shell in a box (auto-unpause/start)").argument(
3783
4891
  "[box]",
3784
4892
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3785
4893
  ).argument(
@@ -3796,10 +4904,10 @@ var shellCommand = new Command19("shell").description("Open an interactive bash
3796
4904
  const login = cfg.effective.shell.login;
3797
4905
  const insp = await inspectBox(box.id);
3798
4906
  if (insp.state === "paused") {
3799
- log20.info("box is paused; unpausing");
4907
+ log21.info("box is paused; unpausing");
3800
4908
  await unpauseBox(box.id);
3801
4909
  } else if (insp.state === "stopped") {
3802
- log20.info("box is stopped; starting (remounting overlay)");
4910
+ log21.info("box is stopped; starting");
3803
4911
  await startBox(box.id);
3804
4912
  } else if (insp.state === "missing") {
3805
4913
  throw new Error(`box ${box.name} has no container; was it destroyed?`);
@@ -3808,59 +4916,57 @@ var shellCommand = new Command19("shell").description("Open an interactive bash
3808
4916
  const bashArgs = [];
3809
4917
  if (login) bashArgs.push("-l");
3810
4918
  if (effectiveCmd.length > 0) bashArgs.push("-c", effectiveCmd.join(" "));
3811
- const ttyFlag = process.stdout.isTTY && process.stdin.isTTY ? "-it" : "-i";
3812
- const child = spawnSync6(
3813
- "docker",
3814
- [
3815
- "exec",
3816
- ttyFlag,
3817
- "-e",
3818
- `TERM=${term2}`,
3819
- "--user",
3820
- user,
3821
- box.container,
3822
- "bash",
3823
- ...bashArgs
3824
- ],
3825
- { stdio: "inherit" }
3826
- );
3827
- process.exit(child.status ?? 0);
4919
+ const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
4920
+ const ttyFlag = isInteractive ? "-it" : "-i";
4921
+ const dockerArgv = [
4922
+ "exec",
4923
+ ttyFlag,
4924
+ "-e",
4925
+ `TERM=${term2}`,
4926
+ "--user",
4927
+ user,
4928
+ box.container,
4929
+ "bash",
4930
+ ...bashArgs
4931
+ ];
4932
+ if (!isInteractive || effectiveCmd.length > 0) {
4933
+ const child = spawnSync6("docker", dockerArgv, { stdio: "inherit" });
4934
+ process.exit(child.status ?? 0);
4935
+ }
4936
+ const code = await runWrappedAttach({
4937
+ container: box.container,
4938
+ dockerArgv,
4939
+ relayBaseUrl: RELAY_HOST_URL3,
4940
+ boxId: box.id,
4941
+ boxName: box.name,
4942
+ projectIndex: box.projectIndex,
4943
+ mode: "shell"
4944
+ });
4945
+ process.exit(code);
3828
4946
  } catch (err) {
3829
4947
  handleLifecycleError(err);
3830
4948
  }
3831
4949
  });
3832
4950
 
3833
4951
  // src/commands/start.ts
3834
- import { Command as Command20 } from "commander";
3835
- var startCommand = new Command20("start").description("Start a stopped box (docker start + re-mount the FUSE overlay)").argument(
4952
+ import { Command as Command22 } from "commander";
4953
+ var startCommand = new Command22("start").description("Start a stopped box (docker start + relaunch ctl/dockerd/vnc daemons)").argument(
3836
4954
  "[box]",
3837
4955
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
3838
4956
  ).action(async (idOrName) => {
3839
4957
  try {
3840
4958
  const box = await resolveBoxOrExit(idOrName);
3841
- const { record, overlayChecks } = await startBox(box.id);
4959
+ const { record } = await startBox(box.id);
3842
4960
  process.stdout.write(`started ${record.container}
3843
4961
  `);
3844
- const failed = overlayChecks.filter((c) => !c.ok);
3845
- if (failed.length > 0) {
3846
- for (const c of failed) {
3847
- process.stderr.write(` \u2717 ${c.name}: ${c.detail}
3848
- `);
3849
- }
3850
- process.exit(1);
3851
- }
3852
- for (const c of overlayChecks) {
3853
- process.stdout.write(` \u2713 ${c.name}
3854
- `);
3855
- }
3856
4962
  } catch (err) {
3857
4963
  handleLifecycleError(err);
3858
4964
  }
3859
4965
  });
3860
4966
 
3861
4967
  // src/commands/status.ts
3862
- import { log as log22 } from "@clack/prompts";
3863
- import { Command as Command21 } from "commander";
4968
+ import { log as log23 } from "@clack/prompts";
4969
+ import { Command as Command23 } from "commander";
3864
4970
 
3865
4971
  // src/endpoints-render.ts
3866
4972
  function renderEndpointLines(endpoints, stream) {
@@ -3923,27 +5029,24 @@ function fmtAgo(iso) {
3923
5029
  }
3924
5030
 
3925
5031
  // src/commands/inspect.ts
3926
- import { log as log21 } from "@clack/prompts";
5032
+ import { log as log22 } from "@clack/prompts";
3927
5033
  function fmtLimit(n, unit) {
3928
5034
  return n && n > 0 ? `${String(n)}${unit}` : "unlimited";
3929
5035
  }
3930
5036
  async function renderText(i) {
3931
5037
  const lim = i.record.resourceLimits;
5038
+ const ckptName = i.record.checkpointSource?.ref;
3932
5039
  const projectRoot = i.record.projectRoot ?? i.record.workspacePath;
3933
- const ckptBytes = await projectCheckpointVolumeBytes(projectRoot);
3934
- const upperHost = i.hostPaths.upperLiveOnHost ? `${i.hostPaths.upperLiveOnHost} (live)` : `${i.hostPaths.upperExport} (run \`agentbox open --upper\` to refresh)`;
5040
+ const ckptBytes = ckptName ? await projectCheckpointImageBytes(projectRoot, ckptName) : null;
3935
5041
  const lines = [
3936
5042
  `id ${i.record.id}`,
3937
5043
  `name ${i.record.name}`,
3938
5044
  `container ${i.record.container}`,
3939
5045
  `image ${i.record.image}`,
3940
5046
  `state ${i.state}`,
3941
- `overlay ${i.overlayMounted ? "mounted at /workspace" : "not mounted"}`,
3942
- `workspace ${i.record.workspacePath}`,
5047
+ `workspace ${i.record.workspacePath} (container fs at /workspace)`,
3943
5048
  `project ${i.record.projectRoot ?? "(unset \u2014 pre-feature box)"}`,
3944
5049
  `n ${typeof i.record.projectIndex === "number" ? String(i.record.projectIndex) : "(none)"}`,
3945
- `lower ${i.record.lowerPath}`,
3946
- `upper volume ${i.upperVolume.name}${i.upperVolume.mountpoint ? ` (${i.upperVolume.mountpoint})` : ""}`,
3947
5050
  `claude config ${i.record.claudeConfigVolume ?? "(none)"}`,
3948
5051
  `claude session ${renderClaudeSession(i)}`,
3949
5052
  `claude activity ${renderClaudeActivity(i)}`,
@@ -3956,15 +5059,20 @@ async function renderText(i) {
3956
5059
  `cpu limit ${fmtLimit(lim?.cpus, "")}`,
3957
5060
  `pids limit ${fmtLimit(lim?.pidsLimit, "")}`,
3958
5061
  `disk limit ${lim?.disk ? `${lim.disk} (best-effort; no-op on overlay2/macOS)` : "unlimited"}`,
3959
- `snapshot dir ${i.record.snapshotDir ?? "(none \u2014 live workspace mount)"}`,
5062
+ `snapshot dir ${i.record.snapshotDir ?? "(none)"}`,
3960
5063
  `snapshot size ${fmtBytes(i.snapshotSizeBytes)}`,
3961
- `checkpoint vol ${ckptBytes === null ? "(none)" : fmtBytes(ckptBytes)}`,
5064
+ `checkpoint ${renderCheckpoint(i, ckptBytes)}`,
3962
5065
  `host export ${i.hostPaths.mergedExport} (run \`agentbox open\` to refresh)`,
3963
- `upper host ${upperHost}`,
3964
5066
  `created ${i.record.createdAt}`
3965
5067
  ];
3966
5068
  return lines.join("\n");
3967
5069
  }
5070
+ function renderCheckpoint(i, sizeBytes) {
5071
+ const src = i.record.checkpointSource;
5072
+ if (!src || !i.record.checkpointImage) return "(none)";
5073
+ const sizePart = sizeBytes !== null ? ` ${fmtBytes(sizeBytes)}` : "";
5074
+ return `${src.ref} (${src.type}, chain ${src.chain.length}) \u2192 ${i.record.checkpointImage}${sizePart}`;
5075
+ }
3968
5076
  function renderClaudeSession(i) {
3969
5077
  if (i.claudeSession === null) return "(n/a \u2014 box not running)";
3970
5078
  if (!i.claudeSession.running) return `not running ("${i.claudeSession.sessionName}")`;
@@ -3988,7 +5096,7 @@ function renderEndpoints(i) {
3988
5096
  async function runInspect(box, opts) {
3989
5097
  try {
3990
5098
  if (opts.json && opts.watch) {
3991
- log21.error("cannot combine --json with --watch");
5099
+ log22.error("cannot combine --json with --watch");
3992
5100
  process.exit(2);
3993
5101
  }
3994
5102
  if (opts.watch) {
@@ -4008,14 +5116,14 @@ async function runInspect(box, opts) {
4008
5116
 
4009
5117
  // src/commands/status.ts
4010
5118
  var statusCommand = withWatchOptions(
4011
- new Command21("status").description("Show service + task status from a box's agentbox-ctl daemon").argument(
5119
+ new Command23("status").description("Show service + task status from a box's agentbox-ctl daemon").argument(
4012
5120
  "[box]",
4013
5121
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4014
5122
  ).option("-j, --json", "machine-readable JSON output").option("--inspect", "show detailed box info (volumes, limits, paths) instead of service/task status")
4015
5123
  ).action(async (idOrName, opts) => {
4016
5124
  try {
4017
5125
  if (opts.json && opts.watch) {
4018
- log22.error("cannot combine --json with --watch");
5126
+ log23.error("cannot combine --json with --watch");
4019
5127
  process.exit(2);
4020
5128
  }
4021
5129
  const box = await resolveBoxOrExit(idOrName);
@@ -4170,8 +5278,8 @@ function renderPersisted2(s, state) {
4170
5278
  }
4171
5279
 
4172
5280
  // src/commands/stop.ts
4173
- import { Command as Command22 } from "commander";
4174
- var stopCommand = new Command22("stop").description("Stop a box (docker stop; preserves upper + node_modules volumes)").argument(
5281
+ import { Command as Command24 } from "commander";
5282
+ var stopCommand = new Command24("stop").description("Stop a box (docker stop; preserves upper + node_modules volumes)").argument(
4175
5283
  "[box]",
4176
5284
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4177
5285
  ).action(async (idOrName) => {
@@ -4189,7 +5297,7 @@ restart with: agentbox start ${record.name}
4189
5297
  });
4190
5298
 
4191
5299
  // src/commands/top.ts
4192
- import { Command as Command23 } from "commander";
5300
+ import { Command as Command25 } from "commander";
4193
5301
  var COLS = ["BOX", "STATE", "CPU%", "MEM USAGE / LIMIT", "MEM%", "PIDS", "DISK", "NET I/O"];
4194
5302
  function row(name, state, s) {
4195
5303
  const mem = `${fmtBytes(s.memUsedBytes)} / ${fmtBytes(s.memLimitBytes)}`;
@@ -4228,7 +5336,7 @@ async function snapshot(idOrName, opts) {
4228
5336
  async function renderProjectFooters() {
4229
5337
  const parts = [];
4230
5338
  const [ckpt, home] = await Promise.all([
4231
- allCheckpointVolumesBytes(),
5339
+ allCheckpointImagesBytes(),
4232
5340
  agentboxHomeBytes()
4233
5341
  ]);
4234
5342
  if (home !== null) parts.push(`~/.agentbox: ${fmtBytes(home)}`);
@@ -4237,7 +5345,7 @@ async function renderProjectFooters() {
4237
5345
 
4238
5346
  SYSTEM: ${parts.join(" - ")}` : "";
4239
5347
  }
4240
- var topCommand = new Command23("top").description("Live resource monitor (cpu/mem/pids/disk) for a box, the project, or every box").argument(
5348
+ var topCommand = new Command25("top").description("Live resource monitor (cpu/mem/pids/disk) for a box, the project, or every box").argument(
4241
5349
  "[box]",
4242
5350
  "box ref (default: every box on the host; --project narrows to the cwd's project)"
4243
5351
  ).option("-p, --project", "show only boxes in the cwd's project").option("--once", "print a single snapshot instead of watching").option("-j, --json", "machine-readable JSON (implies --once)").option("--interval <seconds>", "refresh interval", "2").action(async (idOrName, opts) => {
@@ -4270,8 +5378,8 @@ var topCommand = new Command23("top").description("Live resource monitor (cpu/me
4270
5378
  });
4271
5379
 
4272
5380
  // src/commands/unpause.ts
4273
- import { Command as Command24 } from "commander";
4274
- var unpauseCommand = new Command24("unpause").description("Resume a paused box (docker unpause \u2014 sub-second)").argument(
5381
+ import { Command as Command26 } from "commander";
5382
+ var unpauseCommand = new Command26("unpause").description("Resume a paused box (docker unpause \u2014 sub-second)").argument(
4275
5383
  "[box]",
4276
5384
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4277
5385
  ).action(async (idOrName) => {
@@ -4287,8 +5395,8 @@ var unpauseCommand = new Command24("unpause").description("Resume a paused box (
4287
5395
 
4288
5396
  // src/commands/update.ts
4289
5397
  import { spawn as spawn4 } from "child_process";
4290
- import { confirm as confirm10, intro as intro3, isCancel as isCancel10, log as log23, outro as outro3, spinner as spinner3 } from "@clack/prompts";
4291
- import { Command as Command25 } from "commander";
5398
+ import { confirm as confirm10, intro as intro3, isCancel as isCancel10, log as log24, outro as outro3, spinner as spinner4 } from "@clack/prompts";
5399
+ import { Command as Command27 } from "commander";
4292
5400
 
4293
5401
  // src/exec-method.ts
4294
5402
  function detectExecutionMethod(input) {
@@ -4332,7 +5440,7 @@ function runInherit(cmd, args) {
4332
5440
  child.on("close", (code) => resolveP(code ?? 0));
4333
5441
  });
4334
5442
  }
4335
- var updateCommand = new Command25("self-update").description(
5443
+ var updateCommand = new Command27("self-update").description(
4336
5444
  "Update agentbox: self-update via npm/pnpm (unless run via npx), wipe the box image so it rebuilds, and reload the relay"
4337
5445
  ).option("-y, --yes", "skip the confirmation prompt").option("--dry-run", "show what would happen, don't change anything").option("--skip-self", "skip the package self-update; only refresh the image + relay").action(async (opts) => {
4338
5446
  try {
@@ -4342,7 +5450,7 @@ var updateCommand = new Command25("self-update").description(
4342
5450
  });
4343
5451
  intro3("agentbox self-update");
4344
5452
  const selfStep = opts.skipSelf ? "self-update: skipped (--skip-self)" : describeSelfUpdate(method);
4345
- log23.info(
5453
+ log24.info(
4346
5454
  [
4347
5455
  "plan:",
4348
5456
  ` ${selfStep}`,
@@ -4357,52 +5465,52 @@ var updateCommand = new Command25("self-update").description(
4357
5465
  if (!opts.yes) {
4358
5466
  const ok = await confirm10({ message: "Proceed with update?", initialValue: true });
4359
5467
  if (isCancel10(ok) || !ok) {
4360
- log23.info("cancelled");
5468
+ log24.info("cancelled");
4361
5469
  return;
4362
5470
  }
4363
5471
  }
4364
5472
  let selfUpdated = false;
4365
5473
  if (opts.skipSelf) {
4366
- log23.info("skipping self-update (--skip-self)");
5474
+ log24.info("skipping self-update (--skip-self)");
4367
5475
  } else {
4368
5476
  const cmd = selfUpdateCommand(method);
4369
5477
  if (cmd === null) {
4370
- log23.info(describeSelfUpdate(method));
5478
+ log24.info(describeSelfUpdate(method));
4371
5479
  } else {
4372
- log23.info(`running: ${cmd.cmd} ${cmd.args.join(" ")}`);
5480
+ log24.info(`running: ${cmd.cmd} ${cmd.args.join(" ")}`);
4373
5481
  const code = await runInherit(cmd.cmd, cmd.args);
4374
5482
  if (code !== 0) {
4375
5483
  throw new Error(`${cmd.cmd} exited with code ${String(code)}`);
4376
5484
  }
4377
5485
  selfUpdated = true;
4378
- log23.success(`updated ${PKG} via ${cmd.cmd}`);
5486
+ log24.success(`updated ${PKG} via ${cmd.cmd}`);
4379
5487
  }
4380
5488
  }
4381
- const s = spinner3();
5489
+ const s = spinner4();
4382
5490
  s.start(`removing image ${DEFAULT_BOX_IMAGE}`);
4383
5491
  const removed = await removeImage(DEFAULT_BOX_IMAGE);
4384
5492
  s.stop(
4385
5493
  removed ? `removed image ${DEFAULT_BOX_IMAGE} (rebuilds on next create/claude)` : `image ${DEFAULT_BOX_IMAGE} not present (nothing to remove)`
4386
5494
  );
4387
- const sr = spinner3();
5495
+ const sr = spinner4();
4388
5496
  sr.start("stopping relay");
4389
5497
  const stop = await stopRelay();
4390
5498
  sr.stop(
4391
5499
  stop.stopped ? `stopped relay (pid ${String(stop.pid)})` : "relay was not running"
4392
5500
  );
4393
5501
  if (selfUpdated) {
4394
- log23.info(
5502
+ log24.info(
4395
5503
  "relay will restart automatically (with the updated build) on your next `agentbox create` / `agentbox claude`"
4396
5504
  );
4397
5505
  } else {
4398
- const sr2 = spinner3();
5506
+ const sr2 = spinner4();
4399
5507
  sr2.start("restarting relay");
4400
5508
  try {
4401
5509
  const ep = await ensureRelay();
4402
5510
  sr2.stop(`relay back up on ${ep.hostUrl}`);
4403
5511
  } catch (err) {
4404
5512
  sr2.stop("relay restart failed");
4405
- log23.warn(
5513
+ log24.warn(
4406
5514
  `${err instanceof Error ? err.message : String(err)} \u2014 it will retry on the next box command`
4407
5515
  );
4408
5516
  }
@@ -4414,9 +5522,9 @@ var updateCommand = new Command25("self-update").description(
4414
5522
  });
4415
5523
 
4416
5524
  // src/commands/wait.ts
4417
- import { log as log24 } from "@clack/prompts";
4418
- import { Command as Command26 } from "commander";
4419
- var waitCommand = new Command26("wait").description("Block until the box reports all autostart units ready").argument(
5525
+ import { log as log25 } from "@clack/prompts";
5526
+ import { Command as Command28 } from "commander";
5527
+ var waitCommand = new Command28("wait").description("Block until the box reports all autostart units ready").argument(
4420
5528
  "[box]",
4421
5529
  "box ref: project index, id, id prefix, name, or container (default: the only box in this project)"
4422
5530
  ).option("--timeout <ms>", "overall timeout in milliseconds", "120000").option("--units <names...>", "restrict to the named units").option("-j, --json", "machine-readable JSON output").action(async (idOrName, opts) => {
@@ -4431,7 +5539,7 @@ var waitCommand = new Command26("wait").description("Block until the box reports
4431
5539
  try {
4432
5540
  parsed = JSON.parse(proc.stdout);
4433
5541
  } catch {
4434
- log24.error(`agentbox-ctl wait-ready failed: ${proc.stderr || proc.stdout}`);
5542
+ log25.error(`agentbox-ctl wait-ready failed: ${proc.stderr || proc.stdout}`);
4435
5543
  process.exit(1);
4436
5544
  }
4437
5545
  if (opts.json) {
@@ -4451,7 +5559,8 @@ var waitCommand = new Command26("wait").description("Block until the box reports
4451
5559
  });
4452
5560
 
4453
5561
  // src/index.ts
4454
- var program = new Command27();
5562
+ process.env.DOCKER_CLI_HINTS ??= "false";
5563
+ var program = new Command29();
4455
5564
  program.name("agentbox").description("Launch coding agents in isolated sandboxes").version("0.0.0");
4456
5565
  program.enablePositionalOptions();
4457
5566
  program.addCommand(createCommand);
@@ -4462,7 +5571,8 @@ program.addCommand(listCommand2);
4462
5571
  program.addCommand(openCommand);
4463
5572
  program.addCommand(browserCommand);
4464
5573
  program.addCommand(screenCommand);
4465
- program.addCommand(pullCommand);
5574
+ program.addCommand(downloadCommand);
5575
+ program.addCommand(cpCommand);
4466
5576
  program.addCommand(statusCommand);
4467
5577
  program.addCommand(topCommand);
4468
5578
  program.addCommand(dashboardCommand);
@@ -4476,6 +5586,7 @@ program.addCommand(destroyCommand);
4476
5586
  program.addCommand(pruneCommand);
4477
5587
  program.addCommand(checkpointCommand);
4478
5588
  program.addCommand(configCommand);
5589
+ program.addCommand(relayCommand);
4479
5590
  program.addCommand(updateCommand);
4480
5591
  program.configureHelp({ visibleCommands: () => [] });
4481
5592
  program.addHelpText("after", () => "\n" + buildGroupedHelp(program));