@locusai/cli 0.17.15 → 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 +2013 -568
  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"]
@@ -5857,7 +6947,7 @@ ${gitLog}
5857
6947
  function buildExecutionRules(config) {
5858
6948
  return `# Execution Rules
5859
6949
 
5860
- 1. **Commit format:** Use conventional commits: \`feat: <title> (#<issue>)\`, \`fix: ...\`, \`chore: ...\`. Always include a blank line followed by \`Co-Authored-By: LocusAgent <agent@locusai.team>\` as a trailer in every commit message.
6950
+ 1. **Commit format:** Use conventional commits: \`feat: <title> (#<issue>)\`, \`fix: ...\`, \`chore: ...\`. Every commit message MUST be multi-line: the first line is the title, then a blank line, then \`Co-Authored-By: LocusAgent <agent@locusai.team>\` as a Git trailer. Use \`git commit -m "<title>" -m "Co-Authored-By: LocusAgent <agent@locusai.team>"\` (two separate -m flags) to ensure the trailer is on its own line.
5861
6951
  2. **Code quality:** Follow existing code style. Run linters/formatters if available.
5862
6952
  3. **Testing:** If test files exist for modified code, update them accordingly.
5863
6953
  4. **Do NOT:**
@@ -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,8 @@ 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 -m ${JSON.stringify(message)}`, {
9467
+ execSync15(`git commit -F -`, {
9468
+ input: message,
8305
9469
  cwd: projectRoot,
8306
9470
  encoding: "utf-8",
8307
9471
  stdio: ["pipe", "pipe", "pipe"]
@@ -8314,7 +9478,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8314
9478
  if (!config.agent.autoPR)
8315
9479
  return;
8316
9480
  try {
8317
- const diff = execSync12(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9481
+ const diff = execSync15(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8318
9482
  cwd: projectRoot,
8319
9483
  encoding: "utf-8",
8320
9484
  stdio: ["pipe", "pipe", "pipe"]
@@ -8324,7 +9488,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
8324
9488
  `);
8325
9489
  return;
8326
9490
  }
8327
- execSync12(`git push -u origin ${branchName}`, {
9491
+ execSync15(`git push -u origin ${branchName}`, {
8328
9492
  cwd: projectRoot,
8329
9493
  encoding: "utf-8",
8330
9494
  stdio: ["pipe", "pipe", "pipe"]
@@ -8357,6 +9521,7 @@ var init_run = __esm(() => {
8357
9521
  init_logger();
8358
9522
  init_rate_limiter();
8359
9523
  init_run_state();
9524
+ init_sandbox();
8360
9525
  init_shutdown();
8361
9526
  init_worktree();
8362
9527
  init_progress();
@@ -8469,13 +9634,13 @@ __export(exports_plan, {
8469
9634
  parsePlanArgs: () => parsePlanArgs
8470
9635
  });
8471
9636
  import {
8472
- existsSync as existsSync16,
9637
+ existsSync as existsSync17,
8473
9638
  mkdirSync as mkdirSync11,
8474
9639
  readdirSync as readdirSync7,
8475
- readFileSync as readFileSync12,
9640
+ readFileSync as readFileSync13,
8476
9641
  writeFileSync as writeFileSync9
8477
9642
  } from "node:fs";
8478
- import { join as join16 } from "node:path";
9643
+ import { join as join17 } from "node:path";
8479
9644
  function printHelp() {
8480
9645
  process.stderr.write(`
8481
9646
  ${bold("locus plan")} — AI-powered sprint planning
@@ -8506,28 +9671,28 @@ function normalizeSprintName(name) {
8506
9671
  return name.trim().toLowerCase();
8507
9672
  }
8508
9673
  function getPlansDir(projectRoot) {
8509
- return join16(projectRoot, ".locus", "plans");
9674
+ return join17(projectRoot, ".locus", "plans");
8510
9675
  }
8511
9676
  function ensurePlansDir(projectRoot) {
8512
9677
  const dir = getPlansDir(projectRoot);
8513
- if (!existsSync16(dir)) {
9678
+ if (!existsSync17(dir)) {
8514
9679
  mkdirSync11(dir, { recursive: true });
8515
9680
  }
8516
9681
  return dir;
8517
9682
  }
8518
9683
  function generateId() {
8519
- return `${Math.random().toString(36).slice(2, 8)}`;
9684
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8520
9685
  }
8521
9686
  function loadPlanFile(projectRoot, id) {
8522
9687
  const dir = getPlansDir(projectRoot);
8523
- if (!existsSync16(dir))
9688
+ if (!existsSync17(dir))
8524
9689
  return null;
8525
9690
  const files = readdirSync7(dir).filter((f) => f.endsWith(".json"));
8526
9691
  const match = files.find((f) => f.startsWith(id));
8527
9692
  if (!match)
8528
9693
  return null;
8529
9694
  try {
8530
- const content = readFileSync12(join16(dir, match), "utf-8");
9695
+ const content = readFileSync13(join17(dir, match), "utf-8");
8531
9696
  return JSON.parse(content);
8532
9697
  } catch {
8533
9698
  return null;
@@ -8573,7 +9738,7 @@ async function planCommand(projectRoot, args, flags = {}) {
8573
9738
  }
8574
9739
  function handleListPlans(projectRoot) {
8575
9740
  const dir = getPlansDir(projectRoot);
8576
- if (!existsSync16(dir)) {
9741
+ if (!existsSync17(dir)) {
8577
9742
  process.stderr.write(`${dim("No saved plans yet.")}
8578
9743
  `);
8579
9744
  return;
@@ -8591,7 +9756,7 @@ ${bold("Saved Plans:")}
8591
9756
  for (const file of files) {
8592
9757
  const id = file.replace(".json", "");
8593
9758
  try {
8594
- const content = readFileSync12(join16(dir, file), "utf-8");
9759
+ const content = readFileSync13(join17(dir, file), "utf-8");
8595
9760
  const plan = JSON.parse(content);
8596
9761
  const date = plan.createdAt ? plan.createdAt.slice(0, 10) : "";
8597
9762
  const issueCount = Array.isArray(plan.issues) ? plan.issues.length : 0;
@@ -8702,7 +9867,7 @@ ${bold("Approving plan:")}
8702
9867
  async function handleAIPlan(projectRoot, config, directive, sprintName, flags) {
8703
9868
  const id = generateId();
8704
9869
  const plansDir = ensurePlansDir(projectRoot);
8705
- const planPath = join16(plansDir, `${id}.json`);
9870
+ const planPath = join17(plansDir, `${id}.json`);
8706
9871
  const planPathRelative = `.locus/plans/${id}.json`;
8707
9872
  const displayDirective = directive;
8708
9873
  process.stderr.write(`
@@ -8720,7 +9885,9 @@ ${bold("Planning:")} ${cyan(displayDirective)}
8720
9885
  provider: config.ai.provider,
8721
9886
  model: flags.model ?? config.ai.model,
8722
9887
  cwd: projectRoot,
8723
- activity: "planning"
9888
+ activity: "planning",
9889
+ sandboxed: config.sandbox.enabled,
9890
+ sandboxName: config.sandbox.name
8724
9891
  });
8725
9892
  if (aiResult.interrupted) {
8726
9893
  process.stderr.write(`
@@ -8734,7 +9901,7 @@ ${red("✗")} Planning failed: ${aiResult.error}
8734
9901
  `);
8735
9902
  return;
8736
9903
  }
8737
- if (!existsSync16(planPath)) {
9904
+ if (!existsSync17(planPath)) {
8738
9905
  process.stderr.write(`
8739
9906
  ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8740
9907
  `);
@@ -8744,7 +9911,7 @@ ${yellow("⚠")} Plan file was not created at ${bold(planPathRelative)}.
8744
9911
  }
8745
9912
  let plan;
8746
9913
  try {
8747
- const content = readFileSync12(planPath, "utf-8");
9914
+ const content = readFileSync13(planPath, "utf-8");
8748
9915
  plan = JSON.parse(content);
8749
9916
  } catch {
8750
9917
  process.stderr.write(`
@@ -8828,7 +9995,9 @@ Start with foundational/setup tasks, then core features, then integration/testin
8828
9995
  model: flags.model ?? config.ai.model,
8829
9996
  cwd: projectRoot,
8830
9997
  activity: "issue ordering",
8831
- silent: true
9998
+ silent: true,
9999
+ sandboxed: config.sandbox.enabled,
10000
+ sandboxName: config.sandbox.name
8832
10001
  });
8833
10002
  if (aiResult.interrupted) {
8834
10003
  process.stderr.write(`
@@ -8899,16 +10068,16 @@ function buildPlanningPrompt(projectRoot, config, directive, sprintName, id, pla
8899
10068
  parts.push(`SPRINT: ${sprintName}`);
8900
10069
  }
8901
10070
  parts.push("");
8902
- const locusPath = join16(projectRoot, "LOCUS.md");
8903
- if (existsSync16(locusPath)) {
8904
- const content = readFileSync12(locusPath, "utf-8");
10071
+ const locusPath = join17(projectRoot, "LOCUS.md");
10072
+ if (existsSync17(locusPath)) {
10073
+ const content = readFileSync13(locusPath, "utf-8");
8905
10074
  parts.push("PROJECT CONTEXT (LOCUS.md):");
8906
10075
  parts.push(content.slice(0, 3000));
8907
10076
  parts.push("");
8908
10077
  }
8909
- const learningsPath = join16(projectRoot, ".locus", "LEARNINGS.md");
8910
- if (existsSync16(learningsPath)) {
8911
- 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");
8912
10081
  parts.push("PAST LEARNINGS:");
8913
10082
  parts.push(content.slice(0, 2000));
8914
10083
  parts.push("");
@@ -9084,9 +10253,9 @@ var exports_review = {};
9084
10253
  __export(exports_review, {
9085
10254
  reviewCommand: () => reviewCommand
9086
10255
  });
9087
- import { execSync as execSync13 } from "node:child_process";
9088
- import { existsSync as existsSync17, readFileSync as readFileSync13 } from "node:fs";
9089
- 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";
9090
10259
  function printHelp2() {
9091
10260
  process.stderr.write(`
9092
10261
  ${bold("locus review")} — AI-powered code review
@@ -9162,7 +10331,7 @@ ${bold("Review complete:")} ${green(`✓ ${reviewed}`)}${failed > 0 ? ` ${red(`
9162
10331
  async function reviewSinglePR(projectRoot, config, prNumber, focus, flags) {
9163
10332
  let prInfo;
9164
10333
  try {
9165
- 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"] });
9166
10335
  const raw = JSON.parse(result);
9167
10336
  prInfo = {
9168
10337
  number: raw.number,
@@ -9205,7 +10374,9 @@ async function reviewPR(projectRoot, config, pr, focus, flags) {
9205
10374
  provider: config.ai.provider,
9206
10375
  model: flags.model ?? config.ai.model,
9207
10376
  cwd: projectRoot,
9208
- activity: `PR #${pr.number}`
10377
+ activity: `PR #${pr.number}`,
10378
+ sandboxed: config.sandbox.enabled,
10379
+ sandboxName: config.sandbox.name
9209
10380
  });
9210
10381
  if (aiResult.interrupted) {
9211
10382
  process.stderr.write(` ${yellow("⚡")} Review interrupted.
@@ -9226,7 +10397,7 @@ ${output.slice(0, 60000)}
9226
10397
 
9227
10398
  ---
9228
10399
  _Reviewed by Locus AI (${config.ai.provider}/${flags.model ?? config.ai.model})_`;
9229
- 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"] });
9230
10401
  process.stderr.write(` ${green("✓")} Review posted ${dim(`(${timer.formatted()})`)}
9231
10402
  `);
9232
10403
  } catch (e) {
@@ -9243,9 +10414,9 @@ function buildReviewPrompt(projectRoot, config, pr, diff, focus) {
9243
10414
  const parts = [];
9244
10415
  parts.push(`You are an expert code reviewer for the ${config.github.owner}/${config.github.repo} repository.`);
9245
10416
  parts.push("");
9246
- const locusPath = join17(projectRoot, "LOCUS.md");
9247
- if (existsSync17(locusPath)) {
9248
- const content = readFileSync13(locusPath, "utf-8");
10417
+ const locusPath = join18(projectRoot, "LOCUS.md");
10418
+ if (existsSync18(locusPath)) {
10419
+ const content = readFileSync14(locusPath, "utf-8");
9249
10420
  parts.push("PROJECT CONTEXT:");
9250
10421
  parts.push(content.slice(0, 2000));
9251
10422
  parts.push("");
@@ -9297,7 +10468,7 @@ var exports_iterate = {};
9297
10468
  __export(exports_iterate, {
9298
10469
  iterateCommand: () => iterateCommand
9299
10470
  });
9300
- import { execSync as execSync14 } from "node:child_process";
10471
+ import { execSync as execSync17 } from "node:child_process";
9301
10472
  function printHelp3() {
9302
10473
  process.stderr.write(`
9303
10474
  ${bold("locus iterate")} — Re-execute tasks with PR feedback
@@ -9507,12 +10678,12 @@ ${bold("Summary:")} ${green(`✓ ${succeeded}`)}${failed > 0 ? ` ${red(`✗ ${fa
9507
10678
  }
9508
10679
  function findPRForIssue(projectRoot, issueNumber) {
9509
10680
  try {
9510
- 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"] });
9511
10682
  const parsed = JSON.parse(result);
9512
10683
  if (parsed.length > 0) {
9513
10684
  return parsed[0].number;
9514
10685
  }
9515
- 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"] });
9516
10687
  const branchParsed = JSON.parse(branchResult);
9517
10688
  if (branchParsed.length > 0) {
9518
10689
  return branchParsed[0].number;
@@ -9547,14 +10718,14 @@ __export(exports_discuss, {
9547
10718
  discussCommand: () => discussCommand
9548
10719
  });
9549
10720
  import {
9550
- existsSync as existsSync18,
10721
+ existsSync as existsSync19,
9551
10722
  mkdirSync as mkdirSync12,
9552
10723
  readdirSync as readdirSync8,
9553
- readFileSync as readFileSync14,
10724
+ readFileSync as readFileSync15,
9554
10725
  unlinkSync as unlinkSync5,
9555
10726
  writeFileSync as writeFileSync10
9556
10727
  } from "node:fs";
9557
- import { join as join18 } from "node:path";
10728
+ import { join as join19 } from "node:path";
9558
10729
  function printHelp4() {
9559
10730
  process.stderr.write(`
9560
10731
  ${bold("locus discuss")} — AI-powered architectural discussions
@@ -9576,11 +10747,11 @@ ${bold("Examples:")}
9576
10747
  `);
9577
10748
  }
9578
10749
  function getDiscussionsDir(projectRoot) {
9579
- return join18(projectRoot, ".locus", "discussions");
10750
+ return join19(projectRoot, ".locus", "discussions");
9580
10751
  }
9581
10752
  function ensureDiscussionsDir(projectRoot) {
9582
10753
  const dir = getDiscussionsDir(projectRoot);
9583
- if (!existsSync18(dir)) {
10754
+ if (!existsSync19(dir)) {
9584
10755
  mkdirSync12(dir, { recursive: true });
9585
10756
  }
9586
10757
  return dir;
@@ -9615,7 +10786,7 @@ async function discussCommand(projectRoot, args, flags = {}) {
9615
10786
  }
9616
10787
  function listDiscussions(projectRoot) {
9617
10788
  const dir = getDiscussionsDir(projectRoot);
9618
- if (!existsSync18(dir)) {
10789
+ if (!existsSync19(dir)) {
9619
10790
  process.stderr.write(`${dim("No discussions yet.")}
9620
10791
  `);
9621
10792
  return;
@@ -9632,7 +10803,7 @@ ${bold("Discussions:")}
9632
10803
  `);
9633
10804
  for (const file of files) {
9634
10805
  const id = file.replace(".md", "");
9635
- const content = readFileSync14(join18(dir, file), "utf-8");
10806
+ const content = readFileSync15(join19(dir, file), "utf-8");
9636
10807
  const titleMatch = content.match(/^#\s+(.+)/m);
9637
10808
  const title = titleMatch ? titleMatch[1] : id;
9638
10809
  const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
@@ -9650,7 +10821,7 @@ function showDiscussion(projectRoot, id) {
9650
10821
  return;
9651
10822
  }
9652
10823
  const dir = getDiscussionsDir(projectRoot);
9653
- if (!existsSync18(dir)) {
10824
+ if (!existsSync19(dir)) {
9654
10825
  process.stderr.write(`${red("✗")} No discussions found.
9655
10826
  `);
9656
10827
  return;
@@ -9662,7 +10833,7 @@ function showDiscussion(projectRoot, id) {
9662
10833
  `);
9663
10834
  return;
9664
10835
  }
9665
- const content = readFileSync14(join18(dir, match), "utf-8");
10836
+ const content = readFileSync15(join19(dir, match), "utf-8");
9666
10837
  process.stdout.write(`${content}
9667
10838
  `);
9668
10839
  }
@@ -9673,7 +10844,7 @@ function deleteDiscussion(projectRoot, id) {
9673
10844
  return;
9674
10845
  }
9675
10846
  const dir = getDiscussionsDir(projectRoot);
9676
- if (!existsSync18(dir)) {
10847
+ if (!existsSync19(dir)) {
9677
10848
  process.stderr.write(`${red("✗")} No discussions found.
9678
10849
  `);
9679
10850
  return;
@@ -9685,7 +10856,7 @@ function deleteDiscussion(projectRoot, id) {
9685
10856
  `);
9686
10857
  return;
9687
10858
  }
9688
- unlinkSync5(join18(dir, match));
10859
+ unlinkSync5(join19(dir, match));
9689
10860
  process.stderr.write(`${green("✓")} Deleted discussion: ${match.replace(".md", "")}
9690
10861
  `);
9691
10862
  }
@@ -9698,7 +10869,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9698
10869
  return;
9699
10870
  }
9700
10871
  const dir = getDiscussionsDir(projectRoot);
9701
- if (!existsSync18(dir)) {
10872
+ if (!existsSync19(dir)) {
9702
10873
  process.stderr.write(`${red("✗")} No discussions found.
9703
10874
  `);
9704
10875
  return;
@@ -9710,7 +10881,7 @@ async function convertDiscussionToPlan(projectRoot, id) {
9710
10881
  `);
9711
10882
  return;
9712
10883
  }
9713
- const content = readFileSync14(join18(dir, match), "utf-8");
10884
+ const content = readFileSync15(join19(dir, match), "utf-8");
9714
10885
  const titleMatch = content.match(/^#\s+(.+)/m);
9715
10886
  const discussionTitle = titleMatch ? titleMatch[1].trim() : id;
9716
10887
  await planCommand(projectRoot, [
@@ -9755,7 +10926,9 @@ ${bold("Discussion:")} ${cyan(topic)}
9755
10926
  provider: config.ai.provider,
9756
10927
  model: flags.model ?? config.ai.model,
9757
10928
  cwd: projectRoot,
9758
- activity: "discussion"
10929
+ activity: "discussion",
10930
+ sandboxed: config.sandbox.enabled,
10931
+ sandboxName: config.sandbox.name
9759
10932
  });
9760
10933
  if (aiResult.interrupted) {
9761
10934
  process.stderr.write(`
@@ -9822,7 +10995,7 @@ ${turn.content}`;
9822
10995
  ...conversation.length > 1 ? [`---`, ``, `## Discussion Transcript`, ``, transcript, ``] : []
9823
10996
  ].join(`
9824
10997
  `);
9825
- writeFileSync10(join18(dir, `${id}.md`), markdown, "utf-8");
10998
+ writeFileSync10(join19(dir, `${id}.md`), markdown, "utf-8");
9826
10999
  process.stderr.write(`
9827
11000
  ${green("✓")} Discussion saved: ${cyan(id)} ${dim(`(${timer.formatted()})`)}
9828
11001
  `);
@@ -9836,16 +11009,16 @@ function buildDiscussionPrompt(projectRoot, config, topic, conversation, forceFi
9836
11009
  const parts = [];
9837
11010
  parts.push(`You are a senior software architect and consultant for the ${config.github.owner}/${config.github.repo} project.`);
9838
11011
  parts.push("");
9839
- const locusPath = join18(projectRoot, "LOCUS.md");
9840
- if (existsSync18(locusPath)) {
9841
- const content = readFileSync14(locusPath, "utf-8");
11012
+ const locusPath = join19(projectRoot, "LOCUS.md");
11013
+ if (existsSync19(locusPath)) {
11014
+ const content = readFileSync15(locusPath, "utf-8");
9842
11015
  parts.push("PROJECT CONTEXT:");
9843
11016
  parts.push(content.slice(0, 3000));
9844
11017
  parts.push("");
9845
11018
  }
9846
- const learningsPath = join18(projectRoot, ".locus", "LEARNINGS.md");
9847
- if (existsSync18(learningsPath)) {
9848
- 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");
9849
11022
  parts.push("PAST LEARNINGS:");
9850
11023
  parts.push(content.slice(0, 2000));
9851
11024
  parts.push("");
@@ -9904,8 +11077,8 @@ __export(exports_artifacts, {
9904
11077
  formatDate: () => formatDate2,
9905
11078
  artifactsCommand: () => artifactsCommand
9906
11079
  });
9907
- import { existsSync as existsSync19, readdirSync as readdirSync9, readFileSync as readFileSync15, statSync as statSync4 } from "node:fs";
9908
- 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";
9909
11082
  function printHelp5() {
9910
11083
  process.stderr.write(`
9911
11084
  ${bold("locus artifacts")} — View and manage AI-generated artifacts
@@ -9925,14 +11098,14 @@ ${dim("Artifact names support partial matching.")}
9925
11098
  `);
9926
11099
  }
9927
11100
  function getArtifactsDir(projectRoot) {
9928
- return join19(projectRoot, ".locus", "artifacts");
11101
+ return join20(projectRoot, ".locus", "artifacts");
9929
11102
  }
9930
11103
  function listArtifacts(projectRoot) {
9931
11104
  const dir = getArtifactsDir(projectRoot);
9932
- if (!existsSync19(dir))
11105
+ if (!existsSync20(dir))
9933
11106
  return [];
9934
11107
  return readdirSync9(dir).filter((f) => f.endsWith(".md")).map((fileName) => {
9935
- const filePath = join19(dir, fileName);
11108
+ const filePath = join20(dir, fileName);
9936
11109
  const stat = statSync4(filePath);
9937
11110
  return {
9938
11111
  name: fileName.replace(/\.md$/, ""),
@@ -9945,12 +11118,12 @@ function listArtifacts(projectRoot) {
9945
11118
  function readArtifact(projectRoot, name) {
9946
11119
  const dir = getArtifactsDir(projectRoot);
9947
11120
  const fileName = name.endsWith(".md") ? name : `${name}.md`;
9948
- const filePath = join19(dir, fileName);
9949
- if (!existsSync19(filePath))
11121
+ const filePath = join20(dir, fileName);
11122
+ if (!existsSync20(filePath))
9950
11123
  return null;
9951
11124
  const stat = statSync4(filePath);
9952
11125
  return {
9953
- content: readFileSync15(filePath, "utf-8"),
11126
+ content: readFileSync16(filePath, "utf-8"),
9954
11127
  info: {
9955
11128
  name: fileName.replace(/\.md$/, ""),
9956
11129
  fileName,
@@ -10108,23 +11281,276 @@ var init_artifacts = __esm(() => {
10108
11281
  init_terminal();
10109
11282
  });
10110
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
+
10111
11537
  // src/cli.ts
10112
11538
  init_config();
10113
11539
  init_context();
10114
11540
  init_logger();
10115
11541
  init_rate_limiter();
10116
11542
  init_terminal();
10117
- import { existsSync as existsSync20, readFileSync as readFileSync16 } from "node:fs";
10118
- 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";
10119
11545
  import { fileURLToPath } from "node:url";
10120
11546
  function getCliVersion() {
10121
11547
  const fallbackVersion = "0.0.0";
10122
- const packageJsonPath = join20(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
10123
- if (!existsSync20(packageJsonPath)) {
11548
+ const packageJsonPath = join21(fileURLToPath(new URL(".", import.meta.url)), "..", "package.json");
11549
+ if (!existsSync21(packageJsonPath)) {
10124
11550
  return fallbackVersion;
10125
11551
  }
10126
11552
  try {
10127
- const parsed = JSON.parse(readFileSync16(packageJsonPath, "utf-8"));
11553
+ const parsed = JSON.parse(readFileSync17(packageJsonPath, "utf-8"));
10128
11554
  return parsed.version ?? fallbackVersion;
10129
11555
  } catch {
10130
11556
  return fallbackVersion;
@@ -10144,7 +11570,8 @@ function parseArgs(argv) {
10144
11570
  dryRun: false,
10145
11571
  check: false,
10146
11572
  upgrade: false,
10147
- list: false
11573
+ list: false,
11574
+ noSandbox: false
10148
11575
  };
10149
11576
  const positional = [];
10150
11577
  let i = 0;
@@ -10221,7 +11648,14 @@ function parseArgs(argv) {
10221
11648
  case "--target-version":
10222
11649
  flags.targetVersion = rawArgs[++i];
10223
11650
  break;
11651
+ case "--no-sandbox":
11652
+ flags.noSandbox = true;
11653
+ break;
10224
11654
  default:
11655
+ if (arg.startsWith("--sandbox=")) {
11656
+ flags.sandbox = arg.slice("--sandbox=".length);
11657
+ break;
11658
+ }
10225
11659
  positional.push(arg);
10226
11660
  }
10227
11661
  i++;
@@ -10255,6 +11689,7 @@ ${bold("Commands:")}
10255
11689
  ${cyan("uninstall")} Remove an installed package
10256
11690
  ${cyan("packages")} Manage installed packages (list, outdated)
10257
11691
  ${cyan("pkg")} ${dim("<name> [cmd]")} Run a command from an installed package
11692
+ ${cyan("sandbox")} Manage Docker sandbox lifecycle
10258
11693
  ${cyan("upgrade")} Check for and install updates
10259
11694
 
10260
11695
  ${bold("Options:")}
@@ -10270,6 +11705,10 @@ ${bold("Examples:")}
10270
11705
  locus plan approve <id> ${dim("# Create issues from saved plan")}
10271
11706
  locus run ${dim("# Execute active sprint")}
10272
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")}
10273
11712
 
10274
11713
  `);
10275
11714
  }
@@ -10300,7 +11739,7 @@ async function main() {
10300
11739
  try {
10301
11740
  const root = getGitRoot(cwd);
10302
11741
  if (isInitialized(root)) {
10303
- logDir = join20(root, ".locus", "logs");
11742
+ logDir = join21(root, ".locus", "logs");
10304
11743
  getRateLimiter(root);
10305
11744
  }
10306
11745
  } catch {}
@@ -10374,7 +11813,6 @@ async function main() {
10374
11813
  process.stderr.write(`${red("✗")} Not inside a git repository.
10375
11814
  `);
10376
11815
  process.exit(1);
10377
- return;
10378
11816
  }
10379
11817
  if (!isInitialized(projectRoot)) {
10380
11818
  process.stderr.write(`${red("✗")} Locus is not initialized in this project.
@@ -10382,7 +11820,6 @@ async function main() {
10382
11820
  process.stderr.write(` Run: ${bold("locus init")}
10383
11821
  `);
10384
11822
  process.exit(1);
10385
- return;
10386
11823
  }
10387
11824
  switch (command) {
10388
11825
  case "config": {
@@ -10427,7 +11864,9 @@ async function main() {
10427
11864
  await runCommand2(projectRoot, runArgs, {
10428
11865
  resume: parsed.flags.resume,
10429
11866
  dryRun: parsed.flags.dryRun,
10430
- model: parsed.flags.model
11867
+ model: parsed.flags.model,
11868
+ sandbox: parsed.flags.sandbox,
11869
+ noSandbox: parsed.flags.noSandbox
10431
11870
  });
10432
11871
  break;
10433
11872
  }
@@ -10477,6 +11916,12 @@ async function main() {
10477
11916
  await artifactsCommand2(projectRoot, artifactsArgs);
10478
11917
  break;
10479
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
+ }
10480
11925
  case "upgrade": {
10481
11926
  const { upgradeCommand: upgradeCommand2 } = await Promise.resolve().then(() => (init_upgrade(), exports_upgrade));
10482
11927
  await upgradeCommand2(projectRoot, parsed.args, {