@locusai/cli 0.18.2 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/locus.js +1260 -1181
  2. package/package.json +3 -3
package/bin/locus.js CHANGED
@@ -62,6 +62,143 @@ var init_ai_models = __esm(() => {
62
62
  ALL_MODEL_SET = new Set([...CLAUDE_MODELS, ...CODEX_MODELS]);
63
63
  });
64
64
 
65
+ // src/core/config.ts
66
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
67
+ import { dirname, join } from "node:path";
68
+ function getConfigPath(projectRoot) {
69
+ return join(projectRoot, ".locus", "config.json");
70
+ }
71
+ function loadConfig(projectRoot) {
72
+ const configPath = getConfigPath(projectRoot);
73
+ if (!existsSync(configPath)) {
74
+ throw new Error(`No Locus config found at ${configPath}. Run "locus init" first.`);
75
+ }
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(readFileSync(configPath, "utf-8"));
79
+ } catch (e) {
80
+ throw new Error(`Failed to parse config at ${configPath}: ${e}`);
81
+ }
82
+ const config = deepMerge(DEFAULT_CONFIG, raw);
83
+ const inferredProvider = inferProviderFromModel(config.ai.model);
84
+ if (inferredProvider) {
85
+ config.ai.provider = inferredProvider;
86
+ }
87
+ if (raw.version !== config.version) {
88
+ saveConfig(projectRoot, config);
89
+ }
90
+ return config;
91
+ }
92
+ function saveConfig(projectRoot, config) {
93
+ const configPath = getConfigPath(projectRoot);
94
+ const dir = dirname(configPath);
95
+ if (!existsSync(dir)) {
96
+ mkdirSync(dir, { recursive: true });
97
+ }
98
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
99
+ `, "utf-8");
100
+ }
101
+ function updateConfigValue(projectRoot, path, value) {
102
+ const config = loadConfig(projectRoot);
103
+ setNestedValue(config, path, value);
104
+ if (path === "ai.model" && typeof value === "string") {
105
+ const inferredProvider = inferProviderFromModel(value);
106
+ if (inferredProvider) {
107
+ config.ai.provider = inferredProvider;
108
+ }
109
+ }
110
+ saveConfig(projectRoot, config);
111
+ return config;
112
+ }
113
+ function isInitialized(projectRoot) {
114
+ return existsSync(join(projectRoot, ".locus", "config.json"));
115
+ }
116
+ function deepMerge(target, source) {
117
+ if (typeof target !== "object" || target === null || typeof source !== "object" || source === null) {
118
+ return source ?? target;
119
+ }
120
+ const result = {
121
+ ...target
122
+ };
123
+ const src = source;
124
+ for (const key of Object.keys(src)) {
125
+ if (key in result && typeof result[key] === "object" && result[key] !== null && typeof src[key] === "object" && src[key] !== null && !Array.isArray(result[key])) {
126
+ result[key] = deepMerge(result[key], src[key]);
127
+ } else if (src[key] !== undefined) {
128
+ result[key] = src[key];
129
+ }
130
+ }
131
+ return result;
132
+ }
133
+ function setNestedValue(obj, path, value) {
134
+ const keys = path.split(".");
135
+ let current = obj;
136
+ for (let i = 0;i < keys.length - 1; i++) {
137
+ const key = keys[i];
138
+ if (typeof current[key] !== "object" || current[key] === null) {
139
+ current[key] = {};
140
+ }
141
+ current = current[key];
142
+ }
143
+ const lastKey = keys[keys.length - 1];
144
+ let coerced = value;
145
+ if (value === "true")
146
+ coerced = true;
147
+ else if (value === "false")
148
+ coerced = false;
149
+ else if (typeof value === "string" && /^\d+$/.test(value))
150
+ coerced = Number.parseInt(value, 10);
151
+ current[lastKey] = coerced;
152
+ }
153
+ function getNestedValue(obj, path) {
154
+ const keys = path.split(".");
155
+ let current = obj;
156
+ for (const key of keys) {
157
+ if (typeof current !== "object" || current === null)
158
+ return;
159
+ current = current[key];
160
+ }
161
+ return current;
162
+ }
163
+ var DEFAULT_CONFIG;
164
+ var init_config = __esm(() => {
165
+ init_ai_models();
166
+ DEFAULT_CONFIG = {
167
+ version: "3.2.0",
168
+ github: {
169
+ owner: "",
170
+ repo: "",
171
+ defaultBranch: "main"
172
+ },
173
+ ai: {
174
+ provider: "claude",
175
+ model: "claude-sonnet-4-6"
176
+ },
177
+ agent: {
178
+ maxParallel: 3,
179
+ autoLabel: true,
180
+ autoPR: true,
181
+ baseBranch: "main",
182
+ rebaseBeforeTask: true
183
+ },
184
+ sprint: {
185
+ active: null,
186
+ stopOnFailure: true
187
+ },
188
+ logging: {
189
+ level: "normal",
190
+ maxFiles: 20,
191
+ maxTotalSizeMB: 50
192
+ },
193
+ sandbox: {
194
+ enabled: true,
195
+ providers: {},
196
+ extraWorkspaces: [],
197
+ readOnlyPaths: []
198
+ }
199
+ };
200
+ });
201
+
65
202
  // src/display/terminal.ts
66
203
  function getCapabilities() {
67
204
  if (cachedCapabilities)
@@ -183,13 +320,13 @@ var init_terminal = __esm(() => {
183
320
  // src/core/logger.ts
184
321
  import {
185
322
  appendFileSync,
186
- existsSync,
187
- mkdirSync,
323
+ existsSync as existsSync2,
324
+ mkdirSync as mkdirSync2,
188
325
  readdirSync,
189
326
  statSync,
190
327
  unlinkSync
191
328
  } from "node:fs";
192
- import { join } from "node:path";
329
+ import { join as join2 } from "node:path";
193
330
  function redactSensitive(text) {
194
331
  let result = text;
195
332
  for (const pattern of SENSITIVE_PATTERNS) {
@@ -219,11 +356,11 @@ class Logger {
219
356
  initLogFile() {
220
357
  if (!this.logDir)
221
358
  return;
222
- if (!existsSync(this.logDir)) {
223
- mkdirSync(this.logDir, { recursive: true });
359
+ if (!existsSync2(this.logDir)) {
360
+ mkdirSync2(this.logDir, { recursive: true });
224
361
  }
225
362
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
226
- this.logFile = join(this.logDir, `locus-${timestamp}.log`);
363
+ this.logFile = join2(this.logDir, `locus-${timestamp}.log`);
227
364
  this.pruneOldLogs();
228
365
  }
229
366
  startFlushTimer() {
@@ -318,14 +455,14 @@ class Logger {
318
455
  }
319
456
  }
320
457
  pruneOldLogs() {
321
- if (!this.logDir || !existsSync(this.logDir))
458
+ if (!this.logDir || !existsSync2(this.logDir))
322
459
  return;
323
460
  try {
324
461
  const logDir = this.logDir;
325
462
  const files = readdirSync(logDir).filter((f) => f.startsWith("locus-") && f.endsWith(".log")).map((f) => ({
326
463
  name: f,
327
- path: join(logDir, f),
328
- stat: statSync(join(logDir, f))
464
+ path: join2(logDir, f),
465
+ stat: statSync(join2(logDir, f))
329
466
  })).sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
330
467
  while (files.length > this.maxFiles) {
331
468
  const oldest = files.pop();
@@ -405,258 +542,92 @@ var init_logger = __esm(() => {
405
542
  ];
406
543
  });
407
544
 
408
- // src/core/config.ts
409
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
410
- import { dirname, join as join2 } from "node:path";
411
- function applyMigrations(config) {
412
- const currentVersion = config.version ?? "3.0.0";
413
- let migrated = { ...config };
414
- let version = currentVersion;
415
- for (const migration of migrations) {
416
- if (version.startsWith(migration.from)) {
417
- migrated = migration.migrate(migrated);
418
- version = migration.to;
419
- getLogger().info(`Migrated config from ${migration.from} ${migration.to}`);
545
+ // src/core/context.ts
546
+ import { execSync } from "node:child_process";
547
+ function detectRepoContext(cwd) {
548
+ const log = getLogger();
549
+ const remoteUrl = git("config --get remote.origin.url", cwd).trim();
550
+ if (!remoteUrl) {
551
+ throw new Error("No git remote 'origin' found. Add a GitHub remote: git remote add origin <url>");
552
+ }
553
+ log.verbose("Detected remote URL", { remoteUrl });
554
+ const { owner, repo } = parseRemoteUrl(remoteUrl);
555
+ if (!owner || !repo) {
556
+ throw new Error(`Could not parse owner/repo from remote URL: ${remoteUrl}`);
557
+ }
558
+ const currentBranch = git("rev-parse --abbrev-ref HEAD", cwd).trim();
559
+ let defaultBranch = "main";
560
+ try {
561
+ const ghOutput = execSync("gh repo view --json defaultBranchRef --jq .defaultBranchRef.name", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
562
+ if (ghOutput) {
563
+ defaultBranch = ghOutput;
564
+ }
565
+ } catch {
566
+ try {
567
+ const remoteHead = git("symbolic-ref refs/remotes/origin/HEAD", cwd).trim().replace("refs/remotes/origin/", "");
568
+ if (remoteHead) {
569
+ defaultBranch = remoteHead;
570
+ }
571
+ } catch {
572
+ log.verbose("Could not detect default branch, using 'main'");
420
573
  }
421
574
  }
422
- return migrated;
575
+ log.verbose("Repository context", {
576
+ owner,
577
+ repo,
578
+ defaultBranch,
579
+ currentBranch
580
+ });
581
+ return { owner, repo, defaultBranch, currentBranch, remoteUrl };
423
582
  }
424
- function getConfigPath(projectRoot) {
425
- return join2(projectRoot, ".locus", "config.json");
583
+ function parseRemoteUrl(url) {
584
+ let match = url.match(/git@github\.com:([^/]+)\/([^/.]+)/);
585
+ if (match)
586
+ return { owner: match[1], repo: match[2] };
587
+ match = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
588
+ if (match)
589
+ return { owner: match[1], repo: match[2] };
590
+ match = url.match(/^([^/]+)\/([^/.]+)$/);
591
+ if (match)
592
+ return { owner: match[1], repo: match[2] };
593
+ return { owner: "", repo: "" };
426
594
  }
427
- function loadConfig(projectRoot) {
428
- const configPath = getConfigPath(projectRoot);
429
- if (!existsSync2(configPath)) {
430
- throw new Error(`No Locus config found at ${configPath}. Run "locus init" first.`);
431
- }
432
- let raw;
595
+ function isGitRepo(cwd) {
433
596
  try {
434
- raw = JSON.parse(readFileSync(configPath, "utf-8"));
435
- } catch (e) {
436
- throw new Error(`Failed to parse config at ${configPath}: ${e}`);
437
- }
438
- const migrated = applyMigrations(raw);
439
- const config = deepMerge(DEFAULT_CONFIG, migrated);
440
- const inferredProvider = inferProviderFromModel(config.ai.model);
441
- if (inferredProvider) {
442
- config.ai.provider = inferredProvider;
443
- }
444
- if (raw.version !== config.version) {
445
- saveConfig(projectRoot, config);
597
+ git("rev-parse --git-dir", cwd);
598
+ return true;
599
+ } catch {
600
+ return false;
446
601
  }
447
- return config;
448
602
  }
449
- function saveConfig(projectRoot, config) {
450
- const configPath = getConfigPath(projectRoot);
451
- const dir = dirname(configPath);
452
- if (!existsSync2(dir)) {
453
- mkdirSync2(dir, { recursive: true });
603
+ function checkGhCli() {
604
+ try {
605
+ execSync("gh --version", {
606
+ encoding: "utf-8",
607
+ stdio: ["pipe", "pipe", "pipe"]
608
+ });
609
+ } catch {
610
+ return { installed: false, authenticated: false };
454
611
  }
455
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
456
- `, "utf-8");
457
- }
458
- function updateConfigValue(projectRoot, path, value) {
459
- const config = loadConfig(projectRoot);
460
- setNestedValue(config, path, value);
461
- if (path === "ai.model" && typeof value === "string") {
462
- const inferredProvider = inferProviderFromModel(value);
463
- if (inferredProvider) {
464
- config.ai.provider = inferredProvider;
465
- }
612
+ try {
613
+ execSync("gh auth status", {
614
+ encoding: "utf-8",
615
+ stdio: ["pipe", "pipe", "pipe"]
616
+ });
617
+ return { installed: true, authenticated: true };
618
+ } catch {
619
+ return { installed: true, authenticated: false };
466
620
  }
467
- saveConfig(projectRoot, config);
468
- return config;
469
621
  }
470
- function isInitialized(projectRoot) {
471
- return existsSync2(join2(projectRoot, ".locus", "config.json"));
622
+ function getGitRoot(cwd) {
623
+ return git("rev-parse --show-toplevel", cwd).trim();
472
624
  }
473
- function deepMerge(target, source) {
474
- if (typeof target !== "object" || target === null || typeof source !== "object" || source === null) {
475
- return source ?? target;
476
- }
477
- const result = {
478
- ...target
479
- };
480
- const src = source;
481
- for (const key of Object.keys(src)) {
482
- if (key in result && typeof result[key] === "object" && result[key] !== null && typeof src[key] === "object" && src[key] !== null && !Array.isArray(result[key])) {
483
- result[key] = deepMerge(result[key], src[key]);
484
- } else if (src[key] !== undefined) {
485
- result[key] = src[key];
486
- }
487
- }
488
- return result;
489
- }
490
- function setNestedValue(obj, path, value) {
491
- const keys = path.split(".");
492
- let current = obj;
493
- for (let i = 0;i < keys.length - 1; i++) {
494
- const key = keys[i];
495
- if (typeof current[key] !== "object" || current[key] === null) {
496
- current[key] = {};
497
- }
498
- current = current[key];
499
- }
500
- const lastKey = keys[keys.length - 1];
501
- let coerced = value;
502
- if (value === "true")
503
- coerced = true;
504
- else if (value === "false")
505
- coerced = false;
506
- else if (typeof value === "string" && /^\d+$/.test(value))
507
- coerced = Number.parseInt(value, 10);
508
- current[lastKey] = coerced;
509
- }
510
- function getNestedValue(obj, path) {
511
- const keys = path.split(".");
512
- let current = obj;
513
- for (const key of keys) {
514
- if (typeof current !== "object" || current === null)
515
- return;
516
- current = current[key];
517
- }
518
- return current;
519
- }
520
- var DEFAULT_CONFIG, migrations;
521
- var init_config = __esm(() => {
522
- init_ai_models();
523
- init_logger();
524
- DEFAULT_CONFIG = {
525
- version: "3.1.0",
526
- github: {
527
- owner: "",
528
- repo: "",
529
- defaultBranch: "main"
530
- },
531
- ai: {
532
- provider: "claude",
533
- model: "claude-sonnet-4-6"
534
- },
535
- agent: {
536
- maxParallel: 3,
537
- autoLabel: true,
538
- autoPR: true,
539
- baseBranch: "main",
540
- rebaseBeforeTask: true
541
- },
542
- sprint: {
543
- active: null,
544
- stopOnFailure: true
545
- },
546
- logging: {
547
- level: "normal",
548
- maxFiles: 20,
549
- maxTotalSizeMB: 50
550
- },
551
- sandbox: {
552
- enabled: true,
553
- extraWorkspaces: [],
554
- readOnlyPaths: []
555
- }
556
- };
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
- ];
572
- });
573
-
574
- // src/core/context.ts
575
- import { execSync } from "node:child_process";
576
- function detectRepoContext(cwd) {
577
- const log = getLogger();
578
- const remoteUrl = git("config --get remote.origin.url", cwd).trim();
579
- if (!remoteUrl) {
580
- throw new Error("No git remote 'origin' found. Add a GitHub remote: git remote add origin <url>");
581
- }
582
- log.verbose("Detected remote URL", { remoteUrl });
583
- const { owner, repo } = parseRemoteUrl(remoteUrl);
584
- if (!owner || !repo) {
585
- throw new Error(`Could not parse owner/repo from remote URL: ${remoteUrl}`);
586
- }
587
- const currentBranch = git("rev-parse --abbrev-ref HEAD", cwd).trim();
588
- let defaultBranch = "main";
589
- try {
590
- const ghOutput = execSync("gh repo view --json defaultBranchRef --jq .defaultBranchRef.name", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
591
- if (ghOutput) {
592
- defaultBranch = ghOutput;
593
- }
594
- } catch {
595
- try {
596
- const remoteHead = git("symbolic-ref refs/remotes/origin/HEAD", cwd).trim().replace("refs/remotes/origin/", "");
597
- if (remoteHead) {
598
- defaultBranch = remoteHead;
599
- }
600
- } catch {
601
- log.verbose("Could not detect default branch, using 'main'");
602
- }
603
- }
604
- log.verbose("Repository context", {
605
- owner,
606
- repo,
607
- defaultBranch,
608
- currentBranch
609
- });
610
- return { owner, repo, defaultBranch, currentBranch, remoteUrl };
611
- }
612
- function parseRemoteUrl(url) {
613
- let match = url.match(/git@github\.com:([^/]+)\/([^/.]+)/);
614
- if (match)
615
- return { owner: match[1], repo: match[2] };
616
- match = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
617
- if (match)
618
- return { owner: match[1], repo: match[2] };
619
- match = url.match(/^([^/]+)\/([^/.]+)$/);
620
- if (match)
621
- return { owner: match[1], repo: match[2] };
622
- return { owner: "", repo: "" };
623
- }
624
- function isGitRepo(cwd) {
625
- try {
626
- git("rev-parse --git-dir", cwd);
627
- return true;
628
- } catch {
629
- return false;
630
- }
631
- }
632
- function checkGhCli() {
633
- try {
634
- execSync("gh --version", {
635
- encoding: "utf-8",
636
- stdio: ["pipe", "pipe", "pipe"]
637
- });
638
- } catch {
639
- return { installed: false, authenticated: false };
640
- }
641
- try {
642
- execSync("gh auth status", {
643
- encoding: "utf-8",
644
- stdio: ["pipe", "pipe", "pipe"]
645
- });
646
- return { installed: true, authenticated: true };
647
- } catch {
648
- return { installed: true, authenticated: false };
649
- }
650
- }
651
- function getGitRoot(cwd) {
652
- return git("rev-parse --show-toplevel", cwd).trim();
653
- }
654
- function git(args, cwd) {
655
- return execSync(`git ${args}`, {
656
- cwd,
657
- encoding: "utf-8",
658
- stdio: ["pipe", "pipe", "pipe"]
659
- });
625
+ function git(args, cwd) {
626
+ return execSync(`git ${args}`, {
627
+ cwd,
628
+ encoding: "utf-8",
629
+ stdio: ["pipe", "pipe", "pipe"]
630
+ });
660
631
  }
661
632
  var init_context = __esm(() => {
662
633
  init_logger();
@@ -915,11 +886,20 @@ var init_progress = __esm(() => {
915
886
  var exports_sandbox = {};
916
887
  __export(exports_sandbox, {
917
888
  resolveSandboxMode: () => resolveSandboxMode,
889
+ getProviderSandboxName: () => getProviderSandboxName,
890
+ getModelSandboxName: () => getModelSandboxName,
918
891
  displaySandboxWarning: () => displaySandboxWarning,
919
892
  detectSandboxSupport: () => detectSandboxSupport
920
893
  });
921
894
  import { execFile } from "node:child_process";
922
895
  import { createInterface } from "node:readline";
896
+ function getProviderSandboxName(config, provider) {
897
+ return config.providers[provider];
898
+ }
899
+ function getModelSandboxName(config, model, fallbackProvider) {
900
+ const provider = inferProviderFromModel(model) ?? fallbackProvider;
901
+ return getProviderSandboxName(config, provider);
902
+ }
923
903
  async function detectSandboxSupport() {
924
904
  if (cachedStatus)
925
905
  return cachedStatus;
@@ -1051,6 +1031,7 @@ function waitForEnter() {
1051
1031
  var TIMEOUT_MS = 5000, cachedStatus = null;
1052
1032
  var init_sandbox = __esm(() => {
1053
1033
  init_terminal();
1034
+ init_ai_models();
1054
1035
  init_logger();
1055
1036
  });
1056
1037
 
@@ -1816,14 +1797,13 @@ ${bold("Sandbox mode")} ${dim("(recommended)")}
1816
1797
  process.stderr.write(` Run AI agents in an isolated Docker sandbox for safety.
1817
1798
 
1818
1799
  `);
1819
- process.stderr.write(` ${gray("1.")} ${cyan("locus sandbox")} ${dim("Create the sandbox environment")}
1800
+ process.stderr.write(` ${gray("1.")} ${cyan("locus sandbox")} ${dim("Create claude/codex sandboxes")}
1820
1801
  `);
1821
1802
  process.stderr.write(` ${gray("2.")} ${cyan("locus sandbox claude")} ${dim("Login to Claude inside the sandbox")}
1822
1803
  `);
1823
- process.stderr.write(` ${gray("3.")} ${cyan("locus exec")} ${dim("All commands now run sandboxed")}
1804
+ process.stderr.write(` ${gray("3.")} ${cyan("locus sandbox codex")} ${dim("Login to Codex inside the sandbox")}
1824
1805
  `);
1825
- process.stderr.write(`
1826
- ${dim("Using Codex? Run")} ${cyan("locus sandbox codex")} ${dim("instead of step 2.")}
1806
+ process.stderr.write(` ${gray("4.")} ${cyan("locus exec")} ${dim("All commands now run sandboxed")}
1827
1807
  `);
1828
1808
  process.stderr.write(` ${dim("Learn more:")} ${cyan("locus sandbox help")}
1829
1809
  `);
@@ -4402,449 +4382,161 @@ var init_sandbox_ignore = __esm(() => {
4402
4382
  execAsync = promisify(exec);
4403
4383
  });
4404
4384
 
4405
- // src/core/run-state.ts
4406
- import {
4407
- existsSync as existsSync13,
4408
- mkdirSync as mkdirSync9,
4409
- readFileSync as readFileSync9,
4410
- unlinkSync as unlinkSync3,
4411
- writeFileSync as writeFileSync6
4412
- } from "node:fs";
4413
- import { dirname as dirname3, join as join12 } from "node:path";
4414
- function getRunStatePath(projectRoot) {
4415
- return join12(projectRoot, ".locus", "run-state.json");
4416
- }
4417
- function loadRunState(projectRoot) {
4418
- const path = getRunStatePath(projectRoot);
4419
- if (!existsSync13(path))
4420
- return null;
4421
- try {
4422
- return JSON.parse(readFileSync9(path, "utf-8"));
4423
- } catch {
4424
- getLogger().warn("Corrupted run-state.json, ignoring");
4425
- return null;
4426
- }
4427
- }
4428
- function saveRunState(projectRoot, state) {
4429
- const path = getRunStatePath(projectRoot);
4430
- const dir = dirname3(path);
4431
- if (!existsSync13(dir)) {
4432
- mkdirSync9(dir, { recursive: true });
4433
- }
4434
- writeFileSync6(path, `${JSON.stringify(state, null, 2)}
4435
- `, "utf-8");
4436
- }
4437
- function clearRunState(projectRoot) {
4438
- const path = getRunStatePath(projectRoot);
4439
- if (existsSync13(path)) {
4440
- unlinkSync3(path);
4441
- }
4442
- }
4443
- function createSprintRunState(sprint, branch, issues) {
4444
- return {
4445
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4446
- type: "sprint",
4447
- sprint,
4448
- branch,
4449
- startedAt: new Date().toISOString(),
4450
- tasks: issues.map(({ number, order }) => ({
4451
- issue: number,
4452
- order,
4453
- status: "pending"
4454
- }))
4455
- };
4456
- }
4457
- function createParallelRunState(issueNumbers) {
4458
- return {
4459
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4460
- type: "parallel",
4461
- startedAt: new Date().toISOString(),
4462
- tasks: issueNumbers.map((issue, i) => ({
4463
- issue,
4464
- order: i + 1,
4465
- status: "pending"
4466
- }))
4467
- };
4468
- }
4469
- function markTaskInProgress(state, issueNumber) {
4470
- const task = state.tasks.find((t) => t.issue === issueNumber);
4471
- if (task) {
4472
- task.status = "in_progress";
4385
+ // src/ai/claude-sandbox.ts
4386
+ import { spawn as spawn3 } from "node:child_process";
4387
+
4388
+ class SandboxedClaudeRunner {
4389
+ sandboxName;
4390
+ name = "claude-sandboxed";
4391
+ process = null;
4392
+ aborted = false;
4393
+ constructor(sandboxName) {
4394
+ this.sandboxName = sandboxName;
4473
4395
  }
4474
- }
4475
- function markTaskDone(state, issueNumber, prNumber) {
4476
- const task = state.tasks.find((t) => t.issue === issueNumber);
4477
- if (task) {
4478
- task.status = "done";
4479
- task.completedAt = new Date().toISOString();
4480
- if (prNumber)
4481
- task.pr = prNumber;
4396
+ async isAvailable() {
4397
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4398
+ const delegate = new ClaudeRunner2;
4399
+ return delegate.isAvailable();
4482
4400
  }
4483
- }
4484
- function markTaskFailed(state, issueNumber, error) {
4485
- const task = state.tasks.find((t) => t.issue === issueNumber);
4486
- if (task) {
4487
- task.status = "failed";
4488
- task.failedAt = new Date().toISOString();
4489
- task.error = error;
4490
- }
4491
- }
4492
- function getRunStats(state) {
4493
- const tasks = state.tasks;
4494
- return {
4495
- total: tasks.length,
4496
- done: tasks.filter((t) => t.status === "done").length,
4497
- failed: tasks.filter((t) => t.status === "failed").length,
4498
- pending: tasks.filter((t) => t.status === "pending").length,
4499
- inProgress: tasks.filter((t) => t.status === "in_progress").length
4500
- };
4501
- }
4502
- function getNextTask(state) {
4503
- const failed = state.tasks.find((t) => t.status === "failed");
4504
- if (failed)
4505
- return failed;
4506
- return state.tasks.find((t) => t.status === "pending") ?? null;
4507
- }
4508
- var init_run_state = __esm(() => {
4509
- init_logger();
4510
- });
4511
-
4512
- // src/core/shutdown.ts
4513
- import { execSync as execSync6 } from "node:child_process";
4514
- function registerActiveSandbox(name) {
4515
- activeSandboxes.add(name);
4516
- }
4517
- function unregisterActiveSandbox(name) {
4518
- activeSandboxes.delete(name);
4519
- }
4520
- function cleanupActiveSandboxes() {
4521
- for (const name of activeSandboxes) {
4522
- try {
4523
- execSync6(`docker sandbox rm ${name}`, { timeout: 1e4 });
4524
- } catch {}
4525
- }
4526
- activeSandboxes.clear();
4527
- }
4528
- function registerShutdownHandlers(ctx) {
4529
- shutdownContext = ctx;
4530
- interruptCount = 0;
4531
- const handler = () => {
4532
- interruptCount++;
4533
- if (interruptCount >= 2) {
4534
- process.stderr.write(`
4535
- Force exit.
4536
- `);
4537
- process.exit(1);
4538
- }
4539
- process.stderr.write(`
4540
-
4541
- Interrupted. Saving state...
4542
- `);
4543
- const state = shutdownContext?.getRunState?.();
4544
- if (state && shutdownContext) {
4545
- for (const task of state.tasks) {
4546
- if (task.status === "in_progress") {
4547
- task.status = "failed";
4548
- task.failedAt = new Date().toISOString();
4549
- task.error = "Interrupted by user";
4550
- }
4551
- }
4552
- try {
4553
- saveRunState(shutdownContext.projectRoot, state);
4554
- process.stderr.write(`State saved. Resume with: locus run --resume
4555
- `);
4556
- } catch {
4557
- process.stderr.write(`Warning: Could not save run state.
4558
- `);
4559
- }
4560
- }
4561
- cleanupActiveSandboxes();
4562
- shutdownContext?.onShutdown?.();
4563
- if (interruptTimer)
4564
- clearTimeout(interruptTimer);
4565
- interruptTimer = setTimeout(() => {
4566
- interruptCount = 0;
4567
- }, 2000);
4568
- setTimeout(() => {
4569
- process.exit(130);
4570
- }, 100);
4571
- };
4572
- if (!shutdownRegistered) {
4573
- process.on("SIGINT", handler);
4574
- process.on("SIGTERM", handler);
4575
- shutdownRegistered = true;
4576
- }
4577
- return () => {
4578
- process.removeListener("SIGINT", handler);
4579
- process.removeListener("SIGTERM", handler);
4580
- shutdownRegistered = false;
4581
- shutdownContext = null;
4582
- interruptCount = 0;
4583
- if (interruptTimer) {
4584
- clearTimeout(interruptTimer);
4585
- interruptTimer = null;
4586
- }
4587
- };
4588
- }
4589
- var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
4590
- var init_shutdown = __esm(() => {
4591
- init_run_state();
4592
- activeSandboxes = new Set;
4593
- });
4594
-
4595
- // src/ai/claude-sandbox.ts
4596
- import { execSync as execSync7, spawn as spawn3 } from "node:child_process";
4597
-
4598
- class SandboxedClaudeRunner {
4599
- name = "claude-sandboxed";
4600
- process = null;
4601
- aborted = false;
4602
- sandboxName = null;
4603
- persistent;
4604
- sandboxCreated = false;
4605
- userManaged = false;
4606
- constructor(persistentName, userManaged = false) {
4607
- if (persistentName) {
4608
- this.persistent = true;
4609
- this.sandboxName = persistentName;
4610
- this.userManaged = userManaged;
4611
- if (userManaged) {
4612
- this.sandboxCreated = true;
4613
- }
4614
- } else {
4615
- this.persistent = false;
4616
- }
4617
- }
4618
- async isAvailable() {
4619
- const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4620
- const delegate = new ClaudeRunner2;
4621
- return delegate.isAvailable();
4622
- }
4623
- async getVersion() {
4624
- const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4625
- const delegate = new ClaudeRunner2;
4626
- return delegate.getVersion();
4401
+ async getVersion() {
4402
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4403
+ const delegate = new ClaudeRunner2;
4404
+ return delegate.getVersion();
4627
4405
  }
4628
4406
  async execute(options) {
4629
4407
  const log = getLogger();
4630
4408
  this.aborted = false;
4631
- const claudeArgs = ["-p", options.prompt, ...buildClaudeArgs(options)];
4632
- let dockerArgs;
4633
- if (this.persistent && !this.sandboxName) {
4634
- throw new Error("Sandbox name is required");
4635
- }
4636
- if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
4637
- const name = this.sandboxName;
4638
- if (!name) {
4639
- throw new Error("Sandbox name is required");
4640
- }
4641
- options.onStatusChange?.("Syncing sandbox...");
4642
- await enforceSandboxIgnore(name, options.cwd);
4643
- options.onStatusChange?.("Thinking...");
4644
- dockerArgs = [
4645
- "sandbox",
4646
- "exec",
4647
- "-w",
4648
- options.cwd,
4649
- name,
4650
- "claude",
4651
- ...claudeArgs
4652
- ];
4653
- } else {
4654
- if (!this.persistent) {
4655
- this.sandboxName = buildSandboxName(options);
4656
- }
4657
- const name = this.sandboxName;
4658
- if (!name) {
4659
- throw new Error("Sandbox name is required");
4660
- }
4661
- registerActiveSandbox(name);
4662
- options.onStatusChange?.("Syncing sandbox...");
4663
- dockerArgs = [
4664
- "sandbox",
4665
- "run",
4666
- "--name",
4667
- name,
4668
- "claude",
4669
- options.cwd,
4670
- "--",
4671
- ...claudeArgs
4672
- ];
4409
+ if (!await this.isSandboxRunning()) {
4410
+ return {
4411
+ success: false,
4412
+ output: "",
4413
+ error: `Sandbox is not running: ${this.sandboxName}`,
4414
+ exitCode: 1
4415
+ };
4673
4416
  }
4417
+ const claudeArgs = ["-p", options.prompt, ...buildClaudeArgs(options)];
4418
+ options.onStatusChange?.("Syncing sandbox...");
4419
+ await enforceSandboxIgnore(this.sandboxName, options.cwd);
4420
+ options.onStatusChange?.("Thinking...");
4421
+ const dockerArgs = [
4422
+ "sandbox",
4423
+ "exec",
4424
+ "-w",
4425
+ options.cwd,
4426
+ this.sandboxName,
4427
+ "claude",
4428
+ ...claudeArgs
4429
+ ];
4674
4430
  log.debug("Spawning sandboxed claude", {
4675
4431
  sandboxName: this.sandboxName,
4676
- persistent: this.persistent,
4677
- reusing: this.persistent && this.sandboxCreated,
4678
4432
  args: dockerArgs.join(" "),
4679
4433
  cwd: options.cwd
4680
4434
  });
4681
- try {
4682
- return await new Promise((resolve2) => {
4683
- let output = "";
4684
- let errorOutput = "";
4685
- this.process = spawn3("docker", dockerArgs, {
4686
- stdio: ["ignore", "pipe", "pipe"],
4687
- env: process.env
4688
- });
4689
- if (this.persistent && !this.sandboxCreated) {
4690
- this.process.on("spawn", () => {
4691
- this.sandboxCreated = true;
4692
- });
4693
- }
4694
- if (options.verbose) {
4695
- let lineBuffer = "";
4696
- const seenToolIds = new Set;
4697
- this.process.stdout?.on("data", (chunk) => {
4698
- lineBuffer += chunk.toString();
4699
- const lines = lineBuffer.split(`
4700
- `);
4701
- lineBuffer = lines.pop() ?? "";
4702
- for (const line of lines) {
4703
- if (!line.trim())
4704
- continue;
4705
- try {
4706
- const event = JSON.parse(line);
4707
- if (event.type === "assistant" && event.message?.content) {
4708
- for (const item of event.message.content) {
4709
- if (item.type === "tool_use" && item.id && !seenToolIds.has(item.id)) {
4710
- seenToolIds.add(item.id);
4711
- options.onToolActivity?.(formatToolCall2(item.name ?? "", item.input ?? {}));
4712
- }
4435
+ return await new Promise((resolve2) => {
4436
+ let output = "";
4437
+ let errorOutput = "";
4438
+ this.process = spawn3("docker", dockerArgs, {
4439
+ stdio: ["ignore", "pipe", "pipe"],
4440
+ env: process.env
4441
+ });
4442
+ if (options.verbose) {
4443
+ let lineBuffer = "";
4444
+ const seenToolIds = new Set;
4445
+ this.process.stdout?.on("data", (chunk) => {
4446
+ lineBuffer += chunk.toString();
4447
+ const lines = lineBuffer.split(`
4448
+ `);
4449
+ lineBuffer = lines.pop() ?? "";
4450
+ for (const line of lines) {
4451
+ if (!line.trim())
4452
+ continue;
4453
+ try {
4454
+ const event = JSON.parse(line);
4455
+ if (event.type === "assistant" && event.message?.content) {
4456
+ for (const item of event.message.content) {
4457
+ if (item.type === "tool_use" && item.id && !seenToolIds.has(item.id)) {
4458
+ seenToolIds.add(item.id);
4459
+ options.onToolActivity?.(formatToolCall2(item.name ?? "", item.input ?? {}));
4713
4460
  }
4714
- } else if (event.type === "result") {
4715
- const text = event.result ?? "";
4716
- output = text;
4717
- options.onOutput?.(text);
4718
4461
  }
4719
- } catch {
4720
- const newLine = `${line}
4721
- `;
4722
- output += newLine;
4723
- options.onOutput?.(newLine);
4462
+ } else if (event.type === "result") {
4463
+ const text = event.result ?? "";
4464
+ output = text;
4465
+ options.onOutput?.(text);
4724
4466
  }
4467
+ } catch {
4468
+ const newLine = `${line}
4469
+ `;
4470
+ output += newLine;
4471
+ options.onOutput?.(newLine);
4725
4472
  }
4726
- });
4727
- } else {
4728
- this.process.stdout?.on("data", (chunk) => {
4729
- const text = chunk.toString();
4730
- output += text;
4731
- options.onOutput?.(text);
4732
- });
4733
- }
4734
- this.process.stderr?.on("data", (chunk) => {
4473
+ }
4474
+ });
4475
+ } else {
4476
+ this.process.stdout?.on("data", (chunk) => {
4735
4477
  const text = chunk.toString();
4736
- errorOutput += text;
4737
- log.debug("sandboxed claude stderr", { text: text.slice(0, 500) });
4478
+ output += text;
4738
4479
  options.onOutput?.(text);
4739
4480
  });
4740
- this.process.on("close", (code) => {
4741
- this.process = null;
4742
- if (this.aborted) {
4743
- resolve2({
4744
- success: false,
4745
- output,
4746
- error: "Aborted by user",
4747
- exitCode: code ?? 143
4748
- });
4749
- return;
4750
- }
4751
- if (code === 0) {
4752
- resolve2({
4753
- success: true,
4754
- output,
4755
- exitCode: 0
4756
- });
4757
- } else {
4758
- resolve2({
4759
- success: false,
4760
- output,
4761
- error: errorOutput || `sandboxed claude exited with code ${code}`,
4762
- exitCode: code ?? 1
4763
- });
4764
- }
4765
- });
4766
- this.process.on("error", (err) => {
4767
- this.process = null;
4768
- if (this.persistent && !this.sandboxCreated) {}
4481
+ }
4482
+ this.process.stderr?.on("data", (chunk) => {
4483
+ const text = chunk.toString();
4484
+ errorOutput += text;
4485
+ log.debug("sandboxed claude stderr", { text: text.slice(0, 500) });
4486
+ options.onOutput?.(text);
4487
+ });
4488
+ this.process.on("close", (code) => {
4489
+ this.process = null;
4490
+ if (this.aborted) {
4769
4491
  resolve2({
4770
4492
  success: false,
4771
4493
  output,
4772
- error: `Failed to spawn docker sandbox: ${err.message}`,
4773
- exitCode: 1
4494
+ error: "Aborted by user",
4495
+ exitCode: code ?? 143
4774
4496
  });
4775
- });
4776
- if (options.signal) {
4777
- options.signal.addEventListener("abort", () => {
4778
- this.abort();
4497
+ return;
4498
+ }
4499
+ if (code === 0) {
4500
+ resolve2({ success: true, output, exitCode: 0 });
4501
+ } else {
4502
+ resolve2({
4503
+ success: false,
4504
+ output,
4505
+ error: errorOutput || `sandboxed claude exited with code ${code}`,
4506
+ exitCode: code ?? 1
4779
4507
  });
4780
4508
  }
4781
4509
  });
4782
- } finally {
4783
- if (!this.persistent) {
4784
- this.cleanupSandbox();
4510
+ this.process.on("error", (err) => {
4511
+ this.process = null;
4512
+ resolve2({
4513
+ success: false,
4514
+ output,
4515
+ error: `Failed to spawn docker sandbox: ${err.message}`,
4516
+ exitCode: 1
4517
+ });
4518
+ });
4519
+ if (options.signal) {
4520
+ options.signal.addEventListener("abort", () => {
4521
+ this.abort();
4522
+ });
4785
4523
  }
4786
- }
4524
+ });
4787
4525
  }
4788
4526
  abort() {
4789
4527
  this.aborted = true;
4790
- const log = getLogger();
4791
- if (this.persistent) {
4792
- log.debug("Aborting sandboxed claude (persistent — keeping sandbox)", {
4793
- sandboxName: this.sandboxName
4794
- });
4528
+ if (!this.process)
4529
+ return;
4530
+ this.process.kill("SIGTERM");
4531
+ const timer = setTimeout(() => {
4795
4532
  if (this.process) {
4796
- this.process.kill("SIGTERM");
4797
- const timer = setTimeout(() => {
4798
- if (this.process) {
4799
- this.process.kill("SIGKILL");
4800
- }
4801
- }, 3000);
4802
- if (timer.unref)
4803
- timer.unref();
4533
+ this.process.kill("SIGKILL");
4804
4534
  }
4805
- } else {
4806
- if (!this.sandboxName)
4807
- return;
4808
- log.debug("Aborting sandboxed claude (ephemeral — removing sandbox)", {
4809
- sandboxName: this.sandboxName
4810
- });
4811
- try {
4812
- execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4813
- } catch {}
4814
- }
4815
- }
4816
- destroy() {
4817
- if (!this.sandboxName)
4818
- return;
4819
- if (this.userManaged) {
4820
- unregisterActiveSandbox(this.sandboxName);
4821
- return;
4822
- }
4823
- const log = getLogger();
4824
- log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
4825
- try {
4826
- execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4827
- } catch {}
4828
- unregisterActiveSandbox(this.sandboxName);
4829
- this.sandboxName = null;
4830
- this.sandboxCreated = false;
4831
- }
4832
- cleanupSandbox() {
4833
- if (!this.sandboxName)
4834
- return;
4835
- const log = getLogger();
4836
- log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
4837
- try {
4838
- execSync7(`docker sandbox rm ${this.sandboxName}`, {
4839
- timeout: 60000
4840
- });
4841
- } catch {}
4842
- unregisterActiveSandbox(this.sandboxName);
4843
- this.sandboxName = null;
4535
+ }, 3000);
4536
+ if (timer.unref)
4537
+ timer.unref();
4844
4538
  }
4845
4539
  async isSandboxRunning() {
4846
- if (!this.sandboxName)
4847
- return false;
4848
4540
  try {
4849
4541
  const { promisify: promisify2 } = await import("node:util");
4850
4542
  const { exec: exec2 } = await import("node:child_process");
@@ -4857,24 +4549,6 @@ class SandboxedClaudeRunner {
4857
4549
  return false;
4858
4550
  }
4859
4551
  }
4860
- getSandboxName() {
4861
- return this.sandboxName;
4862
- }
4863
- }
4864
- function buildSandboxName(options) {
4865
- const ts = Date.now();
4866
- if (options.activity) {
4867
- const match = options.activity.match(/issue\s*#(\d+)/i);
4868
- if (match) {
4869
- return `locus-issue-${match[1]}-${ts}`;
4870
- }
4871
- }
4872
- const segment = options.cwd.split("/").pop() ?? "run";
4873
- return `locus-${segment}-${ts}`;
4874
- }
4875
- function buildPersistentSandboxName(cwd) {
4876
- const segment = cwd.split("/").pop() ?? "repl";
4877
- return `locus-${segment}-${Date.now()}`;
4878
4552
  }
4879
4553
  function formatToolCall2(name, input) {
4880
4554
  switch (name) {
@@ -4898,7 +4572,7 @@ function formatToolCall2(name, input) {
4898
4572
  case "WebSearch":
4899
4573
  return `searching: ${input.query ?? ""}`;
4900
4574
  case "Task":
4901
- return `spawning agent`;
4575
+ return "spawning agent";
4902
4576
  default:
4903
4577
  return name;
4904
4578
  }
@@ -4906,12 +4580,11 @@ function formatToolCall2(name, input) {
4906
4580
  var init_claude_sandbox = __esm(() => {
4907
4581
  init_logger();
4908
4582
  init_sandbox_ignore();
4909
- init_shutdown();
4910
4583
  init_claude();
4911
4584
  });
4912
4585
 
4913
4586
  // src/ai/codex.ts
4914
- import { execSync as execSync8, spawn as spawn4 } from "node:child_process";
4587
+ import { execSync as execSync6, spawn as spawn4 } from "node:child_process";
4915
4588
  function buildCodexArgs(model) {
4916
4589
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
4917
4590
  if (model) {
@@ -4927,7 +4600,7 @@ class CodexRunner {
4927
4600
  aborted = false;
4928
4601
  async isAvailable() {
4929
4602
  try {
4930
- execSync8("codex --version", {
4603
+ execSync6("codex --version", {
4931
4604
  encoding: "utf-8",
4932
4605
  stdio: ["pipe", "pipe", "pipe"]
4933
4606
  });
@@ -4938,7 +4611,7 @@ class CodexRunner {
4938
4611
  }
4939
4612
  async getVersion() {
4940
4613
  try {
4941
- const output = execSync8("codex --version", {
4614
+ const output = execSync6("codex --version", {
4942
4615
  encoding: "utf-8",
4943
4616
  stdio: ["pipe", "pipe", "pipe"]
4944
4617
  }).trim();
@@ -5086,28 +4759,16 @@ var init_codex = __esm(() => {
5086
4759
  });
5087
4760
 
5088
4761
  // src/ai/codex-sandbox.ts
5089
- import { execSync as execSync9, spawn as spawn5 } from "node:child_process";
4762
+ import { spawn as spawn5 } from "node:child_process";
5090
4763
 
5091
4764
  class SandboxedCodexRunner {
4765
+ sandboxName;
5092
4766
  name = "codex-sandboxed";
5093
4767
  process = null;
5094
4768
  aborted = false;
5095
- sandboxName = null;
5096
- persistent;
5097
- sandboxCreated = false;
5098
- userManaged = false;
5099
4769
  codexInstalled = false;
5100
- constructor(persistentName, userManaged = false) {
5101
- if (persistentName) {
5102
- this.persistent = true;
5103
- this.sandboxName = persistentName;
5104
- this.userManaged = userManaged;
5105
- if (userManaged) {
5106
- this.sandboxCreated = true;
5107
- }
5108
- } else {
5109
- this.persistent = false;
5110
- }
4770
+ constructor(sandboxName) {
4771
+ this.sandboxName = sandboxName;
5111
4772
  }
5112
4773
  async isAvailable() {
5113
4774
  const delegate = new CodexRunner;
@@ -5120,251 +4781,158 @@ class SandboxedCodexRunner {
5120
4781
  async execute(options) {
5121
4782
  const log = getLogger();
5122
4783
  this.aborted = false;
5123
- const codexArgs = buildCodexArgs(options.model);
5124
- let dockerArgs;
5125
- if (this.persistent && !this.sandboxName) {
5126
- throw new Error("Sandbox name is required");
4784
+ if (!await this.isSandboxRunning()) {
4785
+ return {
4786
+ success: false,
4787
+ output: "",
4788
+ error: `Sandbox is not running: ${this.sandboxName}`,
4789
+ exitCode: 1
4790
+ };
5127
4791
  }
5128
- if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
5129
- const name = this.sandboxName;
5130
- if (!name) {
5131
- throw new Error("Sandbox name is required");
5132
- }
5133
- options.onStatusChange?.("Syncing sandbox...");
5134
- await enforceSandboxIgnore(name, options.cwd);
5135
- if (!this.codexInstalled) {
5136
- options.onStatusChange?.("Checking codex...");
5137
- await this.ensureCodexInstalled(name);
5138
- this.codexInstalled = true;
5139
- }
5140
- options.onStatusChange?.("Thinking...");
5141
- dockerArgs = [
5142
- "sandbox",
5143
- "exec",
5144
- "-i",
5145
- "-w",
5146
- options.cwd,
5147
- name,
5148
- "codex",
5149
- ...codexArgs
5150
- ];
5151
- } else {
5152
- if (!this.persistent) {
5153
- this.sandboxName = buildSandboxName2(options);
5154
- }
5155
- const name = this.sandboxName;
5156
- if (!name) {
5157
- throw new Error("Sandbox name is required");
5158
- }
5159
- registerActiveSandbox(name);
5160
- options.onStatusChange?.("Creating sandbox...");
5161
- await this.createSandboxWithClaude(name, options.cwd);
5162
- options.onStatusChange?.("Installing codex...");
5163
- await this.ensureCodexInstalled(name);
4792
+ const codexArgs = buildCodexArgs(options.model);
4793
+ options.onStatusChange?.("Syncing sandbox...");
4794
+ await enforceSandboxIgnore(this.sandboxName, options.cwd);
4795
+ if (!this.codexInstalled) {
4796
+ options.onStatusChange?.("Checking codex...");
4797
+ await this.ensureCodexInstalled(this.sandboxName);
5164
4798
  this.codexInstalled = true;
5165
- options.onStatusChange?.("Syncing sandbox...");
5166
- await enforceSandboxIgnore(name, options.cwd);
5167
- options.onStatusChange?.("Thinking...");
5168
- dockerArgs = [
5169
- "sandbox",
5170
- "exec",
5171
- "-i",
5172
- "-w",
5173
- options.cwd,
5174
- name,
5175
- "codex",
5176
- ...codexArgs
5177
- ];
5178
4799
  }
4800
+ options.onStatusChange?.("Thinking...");
4801
+ const dockerArgs = [
4802
+ "sandbox",
4803
+ "exec",
4804
+ "-i",
4805
+ "-w",
4806
+ options.cwd,
4807
+ this.sandboxName,
4808
+ "codex",
4809
+ ...codexArgs
4810
+ ];
5179
4811
  log.debug("Spawning sandboxed codex", {
5180
4812
  sandboxName: this.sandboxName,
5181
- persistent: this.persistent,
5182
- reusing: this.persistent && this.sandboxCreated,
5183
4813
  args: dockerArgs.join(" "),
5184
4814
  cwd: options.cwd
5185
4815
  });
5186
- try {
5187
- return await new Promise((resolve2) => {
5188
- let rawOutput = "";
5189
- let errorOutput = "";
5190
- this.process = spawn5("docker", dockerArgs, {
5191
- stdio: ["pipe", "pipe", "pipe"],
5192
- env: process.env
5193
- });
5194
- if (this.persistent && !this.sandboxCreated) {
5195
- this.process.on("spawn", () => {
5196
- this.sandboxCreated = true;
5197
- });
5198
- }
5199
- let agentMessages = [];
5200
- const flushAgentMessages = () => {
5201
- if (agentMessages.length > 0) {
5202
- options.onOutput?.(agentMessages.join(`
4816
+ return await new Promise((resolve2) => {
4817
+ let rawOutput = "";
4818
+ let errorOutput = "";
4819
+ this.process = spawn5("docker", dockerArgs, {
4820
+ stdio: ["pipe", "pipe", "pipe"],
4821
+ env: process.env
4822
+ });
4823
+ let agentMessages = [];
4824
+ const flushAgentMessages = () => {
4825
+ if (agentMessages.length > 0) {
4826
+ options.onOutput?.(agentMessages.join(`
5203
4827
 
5204
4828
  `));
5205
- agentMessages = [];
5206
- }
5207
- };
5208
- let lineBuffer = "";
5209
- this.process.stdout?.on("data", (chunk) => {
5210
- lineBuffer += chunk.toString();
5211
- const lines = lineBuffer.split(`
4829
+ agentMessages = [];
4830
+ }
4831
+ };
4832
+ let lineBuffer = "";
4833
+ this.process.stdout?.on("data", (chunk) => {
4834
+ lineBuffer += chunk.toString();
4835
+ const lines = lineBuffer.split(`
5212
4836
  `);
5213
- lineBuffer = lines.pop() ?? "";
5214
- for (const line of lines) {
5215
- if (!line.trim())
5216
- continue;
5217
- rawOutput += `${line}
4837
+ lineBuffer = lines.pop() ?? "";
4838
+ for (const line of lines) {
4839
+ if (!line.trim())
4840
+ continue;
4841
+ rawOutput += `${line}
5218
4842
  `;
5219
- log.debug("sandboxed codex stdout line", { line });
5220
- try {
5221
- const event = JSON.parse(line);
5222
- const { type, item } = event;
5223
- if (type === "item.started" && item?.type === "command_execution") {
5224
- const cmd = (item.command ?? "").split(`
4843
+ log.debug("sandboxed codex stdout line", { line });
4844
+ try {
4845
+ const event = JSON.parse(line);
4846
+ const { type, item } = event;
4847
+ if (type === "item.started" && item?.type === "command_execution") {
4848
+ const cmd = (item.command ?? "").split(`
5225
4849
  `)[0].slice(0, 80);
5226
- options.onToolActivity?.(`running: ${cmd}`);
5227
- } else if (type === "item.completed" && item?.type === "command_execution") {
5228
- const code = item.exit_code;
5229
- options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
5230
- } else if (type === "item.completed" && item?.type === "reasoning") {
5231
- const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
5232
- if (text)
5233
- options.onToolActivity?.(text);
5234
- } else if (type === "item.completed" && item?.type === "agent_message") {
5235
- const text = item.text ?? "";
5236
- if (text) {
5237
- agentMessages.push(text);
5238
- options.onToolActivity?.(text.split(`
4850
+ options.onToolActivity?.(`running: ${cmd}`);
4851
+ } else if (type === "item.completed" && item?.type === "command_execution") {
4852
+ const code = item.exit_code;
4853
+ options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
4854
+ } else if (type === "item.completed" && item?.type === "reasoning") {
4855
+ const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
4856
+ if (text)
4857
+ options.onToolActivity?.(text);
4858
+ } else if (type === "item.completed" && item?.type === "agent_message") {
4859
+ const text = item.text ?? "";
4860
+ if (text) {
4861
+ agentMessages.push(text);
4862
+ options.onToolActivity?.(text.split(`
5239
4863
  `)[0].slice(0, 80));
5240
- }
5241
- } else if (type === "turn.completed") {
5242
- flushAgentMessages();
5243
4864
  }
5244
- } catch {
5245
- const newLine = `${line}
5246
- `;
5247
- rawOutput += newLine;
5248
- options.onOutput?.(newLine);
4865
+ } else if (type === "turn.completed") {
4866
+ flushAgentMessages();
5249
4867
  }
4868
+ } catch {
4869
+ const newLine = `${line}
4870
+ `;
4871
+ rawOutput += newLine;
4872
+ options.onOutput?.(newLine);
5250
4873
  }
5251
- });
5252
- this.process.stderr?.on("data", (chunk) => {
5253
- const text = chunk.toString();
5254
- errorOutput += text;
5255
- log.debug("sandboxed codex stderr", { text: text.slice(0, 500) });
5256
- });
5257
- this.process.on("close", (code) => {
5258
- this.process = null;
5259
- flushAgentMessages();
5260
- if (this.aborted) {
5261
- resolve2({
5262
- success: false,
5263
- output: rawOutput,
5264
- error: "Aborted by user",
5265
- exitCode: code ?? 143
5266
- });
5267
- return;
5268
- }
5269
- if (code === 0) {
5270
- resolve2({
5271
- success: true,
5272
- output: rawOutput,
5273
- exitCode: 0
5274
- });
5275
- } else {
5276
- resolve2({
5277
- success: false,
5278
- output: rawOutput,
5279
- error: errorOutput || `sandboxed codex exited with code ${code}`,
5280
- exitCode: code ?? 1
5281
- });
5282
- }
5283
- });
5284
- this.process.on("error", (err) => {
5285
- this.process = null;
5286
- if (this.persistent && !this.sandboxCreated) {}
4874
+ }
4875
+ });
4876
+ this.process.stderr?.on("data", (chunk) => {
4877
+ const text = chunk.toString();
4878
+ errorOutput += text;
4879
+ log.debug("sandboxed codex stderr", { text: text.slice(0, 500) });
4880
+ });
4881
+ this.process.on("close", (code) => {
4882
+ this.process = null;
4883
+ flushAgentMessages();
4884
+ if (this.aborted) {
5287
4885
  resolve2({
5288
4886
  success: false,
5289
4887
  output: rawOutput,
5290
- error: `Failed to spawn docker sandbox: ${err.message}`,
5291
- exitCode: 1
4888
+ error: "Aborted by user",
4889
+ exitCode: code ?? 143
5292
4890
  });
5293
- });
5294
- if (options.signal) {
5295
- options.signal.addEventListener("abort", () => {
5296
- this.abort();
4891
+ return;
4892
+ }
4893
+ if (code === 0) {
4894
+ resolve2({ success: true, output: rawOutput, exitCode: 0 });
4895
+ } else {
4896
+ resolve2({
4897
+ success: false,
4898
+ output: rawOutput,
4899
+ error: errorOutput || `sandboxed codex exited with code ${code}`,
4900
+ exitCode: code ?? 1
5297
4901
  });
5298
4902
  }
5299
- this.process.stdin?.write(options.prompt);
5300
- this.process.stdin?.end();
5301
4903
  });
5302
- } finally {
5303
- if (!this.persistent) {
5304
- this.cleanupSandbox();
4904
+ this.process.on("error", (err) => {
4905
+ this.process = null;
4906
+ resolve2({
4907
+ success: false,
4908
+ output: rawOutput,
4909
+ error: `Failed to spawn docker sandbox: ${err.message}`,
4910
+ exitCode: 1
4911
+ });
4912
+ });
4913
+ if (options.signal) {
4914
+ options.signal.addEventListener("abort", () => {
4915
+ this.abort();
4916
+ });
5305
4917
  }
5306
- }
4918
+ this.process.stdin?.write(options.prompt);
4919
+ this.process.stdin?.end();
4920
+ });
5307
4921
  }
5308
4922
  abort() {
5309
4923
  this.aborted = true;
5310
- const log = getLogger();
5311
- if (this.persistent) {
5312
- log.debug("Aborting sandboxed codex (persistent — keeping sandbox)", {
5313
- sandboxName: this.sandboxName
5314
- });
4924
+ if (!this.process)
4925
+ return;
4926
+ this.process.kill("SIGTERM");
4927
+ const timer = setTimeout(() => {
5315
4928
  if (this.process) {
5316
- this.process.kill("SIGTERM");
5317
- const timer = setTimeout(() => {
5318
- if (this.process) {
5319
- this.process.kill("SIGKILL");
5320
- }
5321
- }, 3000);
5322
- if (timer.unref)
5323
- timer.unref();
4929
+ this.process.kill("SIGKILL");
5324
4930
  }
5325
- } else {
5326
- if (!this.sandboxName)
5327
- return;
5328
- log.debug("Aborting sandboxed codex (ephemeral — removing sandbox)", {
5329
- sandboxName: this.sandboxName
5330
- });
5331
- try {
5332
- execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5333
- } catch {}
5334
- }
5335
- }
5336
- destroy() {
5337
- if (!this.sandboxName)
5338
- return;
5339
- if (this.userManaged) {
5340
- unregisterActiveSandbox(this.sandboxName);
5341
- return;
5342
- }
5343
- const log = getLogger();
5344
- log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
5345
- try {
5346
- execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5347
- } catch {}
5348
- unregisterActiveSandbox(this.sandboxName);
5349
- this.sandboxName = null;
5350
- this.sandboxCreated = false;
5351
- }
5352
- cleanupSandbox() {
5353
- if (!this.sandboxName)
5354
- return;
5355
- const log = getLogger();
5356
- log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
5357
- try {
5358
- execSync9(`docker sandbox rm ${this.sandboxName}`, {
5359
- timeout: 60000
5360
- });
5361
- } catch {}
5362
- unregisterActiveSandbox(this.sandboxName);
5363
- this.sandboxName = null;
4931
+ }, 3000);
4932
+ if (timer.unref)
4933
+ timer.unref();
5364
4934
  }
5365
4935
  async isSandboxRunning() {
5366
- if (!this.sandboxName)
5367
- return false;
5368
4936
  try {
5369
4937
  const { promisify: promisify2 } = await import("node:util");
5370
4938
  const { exec: exec2 } = await import("node:child_process");
@@ -5377,14 +4945,6 @@ class SandboxedCodexRunner {
5377
4945
  return false;
5378
4946
  }
5379
4947
  }
5380
- async createSandboxWithClaude(name, cwd) {
5381
- const { promisify: promisify2 } = await import("node:util");
5382
- const { exec: exec2 } = await import("node:child_process");
5383
- const execAsync2 = promisify2(exec2);
5384
- try {
5385
- await execAsync2(`docker sandbox run --name ${name} claude ${cwd} -- --version`, { timeout: 120000 });
5386
- } catch {}
5387
- }
5388
4948
  async ensureCodexInstalled(name) {
5389
4949
  const { promisify: promisify2 } = await import("node:util");
5390
4950
  const { exec: exec2 } = await import("node:child_process");
@@ -5394,38 +4954,28 @@ class SandboxedCodexRunner {
5394
4954
  timeout: 5000
5395
4955
  });
5396
4956
  } catch {
5397
- await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, { timeout: 120000 });
5398
- }
5399
- }
5400
- getSandboxName() {
5401
- return this.sandboxName;
5402
- }
5403
- }
5404
- function buildSandboxName2(options) {
5405
- const ts = Date.now();
5406
- if (options.activity) {
5407
- const match = options.activity.match(/issue\s*#(\d+)/i);
5408
- if (match) {
5409
- return `locus-codex-issue-${match[1]}-${ts}`;
4957
+ await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, {
4958
+ timeout: 120000
4959
+ });
5410
4960
  }
5411
4961
  }
5412
- const segment = options.cwd.split("/").pop() ?? "run";
5413
- return `locus-codex-${segment}-${ts}`;
5414
4962
  }
5415
4963
  var init_codex_sandbox = __esm(() => {
5416
4964
  init_logger();
5417
4965
  init_sandbox_ignore();
5418
- init_shutdown();
5419
4966
  init_codex();
5420
4967
  });
5421
4968
 
5422
4969
  // src/ai/runner.ts
5423
4970
  async function createRunnerAsync(provider, sandboxed) {
4971
+ if (sandboxed) {
4972
+ throw new Error("Sandboxed runner creation requires a provider sandbox name. Use createUserManagedSandboxRunner().");
4973
+ }
5424
4974
  switch (provider) {
5425
4975
  case "claude":
5426
- return sandboxed ? new SandboxedClaudeRunner : new ClaudeRunner;
4976
+ return new ClaudeRunner;
5427
4977
  case "codex":
5428
- return sandboxed ? new SandboxedCodexRunner : new CodexRunner;
4978
+ return new CodexRunner;
5429
4979
  default:
5430
4980
  throw new Error(`Unknown AI provider: ${provider}`);
5431
4981
  }
@@ -5433,9 +4983,9 @@ async function createRunnerAsync(provider, sandboxed) {
5433
4983
  function createUserManagedSandboxRunner(provider, sandboxName) {
5434
4984
  switch (provider) {
5435
4985
  case "claude":
5436
- return new SandboxedClaudeRunner(sandboxName, true);
4986
+ return new SandboxedClaudeRunner(sandboxName);
5437
4987
  case "codex":
5438
- return new SandboxedCodexRunner(sandboxName, true);
4988
+ return new SandboxedCodexRunner(sandboxName);
5439
4989
  default:
5440
4990
  throw new Error(`Unknown AI provider: ${provider}`);
5441
4991
  }
@@ -5535,10 +5085,20 @@ ${red("✗")} ${dim("Force exit.")}\r
5535
5085
  });
5536
5086
  if (options.runner) {
5537
5087
  runner = options.runner;
5538
- } else if (options.sandboxName) {
5088
+ } else if (options.sandboxed ?? true) {
5089
+ if (!options.sandboxName) {
5090
+ indicator.stop();
5091
+ return {
5092
+ success: false,
5093
+ output: "",
5094
+ error: `Sandbox for provider "${resolvedProvider}" is not configured. ` + `Run "locus sandbox" and authenticate via "locus sandbox ${resolvedProvider}".`,
5095
+ interrupted: false,
5096
+ exitCode: 1
5097
+ };
5098
+ }
5539
5099
  runner = createUserManagedSandboxRunner(resolvedProvider, options.sandboxName);
5540
5100
  } else {
5541
- runner = await createRunnerAsync(resolvedProvider, options.sandboxed ?? true);
5101
+ runner = await createRunnerAsync(resolvedProvider, false);
5542
5102
  }
5543
5103
  const available = await runner.isAvailable();
5544
5104
  if (!available) {
@@ -5864,7 +5424,7 @@ async function issueCreate(projectRoot, parsed) {
5864
5424
  silent: true,
5865
5425
  activity: "generating issue",
5866
5426
  sandboxed: config.sandbox.enabled,
5867
- sandboxName: config.sandbox.name
5427
+ sandboxName: getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider)
5868
5428
  });
5869
5429
  if (!aiResult.success && !aiResult.interrupted) {
5870
5430
  process.stderr.write(`${red("✗")} Failed to generate issue: ${aiResult.error}
@@ -6409,6 +5969,7 @@ var init_issue = __esm(() => {
6409
5969
  init_config();
6410
5970
  init_github();
6411
5971
  init_logger();
5972
+ init_sandbox();
6412
5973
  init_table();
6413
5974
  init_terminal();
6414
5975
  init_types();
@@ -7066,9 +6627,9 @@ var init_sprint = __esm(() => {
7066
6627
  });
7067
6628
 
7068
6629
  // src/core/prompt-builder.ts
7069
- import { execSync as execSync10 } from "node:child_process";
7070
- import { existsSync as existsSync14, readdirSync as readdirSync3, readFileSync as readFileSync10 } from "node:fs";
7071
- import { join as join13 } from "node:path";
6630
+ import { execSync as execSync7 } from "node:child_process";
6631
+ import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync9 } from "node:fs";
6632
+ import { join as join12 } from "node:path";
7072
6633
  function buildExecutionPrompt(ctx) {
7073
6634
  const sections = [];
7074
6635
  sections.push(buildSystemContext(ctx.projectRoot));
@@ -7098,13 +6659,13 @@ function buildFeedbackPrompt(ctx) {
7098
6659
  }
7099
6660
  function buildReplPrompt(userMessage, projectRoot, _config, previousMessages) {
7100
6661
  const sections = [];
7101
- const locusmd = readFileSafe(join13(projectRoot, ".locus", "LOCUS.md"));
6662
+ const locusmd = readFileSafe(join12(projectRoot, ".locus", "LOCUS.md"));
7102
6663
  if (locusmd) {
7103
6664
  sections.push(`<project-instructions>
7104
6665
  ${locusmd}
7105
6666
  </project-instructions>`);
7106
6667
  }
7107
- const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
6668
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
7108
6669
  if (learnings) {
7109
6670
  sections.push(`<past-learnings>
7110
6671
  ${learnings}
@@ -7130,24 +6691,24 @@ ${userMessage}
7130
6691
  }
7131
6692
  function buildSystemContext(projectRoot) {
7132
6693
  const parts = [];
7133
- const locusmd = readFileSafe(join13(projectRoot, ".locus", "LOCUS.md"));
6694
+ const locusmd = readFileSafe(join12(projectRoot, ".locus", "LOCUS.md"));
7134
6695
  if (locusmd) {
7135
6696
  parts.push(`<project-instructions>
7136
6697
  ${locusmd}
7137
6698
  </project-instructions>`);
7138
6699
  }
7139
- const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
6700
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
7140
6701
  if (learnings) {
7141
6702
  parts.push(`<past-learnings>
7142
6703
  ${learnings}
7143
6704
  </past-learnings>`);
7144
6705
  }
7145
- const discussionsDir = join13(projectRoot, ".locus", "discussions");
7146
- if (existsSync14(discussionsDir)) {
6706
+ const discussionsDir = join12(projectRoot, ".locus", "discussions");
6707
+ if (existsSync13(discussionsDir)) {
7147
6708
  try {
7148
6709
  const files = readdirSync3(discussionsDir).filter((f) => f.endsWith(".md")).slice(0, 3);
7149
6710
  for (const file of files) {
7150
- const content = readFileSafe(join13(discussionsDir, file));
6711
+ const content = readFileSafe(join12(discussionsDir, file));
7151
6712
  if (content) {
7152
6713
  const name = file.replace(".md", "");
7153
6714
  parts.push(`<discussion name="${name}">
@@ -7214,7 +6775,7 @@ ${parts.join(`
7214
6775
  function buildRepoContext(projectRoot) {
7215
6776
  const parts = [];
7216
6777
  try {
7217
- const tree = execSync10("find . -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.locus/*' -not -path '*/dist/*' -not -path '*/build/*' | head -80", { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
6778
+ const tree = execSync7("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();
7218
6779
  if (tree) {
7219
6780
  parts.push(`<file-tree>
7220
6781
  \`\`\`
@@ -7224,7 +6785,7 @@ ${tree}
7224
6785
  }
7225
6786
  } catch {}
7226
6787
  try {
7227
- const gitLog = execSync10("git log --oneline -10", {
6788
+ const gitLog = execSync7("git log --oneline -10", {
7228
6789
  cwd: projectRoot,
7229
6790
  encoding: "utf-8",
7230
6791
  stdio: ["pipe", "pipe", "pipe"]
@@ -7238,7 +6799,7 @@ ${gitLog}
7238
6799
  }
7239
6800
  } catch {}
7240
6801
  try {
7241
- const branch = execSync10("git rev-parse --abbrev-ref HEAD", {
6802
+ const branch = execSync7("git rev-parse --abbrev-ref HEAD", {
7242
6803
  cwd: projectRoot,
7243
6804
  encoding: "utf-8",
7244
6805
  stdio: ["pipe", "pipe", "pipe"]
@@ -7298,9 +6859,9 @@ function buildFeedbackInstructions() {
7298
6859
  }
7299
6860
  function readFileSafe(path) {
7300
6861
  try {
7301
- if (!existsSync14(path))
6862
+ if (!existsSync13(path))
7302
6863
  return null;
7303
- return readFileSync10(path, "utf-8");
6864
+ return readFileSync9(path, "utf-8");
7304
6865
  } catch {
7305
6866
  return null;
7306
6867
  }
@@ -7492,7 +7053,7 @@ var init_diff_renderer = __esm(() => {
7492
7053
  });
7493
7054
 
7494
7055
  // src/repl/commands.ts
7495
- import { execSync as execSync11 } from "node:child_process";
7056
+ import { execSync as execSync8 } from "node:child_process";
7496
7057
  function getSlashCommands() {
7497
7058
  return [
7498
7059
  {
@@ -7684,7 +7245,7 @@ function cmdModel(args, ctx) {
7684
7245
  }
7685
7246
  function cmdDiff(_args, ctx) {
7686
7247
  try {
7687
- const diff = execSync11("git diff", {
7248
+ const diff = execSync8("git diff", {
7688
7249
  cwd: ctx.projectRoot,
7689
7250
  encoding: "utf-8",
7690
7251
  stdio: ["pipe", "pipe", "pipe"]
@@ -7720,7 +7281,7 @@ function cmdDiff(_args, ctx) {
7720
7281
  }
7721
7282
  function cmdUndo(_args, ctx) {
7722
7283
  try {
7723
- const status = execSync11("git status --porcelain", {
7284
+ const status = execSync8("git status --porcelain", {
7724
7285
  cwd: ctx.projectRoot,
7725
7286
  encoding: "utf-8",
7726
7287
  stdio: ["pipe", "pipe", "pipe"]
@@ -7730,7 +7291,7 @@ function cmdUndo(_args, ctx) {
7730
7291
  `);
7731
7292
  return;
7732
7293
  }
7733
- execSync11("git checkout .", {
7294
+ execSync8("git checkout .", {
7734
7295
  cwd: ctx.projectRoot,
7735
7296
  encoding: "utf-8",
7736
7297
  stdio: ["pipe", "pipe", "pipe"]
@@ -7764,7 +7325,7 @@ var init_commands = __esm(() => {
7764
7325
 
7765
7326
  // src/repl/completions.ts
7766
7327
  import { readdirSync as readdirSync4 } from "node:fs";
7767
- import { basename as basename2, dirname as dirname4, join as join14 } from "node:path";
7328
+ import { basename as basename2, dirname as dirname3, join as join13 } from "node:path";
7768
7329
 
7769
7330
  class SlashCommandCompletion {
7770
7331
  commands;
@@ -7819,7 +7380,7 @@ class FilePathCompletion {
7819
7380
  }
7820
7381
  findMatches(partial) {
7821
7382
  try {
7822
- const dir = partial.includes("/") ? join14(this.projectRoot, dirname4(partial)) : this.projectRoot;
7383
+ const dir = partial.includes("/") ? join13(this.projectRoot, dirname3(partial)) : this.projectRoot;
7823
7384
  const prefix = basename2(partial);
7824
7385
  const entries = readdirSync4(dir, { withFileTypes: true });
7825
7386
  return entries.filter((e) => {
@@ -7830,7 +7391,7 @@ class FilePathCompletion {
7830
7391
  return e.name.startsWith(prefix);
7831
7392
  }).map((e) => {
7832
7393
  const name = e.isDirectory() ? `${e.name}/` : e.name;
7833
- return partial.includes("/") ? `${dirname4(partial)}/${name}` : name;
7394
+ return partial.includes("/") ? `${dirname3(partial)}/${name}` : name;
7834
7395
  }).slice(0, 20);
7835
7396
  } catch {
7836
7397
  return [];
@@ -7855,14 +7416,14 @@ class CombinedCompletion {
7855
7416
  var init_completions = () => {};
7856
7417
 
7857
7418
  // src/repl/input-history.ts
7858
- import { existsSync as existsSync15, mkdirSync as mkdirSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
7859
- import { dirname as dirname5, join as join15 } from "node:path";
7419
+ import { existsSync as existsSync14, mkdirSync as mkdirSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "node:fs";
7420
+ import { dirname as dirname4, join as join14 } from "node:path";
7860
7421
 
7861
7422
  class InputHistory {
7862
7423
  entries = [];
7863
7424
  filePath;
7864
7425
  constructor(projectRoot) {
7865
- this.filePath = join15(projectRoot, ".locus", "sessions", ".input-history");
7426
+ this.filePath = join14(projectRoot, ".locus", "sessions", ".input-history");
7866
7427
  this.load();
7867
7428
  }
7868
7429
  add(text) {
@@ -7901,22 +7462,22 @@ class InputHistory {
7901
7462
  }
7902
7463
  load() {
7903
7464
  try {
7904
- if (!existsSync15(this.filePath))
7465
+ if (!existsSync14(this.filePath))
7905
7466
  return;
7906
- const content = readFileSync11(this.filePath, "utf-8");
7467
+ const content = readFileSync10(this.filePath, "utf-8");
7907
7468
  this.entries = content.split(`
7908
7469
  `).map((line) => this.unescape(line)).filter(Boolean);
7909
7470
  } catch {}
7910
7471
  }
7911
7472
  save() {
7912
7473
  try {
7913
- const dir = dirname5(this.filePath);
7914
- if (!existsSync15(dir)) {
7915
- mkdirSync10(dir, { recursive: true });
7474
+ const dir = dirname4(this.filePath);
7475
+ if (!existsSync14(dir)) {
7476
+ mkdirSync9(dir, { recursive: true });
7916
7477
  }
7917
7478
  const content = this.entries.map((e) => this.escape(e)).join(`
7918
7479
  `);
7919
- writeFileSync7(this.filePath, content, "utf-8");
7480
+ writeFileSync6(this.filePath, content, "utf-8");
7920
7481
  } catch {}
7921
7482
  }
7922
7483
  escape(text) {
@@ -7942,21 +7503,21 @@ var init_model_config = __esm(() => {
7942
7503
 
7943
7504
  // src/repl/session-manager.ts
7944
7505
  import {
7945
- existsSync as existsSync16,
7946
- mkdirSync as mkdirSync11,
7506
+ existsSync as existsSync15,
7507
+ mkdirSync as mkdirSync10,
7947
7508
  readdirSync as readdirSync5,
7948
- readFileSync as readFileSync12,
7949
- unlinkSync as unlinkSync4,
7950
- writeFileSync as writeFileSync8
7509
+ readFileSync as readFileSync11,
7510
+ unlinkSync as unlinkSync3,
7511
+ writeFileSync as writeFileSync7
7951
7512
  } from "node:fs";
7952
- import { basename as basename3, join as join16 } from "node:path";
7513
+ import { basename as basename3, join as join15 } from "node:path";
7953
7514
 
7954
7515
  class SessionManager {
7955
7516
  sessionsDir;
7956
7517
  constructor(projectRoot) {
7957
- this.sessionsDir = join16(projectRoot, ".locus", "sessions");
7958
- if (!existsSync16(this.sessionsDir)) {
7959
- mkdirSync11(this.sessionsDir, { recursive: true });
7518
+ this.sessionsDir = join15(projectRoot, ".locus", "sessions");
7519
+ if (!existsSync15(this.sessionsDir)) {
7520
+ mkdirSync10(this.sessionsDir, { recursive: true });
7960
7521
  }
7961
7522
  }
7962
7523
  create(options) {
@@ -7981,14 +7542,14 @@ class SessionManager {
7981
7542
  }
7982
7543
  isPersisted(sessionOrId) {
7983
7544
  const sessionId = typeof sessionOrId === "string" ? sessionOrId : sessionOrId.id;
7984
- return existsSync16(this.getSessionPath(sessionId));
7545
+ return existsSync15(this.getSessionPath(sessionId));
7985
7546
  }
7986
7547
  load(idOrPrefix) {
7987
7548
  const files = this.listSessionFiles();
7988
7549
  const exactPath = this.getSessionPath(idOrPrefix);
7989
- if (existsSync16(exactPath)) {
7550
+ if (existsSync15(exactPath)) {
7990
7551
  try {
7991
- return JSON.parse(readFileSync12(exactPath, "utf-8"));
7552
+ return JSON.parse(readFileSync11(exactPath, "utf-8"));
7992
7553
  } catch {
7993
7554
  return null;
7994
7555
  }
@@ -7996,7 +7557,7 @@ class SessionManager {
7996
7557
  const matches = files.filter((f) => basename3(f, ".json").startsWith(idOrPrefix));
7997
7558
  if (matches.length === 1) {
7998
7559
  try {
7999
- return JSON.parse(readFileSync12(matches[0], "utf-8"));
7560
+ return JSON.parse(readFileSync11(matches[0], "utf-8"));
8000
7561
  } catch {
8001
7562
  return null;
8002
7563
  }
@@ -8009,7 +7570,7 @@ class SessionManager {
8009
7570
  save(session) {
8010
7571
  session.updated = new Date().toISOString();
8011
7572
  const path = this.getSessionPath(session.id);
8012
- writeFileSync8(path, `${JSON.stringify(session, null, 2)}
7573
+ writeFileSync7(path, `${JSON.stringify(session, null, 2)}
8013
7574
  `, "utf-8");
8014
7575
  }
8015
7576
  addMessage(session, message) {
@@ -8021,7 +7582,7 @@ class SessionManager {
8021
7582
  const sessions = [];
8022
7583
  for (const file of files) {
8023
7584
  try {
8024
- const session = JSON.parse(readFileSync12(file, "utf-8"));
7585
+ const session = JSON.parse(readFileSync11(file, "utf-8"));
8025
7586
  sessions.push({
8026
7587
  id: session.id,
8027
7588
  created: session.created,
@@ -8036,8 +7597,8 @@ class SessionManager {
8036
7597
  }
8037
7598
  delete(sessionId) {
8038
7599
  const path = this.getSessionPath(sessionId);
8039
- if (existsSync16(path)) {
8040
- unlinkSync4(path);
7600
+ if (existsSync15(path)) {
7601
+ unlinkSync3(path);
8041
7602
  return true;
8042
7603
  }
8043
7604
  return false;
@@ -8048,7 +7609,7 @@ class SessionManager {
8048
7609
  let pruned = 0;
8049
7610
  const withStats = files.map((f) => {
8050
7611
  try {
8051
- const session = JSON.parse(readFileSync12(f, "utf-8"));
7612
+ const session = JSON.parse(readFileSync11(f, "utf-8"));
8052
7613
  return { path: f, updated: new Date(session.updated).getTime() };
8053
7614
  } catch {
8054
7615
  return { path: f, updated: 0 };
@@ -8058,7 +7619,7 @@ class SessionManager {
8058
7619
  for (const entry of withStats) {
8059
7620
  if (now - entry.updated > SESSION_MAX_AGE_MS) {
8060
7621
  try {
8061
- unlinkSync4(entry.path);
7622
+ unlinkSync3(entry.path);
8062
7623
  pruned++;
8063
7624
  } catch {}
8064
7625
  }
@@ -8066,10 +7627,10 @@ class SessionManager {
8066
7627
  const remaining = withStats.length - pruned;
8067
7628
  if (remaining > MAX_SESSIONS) {
8068
7629
  const toRemove = remaining - MAX_SESSIONS;
8069
- const alive = withStats.filter((e) => existsSync16(e.path));
7630
+ const alive = withStats.filter((e) => existsSync15(e.path));
8070
7631
  for (let i = 0;i < toRemove && i < alive.length; i++) {
8071
7632
  try {
8072
- unlinkSync4(alive[i].path);
7633
+ unlinkSync3(alive[i].path);
8073
7634
  pruned++;
8074
7635
  } catch {}
8075
7636
  }
@@ -8081,7 +7642,7 @@ class SessionManager {
8081
7642
  }
8082
7643
  listSessionFiles() {
8083
7644
  try {
8084
- return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join16(this.sessionsDir, f));
7645
+ return readdirSync5(this.sessionsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => join15(this.sessionsDir, f));
8085
7646
  } catch {
8086
7647
  return [];
8087
7648
  }
@@ -8090,7 +7651,7 @@ class SessionManager {
8090
7651
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8091
7652
  }
8092
7653
  getSessionPath(sessionId) {
8093
- return join16(this.sessionsDir, `${sessionId}.json`);
7654
+ return join15(this.sessionsDir, `${sessionId}.json`);
8094
7655
  }
8095
7656
  }
8096
7657
  var MAX_SESSIONS = 50, SESSION_MAX_AGE_MS;
@@ -8100,7 +7661,7 @@ var init_session_manager = __esm(() => {
8100
7661
  });
8101
7662
 
8102
7663
  // src/repl/repl.ts
8103
- import { execSync as execSync12 } from "node:child_process";
7664
+ import { execSync as execSync9 } from "node:child_process";
8104
7665
  async function startRepl(options) {
8105
7666
  const { projectRoot, config } = options;
8106
7667
  const sessionManager = new SessionManager(projectRoot);
@@ -8118,7 +7679,7 @@ async function startRepl(options) {
8118
7679
  } else {
8119
7680
  let branch = "main";
8120
7681
  try {
8121
- branch = execSync12("git rev-parse --abbrev-ref HEAD", {
7682
+ branch = execSync9("git rev-parse --abbrev-ref HEAD", {
8122
7683
  cwd: projectRoot,
8123
7684
  encoding: "utf-8",
8124
7685
  stdio: ["pipe", "pipe", "pipe"]
@@ -8161,16 +7722,17 @@ async function executeOneShotPrompt(prompt, session, sessionManager, options) {
8161
7722
  async function runInteractiveRepl(session, sessionManager, options) {
8162
7723
  const { projectRoot, config } = options;
8163
7724
  let sandboxRunner = null;
8164
- if (config.sandbox.enabled && config.sandbox.name) {
7725
+ if (config.sandbox.enabled) {
8165
7726
  const provider = inferProviderFromModel(config.ai.model) || config.ai.provider;
8166
- sandboxRunner = createUserManagedSandboxRunner(provider, config.sandbox.name);
8167
- process.stderr.write(`${dim("Using sandbox")} ${dim(config.sandbox.name)}
7727
+ const sandboxName = getProviderSandboxName(config.sandbox, provider);
7728
+ if (sandboxName) {
7729
+ sandboxRunner = createUserManagedSandboxRunner(provider, sandboxName);
7730
+ process.stderr.write(`${dim("Using")} ${dim(provider)} ${dim("sandbox")} ${dim(sandboxName)}
8168
7731
  `);
8169
- } else if (config.sandbox.enabled) {
8170
- const sandboxName = buildPersistentSandboxName(projectRoot);
8171
- sandboxRunner = new SandboxedClaudeRunner(sandboxName);
8172
- process.stderr.write(`${dim("Sandbox mode: prompts will share sandbox")} ${dim(sandboxName)}
7732
+ } else {
7733
+ process.stderr.write(`${yellow("⚠")} ${dim(`No sandbox configured for ${provider}. Run locus sandbox.`)}
8173
7734
  `);
7735
+ }
8174
7736
  }
8175
7737
  const history = new InputHistory(projectRoot);
8176
7738
  const completion = new CombinedCompletion([
@@ -8204,10 +7766,17 @@ async function runInteractiveRepl(session, sessionManager, options) {
8204
7766
  const providerChanged = inferredProvider !== currentProvider;
8205
7767
  currentProvider = inferredProvider;
8206
7768
  session.metadata.provider = inferredProvider;
8207
- if (providerChanged && config.sandbox.enabled && config.sandbox.name) {
8208
- sandboxRunner = createUserManagedSandboxRunner(inferredProvider, config.sandbox.name);
8209
- process.stderr.write(`${dim("Switched sandbox agent to")} ${dim(inferredProvider)}
7769
+ if (providerChanged && config.sandbox.enabled) {
7770
+ const sandboxName = getProviderSandboxName(config.sandbox, inferredProvider);
7771
+ if (sandboxName) {
7772
+ sandboxRunner = createUserManagedSandboxRunner(inferredProvider, sandboxName);
7773
+ process.stderr.write(`${dim("Switched sandbox agent to")} ${dim(inferredProvider)} ${dim(`(${sandboxName})`)}
7774
+ `);
7775
+ } else {
7776
+ sandboxRunner = null;
7777
+ process.stderr.write(`${yellow("⚠")} ${dim(`No sandbox configured for ${inferredProvider}. Run locus sandbox.`)}
8210
7778
  `);
7779
+ }
8211
7780
  }
8212
7781
  }
8213
7782
  persistReplModelSelection(projectRoot, config, model);
@@ -8279,10 +7848,6 @@ ${red("✗")} ${msg}
8279
7848
  break;
8280
7849
  }
8281
7850
  }
8282
- if (sandboxRunner && "destroy" in sandboxRunner) {
8283
- const runner = sandboxRunner;
8284
- runner.destroy();
8285
- }
8286
7851
  const shouldPersistOnExit = session.messages.length > 0 || sessionManager.isPersisted(session);
8287
7852
  if (shouldPersistOnExit) {
8288
7853
  sessionManager.save(session);
@@ -8300,6 +7865,7 @@ ${red("✗")} ${msg}
8300
7865
  }
8301
7866
  async function executeAITurn(prompt, session, options, verbose = false, runner) {
8302
7867
  const { config, projectRoot } = options;
7868
+ const sandboxName = getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider);
8303
7869
  const aiResult = await runAI({
8304
7870
  prompt,
8305
7871
  provider: config.ai.provider,
@@ -8307,7 +7873,7 @@ async function executeAITurn(prompt, session, options, verbose = false, runner)
8307
7873
  cwd: projectRoot,
8308
7874
  verbose,
8309
7875
  sandboxed: config.sandbox.enabled,
8310
- sandboxName: config.sandbox.name,
7876
+ sandboxName,
8311
7877
  runner
8312
7878
  });
8313
7879
  if (aiResult.interrupted) {
@@ -8338,11 +7904,11 @@ function printWelcome(session) {
8338
7904
  `);
8339
7905
  }
8340
7906
  var init_repl = __esm(() => {
8341
- init_claude_sandbox();
8342
7907
  init_run_ai();
8343
7908
  init_runner();
8344
7909
  init_ai_models();
8345
7910
  init_prompt_builder();
7911
+ init_sandbox();
8346
7912
  init_terminal();
8347
7913
  init_commands();
8348
7914
  init_completions();
@@ -8360,7 +7926,6 @@ __export(exports_exec, {
8360
7926
  });
8361
7927
  async function execCommand(projectRoot, args, flags = {}) {
8362
7928
  const config = loadConfig(projectRoot);
8363
- const _log = getLogger();
8364
7929
  if (args[0] === "sessions") {
8365
7930
  return handleSessionSubcommand(projectRoot, args.slice(1));
8366
7931
  }
@@ -8483,7 +8048,12 @@ async function handleJsonStream(projectRoot, config, args, sessionId) {
8483
8048
  stream.emitStatus("thinking");
8484
8049
  try {
8485
8050
  const fullPrompt = buildReplPrompt(prompt, projectRoot, config);
8486
- const runner = config.sandbox.name ? createUserManagedSandboxRunner(config.ai.provider, config.sandbox.name) : await createRunnerAsync(config.ai.provider, config.sandbox.enabled);
8051
+ const sandboxName = getProviderSandboxName(config.sandbox, config.ai.provider);
8052
+ const runner = config.sandbox.enabled ? sandboxName ? createUserManagedSandboxRunner(config.ai.provider, sandboxName) : null : await createRunnerAsync(config.ai.provider, false);
8053
+ if (!runner) {
8054
+ stream.emitError(`Sandbox for provider "${config.ai.provider}" is not configured. Run locus sandbox.`, false);
8055
+ return;
8056
+ }
8487
8057
  const available = await runner.isAvailable();
8488
8058
  if (!available) {
8489
8059
  stream.emitError(`${config.ai.provider} CLI not available`, false);
@@ -8523,15 +8093,15 @@ function formatAge(isoDate) {
8523
8093
  var init_exec = __esm(() => {
8524
8094
  init_runner();
8525
8095
  init_config();
8526
- init_logger();
8527
8096
  init_prompt_builder();
8097
+ init_sandbox();
8528
8098
  init_terminal();
8529
8099
  init_repl();
8530
8100
  init_session_manager();
8531
8101
  });
8532
8102
 
8533
8103
  // src/core/agent.ts
8534
- import { execSync as execSync13 } from "node:child_process";
8104
+ import { execSync as execSync10 } from "node:child_process";
8535
8105
  async function executeIssue(projectRoot, options) {
8536
8106
  const log = getLogger();
8537
8107
  const timer = createTimer();
@@ -8560,7 +8130,7 @@ ${cyan("●")} ${bold(`#${issueNumber}`)} ${issue.title}
8560
8130
  }
8561
8131
  let issueComments = [];
8562
8132
  try {
8563
- const commentsRaw = execSync13(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8133
+ const commentsRaw = execSync10(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8564
8134
  if (commentsRaw) {
8565
8135
  issueComments = commentsRaw.split(`
8566
8136
  `).filter(Boolean);
@@ -8703,7 +8273,7 @@ ${c.body}`),
8703
8273
  cwd: projectRoot,
8704
8274
  activity: `iterating on PR #${prNumber}`,
8705
8275
  sandboxed: config.sandbox.enabled,
8706
- sandboxName: config.sandbox.name
8276
+ sandboxName: getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider)
8707
8277
  });
8708
8278
  if (aiResult.interrupted) {
8709
8279
  process.stderr.write(`
@@ -8724,12 +8294,12 @@ ${aiResult.success ? green("✓") : red("✗")} Iteration ${aiResult.success ? "
8724
8294
  }
8725
8295
  async function createIssuePR(projectRoot, config, issue) {
8726
8296
  try {
8727
- const currentBranch = execSync13("git rev-parse --abbrev-ref HEAD", {
8297
+ const currentBranch = execSync10("git rev-parse --abbrev-ref HEAD", {
8728
8298
  cwd: projectRoot,
8729
8299
  encoding: "utf-8",
8730
8300
  stdio: ["pipe", "pipe", "pipe"]
8731
8301
  }).trim();
8732
- const diff = execSync13(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8302
+ const diff = execSync10(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8733
8303
  cwd: projectRoot,
8734
8304
  encoding: "utf-8",
8735
8305
  stdio: ["pipe", "pipe", "pipe"]
@@ -8738,7 +8308,7 @@ async function createIssuePR(projectRoot, config, issue) {
8738
8308
  getLogger().verbose("No changes to create PR for");
8739
8309
  return;
8740
8310
  }
8741
- execSync13(`git push -u origin ${currentBranch}`, {
8311
+ execSync10(`git push -u origin ${currentBranch}`, {
8742
8312
  cwd: projectRoot,
8743
8313
  encoding: "utf-8",
8744
8314
  stdio: ["pipe", "pipe", "pipe"]
@@ -8781,12 +8351,13 @@ var init_agent = __esm(() => {
8781
8351
  init_github();
8782
8352
  init_logger();
8783
8353
  init_prompt_builder();
8354
+ init_sandbox();
8784
8355
  });
8785
8356
 
8786
8357
  // src/core/conflict.ts
8787
- import { execSync as execSync14 } from "node:child_process";
8358
+ import { execSync as execSync11 } from "node:child_process";
8788
8359
  function git2(args, cwd) {
8789
- return execSync14(`git ${args}`, {
8360
+ return execSync11(`git ${args}`, {
8790
8361
  cwd,
8791
8362
  encoding: "utf-8",
8792
8363
  stdio: ["pipe", "pipe", "pipe"]
@@ -8906,17 +8477,201 @@ ${bold(yellow("⚠"))} Base branch has ${result.newCommits} new commit${result.n
8906
8477
  `);
8907
8478
  }
8908
8479
  }
8909
- var init_conflict = __esm(() => {
8910
- init_terminal();
8911
- init_logger();
8480
+ var init_conflict = __esm(() => {
8481
+ init_terminal();
8482
+ init_logger();
8483
+ });
8484
+
8485
+ // src/core/run-state.ts
8486
+ import {
8487
+ existsSync as existsSync16,
8488
+ mkdirSync as mkdirSync11,
8489
+ readFileSync as readFileSync12,
8490
+ unlinkSync as unlinkSync4,
8491
+ writeFileSync as writeFileSync8
8492
+ } from "node:fs";
8493
+ import { dirname as dirname5, join as join16 } from "node:path";
8494
+ function getRunStatePath(projectRoot) {
8495
+ return join16(projectRoot, ".locus", "run-state.json");
8496
+ }
8497
+ function loadRunState(projectRoot) {
8498
+ const path = getRunStatePath(projectRoot);
8499
+ if (!existsSync16(path))
8500
+ return null;
8501
+ try {
8502
+ return JSON.parse(readFileSync12(path, "utf-8"));
8503
+ } catch {
8504
+ getLogger().warn("Corrupted run-state.json, ignoring");
8505
+ return null;
8506
+ }
8507
+ }
8508
+ function saveRunState(projectRoot, state) {
8509
+ const path = getRunStatePath(projectRoot);
8510
+ const dir = dirname5(path);
8511
+ if (!existsSync16(dir)) {
8512
+ mkdirSync11(dir, { recursive: true });
8513
+ }
8514
+ writeFileSync8(path, `${JSON.stringify(state, null, 2)}
8515
+ `, "utf-8");
8516
+ }
8517
+ function clearRunState(projectRoot) {
8518
+ const path = getRunStatePath(projectRoot);
8519
+ if (existsSync16(path)) {
8520
+ unlinkSync4(path);
8521
+ }
8522
+ }
8523
+ function createSprintRunState(sprint, branch, issues) {
8524
+ return {
8525
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
8526
+ type: "sprint",
8527
+ sprint,
8528
+ branch,
8529
+ startedAt: new Date().toISOString(),
8530
+ tasks: issues.map(({ number, order }) => ({
8531
+ issue: number,
8532
+ order,
8533
+ status: "pending"
8534
+ }))
8535
+ };
8536
+ }
8537
+ function createParallelRunState(issueNumbers) {
8538
+ return {
8539
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
8540
+ type: "parallel",
8541
+ startedAt: new Date().toISOString(),
8542
+ tasks: issueNumbers.map((issue, i) => ({
8543
+ issue,
8544
+ order: i + 1,
8545
+ status: "pending"
8546
+ }))
8547
+ };
8548
+ }
8549
+ function markTaskInProgress(state, issueNumber) {
8550
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8551
+ if (task) {
8552
+ task.status = "in_progress";
8553
+ }
8554
+ }
8555
+ function markTaskDone(state, issueNumber, prNumber) {
8556
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8557
+ if (task) {
8558
+ task.status = "done";
8559
+ task.completedAt = new Date().toISOString();
8560
+ if (prNumber)
8561
+ task.pr = prNumber;
8562
+ }
8563
+ }
8564
+ function markTaskFailed(state, issueNumber, error) {
8565
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8566
+ if (task) {
8567
+ task.status = "failed";
8568
+ task.failedAt = new Date().toISOString();
8569
+ task.error = error;
8570
+ }
8571
+ }
8572
+ function getRunStats(state) {
8573
+ const tasks = state.tasks;
8574
+ return {
8575
+ total: tasks.length,
8576
+ done: tasks.filter((t) => t.status === "done").length,
8577
+ failed: tasks.filter((t) => t.status === "failed").length,
8578
+ pending: tasks.filter((t) => t.status === "pending").length,
8579
+ inProgress: tasks.filter((t) => t.status === "in_progress").length
8580
+ };
8581
+ }
8582
+ function getNextTask(state) {
8583
+ const failed = state.tasks.find((t) => t.status === "failed");
8584
+ if (failed)
8585
+ return failed;
8586
+ return state.tasks.find((t) => t.status === "pending") ?? null;
8587
+ }
8588
+ var init_run_state = __esm(() => {
8589
+ init_logger();
8590
+ });
8591
+
8592
+ // src/core/shutdown.ts
8593
+ import { execSync as execSync12 } from "node:child_process";
8594
+ function cleanupActiveSandboxes() {
8595
+ for (const name of activeSandboxes) {
8596
+ try {
8597
+ execSync12(`docker sandbox rm ${name}`, { timeout: 1e4 });
8598
+ } catch {}
8599
+ }
8600
+ activeSandboxes.clear();
8601
+ }
8602
+ function registerShutdownHandlers(ctx) {
8603
+ shutdownContext = ctx;
8604
+ interruptCount = 0;
8605
+ const handler = () => {
8606
+ interruptCount++;
8607
+ if (interruptCount >= 2) {
8608
+ process.stderr.write(`
8609
+ Force exit.
8610
+ `);
8611
+ process.exit(1);
8612
+ }
8613
+ process.stderr.write(`
8614
+
8615
+ Interrupted. Saving state...
8616
+ `);
8617
+ const state = shutdownContext?.getRunState?.();
8618
+ if (state && shutdownContext) {
8619
+ for (const task of state.tasks) {
8620
+ if (task.status === "in_progress") {
8621
+ task.status = "failed";
8622
+ task.failedAt = new Date().toISOString();
8623
+ task.error = "Interrupted by user";
8624
+ }
8625
+ }
8626
+ try {
8627
+ saveRunState(shutdownContext.projectRoot, state);
8628
+ process.stderr.write(`State saved. Resume with: locus run --resume
8629
+ `);
8630
+ } catch {
8631
+ process.stderr.write(`Warning: Could not save run state.
8632
+ `);
8633
+ }
8634
+ }
8635
+ cleanupActiveSandboxes();
8636
+ shutdownContext?.onShutdown?.();
8637
+ if (interruptTimer)
8638
+ clearTimeout(interruptTimer);
8639
+ interruptTimer = setTimeout(() => {
8640
+ interruptCount = 0;
8641
+ }, 2000);
8642
+ setTimeout(() => {
8643
+ process.exit(130);
8644
+ }, 100);
8645
+ };
8646
+ if (!shutdownRegistered) {
8647
+ process.on("SIGINT", handler);
8648
+ process.on("SIGTERM", handler);
8649
+ shutdownRegistered = true;
8650
+ }
8651
+ return () => {
8652
+ process.removeListener("SIGINT", handler);
8653
+ process.removeListener("SIGTERM", handler);
8654
+ shutdownRegistered = false;
8655
+ shutdownContext = null;
8656
+ interruptCount = 0;
8657
+ if (interruptTimer) {
8658
+ clearTimeout(interruptTimer);
8659
+ interruptTimer = null;
8660
+ }
8661
+ };
8662
+ }
8663
+ var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
8664
+ var init_shutdown = __esm(() => {
8665
+ init_run_state();
8666
+ activeSandboxes = new Set;
8912
8667
  });
8913
8668
 
8914
8669
  // src/core/worktree.ts
8915
- import { execSync as execSync15 } from "node:child_process";
8670
+ import { execSync as execSync13 } from "node:child_process";
8916
8671
  import { existsSync as existsSync17, readdirSync as readdirSync6, realpathSync, statSync as statSync3 } from "node:fs";
8917
8672
  import { join as join17 } from "node:path";
8918
8673
  function git3(args, cwd) {
8919
- return execSync15(`git ${args}`, {
8674
+ return execSync13(`git ${args}`, {
8920
8675
  cwd,
8921
8676
  encoding: "utf-8",
8922
8677
  stdio: ["pipe", "pipe", "pipe"]
@@ -8941,7 +8696,7 @@ function generateBranchName(issueNumber) {
8941
8696
  }
8942
8697
  function getWorktreeBranch(worktreePath) {
8943
8698
  try {
8944
- return execSync15("git branch --show-current", {
8699
+ return execSync13("git branch --show-current", {
8945
8700
  cwd: worktreePath,
8946
8701
  encoding: "utf-8",
8947
8702
  stdio: ["pipe", "pipe", "pipe"]
@@ -9066,7 +8821,13 @@ var exports_run = {};
9066
8821
  __export(exports_run, {
9067
8822
  runCommand: () => runCommand
9068
8823
  });
9069
- import { execSync as execSync16 } from "node:child_process";
8824
+ import { execSync as execSync14 } from "node:child_process";
8825
+ function resolveExecutionContext(config, modelOverride) {
8826
+ const model = modelOverride ?? config.ai.model;
8827
+ const provider = inferProviderFromModel(model) ?? config.ai.provider;
8828
+ const sandboxName = getModelSandboxName(config.sandbox, model, provider);
8829
+ return { provider, model, sandboxName };
8830
+ }
9070
8831
  function printRunHelp() {
9071
8832
  process.stderr.write(`
9072
8833
  ${bold("locus run")} — Execute issues using AI agents
@@ -9149,6 +8910,7 @@ async function runCommand(projectRoot, args, flags = {}) {
9149
8910
  }
9150
8911
  async function handleSprintRun(projectRoot, config, flags, sandboxed) {
9151
8912
  const log = getLogger();
8913
+ const execution = resolveExecutionContext(config, flags.model);
9152
8914
  if (!config.sprint.active) {
9153
8915
  process.stderr.write(`${red("✗")} No active sprint. Set one with: ${bold("locus sprint active <name>")}
9154
8916
  `);
@@ -9210,7 +8972,7 @@ ${yellow("⚠")} A sprint run is already in progress.
9210
8972
  }
9211
8973
  if (!flags.dryRun) {
9212
8974
  try {
9213
- execSync16(`git checkout -B ${branchName}`, {
8975
+ execSync14(`git checkout -B ${branchName}`, {
9214
8976
  cwd: projectRoot,
9215
8977
  encoding: "utf-8",
9216
8978
  stdio: ["pipe", "pipe", "pipe"]
@@ -9260,7 +9022,7 @@ ${red("✗")} Auto-rebase failed. Resolve manually.
9260
9022
  let sprintContext;
9261
9023
  if (i > 0 && !flags.dryRun) {
9262
9024
  try {
9263
- sprintContext = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9025
+ sprintContext = execSync14(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9264
9026
  cwd: projectRoot,
9265
9027
  encoding: "utf-8",
9266
9028
  stdio: ["pipe", "pipe", "pipe"]
@@ -9275,12 +9037,13 @@ ${progressBar(i, state.tasks.length, { label: "Sprint Progress" })}
9275
9037
  saveRunState(projectRoot, state);
9276
9038
  const result = await executeIssue(projectRoot, {
9277
9039
  issueNumber: task.issue,
9278
- provider: config.ai.provider,
9279
- model: flags.model ?? config.ai.model,
9040
+ provider: execution.provider,
9041
+ model: execution.model,
9280
9042
  dryRun: flags.dryRun,
9281
9043
  sprintContext,
9282
9044
  skipPR: true,
9283
- sandboxed
9045
+ sandboxed,
9046
+ sandboxName: execution.sandboxName
9284
9047
  });
9285
9048
  if (result.success) {
9286
9049
  if (!flags.dryRun) {
@@ -9324,7 +9087,7 @@ ${bold("Summary:")}
9324
9087
  const prNumber = await createSprintPR(projectRoot, config, sprintName, branchName, completedTasks);
9325
9088
  if (prNumber !== undefined) {
9326
9089
  try {
9327
- execSync16(`git checkout ${config.agent.baseBranch}`, {
9090
+ execSync14(`git checkout ${config.agent.baseBranch}`, {
9328
9091
  cwd: projectRoot,
9329
9092
  encoding: "utf-8",
9330
9093
  stdio: ["pipe", "pipe", "pipe"]
@@ -9339,6 +9102,7 @@ ${bold("Summary:")}
9339
9102
  }
9340
9103
  }
9341
9104
  async function handleSingleIssue(projectRoot, config, issueNumber, flags, sandboxed) {
9105
+ const execution = resolveExecutionContext(config, flags.model);
9342
9106
  let isSprintIssue = false;
9343
9107
  try {
9344
9108
  const issue = getIssue(issueNumber, { cwd: projectRoot });
@@ -9351,11 +9115,11 @@ ${bold("Running sprint issue")} ${cyan(`#${issueNumber}`)} ${dim("(sequential, n
9351
9115
  `);
9352
9116
  await executeIssue(projectRoot, {
9353
9117
  issueNumber,
9354
- provider: config.ai.provider,
9355
- model: flags.model ?? config.ai.model,
9118
+ provider: execution.provider,
9119
+ model: execution.model,
9356
9120
  dryRun: flags.dryRun,
9357
9121
  sandboxed,
9358
- sandboxName: config.sandbox.name
9122
+ sandboxName: execution.sandboxName
9359
9123
  });
9360
9124
  return;
9361
9125
  }
@@ -9382,11 +9146,11 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
9382
9146
  const result = await executeIssue(projectRoot, {
9383
9147
  issueNumber,
9384
9148
  worktreePath,
9385
- provider: config.ai.provider,
9386
- model: flags.model ?? config.ai.model,
9149
+ provider: execution.provider,
9150
+ model: execution.model,
9387
9151
  dryRun: flags.dryRun,
9388
9152
  sandboxed,
9389
- sandboxName: config.sandbox.name
9153
+ sandboxName: execution.sandboxName
9390
9154
  });
9391
9155
  if (worktreePath && !flags.dryRun) {
9392
9156
  if (result.success) {
@@ -9401,6 +9165,7 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
9401
9165
  }
9402
9166
  async function handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed) {
9403
9167
  const log = getLogger();
9168
+ const execution = resolveExecutionContext(config, flags.model);
9404
9169
  const maxConcurrent = config.agent.maxParallel;
9405
9170
  process.stderr.write(`
9406
9171
  ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxConcurrent} parallel, worktrees)`)}
@@ -9452,11 +9217,11 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
9452
9217
  const result = await executeIssue(projectRoot, {
9453
9218
  issueNumber,
9454
9219
  worktreePath,
9455
- provider: config.ai.provider,
9456
- model: flags.model ?? config.ai.model,
9220
+ provider: execution.provider,
9221
+ model: execution.model,
9457
9222
  dryRun: flags.dryRun,
9458
9223
  sandboxed,
9459
- sandboxName: config.sandbox.name
9224
+ sandboxName: execution.sandboxName
9460
9225
  });
9461
9226
  if (result.success) {
9462
9227
  markTaskDone(state, issueNumber, result.prNumber);
@@ -9506,6 +9271,7 @@ ${yellow("⚠")} Failed worktrees preserved for debugging:
9506
9271
  }
9507
9272
  }
9508
9273
  async function handleResume(projectRoot, config, sandboxed) {
9274
+ const execution = resolveExecutionContext(config);
9509
9275
  const state = loadRunState(projectRoot);
9510
9276
  if (!state) {
9511
9277
  process.stderr.write(`${red("✗")} No run state found. Nothing to resume.
@@ -9521,13 +9287,13 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
9521
9287
  `);
9522
9288
  if (state.type === "sprint" && state.branch) {
9523
9289
  try {
9524
- const currentBranch = execSync16("git rev-parse --abbrev-ref HEAD", {
9290
+ const currentBranch = execSync14("git rev-parse --abbrev-ref HEAD", {
9525
9291
  cwd: projectRoot,
9526
9292
  encoding: "utf-8",
9527
9293
  stdio: ["pipe", "pipe", "pipe"]
9528
9294
  }).trim();
9529
9295
  if (currentBranch !== state.branch) {
9530
- execSync16(`git checkout ${state.branch}`, {
9296
+ execSync14(`git checkout ${state.branch}`, {
9531
9297
  cwd: projectRoot,
9532
9298
  encoding: "utf-8",
9533
9299
  stdio: ["pipe", "pipe", "pipe"]
@@ -9551,11 +9317,11 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
9551
9317
  saveRunState(projectRoot, state);
9552
9318
  const result = await executeIssue(projectRoot, {
9553
9319
  issueNumber: task.issue,
9554
- provider: config.ai.provider,
9555
- model: config.ai.model,
9320
+ provider: execution.provider,
9321
+ model: execution.model,
9556
9322
  skipPR: isSprintRun,
9557
9323
  sandboxed,
9558
- sandboxName: isSprintRun ? undefined : config.sandbox.name
9324
+ sandboxName: execution.sandboxName
9559
9325
  });
9560
9326
  if (result.success) {
9561
9327
  if (isSprintRun) {
@@ -9594,7 +9360,7 @@ ${bold("Resume complete:")} ${green(`✓ ${finalStats.done}`)} ${finalStats.fail
9594
9360
  const prNumber = await createSprintPR(projectRoot, config, state.sprint, state.branch, completedTasks);
9595
9361
  if (prNumber !== undefined) {
9596
9362
  try {
9597
- execSync16(`git checkout ${config.agent.baseBranch}`, {
9363
+ execSync14(`git checkout ${config.agent.baseBranch}`, {
9598
9364
  cwd: projectRoot,
9599
9365
  encoding: "utf-8",
9600
9366
  stdio: ["pipe", "pipe", "pipe"]
@@ -9625,14 +9391,14 @@ function getOrder2(issue) {
9625
9391
  }
9626
9392
  function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
9627
9393
  try {
9628
- const status = execSync16("git status --porcelain", {
9394
+ const status = execSync14("git status --porcelain", {
9629
9395
  cwd: projectRoot,
9630
9396
  encoding: "utf-8",
9631
9397
  stdio: ["pipe", "pipe", "pipe"]
9632
9398
  }).trim();
9633
9399
  if (!status)
9634
9400
  return;
9635
- execSync16("git add -A", {
9401
+ execSync14("git add -A", {
9636
9402
  cwd: projectRoot,
9637
9403
  encoding: "utf-8",
9638
9404
  stdio: ["pipe", "pipe", "pipe"]
@@ -9640,7 +9406,7 @@ function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
9640
9406
  const message = `chore: complete #${issueNumber} - ${issueTitle}
9641
9407
 
9642
9408
  Co-Authored-By: LocusAgent <agent@locusai.team>`;
9643
- execSync16(`git commit -F -`, {
9409
+ execSync14(`git commit -F -`, {
9644
9410
  input: message,
9645
9411
  cwd: projectRoot,
9646
9412
  encoding: "utf-8",
@@ -9654,7 +9420,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
9654
9420
  if (!config.agent.autoPR)
9655
9421
  return;
9656
9422
  try {
9657
- const diff = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9423
+ const diff = execSync14(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9658
9424
  cwd: projectRoot,
9659
9425
  encoding: "utf-8",
9660
9426
  stdio: ["pipe", "pipe", "pipe"]
@@ -9664,7 +9430,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
9664
9430
  `);
9665
9431
  return;
9666
9432
  }
9667
- execSync16(`git push -u origin ${branchName}`, {
9433
+ execSync14(`git push -u origin ${branchName}`, {
9668
9434
  cwd: projectRoot,
9669
9435
  encoding: "utf-8",
9670
9436
  stdio: ["pipe", "pipe", "pipe"]
@@ -9690,6 +9456,7 @@ ${taskLines}
9690
9456
  }
9691
9457
  }
9692
9458
  var init_run = __esm(() => {
9459
+ init_ai_models();
9693
9460
  init_agent();
9694
9461
  init_config();
9695
9462
  init_conflict();
@@ -10066,7 +9833,7 @@ ${bold("Planning:")} ${cyan(displayDirective)}
10066
9833
  cwd: projectRoot,
10067
9834
  activity: "planning",
10068
9835
  sandboxed: config.sandbox.enabled,
10069
- sandboxName: config.sandbox.name
9836
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10070
9837
  });
10071
9838
  if (aiResult.interrupted) {
10072
9839
  process.stderr.write(`
@@ -10181,7 +9948,7 @@ Start with foundational/setup tasks, then core features, then integration/testin
10181
9948
  activity: "issue ordering",
10182
9949
  silent: true,
10183
9950
  sandboxed: config.sandbox.enabled,
10184
- sandboxName: config.sandbox.name
9951
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10185
9952
  });
10186
9953
  if (aiResult.interrupted) {
10187
9954
  process.stderr.write(`
@@ -10433,6 +10200,7 @@ var init_plan = __esm(() => {
10433
10200
  init_config();
10434
10201
  init_github();
10435
10202
  init_terminal();
10203
+ init_sandbox();
10436
10204
  });
10437
10205
 
10438
10206
  // src/commands/review.ts
@@ -10440,7 +10208,7 @@ var exports_review = {};
10440
10208
  __export(exports_review, {
10441
10209
  reviewCommand: () => reviewCommand
10442
10210
  });
10443
- import { execSync as execSync17 } from "node:child_process";
10211
+ import { execSync as execSync15 } from "node:child_process";
10444
10212
  import { existsSync as existsSync19, readFileSync as readFileSync14 } from "node:fs";
10445
10213
  import { join as join19 } from "node:path";
10446
10214
  function printHelp2() {
@@ -10518,7 +10286,7 @@ ${bold("Review complete:")} ${green(`✓ ${reviewed}`)}${failed > 0 ? ` ${red(`
10518
10286
  async function reviewSinglePR(projectRoot, config, prNumber, focus, flags) {
10519
10287
  let prInfo;
10520
10288
  try {
10521
- const result = execSync17(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10289
+ const result = execSync15(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10522
10290
  const raw = JSON.parse(result);
10523
10291
  prInfo = {
10524
10292
  number: raw.number,
@@ -10563,7 +10331,7 @@ async function reviewPR(projectRoot, config, pr, focus, flags) {
10563
10331
  cwd: projectRoot,
10564
10332
  activity: `PR #${pr.number}`,
10565
10333
  sandboxed: config.sandbox.enabled,
10566
- sandboxName: config.sandbox.name
10334
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10567
10335
  });
10568
10336
  if (aiResult.interrupted) {
10569
10337
  process.stderr.write(` ${yellow("⚡")} Review interrupted.
@@ -10584,7 +10352,7 @@ ${output.slice(0, 60000)}
10584
10352
 
10585
10353
  ---
10586
10354
  _Reviewed by Locus AI (${config.ai.provider}/${flags.model ?? config.ai.model})_`;
10587
- execSync17(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10355
+ execSync15(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10588
10356
  process.stderr.write(` ${green("✓")} Review posted ${dim(`(${timer.formatted()})`)}
10589
10357
  `);
10590
10358
  } catch (e) {
@@ -10654,6 +10422,7 @@ var init_review = __esm(() => {
10654
10422
  init_run_ai();
10655
10423
  init_config();
10656
10424
  init_github();
10425
+ init_sandbox();
10657
10426
  init_progress();
10658
10427
  init_terminal();
10659
10428
  });
@@ -10663,7 +10432,7 @@ var exports_iterate = {};
10663
10432
  __export(exports_iterate, {
10664
10433
  iterateCommand: () => iterateCommand
10665
10434
  });
10666
- import { execSync as execSync18 } from "node:child_process";
10435
+ import { execSync as execSync16 } from "node:child_process";
10667
10436
  function printHelp3() {
10668
10437
  process.stderr.write(`
10669
10438
  ${bold("locus iterate")} — Re-execute tasks with PR feedback
@@ -10873,12 +10642,12 @@ ${bold("Summary:")} ${green(`✓ ${succeeded}`)}${failed > 0 ? ` ${red(`✗ ${fa
10873
10642
  }
10874
10643
  function findPRForIssue(projectRoot, issueNumber) {
10875
10644
  try {
10876
- const result = execSync18(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10645
+ const result = execSync16(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10877
10646
  const parsed = JSON.parse(result);
10878
10647
  if (parsed.length > 0) {
10879
10648
  return parsed[0].number;
10880
10649
  }
10881
- const branchResult = execSync18(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10650
+ const branchResult = execSync16(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10882
10651
  const branchParsed = JSON.parse(branchResult);
10883
10652
  if (branchParsed.length > 0) {
10884
10653
  return branchParsed[0].number;
@@ -11123,7 +10892,7 @@ ${bold("Discussion:")} ${cyan(topic)}
11123
10892
  cwd: projectRoot,
11124
10893
  activity: "discussion",
11125
10894
  sandboxed: config.sandbox.enabled,
11126
- sandboxName: config.sandbox.name
10895
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
11127
10896
  });
11128
10897
  if (aiResult.interrupted) {
11129
10898
  process.stderr.write(`
@@ -11269,6 +11038,7 @@ var MAX_DISCUSSION_ROUNDS = 5;
11269
11038
  var init_discuss = __esm(() => {
11270
11039
  init_run_ai();
11271
11040
  init_config();
11041
+ init_sandbox();
11272
11042
  init_progress();
11273
11043
  init_terminal();
11274
11044
  init_input_handler();
@@ -11491,24 +11261,34 @@ var init_artifacts = __esm(() => {
11491
11261
  // src/commands/sandbox.ts
11492
11262
  var exports_sandbox2 = {};
11493
11263
  __export(exports_sandbox2, {
11494
- sandboxCommand: () => sandboxCommand
11264
+ sandboxCommand: () => sandboxCommand,
11265
+ parseSandboxLogsArgs: () => parseSandboxLogsArgs,
11266
+ parseSandboxInstallArgs: () => parseSandboxInstallArgs,
11267
+ parseSandboxExecArgs: () => parseSandboxExecArgs
11495
11268
  });
11496
- import { execSync as execSync19, spawn as spawn6 } from "node:child_process";
11269
+ import { execSync as execSync17, spawn as spawn6 } from "node:child_process";
11270
+ import { createHash } from "node:crypto";
11271
+ import { basename as basename4 } from "node:path";
11497
11272
  function printSandboxHelp() {
11498
11273
  process.stderr.write(`
11499
11274
  ${bold("locus sandbox")} — Manage Docker sandbox lifecycle
11500
11275
 
11501
11276
  ${bold("Usage:")}
11502
- locus sandbox ${dim("# Create sandbox and enable sandbox mode")}
11277
+ locus sandbox ${dim("# Create claude/codex sandboxes and enable sandbox mode")}
11503
11278
  locus sandbox claude ${dim("# Run claude interactively (for login)")}
11504
11279
  locus sandbox codex ${dim("# Run codex interactively (for login)")}
11505
- locus sandbox rm ${dim("# Destroy sandbox and disable sandbox mode")}
11280
+ locus sandbox install <pkg> ${dim("# npm install -g package(s) in sandbox(es)")}
11281
+ locus sandbox exec <provider> -- <cmd...> ${dim("# Run one command inside provider sandbox")}
11282
+ locus sandbox shell <provider> ${dim("# Open interactive shell in provider sandbox")}
11283
+ locus sandbox logs <provider> ${dim("# Show provider sandbox logs")}
11284
+ locus sandbox rm ${dim("# Destroy all provider sandboxes and disable sandbox mode")}
11506
11285
  locus sandbox status ${dim("# Show current sandbox state")}
11507
11286
 
11508
11287
  ${bold("Flow:")}
11509
- 1. ${cyan("locus sandbox")} Create the sandbox environment
11510
- 2. ${cyan("locus sandbox claude")} Login to Claude inside the sandbox
11511
- 3. ${cyan("locus exec")} All commands now run inside the sandbox
11288
+ 1. ${cyan("locus sandbox")} Create provider sandboxes
11289
+ 2. ${cyan("locus sandbox claude")} Login Claude inside its sandbox
11290
+ 3. ${cyan("locus sandbox codex")} Login Codex inside its sandbox
11291
+ 4. ${cyan("locus sandbox install bun")} Install extra tools (optional)
11512
11292
 
11513
11293
  `);
11514
11294
  }
@@ -11521,6 +11301,14 @@ async function sandboxCommand(projectRoot, args) {
11521
11301
  case "claude":
11522
11302
  case "codex":
11523
11303
  return handleAgentLogin(projectRoot, subcommand);
11304
+ case "install":
11305
+ return handleInstall(projectRoot, args.slice(1));
11306
+ case "exec":
11307
+ return handleExec(projectRoot, args.slice(1));
11308
+ case "shell":
11309
+ return handleShell(projectRoot, args.slice(1));
11310
+ case "logs":
11311
+ return handleLogs(projectRoot, args.slice(1));
11524
11312
  case "rm":
11525
11313
  return handleRemove(projectRoot);
11526
11314
  case "status":
@@ -11530,24 +11318,12 @@ async function sandboxCommand(projectRoot, args) {
11530
11318
  default:
11531
11319
  process.stderr.write(`${red("✗")} Unknown sandbox subcommand: ${bold(subcommand)}
11532
11320
  `);
11533
- process.stderr.write(` Available: ${cyan("claude")}, ${cyan("codex")}, ${cyan("rm")}, ${cyan("status")}
11321
+ process.stderr.write(` Available: ${cyan("claude")}, ${cyan("codex")}, ${cyan("install")}, ${cyan("exec")}, ${cyan("shell")}, ${cyan("logs")}, ${cyan("rm")}, ${cyan("status")}
11534
11322
  `);
11535
11323
  }
11536
11324
  }
11537
11325
  async function handleCreate(projectRoot) {
11538
11326
  const config = loadConfig(projectRoot);
11539
- if (config.sandbox.name) {
11540
- const alive = isSandboxAlive(config.sandbox.name);
11541
- if (alive) {
11542
- process.stderr.write(`${green("✓")} Sandbox already exists: ${bold(config.sandbox.name)}
11543
- `);
11544
- process.stderr.write(` Run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to login.
11545
- `);
11546
- return;
11547
- }
11548
- process.stderr.write(`${yellow("⚠")} Previous sandbox ${dim(config.sandbox.name)} is no longer running. Creating a new one.
11549
- `);
11550
- }
11551
11327
  const status = await detectSandboxSupport();
11552
11328
  if (!status.available) {
11553
11329
  process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
@@ -11556,86 +11332,68 @@ async function handleCreate(projectRoot) {
11556
11332
  `);
11557
11333
  return;
11558
11334
  }
11559
- const segment = projectRoot.split("/").pop() ?? "sandbox";
11560
- const sandboxName = `locus-${segment}-${Date.now()}`;
11335
+ const sandboxNames = buildProviderSandboxNames(projectRoot);
11336
+ const readySandboxes = {};
11337
+ let failed = false;
11338
+ for (const provider of PROVIDERS) {
11339
+ const name = sandboxNames[provider];
11340
+ if (isSandboxAlive(name)) {
11341
+ process.stderr.write(`${green("✓")} ${provider} sandbox ready: ${bold(name)}
11342
+ `);
11343
+ readySandboxes[provider] = name;
11344
+ continue;
11345
+ }
11346
+ process.stderr.write(`Creating ${bold(provider)} sandbox ${dim(name)} with workspace ${dim(projectRoot)}...
11347
+ `);
11348
+ const created = await createProviderSandbox(provider, name, projectRoot);
11349
+ if (!created) {
11350
+ process.stderr.write(`${red("✗")} Failed to create ${provider} sandbox (${name}).
11351
+ `);
11352
+ failed = true;
11353
+ continue;
11354
+ }
11355
+ process.stderr.write(`${green("✓")} ${provider} sandbox created: ${bold(name)}
11356
+ `);
11357
+ readySandboxes[provider] = name;
11358
+ }
11561
11359
  config.sandbox.enabled = true;
11562
- config.sandbox.name = sandboxName;
11360
+ config.sandbox.providers = readySandboxes;
11563
11361
  saveConfig(projectRoot, config);
11564
- process.stderr.write(`${green("✓")} Sandbox name reserved: ${bold(sandboxName)}
11362
+ if (failed) {
11363
+ process.stderr.write(`
11364
+ ${yellow("⚠")} Some sandboxes failed to create. Re-run ${cyan("locus sandbox")} after resolving Docker issues.
11565
11365
  `);
11566
- process.stderr.write(` Next: run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to create the sandbox and login.
11366
+ }
11367
+ process.stderr.write(`
11368
+ ${green("✓")} Sandbox mode enabled with provider-specific sandboxes.
11369
+ `);
11370
+ process.stderr.write(` Next: run ${cyan("locus sandbox claude")} and ${cyan("locus sandbox codex")} to authenticate both providers.
11567
11371
  `);
11568
11372
  }
11569
11373
  async function handleAgentLogin(projectRoot, agent) {
11570
11374
  const config = loadConfig(projectRoot);
11571
- if (!config.sandbox.name) {
11572
- const status = await detectSandboxSupport();
11573
- if (!status.available) {
11574
- process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11575
- `);
11576
- process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11375
+ const sandboxName = getProviderSandboxName(config.sandbox, agent);
11376
+ if (!sandboxName) {
11377
+ process.stderr.write(`${red("✗")} No ${agent} sandbox configured. Run ${cyan("locus sandbox")} first.
11577
11378
  `);
11578
- return;
11579
- }
11580
- const segment = projectRoot.split("/").pop() ?? "sandbox";
11581
- config.sandbox.name = `locus-${segment}-${Date.now()}`;
11582
- config.sandbox.enabled = true;
11583
- saveConfig(projectRoot, config);
11379
+ return;
11584
11380
  }
11585
- const sandboxName = config.sandbox.name;
11586
- const alive = isSandboxAlive(sandboxName);
11587
- let dockerArgs;
11588
- if (alive) {
11589
- if (agent === "codex") {
11590
- await ensureCodexInSandbox(sandboxName);
11591
- }
11592
- process.stderr.write(`Connecting to sandbox ${dim(sandboxName)}...
11593
- `);
11594
- process.stderr.write(`${dim("Login and then exit when ready.")}
11595
-
11596
- `);
11597
- dockerArgs = [
11598
- "sandbox",
11599
- "exec",
11600
- "-it",
11601
- "-w",
11602
- projectRoot,
11603
- sandboxName,
11604
- agent
11605
- ];
11606
- } else if (agent === "codex") {
11607
- process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11381
+ if (!isSandboxAlive(sandboxName)) {
11382
+ process.stderr.write(`${red("✗")} ${agent} sandbox is not running: ${dim(sandboxName)}
11608
11383
  `);
11609
- try {
11610
- execSync19(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, { stdio: ["pipe", "pipe", "pipe"], timeout: 120000 });
11611
- } catch {}
11612
- if (!isSandboxAlive(sandboxName)) {
11613
- process.stderr.write(`${red("✗")} Failed to create sandbox.
11384
+ process.stderr.write(` Recreate it with ${cyan("locus sandbox")}.
11614
11385
  `);
11615
- return;
11616
- }
11386
+ return;
11387
+ }
11388
+ if (agent === "codex") {
11617
11389
  await ensureCodexInSandbox(sandboxName);
11618
- process.stderr.write(`${dim("Login and then exit when ready.")}
11619
-
11620
- `);
11621
- dockerArgs = [
11622
- "sandbox",
11623
- "exec",
11624
- "-it",
11625
- "-w",
11626
- projectRoot,
11627
- sandboxName,
11628
- "codex"
11629
- ];
11630
- } else {
11631
- process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11390
+ }
11391
+ process.stderr.write(`Connecting to ${agent} sandbox ${dim(sandboxName)}...
11632
11392
  `);
11633
- process.stderr.write(`${dim("Login and then exit when ready.")}
11393
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11634
11394
 
11635
11395
  `);
11636
- dockerArgs = ["sandbox", "run", "--name", sandboxName, agent, projectRoot];
11637
- }
11638
- const child = spawn6("docker", dockerArgs, {
11396
+ const child = spawn6("docker", ["sandbox", "exec", "-it", "-w", projectRoot, sandboxName, agent], {
11639
11397
  stdio: "inherit"
11640
11398
  });
11641
11399
  await new Promise((resolve2) => {
@@ -11661,25 +11419,30 @@ ${yellow("⚠")} ${agent} exited with code ${code}.
11661
11419
  }
11662
11420
  function handleRemove(projectRoot) {
11663
11421
  const config = loadConfig(projectRoot);
11664
- if (!config.sandbox.name) {
11665
- process.stderr.write(`${dim("No sandbox to remove.")}
11422
+ const names = Array.from(new Set(Object.values(config.sandbox.providers).filter((value) => typeof value === "string" && value.length > 0)));
11423
+ if (names.length === 0) {
11424
+ config.sandbox.enabled = false;
11425
+ config.sandbox.providers = {};
11426
+ saveConfig(projectRoot, config);
11427
+ process.stderr.write(`${dim("No sandboxes to remove. Sandbox mode disabled.")}
11666
11428
  `);
11667
11429
  return;
11668
11430
  }
11669
- const sandboxName = config.sandbox.name;
11670
- process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11431
+ for (const sandboxName of names) {
11432
+ process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11671
11433
  `);
11672
- try {
11673
- execSync19(`docker sandbox rm ${sandboxName}`, {
11674
- encoding: "utf-8",
11675
- stdio: ["pipe", "pipe", "pipe"],
11676
- timeout: 15000
11677
- });
11678
- } catch {}
11679
- config.sandbox.name = undefined;
11434
+ try {
11435
+ execSync17(`docker sandbox rm ${sandboxName}`, {
11436
+ encoding: "utf-8",
11437
+ stdio: ["pipe", "pipe", "pipe"],
11438
+ timeout: 15000
11439
+ });
11440
+ } catch {}
11441
+ }
11442
+ config.sandbox.providers = {};
11680
11443
  config.sandbox.enabled = false;
11681
11444
  saveConfig(projectRoot, config);
11682
- process.stderr.write(`${green("✓")} Sandbox removed. Sandbox mode disabled.
11445
+ process.stderr.write(`${green("✓")} Provider sandboxes removed. Sandbox mode disabled.
11683
11446
  `);
11684
11447
  }
11685
11448
  function handleStatus(projectRoot) {
@@ -11690,24 +11453,338 @@ ${bold("Sandbox Status")}
11690
11453
  `);
11691
11454
  process.stderr.write(` ${dim("Enabled:")} ${config.sandbox.enabled ? green("yes") : red("no")}
11692
11455
  `);
11693
- process.stderr.write(` ${dim("Name:")} ${config.sandbox.name ? bold(config.sandbox.name) : dim("(none)")}
11694
- `);
11695
- if (config.sandbox.name) {
11696
- const alive = isSandboxAlive(config.sandbox.name);
11697
- process.stderr.write(` ${dim("Running:")} ${alive ? green("yes") : red("no")}
11456
+ for (const provider of PROVIDERS) {
11457
+ const name = config.sandbox.providers[provider];
11458
+ process.stderr.write(` ${dim(`${provider}:`).padEnd(15)}${name ? bold(name) : dim("(not configured)")}
11698
11459
  `);
11699
- if (!alive) {
11700
- process.stderr.write(`
11701
- ${yellow("⚠")} Sandbox is not running. Run ${bold("locus sandbox")} to create a new one.
11460
+ if (name) {
11461
+ const alive = isSandboxAlive(name);
11462
+ process.stderr.write(` ${dim(`${provider} running:`).padEnd(15)}${alive ? green("yes") : red("no")}
11702
11463
  `);
11703
11464
  }
11704
11465
  }
11466
+ if (!config.sandbox.providers.claude || !config.sandbox.providers.codex) {
11467
+ process.stderr.write(`
11468
+ ${yellow("⚠")} Provider sandboxes are incomplete. Run ${bold("locus sandbox")}.
11469
+ `);
11470
+ }
11705
11471
  process.stderr.write(`
11706
11472
  `);
11707
11473
  }
11474
+ function parseSandboxInstallArgs(args) {
11475
+ const packages = [];
11476
+ let provider = "all";
11477
+ for (let i = 0;i < args.length; i++) {
11478
+ const token = args[i];
11479
+ if (token === "--provider") {
11480
+ const value = args[i + 1];
11481
+ if (!value) {
11482
+ return {
11483
+ provider,
11484
+ packages,
11485
+ error: "Missing value for --provider (expected claude, codex, or all)."
11486
+ };
11487
+ }
11488
+ if (value !== "claude" && value !== "codex" && value !== "all") {
11489
+ return {
11490
+ provider,
11491
+ packages,
11492
+ error: `Invalid provider "${value}". Expected claude, codex, or all.`
11493
+ };
11494
+ }
11495
+ provider = value;
11496
+ i++;
11497
+ continue;
11498
+ }
11499
+ if (token.startsWith("--provider=")) {
11500
+ const value = token.slice("--provider=".length);
11501
+ if (value !== "claude" && value !== "codex" && value !== "all") {
11502
+ return {
11503
+ provider,
11504
+ packages,
11505
+ error: `Invalid provider "${value}". Expected claude, codex, or all.`
11506
+ };
11507
+ }
11508
+ provider = value;
11509
+ continue;
11510
+ }
11511
+ if (token.startsWith("-")) {
11512
+ return {
11513
+ provider,
11514
+ packages,
11515
+ error: `Unknown option "${token}".`
11516
+ };
11517
+ }
11518
+ packages.push(token);
11519
+ }
11520
+ if (packages.length === 0) {
11521
+ return {
11522
+ provider,
11523
+ packages,
11524
+ error: "Usage: locus sandbox install <package...> [--provider claude|codex|all]"
11525
+ };
11526
+ }
11527
+ return { provider, packages };
11528
+ }
11529
+ async function handleInstall(projectRoot, args) {
11530
+ const parsed = parseSandboxInstallArgs(args);
11531
+ if (parsed.error) {
11532
+ process.stderr.write(`${red("✗")} ${parsed.error}
11533
+ `);
11534
+ return;
11535
+ }
11536
+ const config = loadConfig(projectRoot);
11537
+ const targets = getTargetProviders(config.sandbox.providers, parsed.provider);
11538
+ if (targets.length === 0) {
11539
+ process.stderr.write(`${red("✗")} No provider sandboxes are configured. Run ${cyan("locus sandbox")} first.
11540
+ `);
11541
+ return;
11542
+ }
11543
+ let anySucceeded = false;
11544
+ let anyFailed = false;
11545
+ for (const provider of targets) {
11546
+ const sandboxName = config.sandbox.providers[provider];
11547
+ if (!sandboxName) {
11548
+ process.stderr.write(`${yellow("⚠")} ${provider} sandbox is not configured. Run ${cyan("locus sandbox")} first.
11549
+ `);
11550
+ anyFailed = true;
11551
+ continue;
11552
+ }
11553
+ if (!isSandboxAlive(sandboxName)) {
11554
+ process.stderr.write(`${yellow("⚠")} ${provider} sandbox is not running: ${dim(sandboxName)}
11555
+ `);
11556
+ anyFailed = true;
11557
+ continue;
11558
+ }
11559
+ process.stderr.write(`Installing ${bold(parsed.packages.join(", "))} in ${provider} sandbox ${dim(sandboxName)}...
11560
+ `);
11561
+ const ok = await runInteractiveCommand("docker", [
11562
+ "sandbox",
11563
+ "exec",
11564
+ sandboxName,
11565
+ "npm",
11566
+ "install",
11567
+ "-g",
11568
+ ...parsed.packages
11569
+ ]);
11570
+ if (ok) {
11571
+ anySucceeded = true;
11572
+ process.stderr.write(`${green("✓")} Installed package(s) in ${provider} sandbox.
11573
+ `);
11574
+ } else {
11575
+ anyFailed = true;
11576
+ process.stderr.write(`${red("✗")} Failed to install package(s) in ${provider} sandbox.
11577
+ `);
11578
+ }
11579
+ }
11580
+ if (!anySucceeded && anyFailed) {
11581
+ process.stderr.write(`${yellow("⚠")} No package installs completed successfully.
11582
+ `);
11583
+ }
11584
+ }
11585
+ function parseSandboxExecArgs(args) {
11586
+ if (args.length === 0) {
11587
+ return {
11588
+ command: [],
11589
+ error: "Usage: locus sandbox exec <provider> -- <command...>"
11590
+ };
11591
+ }
11592
+ const provider = args[0];
11593
+ if (provider !== "claude" && provider !== "codex") {
11594
+ return {
11595
+ command: [],
11596
+ error: `Invalid provider "${provider}". Expected claude or codex.`
11597
+ };
11598
+ }
11599
+ const separatorIndex = args.indexOf("--");
11600
+ const command = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : args.slice(1);
11601
+ if (command.length === 0) {
11602
+ return {
11603
+ provider,
11604
+ command: [],
11605
+ error: "Missing command. Example: locus sandbox exec codex -- bun --version"
11606
+ };
11607
+ }
11608
+ return { provider, command };
11609
+ }
11610
+ async function handleExec(projectRoot, args) {
11611
+ const parsed = parseSandboxExecArgs(args);
11612
+ if (parsed.error || !parsed.provider) {
11613
+ process.stderr.write(`${red("✗")} ${parsed.error}
11614
+ `);
11615
+ return;
11616
+ }
11617
+ const sandboxName = getActiveProviderSandbox(projectRoot, parsed.provider);
11618
+ if (!sandboxName) {
11619
+ return;
11620
+ }
11621
+ await runInteractiveCommand("docker", [
11622
+ "sandbox",
11623
+ "exec",
11624
+ "-w",
11625
+ projectRoot,
11626
+ sandboxName,
11627
+ ...parsed.command
11628
+ ]);
11629
+ }
11630
+ async function handleShell(projectRoot, args) {
11631
+ const provider = args[0];
11632
+ if (provider !== "claude" && provider !== "codex") {
11633
+ process.stderr.write(`${red("✗")} Usage: locus sandbox shell <provider> (provider: claude|codex)
11634
+ `);
11635
+ return;
11636
+ }
11637
+ const sandboxName = getActiveProviderSandbox(projectRoot, provider);
11638
+ if (!sandboxName) {
11639
+ return;
11640
+ }
11641
+ process.stderr.write(`Opening shell in ${provider} sandbox ${dim(sandboxName)}...
11642
+ `);
11643
+ await runInteractiveCommand("docker", [
11644
+ "sandbox",
11645
+ "exec",
11646
+ "-it",
11647
+ "-w",
11648
+ projectRoot,
11649
+ sandboxName,
11650
+ "sh"
11651
+ ]);
11652
+ }
11653
+ function parseSandboxLogsArgs(args) {
11654
+ if (args.length === 0) {
11655
+ return {
11656
+ follow: false,
11657
+ error: "Usage: locus sandbox logs <provider> [--follow] [--tail <lines>]"
11658
+ };
11659
+ }
11660
+ const provider = args[0];
11661
+ if (provider !== "claude" && provider !== "codex") {
11662
+ return {
11663
+ follow: false,
11664
+ error: `Invalid provider "${provider}". Expected claude or codex.`
11665
+ };
11666
+ }
11667
+ let follow = false;
11668
+ let tail;
11669
+ for (let i = 1;i < args.length; i++) {
11670
+ const token = args[i];
11671
+ if (token === "--follow" || token === "-f") {
11672
+ follow = true;
11673
+ continue;
11674
+ }
11675
+ if (token === "--tail") {
11676
+ const value = args[i + 1];
11677
+ if (!value) {
11678
+ return { provider, follow, tail, error: "Missing value for --tail." };
11679
+ }
11680
+ const parsedTail = Number.parseInt(value, 10);
11681
+ if (!Number.isFinite(parsedTail) || parsedTail < 0) {
11682
+ return { provider, follow, tail, error: "Invalid --tail value." };
11683
+ }
11684
+ tail = parsedTail;
11685
+ i++;
11686
+ continue;
11687
+ }
11688
+ if (token.startsWith("--tail=")) {
11689
+ const value = token.slice("--tail=".length);
11690
+ const parsedTail = Number.parseInt(value, 10);
11691
+ if (!Number.isFinite(parsedTail) || parsedTail < 0) {
11692
+ return { provider, follow, tail, error: "Invalid --tail value." };
11693
+ }
11694
+ tail = parsedTail;
11695
+ continue;
11696
+ }
11697
+ return {
11698
+ provider,
11699
+ follow,
11700
+ tail,
11701
+ error: `Unknown option "${token}".`
11702
+ };
11703
+ }
11704
+ return { provider, follow, tail };
11705
+ }
11706
+ async function handleLogs(projectRoot, args) {
11707
+ const parsed = parseSandboxLogsArgs(args);
11708
+ if (parsed.error || !parsed.provider) {
11709
+ process.stderr.write(`${red("✗")} ${parsed.error}
11710
+ `);
11711
+ return;
11712
+ }
11713
+ const sandboxName = getActiveProviderSandbox(projectRoot, parsed.provider);
11714
+ if (!sandboxName) {
11715
+ return;
11716
+ }
11717
+ const dockerArgs = ["sandbox", "logs"];
11718
+ if (parsed.follow) {
11719
+ dockerArgs.push("--follow");
11720
+ }
11721
+ if (parsed.tail !== undefined) {
11722
+ dockerArgs.push("--tail", String(parsed.tail));
11723
+ }
11724
+ dockerArgs.push(sandboxName);
11725
+ await runInteractiveCommand("docker", dockerArgs);
11726
+ }
11727
+ function buildProviderSandboxNames(projectRoot) {
11728
+ const segment = sanitizeSegment(basename4(projectRoot));
11729
+ const hash = createHash("sha1").update(projectRoot).digest("hex").slice(0, 8);
11730
+ return {
11731
+ claude: `locus-${segment}-claude-${hash}`,
11732
+ codex: `locus-${segment}-codex-${hash}`
11733
+ };
11734
+ }
11735
+ function sanitizeSegment(input) {
11736
+ const cleaned = input.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
11737
+ return cleaned || "workspace";
11738
+ }
11739
+ function getTargetProviders(sandboxes, provider) {
11740
+ if (provider === "claude" || provider === "codex") {
11741
+ return [provider];
11742
+ }
11743
+ return PROVIDERS.filter((name) => Boolean(sandboxes[name]));
11744
+ }
11745
+ function getActiveProviderSandbox(projectRoot, provider) {
11746
+ const config = loadConfig(projectRoot);
11747
+ const sandboxName = config.sandbox.providers[provider];
11748
+ if (!sandboxName) {
11749
+ process.stderr.write(`${red("✗")} No ${provider} sandbox configured. Run ${cyan("locus sandbox")} first.
11750
+ `);
11751
+ return null;
11752
+ }
11753
+ if (!isSandboxAlive(sandboxName)) {
11754
+ process.stderr.write(`${red("✗")} ${provider} sandbox is not running: ${dim(sandboxName)}
11755
+ `);
11756
+ process.stderr.write(` Recreate it with ${cyan("locus sandbox")}.
11757
+ `);
11758
+ return null;
11759
+ }
11760
+ return sandboxName;
11761
+ }
11762
+ function runInteractiveCommand(command, args) {
11763
+ return new Promise((resolve2) => {
11764
+ const child = spawn6(command, args, { stdio: "inherit" });
11765
+ child.on("close", (code) => resolve2(code === 0));
11766
+ child.on("error", () => resolve2(false));
11767
+ });
11768
+ }
11769
+ async function createProviderSandbox(provider, sandboxName, projectRoot) {
11770
+ try {
11771
+ execSync17(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, {
11772
+ stdio: ["pipe", "pipe", "pipe"],
11773
+ timeout: 120000
11774
+ });
11775
+ } catch {}
11776
+ if (!isSandboxAlive(sandboxName)) {
11777
+ return false;
11778
+ }
11779
+ if (provider === "codex") {
11780
+ await ensureCodexInSandbox(sandboxName);
11781
+ }
11782
+ await enforceSandboxIgnore(sandboxName, projectRoot);
11783
+ return true;
11784
+ }
11708
11785
  async function ensureCodexInSandbox(sandboxName) {
11709
11786
  try {
11710
- execSync19(`docker sandbox exec ${sandboxName} which codex`, {
11787
+ execSync17(`docker sandbox exec ${sandboxName} which codex`, {
11711
11788
  stdio: ["pipe", "pipe", "pipe"],
11712
11789
  timeout: 5000
11713
11790
  });
@@ -11715,7 +11792,7 @@ async function ensureCodexInSandbox(sandboxName) {
11715
11792
  process.stderr.write(`Installing codex in sandbox...
11716
11793
  `);
11717
11794
  try {
11718
- execSync19(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11795
+ execSync17(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11719
11796
  } catch {
11720
11797
  process.stderr.write(`${red("✗")} Failed to install codex in sandbox.
11721
11798
  `);
@@ -11724,7 +11801,7 @@ async function ensureCodexInSandbox(sandboxName) {
11724
11801
  }
11725
11802
  function isSandboxAlive(name) {
11726
11803
  try {
11727
- const output = execSync19("docker sandbox ls", {
11804
+ const output = execSync17("docker sandbox ls", {
11728
11805
  encoding: "utf-8",
11729
11806
  stdio: ["pipe", "pipe", "pipe"],
11730
11807
  timeout: 5000
@@ -11734,11 +11811,13 @@ function isSandboxAlive(name) {
11734
11811
  return false;
11735
11812
  }
11736
11813
  }
11814
+ var PROVIDERS;
11737
11815
  var init_sandbox2 = __esm(() => {
11738
11816
  init_config();
11739
11817
  init_sandbox();
11740
11818
  init_sandbox_ignore();
11741
11819
  init_terminal();
11820
+ PROVIDERS = ["claude", "codex"];
11742
11821
  });
11743
11822
 
11744
11823
  // src/cli.ts