@locusai/cli 0.17.16 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/locus.js +2011 -567
  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
@@ -1582,6 +1603,15 @@ ${bold("Initializing Locus...")}
1582
1603
  `);
1583
1604
  } else {
1584
1605
  process.stderr.write(`${dim("○")} LEARNINGS.md already exists (preserved)
1606
+ `);
1607
+ }
1608
+ const sandboxIgnorePath = join5(cwd, ".sandboxignore");
1609
+ if (!existsSync5(sandboxIgnorePath)) {
1610
+ writeFileSync4(sandboxIgnorePath, SANDBOXIGNORE_TEMPLATE, "utf-8");
1611
+ process.stderr.write(`${green("✓")} Generated .sandboxignore
1612
+ `);
1613
+ } else {
1614
+ process.stderr.write(`${dim("○")} .sandboxignore already exists (preserved)
1585
1615
  `);
1586
1616
  }
1587
1617
  process.stderr.write(`${cyan("●")} Creating GitHub labels...`);
@@ -1627,6 +1657,23 @@ ${bold(green("Locus initialized!"))}
1627
1657
  process.stderr.write(` ${gray("4.")} Start coding: ${bold("locus exec")}
1628
1658
  `);
1629
1659
  process.stderr.write(`
1660
+ ${bold("Sandbox mode")} ${dim("(recommended)")}
1661
+ `);
1662
+ process.stderr.write(` Run AI agents in an isolated Docker sandbox for safety.
1663
+
1664
+ `);
1665
+ process.stderr.write(` ${gray("1.")} ${cyan("locus sandbox")} ${dim("Create the sandbox environment")}
1666
+ `);
1667
+ process.stderr.write(` ${gray("2.")} ${cyan("locus sandbox claude")} ${dim("Login to Claude inside the sandbox")}
1668
+ `);
1669
+ process.stderr.write(` ${gray("3.")} ${cyan("locus exec")} ${dim("All commands now run sandboxed")}
1670
+ `);
1671
+ process.stderr.write(`
1672
+ ${dim("Using Codex? Run")} ${cyan("locus sandbox codex")} ${dim("instead of step 2.")}
1673
+ `);
1674
+ process.stderr.write(` ${dim("Learn more:")} ${cyan("locus sandbox help")}
1675
+ `);
1676
+ process.stderr.write(`
1630
1677
  `);
1631
1678
  log.info("Locus initialized", {
1632
1679
  owner: context.owner,
@@ -1743,6 +1790,31 @@ Read ".locus/LEARNINGS.md" **before starting any task** to avoid repeating mista
1743
1790
  ## Development Workflow
1744
1791
 
1745
1792
  <!-- How to run, test, build, and deploy the project -->
1793
+ `, SANDBOXIGNORE_TEMPLATE = `# Files and directories to exclude from sandbox environments.
1794
+ # Patterns follow .gitignore syntax (one per line, # for comments).
1795
+ # These files will be removed from the sandbox after creation.
1796
+
1797
+ # Environment files
1798
+ .env
1799
+ .env.*
1800
+ !.env.example
1801
+
1802
+ # Secrets and credentials
1803
+ *.pem
1804
+ *.key
1805
+ *.p12
1806
+ *.pfx
1807
+ *.keystore
1808
+ credentials.json
1809
+ service-account*.json
1810
+
1811
+ # Cloud provider configs
1812
+ .aws/
1813
+ .gcp/
1814
+ .azure/
1815
+
1816
+ # Docker secrets
1817
+ docker-compose.override.yml
1746
1818
  `, LEARNINGS_MD_TEMPLATE = `# Learnings
1747
1819
 
1748
1820
  This file captures important lessons, decisions, and corrections made during development.
@@ -2611,24 +2683,33 @@ var init_status_indicator = __esm(() => {
2611
2683
  startTime = 0;
2612
2684
  activity = "";
2613
2685
  frame = 0;
2686
+ message = "";
2614
2687
  static BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2615
2688
  static DIAMOND = "◆";
2616
2689
  start(message, options) {
2617
2690
  this.stop();
2618
2691
  this.startTime = Date.now();
2619
2692
  this.activity = options?.activity ?? "";
2693
+ this.message = message;
2620
2694
  this.frame = 0;
2621
2695
  if (process.stderr.isTTY) {
2622
2696
  process.stderr.write("\x1B[?25l");
2623
2697
  }
2698
+ this.render();
2699
+ this.frame++;
2624
2700
  this.timer = setInterval(() => {
2625
- this.render(message);
2701
+ this.render();
2626
2702
  this.frame++;
2627
2703
  }, 80);
2628
2704
  }
2629
2705
  setActivity(activity) {
2630
2706
  this.activity = activity;
2631
2707
  }
2708
+ setMessage(message) {
2709
+ this.message = message;
2710
+ if (this.timer)
2711
+ this.render();
2712
+ }
2632
2713
  stop() {
2633
2714
  if (this.timer) {
2634
2715
  clearInterval(this.timer);
@@ -2641,7 +2722,8 @@ var init_status_indicator = __esm(() => {
2641
2722
  isActive() {
2642
2723
  return this.timer !== null;
2643
2724
  }
2644
- render(message) {
2725
+ render() {
2726
+ const message = this.message;
2645
2727
  const caps = getCapabilities();
2646
2728
  const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
2647
2729
  const elapsedStr = `${elapsed}s`;
@@ -2663,7 +2745,7 @@ var init_status_indicator = __esm(() => {
2663
2745
  }
2664
2746
  if (!process.stderr.isTTY)
2665
2747
  return;
2666
- process.stderr.write("\x1B[2K\r" + line);
2748
+ process.stderr.write(`\x1B[2K\r${line}`);
2667
2749
  }
2668
2750
  renderShimmer() {
2669
2751
  const t = Date.now() / 1000;
@@ -3787,7 +3869,25 @@ var init_input_handler = __esm(() => {
3787
3869
  });
3788
3870
 
3789
3871
  // src/ai/claude.ts
3872
+ var exports_claude = {};
3873
+ __export(exports_claude, {
3874
+ buildClaudeArgs: () => buildClaudeArgs,
3875
+ ClaudeRunner: () => ClaudeRunner
3876
+ });
3790
3877
  import { execSync as execSync4, spawn as spawn2 } from "node:child_process";
3878
+ function buildClaudeArgs(options) {
3879
+ const args = [
3880
+ "--dangerously-skip-permissions",
3881
+ "--no-session-persistence"
3882
+ ];
3883
+ if (options.model) {
3884
+ args.push("--model", options.model);
3885
+ }
3886
+ if (options.verbose) {
3887
+ args.push("--verbose", "--output-format", "stream-json");
3888
+ }
3889
+ return args;
3890
+ }
3791
3891
 
3792
3892
  class ClaudeRunner {
3793
3893
  name = "claude";
@@ -3818,17 +3918,7 @@ class ClaudeRunner {
3818
3918
  async execute(options) {
3819
3919
  const log = getLogger();
3820
3920
  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
- }
3921
+ const args = ["--print", ...buildClaudeArgs(options)];
3832
3922
  log.debug("Spawning claude", { args: args.join(" "), cwd: options.cwd });
3833
3923
  return new Promise((resolve2) => {
3834
3924
  let output = "";
@@ -3979,224 +4069,1207 @@ var init_claude = __esm(() => {
3979
4069
  init_logger();
3980
4070
  });
3981
4071
 
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);
4072
+ // src/core/sandbox-ignore.ts
4073
+ import { exec } from "node:child_process";
4074
+ import { existsSync as existsSync11, readFileSync as readFileSync8 } from "node:fs";
4075
+ import { join as join10 } from "node:path";
4076
+ import { promisify } from "node:util";
4077
+ function parseIgnoreFile(filePath) {
4078
+ if (!existsSync11(filePath))
4079
+ return [];
4080
+ const content = readFileSync8(filePath, "utf-8");
4081
+ const rules = [];
4082
+ for (const rawLine of content.split(`
4083
+ `)) {
4084
+ const line = rawLine.trim();
4085
+ if (!line || line.startsWith("#"))
4086
+ continue;
4087
+ const negated = line.startsWith("!");
4088
+ const raw = negated ? line.slice(1) : line;
4089
+ const isDirectory = raw.endsWith("/");
4090
+ const pattern = isDirectory ? raw.slice(0, -1) : raw;
4091
+ rules.push({ pattern, negated, isDirectory });
3988
4092
  }
3989
- args.push("-");
3990
- return args;
4093
+ return rules;
3991
4094
  }
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;
4006
- }
4007
- }
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";
4095
+ function shellEscape(s) {
4096
+ return s.replace(/'/g, "'\\''");
4097
+ }
4098
+ function buildCleanupScript(rules, workspacePath) {
4099
+ const positive = rules.filter((r) => !r.negated);
4100
+ const negated = rules.filter((r) => r.negated);
4101
+ if (positive.length === 0)
4102
+ return null;
4103
+ const exclusions = negated.map((r) => `! -name '${shellEscape(r.pattern)}'`).join(" ");
4104
+ const commands = [];
4105
+ for (const rule of positive) {
4106
+ const parts = ["find", `'${shellEscape(workspacePath)}'`];
4107
+ if (rule.isDirectory) {
4108
+ parts.push("-type d");
4109
+ }
4110
+ parts.push(`-name '${shellEscape(rule.pattern)}'`);
4111
+ if (exclusions) {
4112
+ parts.push(exclusions);
4113
+ }
4114
+ if (rule.isDirectory) {
4115
+ parts.push("-exec rm -rf {} +");
4116
+ } else {
4117
+ parts.push("-delete");
4017
4118
  }
4119
+ commands.push(parts.join(" "));
4018
4120
  }
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(`
4036
-
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,
4097
- error: "Aborted by user",
4098
- exitCode: code ?? 143
4099
- });
4100
- return;
4101
- }
4102
- if (code === 0) {
4103
- resolve2({
4104
- success: true,
4105
- output: rawOutput,
4106
- exitCode: 0
4107
- });
4108
- } else {
4109
- resolve2({
4110
- success: false,
4111
- output: rawOutput,
4112
- error: errorOutput || `codex exited with code ${code}`,
4113
- exitCode: code ?? 1
4114
- });
4115
- }
4116
- });
4117
- this.process.on("error", (err) => {
4118
- this.process = null;
4119
- resolve2({
4120
- success: false,
4121
- output: rawOutput,
4122
- error: `Failed to spawn codex: ${err.message}`,
4123
- exitCode: 1
4124
- });
4125
- });
4126
- if (options.signal) {
4127
- options.signal.addEventListener("abort", () => {
4128
- this.abort();
4129
- });
4130
- }
4131
- this.process.stdin?.write(options.prompt);
4132
- this.process.stdin?.end();
4121
+ return `${commands.join(" 2>/dev/null ; ")} 2>/dev/null`;
4122
+ }
4123
+ async function enforceSandboxIgnore(sandboxName, projectRoot) {
4124
+ const log = getLogger();
4125
+ const ignorePath = join10(projectRoot, ".sandboxignore");
4126
+ const rules = parseIgnoreFile(ignorePath);
4127
+ if (rules.length === 0)
4128
+ return;
4129
+ const script = buildCleanupScript(rules, projectRoot);
4130
+ if (!script)
4131
+ return;
4132
+ log.debug("Enforcing .sandboxignore", {
4133
+ sandboxName,
4134
+ ruleCount: rules.length
4135
+ });
4136
+ try {
4137
+ await execAsync(`docker sandbox exec ${sandboxName} sh -c ${JSON.stringify(script)}`, { timeout: 15000 });
4138
+ log.debug("sandbox-ignore enforcement complete", { sandboxName });
4139
+ } catch (err) {
4140
+ log.debug("sandbox-ignore enforcement failed (non-fatal)", {
4141
+ sandboxName,
4142
+ error: err instanceof Error ? err.message : String(err)
4133
4143
  });
4134
4144
  }
4135
- abort() {
4136
- if (!this.process)
4137
- return;
4138
- this.aborted = true;
4139
- const log = getLogger();
4140
- log.debug("Aborting codex process");
4141
- this.process.kill("SIGTERM");
4142
- const forceKillTimer = setTimeout(() => {
4143
- if (this.process) {
4144
- log.debug("Force killing codex process");
4145
- this.process.kill("SIGKILL");
4146
- }
4147
- }, 3000);
4148
- if (forceKillTimer.unref) {
4149
- forceKillTimer.unref();
4150
- }
4151
- }
4152
4145
  }
4153
- var init_codex = __esm(() => {
4146
+ var execAsync;
4147
+ var init_sandbox_ignore = __esm(() => {
4154
4148
  init_logger();
4149
+ execAsync = promisify(exec);
4155
4150
  });
4156
4151
 
4157
- // src/ai/runner.ts
4158
- async function createRunnerAsync(provider) {
4159
- switch (provider) {
4160
- case "claude":
4161
- return new ClaudeRunner;
4162
- case "codex":
4163
- return new CodexRunner;
4164
- default:
4165
- throw new Error(`Unknown AI provider: ${provider}`);
4152
+ // src/core/run-state.ts
4153
+ import {
4154
+ existsSync as existsSync12,
4155
+ mkdirSync as mkdirSync8,
4156
+ readFileSync as readFileSync9,
4157
+ unlinkSync as unlinkSync3,
4158
+ writeFileSync as writeFileSync6
4159
+ } from "node:fs";
4160
+ import { dirname as dirname3, join as join11 } from "node:path";
4161
+ function getRunStatePath(projectRoot) {
4162
+ return join11(projectRoot, ".locus", "run-state.json");
4163
+ }
4164
+ function loadRunState(projectRoot) {
4165
+ const path = getRunStatePath(projectRoot);
4166
+ if (!existsSync12(path))
4167
+ return null;
4168
+ try {
4169
+ return JSON.parse(readFileSync9(path, "utf-8"));
4170
+ } catch {
4171
+ getLogger().warn("Corrupted run-state.json, ignoring");
4172
+ return null;
4166
4173
  }
4167
4174
  }
4168
- var init_runner = __esm(() => {
4169
- init_claude();
4170
- init_codex();
4171
- });
4172
-
4173
- // src/ai/run-ai.ts
4174
- var exports_run_ai = {};
4175
- __export(exports_run_ai, {
4176
- runAI: () => runAI
4177
- });
4178
- async function runAI(options) {
4179
- const indicator = getStatusIndicator();
4180
- const renderer = options.silent ? null : new StreamRenderer;
4181
- let output = "";
4182
- let wasAborted = false;
4183
- let runner = null;
4184
- const resolvedProvider = inferProviderFromModel(options.model) || options.provider;
4185
- const abortController = new AbortController;
4186
- const cleanupInterrupt = options.noInterrupt ? () => {} : listenForInterrupt(() => {
4187
- if (wasAborted)
4188
- return;
4189
- wasAborted = true;
4190
- indicator.stop();
4191
- renderer?.stop();
4192
- process.stderr.write(`\r
4193
- ${yellow("⚡")} ${dim("Interrupting...")}\r
4194
- `);
4195
- abortController.abort();
4196
- if (runner)
4197
- runner.abort();
4198
- }, () => {
4199
- indicator.stop();
4175
+ function saveRunState(projectRoot, state) {
4176
+ const path = getRunStatePath(projectRoot);
4177
+ const dir = dirname3(path);
4178
+ if (!existsSync12(dir)) {
4179
+ mkdirSync8(dir, { recursive: true });
4180
+ }
4181
+ writeFileSync6(path, `${JSON.stringify(state, null, 2)}
4182
+ `, "utf-8");
4183
+ }
4184
+ function clearRunState(projectRoot) {
4185
+ const path = getRunStatePath(projectRoot);
4186
+ if (existsSync12(path)) {
4187
+ unlinkSync3(path);
4188
+ }
4189
+ }
4190
+ function createSprintRunState(sprint, branch, issues) {
4191
+ return {
4192
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4193
+ type: "sprint",
4194
+ sprint,
4195
+ branch,
4196
+ startedAt: new Date().toISOString(),
4197
+ tasks: issues.map(({ number, order }) => ({
4198
+ issue: number,
4199
+ order,
4200
+ status: "pending"
4201
+ }))
4202
+ };
4203
+ }
4204
+ function createParallelRunState(issueNumbers) {
4205
+ return {
4206
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4207
+ type: "parallel",
4208
+ startedAt: new Date().toISOString(),
4209
+ tasks: issueNumbers.map((issue, i) => ({
4210
+ issue,
4211
+ order: i + 1,
4212
+ status: "pending"
4213
+ }))
4214
+ };
4215
+ }
4216
+ function markTaskInProgress(state, issueNumber) {
4217
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4218
+ if (task) {
4219
+ task.status = "in_progress";
4220
+ }
4221
+ }
4222
+ function markTaskDone(state, issueNumber, prNumber) {
4223
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4224
+ if (task) {
4225
+ task.status = "done";
4226
+ task.completedAt = new Date().toISOString();
4227
+ if (prNumber)
4228
+ task.pr = prNumber;
4229
+ }
4230
+ }
4231
+ function markTaskFailed(state, issueNumber, error) {
4232
+ const task = state.tasks.find((t) => t.issue === issueNumber);
4233
+ if (task) {
4234
+ task.status = "failed";
4235
+ task.failedAt = new Date().toISOString();
4236
+ task.error = error;
4237
+ }
4238
+ }
4239
+ function getRunStats(state) {
4240
+ const tasks = state.tasks;
4241
+ return {
4242
+ total: tasks.length,
4243
+ done: tasks.filter((t) => t.status === "done").length,
4244
+ failed: tasks.filter((t) => t.status === "failed").length,
4245
+ pending: tasks.filter((t) => t.status === "pending").length,
4246
+ inProgress: tasks.filter((t) => t.status === "in_progress").length
4247
+ };
4248
+ }
4249
+ function getNextTask(state) {
4250
+ const failed = state.tasks.find((t) => t.status === "failed");
4251
+ if (failed)
4252
+ return failed;
4253
+ return state.tasks.find((t) => t.status === "pending") ?? null;
4254
+ }
4255
+ var init_run_state = __esm(() => {
4256
+ init_logger();
4257
+ });
4258
+
4259
+ // src/core/shutdown.ts
4260
+ import { execSync as execSync5 } from "node:child_process";
4261
+ function registerActiveSandbox(name) {
4262
+ activeSandboxes.add(name);
4263
+ }
4264
+ function unregisterActiveSandbox(name) {
4265
+ activeSandboxes.delete(name);
4266
+ }
4267
+ function cleanupActiveSandboxes() {
4268
+ for (const name of activeSandboxes) {
4269
+ try {
4270
+ execSync5(`docker sandbox rm ${name}`, { timeout: 1e4 });
4271
+ } catch {}
4272
+ }
4273
+ activeSandboxes.clear();
4274
+ }
4275
+ function registerShutdownHandlers(ctx) {
4276
+ shutdownContext = ctx;
4277
+ interruptCount = 0;
4278
+ const handler = () => {
4279
+ interruptCount++;
4280
+ if (interruptCount >= 2) {
4281
+ process.stderr.write(`
4282
+ Force exit.
4283
+ `);
4284
+ process.exit(1);
4285
+ }
4286
+ process.stderr.write(`
4287
+
4288
+ Interrupted. Saving state...
4289
+ `);
4290
+ const state = shutdownContext?.getRunState?.();
4291
+ if (state && shutdownContext) {
4292
+ for (const task of state.tasks) {
4293
+ if (task.status === "in_progress") {
4294
+ task.status = "failed";
4295
+ task.failedAt = new Date().toISOString();
4296
+ task.error = "Interrupted by user";
4297
+ }
4298
+ }
4299
+ try {
4300
+ saveRunState(shutdownContext.projectRoot, state);
4301
+ process.stderr.write(`State saved. Resume with: locus run --resume
4302
+ `);
4303
+ } catch {
4304
+ process.stderr.write(`Warning: Could not save run state.
4305
+ `);
4306
+ }
4307
+ }
4308
+ cleanupActiveSandboxes();
4309
+ shutdownContext?.onShutdown?.();
4310
+ if (interruptTimer)
4311
+ clearTimeout(interruptTimer);
4312
+ interruptTimer = setTimeout(() => {
4313
+ interruptCount = 0;
4314
+ }, 2000);
4315
+ setTimeout(() => {
4316
+ process.exit(130);
4317
+ }, 100);
4318
+ };
4319
+ if (!shutdownRegistered) {
4320
+ process.on("SIGINT", handler);
4321
+ process.on("SIGTERM", handler);
4322
+ shutdownRegistered = true;
4323
+ }
4324
+ return () => {
4325
+ process.removeListener("SIGINT", handler);
4326
+ process.removeListener("SIGTERM", handler);
4327
+ shutdownRegistered = false;
4328
+ shutdownContext = null;
4329
+ interruptCount = 0;
4330
+ if (interruptTimer) {
4331
+ clearTimeout(interruptTimer);
4332
+ interruptTimer = null;
4333
+ }
4334
+ };
4335
+ }
4336
+ var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
4337
+ var init_shutdown = __esm(() => {
4338
+ init_run_state();
4339
+ activeSandboxes = new Set;
4340
+ });
4341
+
4342
+ // src/ai/claude-sandbox.ts
4343
+ import { execSync as execSync6, spawn as spawn3 } from "node:child_process";
4344
+
4345
+ class SandboxedClaudeRunner {
4346
+ name = "claude-sandboxed";
4347
+ process = null;
4348
+ aborted = false;
4349
+ sandboxName = null;
4350
+ persistent;
4351
+ sandboxCreated = false;
4352
+ userManaged = false;
4353
+ constructor(persistentName, userManaged = false) {
4354
+ if (persistentName) {
4355
+ this.persistent = true;
4356
+ this.sandboxName = persistentName;
4357
+ this.userManaged = userManaged;
4358
+ if (userManaged) {
4359
+ this.sandboxCreated = true;
4360
+ }
4361
+ } else {
4362
+ this.persistent = false;
4363
+ }
4364
+ }
4365
+ async isAvailable() {
4366
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4367
+ const delegate = new ClaudeRunner2;
4368
+ return delegate.isAvailable();
4369
+ }
4370
+ async getVersion() {
4371
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4372
+ const delegate = new ClaudeRunner2;
4373
+ return delegate.getVersion();
4374
+ }
4375
+ async execute(options) {
4376
+ const log = getLogger();
4377
+ this.aborted = false;
4378
+ const claudeArgs = ["-p", options.prompt, ...buildClaudeArgs(options)];
4379
+ let dockerArgs;
4380
+ if (this.persistent && !this.sandboxName) {
4381
+ throw new Error("Sandbox name is required");
4382
+ }
4383
+ if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
4384
+ const name = this.sandboxName;
4385
+ if (!name) {
4386
+ throw new Error("Sandbox name is required");
4387
+ }
4388
+ options.onStatusChange?.("Syncing sandbox...");
4389
+ await enforceSandboxIgnore(name, options.cwd);
4390
+ options.onStatusChange?.("Thinking...");
4391
+ dockerArgs = [
4392
+ "sandbox",
4393
+ "exec",
4394
+ "-w",
4395
+ options.cwd,
4396
+ name,
4397
+ "claude",
4398
+ ...claudeArgs
4399
+ ];
4400
+ } else {
4401
+ if (!this.persistent) {
4402
+ this.sandboxName = buildSandboxName(options);
4403
+ }
4404
+ const name = this.sandboxName;
4405
+ if (!name) {
4406
+ throw new Error("Sandbox name is required");
4407
+ }
4408
+ registerActiveSandbox(name);
4409
+ options.onStatusChange?.("Syncing sandbox...");
4410
+ dockerArgs = [
4411
+ "sandbox",
4412
+ "run",
4413
+ "--name",
4414
+ name,
4415
+ "claude",
4416
+ options.cwd,
4417
+ "--",
4418
+ ...claudeArgs
4419
+ ];
4420
+ }
4421
+ log.debug("Spawning sandboxed claude", {
4422
+ sandboxName: this.sandboxName,
4423
+ persistent: this.persistent,
4424
+ reusing: this.persistent && this.sandboxCreated,
4425
+ args: dockerArgs.join(" "),
4426
+ cwd: options.cwd
4427
+ });
4428
+ try {
4429
+ return await new Promise((resolve2) => {
4430
+ let output = "";
4431
+ let errorOutput = "";
4432
+ this.process = spawn3("docker", dockerArgs, {
4433
+ stdio: ["ignore", "pipe", "pipe"],
4434
+ env: process.env
4435
+ });
4436
+ if (this.persistent && !this.sandboxCreated) {
4437
+ this.process.on("spawn", () => {
4438
+ this.sandboxCreated = true;
4439
+ });
4440
+ }
4441
+ if (options.verbose) {
4442
+ let lineBuffer = "";
4443
+ const seenToolIds = new Set;
4444
+ this.process.stdout?.on("data", (chunk) => {
4445
+ lineBuffer += chunk.toString();
4446
+ const lines = lineBuffer.split(`
4447
+ `);
4448
+ lineBuffer = lines.pop() ?? "";
4449
+ for (const line of lines) {
4450
+ if (!line.trim())
4451
+ continue;
4452
+ try {
4453
+ const event = JSON.parse(line);
4454
+ if (event.type === "assistant" && event.message?.content) {
4455
+ for (const item of event.message.content) {
4456
+ if (item.type === "tool_use" && item.id && !seenToolIds.has(item.id)) {
4457
+ seenToolIds.add(item.id);
4458
+ options.onToolActivity?.(formatToolCall2(item.name ?? "", item.input ?? {}));
4459
+ }
4460
+ }
4461
+ } else if (event.type === "result") {
4462
+ const text = event.result ?? "";
4463
+ output = text;
4464
+ options.onOutput?.(text);
4465
+ }
4466
+ } catch {
4467
+ const newLine = `${line}
4468
+ `;
4469
+ output += newLine;
4470
+ options.onOutput?.(newLine);
4471
+ }
4472
+ }
4473
+ });
4474
+ } else {
4475
+ this.process.stdout?.on("data", (chunk) => {
4476
+ const text = chunk.toString();
4477
+ output += text;
4478
+ options.onOutput?.(text);
4479
+ });
4480
+ }
4481
+ this.process.stderr?.on("data", (chunk) => {
4482
+ const text = chunk.toString();
4483
+ errorOutput += text;
4484
+ log.debug("sandboxed claude stderr", { text: text.slice(0, 500) });
4485
+ options.onOutput?.(text);
4486
+ });
4487
+ this.process.on("close", (code) => {
4488
+ this.process = null;
4489
+ if (this.aborted) {
4490
+ resolve2({
4491
+ success: false,
4492
+ output,
4493
+ error: "Aborted by user",
4494
+ exitCode: code ?? 143
4495
+ });
4496
+ return;
4497
+ }
4498
+ if (code === 0) {
4499
+ resolve2({
4500
+ success: true,
4501
+ output,
4502
+ exitCode: 0
4503
+ });
4504
+ } else {
4505
+ resolve2({
4506
+ success: false,
4507
+ output,
4508
+ error: errorOutput || `sandboxed claude exited with code ${code}`,
4509
+ exitCode: code ?? 1
4510
+ });
4511
+ }
4512
+ });
4513
+ this.process.on("error", (err) => {
4514
+ this.process = null;
4515
+ if (this.persistent && !this.sandboxCreated) {}
4516
+ resolve2({
4517
+ success: false,
4518
+ output,
4519
+ error: `Failed to spawn docker sandbox: ${err.message}`,
4520
+ exitCode: 1
4521
+ });
4522
+ });
4523
+ if (options.signal) {
4524
+ options.signal.addEventListener("abort", () => {
4525
+ this.abort();
4526
+ });
4527
+ }
4528
+ });
4529
+ } finally {
4530
+ if (!this.persistent) {
4531
+ this.cleanupSandbox();
4532
+ }
4533
+ }
4534
+ }
4535
+ abort() {
4536
+ this.aborted = true;
4537
+ const log = getLogger();
4538
+ if (this.persistent) {
4539
+ log.debug("Aborting sandboxed claude (persistent — keeping sandbox)", {
4540
+ sandboxName: this.sandboxName
4541
+ });
4542
+ if (this.process) {
4543
+ this.process.kill("SIGTERM");
4544
+ const timer = setTimeout(() => {
4545
+ if (this.process) {
4546
+ this.process.kill("SIGKILL");
4547
+ }
4548
+ }, 3000);
4549
+ if (timer.unref)
4550
+ timer.unref();
4551
+ }
4552
+ } else {
4553
+ if (!this.sandboxName)
4554
+ return;
4555
+ log.debug("Aborting sandboxed claude (ephemeral — removing sandbox)", {
4556
+ sandboxName: this.sandboxName
4557
+ });
4558
+ try {
4559
+ execSync6(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4560
+ } catch {}
4561
+ }
4562
+ }
4563
+ destroy() {
4564
+ if (!this.sandboxName)
4565
+ return;
4566
+ if (this.userManaged) {
4567
+ unregisterActiveSandbox(this.sandboxName);
4568
+ return;
4569
+ }
4570
+ const log = getLogger();
4571
+ log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
4572
+ try {
4573
+ execSync6(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4574
+ } catch {}
4575
+ unregisterActiveSandbox(this.sandboxName);
4576
+ this.sandboxName = null;
4577
+ this.sandboxCreated = false;
4578
+ }
4579
+ cleanupSandbox() {
4580
+ if (!this.sandboxName)
4581
+ return;
4582
+ const log = getLogger();
4583
+ log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
4584
+ try {
4585
+ execSync6(`docker sandbox rm ${this.sandboxName}`, {
4586
+ timeout: 60000
4587
+ });
4588
+ } catch {}
4589
+ unregisterActiveSandbox(this.sandboxName);
4590
+ this.sandboxName = null;
4591
+ }
4592
+ async isSandboxRunning() {
4593
+ if (!this.sandboxName)
4594
+ return false;
4595
+ try {
4596
+ const { promisify: promisify2 } = await import("node:util");
4597
+ const { exec: exec2 } = await import("node:child_process");
4598
+ const execAsync2 = promisify2(exec2);
4599
+ const { stdout } = await execAsync2("docker sandbox ls", {
4600
+ timeout: 5000
4601
+ });
4602
+ return stdout.includes(this.sandboxName);
4603
+ } catch {
4604
+ return false;
4605
+ }
4606
+ }
4607
+ getSandboxName() {
4608
+ return this.sandboxName;
4609
+ }
4610
+ }
4611
+ function buildSandboxName(options) {
4612
+ const ts = Date.now();
4613
+ if (options.activity) {
4614
+ const match = options.activity.match(/issue\s*#(\d+)/i);
4615
+ if (match) {
4616
+ return `locus-issue-${match[1]}-${ts}`;
4617
+ }
4618
+ }
4619
+ const segment = options.cwd.split("/").pop() ?? "run";
4620
+ return `locus-${segment}-${ts}`;
4621
+ }
4622
+ function buildPersistentSandboxName(cwd) {
4623
+ const segment = cwd.split("/").pop() ?? "repl";
4624
+ return `locus-${segment}-${Date.now()}`;
4625
+ }
4626
+ function formatToolCall2(name, input) {
4627
+ switch (name) {
4628
+ case "Read":
4629
+ return `reading ${input.file_path ?? ""}`;
4630
+ case "Write":
4631
+ return `writing ${input.file_path ?? ""}`;
4632
+ case "Edit":
4633
+ case "MultiEdit":
4634
+ return `editing ${input.file_path ?? ""}`;
4635
+ case "Bash":
4636
+ return `running: ${String(input.command ?? "").slice(0, 60)}`;
4637
+ case "Glob":
4638
+ return `glob ${input.pattern ?? ""}`;
4639
+ case "Grep":
4640
+ return `grep ${input.pattern ?? ""}`;
4641
+ case "LS":
4642
+ return `ls ${input.path ?? ""}`;
4643
+ case "WebFetch":
4644
+ return `fetching ${String(input.url ?? "").slice(0, 50)}`;
4645
+ case "WebSearch":
4646
+ return `searching: ${input.query ?? ""}`;
4647
+ case "Task":
4648
+ return `spawning agent`;
4649
+ default:
4650
+ return name;
4651
+ }
4652
+ }
4653
+ var init_claude_sandbox = __esm(() => {
4654
+ init_logger();
4655
+ init_sandbox_ignore();
4656
+ init_shutdown();
4657
+ init_claude();
4658
+ });
4659
+
4660
+ // src/ai/codex.ts
4661
+ import { execSync as execSync7, spawn as spawn4 } from "node:child_process";
4662
+ function buildCodexArgs(model) {
4663
+ const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
4664
+ if (model) {
4665
+ args.push("--model", model);
4666
+ }
4667
+ args.push("-");
4668
+ return args;
4669
+ }
4670
+
4671
+ class CodexRunner {
4672
+ name = "codex";
4673
+ process = null;
4674
+ aborted = false;
4675
+ async isAvailable() {
4676
+ try {
4677
+ execSync7("codex --version", {
4678
+ encoding: "utf-8",
4679
+ stdio: ["pipe", "pipe", "pipe"]
4680
+ });
4681
+ return true;
4682
+ } catch {
4683
+ return false;
4684
+ }
4685
+ }
4686
+ async getVersion() {
4687
+ try {
4688
+ const output = execSync7("codex --version", {
4689
+ encoding: "utf-8",
4690
+ stdio: ["pipe", "pipe", "pipe"]
4691
+ }).trim();
4692
+ return output.replace(/^codex\s*/i, "");
4693
+ } catch {
4694
+ return "unknown";
4695
+ }
4696
+ }
4697
+ async execute(options) {
4698
+ const log = getLogger();
4699
+ this.aborted = false;
4700
+ const args = buildCodexArgs(options.model);
4701
+ log.debug("Spawning codex", { args: args.join(" "), cwd: options.cwd });
4702
+ return new Promise((resolve2) => {
4703
+ let rawOutput = "";
4704
+ let errorOutput = "";
4705
+ this.process = spawn4("codex", args, {
4706
+ cwd: options.cwd,
4707
+ stdio: ["pipe", "pipe", "pipe"],
4708
+ env: { ...process.env }
4709
+ });
4710
+ let agentMessages = [];
4711
+ const flushAgentMessages = () => {
4712
+ if (agentMessages.length > 0) {
4713
+ options.onOutput?.(agentMessages.join(`
4714
+
4715
+ `));
4716
+ agentMessages = [];
4717
+ }
4718
+ };
4719
+ let lineBuffer = "";
4720
+ this.process.stdout?.on("data", (chunk) => {
4721
+ lineBuffer += chunk.toString();
4722
+ const lines = lineBuffer.split(`
4723
+ `);
4724
+ lineBuffer = lines.pop() ?? "";
4725
+ for (const line of lines) {
4726
+ if (!line.trim())
4727
+ continue;
4728
+ rawOutput += `${line}
4729
+ `;
4730
+ log.debug("codex stdout line", { line });
4731
+ try {
4732
+ const event = JSON.parse(line);
4733
+ const { type, item } = event;
4734
+ if (type === "item.started" && item?.type === "command_execution") {
4735
+ const cmd = (item.command ?? "").split(`
4736
+ `)[0].slice(0, 80);
4737
+ options.onToolActivity?.(`running: ${cmd}`);
4738
+ } else if (type === "item.completed" && item?.type === "command_execution") {
4739
+ const code = item.exit_code;
4740
+ options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
4741
+ } else if (type === "item.completed" && item?.type === "reasoning") {
4742
+ const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
4743
+ if (text)
4744
+ options.onToolActivity?.(text);
4745
+ } else if (type === "item.completed" && item?.type === "agent_message") {
4746
+ const text = item.text ?? "";
4747
+ if (text) {
4748
+ agentMessages.push(text);
4749
+ options.onToolActivity?.(text.split(`
4750
+ `)[0].slice(0, 80));
4751
+ }
4752
+ } else if (type === "turn.completed") {
4753
+ flushAgentMessages();
4754
+ }
4755
+ } catch {
4756
+ const newLine = `${line}
4757
+ `;
4758
+ rawOutput += newLine;
4759
+ options.onOutput?.(newLine);
4760
+ }
4761
+ }
4762
+ });
4763
+ this.process.stderr?.on("data", (chunk) => {
4764
+ const text = chunk.toString();
4765
+ errorOutput += text;
4766
+ log.debug("codex stderr", { text: text.slice(0, 500) });
4767
+ });
4768
+ this.process.on("close", (code) => {
4769
+ this.process = null;
4770
+ flushAgentMessages();
4771
+ if (this.aborted) {
4772
+ resolve2({
4773
+ success: false,
4774
+ output: rawOutput,
4775
+ error: "Aborted by user",
4776
+ exitCode: code ?? 143
4777
+ });
4778
+ return;
4779
+ }
4780
+ if (code === 0) {
4781
+ resolve2({
4782
+ success: true,
4783
+ output: rawOutput,
4784
+ exitCode: 0
4785
+ });
4786
+ } else {
4787
+ resolve2({
4788
+ success: false,
4789
+ output: rawOutput,
4790
+ error: errorOutput || `codex exited with code ${code}`,
4791
+ exitCode: code ?? 1
4792
+ });
4793
+ }
4794
+ });
4795
+ this.process.on("error", (err) => {
4796
+ this.process = null;
4797
+ resolve2({
4798
+ success: false,
4799
+ output: rawOutput,
4800
+ error: `Failed to spawn codex: ${err.message}`,
4801
+ exitCode: 1
4802
+ });
4803
+ });
4804
+ if (options.signal) {
4805
+ options.signal.addEventListener("abort", () => {
4806
+ this.abort();
4807
+ });
4808
+ }
4809
+ this.process.stdin?.write(options.prompt);
4810
+ this.process.stdin?.end();
4811
+ });
4812
+ }
4813
+ abort() {
4814
+ if (!this.process)
4815
+ return;
4816
+ this.aborted = true;
4817
+ const log = getLogger();
4818
+ log.debug("Aborting codex process");
4819
+ this.process.kill("SIGTERM");
4820
+ const forceKillTimer = setTimeout(() => {
4821
+ if (this.process) {
4822
+ log.debug("Force killing codex process");
4823
+ this.process.kill("SIGKILL");
4824
+ }
4825
+ }, 3000);
4826
+ if (forceKillTimer.unref) {
4827
+ forceKillTimer.unref();
4828
+ }
4829
+ }
4830
+ }
4831
+ var init_codex = __esm(() => {
4832
+ init_logger();
4833
+ });
4834
+
4835
+ // src/ai/codex-sandbox.ts
4836
+ import { execSync as execSync8, spawn as spawn5 } from "node:child_process";
4837
+
4838
+ class SandboxedCodexRunner {
4839
+ name = "codex-sandboxed";
4840
+ process = null;
4841
+ aborted = false;
4842
+ sandboxName = null;
4843
+ persistent;
4844
+ sandboxCreated = false;
4845
+ userManaged = false;
4846
+ codexInstalled = false;
4847
+ constructor(persistentName, userManaged = false) {
4848
+ if (persistentName) {
4849
+ this.persistent = true;
4850
+ this.sandboxName = persistentName;
4851
+ this.userManaged = userManaged;
4852
+ if (userManaged) {
4853
+ this.sandboxCreated = true;
4854
+ }
4855
+ } else {
4856
+ this.persistent = false;
4857
+ }
4858
+ }
4859
+ async isAvailable() {
4860
+ const delegate = new CodexRunner;
4861
+ return delegate.isAvailable();
4862
+ }
4863
+ async getVersion() {
4864
+ const delegate = new CodexRunner;
4865
+ return delegate.getVersion();
4866
+ }
4867
+ async execute(options) {
4868
+ const log = getLogger();
4869
+ this.aborted = false;
4870
+ const codexArgs = buildCodexArgs(options.model);
4871
+ let dockerArgs;
4872
+ if (this.persistent && !this.sandboxName) {
4873
+ throw new Error("Sandbox name is required");
4874
+ }
4875
+ if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
4876
+ const name = this.sandboxName;
4877
+ if (!name) {
4878
+ throw new Error("Sandbox name is required");
4879
+ }
4880
+ options.onStatusChange?.("Syncing sandbox...");
4881
+ await enforceSandboxIgnore(name, options.cwd);
4882
+ if (!this.codexInstalled) {
4883
+ options.onStatusChange?.("Checking codex...");
4884
+ await this.ensureCodexInstalled(name);
4885
+ this.codexInstalled = true;
4886
+ }
4887
+ options.onStatusChange?.("Thinking...");
4888
+ dockerArgs = [
4889
+ "sandbox",
4890
+ "exec",
4891
+ "-i",
4892
+ "-w",
4893
+ options.cwd,
4894
+ name,
4895
+ "codex",
4896
+ ...codexArgs
4897
+ ];
4898
+ } else {
4899
+ if (!this.persistent) {
4900
+ this.sandboxName = buildSandboxName2(options);
4901
+ }
4902
+ const name = this.sandboxName;
4903
+ if (!name) {
4904
+ throw new Error("Sandbox name is required");
4905
+ }
4906
+ registerActiveSandbox(name);
4907
+ options.onStatusChange?.("Creating sandbox...");
4908
+ await this.createSandboxWithClaude(name, options.cwd);
4909
+ options.onStatusChange?.("Installing codex...");
4910
+ await this.ensureCodexInstalled(name);
4911
+ this.codexInstalled = true;
4912
+ options.onStatusChange?.("Syncing sandbox...");
4913
+ await enforceSandboxIgnore(name, options.cwd);
4914
+ options.onStatusChange?.("Thinking...");
4915
+ dockerArgs = [
4916
+ "sandbox",
4917
+ "exec",
4918
+ "-i",
4919
+ "-w",
4920
+ options.cwd,
4921
+ name,
4922
+ "codex",
4923
+ ...codexArgs
4924
+ ];
4925
+ }
4926
+ log.debug("Spawning sandboxed codex", {
4927
+ sandboxName: this.sandboxName,
4928
+ persistent: this.persistent,
4929
+ reusing: this.persistent && this.sandboxCreated,
4930
+ args: dockerArgs.join(" "),
4931
+ cwd: options.cwd
4932
+ });
4933
+ try {
4934
+ return await new Promise((resolve2) => {
4935
+ let rawOutput = "";
4936
+ let errorOutput = "";
4937
+ this.process = spawn5("docker", dockerArgs, {
4938
+ stdio: ["pipe", "pipe", "pipe"],
4939
+ env: process.env
4940
+ });
4941
+ if (this.persistent && !this.sandboxCreated) {
4942
+ this.process.on("spawn", () => {
4943
+ this.sandboxCreated = true;
4944
+ });
4945
+ }
4946
+ let agentMessages = [];
4947
+ const flushAgentMessages = () => {
4948
+ if (agentMessages.length > 0) {
4949
+ options.onOutput?.(agentMessages.join(`
4950
+
4951
+ `));
4952
+ agentMessages = [];
4953
+ }
4954
+ };
4955
+ let lineBuffer = "";
4956
+ this.process.stdout?.on("data", (chunk) => {
4957
+ lineBuffer += chunk.toString();
4958
+ const lines = lineBuffer.split(`
4959
+ `);
4960
+ lineBuffer = lines.pop() ?? "";
4961
+ for (const line of lines) {
4962
+ if (!line.trim())
4963
+ continue;
4964
+ rawOutput += `${line}
4965
+ `;
4966
+ log.debug("sandboxed codex stdout line", { line });
4967
+ try {
4968
+ const event = JSON.parse(line);
4969
+ const { type, item } = event;
4970
+ if (type === "item.started" && item?.type === "command_execution") {
4971
+ const cmd = (item.command ?? "").split(`
4972
+ `)[0].slice(0, 80);
4973
+ options.onToolActivity?.(`running: ${cmd}`);
4974
+ } else if (type === "item.completed" && item?.type === "command_execution") {
4975
+ const code = item.exit_code;
4976
+ options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
4977
+ } else if (type === "item.completed" && item?.type === "reasoning") {
4978
+ const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
4979
+ if (text)
4980
+ options.onToolActivity?.(text);
4981
+ } else if (type === "item.completed" && item?.type === "agent_message") {
4982
+ const text = item.text ?? "";
4983
+ if (text) {
4984
+ agentMessages.push(text);
4985
+ options.onToolActivity?.(text.split(`
4986
+ `)[0].slice(0, 80));
4987
+ }
4988
+ } else if (type === "turn.completed") {
4989
+ flushAgentMessages();
4990
+ }
4991
+ } catch {
4992
+ const newLine = `${line}
4993
+ `;
4994
+ rawOutput += newLine;
4995
+ options.onOutput?.(newLine);
4996
+ }
4997
+ }
4998
+ });
4999
+ this.process.stderr?.on("data", (chunk) => {
5000
+ const text = chunk.toString();
5001
+ errorOutput += text;
5002
+ log.debug("sandboxed codex stderr", { text: text.slice(0, 500) });
5003
+ });
5004
+ this.process.on("close", (code) => {
5005
+ this.process = null;
5006
+ flushAgentMessages();
5007
+ if (this.aborted) {
5008
+ resolve2({
5009
+ success: false,
5010
+ output: rawOutput,
5011
+ error: "Aborted by user",
5012
+ exitCode: code ?? 143
5013
+ });
5014
+ return;
5015
+ }
5016
+ if (code === 0) {
5017
+ resolve2({
5018
+ success: true,
5019
+ output: rawOutput,
5020
+ exitCode: 0
5021
+ });
5022
+ } else {
5023
+ resolve2({
5024
+ success: false,
5025
+ output: rawOutput,
5026
+ error: errorOutput || `sandboxed codex exited with code ${code}`,
5027
+ exitCode: code ?? 1
5028
+ });
5029
+ }
5030
+ });
5031
+ this.process.on("error", (err) => {
5032
+ this.process = null;
5033
+ if (this.persistent && !this.sandboxCreated) {}
5034
+ resolve2({
5035
+ success: false,
5036
+ output: rawOutput,
5037
+ error: `Failed to spawn docker sandbox: ${err.message}`,
5038
+ exitCode: 1
5039
+ });
5040
+ });
5041
+ if (options.signal) {
5042
+ options.signal.addEventListener("abort", () => {
5043
+ this.abort();
5044
+ });
5045
+ }
5046
+ this.process.stdin?.write(options.prompt);
5047
+ this.process.stdin?.end();
5048
+ });
5049
+ } finally {
5050
+ if (!this.persistent) {
5051
+ this.cleanupSandbox();
5052
+ }
5053
+ }
5054
+ }
5055
+ abort() {
5056
+ this.aborted = true;
5057
+ const log = getLogger();
5058
+ if (this.persistent) {
5059
+ log.debug("Aborting sandboxed codex (persistent — keeping sandbox)", {
5060
+ sandboxName: this.sandboxName
5061
+ });
5062
+ if (this.process) {
5063
+ this.process.kill("SIGTERM");
5064
+ const timer = setTimeout(() => {
5065
+ if (this.process) {
5066
+ this.process.kill("SIGKILL");
5067
+ }
5068
+ }, 3000);
5069
+ if (timer.unref)
5070
+ timer.unref();
5071
+ }
5072
+ } else {
5073
+ if (!this.sandboxName)
5074
+ return;
5075
+ log.debug("Aborting sandboxed codex (ephemeral — removing sandbox)", {
5076
+ sandboxName: this.sandboxName
5077
+ });
5078
+ try {
5079
+ execSync8(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5080
+ } catch {}
5081
+ }
5082
+ }
5083
+ destroy() {
5084
+ if (!this.sandboxName)
5085
+ return;
5086
+ if (this.userManaged) {
5087
+ unregisterActiveSandbox(this.sandboxName);
5088
+ return;
5089
+ }
5090
+ const log = getLogger();
5091
+ log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
5092
+ try {
5093
+ execSync8(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5094
+ } catch {}
5095
+ unregisterActiveSandbox(this.sandboxName);
5096
+ this.sandboxName = null;
5097
+ this.sandboxCreated = false;
5098
+ }
5099
+ cleanupSandbox() {
5100
+ if (!this.sandboxName)
5101
+ return;
5102
+ const log = getLogger();
5103
+ log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
5104
+ try {
5105
+ execSync8(`docker sandbox rm ${this.sandboxName}`, {
5106
+ timeout: 60000
5107
+ });
5108
+ } catch {}
5109
+ unregisterActiveSandbox(this.sandboxName);
5110
+ this.sandboxName = null;
5111
+ }
5112
+ async isSandboxRunning() {
5113
+ if (!this.sandboxName)
5114
+ return false;
5115
+ try {
5116
+ const { promisify: promisify2 } = await import("node:util");
5117
+ const { exec: exec2 } = await import("node:child_process");
5118
+ const execAsync2 = promisify2(exec2);
5119
+ const { stdout } = await execAsync2("docker sandbox ls", {
5120
+ timeout: 5000
5121
+ });
5122
+ return stdout.includes(this.sandboxName);
5123
+ } catch {
5124
+ return false;
5125
+ }
5126
+ }
5127
+ async createSandboxWithClaude(name, cwd) {
5128
+ const { promisify: promisify2 } = await import("node:util");
5129
+ const { exec: exec2 } = await import("node:child_process");
5130
+ const execAsync2 = promisify2(exec2);
5131
+ try {
5132
+ await execAsync2(`docker sandbox run --name ${name} claude ${cwd} -- --version`, { timeout: 120000 });
5133
+ } catch {}
5134
+ }
5135
+ async ensureCodexInstalled(name) {
5136
+ const { promisify: promisify2 } = await import("node:util");
5137
+ const { exec: exec2 } = await import("node:child_process");
5138
+ const execAsync2 = promisify2(exec2);
5139
+ try {
5140
+ await execAsync2(`docker sandbox exec ${name} which codex`, {
5141
+ timeout: 5000
5142
+ });
5143
+ } catch {
5144
+ await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, { timeout: 120000 });
5145
+ }
5146
+ }
5147
+ getSandboxName() {
5148
+ return this.sandboxName;
5149
+ }
5150
+ }
5151
+ function buildSandboxName2(options) {
5152
+ const ts = Date.now();
5153
+ if (options.activity) {
5154
+ const match = options.activity.match(/issue\s*#(\d+)/i);
5155
+ if (match) {
5156
+ return `locus-codex-issue-${match[1]}-${ts}`;
5157
+ }
5158
+ }
5159
+ const segment = options.cwd.split("/").pop() ?? "run";
5160
+ return `locus-codex-${segment}-${ts}`;
5161
+ }
5162
+ var init_codex_sandbox = __esm(() => {
5163
+ init_logger();
5164
+ init_sandbox_ignore();
5165
+ init_shutdown();
5166
+ init_codex();
5167
+ });
5168
+
5169
+ // src/ai/runner.ts
5170
+ async function createRunnerAsync(provider, sandboxed) {
5171
+ switch (provider) {
5172
+ case "claude":
5173
+ return sandboxed ? new SandboxedClaudeRunner : new ClaudeRunner;
5174
+ case "codex":
5175
+ return sandboxed ? new SandboxedCodexRunner : new CodexRunner;
5176
+ default:
5177
+ throw new Error(`Unknown AI provider: ${provider}`);
5178
+ }
5179
+ }
5180
+ function createUserManagedSandboxRunner(provider, sandboxName) {
5181
+ switch (provider) {
5182
+ case "claude":
5183
+ return new SandboxedClaudeRunner(sandboxName, true);
5184
+ case "codex":
5185
+ return new SandboxedCodexRunner(sandboxName, true);
5186
+ default:
5187
+ throw new Error(`Unknown AI provider: ${provider}`);
5188
+ }
5189
+ }
5190
+ var init_runner = __esm(() => {
5191
+ init_claude();
5192
+ init_claude_sandbox();
5193
+ init_codex();
5194
+ init_codex_sandbox();
5195
+ });
5196
+
5197
+ // src/ai/run-ai.ts
5198
+ var exports_run_ai = {};
5199
+ __export(exports_run_ai, {
5200
+ runAI: () => runAI
5201
+ });
5202
+ function normalizeErrorMessage(error) {
5203
+ if (!error)
5204
+ return;
5205
+ const trimmed = error.trim();
5206
+ return trimmed.length > 0 ? trimmed : undefined;
5207
+ }
5208
+ function stripAnsi2(text) {
5209
+ return text.replace(/\u001B\[[0-9;]*[A-Za-z]/g, "");
5210
+ }
5211
+ function extractErrorFromStructuredLine(line) {
5212
+ try {
5213
+ const parsed = JSON.parse(line);
5214
+ const candidateValues = [
5215
+ parsed.error,
5216
+ parsed.message,
5217
+ parsed.text,
5218
+ typeof parsed.item === "object" && parsed.item ? parsed.item.error : undefined,
5219
+ typeof parsed.item === "object" && parsed.item ? parsed.item.message : undefined,
5220
+ typeof parsed.item === "object" && parsed.item ? parsed.item.text : undefined
5221
+ ];
5222
+ for (const value of candidateValues) {
5223
+ if (typeof value !== "string")
5224
+ continue;
5225
+ const normalized = normalizeErrorMessage(stripAnsi2(value));
5226
+ if (normalized)
5227
+ return normalized;
5228
+ }
5229
+ return;
5230
+ } catch {
5231
+ return;
5232
+ }
5233
+ }
5234
+ function extractErrorFromOutput(output) {
5235
+ if (!output)
5236
+ return;
5237
+ const lines = output.split(`
5238
+ `);
5239
+ for (let index = lines.length - 1;index >= 0; index--) {
5240
+ const rawLine = lines[index] ?? "";
5241
+ const line = normalizeErrorMessage(stripAnsi2(rawLine));
5242
+ if (!line)
5243
+ continue;
5244
+ const structured = extractErrorFromStructuredLine(line);
5245
+ if (structured)
5246
+ return structured.slice(0, 500);
5247
+ return line.slice(0, 500);
5248
+ }
5249
+ return;
5250
+ }
5251
+ async function runAI(options) {
5252
+ const indicator = getStatusIndicator();
5253
+ const renderer = options.silent ? null : new StreamRenderer;
5254
+ let output = "";
5255
+ let wasAborted = false;
5256
+ let runner = null;
5257
+ const resolvedProvider = inferProviderFromModel(options.model) || options.provider;
5258
+ const abortController = new AbortController;
5259
+ const cleanupInterrupt = options.noInterrupt ? () => {} : listenForInterrupt(() => {
5260
+ if (wasAborted)
5261
+ return;
5262
+ wasAborted = true;
5263
+ indicator.stop();
5264
+ renderer?.stop();
5265
+ process.stderr.write(`\r
5266
+ ${yellow("⚡")} ${dim("Interrupting...")}\r
5267
+ `);
5268
+ abortController.abort();
5269
+ if (runner)
5270
+ runner.abort();
5271
+ }, () => {
5272
+ indicator.stop();
4200
5273
  renderer?.stop();
4201
5274
  process.stderr.write(`\r
4202
5275
  ${red("✗")} ${dim("Force exit.")}\r
@@ -4207,7 +5280,13 @@ ${red("✗")} ${dim("Force exit.")}\r
4207
5280
  indicator.start("Thinking...", {
4208
5281
  activity: options.activity
4209
5282
  });
4210
- runner = await createRunnerAsync(resolvedProvider);
5283
+ if (options.runner) {
5284
+ runner = options.runner;
5285
+ } else if (options.sandboxName) {
5286
+ runner = createUserManagedSandboxRunner(resolvedProvider, options.sandboxName);
5287
+ } else {
5288
+ runner = await createRunnerAsync(resolvedProvider, options.sandboxed ?? true);
5289
+ }
4211
5290
  const available = await runner.isAvailable();
4212
5291
  if (!available) {
4213
5292
  indicator.stop();
@@ -4227,6 +5306,7 @@ ${red("✗")} ${dim("Force exit.")}\r
4227
5306
  cwd: options.cwd,
4228
5307
  signal: abortController.signal,
4229
5308
  verbose: options.verbose,
5309
+ activity: options.activity,
4230
5310
  onOutput: (chunk) => {
4231
5311
  if (wasAborted)
4232
5312
  return;
@@ -4237,6 +5317,9 @@ ${red("✗")} ${dim("Force exit.")}\r
4237
5317
  renderer?.push(chunk);
4238
5318
  output += chunk;
4239
5319
  },
5320
+ onStatusChange: (message) => {
5321
+ indicator.setMessage(message);
5322
+ },
4240
5323
  onToolActivity: (() => {
4241
5324
  let lastActivityTime = 0;
4242
5325
  return (summary) => {
@@ -4261,20 +5344,25 @@ ${red("✗")} ${dim("Force exit.")}\r
4261
5344
  exitCode: result.exitCode
4262
5345
  };
4263
5346
  }
5347
+ const normalizedRunnerError = normalizeErrorMessage(result.error);
5348
+ const extractedOutputError = extractErrorFromOutput(result.output);
5349
+ const fallbackError = `${runner.name} failed with exit code ${result.exitCode}.`;
4264
5350
  return {
4265
5351
  success: result.success,
4266
5352
  output,
4267
- error: result.error,
5353
+ error: result.success ? undefined : normalizedRunnerError ?? extractedOutputError ?? fallbackError,
4268
5354
  interrupted: false,
4269
5355
  exitCode: result.exitCode
4270
5356
  };
4271
5357
  } catch (e) {
4272
5358
  indicator.stop();
4273
5359
  renderer?.stop();
5360
+ const normalizedCaughtError = normalizeErrorMessage(e instanceof Error ? e.message : String(e));
5361
+ const fallbackError = `${resolvedProvider} runner failed unexpectedly.`;
4274
5362
  return {
4275
5363
  success: false,
4276
5364
  output,
4277
- error: e instanceof Error ? e.message : String(e),
5365
+ error: normalizedCaughtError ?? fallbackError,
4278
5366
  interrupted: wasAborted,
4279
5367
  exitCode: 1
4280
5368
  };
@@ -4517,7 +5605,9 @@ async function issueCreate(projectRoot, parsed) {
4517
5605
  model: config.ai.model,
4518
5606
  cwd: projectRoot,
4519
5607
  silent: true,
4520
- activity: "generating issue"
5608
+ activity: "generating issue",
5609
+ sandboxed: config.sandbox.enabled,
5610
+ sandboxName: config.sandbox.name
4521
5611
  });
4522
5612
  if (!aiResult.success && !aiResult.interrupted) {
4523
5613
  process.stderr.write(`${red("✗")} Failed to generate issue: ${aiResult.error}
@@ -5675,9 +6765,9 @@ var init_sprint = __esm(() => {
5675
6765
  });
5676
6766
 
5677
6767
  // 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";
6768
+ import { execSync as execSync9 } from "node:child_process";
6769
+ import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync10 } from "node:fs";
6770
+ import { join as join12 } from "node:path";
5681
6771
  function buildExecutionPrompt(ctx) {
5682
6772
  const sections = [];
5683
6773
  sections.push(buildSystemContext(ctx.projectRoot));
@@ -5707,13 +6797,13 @@ function buildFeedbackPrompt(ctx) {
5707
6797
  }
5708
6798
  function buildReplPrompt(userMessage, projectRoot, _config, previousMessages) {
5709
6799
  const sections = [];
5710
- const locusmd = readFileSafe(join10(projectRoot, "LOCUS.md"));
6800
+ const locusmd = readFileSafe(join12(projectRoot, "LOCUS.md"));
5711
6801
  if (locusmd) {
5712
6802
  sections.push(`# Project Instructions
5713
6803
 
5714
6804
  ${locusmd}`);
5715
6805
  }
5716
- const learnings = readFileSafe(join10(projectRoot, ".locus", "LEARNINGS.md"));
6806
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
5717
6807
  if (learnings) {
5718
6808
  sections.push(`# Past Learnings
5719
6809
 
@@ -5739,24 +6829,24 @@ ${userMessage}`);
5739
6829
  }
5740
6830
  function buildSystemContext(projectRoot) {
5741
6831
  const parts = ["# System Context"];
5742
- const locusmd = readFileSafe(join10(projectRoot, "LOCUS.md"));
6832
+ const locusmd = readFileSafe(join12(projectRoot, "LOCUS.md"));
5743
6833
  if (locusmd) {
5744
6834
  parts.push(`## Project Instructions (LOCUS.md)
5745
6835
 
5746
6836
  ${locusmd}`);
5747
6837
  }
5748
- const learnings = readFileSafe(join10(projectRoot, ".locus", "LEARNINGS.md"));
6838
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
5749
6839
  if (learnings) {
5750
6840
  parts.push(`## Past Learnings
5751
6841
 
5752
6842
  ${learnings}`);
5753
6843
  }
5754
- const discussionsDir = join10(projectRoot, ".locus", "discussions");
5755
- if (existsSync11(discussionsDir)) {
6844
+ const discussionsDir = join12(projectRoot, ".locus", "discussions");
6845
+ if (existsSync13(discussionsDir)) {
5756
6846
  try {
5757
6847
  const files = readdirSync3(discussionsDir).filter((f) => f.endsWith(".md")).slice(0, 3);
5758
6848
  for (const file of files) {
5759
- const content = readFileSafe(join10(discussionsDir, file));
6849
+ const content = readFileSafe(join12(discussionsDir, file));
5760
6850
  if (content) {
5761
6851
  parts.push(`## Discussion: ${file.replace(".md", "")}
5762
6852
 
@@ -5819,7 +6909,7 @@ ${diffSummary}
5819
6909
  function buildRepoContext(projectRoot) {
5820
6910
  const parts = ["# Repository Context"];
5821
6911
  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();
6912
+ const tree = execSync9("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
6913
  if (tree) {
5824
6914
  parts.push(`## File Tree
5825
6915
 
@@ -5829,7 +6919,7 @@ ${tree}
5829
6919
  }
5830
6920
  } catch {}
5831
6921
  try {
5832
- const gitLog = execSync6("git log --oneline -10", {
6922
+ const gitLog = execSync9("git log --oneline -10", {
5833
6923
  cwd: projectRoot,
5834
6924
  encoding: "utf-8",
5835
6925
  stdio: ["pipe", "pipe", "pipe"]
@@ -5843,7 +6933,7 @@ ${gitLog}
5843
6933
  }
5844
6934
  } catch {}
5845
6935
  try {
5846
- const branch = execSync6("git rev-parse --abbrev-ref HEAD", {
6936
+ const branch = execSync9("git rev-parse --abbrev-ref HEAD", {
5847
6937
  cwd: projectRoot,
5848
6938
  encoding: "utf-8",
5849
6939
  stdio: ["pipe", "pipe", "pipe"]
@@ -5902,9 +6992,9 @@ function buildFeedbackInstructions() {
5902
6992
  }
5903
6993
  function readFileSafe(path) {
5904
6994
  try {
5905
- if (!existsSync11(path))
6995
+ if (!existsSync13(path))
5906
6996
  return null;
5907
- return readFileSync8(path, "utf-8");
6997
+ return readFileSync10(path, "utf-8");
5908
6998
  } catch {
5909
6999
  return null;
5910
7000
  }
@@ -6096,7 +7186,7 @@ var init_diff_renderer = __esm(() => {
6096
7186
  });
6097
7187
 
6098
7188
  // src/repl/commands.ts
6099
- import { execSync as execSync7 } from "node:child_process";
7189
+ import { execSync as execSync10 } from "node:child_process";
6100
7190
  function getSlashCommands() {
6101
7191
  return [
6102
7192
  {
@@ -6288,7 +7378,7 @@ function cmdModel(args, ctx) {
6288
7378
  }
6289
7379
  function cmdDiff(_args, ctx) {
6290
7380
  try {
6291
- const diff = execSync7("git diff", {
7381
+ const diff = execSync10("git diff", {
6292
7382
  cwd: ctx.projectRoot,
6293
7383
  encoding: "utf-8",
6294
7384
  stdio: ["pipe", "pipe", "pipe"]
@@ -6324,7 +7414,7 @@ function cmdDiff(_args, ctx) {
6324
7414
  }
6325
7415
  function cmdUndo(_args, ctx) {
6326
7416
  try {
6327
- const status = execSync7("git status --porcelain", {
7417
+ const status = execSync10("git status --porcelain", {
6328
7418
  cwd: ctx.projectRoot,
6329
7419
  encoding: "utf-8",
6330
7420
  stdio: ["pipe", "pipe", "pipe"]
@@ -6334,7 +7424,7 @@ function cmdUndo(_args, ctx) {
6334
7424
  `);
6335
7425
  return;
6336
7426
  }
6337
- execSync7("git checkout .", {
7427
+ execSync10("git checkout .", {
6338
7428
  cwd: ctx.projectRoot,
6339
7429
  encoding: "utf-8",
6340
7430
  stdio: ["pipe", "pipe", "pipe"]
@@ -6368,7 +7458,7 @@ var init_commands = __esm(() => {
6368
7458
 
6369
7459
  // src/repl/completions.ts
6370
7460
  import { readdirSync as readdirSync4 } from "node:fs";
6371
- import { basename as basename2, dirname as dirname3, join as join11 } from "node:path";
7461
+ import { basename as basename2, dirname as dirname4, join as join13 } from "node:path";
6372
7462
 
6373
7463
  class SlashCommandCompletion {
6374
7464
  commands;
@@ -6423,7 +7513,7 @@ class FilePathCompletion {
6423
7513
  }
6424
7514
  findMatches(partial) {
6425
7515
  try {
6426
- const dir = partial.includes("/") ? join11(this.projectRoot, dirname3(partial)) : this.projectRoot;
7516
+ const dir = partial.includes("/") ? join13(this.projectRoot, dirname4(partial)) : this.projectRoot;
6427
7517
  const prefix = basename2(partial);
6428
7518
  const entries = readdirSync4(dir, { withFileTypes: true });
6429
7519
  return entries.filter((e) => {
@@ -6434,7 +7524,7 @@ class FilePathCompletion {
6434
7524
  return e.name.startsWith(prefix);
6435
7525
  }).map((e) => {
6436
7526
  const name = e.isDirectory() ? `${e.name}/` : e.name;
6437
- return partial.includes("/") ? `${dirname3(partial)}/${name}` : name;
7527
+ return partial.includes("/") ? `${dirname4(partial)}/${name}` : name;
6438
7528
  }).slice(0, 20);
6439
7529
  } catch {
6440
7530
  return [];
@@ -6459,14 +7549,14 @@ class CombinedCompletion {
6459
7549
  var init_completions = () => {};
6460
7550
 
6461
7551
  // 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";
7552
+ import { existsSync as existsSync14, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
7553
+ import { dirname as dirname5, join as join14 } from "node:path";
6464
7554
 
6465
7555
  class InputHistory {
6466
7556
  entries = [];
6467
7557
  filePath;
6468
7558
  constructor(projectRoot) {
6469
- this.filePath = join12(projectRoot, ".locus", "sessions", ".input-history");
7559
+ this.filePath = join14(projectRoot, ".locus", "sessions", ".input-history");
6470
7560
  this.load();
6471
7561
  }
6472
7562
  add(text) {
@@ -6505,22 +7595,22 @@ class InputHistory {
6505
7595
  }
6506
7596
  load() {
6507
7597
  try {
6508
- if (!existsSync12(this.filePath))
7598
+ if (!existsSync14(this.filePath))
6509
7599
  return;
6510
- const content = readFileSync9(this.filePath, "utf-8");
7600
+ const content = readFileSync11(this.filePath, "utf-8");
6511
7601
  this.entries = content.split(`
6512
7602
  `).map((line) => this.unescape(line)).filter(Boolean);
6513
7603
  } catch {}
6514
7604
  }
6515
7605
  save() {
6516
7606
  try {
6517
- const dir = dirname4(this.filePath);
6518
- if (!existsSync12(dir)) {
6519
- mkdirSync8(dir, { recursive: true });
7607
+ const dir = dirname5(this.filePath);
7608
+ if (!existsSync14(dir)) {
7609
+ mkdirSync9(dir, { recursive: true });
6520
7610
  }
6521
7611
  const content = this.entries.map((e) => this.escape(e)).join(`
6522
7612
  `);
6523
- writeFileSync6(this.filePath, content, "utf-8");
7613
+ writeFileSync7(this.filePath, content, "utf-8");
6524
7614
  } catch {}
6525
7615
  }
6526
7616
  escape(text) {
@@ -6545,23 +7635,22 @@ var init_model_config = __esm(() => {
6545
7635
  });
6546
7636
 
6547
7637
  // src/repl/session-manager.ts
6548
- import { randomBytes } from "node:crypto";
6549
7638
  import {
6550
- existsSync as existsSync13,
6551
- mkdirSync as mkdirSync9,
7639
+ existsSync as existsSync15,
7640
+ mkdirSync as mkdirSync10,
6552
7641
  readdirSync as readdirSync5,
6553
- readFileSync as readFileSync10,
6554
- unlinkSync as unlinkSync3,
6555
- writeFileSync as writeFileSync7
7642
+ readFileSync as readFileSync12,
7643
+ unlinkSync as unlinkSync4,
7644
+ writeFileSync as writeFileSync8
6556
7645
  } from "node:fs";
6557
- import { basename as basename3, join as join13 } from "node:path";
7646
+ import { basename as basename3, join as join15 } from "node:path";
6558
7647
 
6559
7648
  class SessionManager {
6560
7649
  sessionsDir;
6561
7650
  constructor(projectRoot) {
6562
- this.sessionsDir = join13(projectRoot, ".locus", "sessions");
6563
- if (!existsSync13(this.sessionsDir)) {
6564
- mkdirSync9(this.sessionsDir, { recursive: true });
7651
+ this.sessionsDir = join15(projectRoot, ".locus", "sessions");
7652
+ if (!existsSync15(this.sessionsDir)) {
7653
+ mkdirSync10(this.sessionsDir, { recursive: true });
6565
7654
  }
6566
7655
  }
6567
7656
  create(options) {
@@ -6586,14 +7675,14 @@ class SessionManager {
6586
7675
  }
6587
7676
  isPersisted(sessionOrId) {
6588
7677
  const sessionId = typeof sessionOrId === "string" ? sessionOrId : sessionOrId.id;
6589
- return existsSync13(this.getSessionPath(sessionId));
7678
+ return existsSync15(this.getSessionPath(sessionId));
6590
7679
  }
6591
7680
  load(idOrPrefix) {
6592
7681
  const files = this.listSessionFiles();
6593
7682
  const exactPath = this.getSessionPath(idOrPrefix);
6594
- if (existsSync13(exactPath)) {
7683
+ if (existsSync15(exactPath)) {
6595
7684
  try {
6596
- return JSON.parse(readFileSync10(exactPath, "utf-8"));
7685
+ return JSON.parse(readFileSync12(exactPath, "utf-8"));
6597
7686
  } catch {
6598
7687
  return null;
6599
7688
  }
@@ -6601,7 +7690,7 @@ class SessionManager {
6601
7690
  const matches = files.filter((f) => basename3(f, ".json").startsWith(idOrPrefix));
6602
7691
  if (matches.length === 1) {
6603
7692
  try {
6604
- return JSON.parse(readFileSync10(matches[0], "utf-8"));
7693
+ return JSON.parse(readFileSync12(matches[0], "utf-8"));
6605
7694
  } catch {
6606
7695
  return null;
6607
7696
  }
@@ -6614,7 +7703,7 @@ class SessionManager {
6614
7703
  save(session) {
6615
7704
  session.updated = new Date().toISOString();
6616
7705
  const path = this.getSessionPath(session.id);
6617
- writeFileSync7(path, `${JSON.stringify(session, null, 2)}
7706
+ writeFileSync8(path, `${JSON.stringify(session, null, 2)}
6618
7707
  `, "utf-8");
6619
7708
  }
6620
7709
  addMessage(session, message) {
@@ -6626,7 +7715,7 @@ class SessionManager {
6626
7715
  const sessions = [];
6627
7716
  for (const file of files) {
6628
7717
  try {
6629
- const session = JSON.parse(readFileSync10(file, "utf-8"));
7718
+ const session = JSON.parse(readFileSync12(file, "utf-8"));
6630
7719
  sessions.push({
6631
7720
  id: session.id,
6632
7721
  created: session.created,
@@ -6641,8 +7730,8 @@ class SessionManager {
6641
7730
  }
6642
7731
  delete(sessionId) {
6643
7732
  const path = this.getSessionPath(sessionId);
6644
- if (existsSync13(path)) {
6645
- unlinkSync3(path);
7733
+ if (existsSync15(path)) {
7734
+ unlinkSync4(path);
6646
7735
  return true;
6647
7736
  }
6648
7737
  return false;
@@ -6653,7 +7742,7 @@ class SessionManager {
6653
7742
  let pruned = 0;
6654
7743
  const withStats = files.map((f) => {
6655
7744
  try {
6656
- const session = JSON.parse(readFileSync10(f, "utf-8"));
7745
+ const session = JSON.parse(readFileSync12(f, "utf-8"));
6657
7746
  return { path: f, updated: new Date(session.updated).getTime() };
6658
7747
  } catch {
6659
7748
  return { path: f, updated: 0 };
@@ -6663,7 +7752,7 @@ class SessionManager {
6663
7752
  for (const entry of withStats) {
6664
7753
  if (now - entry.updated > SESSION_MAX_AGE_MS) {
6665
7754
  try {
6666
- unlinkSync3(entry.path);
7755
+ unlinkSync4(entry.path);
6667
7756
  pruned++;
6668
7757
  } catch {}
6669
7758
  }
@@ -6671,10 +7760,10 @@ class SessionManager {
6671
7760
  const remaining = withStats.length - pruned;
6672
7761
  if (remaining > MAX_SESSIONS) {
6673
7762
  const toRemove = remaining - MAX_SESSIONS;
6674
- const alive = withStats.filter((e) => existsSync13(e.path));
7763
+ const alive = withStats.filter((e) => existsSync15(e.path));
6675
7764
  for (let i = 0;i < toRemove && i < alive.length; i++) {
6676
7765
  try {
6677
- unlinkSync3(alive[i].path);
7766
+ unlinkSync4(alive[i].path);
6678
7767
  pruned++;
6679
7768
  } catch {}
6680
7769
  }
@@ -6686,16 +7775,16 @@ class SessionManager {
6686
7775
  }
6687
7776
  listSessionFiles() {
6688
7777
  try {
6689
- return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join13(this.sessionsDir, f));
7778
+ return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join15(this.sessionsDir, f));
6690
7779
  } catch {
6691
7780
  return [];
6692
7781
  }
6693
7782
  }
6694
7783
  generateId() {
6695
- return randomBytes(6).toString("hex");
7784
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
6696
7785
  }
6697
7786
  getSessionPath(sessionId) {
6698
- return join13(this.sessionsDir, `${sessionId}.json`);
7787
+ return join15(this.sessionsDir, `${sessionId}.json`);
6699
7788
  }
6700
7789
  }
6701
7790
  var MAX_SESSIONS = 50, SESSION_MAX_AGE_MS;
@@ -6705,7 +7794,7 @@ var init_session_manager = __esm(() => {
6705
7794
  });
6706
7795
 
6707
7796
  // src/repl/repl.ts
6708
- import { execSync as execSync8 } from "node:child_process";
7797
+ import { execSync as execSync11 } from "node:child_process";
6709
7798
  async function startRepl(options) {
6710
7799
  const { projectRoot, config } = options;
6711
7800
  const sessionManager = new SessionManager(projectRoot);
@@ -6723,7 +7812,7 @@ async function startRepl(options) {
6723
7812
  } else {
6724
7813
  let branch = "main";
6725
7814
  try {
6726
- branch = execSync8("git rev-parse --abbrev-ref HEAD", {
7815
+ branch = execSync11("git rev-parse --abbrev-ref HEAD", {
6727
7816
  cwd: projectRoot,
6728
7817
  encoding: "utf-8",
6729
7818
  stdio: ["pipe", "pipe", "pipe"]
@@ -6764,6 +7853,18 @@ async function executeOneShotPrompt(prompt, session, sessionManager, options) {
6764
7853
  }
6765
7854
  async function runInteractiveRepl(session, sessionManager, options) {
6766
7855
  const { projectRoot, config } = options;
7856
+ let sandboxRunner = null;
7857
+ if (config.sandbox.enabled && config.sandbox.name) {
7858
+ const provider = inferProviderFromModel(config.ai.model) || config.ai.provider;
7859
+ sandboxRunner = createUserManagedSandboxRunner(provider, config.sandbox.name);
7860
+ process.stderr.write(`${dim("Using sandbox")} ${dim(config.sandbox.name)}
7861
+ `);
7862
+ } else if (config.sandbox.enabled) {
7863
+ const sandboxName = buildPersistentSandboxName(projectRoot);
7864
+ sandboxRunner = new SandboxedClaudeRunner(sandboxName);
7865
+ process.stderr.write(`${dim("Sandbox mode: prompts will share sandbox")} ${dim(sandboxName)}
7866
+ `);
7867
+ }
6767
7868
  const history = new InputHistory(projectRoot);
6768
7869
  const completion = new CombinedCompletion([
6769
7870
  new SlashCommandCompletion(getAllCommandNames()),
@@ -6793,8 +7894,14 @@ async function runInteractiveRepl(session, sessionManager, options) {
6793
7894
  session.metadata.model = model;
6794
7895
  const inferredProvider = inferProviderFromModel(model);
6795
7896
  if (inferredProvider) {
7897
+ const providerChanged = inferredProvider !== currentProvider;
6796
7898
  currentProvider = inferredProvider;
6797
7899
  session.metadata.provider = inferredProvider;
7900
+ if (providerChanged && config.sandbox.enabled && config.sandbox.name) {
7901
+ sandboxRunner = createUserManagedSandboxRunner(inferredProvider, config.sandbox.name);
7902
+ process.stderr.write(`${dim("Switched sandbox agent to")} ${dim(inferredProvider)}
7903
+ `);
7904
+ }
6798
7905
  }
6799
7906
  persistReplModelSelection(projectRoot, config, model);
6800
7907
  sessionManager.save(session);
@@ -6839,7 +7946,7 @@ async function runInteractiveRepl(session, sessionManager, options) {
6839
7946
  ...config,
6840
7947
  ai: { provider: currentProvider, model: currentModel }
6841
7948
  }
6842
- }, verbose);
7949
+ }, verbose, sandboxRunner ?? undefined);
6843
7950
  sessionManager.addMessage(session, {
6844
7951
  role: "assistant",
6845
7952
  content: response,
@@ -6864,6 +7971,10 @@ ${red("✗")} ${msg}
6864
7971
  break;
6865
7972
  }
6866
7973
  }
7974
+ if (sandboxRunner && "destroy" in sandboxRunner) {
7975
+ const runner = sandboxRunner;
7976
+ runner.destroy();
7977
+ }
6867
7978
  const shouldPersistOnExit = session.messages.length > 0 || sessionManager.isPersisted(session);
6868
7979
  if (shouldPersistOnExit) {
6869
7980
  sessionManager.save(session);
@@ -6879,14 +7990,17 @@ ${red("✗")} ${msg}
6879
7990
  process.stdin.pause();
6880
7991
  process.exit(0);
6881
7992
  }
6882
- async function executeAITurn(prompt, session, options, verbose = false) {
7993
+ async function executeAITurn(prompt, session, options, verbose = false, runner) {
6883
7994
  const { config, projectRoot } = options;
6884
7995
  const aiResult = await runAI({
6885
7996
  prompt,
6886
7997
  provider: config.ai.provider,
6887
7998
  model: config.ai.model,
6888
7999
  cwd: projectRoot,
6889
- verbose
8000
+ verbose,
8001
+ sandboxed: config.sandbox.enabled,
8002
+ sandboxName: config.sandbox.name,
8003
+ runner
6890
8004
  });
6891
8005
  if (aiResult.interrupted) {
6892
8006
  if (aiResult.output) {
@@ -6916,7 +8030,9 @@ function printWelcome(session) {
6916
8030
  `);
6917
8031
  }
6918
8032
  var init_repl = __esm(() => {
8033
+ init_claude_sandbox();
6919
8034
  init_run_ai();
8035
+ init_runner();
6920
8036
  init_ai_models();
6921
8037
  init_prompt_builder();
6922
8038
  init_terminal();
@@ -7059,7 +8175,7 @@ async function handleJsonStream(projectRoot, config, args, sessionId) {
7059
8175
  stream.emitStatus("thinking");
7060
8176
  try {
7061
8177
  const fullPrompt = buildReplPrompt(prompt, projectRoot, config);
7062
- const runner = await createRunnerAsync(config.ai.provider);
8178
+ const runner = config.sandbox.name ? createUserManagedSandboxRunner(config.ai.provider, config.sandbox.name) : await createRunnerAsync(config.ai.provider, config.sandbox.enabled);
7063
8179
  const available = await runner.isAvailable();
7064
8180
  if (!available) {
7065
8181
  stream.emitError(`${config.ai.provider} CLI not available`, false);
@@ -7107,7 +8223,7 @@ var init_exec = __esm(() => {
7107
8223
  });
7108
8224
 
7109
8225
  // src/core/agent.ts
7110
- import { execSync as execSync9 } from "node:child_process";
8226
+ import { execSync as execSync12 } from "node:child_process";
7111
8227
  async function executeIssue(projectRoot, options) {
7112
8228
  const log = getLogger();
7113
8229
  const timer = createTimer();
@@ -7136,7 +8252,7 @@ ${cyan("●")} ${bold(`#${issueNumber}`)} ${issue.title}
7136
8252
  }
7137
8253
  let issueComments = [];
7138
8254
  try {
7139
- const commentsRaw = execSync9(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8255
+ const commentsRaw = execSync12(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
7140
8256
  if (commentsRaw) {
7141
8257
  issueComments = commentsRaw.split(`
7142
8258
  `).filter(Boolean);
@@ -7171,7 +8287,9 @@ ${yellow("⚠")} ${bold("Dry run")} — would execute with:
7171
8287
  provider,
7172
8288
  model,
7173
8289
  cwd: options.worktreePath ?? projectRoot,
7174
- activity: `issue #${issueNumber}`
8290
+ activity: `issue #${issueNumber}`,
8291
+ sandboxed: options.sandboxed,
8292
+ sandboxName: options.sandboxName
7175
8293
  });
7176
8294
  const output = aiResult.output;
7177
8295
  if (aiResult.interrupted) {
@@ -7275,7 +8393,9 @@ ${c.body}`),
7275
8393
  provider: config.ai.provider,
7276
8394
  model: config.ai.model,
7277
8395
  cwd: projectRoot,
7278
- activity: `iterating on PR #${prNumber}`
8396
+ activity: `iterating on PR #${prNumber}`,
8397
+ sandboxed: config.sandbox.enabled,
8398
+ sandboxName: config.sandbox.name
7279
8399
  });
7280
8400
  if (aiResult.interrupted) {
7281
8401
  process.stderr.write(`
@@ -7296,12 +8416,12 @@ ${aiResult.success ? green("✓") : red("✗")} Iteration ${aiResult.success ? "
7296
8416
  }
7297
8417
  async function createIssuePR(projectRoot, config, issue) {
7298
8418
  try {
7299
- const currentBranch = execSync9("git rev-parse --abbrev-ref HEAD", {
8419
+ const currentBranch = execSync12("git rev-parse --abbrev-ref HEAD", {
7300
8420
  cwd: projectRoot,
7301
8421
  encoding: "utf-8",
7302
8422
  stdio: ["pipe", "pipe", "pipe"]
7303
8423
  }).trim();
7304
- const diff = execSync9(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8424
+ const diff = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
7305
8425
  cwd: projectRoot,
7306
8426
  encoding: "utf-8",
7307
8427
  stdio: ["pipe", "pipe", "pipe"]
@@ -7310,7 +8430,7 @@ async function createIssuePR(projectRoot, config, issue) {
7310
8430
  getLogger().verbose("No changes to create PR for");
7311
8431
  return;
7312
8432
  }
7313
- execSync9(`git push -u origin ${currentBranch}`, {
8433
+ execSync12(`git push -u origin ${currentBranch}`, {
7314
8434
  cwd: projectRoot,
7315
8435
  encoding: "utf-8",
7316
8436
  stdio: ["pipe", "pipe", "pipe"]
@@ -7356,9 +8476,9 @@ var init_agent = __esm(() => {
7356
8476
  });
7357
8477
 
7358
8478
  // src/core/conflict.ts
7359
- import { execSync as execSync10 } from "node:child_process";
8479
+ import { execSync as execSync13 } from "node:child_process";
7360
8480
  function git2(args, cwd) {
7361
- return execSync10(`git ${args}`, {
8481
+ return execSync13(`git ${args}`, {
7362
8482
  cwd,
7363
8483
  encoding: "utf-8",
7364
8484
  stdio: ["pipe", "pipe", "pipe"]
@@ -7483,185 +8603,144 @@ var init_conflict = __esm(() => {
7483
8603
  init_logger();
7484
8604
  });
7485
8605
 
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;
8606
+ // src/core/sandbox.ts
8607
+ import { execFile } from "node:child_process";
8608
+ async function detectSandboxSupport() {
8609
+ if (cachedStatus)
8610
+ return cachedStatus;
8611
+ const log = getLogger();
8612
+ log.debug("Detecting Docker sandbox support...");
8613
+ const status = await runDetection();
8614
+ cachedStatus = status;
8615
+ if (status.available) {
8616
+ log.verbose("Docker sandbox support detected");
8617
+ } else {
8618
+ log.verbose(`Docker sandbox not available: ${status.reason}`);
7571
8619
  }
8620
+ return status;
7572
8621
  }
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...
8622
+ function runDetection() {
8623
+ return new Promise((resolve2) => {
8624
+ let settled = false;
8625
+ const child = execFile("docker", ["sandbox", "ls"], { timeout: TIMEOUT_MS }, (error, _stdout, stderr) => {
8626
+ if (settled)
8627
+ return;
8628
+ settled = true;
8629
+ if (!error) {
8630
+ resolve2({ available: true });
8631
+ return;
8632
+ }
8633
+ const code = error.code;
8634
+ if (code === "ENOENT") {
8635
+ resolve2({ available: false, reason: "Docker is not installed" });
8636
+ return;
8637
+ }
8638
+ if (error.killed) {
8639
+ resolve2({ available: false, reason: "Docker is not responding" });
8640
+ return;
8641
+ }
8642
+ const stderrStr = (stderr ?? "").toLowerCase();
8643
+ if (stderrStr.includes("unknown") || stderrStr.includes("not a docker command") || stderrStr.includes("is not a docker command")) {
8644
+ resolve2({
8645
+ available: false,
8646
+ reason: "Docker Desktop 4.58+ with sandbox support required"
8647
+ });
8648
+ return;
8649
+ }
8650
+ resolve2({
8651
+ available: false,
8652
+ reason: "Docker Desktop 4.58+ with sandbox support required"
8653
+ });
8654
+ });
8655
+ child.on?.("error", (err) => {
8656
+ if (settled)
8657
+ return;
8658
+ settled = true;
8659
+ if (err.code === "ENOENT") {
8660
+ resolve2({ available: false, reason: "Docker is not installed" });
8661
+ } else {
8662
+ resolve2({
8663
+ available: false,
8664
+ reason: "Docker Desktop 4.58+ with sandbox support required"
8665
+ });
8666
+ }
8667
+ });
8668
+ });
8669
+ }
8670
+ async function cleanupStaleSandboxes() {
8671
+ const log = getLogger();
8672
+ try {
8673
+ const { stdout } = await execFileAsync("docker", ["sandbox", "ls"], {
8674
+ timeout: TIMEOUT_MS
8675
+ });
8676
+ const lines = stdout.trim().split(`
7608
8677
  `);
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
- }
8678
+ if (lines.length <= 1)
8679
+ return 0;
8680
+ const staleNames = [];
8681
+ for (const line of lines.slice(1)) {
8682
+ const name = line.trim().split(/\s+/)[0];
8683
+ if (name?.startsWith("locus-")) {
8684
+ staleNames.push(name);
7617
8685
  }
8686
+ }
8687
+ if (staleNames.length === 0)
8688
+ return 0;
8689
+ log.verbose(`Found ${staleNames.length} stale sandbox(es) to clean up`);
8690
+ let cleaned = 0;
8691
+ for (const name of staleNames) {
7618
8692
  try {
7619
- saveRunState(shutdownContext.projectRoot, state);
7620
- process.stderr.write(`State saved. Resume with: locus run --resume
7621
- `);
8693
+ await execFileAsync("docker", ["sandbox", "rm", name], {
8694
+ timeout: 1e4
8695
+ });
8696
+ log.debug(`Removed stale sandbox: ${name}`);
8697
+ cleaned++;
7622
8698
  } catch {
7623
- process.stderr.write(`Warning: Could not save run state.
7624
- `);
8699
+ log.debug(`Failed to remove stale sandbox: ${name}`);
7625
8700
  }
7626
8701
  }
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;
8702
+ return cleaned;
8703
+ } catch {
8704
+ return 0;
7641
8705
  }
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;
8706
+ }
8707
+ function execFileAsync(file, args, options) {
8708
+ return new Promise((resolve2, reject) => {
8709
+ execFile(file, args, options, (error, stdout, stderr) => {
8710
+ if (error)
8711
+ reject(error);
8712
+ else
8713
+ resolve2({ stdout: stdout ?? "", stderr: stderr ?? "" });
8714
+ });
8715
+ });
8716
+ }
8717
+ function resolveSandboxMode(config, flags) {
8718
+ if (flags.noSandbox) {
8719
+ return "disabled";
8720
+ }
8721
+ if (flags.sandbox !== undefined) {
8722
+ if (flags.sandbox === "require") {
8723
+ return "required";
7651
8724
  }
7652
- };
8725
+ throw new Error(`Invalid --sandbox value: "${flags.sandbox}". Valid values: require`);
8726
+ }
8727
+ if (!config.enabled) {
8728
+ return "disabled";
8729
+ }
8730
+ return "auto";
7653
8731
  }
7654
- var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null;
7655
- var init_shutdown = __esm(() => {
7656
- init_run_state();
8732
+ var TIMEOUT_MS = 5000, cachedStatus = null;
8733
+ var init_sandbox = __esm(() => {
8734
+ init_terminal();
8735
+ init_logger();
7657
8736
  });
7658
8737
 
7659
8738
  // 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";
8739
+ import { execSync as execSync14 } from "node:child_process";
8740
+ import { existsSync as existsSync16, readdirSync as readdirSync6, realpathSync, statSync as statSync3 } from "node:fs";
8741
+ import { join as join16 } from "node:path";
7663
8742
  function git3(args, cwd) {
7664
- return execSync11(`git ${args}`, {
8743
+ return execSync14(`git ${args}`, {
7665
8744
  cwd,
7666
8745
  encoding: "utf-8",
7667
8746
  stdio: ["pipe", "pipe", "pipe"]
@@ -7675,10 +8754,10 @@ function gitSafe2(args, cwd) {
7675
8754
  }
7676
8755
  }
7677
8756
  function getWorktreeDir(projectRoot) {
7678
- return join15(projectRoot, ".locus", "worktrees");
8757
+ return join16(projectRoot, ".locus", "worktrees");
7679
8758
  }
7680
8759
  function getWorktreePath(projectRoot, issueNumber) {
7681
- return join15(getWorktreeDir(projectRoot), `issue-${issueNumber}`);
8760
+ return join16(getWorktreeDir(projectRoot), `issue-${issueNumber}`);
7682
8761
  }
7683
8762
  function generateBranchName(issueNumber) {
7684
8763
  const randomSuffix = Math.random().toString(36).slice(2, 8);
@@ -7686,7 +8765,7 @@ function generateBranchName(issueNumber) {
7686
8765
  }
7687
8766
  function getWorktreeBranch(worktreePath) {
7688
8767
  try {
7689
- return execSync11("git branch --show-current", {
8768
+ return execSync14("git branch --show-current", {
7690
8769
  cwd: worktreePath,
7691
8770
  encoding: "utf-8",
7692
8771
  stdio: ["pipe", "pipe", "pipe"]
@@ -7698,7 +8777,7 @@ function getWorktreeBranch(worktreePath) {
7698
8777
  function createWorktree(projectRoot, issueNumber, baseBranch) {
7699
8778
  const log = getLogger();
7700
8779
  const worktreePath = getWorktreePath(projectRoot, issueNumber);
7701
- if (existsSync15(worktreePath)) {
8780
+ if (existsSync16(worktreePath)) {
7702
8781
  log.verbose(`Worktree already exists for issue #${issueNumber}`);
7703
8782
  const existingBranch = getWorktreeBranch(worktreePath) ?? `locus/issue-${issueNumber}`;
7704
8783
  return {
@@ -7725,7 +8804,7 @@ function createWorktree(projectRoot, issueNumber, baseBranch) {
7725
8804
  function removeWorktree(projectRoot, issueNumber) {
7726
8805
  const log = getLogger();
7727
8806
  const worktreePath = getWorktreePath(projectRoot, issueNumber);
7728
- if (!existsSync15(worktreePath)) {
8807
+ if (!existsSync16(worktreePath)) {
7729
8808
  log.verbose(`Worktree for issue #${issueNumber} does not exist`);
7730
8809
  return;
7731
8810
  }
@@ -7744,7 +8823,7 @@ function removeWorktree(projectRoot, issueNumber) {
7744
8823
  function listWorktrees(projectRoot) {
7745
8824
  const log = getLogger();
7746
8825
  const worktreeDir = getWorktreeDir(projectRoot);
7747
- if (!existsSync15(worktreeDir)) {
8826
+ if (!existsSync16(worktreeDir)) {
7748
8827
  return [];
7749
8828
  }
7750
8829
  const entries = readdirSync6(worktreeDir).filter((entry) => entry.startsWith("issue-"));
@@ -7764,7 +8843,7 @@ function listWorktrees(projectRoot) {
7764
8843
  if (!match)
7765
8844
  continue;
7766
8845
  const issueNumber = Number.parseInt(match[1], 10);
7767
- const path = join15(worktreeDir, entry);
8846
+ const path = join16(worktreeDir, entry);
7768
8847
  const branch = getWorktreeBranch(path) ?? `locus/issue-${issueNumber}`;
7769
8848
  let resolvedPath;
7770
8849
  try {
@@ -7811,8 +8890,44 @@ var exports_run = {};
7811
8890
  __export(exports_run, {
7812
8891
  runCommand: () => runCommand
7813
8892
  });
7814
- import { execSync as execSync12 } from "node:child_process";
8893
+ import { execSync as execSync15 } from "node:child_process";
8894
+ function printRunHelp() {
8895
+ process.stderr.write(`
8896
+ ${bold("locus run")} — Execute issues using AI agents
8897
+
8898
+ ${bold("Usage:")}
8899
+ locus run ${dim("# Run active sprint (sequential)")}
8900
+ locus run <issue> ${dim("# Run single issue (worktree)")}
8901
+ locus run <issue> <issue> ... ${dim("# Run multiple issues (parallel)")}
8902
+ locus run --resume ${dim("# Resume interrupted run")}
8903
+
8904
+ ${bold("Options:")}
8905
+ --resume Resume a previously interrupted run
8906
+ --dry-run Show what would happen without executing
8907
+ --model <name> Override the AI model for this run
8908
+ --no-sandbox Disable Docker sandbox isolation
8909
+ --sandbox=require Require Docker sandbox (fail if unavailable)
8910
+
8911
+ ${bold("Sandbox:")}
8912
+ By default, agents run inside Docker Desktop sandboxes (4.58+) for
8913
+ hypervisor-level isolation. If Docker is not available, agents run
8914
+ unsandboxed with a warning.
8915
+
8916
+ ${bold("Examples:")}
8917
+ locus run ${dim("# Execute active sprint")}
8918
+ locus run 42 ${dim("# Run single issue")}
8919
+ locus run 42 43 44 ${dim("# Run issues in parallel")}
8920
+ locus run --resume ${dim("# Resume after failure")}
8921
+ locus run 42 --no-sandbox ${dim("# Run without sandbox")}
8922
+ locus run 42 --sandbox=require ${dim("# Require sandbox")}
8923
+
8924
+ `);
8925
+ }
7815
8926
  async function runCommand(projectRoot, args, flags = {}) {
8927
+ if (args[0] === "help") {
8928
+ printRunHelp();
8929
+ return;
8930
+ }
7816
8931
  const config = loadConfig(projectRoot);
7817
8932
  const _log = getLogger();
7818
8933
  const cleanupShutdown = registerShutdownHandlers({
@@ -7820,22 +8935,50 @@ async function runCommand(projectRoot, args, flags = {}) {
7820
8935
  getRunState: () => loadRunState(projectRoot)
7821
8936
  });
7822
8937
  try {
8938
+ const sandboxMode = resolveSandboxMode(config.sandbox, flags);
8939
+ let sandboxed = false;
8940
+ if (sandboxMode !== "disabled") {
8941
+ const status = await detectSandboxSupport();
8942
+ if (!status.available) {
8943
+ if (sandboxMode === "required") {
8944
+ process.stderr.write(`${red("✗")} Docker sandbox required but not available: ${status.reason}
8945
+ `);
8946
+ process.stderr.write(` Install Docker Desktop 4.58+ or remove --sandbox=require to continue.
8947
+ `);
8948
+ process.exit(1);
8949
+ }
8950
+ process.stderr.write(`${yellow("⚠")} Docker sandbox not available: ${status.reason}. Running unsandboxed.
8951
+ `);
8952
+ } else {
8953
+ sandboxed = true;
8954
+ }
8955
+ } else if (flags.noSandbox) {
8956
+ process.stderr.write(`${yellow("⚠")} Running without sandbox. The AI agent will have unrestricted access to your filesystem, network, and environment variables.
8957
+ `);
8958
+ }
8959
+ if (sandboxed) {
8960
+ const staleCleaned = await cleanupStaleSandboxes();
8961
+ if (staleCleaned > 0) {
8962
+ process.stderr.write(` ${dim(`Cleaned up ${staleCleaned} stale sandbox${staleCleaned === 1 ? "" : "es"}.`)}
8963
+ `);
8964
+ }
8965
+ }
7823
8966
  if (flags.resume) {
7824
- return handleResume(projectRoot, config);
8967
+ return handleResume(projectRoot, config, sandboxed);
7825
8968
  }
7826
8969
  const issueNumbers = args.filter((a) => /^\d+$/.test(a)).map(Number);
7827
8970
  if (issueNumbers.length === 0) {
7828
- return handleSprintRun(projectRoot, config, flags);
8971
+ return handleSprintRun(projectRoot, config, flags, sandboxed);
7829
8972
  }
7830
8973
  if (issueNumbers.length === 1) {
7831
- return handleSingleIssue(projectRoot, config, issueNumbers[0], flags);
8974
+ return handleSingleIssue(projectRoot, config, issueNumbers[0], flags, sandboxed);
7832
8975
  }
7833
- return handleParallelRun(projectRoot, config, issueNumbers, flags);
8976
+ return handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed);
7834
8977
  } finally {
7835
8978
  cleanupShutdown();
7836
8979
  }
7837
8980
  }
7838
- async function handleSprintRun(projectRoot, config, flags) {
8981
+ async function handleSprintRun(projectRoot, config, flags, sandboxed) {
7839
8982
  const log = getLogger();
7840
8983
  if (!config.sprint.active) {
7841
8984
  process.stderr.write(`${red("✗")} No active sprint. Set one with: ${bold("locus sprint active <name>")}
@@ -7898,7 +9041,7 @@ ${yellow("⚠")} A sprint run is already in progress.
7898
9041
  }
7899
9042
  if (!flags.dryRun) {
7900
9043
  try {
7901
- execSync12(`git checkout -B ${branchName}`, {
9044
+ execSync15(`git checkout -B ${branchName}`, {
7902
9045
  cwd: projectRoot,
7903
9046
  encoding: "utf-8",
7904
9047
  stdio: ["pipe", "pipe", "pipe"]
@@ -7948,7 +9091,7 @@ ${red("✗")} Auto-rebase failed. Resolve manually.
7948
9091
  let sprintContext;
7949
9092
  if (i > 0 && !flags.dryRun) {
7950
9093
  try {
7951
- sprintContext = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9094
+ sprintContext = execSync15(`git diff origin/${config.agent.baseBranch}..HEAD`, {
7952
9095
  cwd: projectRoot,
7953
9096
  encoding: "utf-8",
7954
9097
  stdio: ["pipe", "pipe", "pipe"]
@@ -7967,7 +9110,9 @@ ${progressBar(i, state.tasks.length, { label: "Sprint Progress" })}
7967
9110
  model: flags.model ?? config.ai.model,
7968
9111
  dryRun: flags.dryRun,
7969
9112
  sprintContext,
7970
- skipPR: true
9113
+ skipPR: true,
9114
+ sandboxed,
9115
+ sandboxName: config.sandbox.name
7971
9116
  });
7972
9117
  if (result.success) {
7973
9118
  if (!flags.dryRun) {
@@ -8007,7 +9152,7 @@ ${bold("Summary:")}
8007
9152
  const prNumber = await createSprintPR(projectRoot, config, sprintName, branchName, completedTasks);
8008
9153
  if (prNumber !== undefined) {
8009
9154
  try {
8010
- execSync12(`git checkout ${config.agent.baseBranch}`, {
9155
+ execSync15(`git checkout ${config.agent.baseBranch}`, {
8011
9156
  cwd: projectRoot,
8012
9157
  encoding: "utf-8",
8013
9158
  stdio: ["pipe", "pipe", "pipe"]
@@ -8021,7 +9166,7 @@ ${bold("Summary:")}
8021
9166
  clearRunState(projectRoot);
8022
9167
  }
8023
9168
  }
8024
- async function handleSingleIssue(projectRoot, config, issueNumber, flags) {
9169
+ async function handleSingleIssue(projectRoot, config, issueNumber, flags, sandboxed) {
8025
9170
  let isSprintIssue = false;
8026
9171
  try {
8027
9172
  const issue = getIssue(issueNumber, { cwd: projectRoot });
@@ -8036,7 +9181,9 @@ ${bold("Running sprint issue")} ${cyan(`#${issueNumber}`)} ${dim("(sequential, n
8036
9181
  issueNumber,
8037
9182
  provider: config.ai.provider,
8038
9183
  model: flags.model ?? config.ai.model,
8039
- dryRun: flags.dryRun
9184
+ dryRun: flags.dryRun,
9185
+ sandboxed,
9186
+ sandboxName: config.sandbox.name
8040
9187
  });
8041
9188
  return;
8042
9189
  }
@@ -8065,7 +9212,9 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
8065
9212
  worktreePath,
8066
9213
  provider: config.ai.provider,
8067
9214
  model: flags.model ?? config.ai.model,
8068
- dryRun: flags.dryRun
9215
+ dryRun: flags.dryRun,
9216
+ sandboxed,
9217
+ sandboxName: config.sandbox.name
8069
9218
  });
8070
9219
  if (worktreePath && !flags.dryRun) {
8071
9220
  if (result.success) {
@@ -8078,7 +9227,7 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
8078
9227
  }
8079
9228
  }
8080
9229
  }
8081
- async function handleParallelRun(projectRoot, config, issueNumbers, flags) {
9230
+ async function handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed) {
8082
9231
  const log = getLogger();
8083
9232
  const maxConcurrent = config.agent.maxParallel;
8084
9233
  process.stderr.write(`
@@ -8133,7 +9282,9 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
8133
9282
  worktreePath,
8134
9283
  provider: config.ai.provider,
8135
9284
  model: flags.model ?? config.ai.model,
8136
- dryRun: flags.dryRun
9285
+ dryRun: flags.dryRun,
9286
+ sandboxed,
9287
+ sandboxName: config.sandbox.name
8137
9288
  });
8138
9289
  if (result.success) {
8139
9290
  markTaskDone(state, issueNumber, result.prNumber);
@@ -8145,9 +9296,19 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
8145
9296
  markTaskFailed(state, issueNumber, result.error ?? "Unknown error");
8146
9297
  }
8147
9298
  saveRunState(projectRoot, state);
8148
- results.push({ issue: issueNumber, success: result.success });
9299
+ return { issue: issueNumber, success: result.success };
8149
9300
  });
8150
- await Promise.all(promises);
9301
+ const settled = await Promise.allSettled(promises);
9302
+ for (const outcome of settled) {
9303
+ if (outcome.status === "fulfilled") {
9304
+ results.push(outcome.value);
9305
+ } else {
9306
+ const idx = settled.indexOf(outcome);
9307
+ const issueNumber = batch[idx];
9308
+ log.warn(`Parallel task #${issueNumber} threw: ${outcome.reason}`);
9309
+ results.push({ issue: issueNumber, success: false });
9310
+ }
9311
+ }
8151
9312
  }
8152
9313
  const succeeded = results.filter((r) => r.success).length;
8153
9314
  const failed = results.filter((r) => !r.success).length;
@@ -8172,7 +9333,7 @@ ${yellow("⚠")} Failed worktrees preserved for debugging:
8172
9333
  clearRunState(projectRoot);
8173
9334
  }
8174
9335
  }
8175
- async function handleResume(projectRoot, config) {
9336
+ async function handleResume(projectRoot, config, sandboxed) {
8176
9337
  const state = loadRunState(projectRoot);
8177
9338
  if (!state) {
8178
9339
  process.stderr.write(`${red("✗")} No run state found. Nothing to resume.
@@ -8188,13 +9349,13 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
8188
9349
  `);
8189
9350
  if (state.type === "sprint" && state.branch) {
8190
9351
  try {
8191
- const currentBranch = execSync12("git rev-parse --abbrev-ref HEAD", {
9352
+ const currentBranch = execSync15("git rev-parse --abbrev-ref HEAD", {
8192
9353
  cwd: projectRoot,
8193
9354
  encoding: "utf-8",
8194
9355
  stdio: ["pipe", "pipe", "pipe"]
8195
9356
  }).trim();
8196
9357
  if (currentBranch !== state.branch) {
8197
- execSync12(`git checkout ${state.branch}`, {
9358
+ execSync15(`git checkout ${state.branch}`, {
8198
9359
  cwd: projectRoot,
8199
9360
  encoding: "utf-8",
8200
9361
  stdio: ["pipe", "pipe", "pipe"]
@@ -8220,7 +9381,9 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
8220
9381
  issueNumber: task.issue,
8221
9382
  provider: config.ai.provider,
8222
9383
  model: config.ai.model,
8223
- skipPR: isSprintRun
9384
+ skipPR: isSprintRun,
9385
+ sandboxed,
9386
+ sandboxName: config.sandbox.name
8224
9387
  });
8225
9388
  if (result.success) {
8226
9389
  if (isSprintRun) {
@@ -8255,7 +9418,7 @@ ${bold("Resume complete:")} ${green(`✓ ${finalStats.done}`)} ${finalStats.fail
8255
9418
  const prNumber = await createSprintPR(projectRoot, config, state.sprint, state.branch, completedTasks);
8256
9419
  if (prNumber !== undefined) {
8257
9420
  try {
8258
- execSync12(`git checkout ${config.agent.baseBranch}`, {
9421
+ execSync15(`git checkout ${config.agent.baseBranch}`, {
8259
9422
  cwd: projectRoot,
8260
9423
  encoding: "utf-8",
8261
9424
  stdio: ["pipe", "pipe", "pipe"]
@@ -8286,14 +9449,14 @@ function getOrder2(issue) {
8286
9449
  }
8287
9450
  function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
8288
9451
  try {
8289
- const status = execSync12("git status --porcelain", {
9452
+ const status = execSync15("git status --porcelain", {
8290
9453
  cwd: projectRoot,
8291
9454
  encoding: "utf-8",
8292
9455
  stdio: ["pipe", "pipe", "pipe"]
8293
9456
  }).trim();
8294
9457
  if (!status)
8295
9458
  return;
8296
- execSync12("git add -A", {
9459
+ execSync15("git add -A", {
8297
9460
  cwd: projectRoot,
8298
9461
  encoding: "utf-8",
8299
9462
  stdio: ["pipe", "pipe", "pipe"]
@@ -8301,7 +9464,7 @@ function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
8301
9464
  const message = `chore: complete #${issueNumber} - ${issueTitle}
8302
9465
 
8303
9466
  Co-Authored-By: LocusAgent <agent@locusai.team>`;
8304
- execSync12(`git commit -F -`, {
9467
+ execSync15(`git commit -F -`, {
8305
9468
  input: message,
8306
9469
  cwd: projectRoot,
8307
9470
  encoding: "utf-8",
@@ -8315,7 +9478,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8315
9478
  if (!config.agent.autoPR)
8316
9479
  return;
8317
9480
  try {
8318
- const diff = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9481
+ const diff = execSync15(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8319
9482
  cwd: projectRoot,
8320
9483
  encoding: "utf-8",
8321
9484
  stdio: ["pipe", "pipe", "pipe"]
@@ -8325,7 +9488,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8325
9488
  `);
8326
9489
  return;
8327
9490
  }
8328
- execSync12(`git push -u origin ${branchName}`, {
9491
+ execSync15(`git push -u origin ${branchName}`, {
8329
9492
  cwd: projectRoot,
8330
9493
  encoding: "utf-8",
8331
9494
  stdio: ["pipe", "pipe", "pipe"]
@@ -8358,6 +9521,7 @@ var init_run = __esm(() => {
8358
9521
  init_logger();
8359
9522
  init_rate_limiter();
8360
9523
  init_run_state();
9524
+ init_sandbox();
8361
9525
  init_shutdown();
8362
9526
  init_worktree();
8363
9527
  init_progress();
@@ -8470,13 +9634,13 @@ __export(exports_plan, {
8470
9634
  parsePlanArgs: () => parsePlanArgs
8471
9635
  });
8472
9636
  import {
8473
- existsSync as existsSync16,
9637
+ existsSync as existsSync17,
8474
9638
  mkdirSync as mkdirSync11,
8475
9639
  readdirSync as readdirSync7,
8476
- readFileSync as readFileSync12,
9640
+ readFileSync as readFileSync13,
8477
9641
  writeFileSync as writeFileSync9
8478
9642
  } from "node:fs";
8479
- import { join as join16 } from "node:path";
9643
+ import { join as join17 } from "node:path";
8480
9644
  function printHelp() {
8481
9645
  process.stderr.write(`
8482
9646
  ${bold("locus plan")} — AI-powered sprint planning
@@ -8507,28 +9671,28 @@ function normalizeSprintName(name) {
8507
9671
  return name.trim().toLowerCase();
8508
9672
  }
8509
9673
  function getPlansDir(projectRoot) {
8510
- return join16(projectRoot, ".locus", "plans");
9674
+ return join17(projectRoot, ".locus", "plans");
8511
9675
  }
8512
9676
  function ensurePlansDir(projectRoot) {
8513
9677
  const dir = getPlansDir(projectRoot);
8514
- if (!existsSync16(dir)) {
9678
+ if (!existsSync17(dir)) {
8515
9679
  mkdirSync11(dir, { recursive: true });
8516
9680
  }
8517
9681
  return dir;
8518
9682
  }
8519
9683
  function generateId() {
8520
- return `${Math.random().toString(36).slice(2, 8)}`;
9684
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8521
9685
  }
8522
9686
  function loadPlanFile(projectRoot, id) {
8523
9687
  const dir = getPlansDir(projectRoot);
8524
- if (!existsSync16(dir))
9688
+ if (!existsSync17(dir))
8525
9689
  return null;
8526
9690
  const files = readdirSync7(dir).filter((f) => f.endsWith(".json"));
8527
9691
  const match = files.find((f) => f.startsWith(id));
8528
9692
  if (!match)
8529
9693
  return null;
8530
9694
  try {
8531
- const content = readFileSync12(join16(dir, match), "utf-8");
9695
+ const content = readFileSync13(join17(dir, match), "utf-8");
8532
9696
  return JSON.parse(content);
8533
9697
  } catch {
8534
9698
  return null;
@@ -8574,7 +9738,7 @@ async function planCommand(projectRoot, args, flags = {}) {
8574
9738
  }
8575
9739
  function handleListPlans(projectRoot) {
8576
9740
  const dir = getPlansDir(projectRoot);
8577
- if (!existsSync16(dir)) {
9741
+ if (!existsSync17(dir)) {
8578
9742
  process.stderr.write(`${dim("No saved plans yet.")}
8579
9743
  `);
8580
9744
  return;
@@ -8592,7 +9756,7 @@ ${bold("Saved Plans:")}
8592
9756
  for (const file of files) {
8593
9757
  const id = file.replace(".json", "");
8594
9758
  try {
8595
- const content = readFileSync12(join16(dir, file), "utf-8");
9759
+ const content = readFileSync13(join17(dir, file), "utf-8");
8596
9760
  const plan = JSON.parse(content);
8597
9761
  const date = plan.createdAt ? plan.createdAt.slice(0, 10) : "";
8598
9762
  const issueCount = Array.isArray(plan.issues) ? plan.issues.length : 0;
@@ -8703,7 +9867,7 @@ ${bold("Approving plan:")}
8703
9867
  async function handleAIPlan(projectRoot, config, directive, sprintName, flags) {
8704
9868
  const id = generateId();
8705
9869
  const plansDir = ensurePlansDir(projectRoot);
8706
- const planPath = join16(plansDir, `${id}.json`);
9870
+ const planPath = join17(plansDir, `${id}.json`);
8707
9871
  const planPathRelative = `.locus/plans/${id}.json`;
8708
9872
  const displayDirective = directive;
8709
9873
  process.stderr.write(`
@@ -8721,7 +9885,9 @@ ${bold("Planning:")} ${cyan(displayDirective)}
8721
9885
  provider: config.ai.provider,
8722
9886
  model: flags.model ?? config.ai.model,
8723
9887
  cwd: projectRoot,
8724
- activity: "planning"
9888
+ activity: "planning",
9889
+ sandboxed: config.sandbox.enabled,
9890
+ sandboxName: config.sandbox.name
8725
9891
  });
8726
9892
  if (aiResult.interrupted) {
8727
9893
  process.stderr.write(`
@@ -8735,7 +9901,7 @@ ${red("✗")} Planning failed: ${aiResult.error}
8735
9901
  `);
8736
9902
  return;
8737
9903
  }
8738
- if (!existsSync16(planPath)) {
9904
+ if (!existsSync17(planPath)) {
8739
9905
  process.stderr.write(`
8740
9906
  ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8741
9907
  `);
@@ -8745,7 +9911,7 @@ ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8745
9911
  }
8746
9912
  let plan;
8747
9913
  try {
8748
- const content = readFileSync12(planPath, "utf-8");
9914
+ const content = readFileSync13(planPath, "utf-8");
8749
9915
  plan = JSON.parse(content);
8750
9916
  } catch {
8751
9917
  process.stderr.write(`
@@ -8829,7 +9995,9 @@ Start with foundational/setup tasks, then core features, then integration/testin
8829
9995
  model: flags.model ?? config.ai.model,
8830
9996
  cwd: projectRoot,
8831
9997
  activity: "issue ordering",
8832
- silent: true
9998
+ silent: true,
9999
+ sandboxed: config.sandbox.enabled,
10000
+ sandboxName: config.sandbox.name
8833
10001
  });
8834
10002
  if (aiResult.interrupted) {
8835
10003
  process.stderr.write(`
@@ -8900,16 +10068,16 @@ function buildPlanningPrompt(projectRoot, config, directive, sprintName, id, pla
8900
10068
  parts.push(`SPRINT: ${sprintName}`);
8901
10069
  }
8902
10070
  parts.push("");
8903
- const locusPath = join16(projectRoot, "LOCUS.md");
8904
- if (existsSync16(locusPath)) {
8905
- const content = readFileSync12(locusPath, "utf-8");
10071
+ const locusPath = join17(projectRoot, "LOCUS.md");
10072
+ if (existsSync17(locusPath)) {
10073
+ const content = readFileSync13(locusPath, "utf-8");
8906
10074
  parts.push("PROJECT CONTEXT (LOCUS.md):");
8907
10075
  parts.push(content.slice(0, 3000));
8908
10076
  parts.push("");
8909
10077
  }
8910
- const learningsPath = join16(projectRoot, ".locus", "LEARNINGS.md");
8911
- if (existsSync16(learningsPath)) {
8912
- const content = readFileSync12(learningsPath, "utf-8");
10078
+ const learningsPath = join17(projectRoot, ".locus", "LEARNINGS.md");
10079
+ if (existsSync17(learningsPath)) {
10080
+ const content = readFileSync13(learningsPath, "utf-8");
8913
10081
  parts.push("PAST LEARNINGS:");
8914
10082
  parts.push(content.slice(0, 2000));
8915
10083
  parts.push("");
@@ -9085,9 +10253,9 @@ var exports_review = {};
9085
10253
  __export(exports_review, {
9086
10254
  reviewCommand: () => reviewCommand
9087
10255
  });
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";
10256
+ import { execSync as execSync16 } from "node:child_process";
10257
+ import { existsSync as existsSync18, readFileSync as readFileSync14 } from "node:fs";
10258
+ import { join as join18 } from "node:path";
9091
10259
  function printHelp2() {
9092
10260
  process.stderr.write(`
9093
10261
  ${bold("locus review")} — AI-powered code review
@@ -9163,7 +10331,7 @@ ${bold("Review complete:")} ${green(`✓ ${reviewed}`)}${failed > 0 ? ` ${red(`
9163
10331
  async function reviewSinglePR(projectRoot, config, prNumber, focus, flags) {
9164
10332
  let prInfo;
9165
10333
  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"] });
10334
+ const result = execSync16(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9167
10335
  const raw = JSON.parse(result);
9168
10336
  prInfo = {
9169
10337
  number: raw.number,
@@ -9206,7 +10374,9 @@ async function reviewPR(projectRoot, config, pr, focus, flags) {
9206
10374
  provider: config.ai.provider,
9207
10375
  model: flags.model ?? config.ai.model,
9208
10376
  cwd: projectRoot,
9209
- activity: `PR #${pr.number}`
10377
+ activity: `PR #${pr.number}`,
10378
+ sandboxed: config.sandbox.enabled,
10379
+ sandboxName: config.sandbox.name
9210
10380
  });
9211
10381
  if (aiResult.interrupted) {
9212
10382
  process.stderr.write(` ${yellow("⚡")} Review interrupted.
@@ -9227,7 +10397,7 @@ ${output.slice(0, 60000)}
9227
10397
 
9228
10398
  ---
9229
10399
  _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"] });
10400
+ execSync16(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9231
10401
  process.stderr.write(` ${green("✓")} Review posted ${dim(`(${timer.formatted()})`)}
9232
10402
  `);
9233
10403
  } catch (e) {
@@ -9244,9 +10414,9 @@ function buildReviewPrompt(projectRoot, config, pr, diff, focus) {
9244
10414
  const parts = [];
9245
10415
  parts.push(`You are an expert code reviewer for the ${config.github.owner}/${config.github.repo} repository.`);
9246
10416
  parts.push("");
9247
- const locusPath = join17(projectRoot, "LOCUS.md");
9248
- if (existsSync17(locusPath)) {
9249
- const content = readFileSync13(locusPath, "utf-8");
10417
+ const locusPath = join18(projectRoot, "LOCUS.md");
10418
+ if (existsSync18(locusPath)) {
10419
+ const content = readFileSync14(locusPath, "utf-8");
9250
10420
  parts.push("PROJECT CONTEXT:");
9251
10421
  parts.push(content.slice(0, 2000));
9252
10422
  parts.push("");
@@ -9298,7 +10468,7 @@ var exports_iterate = {};
9298
10468
  __export(exports_iterate, {
9299
10469
  iterateCommand: () => iterateCommand
9300
10470
  });
9301
- import { execSync as execSync14 } from "node:child_process";
10471
+ import { execSync as execSync17 } from "node:child_process";
9302
10472
  function printHelp3() {
9303
10473
  process.stderr.write(`
9304
10474
  ${bold("locus iterate")} — Re-execute tasks with PR feedback
@@ -9508,12 +10678,12 @@ ${bold("Summary:")} ${green(`✓ ${succeeded}`)}${failed > 0 ? ` ${red(`✗ ${fa
9508
10678
  }
9509
10679
  function findPRForIssue(projectRoot, issueNumber) {
9510
10680
  try {
9511
- const result = execSync14(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10681
+ const result = execSync17(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9512
10682
  const parsed = JSON.parse(result);
9513
10683
  if (parsed.length > 0) {
9514
10684
  return parsed[0].number;
9515
10685
  }
9516
- const branchResult = execSync14(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10686
+ const branchResult = execSync17(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
9517
10687
  const branchParsed = JSON.parse(branchResult);
9518
10688
  if (branchParsed.length > 0) {
9519
10689
  return branchParsed[0].number;
@@ -9548,14 +10718,14 @@ __export(exports_discuss, {
9548
10718
  discussCommand: () => discussCommand
9549
10719
  });
9550
10720
  import {
9551
- existsSync as existsSync18,
10721
+ existsSync as existsSync19,
9552
10722
  mkdirSync as mkdirSync12,
9553
10723
  readdirSync as readdirSync8,
9554
- readFileSync as readFileSync14,
10724
+ readFileSync as readFileSync15,
9555
10725
  unlinkSync as unlinkSync5,
9556
10726
  writeFileSync as writeFileSync10
9557
10727
  } from "node:fs";
9558
- import { join as join18 } from "node:path";
10728
+ import { join as join19 } from "node:path";
9559
10729
  function printHelp4() {
9560
10730
  process.stderr.write(`
9561
10731
  ${bold("locus discuss")} — AI-powered architectural discussions
@@ -9577,11 +10747,11 @@ ${bold("Examples:")}
9577
10747
  `);
9578
10748
  }
9579
10749
  function getDiscussionsDir(projectRoot) {
9580
- return join18(projectRoot, ".locus", "discussions");
10750
+ return join19(projectRoot, ".locus", "discussions");
9581
10751
  }
9582
10752
  function ensureDiscussionsDir(projectRoot) {
9583
10753
  const dir = getDiscussionsDir(projectRoot);
9584
- if (!existsSync18(dir)) {
10754
+ if (!existsSync19(dir)) {
9585
10755
  mkdirSync12(dir, { recursive: true });
9586
10756
  }
9587
10757
  return dir;
@@ -9616,7 +10786,7 @@ async function discussCommand(projectRoot, args, flags = {}) {
9616
10786
  }
9617
10787
  function listDiscussions(projectRoot) {
9618
10788
  const dir = getDiscussionsDir(projectRoot);
9619
- if (!existsSync18(dir)) {
10789
+ if (!existsSync19(dir)) {
9620
10790
  process.stderr.write(`${dim("No discussions yet.")}
9621
10791
  `);
9622
10792
  return;
@@ -9633,7 +10803,7 @@ ${bold("Discussions:")}
9633
10803
  `);
9634
10804
  for (const file of files) {
9635
10805
  const id = file.replace(".md", "");
9636
- const content = readFileSync14(join18(dir, file), "utf-8");
10806
+ const content = readFileSync15(join19(dir, file), "utf-8");
9637
10807
  const titleMatch = content.match(/^#\s+(.+)/m);
9638
10808
  const title = titleMatch ? titleMatch[1] : id;
9639
10809
  const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
@@ -9651,7 +10821,7 @@ function showDiscussion(projectRoot, id) {
9651
10821
  return;
9652
10822
  }
9653
10823
  const dir = getDiscussionsDir(projectRoot);
9654
- if (!existsSync18(dir)) {
10824
+ if (!existsSync19(dir)) {
9655
10825
  process.stderr.write(`${red("✗")} No discussions found.
9656
10826
  `);
9657
10827
  return;
@@ -9663,7 +10833,7 @@ function showDiscussion(projectRoot, id) {
9663
10833
  `);
9664
10834
  return;
9665
10835
  }
9666
- const content = readFileSync14(join18(dir, match), "utf-8");
10836
+ const content = readFileSync15(join19(dir, match), "utf-8");
9667
10837
  process.stdout.write(`${content}
9668
10838
  `);
9669
10839
  }
@@ -9674,7 +10844,7 @@ function deleteDiscussion(projectRoot, id) {
9674
10844
  return;
9675
10845
  }
9676
10846
  const dir = getDiscussionsDir(projectRoot);
9677
- if (!existsSync18(dir)) {
10847
+ if (!existsSync19(dir)) {
9678
10848
  process.stderr.write(`${red("✗")} No discussions found.
9679
10849
  `);
9680
10850
  return;
@@ -9686,7 +10856,7 @@ function deleteDiscussion(projectRoot, id) {
9686
10856
  `);
9687
10857
  return;
9688
10858
  }
9689
- unlinkSync5(join18(dir, match));
10859
+ unlinkSync5(join19(dir, match));
9690
10860
  process.stderr.write(`${green("✓")} Deleted discussion: ${match.replace(".md", "")}
9691
10861
  `);
9692
10862
  }
@@ -9699,7 +10869,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9699
10869
  return;
9700
10870
  }
9701
10871
  const dir = getDiscussionsDir(projectRoot);
9702
- if (!existsSync18(dir)) {
10872
+ if (!existsSync19(dir)) {
9703
10873
  process.stderr.write(`${red("✗")} No discussions found.
9704
10874
  `);
9705
10875
  return;
@@ -9711,7 +10881,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9711
10881
  `);
9712
10882
  return;
9713
10883
  }
9714
- const content = readFileSync14(join18(dir, match), "utf-8");
10884
+ const content = readFileSync15(join19(dir, match), "utf-8");
9715
10885
  const titleMatch = content.match(/^#\s+(.+)/m);
9716
10886
  const discussionTitle = titleMatch ? titleMatch[1].trim() : id;
9717
10887
  await planCommand(projectRoot, [
@@ -9756,7 +10926,9 @@ ${bold("Discussion:")} ${cyan(topic)}
9756
10926
  provider: config.ai.provider,
9757
10927
  model: flags.model ?? config.ai.model,
9758
10928
  cwd: projectRoot,
9759
- activity: "discussion"
10929
+ activity: "discussion",
10930
+ sandboxed: config.sandbox.enabled,
10931
+ sandboxName: config.sandbox.name
9760
10932
  });
9761
10933
  if (aiResult.interrupted) {
9762
10934
  process.stderr.write(`
@@ -9823,7 +10995,7 @@ ${turn.content}`;
9823
10995
  ...conversation.length > 1 ? [`---`, ``, `## Discussion Transcript`, ``, transcript, ``] : []
9824
10996
  ].join(`
9825
10997
  `);
9826
- writeFileSync10(join18(dir, `${id}.md`), markdown, "utf-8");
10998
+ writeFileSync10(join19(dir, `${id}.md`), markdown, "utf-8");
9827
10999
  process.stderr.write(`
9828
11000
  ${green("✓")} Discussion saved: ${cyan(id)} ${dim(`(${timer.formatted()})`)}
9829
11001
  `);
@@ -9837,16 +11009,16 @@ function buildDiscussionPrompt(projectRoot, config, topic, conversation, forceFi
9837
11009
  const parts = [];
9838
11010
  parts.push(`You are a senior software architect and consultant for the ${config.github.owner}/${config.github.repo} project.`);
9839
11011
  parts.push("");
9840
- const locusPath = join18(projectRoot, "LOCUS.md");
9841
- if (existsSync18(locusPath)) {
9842
- const content = readFileSync14(locusPath, "utf-8");
11012
+ const locusPath = join19(projectRoot, "LOCUS.md");
11013
+ if (existsSync19(locusPath)) {
11014
+ const content = readFileSync15(locusPath, "utf-8");
9843
11015
  parts.push("PROJECT CONTEXT:");
9844
11016
  parts.push(content.slice(0, 3000));
9845
11017
  parts.push("");
9846
11018
  }
9847
- const learningsPath = join18(projectRoot, ".locus", "LEARNINGS.md");
9848
- if (existsSync18(learningsPath)) {
9849
- const content = readFileSync14(learningsPath, "utf-8");
11019
+ const learningsPath = join19(projectRoot, ".locus", "LEARNINGS.md");
11020
+ if (existsSync19(learningsPath)) {
11021
+ const content = readFileSync15(learningsPath, "utf-8");
9850
11022
  parts.push("PAST LEARNINGS:");
9851
11023
  parts.push(content.slice(0, 2000));
9852
11024
  parts.push("");
@@ -9905,8 +11077,8 @@ __export(exports_artifacts, {
9905
11077
  formatDate: () => formatDate2,
9906
11078
  artifactsCommand: () => artifactsCommand
9907
11079
  });
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";
11080
+ import { existsSync as existsSync20, readdirSync as readdirSync9, readFileSync as readFileSync16, statSync as statSync4 } from "node:fs";
11081
+ import { join as join20 } from "node:path";
9910
11082
  function printHelp5() {
9911
11083
  process.stderr.write(`
9912
11084
  ${bold("locus artifacts")} — View and manage AI-generated artifacts
@@ -9926,14 +11098,14 @@ ${dim("Artifact names support partial matching.")}
9926
11098
  `);
9927
11099
  }
9928
11100
  function getArtifactsDir(projectRoot) {
9929
- return join19(projectRoot, ".locus", "artifacts");
11101
+ return join20(projectRoot, ".locus", "artifacts");
9930
11102
  }
9931
11103
  function listArtifacts(projectRoot) {
9932
11104
  const dir = getArtifactsDir(projectRoot);
9933
- if (!existsSync19(dir))
11105
+ if (!existsSync20(dir))
9934
11106
  return [];
9935
11107
  return readdirSync9(dir).filter((f) => f.endsWith(".md")).map((fileName) => {
9936
- const filePath = join19(dir, fileName);
11108
+ const filePath = join20(dir, fileName);
9937
11109
  const stat = statSync4(filePath);
9938
11110
  return {
9939
11111
  name: fileName.replace(/\.md$/, ""),
@@ -9946,12 +11118,12 @@ function listArtifacts(projectRoot) {
9946
11118
  function readArtifact(projectRoot, name) {
9947
11119
  const dir = getArtifactsDir(projectRoot);
9948
11120
  const fileName = name.endsWith(".md") ? name : `${name}.md`;
9949
- const filePath = join19(dir, fileName);
9950
- if (!existsSync19(filePath))
11121
+ const filePath = join20(dir, fileName);
11122
+ if (!existsSync20(filePath))
9951
11123
  return null;
9952
11124
  const stat = statSync4(filePath);
9953
11125
  return {
9954
- content: readFileSync15(filePath, "utf-8"),
11126
+ content: readFileSync16(filePath, "utf-8"),
9955
11127
  info: {
9956
11128
  name: fileName.replace(/\.md$/, ""),
9957
11129
  fileName,
@@ -10109,23 +11281,276 @@ var init_artifacts = __esm(() => {
10109
11281
  init_terminal();
10110
11282
  });
10111
11283
 
11284
+ // src/commands/sandbox.ts
11285
+ var exports_sandbox = {};
11286
+ __export(exports_sandbox, {
11287
+ sandboxCommand: () => sandboxCommand
11288
+ });
11289
+ import { execSync as execSync18, spawn as spawn6 } from "node:child_process";
11290
+ function printSandboxHelp() {
11291
+ process.stderr.write(`
11292
+ ${bold("locus sandbox")} — Manage Docker sandbox lifecycle
11293
+
11294
+ ${bold("Usage:")}
11295
+ locus sandbox ${dim("# Create sandbox and enable sandbox mode")}
11296
+ locus sandbox claude ${dim("# Run claude interactively (for login)")}
11297
+ locus sandbox codex ${dim("# Run codex interactively (for login)")}
11298
+ locus sandbox rm ${dim("# Destroy sandbox and disable sandbox mode")}
11299
+ locus sandbox status ${dim("# Show current sandbox state")}
11300
+
11301
+ ${bold("Flow:")}
11302
+ 1. ${cyan("locus sandbox")} Create the sandbox environment
11303
+ 2. ${cyan("locus sandbox claude")} Login to Claude inside the sandbox
11304
+ 3. ${cyan("locus exec")} All commands now run inside the sandbox
11305
+
11306
+ `);
11307
+ }
11308
+ async function sandboxCommand(projectRoot, args) {
11309
+ const subcommand = args[0] ?? "";
11310
+ switch (subcommand) {
11311
+ case "help":
11312
+ printSandboxHelp();
11313
+ return;
11314
+ case "claude":
11315
+ case "codex":
11316
+ return handleAgentLogin(projectRoot, subcommand);
11317
+ case "rm":
11318
+ return handleRemove(projectRoot);
11319
+ case "status":
11320
+ return handleStatus(projectRoot);
11321
+ case "":
11322
+ return handleCreate(projectRoot);
11323
+ default:
11324
+ process.stderr.write(`${red("✗")} Unknown sandbox subcommand: ${bold(subcommand)}
11325
+ `);
11326
+ process.stderr.write(` Available: ${cyan("claude")}, ${cyan("codex")}, ${cyan("rm")}, ${cyan("status")}
11327
+ `);
11328
+ }
11329
+ }
11330
+ async function handleCreate(projectRoot) {
11331
+ const config = loadConfig(projectRoot);
11332
+ if (config.sandbox.name) {
11333
+ const alive = isSandboxAlive(config.sandbox.name);
11334
+ if (alive) {
11335
+ process.stderr.write(`${green("✓")} Sandbox already exists: ${bold(config.sandbox.name)}
11336
+ `);
11337
+ process.stderr.write(` Run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to login.
11338
+ `);
11339
+ return;
11340
+ }
11341
+ process.stderr.write(`${yellow("⚠")} Previous sandbox ${dim(config.sandbox.name)} is no longer running. Creating a new one.
11342
+ `);
11343
+ }
11344
+ const status = await detectSandboxSupport();
11345
+ if (!status.available) {
11346
+ process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11347
+ `);
11348
+ process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11349
+ `);
11350
+ return;
11351
+ }
11352
+ const segment = projectRoot.split("/").pop() ?? "sandbox";
11353
+ const sandboxName = `locus-${segment}-${Date.now()}`;
11354
+ config.sandbox.enabled = true;
11355
+ config.sandbox.name = sandboxName;
11356
+ saveConfig(projectRoot, config);
11357
+ process.stderr.write(`${green("✓")} Sandbox name reserved: ${bold(sandboxName)}
11358
+ `);
11359
+ process.stderr.write(` Next: run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to create the sandbox and login.
11360
+ `);
11361
+ }
11362
+ async function handleAgentLogin(projectRoot, agent) {
11363
+ const config = loadConfig(projectRoot);
11364
+ if (!config.sandbox.name) {
11365
+ const status = await detectSandboxSupport();
11366
+ if (!status.available) {
11367
+ process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11368
+ `);
11369
+ process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11370
+ `);
11371
+ return;
11372
+ }
11373
+ const segment = projectRoot.split("/").pop() ?? "sandbox";
11374
+ config.sandbox.name = `locus-${segment}-${Date.now()}`;
11375
+ config.sandbox.enabled = true;
11376
+ saveConfig(projectRoot, config);
11377
+ }
11378
+ const sandboxName = config.sandbox.name;
11379
+ const alive = isSandboxAlive(sandboxName);
11380
+ let dockerArgs;
11381
+ if (alive) {
11382
+ if (agent === "codex") {
11383
+ await ensureCodexInSandbox(sandboxName);
11384
+ }
11385
+ process.stderr.write(`Connecting to sandbox ${dim(sandboxName)}...
11386
+ `);
11387
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11388
+
11389
+ `);
11390
+ dockerArgs = [
11391
+ "sandbox",
11392
+ "exec",
11393
+ "-it",
11394
+ "-w",
11395
+ projectRoot,
11396
+ sandboxName,
11397
+ agent
11398
+ ];
11399
+ } else if (agent === "codex") {
11400
+ process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11401
+ `);
11402
+ try {
11403
+ execSync18(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, { stdio: ["pipe", "pipe", "pipe"], timeout: 120000 });
11404
+ } catch {}
11405
+ if (!isSandboxAlive(sandboxName)) {
11406
+ process.stderr.write(`${red("✗")} Failed to create sandbox.
11407
+ `);
11408
+ return;
11409
+ }
11410
+ await ensureCodexInSandbox(sandboxName);
11411
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11412
+
11413
+ `);
11414
+ dockerArgs = [
11415
+ "sandbox",
11416
+ "exec",
11417
+ "-it",
11418
+ "-w",
11419
+ projectRoot,
11420
+ sandboxName,
11421
+ "codex"
11422
+ ];
11423
+ } else {
11424
+ process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11425
+ `);
11426
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11427
+
11428
+ `);
11429
+ dockerArgs = ["sandbox", "run", "--name", sandboxName, agent, projectRoot];
11430
+ }
11431
+ const child = spawn6("docker", dockerArgs, {
11432
+ stdio: "inherit"
11433
+ });
11434
+ await new Promise((resolve2) => {
11435
+ child.on("close", async (code) => {
11436
+ await enforceSandboxIgnore(sandboxName, projectRoot);
11437
+ if (code === 0) {
11438
+ process.stderr.write(`
11439
+ ${green("✓")} ${agent} session ended. Auth should now be persisted in the sandbox.
11440
+ `);
11441
+ } else {
11442
+ process.stderr.write(`
11443
+ ${yellow("⚠")} ${agent} exited with code ${code}.
11444
+ `);
11445
+ }
11446
+ resolve2();
11447
+ });
11448
+ child.on("error", (err) => {
11449
+ process.stderr.write(`${red("✗")} Failed to start ${agent}: ${err.message}
11450
+ `);
11451
+ resolve2();
11452
+ });
11453
+ });
11454
+ }
11455
+ function handleRemove(projectRoot) {
11456
+ const config = loadConfig(projectRoot);
11457
+ if (!config.sandbox.name) {
11458
+ process.stderr.write(`${dim("No sandbox to remove.")}
11459
+ `);
11460
+ return;
11461
+ }
11462
+ const sandboxName = config.sandbox.name;
11463
+ process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11464
+ `);
11465
+ try {
11466
+ execSync18(`docker sandbox rm ${sandboxName}`, {
11467
+ encoding: "utf-8",
11468
+ stdio: ["pipe", "pipe", "pipe"],
11469
+ timeout: 15000
11470
+ });
11471
+ } catch {}
11472
+ config.sandbox.name = undefined;
11473
+ config.sandbox.enabled = false;
11474
+ saveConfig(projectRoot, config);
11475
+ process.stderr.write(`${green("✓")} Sandbox removed. Sandbox mode disabled.
11476
+ `);
11477
+ }
11478
+ function handleStatus(projectRoot) {
11479
+ const config = loadConfig(projectRoot);
11480
+ process.stderr.write(`
11481
+ ${bold("Sandbox Status")}
11482
+
11483
+ `);
11484
+ process.stderr.write(` ${dim("Enabled:")} ${config.sandbox.enabled ? green("yes") : red("no")}
11485
+ `);
11486
+ process.stderr.write(` ${dim("Name:")} ${config.sandbox.name ? bold(config.sandbox.name) : dim("(none)")}
11487
+ `);
11488
+ if (config.sandbox.name) {
11489
+ const alive = isSandboxAlive(config.sandbox.name);
11490
+ process.stderr.write(` ${dim("Running:")} ${alive ? green("yes") : red("no")}
11491
+ `);
11492
+ if (!alive) {
11493
+ process.stderr.write(`
11494
+ ${yellow("⚠")} Sandbox is not running. Run ${bold("locus sandbox")} to create a new one.
11495
+ `);
11496
+ }
11497
+ }
11498
+ process.stderr.write(`
11499
+ `);
11500
+ }
11501
+ async function ensureCodexInSandbox(sandboxName) {
11502
+ try {
11503
+ execSync18(`docker sandbox exec ${sandboxName} which codex`, {
11504
+ stdio: ["pipe", "pipe", "pipe"],
11505
+ timeout: 5000
11506
+ });
11507
+ } catch {
11508
+ process.stderr.write(`Installing codex in sandbox...
11509
+ `);
11510
+ try {
11511
+ execSync18(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11512
+ } catch {
11513
+ process.stderr.write(`${red("✗")} Failed to install codex in sandbox.
11514
+ `);
11515
+ }
11516
+ }
11517
+ }
11518
+ function isSandboxAlive(name) {
11519
+ try {
11520
+ const output = execSync18("docker sandbox ls", {
11521
+ encoding: "utf-8",
11522
+ stdio: ["pipe", "pipe", "pipe"],
11523
+ timeout: 5000
11524
+ });
11525
+ return output.includes(name);
11526
+ } catch {
11527
+ return false;
11528
+ }
11529
+ }
11530
+ var init_sandbox2 = __esm(() => {
11531
+ init_config();
11532
+ init_sandbox();
11533
+ init_sandbox_ignore();
11534
+ init_terminal();
11535
+ });
11536
+
10112
11537
  // src/cli.ts
10113
11538
  init_config();
10114
11539
  init_context();
10115
11540
  init_logger();
10116
11541
  init_rate_limiter();
10117
11542
  init_terminal();
10118
- import { existsSync as existsSync20, readFileSync as readFileSync16 } from "node:fs";
10119
- import { join as join20 } from "node:path";
11543
+ import { existsSync as existsSync21, readFileSync as readFileSync17 } from "node:fs";
11544
+ import { join as join21 } from "node:path";
10120
11545
  import { fileURLToPath } from "node:url";
10121
11546
  function getCliVersion() {
10122
11547
  const fallbackVersion = "0.0.0";
10123
- const packageJsonPath = join20(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
10124
- if (!existsSync20(packageJsonPath)) {
11548
+ const packageJsonPath = join21(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
11549
+ if (!existsSync21(packageJsonPath)) {
10125
11550
  return fallbackVersion;
10126
11551
  }
10127
11552
  try {
10128
- const parsed = JSON.parse(readFileSync16(packageJsonPath, "utf-8"));
11553
+ const parsed = JSON.parse(readFileSync17(packageJsonPath, "utf-8"));
10129
11554
  return parsed.version ?? fallbackVersion;
10130
11555
  } catch {
10131
11556
  return fallbackVersion;
@@ -10145,7 +11570,8 @@ function parseArgs(argv) {
10145
11570
  dryRun: false,
10146
11571
  check: false,
10147
11572
  upgrade: false,
10148
- list: false
11573
+ list: false,
11574
+ noSandbox: false
10149
11575
  };
10150
11576
  const positional = [];
10151
11577
  let i = 0;
@@ -10222,7 +11648,14 @@ function parseArgs(argv) {
10222
11648
  case "--target-version":
10223
11649
  flags.targetVersion = rawArgs[++i];
10224
11650
  break;
11651
+ case "--no-sandbox":
11652
+ flags.noSandbox = true;
11653
+ break;
10225
11654
  default:
11655
+ if (arg.startsWith("--sandbox=")) {
11656
+ flags.sandbox = arg.slice("--sandbox=".length);
11657
+ break;
11658
+ }
10226
11659
  positional.push(arg);
10227
11660
  }
10228
11661
  i++;
@@ -10256,6 +11689,7 @@ ${bold("Commands:")}
10256
11689
  ${cyan("uninstall")} Remove an installed package
10257
11690
  ${cyan("packages")} Manage installed packages (list, outdated)
10258
11691
  ${cyan("pkg")} ${dim("<name> [cmd]")} Run a command from an installed package
11692
+ ${cyan("sandbox")} Manage Docker sandbox lifecycle
10259
11693
  ${cyan("upgrade")} Check for and install updates
10260
11694
 
10261
11695
  ${bold("Options:")}
@@ -10271,6 +11705,10 @@ ${bold("Examples:")}
10271
11705
  locus plan approve <id> ${dim("# Create issues from saved plan")}
10272
11706
  locus run ${dim("# Execute active sprint")}
10273
11707
  locus run 42 43 ${dim("# Run issues in parallel")}
11708
+ locus run 42 --no-sandbox ${dim("# Run without sandbox")}
11709
+ locus run 42 --sandbox=require ${dim("# Require Docker sandbox")}
11710
+ locus sandbox ${dim("# Create Docker sandbox")}
11711
+ locus sandbox claude ${dim("# Login to Claude in sandbox")}
10274
11712
 
10275
11713
  `);
10276
11714
  }
@@ -10301,7 +11739,7 @@ async function main() {
10301
11739
  try {
10302
11740
  const root = getGitRoot(cwd);
10303
11741
  if (isInitialized(root)) {
10304
- logDir = join20(root, ".locus", "logs");
11742
+ logDir = join21(root, ".locus", "logs");
10305
11743
  getRateLimiter(root);
10306
11744
  }
10307
11745
  } catch {}
@@ -10375,7 +11813,6 @@ async function main() {
10375
11813
  process.stderr.write(`${red("✗")} Not inside a git repository.
10376
11814
  `);
10377
11815
  process.exit(1);
10378
- return;
10379
11816
  }
10380
11817
  if (!isInitialized(projectRoot)) {
10381
11818
  process.stderr.write(`${red("✗")} Locus is not initialized in this project.
@@ -10383,7 +11820,6 @@ async function main() {
10383
11820
  process.stderr.write(` Run: ${bold("locus init")}
10384
11821
  `);
10385
11822
  process.exit(1);
10386
- return;
10387
11823
  }
10388
11824
  switch (command) {
10389
11825
  case "config": {
@@ -10428,7 +11864,9 @@ async function main() {
10428
11864
  await runCommand2(projectRoot, runArgs, {
10429
11865
  resume: parsed.flags.resume,
10430
11866
  dryRun: parsed.flags.dryRun,
10431
- model: parsed.flags.model
11867
+ model: parsed.flags.model,
11868
+ sandbox: parsed.flags.sandbox,
11869
+ noSandbox: parsed.flags.noSandbox
10432
11870
  });
10433
11871
  break;
10434
11872
  }
@@ -10478,6 +11916,12 @@ async function main() {
10478
11916
  await artifactsCommand2(projectRoot, artifactsArgs);
10479
11917
  break;
10480
11918
  }
11919
+ case "sandbox": {
11920
+ const { sandboxCommand: sandboxCommand2 } = await Promise.resolve().then(() => (init_sandbox2(), exports_sandbox));
11921
+ const sandboxArgs = parsed.flags.help ? ["help"] : parsed.args;
11922
+ await sandboxCommand2(projectRoot, sandboxArgs);
11923
+ break;
11924
+ }
10481
11925
  case "upgrade": {
10482
11926
  const { upgradeCommand: upgradeCommand2 } = await Promise.resolve().then(() => (init_upgrade(), exports_upgrade));
10483
11927
  await upgradeCommand2(projectRoot, parsed.args, {