headlesscode 1.2.0 → 1.2.2

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.
@@ -68,6 +68,7 @@ import {
68
68
  TRIVIAL_DRIFT_AHEAD,
69
69
  } from "./git-sync.js"
70
70
  import { resolveModelForMode } from "../config/mode-models.js"
71
+ import { openshellPreflight } from "../cloud/openshell-preflight.js"
71
72
  import { runPreflight, runLocalPreflight, type PreflightResult, type LocalPreflightResult } from "../llm/preflight.js"
72
73
  import { resolvePerModeEnv } from "../cli.js"
73
74
 
@@ -110,6 +111,7 @@ Options:
110
111
  --poll-interval-ms <n> Watcher poll interval (default: 5000)
111
112
  --memory-dir <path> Phase 3 memory dir for workers (passed as HEADLESSCODE_MEMORY_DIR,
112
113
  which run-worker.sh forwards as --memory-dir to each worker CLI)
114
+ --execution-provider <local|openshell> Worker runtime (default: local)
113
115
  --max-concurrent-sessions <n> Phase 6 GLOBAL cap on concurrent sessions across
114
116
  processes (default: $HEADLESSCODE_MAX_CONCURRENT_SESSIONS or 3).
115
117
  When the cap is already reached this run ABORTS with a clear
@@ -195,6 +197,7 @@ Environment:
195
197
  interface OrchestrateOptions {
196
198
  repo: string
197
199
  issues: number[]
200
+ executionProvider: "local" | "openshell"
198
201
  issuesJson?: string
199
202
  /**
200
203
  * Fix 3: file a REAL GitHub issue for every synthetic --issues-json entry
@@ -301,6 +304,7 @@ export function parseOrchestrateArgs(argv: string[]): { options: OrchestrateOpti
301
304
  const options: OrchestrateOptions = {
302
305
  repo: "",
303
306
  issues: [],
307
+ executionProvider: process.env.HEADLESSCODE_EXECUTION_PROVIDER === "openshell" ? "openshell" : "local",
304
308
  mode: process.env.ORCHESTRATOR_MODE ?? "code",
305
309
  reviewMode: "deepseek-reviewer",
306
310
  review: true,
@@ -341,6 +345,12 @@ export function parseOrchestrateArgs(argv: string[]): { options: OrchestrateOpti
341
345
  }
342
346
 
343
347
  switch (flag) {
348
+ case "--execution-provider": {
349
+ const v = next()
350
+ if (v !== "local" && v !== "openshell") return { options, error: "--execution-provider must be local or openshell" }
351
+ options.executionProvider = v
352
+ break
353
+ }
344
354
  case "--repo": {
345
355
  const v = next()
346
356
  if (v === undefined) {
@@ -1614,6 +1624,7 @@ export interface BuildSpawnEnvOptions {
1614
1624
  repo: string
1615
1625
  /** Harness mode for the workers (e.g. "code"). */
1616
1626
  mode: string
1627
+ executionProvider?: "local" | "openshell"
1617
1628
  /** An explicit --model flag value, if the caller passed one — always wins (resolveModelForMode rule 1). */
1618
1629
  explicitModel?: string
1619
1630
  memoryDir?: string
@@ -1649,6 +1660,7 @@ export function buildSpawnEnv(options: BuildSpawnEnvOptions): { env: NodeJS.Proc
1649
1660
  ...(options.env ?? process.env),
1650
1661
  TARGET_REPO: options.repo,
1651
1662
  ORCHESTRATOR_MODE: options.mode,
1663
+ HEADLESSCODE_EXECUTION_PROVIDER: options.executionProvider ?? process.env.HEADLESSCODE_EXECUTION_PROVIDER ?? "local",
1652
1664
  // The resolved worker model must reach the spawner (and the workers it
1653
1665
  // launches) — same pattern as HEADLESSCODE_PROJECT below.
1654
1666
  ...(workerModel ? { OPENROUTER_MODEL: workerModel } : {}),
@@ -2545,6 +2557,14 @@ export async function orchestrateMain(argv: string[]): Promise<number> {
2545
2557
  process.stderr.write(`headlesscode orchestrate: not a git repo: ${repo}\n`)
2546
2558
  return 2
2547
2559
  }
2560
+ if (!options.dryRun && options.executionProvider === "openshell") {
2561
+ const issue = openshellPreflight()
2562
+ if (issue) {
2563
+ process.stderr.write(`headlesscode orchestrate: OpenShell preflight failed: ${issue}\n`)
2564
+ return 2
2565
+ }
2566
+ }
2567
+ process.env.HEADLESSCODE_EXECUTION_PROVIDER = options.executionProvider
2548
2568
 
2549
2569
  let issues: SplitIssue[]
2550
2570
  try {
@@ -2852,6 +2872,7 @@ export async function orchestrateMain(argv: string[]): Promise<number> {
2852
2872
  const { env: spawnEnv, workerModel } = buildSpawnEnv({
2853
2873
  repo,
2854
2874
  mode: options.mode,
2875
+ executionProvider: options.executionProvider,
2855
2876
  explicitModel: options.model,
2856
2877
  memoryDir: options.memoryDir,
2857
2878
  maxIterations: options.maxIterations,
@@ -3032,6 +3053,7 @@ export async function orchestrateMain(argv: string[]): Promise<number> {
3032
3053
  cwd: repo,
3033
3054
  env: {
3034
3055
  ...process.env,
3056
+ HEADLESSCODE_EXECUTION_PROVIDER: options.executionProvider,
3035
3057
  HEADLESSCODE_ROOT: HARNESS_ROOT_TS,
3036
3058
  ...(options.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(options.memoryDir) } : {}),
3037
3059
  },
@@ -3221,6 +3243,7 @@ export async function orchestrateMain(argv: string[]): Promise<number> {
3221
3243
  cwd: repo,
3222
3244
  env: {
3223
3245
  ...process.env,
3246
+ HEADLESSCODE_EXECUTION_PROVIDER: options.executionProvider,
3224
3247
  HEADLESSCODE_ROOT: HARNESS_ROOT_TS,
3225
3248
  ...(options.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(options.memoryDir) } : {}),
3226
3249
  },
@@ -3376,6 +3399,7 @@ export async function orchestrateMain(argv: string[]): Promise<number> {
3376
3399
  cwd: repo,
3377
3400
  env: {
3378
3401
  ...process.env,
3402
+ HEADLESSCODE_EXECUTION_PROVIDER: options.executionProvider,
3379
3403
  HEADLESSCODE_ROOT: HARNESS_ROOT_TS,
3380
3404
  ...(options.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(options.memoryDir) } : {}),
3381
3405
  },
@@ -36,6 +36,7 @@ import { getNativeTools } from "../vendor/zoo-code/src/core/prompts/tools/native
36
36
  import { addCustomInstructions } from "../vendor/zoo-code/src/core/prompts/sections/custom-instructions.js"
37
37
  import type { ChatTool, LlmClient, SessionResult } from "../engine/types.js"
38
38
  import type { SessionBudget } from "../budget/budget.js"
39
+ import { runOpenShellSubsession } from "../cloud/openshell-subsession.js"
39
40
 
40
41
  /** Default location of the reviewer checklist, relative to the harness repo. */
41
42
  export const DEFAULT_REVIEW_PROMPT_PATH = "shared/prompts/review-mode-prompt.md"
@@ -210,6 +211,16 @@ export async function runReview(options: ReviewOptions): Promise<ReviewResult> {
210
211
  maxIterations = 200,
211
212
  budget,
212
213
  } = options
214
+ if (process.env.HEADLESSCODE_EXECUTION_PROVIDER === "openshell" && !llmClient) {
215
+ try {
216
+ return await runOpenShellSubsession<ReviewResult>("review", workspaceRoot, {
217
+ workspaceRoot, mode, model, reviewPromptPath, taskText, issues, maxIterations, budget,
218
+ })
219
+ } catch (error) {
220
+ const message = error instanceof Error ? error.message : String(error)
221
+ return { findings: [`[review session error] ${message}`], verdict: "error", summary: `Review session failed: ${message}` }
222
+ }
223
+ }
213
224
 
214
225
  // 2026-08-27: verified live — a review session couldn't find the `curlee`
215
226
  // compiler at all ("curlee runtime is not available in this environment"),
@@ -95,11 +95,22 @@ export interface ProjectMetadata {
95
95
  */
96
96
  export function resolveProjectIdentity(workspaceRoot: string): ProjectIdentity {
97
97
  const root = path.resolve(workspaceRoot)
98
+ // OpenShell runs an independent Git clone so its writable workspace does not
99
+ // expose host repository metadata. Preserve the original project's store key
100
+ // without mounting or reading the original repository path in the sandbox.
101
+ const identityRoot = process.env.HEADLESSCODE_PROJECT_IDENTITY_ROOT?.trim()
102
+ if (identityRoot && path.isAbsolute(identityRoot)) return { keySource: path.resolve(identityRoot), kind: "git" }
103
+ // Sandboxed workers may set these to a private Git directory so ordinary
104
+ // commits cannot touch shared host metadata. Project identity must still
105
+ // resolve through the workspace's real .git pointer and shared common dir.
106
+ const gitEnv = { ...process.env }
107
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE"]) delete gitEnv[key]
98
108
  // 1. Git repo → `--git-common-dir` (worktree-aware: resolves to the main
99
109
  // repo's .git from inside a worktree), parent = identity source.
100
110
  try {
101
111
  const out = execFileSync("git", ["rev-parse", "--git-common-dir"], {
102
112
  cwd: root,
113
+ env: gitEnv,
103
114
  encoding: "utf-8",
104
115
  timeout: 10_000,
105
116
  stdio: ["ignore", "pipe", "ignore"],
package/src/qa/qa.ts CHANGED
@@ -49,6 +49,7 @@ import { addCustomInstructions } from "../vendor/zoo-code/src/core/prompts/secti
49
49
  import type { ChatTool, LlmClient, SessionResult } from "../engine/types.js"
50
50
  import type { MemoryStore } from "../memory/types.js"
51
51
  import type { SessionBudget } from "../budget/budget.js"
52
+ import { runOpenShellSubsession } from "../cloud/openshell-subsession.js"
52
53
 
53
54
  /** Default mode slug used for QA sessions (the qa-agent mode). */
54
55
  export const DEFAULT_QA_MODE = "qa-agent"
@@ -246,6 +247,17 @@ export async function runQa(options: RunQaOptions): Promise<QaResult> {
246
247
  memory = null,
247
248
  project,
248
249
  } = options
250
+ if (process.env.HEADLESSCODE_EXECUTION_PROVIDER === "openshell" && !llmClient) {
251
+ try {
252
+ return await runOpenShellSubsession<QaResult>("qa", workspaceRoot, {
253
+ workspaceRoot, mode, model, baseUrl, maxIterations, budget, project,
254
+ ...(options.taskText ? { taskText: options.taskText } : {}),
255
+ })
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error)
258
+ return { verdict: "error", evidence: "", summary: `QA session failed: ${message}` }
259
+ }
260
+ }
249
261
 
250
262
  // Detect whether the target repo actually defines the requested mode. If
251
263
  // not, fall back to the generic checklist as a system prompt override.
@@ -32,6 +32,7 @@ import * as path from "node:path"
32
32
 
33
33
  import { Logger } from "../engine/logger.js"
34
34
  import { watchIssues, type SweepResult } from "./watch.js"
35
+ import { openshellPreflight } from "../cloud/openshell-preflight.js"
35
36
 
36
37
  const WATCH_USAGE = `headlesscode watch — Phase 5 GitHub issue watcher
37
38
 
@@ -61,6 +62,7 @@ Options:
61
62
  <repo>/.worktrees/.watcher-state.json)
62
63
  --mode <slug> Harness mode for workers (default: code; forwarded
63
64
  to the spawner as ORCHESTRATOR_MODE)
65
+ --execution-provider <local|openshell> Worker runtime (default: local)
64
66
  --qa Pass-through: record that a QA round should follow
65
67
  each batch (see docs/phase5-issue-watcher.md)
66
68
  --deploy Pass-through: record that a deploy gate should follow
@@ -92,6 +94,7 @@ export interface WatchCliOptions {
92
94
  maxConcurrentSessions: number
93
95
  stateFile?: string
94
96
  mode: string
97
+ executionProvider: "local" | "openshell"
95
98
  qa: boolean
96
99
  deploy: boolean
97
100
  memoryDir?: string
@@ -110,6 +113,7 @@ export function parseWatchArgs(argv: string[]): { options: WatchCliOptions; erro
110
113
  maxPerSweep: 5,
111
114
  maxConcurrentSessions: 3,
112
115
  mode: "code",
116
+ executionProvider: process.env.HEADLESSCODE_EXECUTION_PROVIDER === "openshell" ? "openshell" : "local",
113
117
  qa: false,
114
118
  deploy: false,
115
119
  dryRun: false,
@@ -135,6 +139,12 @@ export function parseWatchArgs(argv: string[]): { options: WatchCliOptions; erro
135
139
  }
136
140
 
137
141
  switch (flag) {
142
+ case "--execution-provider": {
143
+ const v = next()
144
+ if (v !== "local" && v !== "openshell") return { options, error: "--execution-provider must be local or openshell" }
145
+ options.executionProvider = v
146
+ break
147
+ }
138
148
  case "--owner": {
139
149
  const v = next()
140
150
  if (v === undefined) {
@@ -300,6 +310,13 @@ export async function watchMain(argv: string[]): Promise<number> {
300
310
  return 2
301
311
  }
302
312
  }
313
+ if (!options.dryRun && options.executionProvider === "openshell") {
314
+ const issue = openshellPreflight()
315
+ if (issue) {
316
+ process.stderr.write(`headlesscode watch: OpenShell preflight failed: ${issue}\n`)
317
+ return 2
318
+ }
319
+ }
303
320
 
304
321
  const ghRepo = options.ghRepo ?? path.basename(repoRoot)
305
322
  const logger = new Logger({ level: "info" })
@@ -328,6 +345,7 @@ export async function watchMain(argv: string[]): Promise<number> {
328
345
  retryFailed: options.retryFailed,
329
346
  dryRun: options.dryRun,
330
347
  mode: options.mode,
348
+ executionProvider: options.executionProvider,
331
349
  memoryDir: options.memoryDir,
332
350
  qa: options.qa,
333
351
  deploy: options.deploy,
@@ -129,6 +129,7 @@ export interface WatcherConfig {
129
129
  spawn?: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
130
130
  /** Harness mode for workers (forwarded to the spawner as ORCHESTRATOR_MODE). */
131
131
  mode?: string
132
+ executionProvider?: "local" | "openshell"
132
133
  /** Memory dir for workers (forwarded to the spawner as HEADLESSCODE_MEMORY_DIR). */
133
134
  memoryDir?: string
134
135
  /** Phase 4 pass-through: recorded on the batch entry (see docs). */
@@ -480,7 +481,7 @@ function batchId(): string {
480
481
  * with cwd = the target repo so `git rev-parse --show-toplevel` resolves).
481
482
  * No worktree creation happens in TS; the bash script remains the spawner.
482
483
  */
483
- export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir">): SpawnResult {
484
+ export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir" | "executionProvider">): SpawnResult {
484
485
  const issues: SplitIssue[] = [
485
486
  { number: batch.issue.number, title: batch.issue.title, body: batch.issue.body },
486
487
  ]
@@ -511,6 +512,7 @@ export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mod
511
512
  ...process.env,
512
513
  TARGET_REPO: batch.repoRoot,
513
514
  ORCHESTRATOR_MODE: config.mode ?? "code",
515
+ HEADLESSCODE_EXECUTION_PROVIDER: config.executionProvider ?? process.env.HEADLESSCODE_EXECUTION_PROVIDER ?? "local",
514
516
  HEADLESSCODE_PROJECT: path.basename(batch.repoRoot),
515
517
  ...(config.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(config.memoryDir) } : {}),
516
518
  },