@locusai/cli 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/locus.js +1295 -1428
  2. package/package.json +1 -1
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
 
@@ -1440,6 +1421,9 @@ function updateIssueLabels(number, addLabels, removeLabels, options = {}) {
1440
1421
  }
1441
1422
  gh(args, options);
1442
1423
  }
1424
+ function deleteIssue(number, options = {}) {
1425
+ gh(`issue delete ${number} --yes`, options);
1426
+ }
1443
1427
  function addIssueComment(number, body, options = {}) {
1444
1428
  const cwd = options.cwd ?? process.cwd();
1445
1429
  execFileSync("gh", ["issue", "comment", String(number), "--body", body], {
@@ -1813,14 +1797,13 @@ ${bold("Sandbox mode")} ${dim("(recommended)")}
1813
1797
  process.stderr.write(` Run AI agents in an isolated Docker sandbox for safety.
1814
1798
 
1815
1799
  `);
1816
- 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")}
1817
1801
  `);
1818
1802
  process.stderr.write(` ${gray("2.")} ${cyan("locus sandbox claude")} ${dim("Login to Claude inside the sandbox")}
1819
1803
  `);
1820
- 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")}
1821
1805
  `);
1822
- process.stderr.write(`
1823
- ${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")}
1824
1807
  `);
1825
1808
  process.stderr.write(` ${dim("Learn more:")} ${cyan("locus sandbox help")}
1826
1809
  `);
@@ -4399,449 +4382,161 @@ var init_sandbox_ignore = __esm(() => {
4399
4382
  execAsync = promisify(exec);
4400
4383
  });
4401
4384
 
4402
- // src/core/run-state.ts
4403
- import {
4404
- existsSync as existsSync13,
4405
- mkdirSync as mkdirSync9,
4406
- readFileSync as readFileSync9,
4407
- unlinkSync as unlinkSync3,
4408
- writeFileSync as writeFileSync6
4409
- } from "node:fs";
4410
- import { dirname as dirname3, join as join12 } from "node:path";
4411
- function getRunStatePath(projectRoot) {
4412
- return join12(projectRoot, ".locus", "run-state.json");
4413
- }
4414
- function loadRunState(projectRoot) {
4415
- const path = getRunStatePath(projectRoot);
4416
- if (!existsSync13(path))
4417
- return null;
4418
- try {
4419
- return JSON.parse(readFileSync9(path, "utf-8"));
4420
- } catch {
4421
- getLogger().warn("Corrupted run-state.json, ignoring");
4422
- return null;
4423
- }
4424
- }
4425
- function saveRunState(projectRoot, state) {
4426
- const path = getRunStatePath(projectRoot);
4427
- const dir = dirname3(path);
4428
- if (!existsSync13(dir)) {
4429
- mkdirSync9(dir, { recursive: true });
4430
- }
4431
- writeFileSync6(path, `${JSON.stringify(state, null, 2)}
4432
- `, "utf-8");
4433
- }
4434
- function clearRunState(projectRoot) {
4435
- const path = getRunStatePath(projectRoot);
4436
- if (existsSync13(path)) {
4437
- unlinkSync3(path);
4438
- }
4439
- }
4440
- function createSprintRunState(sprint, branch, issues) {
4441
- return {
4442
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4443
- type: "sprint",
4444
- sprint,
4445
- branch,
4446
- startedAt: new Date().toISOString(),
4447
- tasks: issues.map(({ number, order }) => ({
4448
- issue: number,
4449
- order,
4450
- status: "pending"
4451
- }))
4452
- };
4453
- }
4454
- function createParallelRunState(issueNumbers) {
4455
- return {
4456
- runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
4457
- type: "parallel",
4458
- startedAt: new Date().toISOString(),
4459
- tasks: issueNumbers.map((issue, i) => ({
4460
- issue,
4461
- order: i + 1,
4462
- status: "pending"
4463
- }))
4464
- };
4465
- }
4466
- function markTaskInProgress(state, issueNumber) {
4467
- const task = state.tasks.find((t) => t.issue === issueNumber);
4468
- if (task) {
4469
- task.status = "in_progress";
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;
4470
4395
  }
4471
- }
4472
- function markTaskDone(state, issueNumber, prNumber) {
4473
- const task = state.tasks.find((t) => t.issue === issueNumber);
4474
- if (task) {
4475
- task.status = "done";
4476
- task.completedAt = new Date().toISOString();
4477
- if (prNumber)
4478
- task.pr = prNumber;
4396
+ async isAvailable() {
4397
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4398
+ const delegate = new ClaudeRunner2;
4399
+ return delegate.isAvailable();
4479
4400
  }
4480
- }
4481
- function markTaskFailed(state, issueNumber, error) {
4482
- const task = state.tasks.find((t) => t.issue === issueNumber);
4483
- if (task) {
4484
- task.status = "failed";
4485
- task.failedAt = new Date().toISOString();
4486
- task.error = error;
4487
- }
4488
- }
4489
- function getRunStats(state) {
4490
- const tasks = state.tasks;
4491
- return {
4492
- total: tasks.length,
4493
- done: tasks.filter((t) => t.status === "done").length,
4494
- failed: tasks.filter((t) => t.status === "failed").length,
4495
- pending: tasks.filter((t) => t.status === "pending").length,
4496
- inProgress: tasks.filter((t) => t.status === "in_progress").length
4497
- };
4498
- }
4499
- function getNextTask(state) {
4500
- const failed = state.tasks.find((t) => t.status === "failed");
4501
- if (failed)
4502
- return failed;
4503
- return state.tasks.find((t) => t.status === "pending") ?? null;
4504
- }
4505
- var init_run_state = __esm(() => {
4506
- init_logger();
4507
- });
4508
-
4509
- // src/core/shutdown.ts
4510
- import { execSync as execSync6 } from "node:child_process";
4511
- function registerActiveSandbox(name) {
4512
- activeSandboxes.add(name);
4513
- }
4514
- function unregisterActiveSandbox(name) {
4515
- activeSandboxes.delete(name);
4516
- }
4517
- function cleanupActiveSandboxes() {
4518
- for (const name of activeSandboxes) {
4519
- try {
4520
- execSync6(`docker sandbox rm ${name}`, { timeout: 1e4 });
4521
- } catch {}
4522
- }
4523
- activeSandboxes.clear();
4524
- }
4525
- function registerShutdownHandlers(ctx) {
4526
- shutdownContext = ctx;
4527
- interruptCount = 0;
4528
- const handler = () => {
4529
- interruptCount++;
4530
- if (interruptCount >= 2) {
4531
- process.stderr.write(`
4532
- Force exit.
4533
- `);
4534
- process.exit(1);
4535
- }
4536
- process.stderr.write(`
4537
-
4538
- Interrupted. Saving state...
4539
- `);
4540
- const state = shutdownContext?.getRunState?.();
4541
- if (state && shutdownContext) {
4542
- for (const task of state.tasks) {
4543
- if (task.status === "in_progress") {
4544
- task.status = "failed";
4545
- task.failedAt = new Date().toISOString();
4546
- task.error = "Interrupted by user";
4547
- }
4548
- }
4549
- try {
4550
- saveRunState(shutdownContext.projectRoot, state);
4551
- process.stderr.write(`State saved. Resume with: locus run --resume
4552
- `);
4553
- } catch {
4554
- process.stderr.write(`Warning: Could not save run state.
4555
- `);
4556
- }
4557
- }
4558
- cleanupActiveSandboxes();
4559
- shutdownContext?.onShutdown?.();
4560
- if (interruptTimer)
4561
- clearTimeout(interruptTimer);
4562
- interruptTimer = setTimeout(() => {
4563
- interruptCount = 0;
4564
- }, 2000);
4565
- setTimeout(() => {
4566
- process.exit(130);
4567
- }, 100);
4568
- };
4569
- if (!shutdownRegistered) {
4570
- process.on("SIGINT", handler);
4571
- process.on("SIGTERM", handler);
4572
- shutdownRegistered = true;
4573
- }
4574
- return () => {
4575
- process.removeListener("SIGINT", handler);
4576
- process.removeListener("SIGTERM", handler);
4577
- shutdownRegistered = false;
4578
- shutdownContext = null;
4579
- interruptCount = 0;
4580
- if (interruptTimer) {
4581
- clearTimeout(interruptTimer);
4582
- interruptTimer = null;
4583
- }
4584
- };
4585
- }
4586
- var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
4587
- var init_shutdown = __esm(() => {
4588
- init_run_state();
4589
- activeSandboxes = new Set;
4590
- });
4591
-
4592
- // src/ai/claude-sandbox.ts
4593
- import { execSync as execSync7, spawn as spawn3 } from "node:child_process";
4594
-
4595
- class SandboxedClaudeRunner {
4596
- name = "claude-sandboxed";
4597
- process = null;
4598
- aborted = false;
4599
- sandboxName = null;
4600
- persistent;
4601
- sandboxCreated = false;
4602
- userManaged = false;
4603
- constructor(persistentName, userManaged = false) {
4604
- if (persistentName) {
4605
- this.persistent = true;
4606
- this.sandboxName = persistentName;
4607
- this.userManaged = userManaged;
4608
- if (userManaged) {
4609
- this.sandboxCreated = true;
4610
- }
4611
- } else {
4612
- this.persistent = false;
4613
- }
4614
- }
4615
- async isAvailable() {
4616
- const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4617
- const delegate = new ClaudeRunner2;
4618
- return delegate.isAvailable();
4619
- }
4620
- async getVersion() {
4621
- const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4622
- const delegate = new ClaudeRunner2;
4623
- return delegate.getVersion();
4401
+ async getVersion() {
4402
+ const { ClaudeRunner: ClaudeRunner2 } = await Promise.resolve().then(() => (init_claude(), exports_claude));
4403
+ const delegate = new ClaudeRunner2;
4404
+ return delegate.getVersion();
4624
4405
  }
4625
4406
  async execute(options) {
4626
4407
  const log = getLogger();
4627
4408
  this.aborted = false;
4628
- const claudeArgs = ["-p", options.prompt, ...buildClaudeArgs(options)];
4629
- let dockerArgs;
4630
- if (this.persistent && !this.sandboxName) {
4631
- throw new Error("Sandbox name is required");
4632
- }
4633
- if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
4634
- const name = this.sandboxName;
4635
- if (!name) {
4636
- throw new Error("Sandbox name is required");
4637
- }
4638
- options.onStatusChange?.("Syncing sandbox...");
4639
- await enforceSandboxIgnore(name, options.cwd);
4640
- options.onStatusChange?.("Thinking...");
4641
- dockerArgs = [
4642
- "sandbox",
4643
- "exec",
4644
- "-w",
4645
- options.cwd,
4646
- name,
4647
- "claude",
4648
- ...claudeArgs
4649
- ];
4650
- } else {
4651
- if (!this.persistent) {
4652
- this.sandboxName = buildSandboxName(options);
4653
- }
4654
- const name = this.sandboxName;
4655
- if (!name) {
4656
- throw new Error("Sandbox name is required");
4657
- }
4658
- registerActiveSandbox(name);
4659
- options.onStatusChange?.("Syncing sandbox...");
4660
- dockerArgs = [
4661
- "sandbox",
4662
- "run",
4663
- "--name",
4664
- name,
4665
- "claude",
4666
- options.cwd,
4667
- "--",
4668
- ...claudeArgs
4669
- ];
4409
+ if (!await this.isSandboxRunning()) {
4410
+ return {
4411
+ success: false,
4412
+ output: "",
4413
+ error: `Sandbox is not running: ${this.sandboxName}`,
4414
+ exitCode: 1
4415
+ };
4670
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
+ ];
4671
4430
  log.debug("Spawning sandboxed claude", {
4672
4431
  sandboxName: this.sandboxName,
4673
- persistent: this.persistent,
4674
- reusing: this.persistent && this.sandboxCreated,
4675
4432
  args: dockerArgs.join(" "),
4676
4433
  cwd: options.cwd
4677
4434
  });
4678
- try {
4679
- return await new Promise((resolve2) => {
4680
- let output = "";
4681
- let errorOutput = "";
4682
- this.process = spawn3("docker", dockerArgs, {
4683
- stdio: ["ignore", "pipe", "pipe"],
4684
- env: process.env
4685
- });
4686
- if (this.persistent && !this.sandboxCreated) {
4687
- this.process.on("spawn", () => {
4688
- this.sandboxCreated = true;
4689
- });
4690
- }
4691
- if (options.verbose) {
4692
- let lineBuffer = "";
4693
- const seenToolIds = new Set;
4694
- this.process.stdout?.on("data", (chunk) => {
4695
- lineBuffer += chunk.toString();
4696
- const lines = lineBuffer.split(`
4697
- `);
4698
- lineBuffer = lines.pop() ?? "";
4699
- for (const line of lines) {
4700
- if (!line.trim())
4701
- continue;
4702
- try {
4703
- const event = JSON.parse(line);
4704
- if (event.type === "assistant" && event.message?.content) {
4705
- for (const item of event.message.content) {
4706
- if (item.type === "tool_use" && item.id && !seenToolIds.has(item.id)) {
4707
- seenToolIds.add(item.id);
4708
- options.onToolActivity?.(formatToolCall2(item.name ?? "", item.input ?? {}));
4709
- }
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 ?? {}));
4710
4460
  }
4711
- } else if (event.type === "result") {
4712
- const text = event.result ?? "";
4713
- output = text;
4714
- options.onOutput?.(text);
4715
4461
  }
4716
- } catch {
4717
- const newLine = `${line}
4718
- `;
4719
- output += newLine;
4720
- options.onOutput?.(newLine);
4462
+ } else if (event.type === "result") {
4463
+ const text = event.result ?? "";
4464
+ output = text;
4465
+ options.onOutput?.(text);
4721
4466
  }
4467
+ } catch {
4468
+ const newLine = `${line}
4469
+ `;
4470
+ output += newLine;
4471
+ options.onOutput?.(newLine);
4722
4472
  }
4723
- });
4724
- } else {
4725
- this.process.stdout?.on("data", (chunk) => {
4726
- const text = chunk.toString();
4727
- output += text;
4728
- options.onOutput?.(text);
4729
- });
4730
- }
4731
- this.process.stderr?.on("data", (chunk) => {
4473
+ }
4474
+ });
4475
+ } else {
4476
+ this.process.stdout?.on("data", (chunk) => {
4732
4477
  const text = chunk.toString();
4733
- errorOutput += text;
4734
- log.debug("sandboxed claude stderr", { text: text.slice(0, 500) });
4478
+ output += text;
4735
4479
  options.onOutput?.(text);
4736
4480
  });
4737
- this.process.on("close", (code) => {
4738
- this.process = null;
4739
- if (this.aborted) {
4740
- resolve2({
4741
- success: false,
4742
- output,
4743
- error: "Aborted by user",
4744
- exitCode: code ?? 143
4745
- });
4746
- return;
4747
- }
4748
- if (code === 0) {
4749
- resolve2({
4750
- success: true,
4751
- output,
4752
- exitCode: 0
4753
- });
4754
- } else {
4755
- resolve2({
4756
- success: false,
4757
- output,
4758
- error: errorOutput || `sandboxed claude exited with code ${code}`,
4759
- exitCode: code ?? 1
4760
- });
4761
- }
4762
- });
4763
- this.process.on("error", (err) => {
4764
- this.process = null;
4765
- if (this.persistent && !this.sandboxCreated) {}
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) {
4766
4491
  resolve2({
4767
4492
  success: false,
4768
4493
  output,
4769
- error: `Failed to spawn docker sandbox: ${err.message}`,
4770
- exitCode: 1
4494
+ error: "Aborted by user",
4495
+ exitCode: code ?? 143
4771
4496
  });
4772
- });
4773
- if (options.signal) {
4774
- options.signal.addEventListener("abort", () => {
4775
- 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
4776
4507
  });
4777
4508
  }
4778
4509
  });
4779
- } finally {
4780
- if (!this.persistent) {
4781
- 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
+ });
4782
4523
  }
4783
- }
4524
+ });
4784
4525
  }
4785
4526
  abort() {
4786
4527
  this.aborted = true;
4787
- const log = getLogger();
4788
- if (this.persistent) {
4789
- log.debug("Aborting sandboxed claude (persistent — keeping sandbox)", {
4790
- sandboxName: this.sandboxName
4791
- });
4528
+ if (!this.process)
4529
+ return;
4530
+ this.process.kill("SIGTERM");
4531
+ const timer = setTimeout(() => {
4792
4532
  if (this.process) {
4793
- this.process.kill("SIGTERM");
4794
- const timer = setTimeout(() => {
4795
- if (this.process) {
4796
- this.process.kill("SIGKILL");
4797
- }
4798
- }, 3000);
4799
- if (timer.unref)
4800
- timer.unref();
4533
+ this.process.kill("SIGKILL");
4801
4534
  }
4802
- } else {
4803
- if (!this.sandboxName)
4804
- return;
4805
- log.debug("Aborting sandboxed claude (ephemeral — removing sandbox)", {
4806
- sandboxName: this.sandboxName
4807
- });
4808
- try {
4809
- execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4810
- } catch {}
4811
- }
4812
- }
4813
- destroy() {
4814
- if (!this.sandboxName)
4815
- return;
4816
- if (this.userManaged) {
4817
- unregisterActiveSandbox(this.sandboxName);
4818
- return;
4819
- }
4820
- const log = getLogger();
4821
- log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
4822
- try {
4823
- execSync7(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
4824
- } catch {}
4825
- unregisterActiveSandbox(this.sandboxName);
4826
- this.sandboxName = null;
4827
- this.sandboxCreated = false;
4828
- }
4829
- cleanupSandbox() {
4830
- if (!this.sandboxName)
4831
- return;
4832
- const log = getLogger();
4833
- log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
4834
- try {
4835
- execSync7(`docker sandbox rm ${this.sandboxName}`, {
4836
- timeout: 60000
4837
- });
4838
- } catch {}
4839
- unregisterActiveSandbox(this.sandboxName);
4840
- this.sandboxName = null;
4535
+ }, 3000);
4536
+ if (timer.unref)
4537
+ timer.unref();
4841
4538
  }
4842
4539
  async isSandboxRunning() {
4843
- if (!this.sandboxName)
4844
- return false;
4845
4540
  try {
4846
4541
  const { promisify: promisify2 } = await import("node:util");
4847
4542
  const { exec: exec2 } = await import("node:child_process");
@@ -4854,24 +4549,6 @@ class SandboxedClaudeRunner {
4854
4549
  return false;
4855
4550
  }
4856
4551
  }
4857
- getSandboxName() {
4858
- return this.sandboxName;
4859
- }
4860
- }
4861
- function buildSandboxName(options) {
4862
- const ts = Date.now();
4863
- if (options.activity) {
4864
- const match = options.activity.match(/issue\s*#(\d+)/i);
4865
- if (match) {
4866
- return `locus-issue-${match[1]}-${ts}`;
4867
- }
4868
- }
4869
- const segment = options.cwd.split("/").pop() ?? "run";
4870
- return `locus-${segment}-${ts}`;
4871
- }
4872
- function buildPersistentSandboxName(cwd) {
4873
- const segment = cwd.split("/").pop() ?? "repl";
4874
- return `locus-${segment}-${Date.now()}`;
4875
4552
  }
4876
4553
  function formatToolCall2(name, input) {
4877
4554
  switch (name) {
@@ -4895,7 +4572,7 @@ function formatToolCall2(name, input) {
4895
4572
  case "WebSearch":
4896
4573
  return `searching: ${input.query ?? ""}`;
4897
4574
  case "Task":
4898
- return `spawning agent`;
4575
+ return "spawning agent";
4899
4576
  default:
4900
4577
  return name;
4901
4578
  }
@@ -4903,12 +4580,11 @@ function formatToolCall2(name, input) {
4903
4580
  var init_claude_sandbox = __esm(() => {
4904
4581
  init_logger();
4905
4582
  init_sandbox_ignore();
4906
- init_shutdown();
4907
4583
  init_claude();
4908
4584
  });
4909
4585
 
4910
4586
  // src/ai/codex.ts
4911
- import { execSync as execSync8, spawn as spawn4 } from "node:child_process";
4587
+ import { execSync as execSync6, spawn as spawn4 } from "node:child_process";
4912
4588
  function buildCodexArgs(model) {
4913
4589
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
4914
4590
  if (model) {
@@ -4924,7 +4600,7 @@ class CodexRunner {
4924
4600
  aborted = false;
4925
4601
  async isAvailable() {
4926
4602
  try {
4927
- execSync8("codex --version", {
4603
+ execSync6("codex --version", {
4928
4604
  encoding: "utf-8",
4929
4605
  stdio: ["pipe", "pipe", "pipe"]
4930
4606
  });
@@ -4935,7 +4611,7 @@ class CodexRunner {
4935
4611
  }
4936
4612
  async getVersion() {
4937
4613
  try {
4938
- const output = execSync8("codex --version", {
4614
+ const output = execSync6("codex --version", {
4939
4615
  encoding: "utf-8",
4940
4616
  stdio: ["pipe", "pipe", "pipe"]
4941
4617
  }).trim();
@@ -5083,28 +4759,16 @@ var init_codex = __esm(() => {
5083
4759
  });
5084
4760
 
5085
4761
  // src/ai/codex-sandbox.ts
5086
- import { execSync as execSync9, spawn as spawn5 } from "node:child_process";
4762
+ import { spawn as spawn5 } from "node:child_process";
5087
4763
 
5088
4764
  class SandboxedCodexRunner {
4765
+ sandboxName;
5089
4766
  name = "codex-sandboxed";
5090
4767
  process = null;
5091
4768
  aborted = false;
5092
- sandboxName = null;
5093
- persistent;
5094
- sandboxCreated = false;
5095
- userManaged = false;
5096
4769
  codexInstalled = false;
5097
- constructor(persistentName, userManaged = false) {
5098
- if (persistentName) {
5099
- this.persistent = true;
5100
- this.sandboxName = persistentName;
5101
- this.userManaged = userManaged;
5102
- if (userManaged) {
5103
- this.sandboxCreated = true;
5104
- }
5105
- } else {
5106
- this.persistent = false;
5107
- }
4770
+ constructor(sandboxName) {
4771
+ this.sandboxName = sandboxName;
5108
4772
  }
5109
4773
  async isAvailable() {
5110
4774
  const delegate = new CodexRunner;
@@ -5117,251 +4781,158 @@ class SandboxedCodexRunner {
5117
4781
  async execute(options) {
5118
4782
  const log = getLogger();
5119
4783
  this.aborted = false;
5120
- const codexArgs = buildCodexArgs(options.model);
5121
- let dockerArgs;
5122
- if (this.persistent && !this.sandboxName) {
5123
- throw new Error("Sandbox name is required");
4784
+ if (!await this.isSandboxRunning()) {
4785
+ return {
4786
+ success: false,
4787
+ output: "",
4788
+ error: `Sandbox is not running: ${this.sandboxName}`,
4789
+ exitCode: 1
4790
+ };
5124
4791
  }
5125
- if (this.persistent && this.sandboxCreated && await this.isSandboxRunning()) {
5126
- const name = this.sandboxName;
5127
- if (!name) {
5128
- throw new Error("Sandbox name is required");
5129
- }
5130
- options.onStatusChange?.("Syncing sandbox...");
5131
- await enforceSandboxIgnore(name, options.cwd);
5132
- if (!this.codexInstalled) {
5133
- options.onStatusChange?.("Checking codex...");
5134
- await this.ensureCodexInstalled(name);
5135
- this.codexInstalled = true;
5136
- }
5137
- options.onStatusChange?.("Thinking...");
5138
- dockerArgs = [
5139
- "sandbox",
5140
- "exec",
5141
- "-i",
5142
- "-w",
5143
- options.cwd,
5144
- name,
5145
- "codex",
5146
- ...codexArgs
5147
- ];
5148
- } else {
5149
- if (!this.persistent) {
5150
- this.sandboxName = buildSandboxName2(options);
5151
- }
5152
- const name = this.sandboxName;
5153
- if (!name) {
5154
- throw new Error("Sandbox name is required");
5155
- }
5156
- registerActiveSandbox(name);
5157
- options.onStatusChange?.("Creating sandbox...");
5158
- await this.createSandboxWithClaude(name, options.cwd);
5159
- options.onStatusChange?.("Installing codex...");
5160
- await this.ensureCodexInstalled(name);
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);
5161
4798
  this.codexInstalled = true;
5162
- options.onStatusChange?.("Syncing sandbox...");
5163
- await enforceSandboxIgnore(name, options.cwd);
5164
- options.onStatusChange?.("Thinking...");
5165
- dockerArgs = [
5166
- "sandbox",
5167
- "exec",
5168
- "-i",
5169
- "-w",
5170
- options.cwd,
5171
- name,
5172
- "codex",
5173
- ...codexArgs
5174
- ];
5175
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
+ ];
5176
4811
  log.debug("Spawning sandboxed codex", {
5177
4812
  sandboxName: this.sandboxName,
5178
- persistent: this.persistent,
5179
- reusing: this.persistent && this.sandboxCreated,
5180
4813
  args: dockerArgs.join(" "),
5181
4814
  cwd: options.cwd
5182
4815
  });
5183
- try {
5184
- return await new Promise((resolve2) => {
5185
- let rawOutput = "";
5186
- let errorOutput = "";
5187
- this.process = spawn5("docker", dockerArgs, {
5188
- stdio: ["pipe", "pipe", "pipe"],
5189
- env: process.env
5190
- });
5191
- if (this.persistent && !this.sandboxCreated) {
5192
- this.process.on("spawn", () => {
5193
- this.sandboxCreated = true;
5194
- });
5195
- }
5196
- let agentMessages = [];
5197
- const flushAgentMessages = () => {
5198
- if (agentMessages.length > 0) {
5199
- options.onOutput?.(agentMessages.join(`
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(`
5200
4827
 
5201
4828
  `));
5202
- agentMessages = [];
5203
- }
5204
- };
5205
- let lineBuffer = "";
5206
- this.process.stdout?.on("data", (chunk) => {
5207
- lineBuffer += chunk.toString();
5208
- 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(`
5209
4836
  `);
5210
- lineBuffer = lines.pop() ?? "";
5211
- for (const line of lines) {
5212
- if (!line.trim())
5213
- continue;
5214
- rawOutput += `${line}
4837
+ lineBuffer = lines.pop() ?? "";
4838
+ for (const line of lines) {
4839
+ if (!line.trim())
4840
+ continue;
4841
+ rawOutput += `${line}
5215
4842
  `;
5216
- log.debug("sandboxed codex stdout line", { line });
5217
- try {
5218
- const event = JSON.parse(line);
5219
- const { type, item } = event;
5220
- if (type === "item.started" && item?.type === "command_execution") {
5221
- const cmd = (item.command ?? "").split(`
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(`
5222
4849
  `)[0].slice(0, 80);
5223
- options.onToolActivity?.(`running: ${cmd}`);
5224
- } else if (type === "item.completed" && item?.type === "command_execution") {
5225
- const code = item.exit_code;
5226
- options.onToolActivity?.(code === 0 ? "done" : `exit ${code}`);
5227
- } else if (type === "item.completed" && item?.type === "reasoning") {
5228
- const text = (item.text ?? "").trim().replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1");
5229
- if (text)
5230
- options.onToolActivity?.(text);
5231
- } else if (type === "item.completed" && item?.type === "agent_message") {
5232
- const text = item.text ?? "";
5233
- if (text) {
5234
- agentMessages.push(text);
5235
- options.onToolActivity?.(text.split(`
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(`
5236
4863
  `)[0].slice(0, 80));
5237
- }
5238
- } else if (type === "turn.completed") {
5239
- flushAgentMessages();
5240
4864
  }
5241
- } catch {
5242
- const newLine = `${line}
5243
- `;
5244
- rawOutput += newLine;
5245
- options.onOutput?.(newLine);
4865
+ } else if (type === "turn.completed") {
4866
+ flushAgentMessages();
5246
4867
  }
4868
+ } catch {
4869
+ const newLine = `${line}
4870
+ `;
4871
+ rawOutput += newLine;
4872
+ options.onOutput?.(newLine);
5247
4873
  }
5248
- });
5249
- this.process.stderr?.on("data", (chunk) => {
5250
- const text = chunk.toString();
5251
- errorOutput += text;
5252
- log.debug("sandboxed codex stderr", { text: text.slice(0, 500) });
5253
- });
5254
- this.process.on("close", (code) => {
5255
- this.process = null;
5256
- flushAgentMessages();
5257
- if (this.aborted) {
5258
- resolve2({
5259
- success: false,
5260
- output: rawOutput,
5261
- error: "Aborted by user",
5262
- exitCode: code ?? 143
5263
- });
5264
- return;
5265
- }
5266
- if (code === 0) {
5267
- resolve2({
5268
- success: true,
5269
- output: rawOutput,
5270
- exitCode: 0
5271
- });
5272
- } else {
5273
- resolve2({
5274
- success: false,
5275
- output: rawOutput,
5276
- error: errorOutput || `sandboxed codex exited with code ${code}`,
5277
- exitCode: code ?? 1
5278
- });
5279
- }
5280
- });
5281
- this.process.on("error", (err) => {
5282
- this.process = null;
5283
- if (this.persistent && !this.sandboxCreated) {}
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) {
5284
4885
  resolve2({
5285
4886
  success: false,
5286
4887
  output: rawOutput,
5287
- error: `Failed to spawn docker sandbox: ${err.message}`,
5288
- exitCode: 1
4888
+ error: "Aborted by user",
4889
+ exitCode: code ?? 143
5289
4890
  });
5290
- });
5291
- if (options.signal) {
5292
- options.signal.addEventListener("abort", () => {
5293
- 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
5294
4901
  });
5295
4902
  }
5296
- this.process.stdin?.write(options.prompt);
5297
- this.process.stdin?.end();
5298
4903
  });
5299
- } finally {
5300
- if (!this.persistent) {
5301
- 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
+ });
5302
4917
  }
5303
- }
4918
+ this.process.stdin?.write(options.prompt);
4919
+ this.process.stdin?.end();
4920
+ });
5304
4921
  }
5305
4922
  abort() {
5306
4923
  this.aborted = true;
5307
- const log = getLogger();
5308
- if (this.persistent) {
5309
- log.debug("Aborting sandboxed codex (persistent — keeping sandbox)", {
5310
- sandboxName: this.sandboxName
5311
- });
4924
+ if (!this.process)
4925
+ return;
4926
+ this.process.kill("SIGTERM");
4927
+ const timer = setTimeout(() => {
5312
4928
  if (this.process) {
5313
- this.process.kill("SIGTERM");
5314
- const timer = setTimeout(() => {
5315
- if (this.process) {
5316
- this.process.kill("SIGKILL");
5317
- }
5318
- }, 3000);
5319
- if (timer.unref)
5320
- timer.unref();
4929
+ this.process.kill("SIGKILL");
5321
4930
  }
5322
- } else {
5323
- if (!this.sandboxName)
5324
- return;
5325
- log.debug("Aborting sandboxed codex (ephemeral — removing sandbox)", {
5326
- sandboxName: this.sandboxName
5327
- });
5328
- try {
5329
- execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5330
- } catch {}
5331
- }
5332
- }
5333
- destroy() {
5334
- if (!this.sandboxName)
5335
- return;
5336
- if (this.userManaged) {
5337
- unregisterActiveSandbox(this.sandboxName);
5338
- return;
5339
- }
5340
- const log = getLogger();
5341
- log.debug("Destroying sandbox", { sandboxName: this.sandboxName });
5342
- try {
5343
- execSync9(`docker sandbox rm ${this.sandboxName}`, { timeout: 60000 });
5344
- } catch {}
5345
- unregisterActiveSandbox(this.sandboxName);
5346
- this.sandboxName = null;
5347
- this.sandboxCreated = false;
5348
- }
5349
- cleanupSandbox() {
5350
- if (!this.sandboxName)
5351
- return;
5352
- const log = getLogger();
5353
- log.debug("Cleaning up sandbox", { sandboxName: this.sandboxName });
5354
- try {
5355
- execSync9(`docker sandbox rm ${this.sandboxName}`, {
5356
- timeout: 60000
5357
- });
5358
- } catch {}
5359
- unregisterActiveSandbox(this.sandboxName);
5360
- this.sandboxName = null;
4931
+ }, 3000);
4932
+ if (timer.unref)
4933
+ timer.unref();
5361
4934
  }
5362
4935
  async isSandboxRunning() {
5363
- if (!this.sandboxName)
5364
- return false;
5365
4936
  try {
5366
4937
  const { promisify: promisify2 } = await import("node:util");
5367
4938
  const { exec: exec2 } = await import("node:child_process");
@@ -5374,14 +4945,6 @@ class SandboxedCodexRunner {
5374
4945
  return false;
5375
4946
  }
5376
4947
  }
5377
- async createSandboxWithClaude(name, cwd) {
5378
- const { promisify: promisify2 } = await import("node:util");
5379
- const { exec: exec2 } = await import("node:child_process");
5380
- const execAsync2 = promisify2(exec2);
5381
- try {
5382
- await execAsync2(`docker sandbox run --name ${name} claude ${cwd} -- --version`, { timeout: 120000 });
5383
- } catch {}
5384
- }
5385
4948
  async ensureCodexInstalled(name) {
5386
4949
  const { promisify: promisify2 } = await import("node:util");
5387
4950
  const { exec: exec2 } = await import("node:child_process");
@@ -5391,38 +4954,28 @@ class SandboxedCodexRunner {
5391
4954
  timeout: 5000
5392
4955
  });
5393
4956
  } catch {
5394
- await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, { timeout: 120000 });
5395
- }
5396
- }
5397
- getSandboxName() {
5398
- return this.sandboxName;
5399
- }
5400
- }
5401
- function buildSandboxName2(options) {
5402
- const ts = Date.now();
5403
- if (options.activity) {
5404
- const match = options.activity.match(/issue\s*#(\d+)/i);
5405
- if (match) {
5406
- return `locus-codex-issue-${match[1]}-${ts}`;
4957
+ await execAsync2(`docker sandbox exec ${name} npm install -g @openai/codex`, {
4958
+ timeout: 120000
4959
+ });
5407
4960
  }
5408
4961
  }
5409
- const segment = options.cwd.split("/").pop() ?? "run";
5410
- return `locus-codex-${segment}-${ts}`;
5411
4962
  }
5412
4963
  var init_codex_sandbox = __esm(() => {
5413
4964
  init_logger();
5414
4965
  init_sandbox_ignore();
5415
- init_shutdown();
5416
4966
  init_codex();
5417
4967
  });
5418
4968
 
5419
4969
  // src/ai/runner.ts
5420
4970
  async function createRunnerAsync(provider, sandboxed) {
4971
+ if (sandboxed) {
4972
+ throw new Error("Sandboxed runner creation requires a provider sandbox name. Use createUserManagedSandboxRunner().");
4973
+ }
5421
4974
  switch (provider) {
5422
4975
  case "claude":
5423
- return sandboxed ? new SandboxedClaudeRunner : new ClaudeRunner;
4976
+ return new ClaudeRunner;
5424
4977
  case "codex":
5425
- return sandboxed ? new SandboxedCodexRunner : new CodexRunner;
4978
+ return new CodexRunner;
5426
4979
  default:
5427
4980
  throw new Error(`Unknown AI provider: ${provider}`);
5428
4981
  }
@@ -5430,9 +4983,9 @@ async function createRunnerAsync(provider, sandboxed) {
5430
4983
  function createUserManagedSandboxRunner(provider, sandboxName) {
5431
4984
  switch (provider) {
5432
4985
  case "claude":
5433
- return new SandboxedClaudeRunner(sandboxName, true);
4986
+ return new SandboxedClaudeRunner(sandboxName);
5434
4987
  case "codex":
5435
- return new SandboxedCodexRunner(sandboxName, true);
4988
+ return new SandboxedCodexRunner(sandboxName);
5436
4989
  default:
5437
4990
  throw new Error(`Unknown AI provider: ${provider}`);
5438
4991
  }
@@ -5532,10 +5085,20 @@ ${red("✗")} ${dim("Force exit.")}\r
5532
5085
  });
5533
5086
  if (options.runner) {
5534
5087
  runner = options.runner;
5535
- } 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
+ }
5536
5099
  runner = createUserManagedSandboxRunner(resolvedProvider, options.sandboxName);
5537
5100
  } else {
5538
- runner = await createRunnerAsync(resolvedProvider, options.sandboxed ?? true);
5101
+ runner = await createRunnerAsync(resolvedProvider, false);
5539
5102
  }
5540
5103
  const available = await runner.isAvailable();
5541
5104
  if (!available) {
@@ -5822,6 +5385,10 @@ async function issueCommand(projectRoot, args) {
5822
5385
  case "close":
5823
5386
  await issueClose(projectRoot, parsed);
5824
5387
  break;
5388
+ case "delete":
5389
+ case "rm":
5390
+ await issueDelete(projectRoot, parsed);
5391
+ break;
5825
5392
  default:
5826
5393
  if (/^\d+$/.test(parsed.subcommand)) {
5827
5394
  parsed.positional.unshift(parsed.subcommand);
@@ -5857,7 +5424,7 @@ async function issueCreate(projectRoot, parsed) {
5857
5424
  silent: true,
5858
5425
  activity: "generating issue",
5859
5426
  sandboxed: config.sandbox.enabled,
5860
- sandboxName: config.sandbox.name
5427
+ sandboxName: getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider)
5861
5428
  });
5862
5429
  if (!aiResult.success && !aiResult.interrupted) {
5863
5430
  process.stderr.write(`${red("✗")} Failed to generate issue: ${aiResult.error}
@@ -5949,21 +5516,26 @@ ${dim("────────────────────────
5949
5516
  }
5950
5517
  }
5951
5518
  function buildIssueCreationPrompt(userRequest) {
5952
- return [
5953
- "You are a task planner for a software development team.",
5954
- "Given a user request, create a well-structured GitHub issue.",
5955
- "",
5956
- "Output ONLY a valid JSON object with exactly these fields:",
5957
- '- "title": A concise, actionable issue title (max 80 characters)',
5958
- '- "body": Detailed markdown description with context, acceptance criteria, and technical notes',
5959
- '- "priority": One of: critical, high, medium, low',
5960
- '- "type": One of: feature, bug, chore, refactor, docs',
5961
- "",
5962
- `User request: ${userRequest}`,
5963
- "",
5964
- "Output ONLY the JSON object. No explanations, no code execution, no other text."
5965
- ].join(`
5966
- `);
5519
+ return `<role>
5520
+ You are a task planner for a software development team.
5521
+ Given a user request, create a well-structured GitHub issue.
5522
+ </role>
5523
+
5524
+ <output-format>
5525
+ Output ONLY a valid JSON object with exactly these fields:
5526
+ - "title": A concise, actionable issue title (max 80 characters)
5527
+ - "body": Detailed markdown description with context, acceptance criteria, and technical notes
5528
+ - "priority": One of: critical, high, medium, low
5529
+ - "type": One of: feature, bug, chore, refactor, docs
5530
+ </output-format>
5531
+
5532
+ <user-request>
5533
+ ${userRequest}
5534
+ </user-request>
5535
+
5536
+ <constraints>
5537
+ Output ONLY the JSON object. No explanations, no code execution, no other text.
5538
+ </constraints>`;
5967
5539
  }
5968
5540
  function extractJSON(text) {
5969
5541
  const codeBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -6227,6 +5799,43 @@ async function issueClose(projectRoot, parsed) {
6227
5799
  process.exit(1);
6228
5800
  }
6229
5801
  }
5802
+ async function issueDelete(projectRoot, parsed) {
5803
+ const issueNumbers = parsed.positional.filter((a) => /^\d+$/.test(a)).map(Number);
5804
+ if (issueNumbers.length === 0) {
5805
+ process.stderr.write(`${red("✗")} No issue numbers provided.
5806
+ `);
5807
+ process.stderr.write(` Usage: ${bold("locus issue delete <number...>")}
5808
+ `);
5809
+ process.exit(1);
5810
+ }
5811
+ const label = issueNumbers.length === 1 ? `issue #${issueNumbers[0]}` : `${issueNumbers.length} issues (#${issueNumbers.join(", #")})`;
5812
+ process.stderr.write(`${yellow("⚠")} This will ${bold("permanently delete")} ${label}.
5813
+ `);
5814
+ const answer = await askQuestion(`${cyan("?")} Are you sure? ${dim("[y/N]")} `);
5815
+ if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
5816
+ process.stderr.write(`${yellow("○")} Cancelled.
5817
+ `);
5818
+ return;
5819
+ }
5820
+ let failed = 0;
5821
+ for (const num of issueNumbers) {
5822
+ process.stderr.write(`${cyan("●")} Deleting issue #${num}...`);
5823
+ try {
5824
+ deleteIssue(num, { cwd: projectRoot });
5825
+ process.stderr.write(`\r${green("✓")} Deleted issue #${num}
5826
+ `);
5827
+ } catch (e) {
5828
+ process.stderr.write(`\r${red("✗")} #${num}: ${e.message}
5829
+ `);
5830
+ failed++;
5831
+ }
5832
+ }
5833
+ if (issueNumbers.length > 1 && failed === 0) {
5834
+ process.stderr.write(`
5835
+ ${green("✓")} All ${issueNumbers.length} issues deleted.
5836
+ `);
5837
+ }
5838
+ }
6230
5839
  function formatPriority(labels) {
6231
5840
  for (const label of labels) {
6232
5841
  if (label === "p:critical")
@@ -6329,6 +5938,7 @@ ${bold("Subcommands:")}
6329
5938
  ${cyan("show")} Show issue details
6330
5939
  ${cyan("label")} Bulk-update labels / sprint assignment
6331
5940
  ${cyan("close")} Close an issue
5941
+ ${cyan("delete")} ${dim("(rm)")} Permanently delete issues (bulk)
6332
5942
 
6333
5943
  ${bold("Create options:")}
6334
5944
  ${dim("--sprint, -s")} Assign to sprint (milestone)
@@ -6350,6 +5960,7 @@ ${bold("Examples:")}
6350
5960
  locus issue show 42
6351
5961
  locus issue label 42 43 --sprint "Sprint 2"
6352
5962
  locus issue close 42
5963
+ locus issue delete 42 43 44
6353
5964
 
6354
5965
  `);
6355
5966
  }
@@ -6358,6 +5969,7 @@ var init_issue = __esm(() => {
6358
5969
  init_config();
6359
5970
  init_github();
6360
5971
  init_logger();
5972
+ init_sandbox();
6361
5973
  init_table();
6362
5974
  init_terminal();
6363
5975
  init_types();
@@ -7015,9 +6627,9 @@ var init_sprint = __esm(() => {
7015
6627
  });
7016
6628
 
7017
6629
  // src/core/prompt-builder.ts
7018
- import { execSync as execSync10 } from "node:child_process";
7019
- import { existsSync as existsSync14, readdirSync as readdirSync3, readFileSync as readFileSync10 } from "node:fs";
7020
- import { join as join13 } from "node:path";
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";
7021
6633
  function buildExecutionPrompt(ctx) {
7022
6634
  const sections = [];
7023
6635
  sections.push(buildSystemContext(ctx.projectRoot));
@@ -7047,30 +6659,30 @@ function buildFeedbackPrompt(ctx) {
7047
6659
  }
7048
6660
  function buildReplPrompt(userMessage, projectRoot, _config, previousMessages) {
7049
6661
  const sections = [];
7050
- const locusmd = readFileSafe(join13(projectRoot, "LOCUS.md"));
6662
+ const locusmd = readFileSafe(join12(projectRoot, ".locus", "LOCUS.md"));
7051
6663
  if (locusmd) {
7052
- sections.push(`# Project Instructions
7053
-
7054
- ${locusmd}`);
6664
+ sections.push(`<project-instructions>
6665
+ ${locusmd}
6666
+ </project-instructions>`);
7055
6667
  }
7056
- const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
6668
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
7057
6669
  if (learnings) {
7058
- sections.push(`# Past Learnings
7059
-
7060
- ${learnings}`);
6670
+ sections.push(`<past-learnings>
6671
+ ${learnings}
6672
+ </past-learnings>`);
7061
6673
  }
7062
6674
  if (previousMessages && previousMessages.length > 0) {
7063
6675
  const recent = previousMessages.slice(-10);
7064
6676
  const historyLines = recent.map((msg) => `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}`);
7065
- sections.push(`# Previous Conversation
7066
-
6677
+ sections.push(`<previous-conversation>
7067
6678
  ${historyLines.join(`
7068
6679
 
7069
- `)}`);
6680
+ `)}
6681
+ </previous-conversation>`);
7070
6682
  }
7071
- sections.push(`# Current Request
7072
-
7073
- ${userMessage}`);
6683
+ sections.push(`<current-request>
6684
+ ${userMessage}
6685
+ </current-request>`);
7074
6686
  return sections.join(`
7075
6687
 
7076
6688
  ---
@@ -7078,63 +6690,66 @@ ${userMessage}`);
7078
6690
  `);
7079
6691
  }
7080
6692
  function buildSystemContext(projectRoot) {
7081
- const parts = ["# System Context"];
7082
- const locusmd = readFileSafe(join13(projectRoot, "LOCUS.md"));
6693
+ const parts = [];
6694
+ const locusmd = readFileSafe(join12(projectRoot, ".locus", "LOCUS.md"));
7083
6695
  if (locusmd) {
7084
- parts.push(`## Project Instructions (LOCUS.md)
7085
-
7086
- ${locusmd}`);
6696
+ parts.push(`<project-instructions>
6697
+ ${locusmd}
6698
+ </project-instructions>`);
7087
6699
  }
7088
- const learnings = readFileSafe(join13(projectRoot, ".locus", "LEARNINGS.md"));
6700
+ const learnings = readFileSafe(join12(projectRoot, ".locus", "LEARNINGS.md"));
7089
6701
  if (learnings) {
7090
- parts.push(`## Past Learnings
7091
-
7092
- ${learnings}`);
6702
+ parts.push(`<past-learnings>
6703
+ ${learnings}
6704
+ </past-learnings>`);
7093
6705
  }
7094
- const discussionsDir = join13(projectRoot, ".locus", "discussions");
7095
- if (existsSync14(discussionsDir)) {
6706
+ const discussionsDir = join12(projectRoot, ".locus", "discussions");
6707
+ if (existsSync13(discussionsDir)) {
7096
6708
  try {
7097
6709
  const files = readdirSync3(discussionsDir).filter((f) => f.endsWith(".md")).slice(0, 3);
7098
6710
  for (const file of files) {
7099
- const content = readFileSafe(join13(discussionsDir, file));
6711
+ const content = readFileSafe(join12(discussionsDir, file));
7100
6712
  if (content) {
7101
- parts.push(`## Discussion: ${file.replace(".md", "")}
7102
-
7103
- ${content.slice(0, 2000)}`);
6713
+ const name = file.replace(".md", "");
6714
+ parts.push(`<discussion name="${name}">
6715
+ ${content.slice(0, 2000)}
6716
+ </discussion>`);
7104
6717
  }
7105
6718
  }
7106
6719
  } catch {}
7107
6720
  }
7108
- return parts.join(`
6721
+ return `<system-context>
6722
+ ${parts.join(`
7109
6723
 
7110
- `);
6724
+ `)}
6725
+ </system-context>`;
7111
6726
  }
7112
6727
  function buildTaskContext(issue, comments) {
7113
- const parts = [
7114
- `# Task Context`,
7115
- ``,
7116
- `## Issue #${issue.number}: ${issue.title}`,
7117
- ``,
7118
- issue.body || "_No description provided._"
7119
- ];
6728
+ const parts = [];
6729
+ const issueParts = [issue.body || "_No description provided._"];
7120
6730
  const labels = issue.labels.filter((l) => l.startsWith("p:") || l.startsWith("type:"));
7121
6731
  if (labels.length > 0) {
7122
- parts.push(`
7123
- **Labels:** ${labels.join(", ")}`);
6732
+ issueParts.push(`**Labels:** ${labels.join(", ")}`);
7124
6733
  }
6734
+ parts.push(`<issue number="${issue.number}" title="${issue.title}">
6735
+ ${issueParts.join(`
6736
+
6737
+ `)}
6738
+ </issue>`);
7125
6739
  if (comments && comments.length > 0) {
7126
- parts.push(`
7127
- ## Issue Comments
7128
- `);
7129
- for (const comment of comments) {
7130
- parts.push(comment);
7131
- }
6740
+ parts.push(`<issue-comments>
6741
+ ${comments.join(`
6742
+ `)}
6743
+ </issue-comments>`);
7132
6744
  }
7133
- return parts.join(`
7134
- `);
6745
+ return `<task-context>
6746
+ ${parts.join(`
6747
+
6748
+ `)}
6749
+ </task-context>`;
7135
6750
  }
7136
6751
  function buildSprintContext(sprintName, position, diffSummary) {
7137
- const parts = ["# Sprint Context"];
6752
+ const parts = [];
7138
6753
  if (sprintName) {
7139
6754
  parts.push(`**Sprint:** ${sprintName}`);
7140
6755
  }
@@ -7142,61 +6757,63 @@ function buildSprintContext(sprintName, position, diffSummary) {
7142
6757
  parts.push(`**Position:** Task ${position}`);
7143
6758
  }
7144
6759
  if (diffSummary) {
7145
- parts.push(`
7146
- ## Changes from Previous Tasks
7147
-
6760
+ parts.push(`<previous-changes>
7148
6761
  The following changes have already been made by earlier tasks in this sprint:
7149
6762
 
7150
6763
  \`\`\`diff
7151
6764
  ${diffSummary}
7152
- \`\`\``);
6765
+ \`\`\`
6766
+ </previous-changes>`);
7153
6767
  }
7154
- parts.push(`
7155
- **Important:** Build upon the changes from previous tasks. Do not revert or undo their work.`);
7156
- return parts.join(`
7157
- `);
6768
+ parts.push(`**Important:** Build upon the changes from previous tasks. Do not revert or undo their work.`);
6769
+ return `<sprint-context>
6770
+ ${parts.join(`
6771
+
6772
+ `)}
6773
+ </sprint-context>`;
7158
6774
  }
7159
6775
  function buildRepoContext(projectRoot) {
7160
- const parts = ["# Repository Context"];
6776
+ const parts = [];
7161
6777
  try {
7162
- const tree = execSync10("find . -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.locus/*' -not -path '*/dist/*' -not -path '*/build/*' | head -80", { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
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();
7163
6779
  if (tree) {
7164
- parts.push(`## File Tree
7165
-
6780
+ parts.push(`<file-tree>
7166
6781
  \`\`\`
7167
6782
  ${tree}
7168
- \`\`\``);
6783
+ \`\`\`
6784
+ </file-tree>`);
7169
6785
  }
7170
6786
  } catch {}
7171
6787
  try {
7172
- const gitLog = execSync10("git log --oneline -10", {
6788
+ const gitLog = execSync7("git log --oneline -10", {
7173
6789
  cwd: projectRoot,
7174
6790
  encoding: "utf-8",
7175
6791
  stdio: ["pipe", "pipe", "pipe"]
7176
6792
  }).trim();
7177
6793
  if (gitLog) {
7178
- parts.push(`## Recent Commits
7179
-
6794
+ parts.push(`<recent-commits>
7180
6795
  \`\`\`
7181
6796
  ${gitLog}
7182
- \`\`\``);
6797
+ \`\`\`
6798
+ </recent-commits>`);
7183
6799
  }
7184
6800
  } catch {}
7185
6801
  try {
7186
- const branch = execSync10("git rev-parse --abbrev-ref HEAD", {
6802
+ const branch = execSync7("git rev-parse --abbrev-ref HEAD", {
7187
6803
  cwd: projectRoot,
7188
6804
  encoding: "utf-8",
7189
6805
  stdio: ["pipe", "pipe", "pipe"]
7190
6806
  }).trim();
7191
6807
  parts.push(`**Current branch:** ${branch}`);
7192
6808
  } catch {}
7193
- return parts.join(`
6809
+ return `<repository-context>
6810
+ ${parts.join(`
7194
6811
 
7195
- `);
6812
+ `)}
6813
+ </repository-context>`;
7196
6814
  }
7197
6815
  function buildExecutionRules(config) {
7198
- return `# Execution Rules
7199
-
6816
+ return `<execution-rules>
7200
6817
  1. **Commit format:** Use conventional commits: \`feat: <title> (#<issue>)\`, \`fix: ...\`, \`chore: ...\`. Every commit message MUST be multi-line: the first line is the title, then a blank line, then \`Co-Authored-By: LocusAgent <agent@locusai.team>\` as a Git trailer. Use \`git commit -m "<title>" -m "Co-Authored-By: LocusAgent <agent@locusai.team>"\` (two separate -m flags) to ensure the trailer is on its own line.
7201
6818
  2. **Code quality:** Follow existing code style. Run linters/formatters if available.
7202
6819
  3. **Testing:** If test files exist for modified code, update them accordingly.
@@ -7208,43 +6825,43 @@ function buildExecutionRules(config) {
7208
6825
  5. **Base branch:** ${config.agent.baseBranch}
7209
6826
  6. **Provider:** ${config.ai.provider} / ${config.ai.model}
7210
6827
 
7211
- When you are done, provide a brief summary of what you changed and why.`;
6828
+ When you are done, provide a brief summary of what you changed and why.
6829
+ </execution-rules>`;
7212
6830
  }
7213
6831
  function buildPRContext(prNumber, diff, comments) {
7214
6832
  const parts = [
7215
- `# Current State — PR #${prNumber}`,
7216
- ``,
7217
- `## PR Diff`,
7218
- ``,
7219
- "```diff",
7220
- diff.slice(0, 1e4),
7221
- "```"
6833
+ `<pr-diff>
6834
+ \`\`\`diff
6835
+ ${diff.slice(0, 1e4)}
6836
+ \`\`\`
6837
+ </pr-diff>`
7222
6838
  ];
7223
6839
  if (comments.length > 0) {
7224
- parts.push(`
7225
- ## Review Comments
7226
- `);
7227
- for (const comment of comments) {
7228
- parts.push(comment);
7229
- }
6840
+ parts.push(`<review-comments>
6841
+ ${comments.join(`
6842
+ `)}
6843
+ </review-comments>`);
7230
6844
  }
7231
- return parts.join(`
7232
- `);
6845
+ return `<pr-context number="${prNumber}">
6846
+ ${parts.join(`
6847
+
6848
+ `)}
6849
+ </pr-context>`;
7233
6850
  }
7234
6851
  function buildFeedbackInstructions() {
7235
- return `# Instructions
7236
-
6852
+ return `<instructions>
7237
6853
  1. Address ALL review feedback from the comments above.
7238
6854
  2. Make targeted changes — do NOT rewrite code from scratch.
7239
6855
  3. If a reviewer comment is unclear, make your best judgment and note your interpretation.
7240
6856
  4. Push changes to the same branch — do NOT create a new PR.
7241
- 5. When done, summarize what you changed in response to each comment.`;
6857
+ 5. When done, summarize what you changed in response to each comment.
6858
+ </instructions>`;
7242
6859
  }
7243
6860
  function readFileSafe(path) {
7244
6861
  try {
7245
- if (!existsSync14(path))
6862
+ if (!existsSync13(path))
7246
6863
  return null;
7247
- return readFileSync10(path, "utf-8");
6864
+ return readFileSync9(path, "utf-8");
7248
6865
  } catch {
7249
6866
  return null;
7250
6867
  }
@@ -7436,7 +7053,7 @@ var init_diff_renderer = __esm(() => {
7436
7053
  });
7437
7054
 
7438
7055
  // src/repl/commands.ts
7439
- import { execSync as execSync11 } from "node:child_process";
7056
+ import { execSync as execSync8 } from "node:child_process";
7440
7057
  function getSlashCommands() {
7441
7058
  return [
7442
7059
  {
@@ -7628,7 +7245,7 @@ function cmdModel(args, ctx) {
7628
7245
  }
7629
7246
  function cmdDiff(_args, ctx) {
7630
7247
  try {
7631
- const diff = execSync11("git diff", {
7248
+ const diff = execSync8("git diff", {
7632
7249
  cwd: ctx.projectRoot,
7633
7250
  encoding: "utf-8",
7634
7251
  stdio: ["pipe", "pipe", "pipe"]
@@ -7664,7 +7281,7 @@ function cmdDiff(_args, ctx) {
7664
7281
  }
7665
7282
  function cmdUndo(_args, ctx) {
7666
7283
  try {
7667
- const status = execSync11("git status --porcelain", {
7284
+ const status = execSync8("git status --porcelain", {
7668
7285
  cwd: ctx.projectRoot,
7669
7286
  encoding: "utf-8",
7670
7287
  stdio: ["pipe", "pipe", "pipe"]
@@ -7674,7 +7291,7 @@ function cmdUndo(_args, ctx) {
7674
7291
  `);
7675
7292
  return;
7676
7293
  }
7677
- execSync11("git checkout .", {
7294
+ execSync8("git checkout .", {
7678
7295
  cwd: ctx.projectRoot,
7679
7296
  encoding: "utf-8",
7680
7297
  stdio: ["pipe", "pipe", "pipe"]
@@ -7708,7 +7325,7 @@ var init_commands = __esm(() => {
7708
7325
 
7709
7326
  // src/repl/completions.ts
7710
7327
  import { readdirSync as readdirSync4 } from "node:fs";
7711
- 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";
7712
7329
 
7713
7330
  class SlashCommandCompletion {
7714
7331
  commands;
@@ -7763,7 +7380,7 @@ class FilePathCompletion {
7763
7380
  }
7764
7381
  findMatches(partial) {
7765
7382
  try {
7766
- const dir = partial.includes("/") ? join14(this.projectRoot, dirname4(partial)) : this.projectRoot;
7383
+ const dir = partial.includes("/") ? join13(this.projectRoot, dirname3(partial)) : this.projectRoot;
7767
7384
  const prefix = basename2(partial);
7768
7385
  const entries = readdirSync4(dir, { withFileTypes: true });
7769
7386
  return entries.filter((e) => {
@@ -7774,7 +7391,7 @@ class FilePathCompletion {
7774
7391
  return e.name.startsWith(prefix);
7775
7392
  }).map((e) => {
7776
7393
  const name = e.isDirectory() ? `${e.name}/` : e.name;
7777
- return partial.includes("/") ? `${dirname4(partial)}/${name}` : name;
7394
+ return partial.includes("/") ? `${dirname3(partial)}/${name}` : name;
7778
7395
  }).slice(0, 20);
7779
7396
  } catch {
7780
7397
  return [];
@@ -7799,14 +7416,14 @@ class CombinedCompletion {
7799
7416
  var init_completions = () => {};
7800
7417
 
7801
7418
  // src/repl/input-history.ts
7802
- import { existsSync as existsSync15, mkdirSync as mkdirSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
7803
- import { dirname as dirname5, join as join15 } from "node:path";
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";
7804
7421
 
7805
7422
  class InputHistory {
7806
7423
  entries = [];
7807
7424
  filePath;
7808
7425
  constructor(projectRoot) {
7809
- this.filePath = join15(projectRoot, ".locus", "sessions", ".input-history");
7426
+ this.filePath = join14(projectRoot, ".locus", "sessions", ".input-history");
7810
7427
  this.load();
7811
7428
  }
7812
7429
  add(text) {
@@ -7845,22 +7462,22 @@ class InputHistory {
7845
7462
  }
7846
7463
  load() {
7847
7464
  try {
7848
- if (!existsSync15(this.filePath))
7465
+ if (!existsSync14(this.filePath))
7849
7466
  return;
7850
- const content = readFileSync11(this.filePath, "utf-8");
7467
+ const content = readFileSync10(this.filePath, "utf-8");
7851
7468
  this.entries = content.split(`
7852
7469
  `).map((line) => this.unescape(line)).filter(Boolean);
7853
7470
  } catch {}
7854
7471
  }
7855
7472
  save() {
7856
7473
  try {
7857
- const dir = dirname5(this.filePath);
7858
- if (!existsSync15(dir)) {
7859
- mkdirSync10(dir, { recursive: true });
7474
+ const dir = dirname4(this.filePath);
7475
+ if (!existsSync14(dir)) {
7476
+ mkdirSync9(dir, { recursive: true });
7860
7477
  }
7861
7478
  const content = this.entries.map((e) => this.escape(e)).join(`
7862
7479
  `);
7863
- writeFileSync7(this.filePath, content, "utf-8");
7480
+ writeFileSync6(this.filePath, content, "utf-8");
7864
7481
  } catch {}
7865
7482
  }
7866
7483
  escape(text) {
@@ -7886,21 +7503,21 @@ var init_model_config = __esm(() => {
7886
7503
 
7887
7504
  // src/repl/session-manager.ts
7888
7505
  import {
7889
- existsSync as existsSync16,
7890
- mkdirSync as mkdirSync11,
7506
+ existsSync as existsSync15,
7507
+ mkdirSync as mkdirSync10,
7891
7508
  readdirSync as readdirSync5,
7892
- readFileSync as readFileSync12,
7893
- unlinkSync as unlinkSync4,
7894
- writeFileSync as writeFileSync8
7509
+ readFileSync as readFileSync11,
7510
+ unlinkSync as unlinkSync3,
7511
+ writeFileSync as writeFileSync7
7895
7512
  } from "node:fs";
7896
- import { basename as basename3, join as join16 } from "node:path";
7513
+ import { basename as basename3, join as join15 } from "node:path";
7897
7514
 
7898
7515
  class SessionManager {
7899
7516
  sessionsDir;
7900
7517
  constructor(projectRoot) {
7901
- this.sessionsDir = join16(projectRoot, ".locus", "sessions");
7902
- if (!existsSync16(this.sessionsDir)) {
7903
- mkdirSync11(this.sessionsDir, { recursive: true });
7518
+ this.sessionsDir = join15(projectRoot, ".locus", "sessions");
7519
+ if (!existsSync15(this.sessionsDir)) {
7520
+ mkdirSync10(this.sessionsDir, { recursive: true });
7904
7521
  }
7905
7522
  }
7906
7523
  create(options) {
@@ -7925,14 +7542,14 @@ class SessionManager {
7925
7542
  }
7926
7543
  isPersisted(sessionOrId) {
7927
7544
  const sessionId = typeof sessionOrId === "string" ? sessionOrId : sessionOrId.id;
7928
- return existsSync16(this.getSessionPath(sessionId));
7545
+ return existsSync15(this.getSessionPath(sessionId));
7929
7546
  }
7930
7547
  load(idOrPrefix) {
7931
7548
  const files = this.listSessionFiles();
7932
7549
  const exactPath = this.getSessionPath(idOrPrefix);
7933
- if (existsSync16(exactPath)) {
7550
+ if (existsSync15(exactPath)) {
7934
7551
  try {
7935
- return JSON.parse(readFileSync12(exactPath, "utf-8"));
7552
+ return JSON.parse(readFileSync11(exactPath, "utf-8"));
7936
7553
  } catch {
7937
7554
  return null;
7938
7555
  }
@@ -7940,7 +7557,7 @@ class SessionManager {
7940
7557
  const matches = files.filter((f) => basename3(f, ".json").startsWith(idOrPrefix));
7941
7558
  if (matches.length === 1) {
7942
7559
  try {
7943
- return JSON.parse(readFileSync12(matches[0], "utf-8"));
7560
+ return JSON.parse(readFileSync11(matches[0], "utf-8"));
7944
7561
  } catch {
7945
7562
  return null;
7946
7563
  }
@@ -7953,7 +7570,7 @@ class SessionManager {
7953
7570
  save(session) {
7954
7571
  session.updated = new Date().toISOString();
7955
7572
  const path = this.getSessionPath(session.id);
7956
- writeFileSync8(path, `${JSON.stringify(session, null, 2)}
7573
+ writeFileSync7(path, `${JSON.stringify(session, null, 2)}
7957
7574
  `, "utf-8");
7958
7575
  }
7959
7576
  addMessage(session, message) {
@@ -7965,7 +7582,7 @@ class SessionManager {
7965
7582
  const sessions = [];
7966
7583
  for (const file of files) {
7967
7584
  try {
7968
- const session = JSON.parse(readFileSync12(file, "utf-8"));
7585
+ const session = JSON.parse(readFileSync11(file, "utf-8"));
7969
7586
  sessions.push({
7970
7587
  id: session.id,
7971
7588
  created: session.created,
@@ -7980,8 +7597,8 @@ class SessionManager {
7980
7597
  }
7981
7598
  delete(sessionId) {
7982
7599
  const path = this.getSessionPath(sessionId);
7983
- if (existsSync16(path)) {
7984
- unlinkSync4(path);
7600
+ if (existsSync15(path)) {
7601
+ unlinkSync3(path);
7985
7602
  return true;
7986
7603
  }
7987
7604
  return false;
@@ -7992,7 +7609,7 @@ class SessionManager {
7992
7609
  let pruned = 0;
7993
7610
  const withStats = files.map((f) => {
7994
7611
  try {
7995
- const session = JSON.parse(readFileSync12(f, "utf-8"));
7612
+ const session = JSON.parse(readFileSync11(f, "utf-8"));
7996
7613
  return { path: f, updated: new Date(session.updated).getTime() };
7997
7614
  } catch {
7998
7615
  return { path: f, updated: 0 };
@@ -8002,7 +7619,7 @@ class SessionManager {
8002
7619
  for (const entry of withStats) {
8003
7620
  if (now - entry.updated > SESSION_MAX_AGE_MS) {
8004
7621
  try {
8005
- unlinkSync4(entry.path);
7622
+ unlinkSync3(entry.path);
8006
7623
  pruned++;
8007
7624
  } catch {}
8008
7625
  }
@@ -8010,10 +7627,10 @@ class SessionManager {
8010
7627
  const remaining = withStats.length - pruned;
8011
7628
  if (remaining > MAX_SESSIONS) {
8012
7629
  const toRemove = remaining - MAX_SESSIONS;
8013
- const alive = withStats.filter((e) => existsSync16(e.path));
7630
+ const alive = withStats.filter((e) => existsSync15(e.path));
8014
7631
  for (let i = 0;i < toRemove && i < alive.length; i++) {
8015
7632
  try {
8016
- unlinkSync4(alive[i].path);
7633
+ unlinkSync3(alive[i].path);
8017
7634
  pruned++;
8018
7635
  } catch {}
8019
7636
  }
@@ -8025,7 +7642,7 @@ class SessionManager {
8025
7642
  }
8026
7643
  listSessionFiles() {
8027
7644
  try {
8028
- 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));
8029
7646
  } catch {
8030
7647
  return [];
8031
7648
  }
@@ -8034,7 +7651,7 @@ class SessionManager {
8034
7651
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8035
7652
  }
8036
7653
  getSessionPath(sessionId) {
8037
- return join16(this.sessionsDir, `${sessionId}.json`);
7654
+ return join15(this.sessionsDir, `${sessionId}.json`);
8038
7655
  }
8039
7656
  }
8040
7657
  var MAX_SESSIONS = 50, SESSION_MAX_AGE_MS;
@@ -8044,7 +7661,7 @@ var init_session_manager = __esm(() => {
8044
7661
  });
8045
7662
 
8046
7663
  // src/repl/repl.ts
8047
- import { execSync as execSync12 } from "node:child_process";
7664
+ import { execSync as execSync9 } from "node:child_process";
8048
7665
  async function startRepl(options) {
8049
7666
  const { projectRoot, config } = options;
8050
7667
  const sessionManager = new SessionManager(projectRoot);
@@ -8062,7 +7679,7 @@ async function startRepl(options) {
8062
7679
  } else {
8063
7680
  let branch = "main";
8064
7681
  try {
8065
- branch = execSync12("git rev-parse --abbrev-ref HEAD", {
7682
+ branch = execSync9("git rev-parse --abbrev-ref HEAD", {
8066
7683
  cwd: projectRoot,
8067
7684
  encoding: "utf-8",
8068
7685
  stdio: ["pipe", "pipe", "pipe"]
@@ -8105,16 +7722,17 @@ async function executeOneShotPrompt(prompt, session, sessionManager, options) {
8105
7722
  async function runInteractiveRepl(session, sessionManager, options) {
8106
7723
  const { projectRoot, config } = options;
8107
7724
  let sandboxRunner = null;
8108
- if (config.sandbox.enabled && config.sandbox.name) {
7725
+ if (config.sandbox.enabled) {
8109
7726
  const provider = inferProviderFromModel(config.ai.model) || config.ai.provider;
8110
- sandboxRunner = createUserManagedSandboxRunner(provider, config.sandbox.name);
8111
- process.stderr.write(`${dim("Using sandbox")} ${dim(config.sandbox.name)}
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)}
8112
7731
  `);
8113
- } else if (config.sandbox.enabled) {
8114
- const sandboxName = buildPersistentSandboxName(projectRoot);
8115
- sandboxRunner = new SandboxedClaudeRunner(sandboxName);
8116
- process.stderr.write(`${dim("Sandbox mode: prompts will share sandbox")} ${dim(sandboxName)}
7732
+ } else {
7733
+ process.stderr.write(`${yellow("⚠")} ${dim(`No sandbox configured for ${provider}. Run locus sandbox.`)}
8117
7734
  `);
7735
+ }
8118
7736
  }
8119
7737
  const history = new InputHistory(projectRoot);
8120
7738
  const completion = new CombinedCompletion([
@@ -8148,10 +7766,17 @@ async function runInteractiveRepl(session, sessionManager, options) {
8148
7766
  const providerChanged = inferredProvider !== currentProvider;
8149
7767
  currentProvider = inferredProvider;
8150
7768
  session.metadata.provider = inferredProvider;
8151
- if (providerChanged && config.sandbox.enabled && config.sandbox.name) {
8152
- sandboxRunner = createUserManagedSandboxRunner(inferredProvider, config.sandbox.name);
8153
- process.stderr.write(`${dim("Switched sandbox agent to")} ${dim(inferredProvider)}
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.`)}
8154
7778
  `);
7779
+ }
8155
7780
  }
8156
7781
  }
8157
7782
  persistReplModelSelection(projectRoot, config, model);
@@ -8223,10 +7848,6 @@ ${red("✗")} ${msg}
8223
7848
  break;
8224
7849
  }
8225
7850
  }
8226
- if (sandboxRunner && "destroy" in sandboxRunner) {
8227
- const runner = sandboxRunner;
8228
- runner.destroy();
8229
- }
8230
7851
  const shouldPersistOnExit = session.messages.length > 0 || sessionManager.isPersisted(session);
8231
7852
  if (shouldPersistOnExit) {
8232
7853
  sessionManager.save(session);
@@ -8244,6 +7865,7 @@ ${red("✗")} ${msg}
8244
7865
  }
8245
7866
  async function executeAITurn(prompt, session, options, verbose = false, runner) {
8246
7867
  const { config, projectRoot } = options;
7868
+ const sandboxName = getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider);
8247
7869
  const aiResult = await runAI({
8248
7870
  prompt,
8249
7871
  provider: config.ai.provider,
@@ -8251,7 +7873,7 @@ async function executeAITurn(prompt, session, options, verbose = false, runner)
8251
7873
  cwd: projectRoot,
8252
7874
  verbose,
8253
7875
  sandboxed: config.sandbox.enabled,
8254
- sandboxName: config.sandbox.name,
7876
+ sandboxName,
8255
7877
  runner
8256
7878
  });
8257
7879
  if (aiResult.interrupted) {
@@ -8282,11 +7904,11 @@ function printWelcome(session) {
8282
7904
  `);
8283
7905
  }
8284
7906
  var init_repl = __esm(() => {
8285
- init_claude_sandbox();
8286
7907
  init_run_ai();
8287
7908
  init_runner();
8288
7909
  init_ai_models();
8289
7910
  init_prompt_builder();
7911
+ init_sandbox();
8290
7912
  init_terminal();
8291
7913
  init_commands();
8292
7914
  init_completions();
@@ -8427,7 +8049,12 @@ async function handleJsonStream(projectRoot, config, args, sessionId) {
8427
8049
  stream.emitStatus("thinking");
8428
8050
  try {
8429
8051
  const fullPrompt = buildReplPrompt(prompt, projectRoot, config);
8430
- const runner = config.sandbox.name ? createUserManagedSandboxRunner(config.ai.provider, config.sandbox.name) : await createRunnerAsync(config.ai.provider, config.sandbox.enabled);
8052
+ const sandboxName = getProviderSandboxName(config.sandbox, config.ai.provider);
8053
+ const runner = config.sandbox.enabled ? sandboxName ? createUserManagedSandboxRunner(config.ai.provider, sandboxName) : null : await createRunnerAsync(config.ai.provider, false);
8054
+ if (!runner) {
8055
+ stream.emitError(`Sandbox for provider "${config.ai.provider}" is not configured. Run locus sandbox.`, false);
8056
+ return;
8057
+ }
8431
8058
  const available = await runner.isAvailable();
8432
8059
  if (!available) {
8433
8060
  stream.emitError(`${config.ai.provider} CLI not available`, false);
@@ -8469,13 +8096,14 @@ var init_exec = __esm(() => {
8469
8096
  init_config();
8470
8097
  init_logger();
8471
8098
  init_prompt_builder();
8099
+ init_sandbox();
8472
8100
  init_terminal();
8473
8101
  init_repl();
8474
8102
  init_session_manager();
8475
8103
  });
8476
8104
 
8477
8105
  // src/core/agent.ts
8478
- import { execSync as execSync13 } from "node:child_process";
8106
+ import { execSync as execSync10 } from "node:child_process";
8479
8107
  async function executeIssue(projectRoot, options) {
8480
8108
  const log = getLogger();
8481
8109
  const timer = createTimer();
@@ -8504,7 +8132,7 @@ ${cyan("●")} ${bold(`#${issueNumber}`)} ${issue.title}
8504
8132
  }
8505
8133
  let issueComments = [];
8506
8134
  try {
8507
- const commentsRaw = execSync13(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8135
+ const commentsRaw = execSync10(`gh issue view ${issueNumber} --json comments --jq '.comments[].body'`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
8508
8136
  if (commentsRaw) {
8509
8137
  issueComments = commentsRaw.split(`
8510
8138
  `).filter(Boolean);
@@ -8647,7 +8275,7 @@ ${c.body}`),
8647
8275
  cwd: projectRoot,
8648
8276
  activity: `iterating on PR #${prNumber}`,
8649
8277
  sandboxed: config.sandbox.enabled,
8650
- sandboxName: config.sandbox.name
8278
+ sandboxName: getModelSandboxName(config.sandbox, config.ai.model, config.ai.provider)
8651
8279
  });
8652
8280
  if (aiResult.interrupted) {
8653
8281
  process.stderr.write(`
@@ -8668,12 +8296,12 @@ ${aiResult.success ? green("✓") : red("✗")} Iteration ${aiResult.success ? "
8668
8296
  }
8669
8297
  async function createIssuePR(projectRoot, config, issue) {
8670
8298
  try {
8671
- const currentBranch = execSync13("git rev-parse --abbrev-ref HEAD", {
8299
+ const currentBranch = execSync10("git rev-parse --abbrev-ref HEAD", {
8672
8300
  cwd: projectRoot,
8673
8301
  encoding: "utf-8",
8674
8302
  stdio: ["pipe", "pipe", "pipe"]
8675
8303
  }).trim();
8676
- const diff = execSync13(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8304
+ const diff = execSync10(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
8677
8305
  cwd: projectRoot,
8678
8306
  encoding: "utf-8",
8679
8307
  stdio: ["pipe", "pipe", "pipe"]
@@ -8682,7 +8310,7 @@ async function createIssuePR(projectRoot, config, issue) {
8682
8310
  getLogger().verbose("No changes to create PR for");
8683
8311
  return;
8684
8312
  }
8685
- execSync13(`git push -u origin ${currentBranch}`, {
8313
+ execSync10(`git push -u origin ${currentBranch}`, {
8686
8314
  cwd: projectRoot,
8687
8315
  encoding: "utf-8",
8688
8316
  stdio: ["pipe", "pipe", "pipe"]
@@ -8725,12 +8353,13 @@ var init_agent = __esm(() => {
8725
8353
  init_github();
8726
8354
  init_logger();
8727
8355
  init_prompt_builder();
8356
+ init_sandbox();
8728
8357
  });
8729
8358
 
8730
8359
  // src/core/conflict.ts
8731
- import { execSync as execSync14 } from "node:child_process";
8360
+ import { execSync as execSync11 } from "node:child_process";
8732
8361
  function git2(args, cwd) {
8733
- return execSync14(`git ${args}`, {
8362
+ return execSync11(`git ${args}`, {
8734
8363
  cwd,
8735
8364
  encoding: "utf-8",
8736
8365
  stdio: ["pipe", "pipe", "pipe"]
@@ -8791,76 +8420,260 @@ function checkForConflicts(cwd, baseBranch) {
8791
8420
  `).filter(Boolean) ?? [];
8792
8421
  const overlapping = ourChanges.filter((f) => theirChanges.includes(f));
8793
8422
  return {
8794
- hasConflict: overlapping.length > 0,
8795
- conflictingFiles: overlapping,
8796
- baseAdvanced: true,
8797
- newCommits
8423
+ hasConflict: overlapping.length > 0,
8424
+ conflictingFiles: overlapping,
8425
+ baseAdvanced: true,
8426
+ newCommits
8427
+ };
8428
+ }
8429
+ function attemptRebase(cwd, baseBranch) {
8430
+ const log = getLogger();
8431
+ try {
8432
+ git2(`rebase origin/${baseBranch}`, cwd);
8433
+ log.info(`Successfully rebased onto origin/${baseBranch}`);
8434
+ return { success: true };
8435
+ } catch (_e) {
8436
+ log.warn("Rebase failed, aborting");
8437
+ const conflicts = [];
8438
+ try {
8439
+ const status = git2("diff --name-only --diff-filter=U", cwd);
8440
+ conflicts.push(...status.trim().split(`
8441
+ `).filter(Boolean));
8442
+ } catch {}
8443
+ gitSafe("rebase --abort", cwd);
8444
+ return { success: false, conflicts };
8445
+ }
8446
+ }
8447
+ function printConflictReport(result, baseBranch) {
8448
+ if (!result.baseAdvanced)
8449
+ return;
8450
+ if (result.hasConflict) {
8451
+ process.stderr.write(`
8452
+ ${bold(red("✗"))} ${bold("Merge conflict detected")}
8453
+
8454
+ `);
8455
+ process.stderr.write(` Base branch ${cyan(`origin/${baseBranch}`)} has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"}
8456
+ `);
8457
+ process.stderr.write(` The following files were modified in both branches:
8458
+
8459
+ `);
8460
+ for (const file of result.conflictingFiles) {
8461
+ process.stderr.write(` ${red("•")} ${file}
8462
+ `);
8463
+ }
8464
+ process.stderr.write(`
8465
+ ${bold("To resolve:")}
8466
+ `);
8467
+ process.stderr.write(` 1. ${dim(`git rebase origin/${baseBranch}`)}
8468
+ `);
8469
+ process.stderr.write(` 2. Resolve conflicts in the listed files
8470
+ `);
8471
+ process.stderr.write(` 3. ${dim("git rebase --continue")}
8472
+ `);
8473
+ process.stderr.write(` 4. ${dim("locus run --resume")} to continue the sprint
8474
+
8475
+ `);
8476
+ } else if (result.newCommits > 0) {
8477
+ process.stderr.write(`
8478
+ ${bold(yellow("⚠"))} Base branch has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"} — auto-rebasing...
8479
+ `);
8480
+ }
8481
+ }
8482
+ var init_conflict = __esm(() => {
8483
+ init_terminal();
8484
+ init_logger();
8485
+ });
8486
+
8487
+ // src/core/run-state.ts
8488
+ import {
8489
+ existsSync as existsSync16,
8490
+ mkdirSync as mkdirSync11,
8491
+ readFileSync as readFileSync12,
8492
+ unlinkSync as unlinkSync4,
8493
+ writeFileSync as writeFileSync8
8494
+ } from "node:fs";
8495
+ import { dirname as dirname5, join as join16 } from "node:path";
8496
+ function getRunStatePath(projectRoot) {
8497
+ return join16(projectRoot, ".locus", "run-state.json");
8498
+ }
8499
+ function loadRunState(projectRoot) {
8500
+ const path = getRunStatePath(projectRoot);
8501
+ if (!existsSync16(path))
8502
+ return null;
8503
+ try {
8504
+ return JSON.parse(readFileSync12(path, "utf-8"));
8505
+ } catch {
8506
+ getLogger().warn("Corrupted run-state.json, ignoring");
8507
+ return null;
8508
+ }
8509
+ }
8510
+ function saveRunState(projectRoot, state) {
8511
+ const path = getRunStatePath(projectRoot);
8512
+ const dir = dirname5(path);
8513
+ if (!existsSync16(dir)) {
8514
+ mkdirSync11(dir, { recursive: true });
8515
+ }
8516
+ writeFileSync8(path, `${JSON.stringify(state, null, 2)}
8517
+ `, "utf-8");
8518
+ }
8519
+ function clearRunState(projectRoot) {
8520
+ const path = getRunStatePath(projectRoot);
8521
+ if (existsSync16(path)) {
8522
+ unlinkSync4(path);
8523
+ }
8524
+ }
8525
+ function createSprintRunState(sprint, branch, issues) {
8526
+ return {
8527
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
8528
+ type: "sprint",
8529
+ sprint,
8530
+ branch,
8531
+ startedAt: new Date().toISOString(),
8532
+ tasks: issues.map(({ number, order }) => ({
8533
+ issue: number,
8534
+ order,
8535
+ status: "pending"
8536
+ }))
8537
+ };
8538
+ }
8539
+ function createParallelRunState(issueNumbers) {
8540
+ return {
8541
+ runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`,
8542
+ type: "parallel",
8543
+ startedAt: new Date().toISOString(),
8544
+ tasks: issueNumbers.map((issue, i) => ({
8545
+ issue,
8546
+ order: i + 1,
8547
+ status: "pending"
8548
+ }))
8549
+ };
8550
+ }
8551
+ function markTaskInProgress(state, issueNumber) {
8552
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8553
+ if (task) {
8554
+ task.status = "in_progress";
8555
+ }
8556
+ }
8557
+ function markTaskDone(state, issueNumber, prNumber) {
8558
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8559
+ if (task) {
8560
+ task.status = "done";
8561
+ task.completedAt = new Date().toISOString();
8562
+ if (prNumber)
8563
+ task.pr = prNumber;
8564
+ }
8565
+ }
8566
+ function markTaskFailed(state, issueNumber, error) {
8567
+ const task = state.tasks.find((t) => t.issue === issueNumber);
8568
+ if (task) {
8569
+ task.status = "failed";
8570
+ task.failedAt = new Date().toISOString();
8571
+ task.error = error;
8572
+ }
8573
+ }
8574
+ function getRunStats(state) {
8575
+ const tasks = state.tasks;
8576
+ return {
8577
+ total: tasks.length,
8578
+ done: tasks.filter((t) => t.status === "done").length,
8579
+ failed: tasks.filter((t) => t.status === "failed").length,
8580
+ pending: tasks.filter((t) => t.status === "pending").length,
8581
+ inProgress: tasks.filter((t) => t.status === "in_progress").length
8798
8582
  };
8799
8583
  }
8800
- function attemptRebase(cwd, baseBranch) {
8801
- const log = getLogger();
8802
- try {
8803
- git2(`rebase origin/${baseBranch}`, cwd);
8804
- log.info(`Successfully rebased onto origin/${baseBranch}`);
8805
- return { success: true };
8806
- } catch (_e) {
8807
- log.warn("Rebase failed, aborting");
8808
- const conflicts = [];
8584
+ function getNextTask(state) {
8585
+ const failed = state.tasks.find((t) => t.status === "failed");
8586
+ if (failed)
8587
+ return failed;
8588
+ return state.tasks.find((t) => t.status === "pending") ?? null;
8589
+ }
8590
+ var init_run_state = __esm(() => {
8591
+ init_logger();
8592
+ });
8593
+
8594
+ // src/core/shutdown.ts
8595
+ import { execSync as execSync12 } from "node:child_process";
8596
+ function cleanupActiveSandboxes() {
8597
+ for (const name of activeSandboxes) {
8809
8598
  try {
8810
- const status = git2("diff --name-only --diff-filter=U", cwd);
8811
- conflicts.push(...status.trim().split(`
8812
- `).filter(Boolean));
8599
+ execSync12(`docker sandbox rm ${name}`, { timeout: 1e4 });
8813
8600
  } catch {}
8814
- gitSafe("rebase --abort", cwd);
8815
- return { success: false, conflicts };
8816
8601
  }
8602
+ activeSandboxes.clear();
8817
8603
  }
8818
- function printConflictReport(result, baseBranch) {
8819
- if (!result.baseAdvanced)
8820
- return;
8821
- if (result.hasConflict) {
8822
- process.stderr.write(`
8823
- ${bold(red("✗"))} ${bold("Merge conflict detected")}
8824
-
8825
- `);
8826
- process.stderr.write(` Base branch ${cyan(`origin/${baseBranch}`)} has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"}
8827
- `);
8828
- process.stderr.write(` The following files were modified in both branches:
8829
-
8830
- `);
8831
- for (const file of result.conflictingFiles) {
8832
- process.stderr.write(` ${red("•")} ${file}
8604
+ function registerShutdownHandlers(ctx) {
8605
+ shutdownContext = ctx;
8606
+ interruptCount = 0;
8607
+ const handler = () => {
8608
+ interruptCount++;
8609
+ if (interruptCount >= 2) {
8610
+ process.stderr.write(`
8611
+ Force exit.
8833
8612
  `);
8613
+ process.exit(1);
8834
8614
  }
8835
8615
  process.stderr.write(`
8836
- ${bold("To resolve:")}
8837
- `);
8838
- process.stderr.write(` 1. ${dim(`git rebase origin/${baseBranch}`)}
8839
- `);
8840
- process.stderr.write(` 2. Resolve conflicts in the listed files
8841
- `);
8842
- process.stderr.write(` 3. ${dim("git rebase --continue")}
8843
- `);
8844
- process.stderr.write(` 4. ${dim("locus run --resume")} to continue the sprint
8845
8616
 
8617
+ Interrupted. Saving state...
8846
8618
  `);
8847
- } else if (result.newCommits > 0) {
8848
- process.stderr.write(`
8849
- ${bold(yellow("⚠"))} Base branch has ${result.newCommits} new commit${result.newCommits === 1 ? "" : "s"} — auto-rebasing...
8619
+ const state = shutdownContext?.getRunState?.();
8620
+ if (state && shutdownContext) {
8621
+ for (const task of state.tasks) {
8622
+ if (task.status === "in_progress") {
8623
+ task.status = "failed";
8624
+ task.failedAt = new Date().toISOString();
8625
+ task.error = "Interrupted by user";
8626
+ }
8627
+ }
8628
+ try {
8629
+ saveRunState(shutdownContext.projectRoot, state);
8630
+ process.stderr.write(`State saved. Resume with: locus run --resume
8850
8631
  `);
8632
+ } catch {
8633
+ process.stderr.write(`Warning: Could not save run state.
8634
+ `);
8635
+ }
8636
+ }
8637
+ cleanupActiveSandboxes();
8638
+ shutdownContext?.onShutdown?.();
8639
+ if (interruptTimer)
8640
+ clearTimeout(interruptTimer);
8641
+ interruptTimer = setTimeout(() => {
8642
+ interruptCount = 0;
8643
+ }, 2000);
8644
+ setTimeout(() => {
8645
+ process.exit(130);
8646
+ }, 100);
8647
+ };
8648
+ if (!shutdownRegistered) {
8649
+ process.on("SIGINT", handler);
8650
+ process.on("SIGTERM", handler);
8651
+ shutdownRegistered = true;
8851
8652
  }
8653
+ return () => {
8654
+ process.removeListener("SIGINT", handler);
8655
+ process.removeListener("SIGTERM", handler);
8656
+ shutdownRegistered = false;
8657
+ shutdownContext = null;
8658
+ interruptCount = 0;
8659
+ if (interruptTimer) {
8660
+ clearTimeout(interruptTimer);
8661
+ interruptTimer = null;
8662
+ }
8663
+ };
8852
8664
  }
8853
- var init_conflict = __esm(() => {
8854
- init_terminal();
8855
- init_logger();
8665
+ var shutdownRegistered = false, shutdownContext = null, interruptCount = 0, interruptTimer = null, activeSandboxes;
8666
+ var init_shutdown = __esm(() => {
8667
+ init_run_state();
8668
+ activeSandboxes = new Set;
8856
8669
  });
8857
8670
 
8858
8671
  // src/core/worktree.ts
8859
- import { execSync as execSync15 } from "node:child_process";
8672
+ import { execSync as execSync13 } from "node:child_process";
8860
8673
  import { existsSync as existsSync17, readdirSync as readdirSync6, realpathSync, statSync as statSync3 } from "node:fs";
8861
8674
  import { join as join17 } from "node:path";
8862
8675
  function git3(args, cwd) {
8863
- return execSync15(`git ${args}`, {
8676
+ return execSync13(`git ${args}`, {
8864
8677
  cwd,
8865
8678
  encoding: "utf-8",
8866
8679
  stdio: ["pipe", "pipe", "pipe"]
@@ -8885,7 +8698,7 @@ function generateBranchName(issueNumber) {
8885
8698
  }
8886
8699
  function getWorktreeBranch(worktreePath) {
8887
8700
  try {
8888
- return execSync15("git branch --show-current", {
8701
+ return execSync13("git branch --show-current", {
8889
8702
  cwd: worktreePath,
8890
8703
  encoding: "utf-8",
8891
8704
  stdio: ["pipe", "pipe", "pipe"]
@@ -9010,7 +8823,13 @@ var exports_run = {};
9010
8823
  __export(exports_run, {
9011
8824
  runCommand: () => runCommand
9012
8825
  });
9013
- import { execSync as execSync16 } from "node:child_process";
8826
+ import { execSync as execSync14 } from "node:child_process";
8827
+ function resolveExecutionContext(config, modelOverride) {
8828
+ const model = modelOverride ?? config.ai.model;
8829
+ const provider = inferProviderFromModel(model) ?? config.ai.provider;
8830
+ const sandboxName = getModelSandboxName(config.sandbox, model, provider);
8831
+ return { provider, model, sandboxName };
8832
+ }
9014
8833
  function printRunHelp() {
9015
8834
  process.stderr.write(`
9016
8835
  ${bold("locus run")} — Execute issues using AI agents
@@ -9093,6 +8912,7 @@ async function runCommand(projectRoot, args, flags = {}) {
9093
8912
  }
9094
8913
  async function handleSprintRun(projectRoot, config, flags, sandboxed) {
9095
8914
  const log = getLogger();
8915
+ const execution = resolveExecutionContext(config, flags.model);
9096
8916
  if (!config.sprint.active) {
9097
8917
  process.stderr.write(`${red("✗")} No active sprint. Set one with: ${bold("locus sprint active <name>")}
9098
8918
  `);
@@ -9154,7 +8974,7 @@ ${yellow("⚠")} A sprint run is already in progress.
9154
8974
  }
9155
8975
  if (!flags.dryRun) {
9156
8976
  try {
9157
- execSync16(`git checkout -B ${branchName}`, {
8977
+ execSync14(`git checkout -B ${branchName}`, {
9158
8978
  cwd: projectRoot,
9159
8979
  encoding: "utf-8",
9160
8980
  stdio: ["pipe", "pipe", "pipe"]
@@ -9204,7 +9024,7 @@ ${red("✗")} Auto-rebase failed. Resolve manually.
9204
9024
  let sprintContext;
9205
9025
  if (i > 0 && !flags.dryRun) {
9206
9026
  try {
9207
- sprintContext = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9027
+ sprintContext = execSync14(`git diff origin/${config.agent.baseBranch}..HEAD`, {
9208
9028
  cwd: projectRoot,
9209
9029
  encoding: "utf-8",
9210
9030
  stdio: ["pipe", "pipe", "pipe"]
@@ -9219,12 +9039,13 @@ ${progressBar(i, state.tasks.length, { label: "Sprint Progress" })}
9219
9039
  saveRunState(projectRoot, state);
9220
9040
  const result = await executeIssue(projectRoot, {
9221
9041
  issueNumber: task.issue,
9222
- provider: config.ai.provider,
9223
- model: flags.model ?? config.ai.model,
9042
+ provider: execution.provider,
9043
+ model: execution.model,
9224
9044
  dryRun: flags.dryRun,
9225
9045
  sprintContext,
9226
9046
  skipPR: true,
9227
- sandboxed
9047
+ sandboxed,
9048
+ sandboxName: execution.sandboxName
9228
9049
  });
9229
9050
  if (result.success) {
9230
9051
  if (!flags.dryRun) {
@@ -9268,7 +9089,7 @@ ${bold("Summary:")}
9268
9089
  const prNumber = await createSprintPR(projectRoot, config, sprintName, branchName, completedTasks);
9269
9090
  if (prNumber !== undefined) {
9270
9091
  try {
9271
- execSync16(`git checkout ${config.agent.baseBranch}`, {
9092
+ execSync14(`git checkout ${config.agent.baseBranch}`, {
9272
9093
  cwd: projectRoot,
9273
9094
  encoding: "utf-8",
9274
9095
  stdio: ["pipe", "pipe", "pipe"]
@@ -9283,6 +9104,7 @@ ${bold("Summary:")}
9283
9104
  }
9284
9105
  }
9285
9106
  async function handleSingleIssue(projectRoot, config, issueNumber, flags, sandboxed) {
9107
+ const execution = resolveExecutionContext(config, flags.model);
9286
9108
  let isSprintIssue = false;
9287
9109
  try {
9288
9110
  const issue = getIssue(issueNumber, { cwd: projectRoot });
@@ -9295,11 +9117,11 @@ ${bold("Running sprint issue")} ${cyan(`#${issueNumber}`)} ${dim("(sequential, n
9295
9117
  `);
9296
9118
  await executeIssue(projectRoot, {
9297
9119
  issueNumber,
9298
- provider: config.ai.provider,
9299
- model: flags.model ?? config.ai.model,
9120
+ provider: execution.provider,
9121
+ model: execution.model,
9300
9122
  dryRun: flags.dryRun,
9301
9123
  sandboxed,
9302
- sandboxName: config.sandbox.name
9124
+ sandboxName: execution.sandboxName
9303
9125
  });
9304
9126
  return;
9305
9127
  }
@@ -9326,11 +9148,11 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
9326
9148
  const result = await executeIssue(projectRoot, {
9327
9149
  issueNumber,
9328
9150
  worktreePath,
9329
- provider: config.ai.provider,
9330
- model: flags.model ?? config.ai.model,
9151
+ provider: execution.provider,
9152
+ model: execution.model,
9331
9153
  dryRun: flags.dryRun,
9332
9154
  sandboxed,
9333
- sandboxName: config.sandbox.name
9155
+ sandboxName: execution.sandboxName
9334
9156
  });
9335
9157
  if (worktreePath && !flags.dryRun) {
9336
9158
  if (result.success) {
@@ -9345,6 +9167,7 @@ ${bold("Running issue")} ${cyan(`#${issueNumber}`)} ${dim("(worktree)")}
9345
9167
  }
9346
9168
  async function handleParallelRun(projectRoot, config, issueNumbers, flags, sandboxed) {
9347
9169
  const log = getLogger();
9170
+ const execution = resolveExecutionContext(config, flags.model);
9348
9171
  const maxConcurrent = config.agent.maxParallel;
9349
9172
  process.stderr.write(`
9350
9173
  ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxConcurrent} parallel, worktrees)`)}
@@ -9396,11 +9219,11 @@ ${bold("Running")} ${cyan(`${issueNumbers.length} issues`)} ${dim(`(max ${maxCon
9396
9219
  const result = await executeIssue(projectRoot, {
9397
9220
  issueNumber,
9398
9221
  worktreePath,
9399
- provider: config.ai.provider,
9400
- model: flags.model ?? config.ai.model,
9222
+ provider: execution.provider,
9223
+ model: execution.model,
9401
9224
  dryRun: flags.dryRun,
9402
9225
  sandboxed,
9403
- sandboxName: config.sandbox.name
9226
+ sandboxName: execution.sandboxName
9404
9227
  });
9405
9228
  if (result.success) {
9406
9229
  markTaskDone(state, issueNumber, result.prNumber);
@@ -9450,6 +9273,7 @@ ${yellow("⚠")} Failed worktrees preserved for debugging:
9450
9273
  }
9451
9274
  }
9452
9275
  async function handleResume(projectRoot, config, sandboxed) {
9276
+ const execution = resolveExecutionContext(config);
9453
9277
  const state = loadRunState(projectRoot);
9454
9278
  if (!state) {
9455
9279
  process.stderr.write(`${red("✗")} No run state found. Nothing to resume.
@@ -9465,13 +9289,13 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
9465
9289
  `);
9466
9290
  if (state.type === "sprint" && state.branch) {
9467
9291
  try {
9468
- const currentBranch = execSync16("git rev-parse --abbrev-ref HEAD", {
9292
+ const currentBranch = execSync14("git rev-parse --abbrev-ref HEAD", {
9469
9293
  cwd: projectRoot,
9470
9294
  encoding: "utf-8",
9471
9295
  stdio: ["pipe", "pipe", "pipe"]
9472
9296
  }).trim();
9473
9297
  if (currentBranch !== state.branch) {
9474
- execSync16(`git checkout ${state.branch}`, {
9298
+ execSync14(`git checkout ${state.branch}`, {
9475
9299
  cwd: projectRoot,
9476
9300
  encoding: "utf-8",
9477
9301
  stdio: ["pipe", "pipe", "pipe"]
@@ -9495,11 +9319,11 @@ ${bold("Resuming")} ${state.type} run ${dim(state.runId)}
9495
9319
  saveRunState(projectRoot, state);
9496
9320
  const result = await executeIssue(projectRoot, {
9497
9321
  issueNumber: task.issue,
9498
- provider: config.ai.provider,
9499
- model: config.ai.model,
9322
+ provider: execution.provider,
9323
+ model: execution.model,
9500
9324
  skipPR: isSprintRun,
9501
9325
  sandboxed,
9502
- sandboxName: isSprintRun ? undefined : config.sandbox.name
9326
+ sandboxName: execution.sandboxName
9503
9327
  });
9504
9328
  if (result.success) {
9505
9329
  if (isSprintRun) {
@@ -9538,7 +9362,7 @@ ${bold("Resume complete:")} ${green(`✓ ${finalStats.done}`)} ${finalStats.fail
9538
9362
  const prNumber = await createSprintPR(projectRoot, config, state.sprint, state.branch, completedTasks);
9539
9363
  if (prNumber !== undefined) {
9540
9364
  try {
9541
- execSync16(`git checkout ${config.agent.baseBranch}`, {
9365
+ execSync14(`git checkout ${config.agent.baseBranch}`, {
9542
9366
  cwd: projectRoot,
9543
9367
  encoding: "utf-8",
9544
9368
  stdio: ["pipe", "pipe", "pipe"]
@@ -9569,14 +9393,14 @@ function getOrder2(issue) {
9569
9393
  }
9570
9394
  function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
9571
9395
  try {
9572
- const status = execSync16("git status --porcelain", {
9396
+ const status = execSync14("git status --porcelain", {
9573
9397
  cwd: projectRoot,
9574
9398
  encoding: "utf-8",
9575
9399
  stdio: ["pipe", "pipe", "pipe"]
9576
9400
  }).trim();
9577
9401
  if (!status)
9578
9402
  return;
9579
- execSync16("git add -A", {
9403
+ execSync14("git add -A", {
9580
9404
  cwd: projectRoot,
9581
9405
  encoding: "utf-8",
9582
9406
  stdio: ["pipe", "pipe", "pipe"]
@@ -9584,7 +9408,7 @@ function ensureTaskCommit(projectRoot, issueNumber, issueTitle) {
9584
9408
  const message = `chore: complete #${issueNumber} - ${issueTitle}
9585
9409
 
9586
9410
  Co-Authored-By: LocusAgent <agent@locusai.team>`;
9587
- execSync16(`git commit -F -`, {
9411
+ execSync14(`git commit -F -`, {
9588
9412
  input: message,
9589
9413
  cwd: projectRoot,
9590
9414
  encoding: "utf-8",
@@ -9598,7 +9422,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
9598
9422
  if (!config.agent.autoPR)
9599
9423
  return;
9600
9424
  try {
9601
- const diff = execSync16(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9425
+ const diff = execSync14(`git diff origin/${config.agent.baseBranch}..HEAD --stat`, {
9602
9426
  cwd: projectRoot,
9603
9427
  encoding: "utf-8",
9604
9428
  stdio: ["pipe", "pipe", "pipe"]
@@ -9608,7 +9432,7 @@ async function createSprintPR(projectRoot, config, sprintName, branchName, tasks
9608
9432
  `);
9609
9433
  return;
9610
9434
  }
9611
- execSync16(`git push -u origin ${branchName}`, {
9435
+ execSync14(`git push -u origin ${branchName}`, {
9612
9436
  cwd: projectRoot,
9613
9437
  encoding: "utf-8",
9614
9438
  stdio: ["pipe", "pipe", "pipe"]
@@ -9634,6 +9458,7 @@ ${taskLines}
9634
9458
  }
9635
9459
  }
9636
9460
  var init_run = __esm(() => {
9461
+ init_ai_models();
9637
9462
  init_agent();
9638
9463
  init_config();
9639
9464
  init_conflict();
@@ -10010,7 +9835,7 @@ ${bold("Planning:")} ${cyan(displayDirective)}
10010
9835
  cwd: projectRoot,
10011
9836
  activity: "planning",
10012
9837
  sandboxed: config.sandbox.enabled,
10013
- sandboxName: config.sandbox.name
9838
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10014
9839
  });
10015
9840
  if (aiResult.interrupted) {
10016
9841
  process.stderr.write(`
@@ -10102,16 +9927,21 @@ ${i.body?.slice(0, 300) ?? ""}`).join(`
10102
9927
 
10103
9928
  `);
10104
9929
  const { runAI: runAI2 } = await Promise.resolve().then(() => (init_run_ai(), exports_run_ai));
10105
- const prompt = `You are organizing GitHub issues for a sprint. Analyze these issues and suggest the optimal execution order.
9930
+ const prompt = `<role>
9931
+ You are organizing GitHub issues for a sprint. Analyze these issues and suggest the optimal execution order.
9932
+ </role>
10106
9933
 
10107
- Issues:
9934
+ <issues>
10108
9935
  ${issueDescriptions}
9936
+ </issues>
10109
9937
 
9938
+ <instructions>
10110
9939
  For each issue, output a line in this format:
10111
9940
  ORDER: #<number> <reason for this position>
10112
9941
 
10113
9942
  Order them so that dependencies are respected (issues that produce code needed by later issues should come first).
10114
- Start with foundational/setup tasks, then core features, then integration/testing.`;
9943
+ Start with foundational/setup tasks, then core features, then integration/testing.
9944
+ </instructions>`;
10115
9945
  const aiResult = await runAI2({
10116
9946
  prompt,
10117
9947
  provider: config.ai.provider,
@@ -10120,7 +9950,7 @@ Start with foundational/setup tasks, then core features, then integration/testin
10120
9950
  activity: "issue ordering",
10121
9951
  silent: true,
10122
9952
  sandboxed: config.sandbox.enabled,
10123
- sandboxName: config.sandbox.name
9953
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10124
9954
  });
10125
9955
  if (aiResult.interrupted) {
10126
9956
  process.stderr.write(`
@@ -10184,59 +10014,62 @@ ${bold("Suggested Order:")}
10184
10014
  }
10185
10015
  function buildPlanningPrompt(projectRoot, config, directive, sprintName, id, planPathRelative) {
10186
10016
  const parts = [];
10187
- parts.push(`You are a sprint planning assistant for the GitHub repository ${config.github.owner}/${config.github.repo}.`);
10188
- parts.push("");
10189
- parts.push(`DIRECTIVE: ${directive}`);
10190
- if (sprintName) {
10191
- parts.push(`SPRINT: ${sprintName}`);
10192
- }
10193
- parts.push("");
10194
- const locusPath = join18(projectRoot, "LOCUS.md");
10017
+ parts.push(`<role>
10018
+ You are a sprint planning assistant for the GitHub repository ${config.github.owner}/${config.github.repo}.
10019
+ </role>`);
10020
+ parts.push(`<directive>
10021
+ ${directive}${sprintName ? `
10022
+
10023
+ **Sprint:** ${sprintName}` : ""}
10024
+ </directive>`);
10025
+ const locusPath = join18(projectRoot, ".locus", "LOCUS.md");
10195
10026
  if (existsSync18(locusPath)) {
10196
10027
  const content = readFileSync13(locusPath, "utf-8");
10197
- parts.push("PROJECT CONTEXT (LOCUS.md):");
10198
- parts.push(content.slice(0, 3000));
10199
- parts.push("");
10028
+ parts.push(`<project-context>
10029
+ ${content.slice(0, 3000)}
10030
+ </project-context>`);
10200
10031
  }
10201
10032
  const learningsPath = join18(projectRoot, ".locus", "LEARNINGS.md");
10202
10033
  if (existsSync18(learningsPath)) {
10203
10034
  const content = readFileSync13(learningsPath, "utf-8");
10204
- parts.push("PAST LEARNINGS:");
10205
- parts.push(content.slice(0, 2000));
10206
- parts.push("");
10207
- }
10208
- parts.push("TASK:");
10209
- parts.push(`Break down the directive into specific, actionable GitHub issues and write them to the file: ${planPathRelative}`);
10210
- parts.push("");
10211
- parts.push(`Write ONLY a valid JSON file to ${planPathRelative} with this exact structure:`);
10212
- parts.push("");
10213
- parts.push("```json");
10214
- parts.push("{");
10215
- parts.push(` "id": "${id}",`);
10216
- parts.push(` "directive": ${JSON.stringify(directive)},`);
10217
- parts.push(` "sprint": ${sprintName ? JSON.stringify(sprintName) : "null"},`);
10218
- parts.push(` "createdAt": "${new Date().toISOString()}",`);
10219
- parts.push(' "issues": [');
10220
- parts.push(" {");
10221
- parts.push(' "order": 1,');
10222
- parts.push(' "title": "concise issue title",');
10223
- parts.push(' "body": "detailed markdown body with acceptance criteria",');
10224
- parts.push(' "priority": "critical|high|medium|low",');
10225
- parts.push(' "type": "feature|bug|chore|refactor|docs",');
10226
- parts.push(' "dependsOn": "none or comma-separated order numbers"');
10227
- parts.push(" }");
10228
- parts.push(" ]");
10229
- parts.push("}");
10230
- parts.push("```");
10231
- parts.push("");
10232
- parts.push("Requirements for the issues:");
10233
- parts.push("- Break the directive into 3-10 specific, actionable issues");
10234
- parts.push("- Each issue must be independently executable by an AI agent");
10235
- parts.push("- Order them so dependencies are respected (foundational tasks first)");
10236
- parts.push("- Write detailed issue bodies with clear acceptance criteria");
10237
- parts.push("- Use valid GitHub Markdown only in issue bodies");
10238
- parts.push("- Create the file using the Write tool — do not print the JSON to the terminal");
10035
+ parts.push(`<past-learnings>
10036
+ ${content.slice(0, 2000)}
10037
+ </past-learnings>`);
10038
+ }
10039
+ parts.push(`<task>
10040
+ Break down the directive into specific, actionable GitHub issues and write them to the file: ${planPathRelative}
10041
+
10042
+ Write ONLY a valid JSON file to ${planPathRelative} with this exact structure:
10043
+
10044
+ \`\`\`json
10045
+ {
10046
+ "id": "${id}",
10047
+ "directive": ${JSON.stringify(directive)},
10048
+ "sprint": ${sprintName ? JSON.stringify(sprintName) : "null"},
10049
+ "createdAt": "${new Date().toISOString()}",
10050
+ "issues": [
10051
+ {
10052
+ "order": 1,
10053
+ "title": "concise issue title",
10054
+ "body": "detailed markdown body with acceptance criteria",
10055
+ "priority": "critical|high|medium|low",
10056
+ "type": "feature|bug|chore|refactor|docs",
10057
+ "dependsOn": "none or comma-separated order numbers"
10058
+ }
10059
+ ]
10060
+ }
10061
+ \`\`\`
10062
+ </task>`);
10063
+ parts.push(`<requirements>
10064
+ - Break the directive into 3-10 specific, actionable issues
10065
+ - Each issue must be independently executable by an AI agent
10066
+ - Order them so dependencies are respected (foundational tasks first)
10067
+ - Write detailed issue bodies with clear acceptance criteria
10068
+ - Use valid GitHub Markdown only in issue bodies
10069
+ - Create the file using the Write tool — do not print the JSON to the terminal
10070
+ </requirements>`);
10239
10071
  return parts.join(`
10072
+
10240
10073
  `);
10241
10074
  }
10242
10075
  function sanitizePlanOutput(output) {
@@ -10369,6 +10202,7 @@ var init_plan = __esm(() => {
10369
10202
  init_config();
10370
10203
  init_github();
10371
10204
  init_terminal();
10205
+ init_sandbox();
10372
10206
  });
10373
10207
 
10374
10208
  // src/commands/review.ts
@@ -10376,7 +10210,7 @@ var exports_review = {};
10376
10210
  __export(exports_review, {
10377
10211
  reviewCommand: () => reviewCommand
10378
10212
  });
10379
- import { execSync as execSync17 } from "node:child_process";
10213
+ import { execSync as execSync15 } from "node:child_process";
10380
10214
  import { existsSync as existsSync19, readFileSync as readFileSync14 } from "node:fs";
10381
10215
  import { join as join19 } from "node:path";
10382
10216
  function printHelp2() {
@@ -10454,7 +10288,7 @@ ${bold("Review complete:")} ${green(`✓ ${reviewed}`)}${failed > 0 ? ` ${red(`
10454
10288
  async function reviewSinglePR(projectRoot, config, prNumber, focus, flags) {
10455
10289
  let prInfo;
10456
10290
  try {
10457
- const result = execSync17(`gh pr view ${prNumber} --json number,title,body,state,headRefName,baseRefName,labels,url,createdAt`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10291
+ 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"] });
10458
10292
  const raw = JSON.parse(result);
10459
10293
  prInfo = {
10460
10294
  number: raw.number,
@@ -10499,7 +10333,7 @@ async function reviewPR(projectRoot, config, pr, focus, flags) {
10499
10333
  cwd: projectRoot,
10500
10334
  activity: `PR #${pr.number}`,
10501
10335
  sandboxed: config.sandbox.enabled,
10502
- sandboxName: config.sandbox.name
10336
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
10503
10337
  });
10504
10338
  if (aiResult.interrupted) {
10505
10339
  process.stderr.write(` ${yellow("⚡")} Review interrupted.
@@ -10520,7 +10354,7 @@ ${output.slice(0, 60000)}
10520
10354
 
10521
10355
  ---
10522
10356
  _Reviewed by Locus AI (${config.ai.provider}/${flags.model ?? config.ai.model})_`;
10523
- execSync17(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10357
+ execSync15(`gh pr comment ${pr.number} --body ${JSON.stringify(reviewBody)}`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10524
10358
  process.stderr.write(` ${green("✓")} Review posted ${dim(`(${timer.formatted()})`)}
10525
10359
  `);
10526
10360
  } catch (e) {
@@ -10535,53 +10369,62 @@ _Reviewed by Locus AI (${config.ai.provider}/${flags.model ?? config.ai.model})_
10535
10369
  }
10536
10370
  function buildReviewPrompt(projectRoot, config, pr, diff, focus) {
10537
10371
  const parts = [];
10538
- parts.push(`You are an expert code reviewer for the ${config.github.owner}/${config.github.repo} repository.`);
10539
- parts.push("");
10540
- const locusPath = join19(projectRoot, "LOCUS.md");
10372
+ parts.push(`<role>
10373
+ You are an expert code reviewer for the ${config.github.owner}/${config.github.repo} repository.
10374
+ </role>`);
10375
+ const locusPath = join19(projectRoot, ".locus", "LOCUS.md");
10541
10376
  if (existsSync19(locusPath)) {
10542
10377
  const content = readFileSync14(locusPath, "utf-8");
10543
- parts.push("PROJECT CONTEXT:");
10544
- parts.push(content.slice(0, 2000));
10545
- parts.push("");
10378
+ parts.push(`<project-context>
10379
+ ${content.slice(0, 2000)}
10380
+ </project-context>`);
10546
10381
  }
10547
- parts.push(`PULL REQUEST #${pr.number}: ${pr.title}`);
10548
- parts.push(`Branch: ${pr.head} → ${pr.base}`);
10382
+ const prMeta = [`Branch: ${pr.head} ${pr.base}`];
10549
10383
  if (pr.body) {
10550
- parts.push(`Description:
10384
+ prMeta.push(`Description:
10551
10385
  ${pr.body.slice(0, 1000)}`);
10552
10386
  }
10553
- parts.push("");
10554
- parts.push("DIFF:");
10555
- parts.push(diff.slice(0, 50000));
10556
- parts.push("");
10557
- parts.push("REVIEW INSTRUCTIONS:");
10558
- parts.push("Provide a thorough code review. For each issue found, describe:");
10559
- parts.push("1. The file and approximate location");
10560
- parts.push("2. What the issue is");
10561
- parts.push("3. Why it matters");
10562
- parts.push("4. How to fix it");
10563
- parts.push("");
10564
- parts.push("Categories to check:");
10565
- parts.push("- Correctness: bugs, logic errors, edge cases");
10566
- parts.push("- Security: injection, XSS, auth issues, secret exposure");
10567
- parts.push("- Performance: N+1 queries, unnecessary allocations, missing caching");
10568
- parts.push("- Maintainability: naming, complexity, code organization");
10569
- parts.push("- Testing: missing tests, inadequate coverage");
10387
+ parts.push(`<pull-request number="${pr.number}" title="${pr.title}">
10388
+ ${prMeta.join(`
10389
+ `)}
10390
+ </pull-request>`);
10391
+ parts.push(`<diff>
10392
+ ${diff.slice(0, 50000)}
10393
+ </diff>`);
10394
+ let instructions = `Provide a thorough code review. For each issue found, describe:
10395
+ 1. The file and approximate location
10396
+ 2. What the issue is
10397
+ 3. Why it matters
10398
+ 4. How to fix it
10399
+
10400
+ Categories to check:
10401
+ - Correctness: bugs, logic errors, edge cases
10402
+ - Security: injection, XSS, auth issues, secret exposure
10403
+ - Performance: N+1 queries, unnecessary allocations, missing caching
10404
+ - Maintainability: naming, complexity, code organization
10405
+ - Testing: missing tests, inadequate coverage`;
10570
10406
  if (focus) {
10571
- parts.push("");
10572
- parts.push(`FOCUS AREAS: ${focus}`);
10573
- parts.push("Pay special attention to the above areas.");
10407
+ instructions += `
10408
+
10409
+ **Focus areas:** ${focus}
10410
+ Pay special attention to the above areas.`;
10574
10411
  }
10575
- parts.push("");
10576
- parts.push("End with an overall assessment: APPROVE, REQUEST_CHANGES, or COMMENT.");
10577
- parts.push("Be constructive and specific. Praise good patterns too.");
10412
+ instructions += `
10413
+
10414
+ End with an overall assessment: APPROVE, REQUEST_CHANGES, or COMMENT.
10415
+ Be constructive and specific. Praise good patterns too.`;
10416
+ parts.push(`<review-instructions>
10417
+ ${instructions}
10418
+ </review-instructions>`);
10578
10419
  return parts.join(`
10420
+
10579
10421
  `);
10580
10422
  }
10581
10423
  var init_review = __esm(() => {
10582
10424
  init_run_ai();
10583
10425
  init_config();
10584
10426
  init_github();
10427
+ init_sandbox();
10585
10428
  init_progress();
10586
10429
  init_terminal();
10587
10430
  });
@@ -10591,7 +10434,7 @@ var exports_iterate = {};
10591
10434
  __export(exports_iterate, {
10592
10435
  iterateCommand: () => iterateCommand
10593
10436
  });
10594
- import { execSync as execSync18 } from "node:child_process";
10437
+ import { execSync as execSync16 } from "node:child_process";
10595
10438
  function printHelp3() {
10596
10439
  process.stderr.write(`
10597
10440
  ${bold("locus iterate")} — Re-execute tasks with PR feedback
@@ -10801,12 +10644,12 @@ ${bold("Summary:")} ${green(`✓ ${succeeded}`)}${failed > 0 ? ` ${red(`✗ ${fa
10801
10644
  }
10802
10645
  function findPRForIssue(projectRoot, issueNumber) {
10803
10646
  try {
10804
- const result = execSync18(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10647
+ const result = execSync16(`gh pr list --search "Closes #${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10805
10648
  const parsed = JSON.parse(result);
10806
10649
  if (parsed.length > 0) {
10807
10650
  return parsed[0].number;
10808
10651
  }
10809
- const branchResult = execSync18(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10652
+ const branchResult = execSync16(`gh pr list --head "locus/issue-${issueNumber}" --json number --state open`, { cwd: projectRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
10810
10653
  const branchParsed = JSON.parse(branchResult);
10811
10654
  if (branchParsed.length > 0) {
10812
10655
  return branchParsed[0].number;
@@ -11051,7 +10894,7 @@ ${bold("Discussion:")} ${cyan(topic)}
11051
10894
  cwd: projectRoot,
11052
10895
  activity: "discussion",
11053
10896
  sandboxed: config.sandbox.enabled,
11054
- sandboxName: config.sandbox.name
10897
+ sandboxName: getModelSandboxName(config.sandbox, flags.model ?? config.ai.model, config.ai.provider)
11055
10898
  });
11056
10899
  if (aiResult.interrupted) {
11057
10900
  process.stderr.write(`
@@ -11130,61 +10973,74 @@ ${green("✓")} Discussion saved: ${cyan(id)} ${dim(`(${timer.formatted()})`)}
11130
10973
  }
11131
10974
  function buildDiscussionPrompt(projectRoot, config, topic, conversation, forceFinal) {
11132
10975
  const parts = [];
11133
- parts.push(`You are a senior software architect and consultant for the ${config.github.owner}/${config.github.repo} project.`);
11134
- parts.push("");
11135
- const locusPath = join20(projectRoot, "LOCUS.md");
10976
+ parts.push(`<role>
10977
+ You are a senior software architect and consultant for the ${config.github.owner}/${config.github.repo} project.
10978
+ </role>`);
10979
+ const locusPath = join20(projectRoot, ".locus", "LOCUS.md");
11136
10980
  if (existsSync20(locusPath)) {
11137
10981
  const content = readFileSync15(locusPath, "utf-8");
11138
- parts.push("PROJECT CONTEXT:");
11139
- parts.push(content.slice(0, 3000));
11140
- parts.push("");
10982
+ parts.push(`<project-context>
10983
+ ${content.slice(0, 3000)}
10984
+ </project-context>`);
11141
10985
  }
11142
10986
  const learningsPath = join20(projectRoot, ".locus", "LEARNINGS.md");
11143
10987
  if (existsSync20(learningsPath)) {
11144
10988
  const content = readFileSync15(learningsPath, "utf-8");
11145
- parts.push("PAST LEARNINGS:");
11146
- parts.push(content.slice(0, 2000));
11147
- parts.push("");
10989
+ parts.push(`<past-learnings>
10990
+ ${content.slice(0, 2000)}
10991
+ </past-learnings>`);
11148
10992
  }
11149
- parts.push(`DISCUSSION TOPIC: ${topic}`);
11150
- parts.push("");
10993
+ parts.push(`<discussion-topic>
10994
+ ${topic}
10995
+ </discussion-topic>`);
11151
10996
  if (conversation.length === 0) {
11152
- parts.push("Before providing recommendations, you need to ask targeted clarifying questions.");
11153
- parts.push("");
11154
- parts.push("Ask 3-5 focused questions that will significantly improve the quality of your analysis.");
11155
- parts.push("Format as a numbered list. Be specific and focused on the most important unknowns.");
11156
- parts.push("Do NOT provide any analysis yet questions only.");
10997
+ parts.push(`<instructions>
10998
+ Before providing recommendations, you need to ask targeted clarifying questions.
10999
+
11000
+ Ask 3-5 focused questions that will significantly improve the quality of your analysis.
11001
+ Format as a numbered list. Be specific and focused on the most important unknowns.
11002
+ Do NOT provide any analysis yet — questions only.
11003
+ </instructions>`);
11157
11004
  } else {
11158
- parts.push("CONVERSATION SO FAR:");
11159
- parts.push("");
11005
+ const historyLines = [];
11160
11006
  for (const turn of conversation) {
11161
11007
  if (turn.role === "user") {
11162
- parts.push(`USER: ${turn.content}`);
11008
+ historyLines.push(`USER: ${turn.content}`);
11163
11009
  } else {
11164
- parts.push(`ASSISTANT: ${turn.content}`);
11010
+ historyLines.push(`ASSISTANT: ${turn.content}`);
11165
11011
  }
11166
- parts.push("");
11167
11012
  }
11013
+ parts.push(`<conversation-history>
11014
+ ${historyLines.join(`
11015
+
11016
+ `)}
11017
+ </conversation-history>`);
11168
11018
  if (forceFinal) {
11169
- parts.push("Based on everything discussed, provide your complete analysis and recommendations now.");
11170
- parts.push("Format as a thorough markdown document with a clear title (# Heading), sections, trade-offs, and actionable recommendations.");
11019
+ parts.push(`<instructions>
11020
+ Based on everything discussed, provide your complete analysis and recommendations now.
11021
+ Format as a thorough markdown document with a clear title (# Heading), sections, trade-offs, and actionable recommendations.
11022
+ </instructions>`);
11171
11023
  } else {
11172
- parts.push("Review the information gathered so far.");
11173
- parts.push("");
11174
- parts.push("If you have enough information to make a thorough recommendation:");
11175
- parts.push(" → Provide a complete analysis as a markdown document with a title (# Heading), sections, trade-offs, and concrete recommendations.");
11176
- parts.push("");
11177
- parts.push("If you still need key information to give a good answer:");
11178
- parts.push(" → Ask 2-3 more focused follow-up questions (numbered list only, no analysis yet).");
11024
+ parts.push(`<instructions>
11025
+ Review the information gathered so far.
11026
+
11027
+ If you have enough information to make a thorough recommendation:
11028
+ → Provide a complete analysis as a markdown document with a title (# Heading), sections, trade-offs, and concrete recommendations.
11029
+
11030
+ If you still need key information to give a good answer:
11031
+ → Ask 2-3 more focused follow-up questions (numbered list only, no analysis yet).
11032
+ </instructions>`);
11179
11033
  }
11180
11034
  }
11181
11035
  return parts.join(`
11036
+
11182
11037
  `);
11183
11038
  }
11184
11039
  var MAX_DISCUSSION_ROUNDS = 5;
11185
11040
  var init_discuss = __esm(() => {
11186
11041
  init_run_ai();
11187
11042
  init_config();
11043
+ init_sandbox();
11188
11044
  init_progress();
11189
11045
  init_terminal();
11190
11046
  init_input_handler();
@@ -11409,22 +11265,25 @@ var exports_sandbox2 = {};
11409
11265
  __export(exports_sandbox2, {
11410
11266
  sandboxCommand: () => sandboxCommand
11411
11267
  });
11412
- import { execSync as execSync19, spawn as spawn6 } from "node:child_process";
11268
+ import { execSync as execSync17, spawn as spawn6 } from "node:child_process";
11269
+ import { createHash } from "node:crypto";
11270
+ import { basename as basename4 } from "node:path";
11413
11271
  function printSandboxHelp() {
11414
11272
  process.stderr.write(`
11415
11273
  ${bold("locus sandbox")} — Manage Docker sandbox lifecycle
11416
11274
 
11417
11275
  ${bold("Usage:")}
11418
- locus sandbox ${dim("# Create sandbox and enable sandbox mode")}
11276
+ locus sandbox ${dim("# Create claude/codex sandboxes and enable sandbox mode")}
11419
11277
  locus sandbox claude ${dim("# Run claude interactively (for login)")}
11420
11278
  locus sandbox codex ${dim("# Run codex interactively (for login)")}
11421
- locus sandbox rm ${dim("# Destroy sandbox and disable sandbox mode")}
11279
+ locus sandbox rm ${dim("# Destroy all provider sandboxes and disable sandbox mode")}
11422
11280
  locus sandbox status ${dim("# Show current sandbox state")}
11423
11281
 
11424
11282
  ${bold("Flow:")}
11425
- 1. ${cyan("locus sandbox")} Create the sandbox environment
11426
- 2. ${cyan("locus sandbox claude")} Login to Claude inside the sandbox
11427
- 3. ${cyan("locus exec")} All commands now run inside the sandbox
11283
+ 1. ${cyan("locus sandbox")} Create provider sandboxes
11284
+ 2. ${cyan("locus sandbox claude")} Login Claude inside its sandbox
11285
+ 3. ${cyan("locus sandbox codex")} Login Codex inside its sandbox
11286
+ 4. ${cyan("locus exec")}/${cyan("locus run")} Commands resync + execute in provider sandbox
11428
11287
 
11429
11288
  `);
11430
11289
  }
@@ -11452,18 +11311,6 @@ async function sandboxCommand(projectRoot, args) {
11452
11311
  }
11453
11312
  async function handleCreate(projectRoot) {
11454
11313
  const config = loadConfig(projectRoot);
11455
- if (config.sandbox.name) {
11456
- const alive = isSandboxAlive(config.sandbox.name);
11457
- if (alive) {
11458
- process.stderr.write(`${green("✓")} Sandbox already exists: ${bold(config.sandbox.name)}
11459
- `);
11460
- process.stderr.write(` Run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to login.
11461
- `);
11462
- return;
11463
- }
11464
- process.stderr.write(`${yellow("⚠")} Previous sandbox ${dim(config.sandbox.name)} is no longer running. Creating a new one.
11465
- `);
11466
- }
11467
11314
  const status = await detectSandboxSupport();
11468
11315
  if (!status.available) {
11469
11316
  process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
@@ -11472,86 +11319,68 @@ async function handleCreate(projectRoot) {
11472
11319
  `);
11473
11320
  return;
11474
11321
  }
11475
- const segment = projectRoot.split("/").pop() ?? "sandbox";
11476
- const sandboxName = `locus-${segment}-${Date.now()}`;
11322
+ const sandboxNames = buildProviderSandboxNames(projectRoot);
11323
+ const readySandboxes = {};
11324
+ let failed = false;
11325
+ for (const provider of PROVIDERS) {
11326
+ const name = sandboxNames[provider];
11327
+ if (isSandboxAlive(name)) {
11328
+ process.stderr.write(`${green("✓")} ${provider} sandbox ready: ${bold(name)}
11329
+ `);
11330
+ readySandboxes[provider] = name;
11331
+ continue;
11332
+ }
11333
+ process.stderr.write(`Creating ${bold(provider)} sandbox ${dim(name)} with workspace ${dim(projectRoot)}...
11334
+ `);
11335
+ const created = await createProviderSandbox(provider, name, projectRoot);
11336
+ if (!created) {
11337
+ process.stderr.write(`${red("✗")} Failed to create ${provider} sandbox (${name}).
11338
+ `);
11339
+ failed = true;
11340
+ continue;
11341
+ }
11342
+ process.stderr.write(`${green("✓")} ${provider} sandbox created: ${bold(name)}
11343
+ `);
11344
+ readySandboxes[provider] = name;
11345
+ }
11477
11346
  config.sandbox.enabled = true;
11478
- config.sandbox.name = sandboxName;
11347
+ config.sandbox.providers = readySandboxes;
11479
11348
  saveConfig(projectRoot, config);
11480
- process.stderr.write(`${green("✓")} Sandbox name reserved: ${bold(sandboxName)}
11349
+ if (failed) {
11350
+ process.stderr.write(`
11351
+ ${yellow("⚠")} Some sandboxes failed to create. Re-run ${cyan("locus sandbox")} after resolving Docker issues.
11481
11352
  `);
11482
- process.stderr.write(` Next: run ${cyan("locus sandbox claude")} or ${cyan("locus sandbox codex")} to create the sandbox and login.
11353
+ }
11354
+ process.stderr.write(`
11355
+ ${green("✓")} Sandbox mode enabled with provider-specific sandboxes.
11356
+ `);
11357
+ process.stderr.write(` Next: run ${cyan("locus sandbox claude")} and ${cyan("locus sandbox codex")} to authenticate both providers.
11483
11358
  `);
11484
11359
  }
11485
11360
  async function handleAgentLogin(projectRoot, agent) {
11486
11361
  const config = loadConfig(projectRoot);
11487
- if (!config.sandbox.name) {
11488
- const status = await detectSandboxSupport();
11489
- if (!status.available) {
11490
- process.stderr.write(`${red("✗")} Docker sandbox not available: ${status.reason}
11491
- `);
11492
- process.stderr.write(` Install Docker Desktop 4.58+ with sandbox support.
11362
+ const sandboxName = getProviderSandboxName(config.sandbox, agent);
11363
+ if (!sandboxName) {
11364
+ process.stderr.write(`${red("✗")} No ${agent} sandbox configured. Run ${cyan("locus sandbox")} first.
11493
11365
  `);
11494
- return;
11495
- }
11496
- const segment = projectRoot.split("/").pop() ?? "sandbox";
11497
- config.sandbox.name = `locus-${segment}-${Date.now()}`;
11498
- config.sandbox.enabled = true;
11499
- saveConfig(projectRoot, config);
11366
+ return;
11500
11367
  }
11501
- const sandboxName = config.sandbox.name;
11502
- const alive = isSandboxAlive(sandboxName);
11503
- let dockerArgs;
11504
- if (alive) {
11505
- if (agent === "codex") {
11506
- await ensureCodexInSandbox(sandboxName);
11507
- }
11508
- process.stderr.write(`Connecting to sandbox ${dim(sandboxName)}...
11509
- `);
11510
- process.stderr.write(`${dim("Login and then exit when ready.")}
11511
-
11512
- `);
11513
- dockerArgs = [
11514
- "sandbox",
11515
- "exec",
11516
- "-it",
11517
- "-w",
11518
- projectRoot,
11519
- sandboxName,
11520
- agent
11521
- ];
11522
- } else if (agent === "codex") {
11523
- process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11368
+ if (!isSandboxAlive(sandboxName)) {
11369
+ process.stderr.write(`${red("✗")} ${agent} sandbox is not running: ${dim(sandboxName)}
11524
11370
  `);
11525
- try {
11526
- execSync19(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, { stdio: ["pipe", "pipe", "pipe"], timeout: 120000 });
11527
- } catch {}
11528
- if (!isSandboxAlive(sandboxName)) {
11529
- process.stderr.write(`${red("✗")} Failed to create sandbox.
11371
+ process.stderr.write(` Recreate it with ${cyan("locus sandbox")}.
11530
11372
  `);
11531
- return;
11532
- }
11373
+ return;
11374
+ }
11375
+ if (agent === "codex") {
11533
11376
  await ensureCodexInSandbox(sandboxName);
11534
- process.stderr.write(`${dim("Login and then exit when ready.")}
11535
-
11536
- `);
11537
- dockerArgs = [
11538
- "sandbox",
11539
- "exec",
11540
- "-it",
11541
- "-w",
11542
- projectRoot,
11543
- sandboxName,
11544
- "codex"
11545
- ];
11546
- } else {
11547
- process.stderr.write(`Creating sandbox ${bold(sandboxName)} with workspace ${dim(projectRoot)}...
11377
+ }
11378
+ process.stderr.write(`Connecting to ${agent} sandbox ${dim(sandboxName)}...
11548
11379
  `);
11549
- process.stderr.write(`${dim("Login and then exit when ready.")}
11380
+ process.stderr.write(`${dim("Login and then exit when ready.")}
11550
11381
 
11551
11382
  `);
11552
- dockerArgs = ["sandbox", "run", "--name", sandboxName, agent, projectRoot];
11553
- }
11554
- const child = spawn6("docker", dockerArgs, {
11383
+ const child = spawn6("docker", ["sandbox", "exec", "-it", "-w", projectRoot, sandboxName, agent], {
11555
11384
  stdio: "inherit"
11556
11385
  });
11557
11386
  await new Promise((resolve2) => {
@@ -11577,25 +11406,30 @@ ${yellow("⚠")} ${agent} exited with code ${code}.
11577
11406
  }
11578
11407
  function handleRemove(projectRoot) {
11579
11408
  const config = loadConfig(projectRoot);
11580
- if (!config.sandbox.name) {
11581
- process.stderr.write(`${dim("No sandbox to remove.")}
11409
+ const names = Array.from(new Set(Object.values(config.sandbox.providers).filter((value) => typeof value === "string" && value.length > 0)));
11410
+ if (names.length === 0) {
11411
+ config.sandbox.enabled = false;
11412
+ config.sandbox.providers = {};
11413
+ saveConfig(projectRoot, config);
11414
+ process.stderr.write(`${dim("No sandboxes to remove. Sandbox mode disabled.")}
11582
11415
  `);
11583
11416
  return;
11584
11417
  }
11585
- const sandboxName = config.sandbox.name;
11586
- process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11418
+ for (const sandboxName of names) {
11419
+ process.stderr.write(`Removing sandbox ${bold(sandboxName)}...
11587
11420
  `);
11588
- try {
11589
- execSync19(`docker sandbox rm ${sandboxName}`, {
11590
- encoding: "utf-8",
11591
- stdio: ["pipe", "pipe", "pipe"],
11592
- timeout: 15000
11593
- });
11594
- } catch {}
11595
- config.sandbox.name = undefined;
11421
+ try {
11422
+ execSync17(`docker sandbox rm ${sandboxName}`, {
11423
+ encoding: "utf-8",
11424
+ stdio: ["pipe", "pipe", "pipe"],
11425
+ timeout: 15000
11426
+ });
11427
+ } catch {}
11428
+ }
11429
+ config.sandbox.providers = {};
11596
11430
  config.sandbox.enabled = false;
11597
11431
  saveConfig(projectRoot, config);
11598
- process.stderr.write(`${green("✓")} Sandbox removed. Sandbox mode disabled.
11432
+ process.stderr.write(`${green("✓")} Provider sandboxes removed. Sandbox mode disabled.
11599
11433
  `);
11600
11434
  }
11601
11435
  function handleStatus(projectRoot) {
@@ -11606,24 +11440,55 @@ ${bold("Sandbox Status")}
11606
11440
  `);
11607
11441
  process.stderr.write(` ${dim("Enabled:")} ${config.sandbox.enabled ? green("yes") : red("no")}
11608
11442
  `);
11609
- process.stderr.write(` ${dim("Name:")} ${config.sandbox.name ? bold(config.sandbox.name) : dim("(none)")}
11443
+ for (const provider of PROVIDERS) {
11444
+ const name = config.sandbox.providers[provider];
11445
+ process.stderr.write(` ${dim(`${provider}:`).padEnd(15)}${name ? bold(name) : dim("(not configured)")}
11610
11446
  `);
11611
- if (config.sandbox.name) {
11612
- const alive = isSandboxAlive(config.sandbox.name);
11613
- process.stderr.write(` ${dim("Running:")} ${alive ? green("yes") : red("no")}
11614
- `);
11615
- if (!alive) {
11616
- process.stderr.write(`
11617
- ${yellow("⚠")} Sandbox is not running. Run ${bold("locus sandbox")} to create a new one.
11447
+ if (name) {
11448
+ const alive = isSandboxAlive(name);
11449
+ process.stderr.write(` ${dim(`${provider} running:`).padEnd(15)}${alive ? green("yes") : red("no")}
11618
11450
  `);
11619
11451
  }
11620
11452
  }
11453
+ if (!config.sandbox.providers.claude || !config.sandbox.providers.codex) {
11454
+ process.stderr.write(`
11455
+ ${yellow("⚠")} Provider sandboxes are incomplete. Run ${bold("locus sandbox")}.
11456
+ `);
11457
+ }
11621
11458
  process.stderr.write(`
11622
11459
  `);
11623
11460
  }
11461
+ function buildProviderSandboxNames(projectRoot) {
11462
+ const segment = sanitizeSegment(basename4(projectRoot));
11463
+ const hash = createHash("sha1").update(projectRoot).digest("hex").slice(0, 8);
11464
+ return {
11465
+ claude: `locus-${segment}-claude-${hash}`,
11466
+ codex: `locus-${segment}-codex-${hash}`
11467
+ };
11468
+ }
11469
+ function sanitizeSegment(input) {
11470
+ const cleaned = input.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
11471
+ return cleaned || "workspace";
11472
+ }
11473
+ async function createProviderSandbox(provider, sandboxName, projectRoot) {
11474
+ try {
11475
+ execSync17(`docker sandbox run --name ${sandboxName} claude ${projectRoot} -- --version`, {
11476
+ stdio: ["pipe", "pipe", "pipe"],
11477
+ timeout: 120000
11478
+ });
11479
+ } catch {}
11480
+ if (!isSandboxAlive(sandboxName)) {
11481
+ return false;
11482
+ }
11483
+ if (provider === "codex") {
11484
+ await ensureCodexInSandbox(sandboxName);
11485
+ }
11486
+ await enforceSandboxIgnore(sandboxName, projectRoot);
11487
+ return true;
11488
+ }
11624
11489
  async function ensureCodexInSandbox(sandboxName) {
11625
11490
  try {
11626
- execSync19(`docker sandbox exec ${sandboxName} which codex`, {
11491
+ execSync17(`docker sandbox exec ${sandboxName} which codex`, {
11627
11492
  stdio: ["pipe", "pipe", "pipe"],
11628
11493
  timeout: 5000
11629
11494
  });
@@ -11631,7 +11496,7 @@ async function ensureCodexInSandbox(sandboxName) {
11631
11496
  process.stderr.write(`Installing codex in sandbox...
11632
11497
  `);
11633
11498
  try {
11634
- execSync19(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11499
+ execSync17(`docker sandbox exec ${sandboxName} npm install -g @openai/codex`, { stdio: "inherit", timeout: 120000 });
11635
11500
  } catch {
11636
11501
  process.stderr.write(`${red("✗")} Failed to install codex in sandbox.
11637
11502
  `);
@@ -11640,7 +11505,7 @@ async function ensureCodexInSandbox(sandboxName) {
11640
11505
  }
11641
11506
  function isSandboxAlive(name) {
11642
11507
  try {
11643
- const output = execSync19("docker sandbox ls", {
11508
+ const output = execSync17("docker sandbox ls", {
11644
11509
  encoding: "utf-8",
11645
11510
  stdio: ["pipe", "pipe", "pipe"],
11646
11511
  timeout: 5000
@@ -11650,11 +11515,13 @@ function isSandboxAlive(name) {
11650
11515
  return false;
11651
11516
  }
11652
11517
  }
11518
+ var PROVIDERS;
11653
11519
  var init_sandbox2 = __esm(() => {
11654
11520
  init_config();
11655
11521
  init_sandbox();
11656
11522
  init_sandbox_ignore();
11657
11523
  init_terminal();
11524
+ PROVIDERS = ["claude", "codex"];
11658
11525
  });
11659
11526
 
11660
11527
  // src/cli.ts