@locusai/cli 0.17.16 → 0.18.1

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 (2) hide show
  1. package/bin/locus.js +2148 -535
  2. package/package.json +1 -1
package/bin/locus.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  var __defProp = Object.defineProperty;
3
4
  var __returnValue = (v) => v;
4
5
  function __exportSetter(name, newValue) {
@@ -14,6 +15,7 @@ var __export = (target, all) => {
14
15
  });
15
16
  };
16
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
17
19
 
18
20
  // src/core/ai-models.ts
19
21
  function inferProviderFromModel(model) {
@@ -520,7 +522,7 @@ var init_config = __esm(() => {
520
522
  init_ai_models();
521
523
  init_logger();
522
524
  DEFAULT_CONFIG = {
523
- version: "3.0.0",
525
+ version: "3.1.0",
524
526
  github: {
525
527
  owner: "",
526
528
  repo: "",
@@ -545,9 +547,28 @@ var init_config = __esm(() => {
545
547
  level: "normal",
546
548
  maxFiles: 20,
547
549
  maxTotalSizeMB: 50
550
+ },
551
+ sandbox: {
552
+ enabled: true,
553
+ extraWorkspaces: [],
554
+ readOnlyPaths: []
548
555
  }
549
556
  };
550
- migrations = [];
557
+ migrations = [
558
+ {
559
+ from: "3.0",
560
+ to: "3.1.0",
561
+ migrate: (config) => {
562
+ config.sandbox ??= {
563
+ enabled: true,
564
+ extraWorkspaces: [],
565
+ readOnlyPaths: []
566
+ };
567
+ config.version = "3.1.0";
568
+ return config;
569
+ }
570
+ }
571
+ ];
551
572
  });
552
573
 
553
574
  // src/core/context.ts
@@ -786,6 +807,14 @@ var init_rate_limiter = __esm(() => {
786
807
  });
787
808
 
788
809
  // src/display/progress.ts
810
+ var exports_progress = {};
811
+ __export(exports_progress, {
812
+ renderTaskStatus: () => renderTaskStatus,
813
+ progressBar: () => progressBar,
814
+ formatDuration: () => formatDuration,
815
+ createTimer: () => createTimer,
816
+ Spinner: () => Spinner
817
+ });
789
818
  function progressBar(current, total, options = {}) {
790
819
  const { width = 30, showPercent = true, showCount = true, label } = options;
791
820
  const percent = total > 0 ? current / total : 0;
@@ -882,6 +911,149 @@ var init_progress = __esm(() => {
882
911
  SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
883
912
  });
884
913
 
914
+ // src/core/sandbox.ts
915
+ var exports_sandbox = {};
916
+ __export(exports_sandbox, {
917
+ resolveSandboxMode: () => resolveSandboxMode,
918
+ displaySandboxWarning: () => displaySandboxWarning,
919
+ detectSandboxSupport: () => detectSandboxSupport
920
+ });
921
+ import { execFile } from "node:child_process";
922
+ import { createInterface } from "node:readline";
923
+ async function detectSandboxSupport() {
924
+ if (cachedStatus)
925
+ return cachedStatus;
926
+ const log = getLogger();
927
+ log.debug("Detecting Docker sandbox support...");
928
+ const status = await runDetection();
929
+ cachedStatus = status;
930
+ if (status.available) {
931
+ log.verbose("Docker sandbox support detected");
932
+ } else {
933
+ log.verbose(`Docker sandbox not available: ${status.reason}`);
934
+ }
935
+ return status;
936
+ }
937
+ function runDetection() {
938
+ return new Promise((resolve) => {
939
+ let settled = false;
940
+ const child = execFile("docker", ["sandbox", "ls"], { timeout: TIMEOUT_MS }, (error, _stdout, stderr) => {
941
+ if (settled)
942
+ return;
943
+ settled = true;
944
+ if (!error) {
945
+ resolve({ available: true });
946
+ return;
947
+ }
948
+ const code = error.code;
949
+ if (code === "ENOENT") {
950
+ resolve({ available: false, reason: "Docker is not installed" });
951
+ return;
952
+ }
953
+ if (error.killed) {
954
+ resolve({ available: false, reason: "Docker is not responding" });
955
+ return;
956
+ }
957
+ const stderrStr = (stderr ?? "").toLowerCase();
958
+ if (stderrStr.includes("unknown") || stderrStr.includes("not a docker command") || stderrStr.includes("is not a docker command")) {
959
+ resolve({
960
+ available: false,
961
+ reason: "Docker Desktop 4.58+ with sandbox support required"
962
+ });
963
+ return;
964
+ }
965
+ resolve({
966
+ available: false,
967
+ reason: "Docker Desktop 4.58+ with sandbox support required"
968
+ });
969
+ });
970
+ child.on?.("error", (err) => {
971
+ if (settled)
972
+ return;
973
+ settled = true;
974
+ if (err.code === "ENOENT") {
975
+ resolve({ available: false, reason: "Docker is not installed" });
976
+ } else {
977
+ resolve({
978
+ available: false,
979
+ reason: "Docker Desktop 4.58+ with sandbox support required"
980
+ });
981
+ }
982
+ });
983
+ });
984
+ }
985
+ function resolveSandboxMode(config, flags) {
986
+ if (flags.noSandbox) {
987
+ return "disabled";
988
+ }
989
+ if (flags.sandbox !== undefined) {
990
+ if (flags.sandbox === "require") {
991
+ return "required";
992
+ }
993
+ throw new Error(`Invalid --sandbox value: "${flags.sandbox}". Valid values: require`);
994
+ }
995
+ if (!config.enabled) {
996
+ return "disabled";
997
+ }
998
+ return "auto";
999
+ }
1000
+ async function displaySandboxWarning(mode, status) {
1001
+ if (mode === "required" && !status.available) {
1002
+ process.stderr.write(`
1003
+ ${red("✖")} Docker sandbox required but not available: ${bold(status.reason ?? "Docker Desktop 4.58+ with sandbox support required")}
1004
+ `);
1005
+ process.stderr.write(` Install Docker Desktop 4.58+ or remove --sandbox=require to continue.
1006
+
1007
+ `);
1008
+ process.exit(1);
1009
+ }
1010
+ if (mode === "disabled") {
1011
+ process.stderr.write(`
1012
+ ${yellow("⚠")} ${bold("WARNING:")} Running without sandbox. The AI agent will have unrestricted
1013
+ `);
1014
+ process.stderr.write(` access to your filesystem, network, and environment variables.
1015
+ `);
1016
+ if (process.stdin.isTTY) {
1017
+ process.stderr.write(` Press ${bold("Enter")} to continue or ${bold("Ctrl+C")} to abort.
1018
+ `);
1019
+ await waitForEnter();
1020
+ }
1021
+ process.stderr.write(`
1022
+ `);
1023
+ return false;
1024
+ }
1025
+ if (mode === "auto" && !status.available) {
1026
+ process.stderr.write(`
1027
+ ${yellow("⚠")} Docker sandbox not available. Install Docker Desktop 4.58+ for secure execution.
1028
+ `);
1029
+ process.stderr.write(` Running without sandbox. Use ${dim("--no-sandbox")} to suppress this warning.
1030
+
1031
+ `);
1032
+ return false;
1033
+ }
1034
+ return true;
1035
+ }
1036
+ function waitForEnter() {
1037
+ return new Promise((resolve) => {
1038
+ const rl = createInterface({
1039
+ input: process.stdin,
1040
+ output: process.stderr
1041
+ });
1042
+ rl.once("line", () => {
1043
+ rl.close();
1044
+ resolve();
1045
+ });
1046
+ rl.once("close", () => {
1047
+ resolve();
1048
+ });
1049
+ });
1050
+ }
1051
+ var TIMEOUT_MS = 5000, cachedStatus = null;
1052
+ var init_sandbox = __esm(() => {
1053
+ init_terminal();
1054
+ init_logger();
1055
+ });
1056
+
885
1057
  // src/commands/upgrade.ts
886
1058
  var exports_upgrade = {};
887
1059
  __export(exports_upgrade, {
@@ -1582,6 +1754,15 @@ ${bold("Initializing Locus...")}
1582
1754
  `);
1583
1755
  } else {
1584
1756
  process.stderr.write(`${dim("○")} LEARNINGS.md already exists (preserved)
1757
+ `);
1758
+ }
1759
+ const sandboxIgnorePath = join5(cwd, ".sandboxignore");
1760
+ if (!existsSync5(sandboxIgnorePath)) {
1761
+ writeFileSync4(sandboxIgnorePath, SANDBOXIGNORE_TEMPLATE, "utf-8");
1762
+ process.stderr.write(`${green("✓")} Generated .sandboxignore
1763
+ `);
1764
+ } else {
1765
+ process.stderr.write(`${dim("○")} .sandboxignore already exists (preserved)
1585
1766
  `);
1586
1767
  }
1587
1768
  process.stderr.write(`${cyan("●")} Creating GitHub labels...`);
@@ -1627,6 +1808,23 @@ ${bold(green("Locus initialized!"))}
1627
1808
  process.stderr.write(` ${gray("4.")} Start coding: ${bold("locus exec")}
1628
1809
  `);
1629
1810
  process.stderr.write(`
1811
+ ${bold("Sandbox mode")} ${dim("(recommended)")}
1812
+ `);
1813
+ process.stderr.write(` Run AI agents in an isolated Docker sandbox for safety.
1814
+
1815
+ `);
1816
+ process.stderr.write(` ${gray("1.")} ${cyan("locus sandbox")} ${dim("Create the sandbox environment")}
1817
+ `);
1818
+ process.stderr.write(` ${gray("2.")} ${cyan("locus sandbox claude")} ${dim("Login to Claude inside the sandbox")}
1819
+ `);
1820
+ process.stderr.write(` ${gray("3.")} ${cyan("locus exec")} ${dim("All commands now run sandboxed")}
1821
+ `);
1822
+ process.stderr.write(`
1823
+ ${dim("Using Codex? Run")} ${cyan("locus sandbox codex")} ${dim("instead of step 2.")}
1824
+ `);
1825
+ process.stderr.write(` ${dim("Learn more:")} ${cyan("locus sandbox help")}
1826
+ `);
1827
+ process.stderr.write(`
1630
1828
  `);
1631
1829
  log.info("Locus initialized", {
1632
1830
  owner: context.owner,
@@ -1743,6 +1941,31 @@ Read ".locus/LEARNINGS.md" **before starting any task** to avoid repeating mista
1743
1941
  ## Development Workflow
1744
1942
 
1745
1943
  <!-- How to run, test, build, and deploy the project -->
1944
+ `, SANDBOXIGNORE_TEMPLATE = `# Files and directories to exclude from sandbox environments.
1945
+ # Patterns follow .gitignore syntax (one per line, # for comments).
1946
+ # These files will be removed from the sandbox after creation.
1947
+
1948
+ # Environment files
1949
+ .env
1950
+ .env.*
1951
+ !.env.example
1952
+
1953
+ # Secrets and credentials
1954
+ *.pem
1955
+ *.key
1956
+ *.p12
1957
+ *.pfx
1958
+ *.keystore
1959
+ credentials.json
1960
+ service-account*.json
1961
+
1962
+ # Cloud provider configs
1963
+ .aws/
1964
+ .gcp/
1965
+ .azure/
1966
+
1967
+ # Docker secrets
1968
+ docker-compose.override.yml
1746
1969
  `, LEARNINGS_MD_TEMPLATE = `# Learnings
1747
1970
 
1748
1971
  This file captures important lessons, decisions, and corrections made during development.
@@ -1768,7 +1991,8 @@ var init_init = __esm(() => {
1768
1991
  ".locus/logs/",
1769
1992
  ".locus/worktrees/",
1770
1993
  ".locus/artifacts/",
1771
- ".locus/discussions/"
1994
+ ".locus/discussions/",
1995
+ ".locus/tmp/"
1772
1996
  ];
1773
1997
  });
1774
1998
 
@@ -2611,24 +2835,33 @@ var init_status_indicator = __esm(() => {
2611
2835
  startTime = 0;
2612
2836
  activity = "";
2613
2837
  frame = 0;
2838
+ message = "";
2614
2839
  static BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2615
2840
  static DIAMOND = "◆";
2616
2841
  start(message, options) {
2617
2842
  this.stop();
2618
2843
  this.startTime = Date.now();
2619
2844
  this.activity = options?.activity ?? "";
2845
+ this.message = message;
2620
2846
  this.frame = 0;
2621
2847
  if (process.stderr.isTTY) {
2622
2848
  process.stderr.write("\x1B[?25l");
2623
2849
  }
2850
+ this.render();
2851
+ this.frame++;
2624
2852
  this.timer = setInterval(() => {
2625
- this.render(message);
2853
+ this.render();
2626
2854
  this.frame++;
2627
2855
  }, 80);
2628
2856
  }
2629
2857
  setActivity(activity) {
2630
2858
  this.activity = activity;
2631
2859
  }
2860
+ setMessage(message) {
2861
+ this.message = message;
2862
+ if (this.timer)
2863
+ this.render();
2864
+ }
2632
2865
  stop() {
2633
2866
  if (this.timer) {
2634
2867
  clearInterval(this.timer);
@@ -2641,7 +2874,8 @@ var init_status_indicator = __esm(() => {
2641
2874
  isActive() {
2642
2875
  return this.timer !== null;
2643
2876
  }
2644
- render(message) {
2877
+ render() {
2878
+ const message = this.message;
2645
2879
  const caps = getCapabilities();
2646
2880
  const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
2647
2881
  const elapsedStr = `${elapsed}s`;
@@ -2663,7 +2897,7 @@ var init_status_indicator = __esm(() => {
2663
2897
  }
2664
2898
  if (!process.stderr.isTTY)
2665
2899
  return;
2666
- process.stderr.write("\x1B[2K\r" + line);
2900
+ process.stderr.write(`\x1B[2K\r${line}`);
2667
2901
  }
2668
2902
  renderShimmer() {
2669
2903
  const t = Date.now() / 1000;
@@ -2829,14 +3063,87 @@ var init_stream_renderer = __esm(() => {
2829
3063
  init_terminal();
2830
3064
  });
2831
3065
 
3066
+ // src/repl/clipboard.ts
3067
+ import { execSync as execSync4 } from "node:child_process";
3068
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7 } from "node:fs";
3069
+ import { tmpdir } from "node:os";
3070
+ import { join as join9 } from "node:path";
3071
+ function readClipboardImage() {
3072
+ if (process.platform === "darwin") {
3073
+ return readMacOSClipboardImage();
3074
+ }
3075
+ if (process.platform === "linux") {
3076
+ return readLinuxClipboardImage();
3077
+ }
3078
+ return null;
3079
+ }
3080
+ function ensureStableDir() {
3081
+ if (!existsSync10(STABLE_DIR)) {
3082
+ mkdirSync7(STABLE_DIR, { recursive: true });
3083
+ }
3084
+ }
3085
+ function readMacOSClipboardImage() {
3086
+ try {
3087
+ ensureStableDir();
3088
+ const destPath = join9(STABLE_DIR, `clipboard-${Date.now()}.png`);
3089
+ const script = [
3090
+ `set destPath to POSIX file "${destPath}"`,
3091
+ "try",
3092
+ ` set imgData to the clipboard as «class PNGf»`,
3093
+ "on error",
3094
+ " try",
3095
+ ` set imgData to the clipboard as «class TIFF»`,
3096
+ " on error",
3097
+ ` return "no-image"`,
3098
+ " end try",
3099
+ "end try",
3100
+ "set fRef to open for access destPath with write permission",
3101
+ "write imgData to fRef",
3102
+ "close access fRef",
3103
+ `return "ok"`
3104
+ ].join(`
3105
+ `);
3106
+ const result = execSync4("osascript", {
3107
+ input: script,
3108
+ encoding: "utf-8",
3109
+ timeout: 5000,
3110
+ stdio: ["pipe", "pipe", "pipe"]
3111
+ }).trim();
3112
+ if (result === "ok" && existsSync10(destPath)) {
3113
+ return destPath;
3114
+ }
3115
+ } catch {}
3116
+ return null;
3117
+ }
3118
+ function readLinuxClipboardImage() {
3119
+ try {
3120
+ const targets = execSync4("xclip -selection clipboard -t TARGETS -o 2>/dev/null", { encoding: "utf-8", timeout: 3000 });
3121
+ if (!targets.includes("image/png")) {
3122
+ return null;
3123
+ }
3124
+ ensureStableDir();
3125
+ const destPath = join9(STABLE_DIR, `clipboard-${Date.now()}.png`);
3126
+ execSync4(`xclip -selection clipboard -t image/png -o > "${destPath}" 2>/dev/null`, { timeout: 5000 });
3127
+ if (existsSync10(destPath)) {
3128
+ return destPath;
3129
+ }
3130
+ } catch {}
3131
+ return null;
3132
+ }
3133
+ var STABLE_DIR;
3134
+ var init_clipboard = __esm(() => {
3135
+ STABLE_DIR = join9(tmpdir(), "locus-images");
3136
+ });
3137
+
2832
3138
  // src/repl/image-detect.ts
2833
- import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync7 } from "node:fs";
2834
- import { homedir as homedir3, tmpdir } from "node:os";
2835
- import { basename, extname, join as join9, resolve } from "node:path";
3139
+ import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync8 } from "node:fs";
3140
+ import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
3141
+ import { basename, extname, join as join10, resolve } from "node:path";
2836
3142
  function detectImages(input) {
2837
3143
  const detected = [];
2838
3144
  const byResolved = new Map;
2839
- for (const line of input.split(`
3145
+ const sanitized = input.replace(/!\[Screenshot:[^\]]*\]\(locus:\/\/screenshot-\d+\)/g, "");
3146
+ for (const line of sanitized.split(`
2840
3147
  `)) {
2841
3148
  const trimmed = line.trim();
2842
3149
  if (!trimmed)
@@ -2847,20 +3154,20 @@ function detectImages(input) {
2847
3154
  }
2848
3155
  }
2849
3156
  const quotedPattern = /["']([^"']+\.(?:png|jpg|jpeg|gif|webp|bmp|svg))["']/gi;
2850
- for (const match of input.matchAll(quotedPattern)) {
3157
+ for (const match of sanitized.matchAll(quotedPattern)) {
2851
3158
  if (!match[0] || !match[1])
2852
3159
  continue;
2853
3160
  addIfImage(match[1], match[0], detected, byResolved);
2854
3161
  }
2855
3162
  const escapedPattern = /(?:\/|~\/|\.\/)?(?:[^\s"'\\]|\\ )+\.(?:png|jpg|jpeg|gif|webp|bmp|svg|tiff?)/gi;
2856
- for (const match of input.matchAll(escapedPattern)) {
3163
+ for (const match of sanitized.matchAll(escapedPattern)) {
2857
3164
  if (!match[0])
2858
3165
  continue;
2859
3166
  const path = match[0].replace(/\\ /g, " ");
2860
3167
  addIfImage(path, match[0], detected, byResolved);
2861
3168
  }
2862
3169
  const plainPattern = /(?:\/|~\/|\.\/)[^\s"']+\.(?:png|jpg|jpeg|gif|webp|bmp|svg|tiff?)/gi;
2863
- for (const match of input.matchAll(plainPattern)) {
3170
+ for (const match of sanitized.matchAll(plainPattern)) {
2864
3171
  if (!match[0])
2865
3172
  continue;
2866
3173
  addIfImage(match[0], match[0], detected, byResolved);
@@ -2924,13 +3231,28 @@ function collectReferencedAttachments(input, attachments) {
2924
3231
  const selected = attachments.filter((attachment) => ids.has(attachment.id));
2925
3232
  return dedupeByResolvedPath(selected);
2926
3233
  }
3234
+ function relocateImages(images, projectRoot) {
3235
+ const targetDir = join10(projectRoot, ".locus", "tmp", "images");
3236
+ for (const img of images) {
3237
+ if (!img.exists)
3238
+ continue;
3239
+ try {
3240
+ if (!existsSync11(targetDir)) {
3241
+ mkdirSync8(targetDir, { recursive: true });
3242
+ }
3243
+ const dest = join10(targetDir, basename(img.stablePath));
3244
+ copyFileSync(img.stablePath, dest);
3245
+ img.stablePath = dest;
3246
+ } catch {}
3247
+ }
3248
+ }
2927
3249
  function addIfImage(rawPath, rawMatch, detected, byResolved) {
2928
3250
  const ext = extname(rawPath).toLowerCase();
2929
3251
  if (!IMAGE_EXTENSIONS.has(ext))
2930
3252
  return;
2931
3253
  let resolved = stripQuotes(rawPath).replace(/\\ /g, " ");
2932
3254
  if (resolved.startsWith("~/")) {
2933
- resolved = join9(homedir3(), resolved.slice(2));
3255
+ resolved = join10(homedir3(), resolved.slice(2));
2934
3256
  }
2935
3257
  resolved = resolve(resolved);
2936
3258
  const existing = byResolved.get(resolved);
@@ -2943,7 +3265,7 @@ function addIfImage(rawPath, rawMatch, detected, byResolved) {
2943
3265
  ]);
2944
3266
  return;
2945
3267
  }
2946
- const exists = existsSync10(resolved);
3268
+ const exists = existsSync11(resolved);
2947
3269
  let stablePath = resolved;
2948
3270
  if (exists) {
2949
3271
  stablePath = copyToStable(resolved);
@@ -2997,17 +3319,17 @@ function dedupeByResolvedPath(images) {
2997
3319
  }
2998
3320
  function copyToStable(sourcePath) {
2999
3321
  try {
3000
- if (!existsSync10(STABLE_DIR)) {
3001
- mkdirSync7(STABLE_DIR, { recursive: true });
3322
+ if (!existsSync11(STABLE_DIR2)) {
3323
+ mkdirSync8(STABLE_DIR2, { recursive: true });
3002
3324
  }
3003
- const dest = join9(STABLE_DIR, `${Date.now()}-${basename(sourcePath)}`);
3325
+ const dest = join10(STABLE_DIR2, `${Date.now()}-${basename(sourcePath)}`);
3004
3326
  copyFileSync(sourcePath, dest);
3005
3327
  return dest;
3006
3328
  } catch {
3007
3329
  return sourcePath;
3008
3330
  }
3009
3331
  }
3010
- var IMAGE_EXTENSIONS, STABLE_DIR, PLACEHOLDER_SCHEME = "locus://screenshot-", PLACEHOLDER_ID_PATTERN;
3332
+ var IMAGE_EXTENSIONS, STABLE_DIR2, PLACEHOLDER_SCHEME = "locus://screenshot-", PLACEHOLDER_ID_PATTERN;
3011
3333
  var init_image_detect = __esm(() => {
3012
3334
  IMAGE_EXTENSIONS = new Set([
3013
3335
  ".png",
@@ -3020,7 +3342,7 @@ var init_image_detect = __esm(() => {
3020
3342
  ".tif",
3021
3343
  ".tiff"
3022
3344
  ]);
3023
- STABLE_DIR = join9(tmpdir(), "locus-images");
3345
+ STABLE_DIR2 = join10(tmpdir2(), "locus-images");
3024
3346
  PLACEHOLDER_ID_PATTERN = /\(locus:\/\/screenshot-(\d+)\)/g;
3025
3347
  });
3026
3348
 
@@ -3439,6 +3761,15 @@ ${dim("Press Ctrl+C again to exit")}\r
3439
3761
  pasteBuffer += pending.slice(0, endIdx);
3440
3762
  pending = pending.slice(endIdx + PASTE_END.length);
3441
3763
  isPasting = false;
3764
+ if (pasteBuffer.trim() === "") {
3765
+ const clipboardImagePath = readClipboardImage();
3766
+ if (clipboardImagePath) {
3767
+ insertText(clipboardImagePath);
3768
+ pasteBuffer = "";
3769
+ render();
3770
+ continue;
3771
+ }
3772
+ }
3442
3773
  insertText(normalizeLineEndings(pasteBuffer));
3443
3774
  pasteBuffer = "";
3444
3775
  render();
@@ -3730,6 +4061,7 @@ var CSI = "\x1B[", SAVE_CURSOR = "\x1B7", RESTORE_CURSOR = "\x1B8", ENABLE_BRACK
3730
4061
  `, ESC = "\x1B", BACKSPACE = "", SEQ_LEFT, SEQ_RIGHT, SEQ_UP, SEQ_DOWN, SEQ_HOME, SEQ_END, SEQ_HOME_1, SEQ_END_4, SEQ_HOME_O = "\x1BOH", SEQ_END_O = "\x1BOF", SEQ_DELETE, SEQ_WORD_LEFT, SEQ_WORD_RIGHT, SEQ_SHIFT_LEFT, SEQ_SHIFT_RIGHT, SEQ_META_LEFT, SEQ_META_RIGHT, SEQ_META_SHIFT_LEFT, SEQ_META_SHIFT_RIGHT, SEQ_SHIFT_ENTER_CSI_U, SEQ_SHIFT_ENTER_MODIFY, SEQ_SHIFT_ENTER_TILDE, SEQ_ALT_ENTER, CONTROL_SEQUENCES;
3731
4062
  var init_input_handler = __esm(() => {
3732
4063
  init_terminal();
4064
+ init_clipboard();
3733
4065
  init_image_detect();
3734
4066
  ENABLE_BRACKETED_PASTE = `${CSI}?2004h`;
3735
4067
  DISABLE_BRACKETED_PASTE = `${CSI}?2004l`;
@@ -3787,7 +4119,25 @@ var init_input_handler = __esm(() => {
3787
4119
  });
3788
4120
 
3789
4121
  // src/ai/claude.ts
3790
- import { execSync as execSync4, spawn as spawn2 } from "node:child_process";
4122
+ var exports_claude = {};
4123
+ __export(exports_claude, {
4124
+ buildClaudeArgs: () => buildClaudeArgs,
4125
+ ClaudeRunner: () => ClaudeRunner
4126
+ });
4127
+ import { execSync as execSync5, spawn as spawn2 } from "node:child_process";
4128
+ function buildClaudeArgs(options) {
4129
+ const args = [
4130
+ "--dangerously-skip-permissions",
4131
+ "--no-session-persistence"
4132
+ ];
4133
+ if (options.model) {
4134
+ args.push("--model", options.model);
4135
+ }
4136
+ if (options.verbose) {
4137
+ args.push("--verbose", "--output-format", "stream-json");
4138
+ }
4139
+ return args;
4140
+ }
3791
4141
 
3792
4142
  class ClaudeRunner {
3793
4143
  name = "claude";
@@ -3795,7 +4145,7 @@ class ClaudeRunner {
3795
4145
  aborted = false;
3796
4146
  async isAvailable() {
3797
4147
  try {
3798
- execSync4("claude --version", {
4148
+ execSync5("claude --version", {
3799
4149
  encoding: "utf-8",
3800
4150
  stdio: ["pipe", "pipe", "pipe"]
3801
4151
  });
@@ -3806,7 +4156,7 @@ class ClaudeRunner {
3806
4156
  }
3807
4157
  async getVersion() {
3808
4158
  try {
3809
- const output = execSync4("claude --version", {
4159
+ const output = execSync5("claude --version", {
3810
4160
  encoding: "utf-8",
3811
4161
  stdio: ["pipe", "pipe", "pipe"]
3812
4162
  }).trim();
@@ -3818,17 +4168,7 @@ class ClaudeRunner {
3818
4168
  async execute(options) {
3819
4169
  const log = getLogger();
3820
4170
  this.aborted = false;
3821
- const args = [
3822
- "--print",
3823
- "--dangerously-skip-permissions",
3824
- "--no-session-persistence"
3825
- ];
3826
- if (options.model) {
3827
- args.push("--model", options.model);
3828
- }
3829
- if (options.verbose) {
3830
- args.push("--verbose", "--output-format", "stream-json");
3831
- }
4171
+ const args = ["--print", ...buildClaudeArgs(options)];
3832
4172
  log.debug("Spawning claude", { args: args.join(" "), cwd: options.cwd });
3833
4173
  return new Promise((resolve2) => {
3834
4174
  let output = "";
@@ -3979,121 +4319,709 @@ var init_claude = __esm(() => {
3979
4319
  init_logger();
3980
4320
  });
3981
4321
 
3982
- // src/ai/codex.ts
3983
- import { execSync as execSync5, spawn as spawn3 } from "node:child_process";
3984
- function buildCodexArgs(model) {
3985
- const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
3986
- if (model) {
3987
- args.push("--model", model);
4322
+ // src/core/sandbox-ignore.ts
4323
+ import { exec } from "node:child_process";
4324
+ import { existsSync as existsSync12, readFileSync as readFileSync8 } from "node:fs";
4325
+ import { join as join11 } from "node:path";
4326
+ import { promisify } from "node:util";
4327
+ function parseIgnoreFile(filePath) {
4328
+ if (!existsSync12(filePath))
4329
+ return [];
4330
+ const content = readFileSync8(filePath, "utf-8");
4331
+ const rules = [];
4332
+ for (const rawLine of content.split(`
4333
+ `)) {
4334
+ const line = rawLine.trim();
4335
+ if (!line || line.startsWith("#"))
4336
+ continue;
4337
+ const negated = line.startsWith("!");
4338
+ const raw = negated ? line.slice(1) : line;
4339
+ const isDirectory = raw.endsWith("/");
4340
+ const pattern = isDirectory ? raw.slice(0, -1) : raw;
4341
+ rules.push({ pattern, negated, isDirectory });
3988
4342
  }
3989
- args.push("-");
3990
- return args;
4343
+ return rules;
3991
4344
  }
3992
-
3993
- class CodexRunner {
3994
- name = "codex";
3995
- process = null;
3996
- aborted = false;
3997
- async isAvailable() {
3998
- try {
3999
- execSync5("codex --version", {
4000
- encoding: "utf-8",
4001
- stdio: ["pipe", "pipe", "pipe"]
4002
- });
4003
- return true;
4004
- } catch {
4005
- return false;
4345
+ function shellEscape(s) {
4346
+ return s.replace(/'/g, "'\\''");
4347
+ }
4348
+ function buildCleanupScript(rules, workspacePath) {
4349
+ const positive = rules.filter((r) => !r.negated);
4350
+ const negated = rules.filter((r) => r.negated);
4351
+ if (positive.length === 0)
4352
+ return null;
4353
+ const exclusions = negated.map((r) => `! -name '${shellEscape(r.pattern)}'`).join(" ");
4354
+ const commands = [];
4355
+ for (const rule of positive) {
4356
+ const parts = ["find", `'${shellEscape(workspacePath)}'`];
4357
+ if (rule.isDirectory) {
4358
+ parts.push("-type d");
4359
+ }
4360
+ parts.push(`-name '${shellEscape(rule.pattern)}'`);
4361
+ if (exclusions) {
4362
+ parts.push(exclusions);
4363
+ }
4364
+ if (rule.isDirectory) {
4365
+ parts.push("-exec rm -rf {} +");
4366
+ } else {
4367
+ parts.push("-delete");
4006
4368
  }
4369
+ commands.push(parts.join(" "));
4007
4370
  }
4008
- async getVersion() {
4009
- try {
4010
- const output = execSync5("codex --version", {
4011
- encoding: "utf-8",
4012
- stdio: ["pipe", "pipe", "pipe"]
4013
- }).trim();
4014
- return output.replace(/^codex\s*/i, "");
4015
- } catch {
4016
- return "unknown";
4017
- }
4371
+ return `${commands.join(" 2>/dev/null ; ")} 2>/dev/null`;
4372
+ }
4373
+ async function enforceSandboxIgnore(sandboxName, projectRoot) {
4374
+ const log = getLogger();
4375
+ const ignorePath = join11(projectRoot, ".sandboxignore");
4376
+ const rules = parseIgnoreFile(ignorePath);
4377
+ if (rules.length === 0)
4378
+ return;
4379
+ const script = buildCleanupScript(rules, projectRoot);
4380
+ if (!script)
4381
+ return;
4382
+ log.debug("Enforcing .sandboxignore", {
4383
+ sandboxName,
4384
+ ruleCount: rules.length
4385
+ });
4386
+ try {
4387
+ await execAsync(`docker sandbox exec ${sandboxName} sh -c ${JSON.stringify(script)}`, { timeout: 15000 });
4388
+ log.debug("sandbox-ignore enforcement complete", { sandboxName });
4389
+ } catch (err) {
4390
+ log.debug("sandbox-ignore enforcement failed (non-fatal)", {
4391
+ sandboxName,
4392
+ error: err instanceof Error ? err.message : String(err)
4393
+ });
4018
4394
  }
4019
- async execute(options) {
4020
- const log = getLogger();
4021
- this.aborted = false;
4022
- const args = buildCodexArgs(options.model);
4023
- log.debug("Spawning codex", { args: args.join(" "), cwd: options.cwd });
4024
- return new Promise((resolve2) => {
4025
- let rawOutput = "";
4026
- let errorOutput = "";
4027
- this.process = spawn3("codex", args, {
4028
- cwd: options.cwd,
4029
- stdio: ["pipe", "pipe", "pipe"],
4030
- env: { ...process.env }
4031
- });
4032
- let agentMessages = [];
4033
- const flushAgentMessages = () => {
4034
- if (agentMessages.length > 0) {
4035
- options.onOutput?.(agentMessages.join(`
4395
+ }
4396
+ var execAsync;
4397
+ var init_sandbox_ignore = __esm(() => {
4398
+ init_logger();
4399
+ execAsync = promisify(exec);
4400
+ });
4036
4401
 
4037
- `));
4038
- agentMessages = [];
4039
- }
4040
- };
4041
- let lineBuffer = "";
4042
- this.process.stdout?.on("data", (chunk) => {
4043
- lineBuffer += chunk.toString();
4044
- const lines = lineBuffer.split(`
4045
- `);
4046
- lineBuffer = lines.pop() ?? "";
4047
- for (const line of lines) {
4048
- if (!line.trim())
4049
- continue;
4050
- rawOutput += `${line}
4051
- `;
4052
- log.debug("codex stdout line", { line });
4053
- try {
4054
- const event = JSON.parse(line);
4055
- const { type, item } = event;
4056
- if (type === "item.started" && item?.type === "command_execution") {
4057
- const cmd = (item.command ?? "").split(`
4058
- `)[0].slice(0, 80);
4059
- options.onToolActivity?.(`running: ${cmd}`);
4060
- } else if (type === "item.completed" && item?.type === "command_execution") {
4061
- const code = item.exit_code;
4062
- options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
4063
- } else if (type === "item.completed" && item?.type === "reasoning") {
4064
- const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
4065
- if (text)
4066
- options.onToolActivity?.(text);
4067
- } else if (type === "item.completed" && item?.type === "agent_message") {
4068
- const text = item.text ?? "";
4069
- if (text) {
4070
- agentMessages.push(text);
4071
- options.onToolActivity?.(text.split(`
4072
- `)[0].slice(0, 80));
4073
- }
4074
- } else if (type === "turn.completed") {
4075
- flushAgentMessages();
4076
- }
4077
- } catch {
4078
- const newLine = `${line}
4079
- `;
4080
- rawOutput += newLine;
4081
- options.onOutput?.(newLine);
4082
- }
4083
- }
4084
- });
4085
- this.process.stderr?.on("data", (chunk) => {
4086
- const text = chunk.toString();
4087
- errorOutput += text;
4088
- log.debug("codex stderr", { text: text.slice(0, 500) });
4089
- });
4090
- this.process.on("close", (code) => {
4091
- this.process = null;
4092
- flushAgentMessages();
4093
- if (this.aborted) {
4094
- resolve2({
4095
- success: false,
4096
- output: rawOutput,
4402
+ // src/core/run-state.ts
4403
+ import {
4404
+ existsSync as existsSync13,
4405
+ mkdirSync as mkdirSync9,
4406
+ readFileSync as readFileSync9,
4407
+ unlinkSync as unlinkSync3,
4408
+ writeFileSync as writeFileSync6
4409
+ } from "node:fs";
4410
+ import { dirname as dirname3, join as join12 } from "node:path";
4411
+ function getRunStatePath(projectRoot) {
4412
+ return join12(projectRoot, ".locus", "run-state.json");
4413
+ }
4414
+ function loadRunState(projectRoot) {
4415
+ const path = getRunStatePath(projectRoot);
4416
+ if (!existsSync13(path))
4417
+ return null;
4418
+ try {
4419
+ return JSON.parse(readFileSync9(path, "utf-8"));
4420
+ } catch {
4421
+ getLogger().warn("Corrupted run-state.json, ignoring");
4422
+ return null;
4423
+ }
4424
+ }
4425
+ function saveRunState(projectRoot, state) {
4426
+ const path = getRunStatePath(projectRoot);
4427
+ const dir = dirname3(path);
4428
+ if (!existsSync13(dir)) {
4429
+ mkdirSync9(dir, { recursive: true });
4430
+ }
4431
+ writeFileSync6(path, `${JSON.stringify(state, null, 2)}
4432
+ `, "utf-8");
4433
+ }
4434
+ function clearRunState(projectRoot) {
4435
+ const path = getRunStatePath(projectRoot);
4436
+ if (existsSync13(path)) {
4437
+ unlinkSync3(path);
4438
+ }
4439
+ }
4440
+ function createSprintRunState(sprint, branch, issues) {
4441
+ return {
4442
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4443
+ type: "sprint",
4444
+ sprint,
4445
+ branch,
4446
+ startedAt: new Date().toISOString(),
4447
+ tasks: issues.map(({ number, order }) => ({
4448
+ issue: number,
4449
+ order,
4450
+ status: "pending"
4451
+ }))
4452
+ };
4453
+ }
4454
+ function createParallelRunState(issueNumbers) {
4455
+ return {
4456
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4457
+ type: "parallel",
4458
+ startedAt: new Date().toISOString(),
4459
+ tasks: issueNumbers.map((issue, i) => ({
4460
+ issue,
4461
+ order: i + 1,
4462
+ status: "pending"
4463
+ }))
4464
+ };
4465
+ }
4466
+ function markTaskInProgress(state, issueNumber) {
4467
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4468
+ if (task) {
4469
+ task.status = "in_progress";
4470
+ }
4471
+ }
4472
+ function markTaskDone(state, issueNumber, prNumber) {
4473
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4474
+ if (task) {
4475
+ task.status = "done";
4476
+ task.completedAt = new Date().toISOString();
4477
+ if (prNumber)
4478
+ task.pr = prNumber;
4479
+ }
4480
+ }
4481
+ function markTaskFailed(state, issueNumber, error) {
4482
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4483
+ if (task) {
4484
+ task.status = "failed";
4485
+ task.failedAt = new Date().toISOString();
4486
+ task.error = error;
4487
+ }
4488
+ }
4489
+ function getRunStats(state) {
4490
+ const tasks = state.tasks;
4491
+ return {
4492
+ total: tasks.length,
4493
+ done: tasks.filter((t) => t.status === "done").length,
4494
+ failed: tasks.filter((t) => t.status === "failed").length,
4495
+ pending: tasks.filter((t) => t.status === "pending").length,
4496
+ inProgress: tasks.filter((t) => t.status === "in_progress").length
4497
+ };
4498
+ }
4499
+ function getNextTask(state) {
4500
+ const failed = state.tasks.find((t) => t.status === "failed");
4501
+ if (failed)
4502
+ return failed;
4503
+ return state.tasks.find((t) => t.status === "pending") ?? null;
4504
+ }
4505
+ var init_run_state = __esm(() => {
4506
+ init_logger();
4507
+ });
4508
+
4509
+ // src/core/shutdown.ts
4510
+ import { execSync as execSync6 } from "node:child_process";
4511
+ function registerActiveSandbox(name) {
4512
+ activeSandboxes.add(name);
4513
+ }
4514
+ function unregisterActiveSandbox(name) {
4515
+ activeSandboxes.delete(name);
4516
+ }
4517
+ function cleanupActiveSandboxes() {
4518
+ for (const name of activeSandboxes) {
4519
+ try {
4520
+ execSync6(`docker sandbox rm ${name}`, { timeout: 1e4 });
4521
+ } catch {}
4522
+ }
4523
+ activeSandboxes.clear();
4524
+ }
4525
+ function registerShutdownHandlers(ctx) {
4526
+ shutdownContext = ctx;
4527
+ interruptCount = 0;
4528
+ const handler = () => {
4529
+ interruptCount++;
4530
+ if (interruptCount >= 2) {
4531
+ process.stderr.write(`
4532
+ Force exit.
4533
+ `);
4534
+ process.exit(1);
4535
+ }
4536
+ process.stderr.write(`
4537
+
4538
+ Interrupted. Saving state...
4539
+ `);
4540
+ const state = shutdownContext?.getRunState?.();
4541
+ if (state && shutdownContext) {
4542
+ for (const task of state.tasks) {
4543
+ if (task.status === "in_progress") {
4544
+ task.status = "failed";
4545
+ task.failedAt = new Date().toISOString();
4546
+ task.error = "Interrupted by user";
4547
+ }
4548
+ }
4549
+ try {
4550
+ saveRunState(shutdownContext.projectRoot, state);
4551
+ process.stderr.write(`State saved. Resume with: locus run --resume
4552
+ `);
4553
+ } catch {
4554
+ process.stderr.write(`Warning: Could not save run state.
4555
+ `);
4556
+ }
4557
+ }
4558
+ cleanupActiveSandboxes();
4559
+ shutdownContext?.onShutdown?.();
4560
+ if (interruptTimer)
4561
+ clearTimeout(interruptTimer);
4562
+ interruptTimer = setTimeout(() => {
4563
+ interruptCount = 0;
4564
+ }, 2000);
4565
+ setTimeout(() => {
4566
+ process.exit(130);
4567
+ }, 100);
4568
+ };
4569
+ if (!shutdownRegistered) {
4570
+ process.on("SIGINT", handler);
4571
+ process.on("SIGTERM", handler);
4572
+ shutdownRegistered = true;
4573
+ }
4574
+ return () => {
4575
+ process.removeListener("SIGINT", handler);
4576
+ process.removeListener("SIGTERM", handler);
4577
+ shutdownRegistered = false;
4578
+ shutdownContext = null;
4579
+ interruptCount = 0;
4580
+ if (interruptTimer) {
4581
+ clearTimeout(interruptTimer);
4582
+ interruptTimer = null;
4583
+ }
4584
+ };
4585
+ }
4586
+ var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
4587
+ var init_shutdown = __esm(() => {
4588
+ init_run_state();
4589
+ activeSandboxes = new Set;
4590
+ });
4591
+
4592
+ // src/ai/claude-sandbox.ts
4593
+ import { execSync as execSync7, spawn as spawn3 } from "node:child_process";
4594
+
4595
+ class SandboxedClaudeRunner {
4596
+ name = "claude-sandboxed";
4597
+ process = null;
4598
+ aborted = false;
4599
+ sandboxName = null;
4600
+ persistent;
4601
+ sandboxCreated = false;
4602
+ userManaged = false;
4603
+ constructor(persistentName, userManaged = false) {
4604
+ if (persistentName) {
4605
+ this.persistent = true;
4606
+ this.sandboxName = persistentName;
4607
+ this.userManaged = userManaged;
4608
+ if (userManaged) {
4609
+ this.sandboxCreated = true;
4610
+ }
4611
+ } else {
4612
+ this.persistent = false;
4613
+ }
4614
+ }
4615
+ async isAvailable() {
4616
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4617
+ const delegate = new ClaudeRunner2;
4618
+ return delegate.isAvailable();
4619
+ }
4620
+ async getVersion() {
4621
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4622
+ const delegate = new ClaudeRunner2;
4623
+ return delegate.getVersion();
4624
+ }
4625
+ async execute(options) {
4626
+ const log = getLogger();
4627
+ this.aborted = false;
4628
+ const claudeArgs = ["-p", options.prompt, ...buildClaudeArgs(options)];
4629
+ let dockerArgs;
4630
+ if (this.persistent && !this.sandboxName) {
4631
+ throw new Error("Sandbox name is required");
4632
+ }
4633
+ if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
4634
+ const name = this.sandboxName;
4635
+ if (!name) {
4636
+ throw new Error("Sandbox name is required");
4637
+ }
4638
+ options.onStatusChange?.("Syncing sandbox...");
4639
+ await enforceSandboxIgnore(name, options.cwd);
4640
+ options.onStatusChange?.("Thinking...");
4641
+ dockerArgs = [
4642
+ "sandbox",
4643
+ "exec",
4644
+ "-w",
4645
+ options.cwd,
4646
+ name,
4647
+ "claude",
4648
+ ...claudeArgs
4649
+ ];
4650
+ } else {
4651
+ if (!this.persistent) {
4652
+ this.sandboxName = buildSandboxName(options);
4653
+ }
4654
+ const name = this.sandboxName;
4655
+ if (!name) {
4656
+ throw new Error("Sandbox name is required");
4657
+ }
4658
+ registerActiveSandbox(name);
4659
+ options.onStatusChange?.("Syncing sandbox...");
4660
+ dockerArgs = [
4661
+ "sandbox",
4662
+ "run",
4663
+ "--name",
4664
+ name,
4665
+ "claude",
4666
+ options.cwd,
4667
+ "--",
4668
+ ...claudeArgs
4669
+ ];
4670
+ }
4671
+ log.debug("Spawning sandboxed claude", {
4672
+ sandboxName: this.sandboxName,
4673
+ persistent: this.persistent,
4674
+ reusing: this.persistent && this.sandboxCreated,
4675
+ args: dockerArgs.join(" "),
4676
+ cwd: options.cwd
4677
+ });
4678
+ try {
4679
+ return await new Promise((resolve2) => {
4680
+ let output = "";
4681
+ let errorOutput = "";
4682
+ this.process = spawn3("docker", dockerArgs, {
4683
+ stdio: ["ignore", "pipe", "pipe"],
4684
+ env: process.env
4685
+ });
4686
+ if (this.persistent && !this.sandboxCreated) {
4687
+ this.process.on("spawn", () => {
4688
+ this.sandboxCreated = true;
4689
+ });
4690
+ }
4691
+ if (options.verbose) {
4692
+ let lineBuffer = "";
4693
+ const seenToolIds = new Set;
4694
+ this.process.stdout?.on("data", (chunk) => {
4695
+ lineBuffer += chunk.toString();
4696
+ const lines = lineBuffer.split(`
4697
+ `);
4698
+ lineBuffer = lines.pop() ?? "";
4699
+ for (const line of lines) {
4700
+ if (!line.trim())
4701
+ continue;
4702
+ try {
4703
+ const event = JSON.parse(line);
4704
+ if (event.type === "assistant" && event.message?.content) {
4705
+ for (const item of event.message.content) {
4706
+ if (item.type === "tool_use" && item.id && !seenToolIds.has(item.id)) {
4707
+ seenToolIds.add(item.id);
4708
+ options.onToolActivity?.(formatToolCall2(item.name ?? "", item.input ?? {}));
4709
+ }
4710
+ }
4711
+ } else if (event.type === "result") {
4712
+ const text = event.result ?? "";
4713
+ output = text;
4714
+ options.onOutput?.(text);
4715
+ }
4716
+ } catch {
4717
+ const newLine = `${line}
4718
+ `;
4719
+ output += newLine;
4720
+ options.onOutput?.(newLine);
4721
+ }
4722
+ }
4723
+ });
4724
+ } else {
4725
+ this.process.stdout?.on("data", (chunk) => {
4726
+ const text = chunk.toString();
4727
+ output += text;
4728
+ options.onOutput?.(text);
4729
+ });
4730
+ }
4731
+ this.process.stderr?.on("data", (chunk) => {
4732
+ const text = chunk.toString();
4733
+ errorOutput += text;
4734
+ log.debug("sandboxed claude stderr", { text: text.slice(0, 500) });
4735
+ options.onOutput?.(text);
4736
+ });
4737
+ this.process.on("close", (code) => {
4738
+ this.process = null;
4739
+ if (this.aborted) {
4740
+ resolve2({
4741
+ success: false,
4742
+ output,
4743
+ error: "Aborted by user",
4744
+ exitCode: code ?? 143
4745
+ });
4746
+ return;
4747
+ }
4748
+ if (code === 0) {
4749
+ resolve2({
4750
+ success: true,
4751
+ output,
4752
+ exitCode: 0
4753
+ });
4754
+ } else {
4755
+ resolve2({
4756
+ success: false,
4757
+ output,
4758
+ error: errorOutput || `sandboxed claude exited with code ${code}`,
4759
+ exitCode: code ?? 1
4760
+ });
4761
+ }
4762
+ });
4763
+ this.process.on("error", (err) => {
4764
+ this.process = null;
4765
+ if (this.persistent && !this.sandboxCreated) {}
4766
+ resolve2({
4767
+ success: false,
4768
+ output,
4769
+ error: `Failed to spawn docker sandbox: ${err.message}`,
4770
+ exitCode: 1
4771
+ });
4772
+ });
4773
+ if (options.signal) {
4774
+ options.signal.addEventListener("abort", () => {
4775
+ this.abort();
4776
+ });
4777
+ }
4778
+ });
4779
+ } finally {
4780
+ if (!this.persistent) {
4781
+ this.cleanupSandbox();
4782
+ }
4783
+ }
4784
+ }
4785
+ abort() {
4786
+ this.aborted = true;
4787
+ const log = getLogger();
4788
+ if (this.persistent) {
4789
+ log.debug("Aborting sandboxed claude (persistent — keeping sandbox)", {
4790
+ sandboxName: this.sandboxName
4791
+ });
4792
+ if (this.process) {
4793
+ this.process.kill("SIGTERM");
4794
+ const timer = setTimeout(() => {
4795
+ if (this.process) {
4796
+ this.process.kill("SIGKILL");
4797
+ }
4798
+ }, 3000);
4799
+ if (timer.unref)
4800
+ timer.unref();
4801
+ }
4802
+ } else {
4803
+ if (!this.sandboxName)
4804
+ return;
4805
+ log.debug("Aborting sandboxed claude (ephemeral — removing sandbox)", {
4806
+ sandboxName: this.sandboxName
4807
+ });
4808
+ try {
4809
+ execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4810
+ } catch {}
4811
+ }
4812
+ }
4813
+ destroy() {
4814
+ if (!this.sandboxName)
4815
+ return;
4816
+ if (this.userManaged) {
4817
+ unregisterActiveSandbox(this.sandboxName);
4818
+ return;
4819
+ }
4820
+ const log = getLogger();
4821
+ log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
4822
+ try {
4823
+ execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4824
+ } catch {}
4825
+ unregisterActiveSandbox(this.sandboxName);
4826
+ this.sandboxName = null;
4827
+ this.sandboxCreated = false;
4828
+ }
4829
+ cleanupSandbox() {
4830
+ if (!this.sandboxName)
4831
+ return;
4832
+ const log = getLogger();
4833
+ log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
4834
+ try {
4835
+ execSync7(`docker sandbox rm ${this.sandboxName}`, {
4836
+ timeout: 60000
4837
+ });
4838
+ } catch {}
4839
+ unregisterActiveSandbox(this.sandboxName);
4840
+ this.sandboxName = null;
4841
+ }
4842
+ async isSandboxRunning() {
4843
+ if (!this.sandboxName)
4844
+ return false;
4845
+ try {
4846
+ const { promisify: promisify2 } = await import("node:util");
4847
+ const { exec: exec2 } = await import("node:child_process");
4848
+ const execAsync2 = promisify2(exec2);
4849
+ const { stdout } = await execAsync2("docker sandbox ls", {
4850
+ timeout: 5000
4851
+ });
4852
+ return stdout.includes(this.sandboxName);
4853
+ } catch {
4854
+ return false;
4855
+ }
4856
+ }
4857
+ getSandboxName() {
4858
+ return this.sandboxName;
4859
+ }
4860
+ }
4861
+ function buildSandboxName(options) {
4862
+ const ts = Date.now();
4863
+ if (options.activity) {
4864
+ const match = options.activity.match(/issue\s*#(\d+)/i);
4865
+ if (match) {
4866
+ return `locus-issue-${match[1]}-${ts}`;
4867
+ }
4868
+ }
4869
+ const segment = options.cwd.split("/").pop() ?? "run";
4870
+ return `locus-${segment}-${ts}`;
4871
+ }
4872
+ function buildPersistentSandboxName(cwd) {
4873
+ const segment = cwd.split("/").pop() ?? "repl";
4874
+ return `locus-${segment}-${Date.now()}`;
4875
+ }
4876
+ function formatToolCall2(name, input) {
4877
+ switch (name) {
4878
+ case "Read":
4879
+ return `reading ${input.file_path ?? ""}`;
4880
+ case "Write":
4881
+ return `writing ${input.file_path ?? ""}`;
4882
+ case "Edit":
4883
+ case "MultiEdit":
4884
+ return `editing ${input.file_path ?? ""}`;
4885
+ case "Bash":
4886
+ return `running: ${String(input.command ?? "").slice(0, 60)}`;
4887
+ case "Glob":
4888
+ return `glob ${input.pattern ?? ""}`;
4889
+ case "Grep":
4890
+ return `grep ${input.pattern ?? ""}`;
4891
+ case "LS":
4892
+ return `ls ${input.path ?? ""}`;
4893
+ case "WebFetch":
4894
+ return `fetching ${String(input.url ?? "").slice(0, 50)}`;
4895
+ case "WebSearch":
4896
+ return `searching: ${input.query ?? ""}`;
4897
+ case "Task":
4898
+ return `spawning agent`;
4899
+ default:
4900
+ return name;
4901
+ }
4902
+ }
4903
+ var init_claude_sandbox = __esm(() => {
4904
+ init_logger();
4905
+ init_sandbox_ignore();
4906
+ init_shutdown();
4907
+ init_claude();
4908
+ });
4909
+
4910
+ // src/ai/codex.ts
4911
+ import { execSync as execSync8, spawn as spawn4 } from "node:child_process";
4912
+ function buildCodexArgs(model) {
4913
+ const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
4914
+ if (model) {
4915
+ args.push("--model", model);
4916
+ }
4917
+ args.push("-");
4918
+ return args;
4919
+ }
4920
+
4921
+ class CodexRunner {
4922
+ name = "codex";
4923
+ process = null;
4924
+ aborted = false;
4925
+ async isAvailable() {
4926
+ try {
4927
+ execSync8("codex --version", {
4928
+ encoding: "utf-8",
4929
+ stdio: ["pipe", "pipe", "pipe"]
4930
+ });
4931
+ return true;
4932
+ } catch {
4933
+ return false;
4934
+ }
4935
+ }
4936
+ async getVersion() {
4937
+ try {
4938
+ const output = execSync8("codex --version", {
4939
+ encoding: "utf-8",
4940
+ stdio: ["pipe", "pipe", "pipe"]
4941
+ }).trim();
4942
+ return output.replace(/^codex\s*/i, "");
4943
+ } catch {
4944
+ return "unknown";
4945
+ }
4946
+ }
4947
+ async execute(options) {
4948
+ const log = getLogger();
4949
+ this.aborted = false;
4950
+ const args = buildCodexArgs(options.model);
4951
+ log.debug("Spawning codex", { args: args.join(" "), cwd: options.cwd });
4952
+ return new Promise((resolve2) => {
4953
+ let rawOutput = "";
4954
+ let errorOutput = "";
4955
+ this.process = spawn4("codex", args, {
4956
+ cwd: options.cwd,
4957
+ stdio: ["pipe", "pipe", "pipe"],
4958
+ env: { ...process.env }
4959
+ });
4960
+ let agentMessages = [];
4961
+ const flushAgentMessages = () => {
4962
+ if (agentMessages.length > 0) {
4963
+ options.onOutput?.(agentMessages.join(`
4964
+
4965
+ `));
4966
+ agentMessages = [];
4967
+ }
4968
+ };
4969
+ let lineBuffer = "";
4970
+ this.process.stdout?.on("data", (chunk) => {
4971
+ lineBuffer += chunk.toString();
4972
+ const lines = lineBuffer.split(`
4973
+ `);
4974
+ lineBuffer = lines.pop() ?? "";
4975
+ for (const line of lines) {
4976
+ if (!line.trim())
4977
+ continue;
4978
+ rawOutput += `${line}
4979
+ `;
4980
+ log.debug("codex stdout line", { line });
4981
+ try {
4982
+ const event = JSON.parse(line);
4983
+ const { type, item } = event;
4984
+ if (type === "item.started" && item?.type === "command_execution") {
4985
+ const cmd = (item.command ?? "").split(`
4986
+ `)[0].slice(0, 80);
4987
+ options.onToolActivity?.(`running: ${cmd}`);
4988
+ } else if (type === "item.completed" && item?.type === "command_execution") {
4989
+ const code = item.exit_code;
4990
+ options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
4991
+ } else if (type === "item.completed" && item?.type === "reasoning") {
4992
+ const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
4993
+ if (text)
4994
+ options.onToolActivity?.(text);
4995
+ } else if (type === "item.completed" && item?.type === "agent_message") {
4996
+ const text = item.text ?? "";
4997
+ if (text) {
4998
+ agentMessages.push(text);
4999
+ options.onToolActivity?.(text.split(`
5000
+ `)[0].slice(0, 80));
5001
+ }
5002
+ } else if (type === "turn.completed") {
5003
+ flushAgentMessages();
5004
+ }
5005
+ } catch {
5006
+ const newLine = `${line}
5007
+ `;
5008
+ rawOutput += newLine;
5009
+ options.onOutput?.(newLine);
5010
+ }
5011
+ }
5012
+ });
5013
+ this.process.stderr?.on("data", (chunk) => {
5014
+ const text = chunk.toString();
5015
+ errorOutput += text;
5016
+ log.debug("codex stderr", { text: text.slice(0, 500) });
5017
+ });
5018
+ this.process.on("close", (code) => {
5019
+ this.process = null;
5020
+ flushAgentMessages();
5021
+ if (this.aborted) {
5022
+ resolve2({
5023
+ success: false,
5024
+ output: rawOutput,
4097
5025
  error: "Aborted by user",
4098
5026
  exitCode: code ?? 143
4099
5027
  });
@@ -4122,52 +5050,398 @@ class CodexRunner {
4122
5050
  error: `Failed to spawn codex: ${err.message}`,
4123
5051
  exitCode: 1
4124
5052
  });
4125
- });
4126
- if (options.signal) {
4127
- options.signal.addEventListener("abort", () => {
4128
- this.abort();
5053
+ });
5054
+ if (options.signal) {
5055
+ options.signal.addEventListener("abort", () => {
5056
+ this.abort();
5057
+ });
5058
+ }
5059
+ this.process.stdin?.write(options.prompt);
5060
+ this.process.stdin?.end();
5061
+ });
5062
+ }
5063
+ abort() {
5064
+ if (!this.process)
5065
+ return;
5066
+ this.aborted = true;
5067
+ const log = getLogger();
5068
+ log.debug("Aborting codex process");
5069
+ this.process.kill("SIGTERM");
5070
+ const forceKillTimer = setTimeout(() => {
5071
+ if (this.process) {
5072
+ log.debug("Force killing codex process");
5073
+ this.process.kill("SIGKILL");
5074
+ }
5075
+ }, 3000);
5076
+ if (forceKillTimer.unref) {
5077
+ forceKillTimer.unref();
5078
+ }
5079
+ }
5080
+ }
5081
+ var init_codex = __esm(() => {
5082
+ init_logger();
5083
+ });
5084
+
5085
+ // src/ai/codex-sandbox.ts
5086
+ import { execSync as execSync9, spawn as spawn5 } from "node:child_process";
5087
+
5088
+ class SandboxedCodexRunner {
5089
+ name = "codex-sandboxed";
5090
+ process = null;
5091
+ aborted = false;
5092
+ sandboxName = null;
5093
+ persistent;
5094
+ sandboxCreated = false;
5095
+ userManaged = false;
5096
+ codexInstalled = false;
5097
+ constructor(persistentName, userManaged = false) {
5098
+ if (persistentName) {
5099
+ this.persistent = true;
5100
+ this.sandboxName = persistentName;
5101
+ this.userManaged = userManaged;
5102
+ if (userManaged) {
5103
+ this.sandboxCreated = true;
5104
+ }
5105
+ } else {
5106
+ this.persistent = false;
5107
+ }
5108
+ }
5109
+ async isAvailable() {
5110
+ const delegate = new CodexRunner;
5111
+ return delegate.isAvailable();
5112
+ }
5113
+ async getVersion() {
5114
+ const delegate = new CodexRunner;
5115
+ return delegate.getVersion();
5116
+ }
5117
+ async execute(options) {
5118
+ const log = getLogger();
5119
+ this.aborted = false;
5120
+ const codexArgs = buildCodexArgs(options.model);
5121
+ let dockerArgs;
5122
+ if (this.persistent && !this.sandboxName) {
5123
+ throw new Error("Sandbox name is required");
5124
+ }
5125
+ if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
5126
+ const name = this.sandboxName;
5127
+ if (!name) {
5128
+ throw new Error("Sandbox name is required");
5129
+ }
5130
+ options.onStatusChange?.("Syncing sandbox...");
5131
+ await enforceSandboxIgnore(name, options.cwd);
5132
+ if (!this.codexInstalled) {
5133
+ options.onStatusChange?.("Checking codex...");
5134
+ await this.ensureCodexInstalled(name);
5135
+ this.codexInstalled = true;
5136
+ }
5137
+ options.onStatusChange?.("Thinking...");
5138
+ dockerArgs = [
5139
+ "sandbox",
5140
+ "exec",
5141
+ "-i",
5142
+ "-w",
5143
+ options.cwd,
5144
+ name,
5145
+ "codex",
5146
+ ...codexArgs
5147
+ ];
5148
+ } else {
5149
+ if (!this.persistent) {
5150
+ this.sandboxName = buildSandboxName2(options);
5151
+ }
5152
+ const name = this.sandboxName;
5153
+ if (!name) {
5154
+ throw new Error("Sandbox name is required");
5155
+ }
5156
+ registerActiveSandbox(name);
5157
+ options.onStatusChange?.("Creating sandbox...");
5158
+ await this.createSandboxWithClaude(name, options.cwd);
5159
+ options.onStatusChange?.("Installing codex...");
5160
+ await this.ensureCodexInstalled(name);
5161
+ this.codexInstalled = true;
5162
+ options.onStatusChange?.("Syncing sandbox...");
5163
+ await enforceSandboxIgnore(name, options.cwd);
5164
+ options.onStatusChange?.("Thinking...");
5165
+ dockerArgs = [
5166
+ "sandbox",
5167
+ "exec",
5168
+ "-i",
5169
+ "-w",
5170
+ options.cwd,
5171
+ name,
5172
+ "codex",
5173
+ ...codexArgs
5174
+ ];
5175
+ }
5176
+ log.debug("Spawning sandboxed codex", {
5177
+ sandboxName: this.sandboxName,
5178
+ persistent: this.persistent,
5179
+ reusing: this.persistent && this.sandboxCreated,
5180
+ args: dockerArgs.join(" "),
5181
+ cwd: options.cwd
5182
+ });
5183
+ try {
5184
+ return await new Promise((resolve2) => {
5185
+ let rawOutput = "";
5186
+ let errorOutput = "";
5187
+ this.process = spawn5("docker", dockerArgs, {
5188
+ stdio: ["pipe", "pipe", "pipe"],
5189
+ env: process.env
5190
+ });
5191
+ if (this.persistent && !this.sandboxCreated) {
5192
+ this.process.on("spawn", () => {
5193
+ this.sandboxCreated = true;
5194
+ });
5195
+ }
5196
+ let agentMessages = [];
5197
+ const flushAgentMessages = () => {
5198
+ if (agentMessages.length > 0) {
5199
+ options.onOutput?.(agentMessages.join(`
5200
+
5201
+ `));
5202
+ agentMessages = [];
5203
+ }
5204
+ };
5205
+ let lineBuffer = "";
5206
+ this.process.stdout?.on("data", (chunk) => {
5207
+ lineBuffer += chunk.toString();
5208
+ const lines = lineBuffer.split(`
5209
+ `);
5210
+ lineBuffer = lines.pop() ?? "";
5211
+ for (const line of lines) {
5212
+ if (!line.trim())
5213
+ continue;
5214
+ rawOutput += `${line}
5215
+ `;
5216
+ log.debug("sandboxed codex stdout line", { line });
5217
+ try {
5218
+ const event = JSON.parse(line);
5219
+ const { type, item } = event;
5220
+ if (type === "item.started" && item?.type === "command_execution") {
5221
+ const cmd = (item.command ?? "").split(`
5222
+ `)[0].slice(0, 80);
5223
+ options.onToolActivity?.(`running: ${cmd}`);
5224
+ } else if (type === "item.completed" && item?.type === "command_execution") {
5225
+ const code = item.exit_code;
5226
+ options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
5227
+ } else if (type === "item.completed" && item?.type === "reasoning") {
5228
+ const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
5229
+ if (text)
5230
+ options.onToolActivity?.(text);
5231
+ } else if (type === "item.completed" && item?.type === "agent_message") {
5232
+ const text = item.text ?? "";
5233
+ if (text) {
5234
+ agentMessages.push(text);
5235
+ options.onToolActivity?.(text.split(`
5236
+ `)[0].slice(0, 80));
5237
+ }
5238
+ } else if (type === "turn.completed") {
5239
+ flushAgentMessages();
5240
+ }
5241
+ } catch {
5242
+ const newLine = `${line}
5243
+ `;
5244
+ rawOutput += newLine;
5245
+ options.onOutput?.(newLine);
5246
+ }
5247
+ }
5248
+ });
5249
+ this.process.stderr?.on("data", (chunk) => {
5250
+ const text = chunk.toString();
5251
+ errorOutput += text;
5252
+ log.debug("sandboxed codex stderr", { text: text.slice(0, 500) });
5253
+ });
5254
+ this.process.on("close", (code) => {
5255
+ this.process = null;
5256
+ flushAgentMessages();
5257
+ if (this.aborted) {
5258
+ resolve2({
5259
+ success: false,
5260
+ output: rawOutput,
5261
+ error: "Aborted by user",
5262
+ exitCode: code ?? 143
5263
+ });
5264
+ return;
5265
+ }
5266
+ if (code === 0) {
5267
+ resolve2({
5268
+ success: true,
5269
+ output: rawOutput,
5270
+ exitCode: 0
5271
+ });
5272
+ } else {
5273
+ resolve2({
5274
+ success: false,
5275
+ output: rawOutput,
5276
+ error: errorOutput || `sandboxed codex exited with code ${code}`,
5277
+ exitCode: code ?? 1
5278
+ });
5279
+ }
5280
+ });
5281
+ this.process.on("error", (err) => {
5282
+ this.process = null;
5283
+ if (this.persistent && !this.sandboxCreated) {}
5284
+ resolve2({
5285
+ success: false,
5286
+ output: rawOutput,
5287
+ error: `Failed to spawn docker sandbox: ${err.message}`,
5288
+ exitCode: 1
5289
+ });
4129
5290
  });
5291
+ if (options.signal) {
5292
+ options.signal.addEventListener("abort", () => {
5293
+ this.abort();
5294
+ });
5295
+ }
5296
+ this.process.stdin?.write(options.prompt);
5297
+ this.process.stdin?.end();
5298
+ });
5299
+ } finally {
5300
+ if (!this.persistent) {
5301
+ this.cleanupSandbox();
4130
5302
  }
4131
- this.process.stdin?.write(options.prompt);
4132
- this.process.stdin?.end();
4133
- });
5303
+ }
4134
5304
  }
4135
5305
  abort() {
4136
- if (!this.process)
4137
- return;
4138
5306
  this.aborted = true;
4139
5307
  const log = getLogger();
4140
- log.debug("Aborting codex process");
4141
- this.process.kill("SIGTERM");
4142
- const forceKillTimer = setTimeout(() => {
5308
+ if (this.persistent) {
5309
+ log.debug("Aborting sandboxed codex (persistent — keeping sandbox)", {
5310
+ sandboxName: this.sandboxName
5311
+ });
4143
5312
  if (this.process) {
4144
- log.debug("Force killing codex process");
4145
- this.process.kill("SIGKILL");
5313
+ this.process.kill("SIGTERM");
5314
+ const timer = setTimeout(() => {
5315
+ if (this.process) {
5316
+ this.process.kill("SIGKILL");
5317
+ }
5318
+ }, 3000);
5319
+ if (timer.unref)
5320
+ timer.unref();
4146
5321
  }
4147
- }, 3000);
4148
- if (forceKillTimer.unref) {
4149
- forceKillTimer.unref();
5322
+ } else {
5323
+ if (!this.sandboxName)
5324
+ return;
5325
+ log.debug("Aborting sandboxed codex (ephemeral — removing sandbox)", {
5326
+ sandboxName: this.sandboxName
5327
+ });
5328
+ try {
5329
+ execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5330
+ } catch {}
5331
+ }
5332
+ }
5333
+ destroy() {
5334
+ if (!this.sandboxName)
5335
+ return;
5336
+ if (this.userManaged) {
5337
+ unregisterActiveSandbox(this.sandboxName);
5338
+ return;
5339
+ }
5340
+ const log = getLogger();
5341
+ log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
5342
+ try {
5343
+ execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5344
+ } catch {}
5345
+ unregisterActiveSandbox(this.sandboxName);
5346
+ this.sandboxName = null;
5347
+ this.sandboxCreated = false;
5348
+ }
5349
+ cleanupSandbox() {
5350
+ if (!this.sandboxName)
5351
+ return;
5352
+ const log = getLogger();
5353
+ log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
5354
+ try {
5355
+ execSync9(`docker sandbox rm ${this.sandboxName}`, {
5356
+ timeout: 60000
5357
+ });
5358
+ } catch {}
5359
+ unregisterActiveSandbox(this.sandboxName);
5360
+ this.sandboxName = null;
5361
+ }
5362
+ async isSandboxRunning() {
5363
+ if (!this.sandboxName)
5364
+ return false;
5365
+ try {
5366
+ const { promisify: promisify2 } = await import("node:util");
5367
+ const { exec: exec2 } = await import("node:child_process");
5368
+ const execAsync2 = promisify2(exec2);
5369
+ const { stdout } = await execAsync2("docker sandbox ls", {
5370
+ timeout: 5000
5371
+ });
5372
+ return stdout.includes(this.sandboxName);
5373
+ } catch {
5374
+ return false;
5375
+ }
5376
+ }
5377
+ async createSandboxWithClaude(name, cwd) {
5378
+ const { promisify: promisify2 } = await import("node:util");
5379
+ const { exec: exec2 } = await import("node:child_process");
5380
+ const execAsync2 = promisify2(exec2);
5381
+ try {
5382
+ await execAsync2(`docker sandbox run --name ${name} claude ${cwd} -- --version`, { timeout: 120000 });
5383
+ } catch {}
5384
+ }
5385
+ async ensureCodexInstalled(name) {
5386
+ const { promisify: promisify2 } = await import("node:util");
5387
+ const { exec: exec2 } = await import("node:child_process");
5388
+ const execAsync2 = promisify2(exec2);
5389
+ try {
5390
+ await execAsync2(`docker sandbox exec ${name} which codex`, {
5391
+ timeout: 5000
5392
+ });
5393
+ } catch {
5394
+ await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, { timeout: 120000 });
4150
5395
  }
4151
5396
  }
5397
+ getSandboxName() {
5398
+ return this.sandboxName;
5399
+ }
4152
5400
  }
4153
- var init_codex = __esm(() => {
5401
+ function buildSandboxName2(options) {
5402
+ const ts = Date.now();
5403
+ if (options.activity) {
5404
+ const match = options.activity.match(/issue\s*#(\d+)/i);
5405
+ if (match) {
5406
+ return `locus-codex-issue-${match[1]}-${ts}`;
5407
+ }
5408
+ }
5409
+ const segment = options.cwd.split("/").pop() ?? "run";
5410
+ return `locus-codex-${segment}-${ts}`;
5411
+ }
5412
+ var init_codex_sandbox = __esm(() => {
4154
5413
  init_logger();
5414
+ init_sandbox_ignore();
5415
+ init_shutdown();
5416
+ init_codex();
4155
5417
  });
4156
5418
 
4157
5419
  // src/ai/runner.ts
4158
- async function createRunnerAsync(provider) {
5420
+ async function createRunnerAsync(provider, sandboxed) {
5421
+ switch (provider) {
5422
+ case "claude":
5423
+ return sandboxed ? new SandboxedClaudeRunner : new ClaudeRunner;
5424
+ case "codex":
5425
+ return sandboxed ? new SandboxedCodexRunner : new CodexRunner;
5426
+ default:
5427
+ throw new Error(`Unknown AI provider: ${provider}`);
5428
+ }
5429
+ }
5430
+ function createUserManagedSandboxRunner(provider, sandboxName) {
4159
5431
  switch (provider) {
4160
5432
  case "claude":
4161
- return new ClaudeRunner;
5433
+ return new SandboxedClaudeRunner(sandboxName, true);
4162
5434
  case "codex":
4163
- return new CodexRunner;
5435
+ return new SandboxedCodexRunner(sandboxName, true);
4164
5436
  default:
4165
5437
  throw new Error(`Unknown AI provider: ${provider}`);
4166
5438
  }
4167
5439
  }
4168
5440
  var init_runner = __esm(() => {
4169
5441
  init_claude();
5442
+ init_claude_sandbox();
4170
5443
  init_codex();
5444
+ init_codex_sandbox();
4171
5445
  });
4172
5446
 
4173
5447
  // src/ai/run-ai.ts
@@ -4175,6 +5449,55 @@ var exports_run_ai = {};
4175
5449
  __export(exports_run_ai, {
4176
5450
  runAI: () => runAI
4177
5451
  });
5452
+ function normalizeErrorMessage(error) {
5453
+ if (!error)
5454
+ return;
5455
+ const trimmed = error.trim();
5456
+ return trimmed.length > 0 ? trimmed : undefined;
5457
+ }
5458
+ function stripAnsi2(text) {
5459
+ return text.replace(/\u001B\[[0-9;]*[A-Za-z]/g, "");
5460
+ }
5461
+ function extractErrorFromStructuredLine(line) {
5462
+ try {
5463
+ const parsed = JSON.parse(line);
5464
+ const candidateValues = [
5465
+ parsed.error,
5466
+ parsed.message,
5467
+ parsed.text,
5468
+ typeof parsed.item === "object" && parsed.item ? parsed.item.error : undefined,
5469
+ typeof parsed.item === "object" && parsed.item ? parsed.item.message : undefined,
5470
+ typeof parsed.item === "object" && parsed.item ? parsed.item.text : undefined
5471
+ ];
5472
+ for (const value of candidateValues) {
5473
+ if (typeof value !== "string")
5474
+ continue;
5475
+ const normalized = normalizeErrorMessage(stripAnsi2(value));
5476
+ if (normalized)
5477
+ return normalized;
5478
+ }
5479
+ return;
5480
+ } catch {
5481
+ return;
5482
+ }
5483
+ }
5484
+ function extractErrorFromOutput(output) {
5485
+ if (!output)
5486
+ return;
5487
+ const lines = output.split(`
5488
+ `);
5489
+ for (let index = lines.length - 1;index >= 0; index--) {
5490
+ const rawLine = lines[index] ?? "";
5491
+ const line = normalizeErrorMessage(stripAnsi2(rawLine));
5492
+ if (!line)
5493
+ continue;
5494
+ const structured = extractErrorFromStructuredLine(line);
5495
+ if (structured)
5496
+ return structured.slice(0, 500);
5497
+ return line.slice(0, 500);
5498
+ }
5499
+ return;
5500
+ }
4178
5501
  async function runAI(options) {
4179
5502
  const indicator = getStatusIndicator();
4180
5503
  const renderer = options.silent ? null : new StreamRenderer;
@@ -4207,7 +5530,13 @@ ${red("✗")} ${dim("Force exit.")}\r
4207
5530
  indicator.start("Thinking...", {
4208
5531
  activity: options.activity
4209
5532
  });
4210
- runner = await createRunnerAsync(resolvedProvider);
5533
+ if (options.runner) {
5534
+ runner = options.runner;
5535
+ } else if (options.sandboxName) {
5536
+ runner = createUserManagedSandboxRunner(resolvedProvider, options.sandboxName);
5537
+ } else {
5538
+ runner = await createRunnerAsync(resolvedProvider, options.sandboxed ?? true);
5539
+ }
4211
5540
  const available = await runner.isAvailable();
4212
5541
  if (!available) {
4213
5542
  indicator.stop();
@@ -4227,6 +5556,7 @@ ${red("✗")} ${dim("Force exit.")}\r
4227
5556
  cwd: options.cwd,
4228
5557
  signal: abortController.signal,
4229
5558
  verbose: options.verbose,
5559
+ activity: options.activity,
4230
5560
  onOutput: (chunk) => {
4231
5561
  if (wasAborted)
4232
5562
  return;
@@ -4237,6 +5567,9 @@ ${red("✗")} ${dim("Force exit.")}\r
4237
5567
  renderer?.push(chunk);
4238
5568
  output += chunk;
4239
5569
  },
5570
+ onStatusChange: (message) => {
5571
+ indicator.setMessage(message);
5572
+ },
4240
5573
  onToolActivity: (() => {
4241
5574
  let lastActivityTime = 0;
4242
5575
  return (summary) => {
@@ -4261,20 +5594,25 @@ ${red("✗")} ${dim("Force exit.")}\r
4261
5594
  exitCode: result.exitCode
4262
5595
  };
4263
5596
  }
5597
+ const normalizedRunnerError = normalizeErrorMessage(result.error);
5598
+ const extractedOutputError = extractErrorFromOutput(result.output);
5599
+ const fallbackError = `${runner.name} failed with exit code ${result.exitCode}.`;
4264
5600
  return {
4265
5601
  success: result.success,
4266
5602
  output,
4267
- error: result.error,
5603
+ error: result.success ? undefined : normalizedRunnerError ?? extractedOutputError ?? fallbackError,
4268
5604
  interrupted: false,
4269
5605
  exitCode: result.exitCode
4270
5606
  };
4271
5607
  } catch (e) {
4272
5608
  indicator.stop();
4273
5609
  renderer?.stop();
5610
+ const normalizedCaughtError = normalizeErrorMessage(e instanceof Error ? e.message : String(e));
5611
+ const fallbackError = `${resolvedProvider} runner failed unexpectedly.`;
4274
5612
  return {
4275
5613
  success: false,
4276
5614
  output,
4277
- error: e instanceof Error ? e.message : String(e),
5615
+ error: normalizedCaughtError ?? fallbackError,
4278
5616
  interrupted: wasAborted,
4279
5617
  exitCode: 1
4280
5618
  };
@@ -4405,7 +5743,7 @@ var exports_issue = {};
4405
5743
  __export(exports_issue, {
4406
5744
  issueCommand: () => issueCommand
4407
5745
  });
4408
- import { createInterface } from "node:readline";
5746
+ import { createInterface as createInterface2 } from "node:readline";
4409
5747
  function parseIssueArgs(args) {
4410
5748
  const flags = {};
4411
5749
  const positional = [];
@@ -4517,7 +5855,9 @@ async function issueCreate(projectRoot, parsed) {
4517
5855
  model: config.ai.model,
4518
5856
  cwd: projectRoot,
4519
5857
  silent: true,
4520
- activity: "generating issue"
5858
+ activity: "generating issue",
5859
+ sandboxed: config.sandbox.enabled,
5860
+ sandboxName: config.sandbox.name
4521
5861
  });
4522
5862
  if (!aiResult.success && !aiResult.interrupted) {
4523
5863
  process.stderr.write(`${red("✗")} Failed to generate issue: ${aiResult.error}
@@ -4636,7 +5976,7 @@ function extractJSON(text) {
4636
5976
  }
4637
5977
  function askQuestion(question) {
4638
5978
  return new Promise((resolve2) => {
4639
- const rl = createInterface({
5979
+ const rl = createInterface2({
4640
5980
  input: process.stdin,
4641
5981
  output: process.stderr
4642
5982
  });
@@ -5675,9 +7015,9 @@ var init_sprint = __esm(() => {
5675
7015
  });
5676
7016
 
5677
7017
  // src/core/prompt-builder.ts
5678
- import { execSync as execSync6 } from "node:child_process";
5679
- import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync8 } from "node:fs";
5680
- import { join as join10 } from "node:path";
7018
+ import { execSync as execSync10 } from "node:child_process";
7019
+ import { existsSync as existsSync14, readdirSync as readdirSync3, readFileSync as readFileSync10 } from "node:fs";
7020
+ import { join as join13 } from "node:path";
5681
7021
  function buildExecutionPrompt(ctx) {
5682
7022
  const sections = [];
5683
7023
  sections.push(buildSystemContext(ctx.projectRoot));
@@ -5707,13 +7047,13 @@ function buildFeedbackPrompt(ctx) {
5707
7047
  }
5708
7048
  function buildReplPrompt(userMessage, projectRoot, _config, previousMessages) {
5709
7049
  const sections = [];
5710
- const locusmd = readFileSafe(join10(projectRoot, "LOCUS.md"));
7050
+ const locusmd = readFileSafe(join13(projectRoot, "LOCUS.md"));
5711
7051
  if (locusmd) {
5712
7052
  sections.push(`# Project Instructions
5713
7053
 
5714
7054
  ${locusmd}`);
5715
7055
  }
5716
- const learnings = readFileSafe(join10(projectRoot, ".locus", "LEARNINGS.md"));
7056
+ const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
5717
7057
  if (learnings) {
5718
7058
  sections.push(`# Past Learnings
5719
7059
 
@@ -5739,24 +7079,24 @@ ${userMessage}`);
5739
7079
  }
5740
7080
  function buildSystemContext(projectRoot) {
5741
7081
  const parts = ["# System Context"];
5742
- const locusmd = readFileSafe(join10(projectRoot, "LOCUS.md"));
7082
+ const locusmd = readFileSafe(join13(projectRoot, "LOCUS.md"));
5743
7083
  if (locusmd) {
5744
7084
  parts.push(`## Project Instructions (LOCUS.md)
5745
7085
 
5746
7086
  ${locusmd}`);
5747
7087
  }
5748
- const learnings = readFileSafe(join10(projectRoot, ".locus", "LEARNINGS.md"));
7088
+ const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
5749
7089
  if (learnings) {
5750
7090
  parts.push(`## Past Learnings
5751
7091
 
5752
7092
  ${learnings}`);
5753
7093
  }
5754
- const discussionsDir = join10(projectRoot, ".locus", "discussions");
5755
- if (existsSync11(discussionsDir)) {
7094
+ const discussionsDir = join13(projectRoot, ".locus", "discussions");
7095
+ if (existsSync14(discussionsDir)) {
5756
7096
  try {
5757
7097
  const files = readdirSync3(discussionsDir).filter((f) => f.endsWith(".md")).slice(0, 3);
5758
7098
  for (const file of files) {
5759
- const content = readFileSafe(join10(discussionsDir, file));
7099
+ const content = readFileSafe(join13(discussionsDir, file));
5760
7100
  if (content) {
5761
7101
  parts.push(`## Discussion: ${file.replace(".md", "")}
5762
7102
 
@@ -5819,7 +7159,7 @@ ${diffSummary}
5819
7159
  function buildRepoContext(projectRoot) {
5820
7160
  const parts = ["# Repository Context"];
5821
7161
  try {
5822
- const tree = execSync6("find . -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.locus/*' -not -path '*/dist/*' -not -path '*/build/*' | head -80", { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
7162
+ const tree = execSync10("find . -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.locus/*' -not -path '*/dist/*' -not -path '*/build/*' | head -80", { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
5823
7163
  if (tree) {
5824
7164
  parts.push(`## File Tree
5825
7165
 
@@ -5829,7 +7169,7 @@ ${tree}
5829
7169
  }
5830
7170
  } catch {}
5831
7171
  try {
5832
- const gitLog = execSync6("git log --oneline -10", {
7172
+ const gitLog = execSync10("git log --oneline -10", {
5833
7173
  cwd: projectRoot,
5834
7174
  encoding: "utf-8",
5835
7175
  stdio: ["pipe", "pipe", "pipe"]
@@ -5843,7 +7183,7 @@ ${gitLog}
5843
7183
  }
5844
7184
  } catch {}
5845
7185
  try {
5846
- const branch = execSync6("git rev-parse --abbrev-ref HEAD", {
7186
+ const branch = execSync10("git rev-parse --abbrev-ref HEAD", {
5847
7187
  cwd: projectRoot,
5848
7188
  encoding: "utf-8",
5849
7189
  stdio: ["pipe", "pipe", "pipe"]
@@ -5902,9 +7242,9 @@ function buildFeedbackInstructions() {
5902
7242
  }
5903
7243
  function readFileSafe(path) {
5904
7244
  try {
5905
- if (!existsSync11(path))
7245
+ if (!existsSync14(path))
5906
7246
  return null;
5907
- return readFileSync8(path, "utf-8");
7247
+ return readFileSync10(path, "utf-8");
5908
7248
  } catch {
5909
7249
  return null;
5910
7250
  }
@@ -6096,7 +7436,7 @@ var init_diff_renderer = __esm(() => {
6096
7436
  });
6097
7437
 
6098
7438
  // src/repl/commands.ts
6099
- import { execSync as execSync7 } from "node:child_process";
7439
+ import { execSync as execSync11 } from "node:child_process";
6100
7440
  function getSlashCommands() {
6101
7441
  return [
6102
7442
  {
@@ -6288,7 +7628,7 @@ function cmdModel(args, ctx) {
6288
7628
  }
6289
7629
  function cmdDiff(_args, ctx) {
6290
7630
  try {
6291
- const diff = execSync7("git diff", {
7631
+ const diff = execSync11("git diff", {
6292
7632
  cwd: ctx.projectRoot,
6293
7633
  encoding: "utf-8",
6294
7634
  stdio: ["pipe", "pipe", "pipe"]
@@ -6324,7 +7664,7 @@ function cmdDiff(_args, ctx) {
6324
7664
  }
6325
7665
  function cmdUndo(_args, ctx) {
6326
7666
  try {
6327
- const status = execSync7("git status --porcelain", {
7667
+ const status = execSync11("git status --porcelain", {
6328
7668
  cwd: ctx.projectRoot,
6329
7669
  encoding: "utf-8",
6330
7670
  stdio: ["pipe", "pipe", "pipe"]
@@ -6334,7 +7674,7 @@ function cmdUndo(_args, ctx) {
6334
7674
  `);
6335
7675
  return;
6336
7676
  }
6337
- execSync7("git checkout .", {
7677
+ execSync11("git checkout .", {
6338
7678
  cwd: ctx.projectRoot,
6339
7679
  encoding: "utf-8",
6340
7680
  stdio: ["pipe", "pipe", "pipe"]
@@ -6368,7 +7708,7 @@ var init_commands = __esm(() => {
6368
7708
 
6369
7709
  // src/repl/completions.ts
6370
7710
  import { readdirSync as readdirSync4 } from "node:fs";
6371
- import { basename as basename2, dirname as dirname3, join as join11 } from "node:path";
7711
+ import { basename as basename2, dirname as dirname4, join as join14 } from "node:path";
6372
7712
 
6373
7713
  class SlashCommandCompletion {
6374
7714
  commands;
@@ -6423,7 +7763,7 @@ class FilePathCompletion {
6423
7763
  }
6424
7764
  findMatches(partial) {
6425
7765
  try {
6426
- const dir = partial.includes("/") ? join11(this.projectRoot, dirname3(partial)) : this.projectRoot;
7766
+ const dir = partial.includes("/") ? join14(this.projectRoot, dirname4(partial)) : this.projectRoot;
6427
7767
  const prefix = basename2(partial);
6428
7768
  const entries = readdirSync4(dir, { withFileTypes: true });
6429
7769
  return entries.filter((e) => {
@@ -6434,7 +7774,7 @@ class FilePathCompletion {
6434
7774
  return e.name.startsWith(prefix);
6435
7775
  }).map((e) => {
6436
7776
  const name = e.isDirectory() ? `${e.name}/` : e.name;
6437
- return partial.includes("/") ? `${dirname3(partial)}/${name}` : name;
7777
+ return partial.includes("/") ? `${dirname4(partial)}/${name}` : name;
6438
7778
  }).slice(0, 20);
6439
7779
  } catch {
6440
7780
  return [];
@@ -6459,14 +7799,14 @@ class CombinedCompletion {
6459
7799
  var init_completions = () => {};
6460
7800
 
6461
7801
  // src/repl/input-history.ts
6462
- import { existsSync as existsSync12, mkdirSync as mkdirSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
6463
- import { dirname as dirname4, join as join12 } from "node:path";
7802
+ import { existsSync as existsSync15, mkdirSync as mkdirSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
7803
+ import { dirname as dirname5, join as join15 } from "node:path";
6464
7804
 
6465
7805
  class InputHistory {
6466
7806
  entries = [];
6467
7807
  filePath;
6468
7808
  constructor(projectRoot) {
6469
- this.filePath = join12(projectRoot, ".locus", "sessions", ".input-history");
7809
+ this.filePath = join15(projectRoot, ".locus", "sessions", ".input-history");
6470
7810
  this.load();
6471
7811
  }
6472
7812
  add(text) {
@@ -6505,22 +7845,22 @@ class InputHistory {
6505
7845
  }
6506
7846
  load() {
6507
7847
  try {
6508
- if (!existsSync12(this.filePath))
7848
+ if (!existsSync15(this.filePath))
6509
7849
  return;
6510
- const content = readFileSync9(this.filePath, "utf-8");
7850
+ const content = readFileSync11(this.filePath, "utf-8");
6511
7851
  this.entries = content.split(`
6512
7852
  `).map((line) => this.unescape(line)).filter(Boolean);
6513
7853
  } catch {}
6514
7854
  }
6515
7855
  save() {
6516
7856
  try {
6517
- const dir = dirname4(this.filePath);
6518
- if (!existsSync12(dir)) {
6519
- mkdirSync8(dir, { recursive: true });
7857
+ const dir = dirname5(this.filePath);
7858
+ if (!existsSync15(dir)) {
7859
+ mkdirSync10(dir, { recursive: true });
6520
7860
  }
6521
7861
  const content = this.entries.map((e) => this.escape(e)).join(`
6522
7862
  `);
6523
- writeFileSync6(this.filePath, content, "utf-8");
7863
+ writeFileSync7(this.filePath, content, "utf-8");
6524
7864
  } catch {}
6525
7865
  }
6526
7866
  escape(text) {
@@ -6545,23 +7885,22 @@ var init_model_config = __esm(() => {
6545
7885
  });
6546
7886
 
6547
7887
  // src/repl/session-manager.ts
6548
- import { randomBytes } from "node:crypto";
6549
7888
  import {
6550
- existsSync as existsSync13,
6551
- mkdirSync as mkdirSync9,
7889
+ existsSync as existsSync16,
7890
+ mkdirSync as mkdirSync11,
6552
7891
  readdirSync as readdirSync5,
6553
- readFileSync as readFileSync10,
6554
- unlinkSync as unlinkSync3,
6555
- writeFileSync as writeFileSync7
7892
+ readFileSync as readFileSync12,
7893
+ unlinkSync as unlinkSync4,
7894
+ writeFileSync as writeFileSync8
6556
7895
  } from "node:fs";
6557
- import { basename as basename3, join as join13 } from "node:path";
7896
+ import { basename as basename3, join as join16 } from "node:path";
6558
7897
 
6559
7898
  class SessionManager {
6560
7899
  sessionsDir;
6561
7900
  constructor(projectRoot) {
6562
- this.sessionsDir = join13(projectRoot, ".locus", "sessions");
6563
- if (!existsSync13(this.sessionsDir)) {
6564
- mkdirSync9(this.sessionsDir, { recursive: true });
7901
+ this.sessionsDir = join16(projectRoot, ".locus", "sessions");
7902
+ if (!existsSync16(this.sessionsDir)) {
7903
+ mkdirSync11(this.sessionsDir, { recursive: true });
6565
7904
  }
6566
7905
  }
6567
7906
  create(options) {
@@ -6586,14 +7925,14 @@ class SessionManager {
6586
7925
  }
6587
7926
  isPersisted(sessionOrId) {
6588
7927
  const sessionId = typeof sessionOrId === "string" ? sessionOrId : sessionOrId.id;
6589
- return existsSync13(this.getSessionPath(sessionId));
7928
+ return existsSync16(this.getSessionPath(sessionId));
6590
7929
  }
6591
7930
  load(idOrPrefix) {
6592
7931
  const files = this.listSessionFiles();
6593
7932
  const exactPath = this.getSessionPath(idOrPrefix);
6594
- if (existsSync13(exactPath)) {
7933
+ if (existsSync16(exactPath)) {
6595
7934
  try {
6596
- return JSON.parse(readFileSync10(exactPath, "utf-8"));
7935
+ return JSON.parse(readFileSync12(exactPath, "utf-8"));
6597
7936
  } catch {
6598
7937
  return null;
6599
7938
  }
@@ -6601,7 +7940,7 @@ class SessionManager {
6601
7940
  const matches = files.filter((f) => basename3(f, ".json").startsWith(idOrPrefix));
6602
7941
  if (matches.length === 1) {
6603
7942
  try {
6604
- return JSON.parse(readFileSync10(matches[0], "utf-8"));
7943
+ return JSON.parse(readFileSync12(matches[0], "utf-8"));
6605
7944
  } catch {
6606
7945
  return null;
6607
7946
  }
@@ -6614,7 +7953,7 @@ class SessionManager {
6614
7953
  save(session) {
6615
7954
  session.updated = new Date().toISOString();
6616
7955
  const path = this.getSessionPath(session.id);
6617
- writeFileSync7(path, `${JSON.stringify(session, null, 2)}
7956
+ writeFileSync8(path, `${JSON.stringify(session, null, 2)}
6618
7957
  `, "utf-8");
6619
7958
  }
6620
7959
  addMessage(session, message) {
@@ -6626,7 +7965,7 @@ class SessionManager {
6626
7965
  const sessions = [];
6627
7966
  for (const file of files) {
6628
7967
  try {
6629
- const session = JSON.parse(readFileSync10(file, "utf-8"));
7968
+ const session = JSON.parse(readFileSync12(file, "utf-8"));
6630
7969
  sessions.push({
6631
7970
  id: session.id,
6632
7971
  created: session.created,
@@ -6641,8 +7980,8 @@ class SessionManager {
6641
7980
  }
6642
7981
  delete(sessionId) {
6643
7982
  const path = this.getSessionPath(sessionId);
6644
- if (existsSync13(path)) {
6645
- unlinkSync3(path);
7983
+ if (existsSync16(path)) {
7984
+ unlinkSync4(path);
6646
7985
  return true;
6647
7986
  }
6648
7987
  return false;
@@ -6653,7 +7992,7 @@ class SessionManager {
6653
7992
  let pruned = 0;
6654
7993
  const withStats = files.map((f) => {
6655
7994
  try {
6656
- const session = JSON.parse(readFileSync10(f, "utf-8"));
7995
+ const session = JSON.parse(readFileSync12(f, "utf-8"));
6657
7996
  return { path: f, updated: new Date(session.updated).getTime() };
6658
7997
  } catch {
6659
7998
  return { path: f, updated: 0 };
@@ -6663,7 +8002,7 @@ class SessionManager {
6663
8002
  for (const entry of withStats) {
6664
8003
  if (now - entry.updated > SESSION_MAX_AGE_MS) {
6665
8004
  try {
6666
- unlinkSync3(entry.path);
8005
+ unlinkSync4(entry.path);
6667
8006
  pruned++;
6668
8007
  } catch {}
6669
8008
  }
@@ -6671,10 +8010,10 @@ class SessionManager {
6671
8010
  const remaining = withStats.length - pruned;
6672
8011
  if (remaining > MAX_SESSIONS) {
6673
8012
  const toRemove = remaining - MAX_SESSIONS;
6674
- const alive = withStats.filter((e) => existsSync13(e.path));
8013
+ const alive = withStats.filter((e) => existsSync16(e.path));
6675
8014
  for (let i = 0;i < toRemove && i < alive.length; i++) {
6676
8015
  try {
6677
- unlinkSync3(alive[i].path);
8016
+ unlinkSync4(alive[i].path);
6678
8017
  pruned++;
6679
8018
  } catch {}
6680
8019
  }
@@ -6686,16 +8025,16 @@ class SessionManager {
6686
8025
  }
6687
8026
  listSessionFiles() {
6688
8027
  try {
6689
- return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join13(this.sessionsDir, f));
8028
+ return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join16(this.sessionsDir, f));
6690
8029
  } catch {
6691
8030
  return [];
6692
8031
  }
6693
8032
  }
6694
8033
  generateId() {
6695
- return randomBytes(6).toString("hex");
8034
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
6696
8035
  }
6697
8036
  getSessionPath(sessionId) {
6698
- return join13(this.sessionsDir, `${sessionId}.json`);
8037
+ return join16(this.sessionsDir, `${sessionId}.json`);
6699
8038
  }
6700
8039
  }
6701
8040
  var MAX_SESSIONS = 50, SESSION_MAX_AGE_MS;
@@ -6705,7 +8044,7 @@ var init_session_manager = __esm(() => {
6705
8044
  });
6706
8045
 
6707
8046
  // src/repl/repl.ts
6708
- import { execSync as execSync8 } from "node:child_process";
8047
+ import { execSync as execSync12 } from "node:child_process";
6709
8048
  async function startRepl(options) {
6710
8049
  const { projectRoot, config } = options;
6711
8050
  const sessionManager = new SessionManager(projectRoot);
@@ -6723,7 +8062,7 @@ async function startRepl(options) {
6723
8062
  } else {
6724
8063
  let branch = "main";
6725
8064
  try {
6726
- branch = execSync8("git rev-parse --abbrev-ref HEAD", {
8065
+ branch = execSync12("git rev-parse --abbrev-ref HEAD", {
6727
8066
  cwd: projectRoot,
6728
8067
  encoding: "utf-8",
6729
8068
  stdio: ["pipe", "pipe", "pipe"]
@@ -6748,6 +8087,7 @@ async function executeOneShotPrompt(prompt, session, sessionManager, options) {
6748
8087
  const normalized = normalizeImagePlaceholders(prompt);
6749
8088
  const text = normalized.text;
6750
8089
  const images = collectReferencedAttachments(text, normalized.attachments);
8090
+ relocateImages(images, projectRoot);
6751
8091
  const imageContext = buildImageContext(images);
6752
8092
  const fullPrompt = buildReplPrompt(text + imageContext, projectRoot, config, session.messages);
6753
8093
  sessionManager.addMessage(session, {
@@ -6764,6 +8104,18 @@ async function executeOneShotPrompt(prompt, session, sessionManager, options) {
6764
8104
  }
6765
8105
  async function runInteractiveRepl(session, sessionManager, options) {
6766
8106
  const { projectRoot, config } = options;
8107
+ let sandboxRunner = null;
8108
+ if (config.sandbox.enabled && config.sandbox.name) {
8109
+ const provider = inferProviderFromModel(config.ai.model) || config.ai.provider;
8110
+ sandboxRunner = createUserManagedSandboxRunner(provider, config.sandbox.name);
8111
+ process.stderr.write(`${dim("Using sandbox")} ${dim(config.sandbox.name)}
8112
+ `);
8113
+ } else if (config.sandbox.enabled) {
8114
+ const sandboxName = buildPersistentSandboxName(projectRoot);
8115
+ sandboxRunner = new SandboxedClaudeRunner(sandboxName);
8116
+ process.stderr.write(`${dim("Sandbox mode: prompts will share sandbox")} ${dim(sandboxName)}
8117
+ `);
8118
+ }
6767
8119
  const history = new InputHistory(projectRoot);
6768
8120
  const completion = new CombinedCompletion([
6769
8121
  new SlashCommandCompletion(getAllCommandNames()),
@@ -6793,8 +8145,14 @@ async function runInteractiveRepl(session, sessionManager, options) {
6793
8145
  session.metadata.model = model;
6794
8146
  const inferredProvider = inferProviderFromModel(model);
6795
8147
  if (inferredProvider) {
8148
+ const providerChanged = inferredProvider !== currentProvider;
6796
8149
  currentProvider = inferredProvider;
6797
8150
  session.metadata.provider = inferredProvider;
8151
+ if (providerChanged && config.sandbox.enabled && config.sandbox.name) {
8152
+ sandboxRunner = createUserManagedSandboxRunner(inferredProvider, config.sandbox.name);
8153
+ process.stderr.write(`${dim("Switched sandbox agent to")} ${dim(inferredProvider)}
8154
+ `);
8155
+ }
6798
8156
  }
6799
8157
  persistReplModelSelection(projectRoot, config, model);
6800
8158
  sessionManager.save(session);
@@ -6824,6 +8182,7 @@ async function runInteractiveRepl(session, sessionManager, options) {
6824
8182
  continue;
6825
8183
  }
6826
8184
  history.add(text);
8185
+ relocateImages(result.images, projectRoot);
6827
8186
  const imageContext = buildImageContext(result.images);
6828
8187
  const fullPrompt = buildReplPrompt(text + imageContext, projectRoot, { ...config, ai: { provider: currentProvider, model: currentModel } }, session.messages);
6829
8188
  sessionManager.addMessage(session, {
@@ -6839,7 +8198,7 @@ async function runInteractiveRepl(session, sessionManager, options) {
6839
8198
  ...config,
6840
8199
  ai: { provider: currentProvider, model: currentModel }
6841
8200
  }
6842
- }, verbose);
8201
+ }, verbose, sandboxRunner ?? undefined);
6843
8202
  sessionManager.addMessage(session, {
6844
8203
  role: "assistant",
6845
8204
  content: response,
@@ -6864,6 +8223,10 @@ ${red("✗")} ${msg}
6864
8223
  break;
6865
8224
  }
6866
8225
  }
8226
+ if (sandboxRunner && "destroy" in sandboxRunner) {
8227
+ const runner = sandboxRunner;
8228
+ runner.destroy();
8229
+ }
6867
8230
  const shouldPersistOnExit = session.messages.length > 0 || sessionManager.isPersisted(session);
6868
8231
  if (shouldPersistOnExit) {
6869
8232
  sessionManager.save(session);
@@ -6879,14 +8242,17 @@ ${red("✗")} ${msg}
6879
8242
  process.stdin.pause();
6880
8243
  process.exit(0);
6881
8244
  }
6882
- async function executeAITurn(prompt, session, options, verbose = false) {
8245
+ async function executeAITurn(prompt, session, options, verbose = false, runner) {
6883
8246
  const { config, projectRoot } = options;
6884
8247
  const aiResult = await runAI({
6885
8248
  prompt,
6886
8249
  provider: config.ai.provider,
6887
8250
  model: config.ai.model,
6888
8251
  cwd: projectRoot,
6889
- verbose
8252
+ verbose,
8253
+ sandboxed: config.sandbox.enabled,
8254
+ sandboxName: config.sandbox.name,
8255
+ runner
6890
8256
  });
6891
8257
  if (aiResult.interrupted) {
6892
8258
  if (aiResult.output) {
@@ -6916,7 +8282,9 @@ function printWelcome(session) {
6916
8282
  `);
6917
8283
  }
6918
8284
  var init_repl = __esm(() => {
8285
+ init_claude_sandbox();
6919
8286
  init_run_ai();
8287
+ init_runner();
6920
8288
  init_ai_models();
6921
8289
  init_prompt_builder();
6922
8290
  init_terminal();
@@ -7059,7 +8427,7 @@ async function handleJsonStream(projectRoot, config, args, sessionId) {
7059
8427
  stream.emitStatus("thinking");
7060
8428
  try {
7061
8429
  const fullPrompt = buildReplPrompt(prompt, projectRoot, config);
7062
- const runner = await createRunnerAsync(config.ai.provider);
8430
+ const runner = config.sandbox.name ? createUserManagedSandboxRunner(config.ai.provider, config.sandbox.name) : await createRunnerAsync(config.ai.provider, config.sandbox.enabled);
7063
8431
  const available = await runner.isAvailable();
7064
8432
  if (!available) {
7065
8433
  stream.emitError(`${config.ai.provider} CLI not available`, false);
@@ -7107,7 +8475,7 @@ var init_exec = __esm(() => {
7107
8475
  });
7108
8476
 
7109
8477
  // src/core/agent.ts
7110
- import { execSync as execSync9 } from "node:child_process";
8478
+ import { execSync as execSync13 } from "node:child_process";
7111
8479
  async function executeIssue(projectRoot, options) {
7112
8480
  const log = getLogger();
7113
8481
  const timer = createTimer();
@@ -7136,7 +8504,7 @@ ${cyan("●")} ${bold(`#${issueNumber}`)} ${issue.title}
7136
8504
  }
7137
8505
  let issueComments = [];
7138
8506
  try {
7139
- const commentsRaw = execSync9(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8507
+ const commentsRaw = execSync13(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
7140
8508
  if (commentsRaw) {
7141
8509
  issueComments = commentsRaw.split(`
7142
8510
  `).filter(Boolean);
@@ -7171,7 +8539,9 @@ ${yellow("⚠")} ${bold("Dry run")} — would execute with:
7171
8539
  provider,
7172
8540
  model,
7173
8541
  cwd: options.worktreePath ?? projectRoot,
7174
- activity: `issue #${issueNumber}`
8542
+ activity: `issue #${issueNumber}`,
8543
+ sandboxed: options.sandboxed,
8544
+ sandboxName: options.sandboxName
7175
8545
  });
7176
8546
  const output = aiResult.output;
7177
8547
  if (aiResult.interrupted) {
@@ -7275,7 +8645,9 @@ ${c.body}`),
7275
8645
  provider: config.ai.provider,
7276
8646
  model: config.ai.model,
7277
8647
  cwd: projectRoot,
7278
- activity: `iterating on PR #${prNumber}`
8648
+ activity: `iterating on PR #${prNumber}`,
8649
+ sandboxed: config.sandbox.enabled,
8650
+ sandboxName: config.sandbox.name
7279
8651
  });
7280
8652
  if (aiResult.interrupted) {
7281
8653
  process.stderr.write(`
@@ -7296,12 +8668,12 @@ ${aiResult.success ? green("✓") : red("✗")} Iteration ${aiResult.success ? "
7296
8668
  }
7297
8669
  async function createIssuePR(projectRoot, config, issue) {
7298
8670
  try {
7299
- const currentBranch = execSync9("git rev-parse --abbrev-ref HEAD", {
8671
+ const currentBranch = execSync13("git rev-parse --abbrev-ref HEAD", {
7300
8672
  cwd: projectRoot,
7301
8673
  encoding: "utf-8",
7302
8674
  stdio: ["pipe", "pipe", "pipe"]
7303
8675
  }).trim();
7304
- const diff = execSync9(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8676
+ const diff = execSync13(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
7305
8677
  cwd: projectRoot,
7306
8678
  encoding: "utf-8",
7307
8679
  stdio: ["pipe", "pipe", "pipe"]
@@ -7310,7 +8682,7 @@ async function createIssuePR(projectRoot, config, issue) {
7310
8682
  getLogger().verbose("No changes to create PR for");
7311
8683
  return;
7312
8684
  }
7313
- execSync9(`git push -u origin ${currentBranch}`, {
8685
+ execSync13(`git push -u origin ${currentBranch}`, {
7314
8686
  cwd: projectRoot,
7315
8687
  encoding: "utf-8",
7316
8688
  stdio: ["pipe", "pipe", "pipe"]
@@ -7356,9 +8728,9 @@ var init_agent = __esm(() => {
7356
8728
  });
7357
8729
 
7358
8730
  // src/core/conflict.ts
7359
- import { execSync as execSync10 } from "node:child_process";
8731
+ import { execSync as execSync14 } from "node:child_process";
7360
8732
  function git2(args, cwd) {
7361
- return execSync10(`git ${args}`, {
8733
+ return execSync14(`git ${args}`, {
7362
8734
  cwd,
7363
8735
  encoding: "utf-8",
7364
8736
  stdio: ["pipe", "pipe", "pipe"]
@@ -7470,198 +8842,25 @@ ${bold(red("✗"))} ${bold("Merge conflict detected")}
7470
8842
  process.stderr.write(` 3. ${dim("git rebase --continue")}
7471
8843
  `);
7472
8844
  process.stderr.write(` 4. ${dim("locus run --resume")} to continue the sprint
7473
-
7474
- `);
7475
- } else if (result.newCommits > 0) {
7476
- process.stderr.write(`
7477
- ${bold(yellow("⚠"))} Base branch has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"} — auto-rebasing...
7478
- `);
7479
- }
7480
- }
7481
- var init_conflict = __esm(() => {
7482
- init_terminal();
7483
- init_logger();
7484
- });
7485
-
7486
- // src/core/run-state.ts
7487
- import {
7488
- existsSync as existsSync14,
7489
- mkdirSync as mkdirSync10,
7490
- readFileSync as readFileSync11,
7491
- unlinkSync as unlinkSync4,
7492
- writeFileSync as writeFileSync8
7493
- } from "node:fs";
7494
- import { dirname as dirname5, join as join14 } from "node:path";
7495
- function getRunStatePath(projectRoot) {
7496
- return join14(projectRoot, ".locus", "run-state.json");
7497
- }
7498
- function loadRunState(projectRoot) {
7499
- const path = getRunStatePath(projectRoot);
7500
- if (!existsSync14(path))
7501
- return null;
7502
- try {
7503
- return JSON.parse(readFileSync11(path, "utf-8"));
7504
- } catch {
7505
- getLogger().warn("Corrupted run-state.json, ignoring");
7506
- return null;
7507
- }
7508
- }
7509
- function saveRunState(projectRoot, state) {
7510
- const path = getRunStatePath(projectRoot);
7511
- const dir = dirname5(path);
7512
- if (!existsSync14(dir)) {
7513
- mkdirSync10(dir, { recursive: true });
7514
- }
7515
- writeFileSync8(path, `${JSON.stringify(state, null, 2)}
7516
- `, "utf-8");
7517
- }
7518
- function clearRunState(projectRoot) {
7519
- const path = getRunStatePath(projectRoot);
7520
- if (existsSync14(path)) {
7521
- unlinkSync4(path);
7522
- }
7523
- }
7524
- function createSprintRunState(sprint, branch, issues) {
7525
- return {
7526
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
7527
- type: "sprint",
7528
- sprint,
7529
- branch,
7530
- startedAt: new Date().toISOString(),
7531
- tasks: issues.map(({ number, order }) => ({
7532
- issue: number,
7533
- order,
7534
- status: "pending"
7535
- }))
7536
- };
7537
- }
7538
- function createParallelRunState(issueNumbers) {
7539
- return {
7540
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
7541
- type: "parallel",
7542
- startedAt: new Date().toISOString(),
7543
- tasks: issueNumbers.map((issue, i) => ({
7544
- issue,
7545
- order: i + 1,
7546
- status: "pending"
7547
- }))
7548
- };
7549
- }
7550
- function markTaskInProgress(state, issueNumber) {
7551
- const task = state.tasks.find((t) => t.issue === issueNumber);
7552
- if (task) {
7553
- task.status = "in_progress";
7554
- }
7555
- }
7556
- function markTaskDone(state, issueNumber, prNumber) {
7557
- const task = state.tasks.find((t) => t.issue === issueNumber);
7558
- if (task) {
7559
- task.status = "done";
7560
- task.completedAt = new Date().toISOString();
7561
- if (prNumber)
7562
- task.pr = prNumber;
7563
- }
7564
- }
7565
- function markTaskFailed(state, issueNumber, error) {
7566
- const task = state.tasks.find((t) => t.issue === issueNumber);
7567
- if (task) {
7568
- task.status = "failed";
7569
- task.failedAt = new Date().toISOString();
7570
- task.error = error;
7571
- }
7572
- }
7573
- function getRunStats(state) {
7574
- const tasks = state.tasks;
7575
- return {
7576
- total: tasks.length,
7577
- done: tasks.filter((t) => t.status === "done").length,
7578
- failed: tasks.filter((t) => t.status === "failed").length,
7579
- pending: tasks.filter((t) => t.status === "pending").length,
7580
- inProgress: tasks.filter((t) => t.status === "in_progress").length
7581
- };
7582
- }
7583
- function getNextTask(state) {
7584
- const failed = state.tasks.find((t) => t.status === "failed");
7585
- if (failed)
7586
- return failed;
7587
- return state.tasks.find((t) => t.status === "pending") ?? null;
7588
- }
7589
- var init_run_state = __esm(() => {
7590
- init_logger();
7591
- });
7592
-
7593
- // src/core/shutdown.ts
7594
- function registerShutdownHandlers(ctx) {
7595
- shutdownContext = ctx;
7596
- interruptCount = 0;
7597
- const handler = () => {
7598
- interruptCount++;
7599
- if (interruptCount >= 2) {
7600
- process.stderr.write(`
7601
- Force exit.
7602
- `);
7603
- process.exit(1);
7604
- }
7605
- process.stderr.write(`
7606
-
7607
- Interrupted. Saving state...
7608
- `);
7609
- const state = shutdownContext?.getRunState?.();
7610
- if (state && shutdownContext) {
7611
- for (const task of state.tasks) {
7612
- if (task.status === "in_progress") {
7613
- task.status = "failed";
7614
- task.failedAt = new Date().toISOString();
7615
- task.error = "Interrupted by user";
7616
- }
7617
- }
7618
- try {
7619
- saveRunState(shutdownContext.projectRoot, state);
7620
- process.stderr.write(`State saved. Resume with: locus run --resume
8845
+
7621
8846
  `);
7622
- } catch {
7623
- process.stderr.write(`Warning: Could not save run state.
8847
+ } else if (result.newCommits > 0) {
8848
+ process.stderr.write(`
8849
+ ${bold(yellow("⚠"))} Base branch has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"} — auto-rebasing...
7624
8850
  `);
7625
- }
7626
- }
7627
- shutdownContext?.onShutdown?.();
7628
- if (interruptTimer)
7629
- clearTimeout(interruptTimer);
7630
- interruptTimer = setTimeout(() => {
7631
- interruptCount = 0;
7632
- }, 2000);
7633
- setTimeout(() => {
7634
- process.exit(130);
7635
- }, 100);
7636
- };
7637
- if (!shutdownRegistered) {
7638
- process.on("SIGINT", handler);
7639
- process.on("SIGTERM", handler);
7640
- shutdownRegistered = true;
7641
8851
  }
7642
- return () => {
7643
- process.removeListener("SIGINT", handler);
7644
- process.removeListener("SIGTERM", handler);
7645
- shutdownRegistered = false;
7646
- shutdownContext = null;
7647
- interruptCount = 0;
7648
- if (interruptTimer) {
7649
- clearTimeout(interruptTimer);
7650
- interruptTimer = null;
7651
- }
7652
- };
7653
8852
  }
7654
- var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null;
7655
- var init_shutdown = __esm(() => {
7656
- init_run_state();
8853
+ var init_conflict = __esm(() => {
8854
+ init_terminal();
8855
+ init_logger();
7657
8856
  });
7658
8857
 
7659
8858
  // src/core/worktree.ts
7660
- import { execSync as execSync11 } from "node:child_process";
7661
- import { existsSync as existsSync15, readdirSync as readdirSync6, realpathSync, statSync as statSync3 } from "node:fs";
7662
- import { join as join15 } from "node:path";
8859
+ import { execSync as execSync15 } from "node:child_process";
8860
+ import { existsSync as existsSync17, readdirSync as readdirSync6, realpathSync, statSync as statSync3 } from "node:fs";
8861
+ import { join as join17 } from "node:path";
7663
8862
  function git3(args, cwd) {
7664
- return execSync11(`git ${args}`, {
8863
+ return execSync15(`git ${args}`, {
7665
8864
  cwd,
7666
8865
  encoding: "utf-8",
7667
8866
  stdio: ["pipe", "pipe", "pipe"]
@@ -7675,10 +8874,10 @@ function gitSafe2(args, cwd) {
7675
8874
  }
7676
8875
  }
7677
8876
  function getWorktreeDir(projectRoot) {
7678
- return join15(projectRoot, ".locus", "worktrees");
8877
+ return join17(projectRoot, ".locus", "worktrees");
7679
8878
  }
7680
8879
  function getWorktreePath(projectRoot, issueNumber) {
7681
- return join15(getWorktreeDir(projectRoot), `issue-${issueNumber}`);
8880
+ return join17(getWorktreeDir(projectRoot), `issue-${issueNumber}`);
7682
8881
  }
7683
8882
  function generateBranchName(issueNumber) {
7684
8883
  const randomSuffix = Math.random().toString(36).slice(2, 8);
@@ -7686,7 +8885,7 @@ function generateBranchName(issueNumber) {
7686
8885
  }
7687
8886
  function getWorktreeBranch(worktreePath) {
7688
8887
  try {
7689
- return execSync11("git branch --show-current", {
8888
+ return execSync15("git branch --show-current", {
7690
8889
  cwd: worktreePath,
7691
8890
  encoding: "utf-8",
7692
8891
  stdio: ["pipe", "pipe", "pipe"]
@@ -7698,7 +8897,7 @@ function getWorktreeBranch(worktreePath) {
7698
8897
  function createWorktree(projectRoot, issueNumber, baseBranch) {
7699
8898
  const log = getLogger();
7700
8899
  const worktreePath = getWorktreePath(projectRoot, issueNumber);
7701
- if (existsSync15(worktreePath)) {
8900
+ if (existsSync17(worktreePath)) {
7702
8901
  log.verbose(`Worktree already exists for issue #${issueNumber}`);
7703
8902
  const existingBranch = getWorktreeBranch(worktreePath) ?? `locus/issue-${issueNumber}`;
7704
8903
  return {
@@ -7725,7 +8924,7 @@ function createWorktree(projectRoot, issueNumber, baseBranch) {
7725
8924
  function removeWorktree(projectRoot, issueNumber) {
7726
8925
  const log = getLogger();
7727
8926
  const worktreePath = getWorktreePath(projectRoot, issueNumber);
7728
- if (!existsSync15(worktreePath)) {
8927
+ if (!existsSync17(worktreePath)) {
7729
8928
  log.verbose(`Worktree for issue #${issueNumber} does not exist`);
7730
8929
  return;
7731
8930
  }
@@ -7744,7 +8943,7 @@ function removeWorktree(projectRoot, issueNumber) {
7744
8943
  function listWorktrees(projectRoot) {
7745
8944
  const log = getLogger();
7746
8945
  const worktreeDir = getWorktreeDir(projectRoot);
7747
- if (!existsSync15(worktreeDir)) {
8946
+ if (!existsSync17(worktreeDir)) {
7748
8947
  return [];
7749
8948
  }
7750
8949
  const entries = readdirSync6(worktreeDir).filter((entry) => entry.startsWith("issue-"));
@@ -7764,7 +8963,7 @@ function listWorktrees(projectRoot) {
7764
8963
  if (!match)
7765
8964
  continue;
7766
8965
  const issueNumber = Number.parseInt(match[1], 10);
7767
- const path = join15(worktreeDir, entry);
8966
+ const path = join17(worktreeDir, entry);
7768
8967
  const branch = getWorktreeBranch(path) ?? `locus/issue-${issueNumber}`;
7769
8968
  let resolvedPath;
7770
8969
  try {
@@ -7811,8 +9010,44 @@ var exports_run = {};
7811
9010
  __export(exports_run, {
7812
9011
  runCommand: () => runCommand
7813
9012
  });
7814
- import { execSync as execSync12 } from "node:child_process";
9013
+ import { execSync as execSync16 } from "node:child_process";
9014
+ function printRunHelp() {
9015
+ process.stderr.write(`
9016
+ ${bold("locus run")} — Execute issues using AI agents
9017
+
9018
+ ${bold("Usage:")}
9019
+ locus run ${dim("# Run active sprint (sequential)")}
9020
+ locus run <issue> ${dim("# Run single issue (worktree)")}
9021
+ locus run <issue> <issue> ... ${dim("# Run multiple issues (parallel)")}
9022
+ locus run --resume ${dim("# Resume interrupted run")}
9023
+
9024
+ ${bold("Options:")}
9025
+ --resume Resume a previously interrupted run
9026
+ --dry-run Show what would happen without executing
9027
+ --model <name> Override the AI model for this run
9028
+ --no-sandbox Disable Docker sandbox isolation
9029
+ --sandbox=require Require Docker sandbox (fail if unavailable)
9030
+
9031
+ ${bold("Sandbox:")}
9032
+ By default, agents run inside Docker Desktop sandboxes (4.58+) for
9033
+ hypervisor-level isolation. If Docker is not available, agents run
9034
+ unsandboxed with a warning.
9035
+
9036
+ ${bold("Examples:")}
9037
+ locus run ${dim("# Execute active sprint")}
9038
+ locus run 42 ${dim("# Run single issue")}
9039
+ locus run 42 43 44 ${dim("# Run issues in parallel")}
9040
+ locus run --resume ${dim("# Resume after failure")}
9041
+ locus run 42 --no-sandbox ${dim("# Run without sandbox")}
9042
+ locus run 42 --sandbox=require ${dim("# Require sandbox")}
9043
+
9044
+ `);
9045
+ }
7815
9046
  async function runCommand(projectRoot, args, flags = {}) {
9047
+ if (args[0] === "help") {
9048
+ printRunHelp();
9049
+ return;
9050
+ }
7816
9051
  const config = loadConfig(projectRoot);
7817
9052
  const _log = getLogger();
7818
9053
  const cleanupShutdown = registerShutdownHandlers({
@@ -7820,22 +9055,43 @@ async function runCommand(projectRoot, args, flags = {}) {
7820
9055
  getRunState: () => loadRunState(projectRoot)
7821
9056
  });
7822
9057
  try {
9058
+ const sandboxMode = resolveSandboxMode(config.sandbox, flags);
9059
+ let sandboxed = false;
9060
+ if (sandboxMode !== "disabled") {
9061
+ const status = await detectSandboxSupport();
9062
+ if (!status.available) {
9063
+ if (sandboxMode === "required") {
9064
+ process.stderr.write(`${red("✗")} Docker sandbox required but not available: ${status.reason}
9065
+ `);
9066
+ process.stderr.write(` Install Docker Desktop 4.58+ or remove --sandbox=require to continue.
9067
+ `);
9068
+ process.exit(1);
9069
+ }
9070
+ process.stderr.write(`${yellow("⚠")} Docker sandbox not available: ${status.reason}. Running unsandboxed.
9071
+ `);
9072
+ } else {
9073
+ sandboxed = true;
9074
+ }
9075
+ } else if (flags.noSandbox) {
9076
+ process.stderr.write(`${yellow("⚠")} Running without sandbox. The AI agent will have unrestricted access to your filesystem, network, and environment variables.
9077
+ `);
9078
+ }
7823
9079
  if (flags.resume) {
7824
- return handleResume(projectRoot, config);
9080
+ return handleResume(projectRoot, config, sandboxed);
7825
9081
  }
7826
9082
  const issueNumbers = args.filter((a) => /^\d+$/.test(a)).map(Number);
7827
9083
  if (issueNumbers.length === 0) {
7828
- return handleSprintRun(projectRoot, config, flags);
9084
+ return handleSprintRun(projectRoot, config, flags, sandboxed);
7829
9085
  }
7830
9086
  if (issueNumbers.length === 1) {
7831
- return handleSingleIssue(projectRoot, config, issueNumbers[0], flags);
9087
+ return handleSingleIssue(projectRoot, config, issueNumbers[0], flags, sandboxed);
7832
9088
  }
7833
- return handleParallelRun(projectRoot, config, issueNumbers, flags);
9089
+ return handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed);
7834
9090
  } finally {
7835
9091
  cleanupShutdown();
7836
9092
  }
7837
9093
  }
7838
- async function handleSprintRun(projectRoot, config, flags) {
9094
+ async function handleSprintRun(projectRoot, config, flags, sandboxed) {
7839
9095
  const log = getLogger();
7840
9096
  if (!config.sprint.active) {
7841
9097
  process.stderr.write(`${red("✗")} No active sprint. Set one with: ${bold("locus sprint active <name>")}
@@ -7898,7 +9154,7 @@ ${yellow("⚠")} A sprint run is already in progress.
7898
9154
  }
7899
9155
  if (!flags.dryRun) {
7900
9156
  try {
7901
- execSync12(`git checkout -B ${branchName}`, {
9157
+ execSync16(`git checkout -B ${branchName}`, {
7902
9158
  cwd: projectRoot,
7903
9159
  encoding: "utf-8",
7904
9160
  stdio: ["pipe", "pipe", "pipe"]
@@ -7948,7 +9204,7 @@ ${red("✗")} Auto-rebase failed. Resolve manually.
7948
9204
  let sprintContext;
7949
9205
  if (i > 0 && !flags.dryRun) {
7950
9206
  try {
7951
- sprintContext = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9207
+ sprintContext = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD`, {
7952
9208
  cwd: projectRoot,
7953
9209
  encoding: "utf-8",
7954
9210
  stdio: ["pipe", "pipe", "pipe"]
@@ -7967,12 +9223,17 @@ ${progressBar(i, state.tasks.length, { label: "Sprint Progress" })}
7967
9223
  model: flags.model ?? config.ai.model,
7968
9224
  dryRun: flags.dryRun,
7969
9225
  sprintContext,
7970
- skipPR: true
9226
+ skipPR: true,
9227
+ sandboxed
7971
9228
  });
7972
9229
  if (result.success) {
7973
9230
  if (!flags.dryRun) {
7974
9231
  const issueTitle = issue?.title ?? "";
7975
9232
  ensureTaskCommit(projectRoot, task.issue, issueTitle);
9233
+ if (sandboxed && i < state.tasks.length - 1) {
9234
+ process.stderr.write(` ${dim("↻ Sandbox will resync on next task")}
9235
+ `);
9236
+ }
7976
9237
  }
7977
9238
  markTaskDone(state, task.issue, result.prNumber);
7978
9239
  } else {
@@ -8007,7 +9268,7 @@ ${bold("Summary:")}
8007
9268
  const prNumber = await createSprintPR(projectRoot, config, sprintName, branchName, completedTasks);
8008
9269
  if (prNumber !== undefined) {
8009
9270
  try {
8010
- execSync12(`git checkout ${config.agent.baseBranch}`, {
9271
+ execSync16(`git checkout ${config.agent.baseBranch}`, {
8011
9272
  cwd: projectRoot,
8012
9273
  encoding: "utf-8",
8013
9274
  stdio: ["pipe", "pipe", "pipe"]
@@ -8021,7 +9282,7 @@ ${bold("Summary:")}
8021
9282
  clearRunState(projectRoot);
8022
9283
  }
8023
9284
  }
8024
- async function handleSingleIssue(projectRoot, config, issueNumber, flags) {
9285
+ async function handleSingleIssue(projectRoot, config, issueNumber, flags, sandboxed) {
8025
9286
  let isSprintIssue = false;
8026
9287
  try {
8027
9288
  const issue = getIssue(issueNumber, { cwd: projectRoot });
@@ -8036,7 +9297,9 @@ ${bold("Running sprint issue")} ${cyan(`#${issueNumber}`)} ${dim("(sequential, n
8036
9297
  issueNumber,
8037
9298
  provider: config.ai.provider,
8038
9299
  model: flags.model ?? config.ai.model,
8039
- dryRun: flags.dryRun
9300
+ dryRun: flags.dryRun,
9301
+ sandboxed,
9302
+ sandboxName: config.sandbox.name
8040
9303
  });
8041
9304
  return;
8042
9305
  }
@@ -8065,7 +9328,9 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
8065
9328
  worktreePath,
8066
9329
  provider: config.ai.provider,
8067
9330
  model: flags.model ?? config.ai.model,
8068
- dryRun: flags.dryRun
9331
+ dryRun: flags.dryRun,
9332
+ sandboxed,
9333
+ sandboxName: config.sandbox.name
8069
9334
  });
8070
9335
  if (worktreePath && !flags.dryRun) {
8071
9336
  if (result.success) {
@@ -8078,7 +9343,7 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
8078
9343
  }
8079
9344
  }
8080
9345
  }
8081
- async function handleParallelRun(projectRoot, config, issueNumbers, flags) {
9346
+ async function handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed) {
8082
9347
  const log = getLogger();
8083
9348
  const maxConcurrent = config.agent.maxParallel;
8084
9349
  process.stderr.write(`
@@ -8133,7 +9398,9 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
8133
9398
  worktreePath,
8134
9399
  provider: config.ai.provider,
8135
9400
  model: flags.model ?? config.ai.model,
8136
- dryRun: flags.dryRun
9401
+ dryRun: flags.dryRun,
9402
+ sandboxed,
9403
+ sandboxName: config.sandbox.name
8137
9404
  });
8138
9405
  if (result.success) {
8139
9406
  markTaskDone(state, issueNumber, result.prNumber);
@@ -8145,9 +9412,19 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
8145
9412
  markTaskFailed(state, issueNumber, result.error ?? "Unknown error");
8146
9413
  }
8147
9414
  saveRunState(projectRoot, state);
8148
- results.push({ issue: issueNumber, success: result.success });
9415
+ return { issue: issueNumber, success: result.success };
8149
9416
  });
8150
- await Promise.all(promises);
9417
+ const settled = await Promise.allSettled(promises);
9418
+ for (const outcome of settled) {
9419
+ if (outcome.status === "fulfilled") {
9420
+ results.push(outcome.value);
9421
+ } else {
9422
+ const idx = settled.indexOf(outcome);
9423
+ const issueNumber = batch[idx];
9424
+ log.warn(`Parallel task #${issueNumber} threw: ${outcome.reason}`);
9425
+ results.push({ issue: issueNumber, success: false });
9426
+ }
9427
+ }
8151
9428
  }
8152
9429
  const succeeded = results.filter((r) => r.success).length;
8153
9430
  const failed = results.filter((r) => !r.success).length;
@@ -8172,7 +9449,7 @@ ${yellow("⚠")} Failed worktrees preserved for debugging:
8172
9449
  clearRunState(projectRoot);
8173
9450
  }
8174
9451
  }
8175
- async function handleResume(projectRoot, config) {
9452
+ async function handleResume(projectRoot, config, sandboxed) {
8176
9453
  const state = loadRunState(projectRoot);
8177
9454
  if (!state) {
8178
9455
  process.stderr.write(`${red("✗")} No run state found. Nothing to resume.
@@ -8188,13 +9465,13 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
8188
9465
  `);
8189
9466
  if (state.type === "sprint" && state.branch) {
8190
9467
  try {
8191
- const currentBranch = execSync12("git rev-parse --abbrev-ref HEAD", {
9468
+ const currentBranch = execSync16("git rev-parse --abbrev-ref HEAD", {
8192
9469
  cwd: projectRoot,
8193
9470
  encoding: "utf-8",
8194
9471
  stdio: ["pipe", "pipe", "pipe"]
8195
9472
  }).trim();
8196
9473
  if (currentBranch !== state.branch) {
8197
- execSync12(`git checkout ${state.branch}`, {
9474
+ execSync16(`git checkout ${state.branch}`, {
8198
9475
  cwd: projectRoot,
8199
9476
  encoding: "utf-8",
8200
9477
  stdio: ["pipe", "pipe", "pipe"]
@@ -8220,7 +9497,9 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
8220
9497
  issueNumber: task.issue,
8221
9498
  provider: config.ai.provider,
8222
9499
  model: config.ai.model,
8223
- skipPR: isSprintRun
9500
+ skipPR: isSprintRun,
9501
+ sandboxed,
9502
+ sandboxName: isSprintRun ? undefined : config.sandbox.name
8224
9503
  });
8225
9504
  if (result.success) {
8226
9505
  if (isSprintRun) {
@@ -8230,6 +9509,10 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
8230
9509
  issueTitle = iss.title;
8231
9510
  } catch {}
8232
9511
  ensureTaskCommit(projectRoot, task.issue, issueTitle);
9512
+ if (sandboxed) {
9513
+ process.stderr.write(` ${dim("↻ Sandbox will resync on next task")}
9514
+ `);
9515
+ }
8233
9516
  }
8234
9517
  markTaskDone(state, task.issue, result.prNumber);
8235
9518
  } else {
@@ -8255,7 +9538,7 @@ ${bold("Resume complete:")} ${green(`✓ ${finalStats.done}`)} ${finalStats.fail
8255
9538
  const prNumber = await createSprintPR(projectRoot, config, state.sprint, state.branch, completedTasks);
8256
9539
  if (prNumber !== undefined) {
8257
9540
  try {
8258
- execSync12(`git checkout ${config.agent.baseBranch}`, {
9541
+ execSync16(`git checkout ${config.agent.baseBranch}`, {
8259
9542
  cwd: projectRoot,
8260
9543
  encoding: "utf-8",
8261
9544
  stdio: ["pipe", "pipe", "pipe"]
@@ -8286,14 +9569,14 @@ function getOrder2(issue) {
8286
9569
  }
8287
9570
  function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
8288
9571
  try {
8289
- const status = execSync12("git status --porcelain", {
9572
+ const status = execSync16("git status --porcelain", {
8290
9573
  cwd: projectRoot,
8291
9574
  encoding: "utf-8",
8292
9575
  stdio: ["pipe", "pipe", "pipe"]
8293
9576
  }).trim();
8294
9577
  if (!status)
8295
9578
  return;
8296
- execSync12("git add -A", {
9579
+ execSync16("git add -A", {
8297
9580
  cwd: projectRoot,
8298
9581
  encoding: "utf-8",
8299
9582
  stdio: ["pipe", "pipe", "pipe"]
@@ -8301,7 +9584,7 @@ function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
8301
9584
  const message = `chore: complete #${issueNumber} - ${issueTitle}
8302
9585
 
8303
9586
  Co-Authored-By: LocusAgent <agent@locusai.team>`;
8304
- execSync12(`git commit -F -`, {
9587
+ execSync16(`git commit -F -`, {
8305
9588
  input: message,
8306
9589
  cwd: projectRoot,
8307
9590
  encoding: "utf-8",
@@ -8315,7 +9598,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8315
9598
  if (!config.agent.autoPR)
8316
9599
  return;
8317
9600
  try {
8318
- const diff = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9601
+ const diff = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8319
9602
  cwd: projectRoot,
8320
9603
  encoding: "utf-8",
8321
9604
  stdio: ["pipe", "pipe", "pipe"]
@@ -8325,7 +9608,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8325
9608
  `);
8326
9609
  return;
8327
9610
  }
8328
- execSync12(`git push -u origin ${branchName}`, {
9611
+ execSync16(`git push -u origin ${branchName}`, {
8329
9612
  cwd: projectRoot,
8330
9613
  encoding: "utf-8",
8331
9614
  stdio: ["pipe", "pipe", "pipe"]
@@ -8358,6 +9641,7 @@ var init_run = __esm(() => {
8358
9641
  init_logger();
8359
9642
  init_rate_limiter();
8360
9643
  init_run_state();
9644
+ init_sandbox();
8361
9645
  init_shutdown();
8362
9646
  init_worktree();
8363
9647
  init_progress();
@@ -8371,6 +9655,8 @@ __export(exports_status, {
8371
9655
  });
8372
9656
  async function statusCommand(projectRoot) {
8373
9657
  const config = loadConfig(projectRoot);
9658
+ const spinner = new Spinner;
9659
+ spinner.start("Fetching project status...");
8374
9660
  const lines = [];
8375
9661
  lines.push(` ${dim("Repo:")} ${cyan(`${config.github.owner}/${config.github.repo}`)}`);
8376
9662
  lines.push(` ${dim("Provider:")} ${config.ai.provider} / ${config.ai.model}`);
@@ -8447,6 +9733,7 @@ async function statusCommand(projectRoot) {
8447
9733
  }
8448
9734
  }
8449
9735
  } catch {}
9736
+ spinner.stop();
8450
9737
  lines.push("");
8451
9738
  process.stderr.write(`
8452
9739
  ${drawBox(lines, { title: "Locus Status" })}
@@ -8470,13 +9757,13 @@ __export(exports_plan, {
8470
9757
  parsePlanArgs: () => parsePlanArgs
8471
9758
  });
8472
9759
  import {
8473
- existsSync as existsSync16,
8474
- mkdirSync as mkdirSync11,
9760
+ existsSync as existsSync18,
9761
+ mkdirSync as mkdirSync12,
8475
9762
  readdirSync as readdirSync7,
8476
- readFileSync as readFileSync12,
9763
+ readFileSync as readFileSync13,
8477
9764
  writeFileSync as writeFileSync9
8478
9765
  } from "node:fs";
8479
- import { join as join16 } from "node:path";
9766
+ import { join as join18 } from "node:path";
8480
9767
  function printHelp() {
8481
9768
  process.stderr.write(`
8482
9769
  ${bold("locus plan")} — AI-powered sprint planning
@@ -8507,28 +9794,28 @@ function normalizeSprintName(name) {
8507
9794
  return name.trim().toLowerCase();
8508
9795
  }
8509
9796
  function getPlansDir(projectRoot) {
8510
- return join16(projectRoot, ".locus", "plans");
9797
+ return join18(projectRoot, ".locus", "plans");
8511
9798
  }
8512
9799
  function ensurePlansDir(projectRoot) {
8513
9800
  const dir = getPlansDir(projectRoot);
8514
- if (!existsSync16(dir)) {
8515
- mkdirSync11(dir, { recursive: true });
9801
+ if (!existsSync18(dir)) {
9802
+ mkdirSync12(dir, { recursive: true });
8516
9803
  }
8517
9804
  return dir;
8518
9805
  }
8519
9806
  function generateId() {
8520
- return `${Math.random().toString(36).slice(2, 8)}`;
9807
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8521
9808
  }
8522
9809
  function loadPlanFile(projectRoot, id) {
8523
9810
  const dir = getPlansDir(projectRoot);
8524
- if (!existsSync16(dir))
9811
+ if (!existsSync18(dir))
8525
9812
  return null;
8526
9813
  const files = readdirSync7(dir).filter((f) => f.endsWith(".json"));
8527
9814
  const match = files.find((f) => f.startsWith(id));
8528
9815
  if (!match)
8529
9816
  return null;
8530
9817
  try {
8531
- const content = readFileSync12(join16(dir, match), "utf-8");
9818
+ const content = readFileSync13(join18(dir, match), "utf-8");
8532
9819
  return JSON.parse(content);
8533
9820
  } catch {
8534
9821
  return null;
@@ -8574,7 +9861,7 @@ async function planCommand(projectRoot, args, flags = {}) {
8574
9861
  }
8575
9862
  function handleListPlans(projectRoot) {
8576
9863
  const dir = getPlansDir(projectRoot);
8577
- if (!existsSync16(dir)) {
9864
+ if (!existsSync18(dir)) {
8578
9865
  process.stderr.write(`${dim("No saved plans yet.")}
8579
9866
  `);
8580
9867
  return;
@@ -8592,7 +9879,7 @@ ${bold("Saved Plans:")}
8592
9879
  for (const file of files) {
8593
9880
  const id = file.replace(".json", "");
8594
9881
  try {
8595
- const content = readFileSync12(join16(dir, file), "utf-8");
9882
+ const content = readFileSync13(join18(dir, file), "utf-8");
8596
9883
  const plan = JSON.parse(content);
8597
9884
  const date = plan.createdAt ? plan.createdAt.slice(0, 10) : "";
8598
9885
  const issueCount = Array.isArray(plan.issues) ? plan.issues.length : 0;
@@ -8703,7 +9990,7 @@ ${bold("Approving plan:")}
8703
9990
  async function handleAIPlan(projectRoot, config, directive, sprintName, flags) {
8704
9991
  const id = generateId();
8705
9992
  const plansDir = ensurePlansDir(projectRoot);
8706
- const planPath = join16(plansDir, `${id}.json`);
9993
+ const planPath = join18(plansDir, `${id}.json`);
8707
9994
  const planPathRelative = `.locus/plans/${id}.json`;
8708
9995
  const displayDirective = directive;
8709
9996
  process.stderr.write(`
@@ -8721,7 +10008,9 @@ ${bold("Planning:")} ${cyan(displayDirective)}
8721
10008
  provider: config.ai.provider,
8722
10009
  model: flags.model ?? config.ai.model,
8723
10010
  cwd: projectRoot,
8724
- activity: "planning"
10011
+ activity: "planning",
10012
+ sandboxed: config.sandbox.enabled,
10013
+ sandboxName: config.sandbox.name
8725
10014
  });
8726
10015
  if (aiResult.interrupted) {
8727
10016
  process.stderr.write(`
@@ -8735,7 +10024,7 @@ ${red("✗")} Planning failed: ${aiResult.error}
8735
10024
  `);
8736
10025
  return;
8737
10026
  }
8738
- if (!existsSync16(planPath)) {
10027
+ if (!existsSync18(planPath)) {
8739
10028
  process.stderr.write(`
8740
10029
  ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8741
10030
  `);
@@ -8745,7 +10034,7 @@ ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8745
10034
  }
8746
10035
  let plan;
8747
10036
  try {
8748
- const content = readFileSync12(planPath, "utf-8");
10037
+ const content = readFileSync13(planPath, "utf-8");
8749
10038
  plan = JSON.parse(content);
8750
10039
  } catch {
8751
10040
  process.stderr.write(`
@@ -8829,7 +10118,9 @@ Start with foundational/setup tasks, then core features, then integration/testin
8829
10118
  model: flags.model ?? config.ai.model,
8830
10119
  cwd: projectRoot,
8831
10120
  activity: "issue ordering",
8832
- silent: true
10121
+ silent: true,
10122
+ sandboxed: config.sandbox.enabled,
10123
+ sandboxName: config.sandbox.name
8833
10124
  });
8834
10125
  if (aiResult.interrupted) {
8835
10126
  process.stderr.write(`
@@ -8900,16 +10191,16 @@ function buildPlanningPrompt(projectRoot, config, directive, sprintName, id, pla
8900
10191
  parts.push(`SPRINT: ${sprintName}`);
8901
10192
  }
8902
10193
  parts.push("");
8903
- const locusPath = join16(projectRoot, "LOCUS.md");
8904
- if (existsSync16(locusPath)) {
8905
- const content = readFileSync12(locusPath, "utf-8");
10194
+ const locusPath = join18(projectRoot, "LOCUS.md");
10195
+ if (existsSync18(locusPath)) {
10196
+ const content = readFileSync13(locusPath, "utf-8");
8906
10197
  parts.push("PROJECT CONTEXT (LOCUS.md):");
8907
10198
  parts.push(content.slice(0, 3000));
8908
10199
  parts.push("");
8909
10200
  }
8910
- const learningsPath = join16(projectRoot, ".locus", "LEARNINGS.md");
8911
- if (existsSync16(learningsPath)) {
8912
- const content = readFileSync12(learningsPath, "utf-8");
10201
+ const learningsPath = join18(projectRoot, ".locus", "LEARNINGS.md");
10202
+ if (existsSync18(learningsPath)) {
10203
+ const content = readFileSync13(learningsPath, "utf-8");
8913
10204
  parts.push("PAST LEARNINGS:");
8914
10205
  parts.push(content.slice(0, 2000));
8915
10206
  parts.push("");
@@ -9085,9 +10376,9 @@ var exports_review = {};
9085
10376
  __export(exports_review, {
9086
10377
  reviewCommand: () => reviewCommand
9087
10378
  });
9088
- import { execSync as execSync13 } from "node:child_process";
9089
- import { existsSync as existsSync17, readFileSync as readFileSync13 } from "node:fs";
9090
- import { join as join17 } from "node:path";
10379
+ import { execSync as execSync17 } from "node:child_process";
10380
+ import { existsSync as existsSync19, readFileSync as readFileSync14 } from "node:fs";
10381
+ import { join as join19 } from "node:path";
9091
10382
  function printHelp2() {
9092
10383
  process.stderr.write(`
9093
10384
  ${bold("locus review")} — AI-powered code review
@@ -9163,7 +10454,7 @@ ${bold("Review complete:")} ${green(`✓ ${reviewed}`)}${failed > 0 ? ` ${red(`
9163
10454
  async function reviewSinglePR(projectRoot, config, prNumber, focus, flags) {
9164
10455
  let prInfo;
9165
10456
  try {
9166
- const result = execSync13(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10457
+ const result = execSync17(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9167
10458
  const raw = JSON.parse(result);
9168
10459
  prInfo = {
9169
10460
  number: raw.number,
@@ -9206,7 +10497,9 @@ async function reviewPR(projectRoot, config, pr, focus, flags) {
9206
10497
  provider: config.ai.provider,
9207
10498
  model: flags.model ?? config.ai.model,
9208
10499
  cwd: projectRoot,
9209
- activity: `PR #${pr.number}`
10500
+ activity: `PR #${pr.number}`,
10501
+ sandboxed: config.sandbox.enabled,
10502
+ sandboxName: config.sandbox.name
9210
10503
  });
9211
10504
  if (aiResult.interrupted) {
9212
10505
  process.stderr.write(` ${yellow("⚡")} Review interrupted.
@@ -9227,7 +10520,7 @@ ${output.slice(0, 60000)}
9227
10520
 
9228
10521
  ---
9229
10522
  _Reviewed by Locus AI (${config.ai.provider}/${flags.model ?? config.ai.model})_`;
9230
- execSync13(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10523
+ execSync17(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9231
10524
  process.stderr.write(` ${green("✓")} Review posted ${dim(`(${timer.formatted()})`)}
9232
10525
  `);
9233
10526
  } catch (e) {
@@ -9244,9 +10537,9 @@ function buildReviewPrompt(projectRoot, config, pr, diff, focus) {
9244
10537
  const parts = [];
9245
10538
  parts.push(`You are an expert code reviewer for the ${config.github.owner}/${config.github.repo} repository.`);
9246
10539
  parts.push("");
9247
- const locusPath = join17(projectRoot, "LOCUS.md");
9248
- if (existsSync17(locusPath)) {
9249
- const content = readFileSync13(locusPath, "utf-8");
10540
+ const locusPath = join19(projectRoot, "LOCUS.md");
10541
+ if (existsSync19(locusPath)) {
10542
+ const content = readFileSync14(locusPath, "utf-8");
9250
10543
  parts.push("PROJECT CONTEXT:");
9251
10544
  parts.push(content.slice(0, 2000));
9252
10545
  parts.push("");
@@ -9298,7 +10591,7 @@ var exports_iterate = {};
9298
10591
  __export(exports_iterate, {
9299
10592
  iterateCommand: () => iterateCommand
9300
10593
  });
9301
- import { execSync as execSync14 } from "node:child_process";
10594
+ import { execSync as execSync18 } from "node:child_process";
9302
10595
  function printHelp3() {
9303
10596
  process.stderr.write(`
9304
10597
  ${bold("locus iterate")} — Re-execute tasks with PR feedback
@@ -9508,12 +10801,12 @@ ${bold("Summary:")} ${green(`✓ ${succeeded}`)}${failed > 0 ? ` ${red(`✗ ${fa
9508
10801
  }
9509
10802
  function findPRForIssue(projectRoot, issueNumber) {
9510
10803
  try {
9511
- const result = execSync14(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10804
+ const result = execSync18(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9512
10805
  const parsed = JSON.parse(result);
9513
10806
  if (parsed.length > 0) {
9514
10807
  return parsed[0].number;
9515
10808
  }
9516
- const branchResult = execSync14(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10809
+ const branchResult = execSync18(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9517
10810
  const branchParsed = JSON.parse(branchResult);
9518
10811
  if (branchParsed.length > 0) {
9519
10812
  return branchParsed[0].number;
@@ -9548,14 +10841,14 @@ __export(exports_discuss, {
9548
10841
  discussCommand: () => discussCommand
9549
10842
  });
9550
10843
  import {
9551
- existsSync as existsSync18,
9552
- mkdirSync as mkdirSync12,
10844
+ existsSync as existsSync20,
10845
+ mkdirSync as mkdirSync13,
9553
10846
  readdirSync as readdirSync8,
9554
- readFileSync as readFileSync14,
10847
+ readFileSync as readFileSync15,
9555
10848
  unlinkSync as unlinkSync5,
9556
10849
  writeFileSync as writeFileSync10
9557
10850
  } from "node:fs";
9558
- import { join as join18 } from "node:path";
10851
+ import { join as join20 } from "node:path";
9559
10852
  function printHelp4() {
9560
10853
  process.stderr.write(`
9561
10854
  ${bold("locus discuss")} — AI-powered architectural discussions
@@ -9577,12 +10870,12 @@ ${bold("Examples:")}
9577
10870
  `);
9578
10871
  }
9579
10872
  function getDiscussionsDir(projectRoot) {
9580
- return join18(projectRoot, ".locus", "discussions");
10873
+ return join20(projectRoot, ".locus", "discussions");
9581
10874
  }
9582
10875
  function ensureDiscussionsDir(projectRoot) {
9583
10876
  const dir = getDiscussionsDir(projectRoot);
9584
- if (!existsSync18(dir)) {
9585
- mkdirSync12(dir, { recursive: true });
10877
+ if (!existsSync20(dir)) {
10878
+ mkdirSync13(dir, { recursive: true });
9586
10879
  }
9587
10880
  return dir;
9588
10881
  }
@@ -9616,7 +10909,7 @@ async function discussCommand(projectRoot, args, flags = {}) {
9616
10909
  }
9617
10910
  function listDiscussions(projectRoot) {
9618
10911
  const dir = getDiscussionsDir(projectRoot);
9619
- if (!existsSync18(dir)) {
10912
+ if (!existsSync20(dir)) {
9620
10913
  process.stderr.write(`${dim("No discussions yet.")}
9621
10914
  `);
9622
10915
  return;
@@ -9633,7 +10926,7 @@ ${bold("Discussions:")}
9633
10926
  `);
9634
10927
  for (const file of files) {
9635
10928
  const id = file.replace(".md", "");
9636
- const content = readFileSync14(join18(dir, file), "utf-8");
10929
+ const content = readFileSync15(join20(dir, file), "utf-8");
9637
10930
  const titleMatch = content.match(/^#\s+(.+)/m);
9638
10931
  const title = titleMatch ? titleMatch[1] : id;
9639
10932
  const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
@@ -9651,7 +10944,7 @@ function showDiscussion(projectRoot, id) {
9651
10944
  return;
9652
10945
  }
9653
10946
  const dir = getDiscussionsDir(projectRoot);
9654
- if (!existsSync18(dir)) {
10947
+ if (!existsSync20(dir)) {
9655
10948
  process.stderr.write(`${red("✗")} No discussions found.
9656
10949
  `);
9657
10950
  return;
@@ -9663,7 +10956,7 @@ function showDiscussion(projectRoot, id) {
9663
10956
  `);
9664
10957
  return;
9665
10958
  }
9666
- const content = readFileSync14(join18(dir, match), "utf-8");
10959
+ const content = readFileSync15(join20(dir, match), "utf-8");
9667
10960
  process.stdout.write(`${content}
9668
10961
  `);
9669
10962
  }
@@ -9674,7 +10967,7 @@ function deleteDiscussion(projectRoot, id) {
9674
10967
  return;
9675
10968
  }
9676
10969
  const dir = getDiscussionsDir(projectRoot);
9677
- if (!existsSync18(dir)) {
10970
+ if (!existsSync20(dir)) {
9678
10971
  process.stderr.write(`${red("✗")} No discussions found.
9679
10972
  `);
9680
10973
  return;
@@ -9686,7 +10979,7 @@ function deleteDiscussion(projectRoot, id) {
9686
10979
  `);
9687
10980
  return;
9688
10981
  }
9689
- unlinkSync5(join18(dir, match));
10982
+ unlinkSync5(join20(dir, match));
9690
10983
  process.stderr.write(`${green("✓")} Deleted discussion: ${match.replace(".md", "")}
9691
10984
  `);
9692
10985
  }
@@ -9699,7 +10992,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9699
10992
  return;
9700
10993
  }
9701
10994
  const dir = getDiscussionsDir(projectRoot);
9702
- if (!existsSync18(dir)) {
10995
+ if (!existsSync20(dir)) {
9703
10996
  process.stderr.write(`${red("✗")} No discussions found.
9704
10997
  `);
9705
10998
  return;
@@ -9711,7 +11004,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9711
11004
  `);
9712
11005
  return;
9713
11006
  }
9714
- const content = readFileSync14(join18(dir, match), "utf-8");
11007
+ const content = readFileSync15(join20(dir, match), "utf-8");
9715
11008
  const titleMatch = content.match(/^#\s+(.+)/m);
9716
11009
  const discussionTitle = titleMatch ? titleMatch[1].trim() : id;
9717
11010
  await planCommand(projectRoot, [
@@ -9756,7 +11049,9 @@ ${bold("Discussion:")} ${cyan(topic)}
9756
11049
  provider: config.ai.provider,
9757
11050
  model: flags.model ?? config.ai.model,
9758
11051
  cwd: projectRoot,
9759
- activity: "discussion"
11052
+ activity: "discussion",
11053
+ sandboxed: config.sandbox.enabled,
11054
+ sandboxName: config.sandbox.name
9760
11055
  });
9761
11056
  if (aiResult.interrupted) {
9762
11057
  process.stderr.write(`
@@ -9823,7 +11118,7 @@ ${turn.content}`;
9823
11118
  ...conversation.length > 1 ? [`---`, ``, `## Discussion Transcript`, ``, transcript, ``] : []
9824
11119
  ].join(`
9825
11120
  `);
9826
- writeFileSync10(join18(dir, `${id}.md`), markdown, "utf-8");
11121
+ writeFileSync10(join20(dir, `${id}.md`), markdown, "utf-8");
9827
11122
  process.stderr.write(`
9828
11123
  ${green("✓")} Discussion saved: ${cyan(id)} ${dim(`(${timer.formatted()})`)}
9829
11124
  `);
@@ -9837,16 +11132,16 @@ function buildDiscussionPrompt(projectRoot, config, topic, conversation, forceFi
9837
11132
  const parts = [];
9838
11133
  parts.push(`You are a senior software architect and consultant for the ${config.github.owner}/${config.github.repo} project.`);
9839
11134
  parts.push("");
9840
- const locusPath = join18(projectRoot, "LOCUS.md");
9841
- if (existsSync18(locusPath)) {
9842
- const content = readFileSync14(locusPath, "utf-8");
11135
+ const locusPath = join20(projectRoot, "LOCUS.md");
11136
+ if (existsSync20(locusPath)) {
11137
+ const content = readFileSync15(locusPath, "utf-8");
9843
11138
  parts.push("PROJECT CONTEXT:");
9844
11139
  parts.push(content.slice(0, 3000));
9845
11140
  parts.push("");
9846
11141
  }
9847
- const learningsPath = join18(projectRoot, ".locus", "LEARNINGS.md");
9848
- if (existsSync18(learningsPath)) {
9849
- const content = readFileSync14(learningsPath, "utf-8");
11142
+ const learningsPath = join20(projectRoot, ".locus", "LEARNINGS.md");
11143
+ if (existsSync20(learningsPath)) {
11144
+ const content = readFileSync15(learningsPath, "utf-8");
9850
11145
  parts.push("PAST LEARNINGS:");
9851
11146
  parts.push(content.slice(0, 2000));
9852
11147
  parts.push("");
@@ -9905,8 +11200,8 @@ __export(exports_artifacts, {
9905
11200
  formatDate: () => formatDate2,
9906
11201
  artifactsCommand: () => artifactsCommand
9907
11202
  });
9908
- import { existsSync as existsSync19, readdirSync as readdirSync9, readFileSync as readFileSync15, statSync as statSync4 } from "node:fs";
9909
- import { join as join19 } from "node:path";
11203
+ import { existsSync as existsSync21, readdirSync as readdirSync9, readFileSync as readFileSync16, statSync as statSync4 } from "node:fs";
11204
+ import { join as join21 } from "node:path";
9910
11205
  function printHelp5() {
9911
11206
  process.stderr.write(`
9912
11207
  ${bold("locus artifacts")} — View and manage AI-generated artifacts
@@ -9926,14 +11221,14 @@ ${dim("Artifact names support partial matching.")}
9926
11221
  `);
9927
11222
  }
9928
11223
  function getArtifactsDir(projectRoot) {
9929
- return join19(projectRoot, ".locus", "artifacts");
11224
+ return join21(projectRoot, ".locus", "artifacts");
9930
11225
  }
9931
11226
  function listArtifacts(projectRoot) {
9932
11227
  const dir = getArtifactsDir(projectRoot);
9933
- if (!existsSync19(dir))
11228
+ if (!existsSync21(dir))
9934
11229
  return [];
9935
11230
  return readdirSync9(dir).filter((f) => f.endsWith(".md")).map((fileName) => {
9936
- const filePath = join19(dir, fileName);
11231
+ const filePath = join21(dir, fileName);
9937
11232
  const stat = statSync4(filePath);
9938
11233
  return {
9939
11234
  name: fileName.replace(/\.md$/, ""),
@@ -9946,12 +11241,12 @@ function listArtifacts(projectRoot) {
9946
11241
  function readArtifact(projectRoot, name) {
9947
11242
  const dir = getArtifactsDir(projectRoot);
9948
11243
  const fileName = name.endsWith(".md") ? name : `${name}.md`;
9949
- const filePath = join19(dir, fileName);
9950
- if (!existsSync19(filePath))
11244
+ const filePath = join21(dir, fileName);
11245
+ if (!existsSync21(filePath))
9951
11246
  return null;
9952
11247
  const stat = statSync4(filePath);
9953
11248
  return {
9954
- content: readFileSync15(filePath, "utf-8"),
11249
+ content: readFileSync16(filePath, "utf-8"),
9955
11250
  info: {
9956
11251
  name: fileName.replace(/\.md$/, ""),
9957
11252
  fileName,
@@ -10109,23 +11404,276 @@ var init_artifacts = __esm(() => {
10109
11404
  init_terminal();
10110
11405
  });
10111
11406
 
11407
+ // src/commands/sandbox.ts
11408
+ var exports_sandbox2 = {};
11409
+ __export(exports_sandbox2, {
11410
+ sandboxCommand: () => sandboxCommand
11411
+ });
11412
+ import { execSync as execSync19, spawn as spawn6 } from "node:child_process";
11413
+ function printSandboxHelp() {
11414
+ process.stderr.write(`
11415
+ ${bold("locus sandbox")} — Manage Docker sandbox lifecycle
11416
+
11417
+ ${bold("Usage:")}
11418
+ locus sandbox ${dim("# Create sandbox and enable sandbox mode")}
11419
+ locus sandbox claude ${dim("# Run claude interactively (for login)")}
11420
+ locus sandbox codex ${dim("# Run codex interactively (for login)")}
11421
+ locus sandbox rm ${dim("# Destroy sandbox and disable sandbox mode")}
11422
+ locus sandbox status ${dim("# Show current sandbox state")}
11423
+
11424
+ ${bold("Flow:")}
11425
+ 1. ${cyan("locus sandbox")} Create the sandbox environment
11426
+ 2. ${cyan("locus sandbox claude")} Login to Claude inside the sandbox
11427
+ 3. ${cyan("locus exec")} All commands now run inside the sandbox
11428
+
11429
+ `);
11430
+ }
11431
+ async function sandboxCommand(projectRoot, args) {
11432
+ const subcommand = args[0] ?? "";
11433
+ switch (subcommand) {
11434
+ case "help":
11435
+ printSandboxHelp();
11436
+ return;
11437
+ case "claude":
11438
+ case "codex":
11439
+ return handleAgentLogin(projectRoot, subcommand);
11440
+ case "rm":
11441
+ return handleRemove(projectRoot);
11442
+ case "status":
11443
+ return handleStatus(projectRoot);
11444
+ case "":
11445
+ return handleCreate(projectRoot);
11446
+ default:
11447
+ process.stderr.write(`${red("✗")} Unknown sandbox subcommand: ${bold(subcommand)}
11448
+ `);
11449
+ process.stderr.write(` Available: ${cyan("claude")}, ${cyan("codex")}, ${cyan("rm")}, ${cyan("status")}
11450
+ `);
11451
+ }
11452
+ }
11453
+ async function handleCreate(projectRoot) {
11454
+ const config = loadConfig(projectRoot);
11455
+ if (config.sandbox.name) {
11456
+ const alive = isSandboxAlive(config.sandbox.name);
11457
+ if (alive) {
11458
+ process.stderr.write(`${green("✓")} Sandbox already exists: ${bold(config.sandbox.name)}
11459
+ `);
11460
+ process.stderr.write(` Run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to login.
11461
+ `);
11462
+ return;
11463
+ }
11464
+ process.stderr.write(`${yellow("⚠")} Previous sandbox ${dim(config.sandbox.name)} is no longer running. Creating a new one.
11465
+ `);
11466
+ }
11467
+ const status = await detectSandboxSupport();
11468
+ if (!status.available) {
11469
+ process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11470
+ `);
11471
+ process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11472
+ `);
11473
+ return;
11474
+ }
11475
+ const segment = projectRoot.split("/").pop() ?? "sandbox";
11476
+ const sandboxName = `locus-${segment}-${Date.now()}`;
11477
+ config.sandbox.enabled = true;
11478
+ config.sandbox.name = sandboxName;
11479
+ saveConfig(projectRoot, config);
11480
+ process.stderr.write(`${green("✓")} Sandbox name reserved: ${bold(sandboxName)}
11481
+ `);
11482
+ process.stderr.write(` Next: run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to create the sandbox and login.
11483
+ `);
11484
+ }
11485
+ async function handleAgentLogin(projectRoot, agent) {
11486
+ const config = loadConfig(projectRoot);
11487
+ if (!config.sandbox.name) {
11488
+ const status = await detectSandboxSupport();
11489
+ if (!status.available) {
11490
+ process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11491
+ `);
11492
+ process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11493
+ `);
11494
+ return;
11495
+ }
11496
+ const segment = projectRoot.split("/").pop() ?? "sandbox";
11497
+ config.sandbox.name = `locus-${segment}-${Date.now()}`;
11498
+ config.sandbox.enabled = true;
11499
+ saveConfig(projectRoot, config);
11500
+ }
11501
+ const sandboxName = config.sandbox.name;
11502
+ const alive = isSandboxAlive(sandboxName);
11503
+ let dockerArgs;
11504
+ if (alive) {
11505
+ if (agent === "codex") {
11506
+ await ensureCodexInSandbox(sandboxName);
11507
+ }
11508
+ process.stderr.write(`Connecting to sandbox ${dim(sandboxName)}...
11509
+ `);
11510
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11511
+
11512
+ `);
11513
+ dockerArgs = [
11514
+ "sandbox",
11515
+ "exec",
11516
+ "-it",
11517
+ "-w",
11518
+ projectRoot,
11519
+ sandboxName,
11520
+ agent
11521
+ ];
11522
+ } else if (agent === "codex") {
11523
+ process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11524
+ `);
11525
+ try {
11526
+ execSync19(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, { stdio: ["pipe", "pipe", "pipe"], timeout: 120000 });
11527
+ } catch {}
11528
+ if (!isSandboxAlive(sandboxName)) {
11529
+ process.stderr.write(`${red("✗")} Failed to create sandbox.
11530
+ `);
11531
+ return;
11532
+ }
11533
+ await ensureCodexInSandbox(sandboxName);
11534
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11535
+
11536
+ `);
11537
+ dockerArgs = [
11538
+ "sandbox",
11539
+ "exec",
11540
+ "-it",
11541
+ "-w",
11542
+ projectRoot,
11543
+ sandboxName,
11544
+ "codex"
11545
+ ];
11546
+ } else {
11547
+ process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11548
+ `);
11549
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11550
+
11551
+ `);
11552
+ dockerArgs = ["sandbox", "run", "--name", sandboxName, agent, projectRoot];
11553
+ }
11554
+ const child = spawn6("docker", dockerArgs, {
11555
+ stdio: "inherit"
11556
+ });
11557
+ await new Promise((resolve2) => {
11558
+ child.on("close", async (code) => {
11559
+ await enforceSandboxIgnore(sandboxName, projectRoot);
11560
+ if (code === 0) {
11561
+ process.stderr.write(`
11562
+ ${green("✓")} ${agent} session ended. Auth should now be persisted in the sandbox.
11563
+ `);
11564
+ } else {
11565
+ process.stderr.write(`
11566
+ ${yellow("⚠")} ${agent} exited with code ${code}.
11567
+ `);
11568
+ }
11569
+ resolve2();
11570
+ });
11571
+ child.on("error", (err) => {
11572
+ process.stderr.write(`${red("✗")} Failed to start ${agent}: ${err.message}
11573
+ `);
11574
+ resolve2();
11575
+ });
11576
+ });
11577
+ }
11578
+ function handleRemove(projectRoot) {
11579
+ const config = loadConfig(projectRoot);
11580
+ if (!config.sandbox.name) {
11581
+ process.stderr.write(`${dim("No sandbox to remove.")}
11582
+ `);
11583
+ return;
11584
+ }
11585
+ const sandboxName = config.sandbox.name;
11586
+ process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11587
+ `);
11588
+ try {
11589
+ execSync19(`docker sandbox rm ${sandboxName}`, {
11590
+ encoding: "utf-8",
11591
+ stdio: ["pipe", "pipe", "pipe"],
11592
+ timeout: 15000
11593
+ });
11594
+ } catch {}
11595
+ config.sandbox.name = undefined;
11596
+ config.sandbox.enabled = false;
11597
+ saveConfig(projectRoot, config);
11598
+ process.stderr.write(`${green("✓")} Sandbox removed. Sandbox mode disabled.
11599
+ `);
11600
+ }
11601
+ function handleStatus(projectRoot) {
11602
+ const config = loadConfig(projectRoot);
11603
+ process.stderr.write(`
11604
+ ${bold("Sandbox Status")}
11605
+
11606
+ `);
11607
+ process.stderr.write(` ${dim("Enabled:")} ${config.sandbox.enabled ? green("yes") : red("no")}
11608
+ `);
11609
+ process.stderr.write(` ${dim("Name:")} ${config.sandbox.name ? bold(config.sandbox.name) : dim("(none)")}
11610
+ `);
11611
+ if (config.sandbox.name) {
11612
+ const alive = isSandboxAlive(config.sandbox.name);
11613
+ process.stderr.write(` ${dim("Running:")} ${alive ? green("yes") : red("no")}
11614
+ `);
11615
+ if (!alive) {
11616
+ process.stderr.write(`
11617
+ ${yellow("⚠")} Sandbox is not running. Run ${bold("locus sandbox")} to create a new one.
11618
+ `);
11619
+ }
11620
+ }
11621
+ process.stderr.write(`
11622
+ `);
11623
+ }
11624
+ async function ensureCodexInSandbox(sandboxName) {
11625
+ try {
11626
+ execSync19(`docker sandbox exec ${sandboxName} which codex`, {
11627
+ stdio: ["pipe", "pipe", "pipe"],
11628
+ timeout: 5000
11629
+ });
11630
+ } catch {
11631
+ process.stderr.write(`Installing codex in sandbox...
11632
+ `);
11633
+ try {
11634
+ execSync19(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11635
+ } catch {
11636
+ process.stderr.write(`${red("✗")} Failed to install codex in sandbox.
11637
+ `);
11638
+ }
11639
+ }
11640
+ }
11641
+ function isSandboxAlive(name) {
11642
+ try {
11643
+ const output = execSync19("docker sandbox ls", {
11644
+ encoding: "utf-8",
11645
+ stdio: ["pipe", "pipe", "pipe"],
11646
+ timeout: 5000
11647
+ });
11648
+ return output.includes(name);
11649
+ } catch {
11650
+ return false;
11651
+ }
11652
+ }
11653
+ var init_sandbox2 = __esm(() => {
11654
+ init_config();
11655
+ init_sandbox();
11656
+ init_sandbox_ignore();
11657
+ init_terminal();
11658
+ });
11659
+
10112
11660
  // src/cli.ts
10113
11661
  init_config();
10114
11662
  init_context();
10115
11663
  init_logger();
10116
11664
  init_rate_limiter();
10117
11665
  init_terminal();
10118
- import { existsSync as existsSync20, readFileSync as readFileSync16 } from "node:fs";
10119
- import { join as join20 } from "node:path";
11666
+ import { existsSync as existsSync22, readFileSync as readFileSync17 } from "node:fs";
11667
+ import { join as join22 } from "node:path";
10120
11668
  import { fileURLToPath } from "node:url";
10121
11669
  function getCliVersion() {
10122
11670
  const fallbackVersion = "0.0.0";
10123
- const packageJsonPath = join20(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
10124
- if (!existsSync20(packageJsonPath)) {
11671
+ const packageJsonPath = join22(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
11672
+ if (!existsSync22(packageJsonPath)) {
10125
11673
  return fallbackVersion;
10126
11674
  }
10127
11675
  try {
10128
- const parsed = JSON.parse(readFileSync16(packageJsonPath, "utf-8"));
11676
+ const parsed = JSON.parse(readFileSync17(packageJsonPath, "utf-8"));
10129
11677
  return parsed.version ?? fallbackVersion;
10130
11678
  } catch {
10131
11679
  return fallbackVersion;
@@ -10145,7 +11693,8 @@ function parseArgs(argv) {
10145
11693
  dryRun: false,
10146
11694
  check: false,
10147
11695
  upgrade: false,
10148
- list: false
11696
+ list: false,
11697
+ noSandbox: false
10149
11698
  };
10150
11699
  const positional = [];
10151
11700
  let i = 0;
@@ -10222,7 +11771,14 @@ function parseArgs(argv) {
10222
11771
  case "--target-version":
10223
11772
  flags.targetVersion = rawArgs[++i];
10224
11773
  break;
11774
+ case "--no-sandbox":
11775
+ flags.noSandbox = true;
11776
+ break;
10225
11777
  default:
11778
+ if (arg.startsWith("--sandbox=")) {
11779
+ flags.sandbox = arg.slice("--sandbox=".length);
11780
+ break;
11781
+ }
10226
11782
  positional.push(arg);
10227
11783
  }
10228
11784
  i++;
@@ -10256,6 +11812,7 @@ ${bold("Commands:")}
10256
11812
  ${cyan("uninstall")} Remove an installed package
10257
11813
  ${cyan("packages")} Manage installed packages (list, outdated)
10258
11814
  ${cyan("pkg")} ${dim("<name> [cmd]")} Run a command from an installed package
11815
+ ${cyan("sandbox")} Manage Docker sandbox lifecycle
10259
11816
  ${cyan("upgrade")} Check for and install updates
10260
11817
 
10261
11818
  ${bold("Options:")}
@@ -10271,6 +11828,10 @@ ${bold("Examples:")}
10271
11828
  locus plan approve <id> ${dim("# Create issues from saved plan")}
10272
11829
  locus run ${dim("# Execute active sprint")}
10273
11830
  locus run 42 43 ${dim("# Run issues in parallel")}
11831
+ locus run 42 --no-sandbox ${dim("# Run without sandbox")}
11832
+ locus run 42 --sandbox=require ${dim("# Require Docker sandbox")}
11833
+ locus sandbox ${dim("# Create Docker sandbox")}
11834
+ locus sandbox claude ${dim("# Login to Claude in sandbox")}
10274
11835
 
10275
11836
  `);
10276
11837
  }
@@ -10282,6 +11843,46 @@ function resolveAlias(command) {
10282
11843
  };
10283
11844
  return aliases[command] ?? command;
10284
11845
  }
11846
+ function requiresSandboxSync(command, args, flags) {
11847
+ if (flags.noSandbox)
11848
+ return false;
11849
+ if (flags.help)
11850
+ return false;
11851
+ switch (command) {
11852
+ case "run":
11853
+ case "review":
11854
+ case "iterate":
11855
+ return true;
11856
+ case "exec":
11857
+ return args[0] !== "sessions" && args[0] !== "help";
11858
+ case "issue":
11859
+ return args[0] === "create";
11860
+ case "plan":
11861
+ if (args.length === 0)
11862
+ return false;
11863
+ return !["list", "show", "approve", "help"].includes(args[0]);
11864
+ case "discuss":
11865
+ if (args.length === 0)
11866
+ return false;
11867
+ return !["list", "show", "delete", "help"].includes(args[0]);
11868
+ case "config":
11869
+ return args[0] === "set";
11870
+ default:
11871
+ return false;
11872
+ }
11873
+ }
11874
+ async function prepareSandbox() {
11875
+ const { Spinner: Spinner2 } = await Promise.resolve().then(() => (init_progress(), exports_progress));
11876
+ const { detectSandboxSupport: detectSandboxSupport2 } = await Promise.resolve().then(() => (init_sandbox(), exports_sandbox));
11877
+ const spinner = new Spinner2;
11878
+ spinner.start("Preparing sandbox...");
11879
+ const status = await detectSandboxSupport2();
11880
+ if (status.available) {
11881
+ spinner.succeed("Sandbox ready");
11882
+ } else {
11883
+ spinner.warn(`Sandbox not available: ${status.reason}`);
11884
+ }
11885
+ }
10285
11886
  async function main() {
10286
11887
  const parsed = parseArgs(process.argv);
10287
11888
  if (parsed.flags.version) {
@@ -10301,7 +11902,7 @@ async function main() {
10301
11902
  try {
10302
11903
  const root = getGitRoot(cwd);
10303
11904
  if (isInitialized(root)) {
10304
- logDir = join20(root, ".locus", "logs");
11905
+ logDir = join22(root, ".locus", "logs");
10305
11906
  getRateLimiter(root);
10306
11907
  }
10307
11908
  } catch {}
@@ -10375,7 +11976,6 @@ async function main() {
10375
11976
  process.stderr.write(`${red("✗")} Not inside a git repository.
10376
11977
  `);
10377
11978
  process.exit(1);
10378
- return;
10379
11979
  }
10380
11980
  if (!isInitialized(projectRoot)) {
10381
11981
  process.stderr.write(`${red("✗")} Locus is not initialized in this project.
@@ -10383,7 +11983,12 @@ async function main() {
10383
11983
  process.stderr.write(` Run: ${bold("locus init")}
10384
11984
  `);
10385
11985
  process.exit(1);
10386
- return;
11986
+ }
11987
+ if (requiresSandboxSync(command, parsed.args, parsed.flags)) {
11988
+ const config = loadConfig(projectRoot);
11989
+ if (config.sandbox.enabled) {
11990
+ await prepareSandbox();
11991
+ }
10387
11992
  }
10388
11993
  switch (command) {
10389
11994
  case "config": {
@@ -10428,7 +12033,9 @@ async function main() {
10428
12033
  await runCommand2(projectRoot, runArgs, {
10429
12034
  resume: parsed.flags.resume,
10430
12035
  dryRun: parsed.flags.dryRun,
10431
- model: parsed.flags.model
12036
+ model: parsed.flags.model,
12037
+ sandbox: parsed.flags.sandbox,
12038
+ noSandbox: parsed.flags.noSandbox
10432
12039
  });
10433
12040
  break;
10434
12041
  }
@@ -10478,6 +12085,12 @@ async function main() {
10478
12085
  await artifactsCommand2(projectRoot, artifactsArgs);
10479
12086
  break;
10480
12087
  }
12088
+ case "sandbox": {
12089
+ const { sandboxCommand: sandboxCommand2 } = await Promise.resolve().then(() => (init_sandbox2(), exports_sandbox2));
12090
+ const sandboxArgs = parsed.flags.help ? ["help"] : parsed.args;
12091
+ await sandboxCommand2(projectRoot, sandboxArgs);
12092
+ break;
12093
+ }
10481
12094
  case "upgrade": {
10482
12095
  const { upgradeCommand: upgradeCommand2 } = await Promise.resolve().then(() => (init_upgrade(), exports_upgrade));
10483
12096
  await upgradeCommand2(projectRoot, parsed.args, {