headlesscode 1.0.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.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * DockerSessionProvider — a real `CloudProvider` implementation that runs each
3
+ * harness session in its own isolated Docker container.
4
+ *
5
+ * WHY (see docs/multi-tenant-hosting-design.md §1 for the full comparison):
6
+ * the multi-tenant security requirement — one tenant's session (buggy,
7
+ * malicious, or just aggressive) must not be able to see, affect, or exhaust
8
+ * resources for another tenant's session or the host — is not satisfied by
9
+ * the local process model's directory-level separation. Docker gives us a
10
+ * real kernel-enforced boundary per session:
11
+ *
12
+ * - cgroup resource limits (CPU/memory/pids): a runaway session is
13
+ * throttled/oom-killed by the kernel, never able to starve the host or
14
+ * sibling sessions;
15
+ * - per-container network namespace with no published ports: a session's
16
+ * sockets are unreachable from the host and from other sessions;
17
+ * - non-root user (host uid:gid — no root inside the container) +
18
+ * read-only rootfs (tmpfs /tmp): reduces the blast radius of a
19
+ * compromised harness without needing a rootless daemon.
20
+ *
21
+ * The container runs the harness INSIDE the boundary — `runHarness` is
22
+ * `docker exec`, not a host subprocess. `teardown` is `docker rm -f` and
23
+ * VERIFIES the container is actually gone (a leaked container per session is
24
+ * a real cost/security problem at scale — the test suite enumerates
25
+ * containers before/after rather than trusting an exit code).
26
+ *
27
+ * The harness repo (this checkout, containing src/cli.ts + node_modules) is
28
+ * mounted read-only at /harness; the target repo/worktree is mounted at
29
+ * /workspace. No image build per session; the session's own writes land in
30
+ * the workspace mount, so results survive container teardown. Secrets ride
31
+ * in via `CloudSessionRequest.env` (per-session container env, never
32
+ * written to disk/logs) — see the design doc §2.
33
+ */
34
+
35
+ import { spawnSync } from "node:child_process"
36
+ import * as fs from "node:fs"
37
+ import * as path from "node:path"
38
+ import { fileURLToPath } from "node:url"
39
+
40
+ import { writeTaskFiles } from "../orchestrator/cli.js"
41
+ import type { CloudProvider, CloudSessionRequest, CommandResult, SessionHandle } from "./provider.js"
42
+
43
+ /** The harness repo root (parent of src/cloud) — same pattern as the CLI. */
44
+ const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
45
+
46
+ export const DEFAULT_DOCKER_IMAGE = "node:22-bookworm-slim"
47
+
48
+ /** Default per-session CPU cap (fraction of a core, Docker `--cpus`). */
49
+ export const DEFAULT_DOCKER_CPUS = 2
50
+ /** Default per-session memory cap, MB (Docker `--memory`). */
51
+ export const DEFAULT_DOCKER_MEMORY_MB = 2048
52
+ /** Default per-session process cap (Docker `--pids-limit`). */
53
+ export const DEFAULT_DOCKER_PIDS_LIMIT = 512
54
+
55
+ export interface DockerSessionProviderOptions {
56
+ /** Image to run the session in (default: node:22-bookworm-slim). */
57
+ image?: string
58
+ /** Per-session CPU cap (fraction of a core). */
59
+ cpus?: number
60
+ /** Per-session memory cap, MB. */
61
+ memoryMb?: number
62
+ /** Per-session process count cap. */
63
+ pidsLimit?: number
64
+ /** Container name prefix (default: "hcls"). */
65
+ namePrefix?: string
66
+ /**
67
+ * Injectable `docker` runner (tests). Returns { exitCode, output } exactly
68
+ * like the local provider's `run` seam; default shells out to the real
69
+ * `docker` binary.
70
+ */
71
+ run?: (command: string) => CommandResult
72
+ /** Injectable `docker inspect` result parser (tests): container state → readiness. */
73
+ isReady?: (inspectJson: string) => boolean
74
+ /** Injectable `docker ps -a` filter listing (tests): returns container names/lines. */
75
+ listContainers?: () => string[]
76
+ }
77
+
78
+ /**
79
+ * DockerSessionProvider — one isolated container per session behind the
80
+ * existing `CloudProvider` interface. The orchestration layer calls the SAME
81
+ * five methods it calls on LocalProcessProvider; only the execution boundary
82
+ * changes (docs/multi-tenant-hosting-design.md §3).
83
+ */
84
+ export class DockerSessionProvider implements CloudProvider {
85
+ readonly name = "docker"
86
+ private readonly image: string
87
+ private readonly cpus: number
88
+ private readonly memoryMb: number
89
+ private readonly pidsLimit: number
90
+ private readonly namePrefix: string
91
+ private readonly run: (command: string) => CommandResult
92
+ private readonly isReady: (inspectJson: string) => boolean
93
+ private readonly listContainers: () => string[]
94
+
95
+ constructor(options: DockerSessionProviderOptions = {}) {
96
+ this.image = options.image ?? dockerEnv("HEADLESSCODE_DOCKER_IMAGE", DEFAULT_DOCKER_IMAGE)
97
+ this.cpus = numberEnv("HEADLESSCODE_DOCKER_CPUS", options.cpus ?? DEFAULT_DOCKER_CPUS)
98
+ this.memoryMb = numberEnv("HEADLESSCODE_DOCKER_MEMORY_MB", options.memoryMb ?? DEFAULT_DOCKER_MEMORY_MB)
99
+ this.pidsLimit = numberEnv("HEADLESSCODE_DOCKER_PIDS_LIMIT", options.pidsLimit ?? DEFAULT_DOCKER_PIDS_LIMIT)
100
+ this.namePrefix = options.namePrefix ?? "hcls"
101
+ this.run =
102
+ options.run ??
103
+ ((command) => {
104
+ const res = spawnSync("sh", ["-c", command], {
105
+ encoding: "utf-8",
106
+ maxBuffer: 64 * 1024 * 1024,
107
+ })
108
+ const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim()
109
+ return { exitCode: res.status ?? 1, output }
110
+ })
111
+ this.isReady = options.isReady ?? ((json) => {
112
+ try {
113
+ const parsed = JSON.parse(json) as Array<{ State?: { Running?: boolean } }>
114
+ return parsed.length > 0 && parsed[0].State?.Running === true
115
+ } catch {
116
+ return false
117
+ }
118
+ })
119
+ this.listContainers = options.listContainers ?? (() => {
120
+ const res = this.run("docker ps -a --format '{{.Names}}'")
121
+ if (res.exitCode !== 0) {
122
+ return []
123
+ }
124
+ return res.output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
125
+ })
126
+ }
127
+
128
+ /** The container name for a handle (id is the session/worktree name). */
129
+ containerName(handle: SessionHandle): string {
130
+ return `${this.namePrefix}-${handle.id}`
131
+ }
132
+
133
+ /** The tenant workspace mount path (must match what spawnWorktreeSession used). */
134
+ workspacePath(handle: SessionHandle): string {
135
+ if (!handle.address) {
136
+ throw new Error(`DockerSessionProvider: no workspace path on handle ${handle.id}`)
137
+ }
138
+ return handle.address
139
+ }
140
+
141
+ async spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle> {
142
+ const { repo, issue, worktreeSpec } = request
143
+ const wtPath = path.join(path.resolve(repo), ".worktrees", worktreeSpec.name)
144
+
145
+ // 1. Task file under <repo>/plans/parallel-tasks/ — same builder the
146
+ // local provider / watcher use. The workspace mount carries it into
147
+ // the container as <workspace>/plans/parallel-tasks/<taskFile>.
148
+ writeTaskFiles(path.resolve(repo), [worktreeSpec], [issue])
149
+
150
+ // 2. Create + start the isolated container. Deliberately NO published
151
+ // ports (per-container network namespace; a session's sockets are
152
+ // unreachable from the host and from sibling sessions) and no
153
+ // --network host. Non-root user + read-only rootfs with tmpfs /tmp:
154
+ // the harness's own writes go to the workspace mount; everything
155
+ // else is immutable from inside the session.
156
+ const name = this.containerName({ id: worktreeSpec.name, provider: this.name })
157
+ // Two separate commands (newline-joined): a pre-clean of a stale
158
+ // container from a crashed run, then `docker create` (NOT `docker run
159
+ // -d ... tail -f /dev/null`). Using `docker run -d` with a
160
+ // long-running container process attached the container's stdio to
161
+ // OUR stdout pipe, which spawnSync never sees close (the container's
162
+ // tail holds the fd) → the spawn call hangs. `docker create` returns
163
+ // the id without executing; `docker start` detaches. Neither holds
164
+ // our stdio.
165
+ const envEntries = Object.entries(request.env ?? {}).filter(([k]) => k !== "TARGET_REPO")
166
+ const envFlags = [
167
+ `-e TARGET_REPO=/workspace`,
168
+ ...envEntries.map(([k, v]) => `-e ${k}=${v}`),
169
+ ]
170
+ // The pre-clean and the `docker create` must be SEPARATE shell
171
+ // statements (newline), but the docker flags themselves must be ONE
172
+ // space-joined line — a newline between flags turns each flag into a
173
+ // separate shell command (`docker create` alone with no args errors).
174
+ // A space join between the pre-clean and create would parse as
175
+ // `docker rm ... || true docker create ...` — `true` swallows the
176
+ // create and the container is never made.
177
+ const createFlags = [
178
+ `docker create`,
179
+ `--name ${name}`,
180
+ `--cpus ${this.cpus}`,
181
+ `--memory ${this.memoryMb}m`,
182
+ `--memory-swap ${this.memoryMb}m`,
183
+ `--pids-limit ${this.pidsLimit}`,
184
+ // Run as the HOST uid:gid (not the image's `node` user): the
185
+ // workspace is a host-owned bind mount, and the harness must be
186
+ // able to write .harness.* markers + edit files there. The
187
+ // isolation boundary is the container namespace/cgroup separation,
188
+ // not the uid — a non-root uid inside a container shares the host
189
+ // kernel but is still fully separated from sibling sessions.
190
+ `--user ${uidGid()}`,
191
+ `--read-only`,
192
+ `--tmpfs /tmp:rw,noexec,nosuid,size=256m`,
193
+ `-v ${path.resolve(repo)}:/workspace`,
194
+ `-v ${HARNESS_ROOT}:/harness:ro`,
195
+ `-w /workspace`,
196
+ ...envFlags,
197
+ this.image,
198
+ `tail -f /dev/null`,
199
+ ].join(" ")
200
+ const createCmd = [`docker rm -f ${name} 2>/dev/null || true`, createFlags].join("\n")
201
+ const createResult = this.run(createCmd)
202
+ if (createResult.exitCode !== 0) {
203
+ // Best-effort cleanup so a failed spawn never leaks a container.
204
+ this.run(`docker rm -f ${name} 2>/dev/null || true`)
205
+ throw new Error(`DockerSessionProvider: create failed (exit ${createResult.exitCode}): ${createResult.output}`)
206
+ }
207
+ // docker create prints the container id (possibly preceded by warning
208
+ // lines, e.g. the swap-limit cgroup warning seen on this host). Take
209
+ // the last whitespace-separated token that looks like a 64-hex id;
210
+ // fall back to the container name we chose (equally valid for inspect).
211
+ const containerId = [...createResult.output.trim().split(/\s+/)].reverse().find((t) => /^[0-9a-f]{64}$/.test(t)) ?? name
212
+
213
+ const startResult = this.run(`docker start ${containerId}`)
214
+ if (startResult.exitCode !== 0) {
215
+ this.run(`docker rm -f ${name} 2>/dev/null || true`)
216
+ throw new Error(`DockerSessionProvider: start failed (exit ${startResult.exitCode}): ${startResult.output}`)
217
+ }
218
+
219
+ return { id: worktreeSpec.name, provider: this.name, address: wtPath, containerId }
220
+ }
221
+
222
+ async waitReady(handle: SessionHandle): Promise<void> {
223
+ const name = this.containerName(handle)
224
+ const deadline = Date.now() + 60_000
225
+ for (;;) {
226
+ const inspect = this.run(`docker inspect ${name}`)
227
+ if (inspect.exitCode === 0 && this.isReady(inspect.output)) {
228
+ return
229
+ }
230
+ if (Date.now() > deadline) {
231
+ throw new Error(`DockerSessionProvider: ${handle.id} not ready within 60s (last: ${inspect.output.slice(0, 200)})`)
232
+ }
233
+ await sleep(250)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Run the harness command INSIDE the container (docker exec), not on the
239
+ * host. The command is wrapped in single quotes for the OUTER shell so the
240
+ * inner `bash -lc` receives it as ONE argument (JSON.stringify produces
241
+ * double-quoted strings that the outer shell re-splits on spaces — broken
242
+ * for any command with spaces, as the harness commands are). Single quotes
243
+ * inside the command are shell-escaped ('"'"' — the standard idiom).
244
+ */
245
+ async runHarness(handle: SessionHandle, cmd: string): Promise<CommandResult> {
246
+ const name = this.containerName(handle)
247
+ const quoted = `'${cmd.replace(/'/g, `'\\''`)}'`
248
+ const result = this.run(`docker exec ${name} bash -lc ${quoted}`)
249
+ return result
250
+ }
251
+
252
+ async collectResults(handle: SessionHandle): Promise<Record<string, unknown>> {
253
+ const wtPath = this.workspacePath(handle)
254
+ const exitCode = readFileInt(path.join(wtPath, ".harness.exit"))
255
+ const done = fs.existsSync(path.join(wtPath, ".harness.done"))
256
+ const summary = tailFile(path.join(wtPath, "harness.log"), 40)
257
+ return { exitCode, done, summary, worktree: wtPath }
258
+ }
259
+
260
+ /**
261
+ * Remove the container AND verify it is actually gone — a leaked container
262
+ * is a real cost/security problem. Docker's "removal of container ... is
263
+ * already in progress" (a benign race between `docker rm` calls on the
264
+ * same container, hit when parallel sessions teardown close together) is
265
+ * treated as success: the removal IS in progress, and the enumeration
266
+ * below is what actually proves the container is gone.
267
+ */
268
+ async teardown(handle: SessionHandle): Promise<void> {
269
+ const name = this.containerName(handle)
270
+ const rm = this.run(`docker rm -f ${name}`)
271
+ if (rm.exitCode !== 0 && !rm.output.includes("No such container") && !rm.output.includes("is already in progress")) {
272
+ throw new Error(`DockerSessionProvider: teardown rm failed for ${name} (exit ${rm.exitCode}): ${rm.output}`)
273
+ }
274
+ // Prove removal by enumeration, not by trusting the exit code.
275
+ // `docker rm` is async in the daemon: a just-issued rm can still list
276
+ // the container for a few ms, so retry the enumeration briefly before
277
+ // declaring a leak — an eventual rm failure (auth, I/O) IS a real leak
278
+ // and still fails this loop.
279
+ const deadline = Date.now() + 5_000
280
+ for (;;) {
281
+ const remaining = this.listContainers().filter((n) => n === name)
282
+ if (remaining.length === 0) {
283
+ return
284
+ }
285
+ if (Date.now() > deadline) {
286
+ throw new Error(`DockerSessionProvider: teardown did not remove ${name} (still listed by docker ps -a)`)
287
+ }
288
+ await sleep(100)
289
+ }
290
+ }
291
+ }
292
+
293
+ // ─── Small helpers ───────────────────────────────────────────────────────────
294
+
295
+ /** Host uid:gid as "uid:gid" for --user (see the spawn comment: the workspace mount must stay writable by the harness). */
296
+ function uidGid(): string {
297
+ return `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`
298
+ }
299
+
300
+ function dockerEnv(name: string, fallback: string): string {
301
+ const v = process.env[name]
302
+ return v && v.trim() !== "" ? v : fallback
303
+ }
304
+
305
+ function numberEnv(name: string, fallback: number): number {
306
+ const v = process.env[name]
307
+ if (v === undefined || v.trim() === "") {
308
+ return fallback
309
+ }
310
+ const n = Number(v)
311
+ return Number.isFinite(n) && n > 0 ? n : fallback
312
+ }
313
+
314
+ function readFileInt(file: string): number | undefined {
315
+ try {
316
+ const n = Number(fs.readFileSync(file, "utf-8").trim())
317
+ return Number.isFinite(n) ? n : undefined
318
+ } catch {
319
+ return undefined
320
+ }
321
+ }
322
+
323
+ function tailFile(file: string, maxLines: number): string {
324
+ try {
325
+ const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter((l) => l.trim() !== "")
326
+ return lines.slice(-maxLines).join("\n")
327
+ } catch {
328
+ return ""
329
+ }
330
+ }
331
+
332
+ function sleep(ms: number): Promise<void> {
333
+ return new Promise((resolve) => setTimeout(resolve, ms))
334
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Phase 6 — ephemeral compute abstraction (spec 6.1) + the local reference
3
+ * implementation.
4
+ *
5
+ * The goal of 6.1 is "container/VM per issue → run harness → tear down". That
6
+ * slots in BEHIND this interface: a cloud provider implements the same
7
+ * five-method lifecycle as `LocalProcessProvider`, but its
8
+ * `spawnWorktreeSession` creates a container/VM (Hetzner + Docker in the
9
+ * 6.2 evaluation) instead of a local git worktree, `runHarness` executes the
10
+ * harness inside it, and `teardown` deletes it. The orchestration layer
11
+ * (orchestrate/watch) keeps calling the SAME methods either way.
12
+ *
13
+ * The interface is deliberately small — the cloud-specific details (image,
14
+ * volume, network) are implementation concerns; the harness only needs
15
+ * "make me a session, wait until it's usable, run this command in it, get the
16
+ * results, delete it".
17
+ */
18
+
19
+ import { spawnSync } from "node:child_process"
20
+ import * as fs from "node:fs"
21
+ import * as path from "node:path"
22
+ import { fileURLToPath } from "node:url"
23
+
24
+ import { writeTaskFiles } from "../orchestrator/cli.js"
25
+ import type { WorktreeSpec } from "../orchestrator/split.js"
26
+
27
+ /** The worktree/issue spec a provider turns into an isolated session. */
28
+ export interface CloudSessionRequest {
29
+ /** Local clone of the target repo (worktrees are created under it). */
30
+ repo: string
31
+ /** The issue driving this session (for task-file generation + labels). */
32
+ issue: { number: number; title: string; body?: string }
33
+ /** The worktree group spec (name, issues, taskFile) from splitIssues. */
34
+ worktreeSpec: WorktreeSpec
35
+ /** Extra env to pass to the harness (e.g. ORCHESTRATOR_MODE). */
36
+ env?: Record<string, string>
37
+ }
38
+
39
+ /** Opaque handle returned by spawnWorktreeSession; used by the other calls. */
40
+ export interface SessionHandle {
41
+ /** Unique id within the provider (e.g. the worktree/container name). */
42
+ id: string
43
+ /** Provider name (e.g. "local", "docker", "hetzner-docker"). */
44
+ provider: string
45
+ /** Where the session lives (worktree path / container address). */
46
+ address?: string
47
+ /** Provider-specific opaque identifier (e.g. container id) — optional. */
48
+ containerId?: string
49
+ }
50
+
51
+ export interface CommandResult {
52
+ exitCode: number
53
+ /** Combined stdout+stderr. */
54
+ output: string
55
+ }
56
+
57
+ /**
58
+ * The lifecycle contract every compute backend implements. All methods are
59
+ * async (cloud operations are slow/network-bound even when the local
60
+ * implementation is synchronous under the hood).
61
+ */
62
+ export interface CloudProvider {
63
+ readonly name: string
64
+
65
+ /** Create the isolated session (worktree/container/VM) for one issue. */
66
+ spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle>
67
+
68
+ /** Wait until the session is ready to run the harness (no-op locally). */
69
+ waitReady(handle: SessionHandle): Promise<void>
70
+
71
+ /** Run the harness command inside the session; returns its exit code+output. */
72
+ runHarness(handle: SessionHandle, cmd: string): Promise<CommandResult>
73
+
74
+ /** Gather the session's results (exit code, done marker, log summary). */
75
+ collectResults(handle: SessionHandle): Promise<Record<string, unknown>>
76
+
77
+ /** Tear the session down (delete worktree/container/VM). */
78
+ teardown(handle: SessionHandle): Promise<void>
79
+ }
80
+
81
+ // ─── Local reference implementation ─────────────────────────────────────────
82
+ //
83
+ // `LocalProcessProvider` is the CURRENT behavior behind the interface:
84
+ // spawn a local git worktree (scripts/spawn-parallel-worktrees.sh) + a local
85
+ // harness process (scripts/run-worker.sh). It exists so the abstraction is
86
+ // real and tested locally — cloud providers (see the HetznerDockerProvider
87
+ // sketch in docs/phase6-cloud.md) implement the same contract.
88
+
89
+ /** The harness repo root (parent of src/cloud) — same pattern as the CLI. */
90
+ const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
91
+
92
+ export interface LocalProcessProviderOptions {
93
+ /** Override the harness repo root (default: this repo). */
94
+ harnessRoot?: string
95
+ /**
96
+ * Dry-run: record every command instead of executing it (no worktrees, no
97
+ * processes). Used by tests + `--dry-run`-style callers.
98
+ */
99
+ dryRun?: boolean
100
+ /** Override the spawn script (default: scripts/spawn-parallel-worktrees.sh). */
101
+ spawnScript?: string
102
+ /** Override the run-worker script (default: scripts/run-worker.sh). */
103
+ runWorkerScript?: string
104
+ /** Injectable command runner (tests): returns a fake result instead of spawnSync. */
105
+ run?: (command: string, cwd: string, env: Record<string, string>) => CommandResult
106
+ /** Injectable readiness probe (tests): replaces the .harness.pid poll. */
107
+ waitForReady?: (worktree: string, timeoutMs: number) => Promise<void>
108
+ }
109
+
110
+ export class LocalProcessProvider implements CloudProvider {
111
+ readonly name = "local"
112
+ private readonly harnessRoot: string
113
+ private readonly dryRun: boolean
114
+ private readonly spawnScript: string
115
+ private readonly runWorkerScript: string
116
+ private readonly run: (command: string, cwd: string, env: Record<string, string>) => CommandResult
117
+ private readonly waitForReady?: (worktree: string, timeoutMs: number) => Promise<void>
118
+
119
+ constructor(options: LocalProcessProviderOptions = {}) {
120
+ this.harnessRoot = options.harnessRoot ?? HARNESS_ROOT
121
+ this.dryRun = options.dryRun ?? false
122
+ this.spawnScript = options.spawnScript ?? path.join(this.harnessRoot, "scripts", "spawn-parallel-worktrees.sh")
123
+ this.runWorkerScript = options.runWorkerScript ?? path.join(this.harnessRoot, "scripts", "run-worker.sh")
124
+ this.run =
125
+ options.run ??
126
+ ((command, cwd, env) => {
127
+ const res = spawnSync("bash", ["-c", command], {
128
+ cwd,
129
+ env: { ...process.env, ...env },
130
+ encoding: "utf-8",
131
+ })
132
+ const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim()
133
+ if (res.status !== 0) {
134
+ return { exitCode: res.status ?? 1, output }
135
+ }
136
+ return { exitCode: 0, output }
137
+ })
138
+ this.waitForReady = options.waitForReady
139
+ }
140
+
141
+ /** The expected worktree path for a handle (repo/.worktrees/<id>). */
142
+ worktreePath(handle: SessionHandle): string {
143
+ if (handle.address) {
144
+ return handle.address
145
+ }
146
+ throw new Error(`LocalProcessProvider: no address on handle ${handle.id}`)
147
+ }
148
+
149
+ async spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle> {
150
+ const { repo, issue, worktreeSpec } = request
151
+ const wtPath = path.join(path.resolve(repo), ".worktrees", worktreeSpec.name)
152
+
153
+ // 1. Task file under <repo>/plans/parallel-tasks/ (the spawner copies
154
+ // it into the worktree) — same builder the watcher/orchestrator use.
155
+ if (!this.dryRun) {
156
+ writeTaskFiles(path.resolve(repo), [worktreeSpec], [issue])
157
+ }
158
+
159
+ // 2. Spawn via the EXISTING bash spawner (name:offset:taskfile triples,
160
+ // cwd = repo so `git rev-parse --show-toplevel` resolves).
161
+ const triples = `${worktreeSpec.name}:0:plans/parallel-tasks/${worktreeSpec.taskFile}`
162
+ const command = `bash ${this.spawnScript} ${triples}`
163
+ const result = this.run(command, path.resolve(repo), {
164
+ TARGET_REPO: path.resolve(repo),
165
+ ORCHESTRATOR_MODE: request.env?.ORCHESTRATOR_MODE ?? "code",
166
+ // Operator knobs (spawner no-index guardrail + local-explore phase)
167
+ // flow from the ambient process env; an injected `run` only sees
168
+ // this explicit env, so forward them alongside TARGET_REPO.
169
+ ...(process.env.ALLOW_UNINDEXED ? { ALLOW_UNINDEXED: process.env.ALLOW_UNINDEXED } : {}),
170
+ ...(process.env.HEADLESSCODE_AUTO_INDEX ? { HEADLESSCODE_AUTO_INDEX: process.env.HEADLESSCODE_AUTO_INDEX } : {}),
171
+ ...(process.env.HEADLESSCODE_LOCAL_EXPLORE ? { HEADLESSCODE_LOCAL_EXPLORE: process.env.HEADLESSCODE_LOCAL_EXPLORE } : {}),
172
+ ...(process.env.HEADLESSCODE_LOCAL_EXPLORE_MODEL
173
+ ? { HEADLESSCODE_LOCAL_EXPLORE_MODEL: process.env.HEADLESSCODE_LOCAL_EXPLORE_MODEL }
174
+ : {}),
175
+ ...(process.env.HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS
176
+ ? { HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS: process.env.HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS }
177
+ : {}),
178
+ ...(process.env.HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS
179
+ ? { HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS: process.env.HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS }
180
+ : {}),
181
+ })
182
+ if (result.exitCode !== 0) {
183
+ throw new Error(`LocalProcessProvider: spawn failed (exit ${result.exitCode}): ${result.output}`)
184
+ }
185
+
186
+ return { id: worktreeSpec.name, provider: this.name, address: wtPath }
187
+ }
188
+
189
+ async waitReady(handle: SessionHandle): Promise<void> {
190
+ if (this.dryRun) {
191
+ return
192
+ }
193
+ if (this.waitForReady) {
194
+ await this.waitForReady(this.worktreePath(handle), 30_000)
195
+ return
196
+ }
197
+ // Default readiness probe: the worktree exists AND a worker pid file is
198
+ // present (i.e. runHarness has been started). Poll up to 30s.
199
+ const deadline = Date.now() + 30_000
200
+ for (;;) {
201
+ try {
202
+ const pid = fs.readFileSync(path.join(this.worktreePath(handle), ".harness.pid"), "utf-8").trim()
203
+ if (/^\d+$/.test(pid)) {
204
+ return
205
+ }
206
+ } catch {
207
+ // not ready yet
208
+ }
209
+ if (Date.now() > deadline) {
210
+ throw new Error(`LocalProcessProvider: ${handle.id} not ready within 30s`)
211
+ }
212
+ await sleep(200)
213
+ }
214
+ }
215
+
216
+ /** Run the harness in the session (default: scripts/run-worker.sh). */
217
+ async runHarness(handle: SessionHandle, cmd?: string): Promise<CommandResult> {
218
+ const wtPath = this.worktreePath(handle)
219
+ // The spawner copies the task file into each worktree as
220
+ // ORCHESTRATOR_TASK.md, so that is the default run-worker task. Callers
221
+ // can pass any command (e.g. a cloud harness command) instead.
222
+ const command =
223
+ cmd ?? `bash ${this.runWorkerScript} '${wtPath}' ORCHESTRATOR_TASK.md --mode '${process.env.ORCHESTRATOR_MODE ?? "code"}'`
224
+ if (this.dryRun) {
225
+ return { exitCode: 0, output: `[dry-run] ${command}` }
226
+ }
227
+ const result = this.run(command, wtPath, { TARGET_REPO: this.harnessRoot })
228
+ return result
229
+ }
230
+
231
+ async collectResults(handle: SessionHandle): Promise<Record<string, unknown>> {
232
+ const wtPath = this.worktreePath(handle)
233
+ const exitCode = readFileInt(path.join(wtPath, ".harness.exit"))
234
+ const done = fs.existsSync(path.join(wtPath, ".harness.done"))
235
+ const summary = tailFile(path.join(wtPath, "harness.log"), 40)
236
+ return { exitCode, done, summary, worktree: wtPath }
237
+ }
238
+
239
+ async teardown(handle: SessionHandle): Promise<void> {
240
+ if (this.dryRun) {
241
+ return
242
+ }
243
+ const wtPath = this.worktreePath(handle)
244
+ // git worktree remove detaches the worktree cleanly; force in case of
245
+ // uncommitted harness artifacts. Address is <repo>/.worktrees/<name>.
246
+ const repo = path.dirname(path.dirname(wtPath))
247
+ this.run(`git worktree remove '${wtPath}' --force`, repo, {})
248
+ }
249
+ }
250
+
251
+ /** Read a file's contents as an integer (undefined on missing/invalid). */
252
+ function readFileInt(file: string): number | undefined {
253
+ try {
254
+ const n = Number(fs.readFileSync(file, "utf-8").trim())
255
+ return Number.isFinite(n) ? n : undefined
256
+ } catch {
257
+ return undefined
258
+ }
259
+ }
260
+
261
+ /** Tail a file's last `maxLines` non-empty lines ("" when unreadable). */
262
+ function tailFile(file: string, maxLines: number): string {
263
+ try {
264
+ const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter((l) => l.trim() !== "")
265
+ return lines.slice(-maxLines).join("\n")
266
+ } catch {
267
+ return ""
268
+ }
269
+ }
270
+
271
+ function sleep(ms: number): Promise<void> {
272
+ return new Promise((resolve) => setTimeout(resolve, ms))
273
+ }
274
+
275
+ // ─── Hetzner evaluation hook (superseded by the Docker provider) ─────────────
276
+ //
277
+ // `HetznerDockerProvider` was deliberately a SKETCH ONLY — it documented the
278
+ // shape a real 6.1 cloud provider would take (per docs/phase6-cloud.md) but
279
+ // was NOT implemented against live Hetzner: no API credentials, no Docker
280
+ // SDK, no network.
281
+ //
282
+ // It is now SUPERSEDED by the real `DockerSessionProvider` in
283
+ // src/cloud/docker-provider.ts (docs/multi-tenant-hosting-design.md §5): a
284
+ // working container-per-session implementation of the same `CloudProvider`
285
+ // interface, proven against a real local Docker daemon. This sketch is kept
286
+ // as a backward-compatible marker for the Phase 6.2 evaluation history and
287
+ // for the existing provider contract test; the "reason" now points at the
288
+ // replacement instead of describing an unimplemented future.
289
+ export interface HetznerDockerProviderSketch {
290
+ readonly name: string
291
+ readonly implemented: false
292
+ reason: string
293
+ }
294
+
295
+ export const hetznerDockerProviderSketch: HetznerDockerProviderSketch = {
296
+ name: "hetzner-docker",
297
+ implemented: false,
298
+ reason:
299
+ "Evaluation artifact (Phase 6, spec 6.2) superseded by DockerSessionProvider (src/cloud/docker-provider.ts) — see docs/multi-tenant-hosting-design.md.",
300
+ }