headlesscode 1.2.1 → 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.
@@ -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
  },