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
package/src/cli.ts ADDED
@@ -0,0 +1,1535 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * headlesscode — Phase 1 headless CLI entry.
4
+ *
5
+ * Exit codes:
6
+ * 0 success
7
+ * 1 task failed / max iterations / bounded failure
8
+ * 2 usage or configuration error (bad args, missing API key)
9
+ */
10
+
11
+ import * as fs from "node:fs"
12
+ import * as path from "node:path"
13
+
14
+ import { LocalMemoryStore } from "./memory/local.js"
15
+ import type { MemoryStore } from "./memory/types.js"
16
+ import { OpenRouterClient, parseReasoningEffort } from "./llm/openrouter.js"
17
+ import { OllamaClient } from "./llm/ollama.js"
18
+ import type { LlmClient } from "./engine/types.js"
19
+ import { HeadlessSession } from "./engine/loop.js"
20
+ import { chownWorktreeWorkspace } from "./dashboard/session-launch.js"
21
+ import { isLocalExploreEnabled } from "./engine/local-explore.js"
22
+ import { Logger } from "./engine/logger.js"
23
+ import {
24
+ appendCodeIntelTools,
25
+ appendDescribeImageTool,
26
+ buildSystemPrompt,
27
+ loadCustomModes,
28
+ selectToolsForMode,
29
+ } from "./engine/prompt.js"
30
+ import { orchestrateMain } from "./orchestrator/cli.js"
31
+ import { watchMain } from "./watcher/cli.js"
32
+ import { checkpointsMain } from "./checkpoints/cli.js"
33
+ import { dashboardMain } from "./dashboard/cli.js"
34
+ import { trendMain } from "./dashboard/trend-cli.js"
35
+ import { indexMain } from "./codesearch/cli.js"
36
+ import { codemapMain } from "./codemap/cli.js"
37
+ import { initMain } from "./init/cli.js"
38
+ import { provisionMain, pushPrMain } from "./github/cli.js"
39
+ import { decisionProxyMain } from "./decision-proxy/cli.js"
40
+ import { migrateMain } from "./migrate/cli.js"
41
+ import { projectsMain } from "./projects/cli.js"
42
+ import { analyzeCliMain } from "./orchestrator/analyze-cli.js"
43
+ import { costHistoryCliMain } from "./orchestrator/cost-history-cli.js"
44
+ import { resolvePermissions, type PermissionsConfig } from "./permissions/config.js"
45
+ import { resolveModelForMode, resolveReasoningEffortForMode } from "./config/mode-models.js"
46
+
47
+ const VERSION = "0.1.0"
48
+
49
+ interface CliOptions {
50
+ mode: string
51
+ task?: string
52
+ taskFile?: string
53
+ workspace?: string
54
+ /** Explicit session id override (set by the dashboard's session-launch endpoint). */
55
+ sessionId?: string
56
+ /** See HeadlessSessionConfig.requireArtifactPathPattern (loop.ts) — a research-harness primitive. */
57
+ requireArtifactPath?: string
58
+ /** See HeadlessSessionConfig.requireArtifactMinCitations (loop.ts). */
59
+ requireArtifactMinCitations?: number
60
+ /** See HeadlessSessionConfig.requireArtifactSections (loop.ts) — pipe-separated on the CLI. */
61
+ requireArtifactSections?: string[]
62
+ model?: string
63
+ maxIterations?: number
64
+ consecutiveErrorLimit?: number
65
+ /**
66
+ * Recursive task decomposition (`new_task`): hard cap on how deep a single
67
+ * root session may delegate (default DEFAULT_MAX_RECURSION_DEPTH = 2;
68
+ * also $HEADLESSCODE_MAX_RECURSION_DEPTH). See src/engine/loop.ts.
69
+ */
70
+ maxRecursionDepth?: number
71
+ /**
72
+ * Recursive task decomposition (`new_task`): a child's default
73
+ * maxIterations as a fraction of the parent's REMAINING iterations
74
+ * (default DEFAULT_CHILD_ITERATION_FRACTION = 0.5; also
75
+ * $HEADLESSCODE_CHILD_ITERATION_FRACTION). See src/engine/loop.ts.
76
+ */
77
+ childIterationFraction?: number
78
+ /**
79
+ * Hard cap on tokens the model may generate per LLM call (default
80
+ * DEFAULT_MAX_TOKENS = 32768; also $HEADLESSCODE_MAX_TOKENS). See
81
+ * src/engine/loop.ts.
82
+ */
83
+ maxTokens?: number
84
+ /** Sliding-window history cap, in messages (also see DEFAULT_WINDOW_SIZE in engine/loop.ts). */
85
+ windowSize?: number
86
+ /**
87
+ * Phase 3 context condensation: the model's real context window in
88
+ * tokens (default: live OpenRouter lookup, else
89
+ * DEFAULT_CONTEXT_WINDOW_TOKENS in src/engine/condense.ts).
90
+ */
91
+ contextWindowTokens?: number
92
+ /**
93
+ * Sampling temperature sent on every LLM call (default: 0 — fully
94
+ * deterministic/greedy). Exposed 2026-08-27 while investigating whether
95
+ * greedy decoding was a factor in local-model task failures — there was
96
+ * previously no way to override this at all.
97
+ */
98
+ temperature?: number
99
+ /**
100
+ * Phase 3 context condensation: fraction of the context window at which
101
+ * the oldest turns are condensed (default
102
+ * DEFAULT_CONDENSE_THRESHOLD_FRACTION in src/engine/condense.ts).
103
+ */
104
+ condenseThreshold?: number
105
+ /**
106
+ * Async/background condensation: fraction of the context window at which
107
+ * the condensation LLM call fires EARLY in the background (default
108
+ * DEFAULT_CONDENSE_EARLY_FIRE_FRACTION in src/engine/condense.ts). Must be
109
+ * below the hard `condenseThreshold`; when it isn't, the async path is off
110
+ * and only the synchronous hard-threshold path runs.
111
+ */
112
+ condenseEarlyFire?: number
113
+ /**
114
+ * Phase 3 context condensation: model id for the condensation call
115
+ * (default: the session model, or the `_condensation` key in
116
+ * .headlesscode/mode-models.json).
117
+ */
118
+ condenseModel?: string
119
+ /** Per-LLM-call abort timeout, ms (default: DEFAULT_LLM_TIMEOUT_MS in engine/loop.ts). */
120
+ llmTimeoutMs?: number
121
+ /**
122
+ * Streaming-and-reasoning: opt-in SSE streaming (default OFF — the
123
+ * blocking request path is unchanged). Also settable via
124
+ * $HEADLESSCODE_STREAM ("1"/"true"/"yes"/"on").
125
+ */
126
+ stream: boolean
127
+ /** Phase 6: per-session cost cap, USD (also $HEADLESSCODE_MAX_COST_USD). */
128
+ maxCostUsd?: number
129
+ /** Phase 6: per-session duration cap, ms (also $HEADLESSCODE_MAX_DURATION_MS). */
130
+ maxDurationMs?: number
131
+ logFile?: string
132
+ memoryDir?: string
133
+ noMemory: boolean
134
+ dryRun: boolean
135
+ version: boolean
136
+ help: boolean
137
+ /** Checkpoints on by default; --no-checkpoints opts out. */
138
+ noCheckpoints: boolean
139
+ checkpointDir?: string
140
+ /** Decision escalation timeout, ms (also $HEADLESSCODE_DECISION_TIMEOUT_MS). */
141
+ decisionTimeoutMs?: number
142
+ /** Pause/resume max duration, ms (also $HEADLESSCODE_MAX_PAUSE_MS). */
143
+ maxPauseMs?: number
144
+ /** Permissions: comma-separated command prefixes allowed to run (also $HEADLESSCODE_ALLOWED_COMMANDS). */
145
+ allowedCommands?: string
146
+ /** Permissions: comma-separated command prefixes never allowed (also $HEADLESSCODE_DENIED_COMMANDS). */
147
+ deniedCommands?: string
148
+ /** Permissions: comma-separated protected-file globs (also $HEADLESSCODE_PROTECTED_FILES). */
149
+ protectedFiles?: string
150
+ /** Permissions escape hatch: allow writes to protected files (default: false). */
151
+ allowProtectedWrites: boolean
152
+ /**
153
+ * OPT-IN local exploration phase (default OFF): a bounded, read-only local
154
+ * Ollama pass runs before the cloud model's first turn and folds its
155
+ * findings into a labeled synthetic message. Also settable via
156
+ * $HEADLESSCODE_LOCAL_EXPLORE. Experimental — see plans/local-explore-phase-experiment.md.
157
+ */
158
+ localExplore: boolean
159
+ /**
160
+ * switch_mode (plans/switch-mode-headless.md): opt-in auto-approval of
161
+ * in-place mode switches — OFF by default because the approval gate is the
162
+ * security boundary that keeps a restricted mode from silently granting
163
+ * itself a broader mode's permissions. Also settable via
164
+ * $HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH.
165
+ */
166
+ autoApproveModeSwitch: boolean
167
+ /** switch_mode: hard cap on total in-place mode switches per session (default 5). */
168
+ maxModeSwitches?: number
169
+ }
170
+
171
+ const USAGE = `headlesscode — headless coding-agent harness (Phase 1 engine)
172
+
173
+ Usage:
174
+ headlesscode --task "<task text>" [options]
175
+ headlesscode --task-file <path> [options]
176
+ headlesscode --dry-run [options] # build system prompt + validate config, no LLM call
177
+
178
+ Subcommands:
179
+ headlesscode orchestrate --repo <path> --issue <n>... [--qa] [--deploy] [--dry-run]
180
+ Run a full parallel orchestration round
181
+ (split → spawn → review → QA → deploy gate). See
182
+ \`npx tsx src/cli.ts orchestrate --help\` for full options.
183
+ headlesscode orchestrate status --repo <path> [--wait] [--timeout-ms <n>] [--json]
184
+ Print a compact per-group status of a round, or block inside one
185
+ call until every group is terminal (--wait) — replaces
186
+ hand-rolled poll loops over the state file / harness.log. See
187
+ \`npx tsx src/cli.ts orchestrate status --help\` for full options.
188
+ headlesscode orchestrate stop --repo <path> --group <name> [--group <name> ...]
189
+ Stop a group's worker COMPLETELY (whole process tree via
190
+ scripts/stop-worker.sh — issue #20) and mark it needs-human. See
191
+ \`npx tsx src/cli.ts orchestrate stop --help\` for full options.
192
+ headlesscode watch --owner <o> --repo <r> --label <label> [--run-once]
193
+ Poll GitHub for labeled issues and fan out into orchestration
194
+ batches (idempotent). See
195
+ \`npx tsx src/cli.ts watch --help\` for full options.
196
+ headlesscode checkpoints --workspace <path> [list | restore <hash> | diff <hash>]
197
+ List/restore/diff shadow-git checkpoints for a workspace. See
198
+ \`npx tsx src/cli.ts checkpoints --help\` for full options.
199
+ headlesscode dashboard [--port 4390] [--repo <path>]
200
+ Serve a local cost/token dashboard with a live per-session
201
+ event feed and pause/resume control (Phase 3: no longer purely
202
+ read-only — see src/dashboard/server.ts). See
203
+ \`npx tsx src/cli.ts dashboard --help\` for full options.
204
+ headlesscode trend --repo <path> [--repo <path> ...] [--port 4460]
205
+ Serve a local, auto-refreshing page comparing multiple repos'
206
+ cost-efficiency trends side by side (wasted-session tracking,
207
+ cost/iteration vs. round-size correlation, rework rate). See
208
+ \`npx tsx src/cli.ts trend --help\` for full options.
209
+ headlesscode index --workspace <path> [--model <id>] [--embedding-backend <b>]
210
+ Build/refresh the codebase semantic-search index for a workspace
211
+ (used by the codebase_search tool). A separate, explicit step —
212
+ never auto-triggered mid-session. --embedding-backend picks
213
+ openrouter (default), ollama (local), or airunner (local
214
+ AIRunner server). See \`npx tsx src/cli.ts index --help\` for
215
+ full options.
216
+ headlesscode codemap --workspace <path> [--force] [--watch] [--interval-ms <n>]
217
+ Build/refresh a project's deterministic module/import map
218
+ (codemap.json/codemap.lock/codemap.html in the central project
219
+ store). No LLM anywhere in the pipeline; regeneration is
220
+ fingerprint-aware (an unchanged repo writes nothing). --watch
221
+ turns it into a long-running poll loop. See
222
+ \`npx tsx src/cli.ts codemap --help\` for full options.
223
+ headlesscode init --workspace <path> [--skip-index] [--skip-codemap]
224
+ Register a new project in one step: resolves the central data
225
+ dir, detects the stack(s) (drives per-session instruction
226
+ selection), ensures .gitignore excludes .headlesscode/, then
227
+ builds the codesearch index + codemap. See
228
+ \`npx tsx src/cli.ts init --help\` for full options.
229
+ headlesscode provision --installation-id <id> --owner <o> --repo <r> --target <dir>
230
+ Clone a GitHub repo the App installation can access into a local
231
+ dir (token stripped from the remote URL), ready as a
232
+ --workspace value. Also --list-repos <id>. See
233
+ \`npx tsx src/cli.ts provision --help\` for full options.
234
+ headlesscode push-pr --installation-id <id> --owner <o> --repo <r> \\
235
+ --local-dir <path> --branch <name> --title <title> --body <text> [--base <branch>]
236
+ Push a local branch to a GitHub repo with the App installation
237
+ token (token scrubbed from .git/config immediately), then open
238
+ a pull request from it — never to the repo's default branch,
239
+ never auto-merged. See
240
+ \`npx tsx src/cli.ts push-pr --help\` for full options.
241
+ headlesscode decision-proxy --workspace <path> [--task <text>] [--task-file <path>]
242
+ OPT-IN (HEADLESSCODE_DECISION_PROXY=1) LLM stand-in for the
243
+ human on ask_followup_question: watches <path> for
244
+ .harness.needs-decision and answers via .harness.decision-answer,
245
+ grounded in the session's ORIGINAL task text. Writes nothing
246
+ when uncertain/errored — the existing timeout fallback fires
247
+ as today. See \`npx tsx src/cli.ts decision-proxy --help\` for
248
+ full options.
249
+ headlesscode migrate [--workspace <path>]
250
+ One-time central-store migrations: moves global shared
251
+ instructions (~/.roo/) and the checkpoint store
252
+ (~/.headlesscode/checkpoints) into ~/.local/share/headlesscode/,
253
+ plus a workspace's legacy .headlesscode/ content (index,
254
+ mode-models.json, permissions.json) into the central project
255
+ store. Idempotent; each move is verified before the source is
256
+ removed. See \`npx tsx src/cli.ts migrate --help\`.
257
+ headlesscode projects list [--json] [--registered-only] [--stale] [--size]
258
+ Enumerate the central per-project store as a table (or JSON),
259
+ optionally filtered to registered/stale entries, with an
260
+ opt-in size column. See
261
+ \`npx tsx src/cli.ts projects list --help\` for full options.
262
+ headlesscode projects prune [--dry-run] [--yes] [--include-registered]
263
+ Reclaim orphaned store entries (missing paths that were never
264
+ registered, plus pre-registry no-project.json litter). Never
265
+ deletes a registered project without --include-registered; the
266
+ escape hatch for a deleted repo / unmounted drive. See
267
+ \`npx tsx src/cli.ts projects prune --help\` for full options.
268
+
269
+ Options:
270
+ --mode <slug> Mode to run in (built-in or from .roomodes). Default: code
271
+ --task <text> The task description for the agent
272
+ --task-file <path> Read the task from a file (relative to workspace)
273
+ --workspace <root> Workspace root (default: $HEADLESSCODE_WORKSPACE_ROOT or cwd)
274
+ --session-id <id> Explicit session id override (default: a fresh UUID).
275
+ Used by the dashboard's session-launch endpoint so
276
+ the browser can open the session's event view
277
+ immediately; rarely needed from a terminal.
278
+ --model <id> OpenRouter model id (default: $OPENROUTER_MODEL or deepseek/deepseek-v4-flash-0731)
279
+ --max-iterations <n> Loop iteration cap (default: 250 — see DEFAULT_MAX_ITERATIONS
280
+ in src/engine/loop.ts)
281
+ --max-recursion-depth <n> Recursive task decomposition (new_task): hard cap
282
+ on how deep one root session may delegate
283
+ (default: 2 = root → child → grandchild; a
284
+ deeper new_task call is refused as a normal
285
+ tool error). Default:
286
+ $HEADLESSCODE_MAX_RECURSION_DEPTH
287
+ --child-iteration-fraction <f> Recursive task decomposition: a child's
288
+ default maxIterations as a fraction of the
289
+ parent's REMAINING iterations (default: 0.5,
290
+ floor 3 — a child never exceeds what its parent
291
+ has left). Default:
292
+ $HEADLESSCODE_CHILD_ITERATION_FRACTION
293
+ --max-tokens <n> Hard cap on tokens the model may generate per LLM
294
+ call (default: 32768 — see DEFAULT_MAX_TOKENS in
295
+ src/engine/loop.ts). Default: $HEADLESSCODE_MAX_TOKENS
296
+ --consecutive-error-limit <n> Consecutive mistakes before giving up (default: 3,
297
+ or 6 for the local code-mode backend — see cli.ts's
298
+ DEFAULT_LOCAL_CONSECUTIVE_ERROR_LIMIT)
299
+ --window-size <n> Sliding-window history cap, in messages, before the
300
+ oldest are evicted (default: 300; see DEFAULT_WINDOW_SIZE
301
+ in src/engine/loop.ts for why)
302
+ --temperature <f> Sampling temperature, 0-2 (default: 0 — fully deterministic/
303
+ greedy; every LLM call uses this, no per-role override)
304
+ --context-window <n> Phase 3 context condensation: the model's real context
305
+ window in tokens (default: live OpenRouter lookup, else
306
+ 128000, or 40960 for the local code-mode backend — see
307
+ DEFAULT_LOCAL_CONTEXT_WINDOW_TOKENS in cli.ts and
308
+ DEFAULT_CONTEXT_WINDOW_TOKENS in src/engine/condense.ts;
309
+ override with $HEADLESSCODE_CODE_MODE_CONTEXT_WINDOW)
310
+ --condense-threshold <f> Phase 3 context condensation: fraction of the context
311
+ window at which the oldest turns are condensed into one
312
+ summary (default: 0.75, or 0.92 for the local code-mode
313
+ backend — see cli.ts's LOCAL_CONDENSE_THRESHOLD_FRACTION;
314
+ must be between 0 and 1)
315
+ --condense-early-fire <f> Async condensation: fraction of the context window at
316
+ which the condensation LLM call fires EARLY, in the
317
+ background against a snapshot, while the loop keeps
318
+ running (default: 0.6, provisional; DISABLED for the
319
+ local code-mode backend — a second concurrent call
320
+ against a single locally-loaded model has no latency to
321
+ hide and only adds GPU contention — must be below
322
+ --condense-threshold or the async path is off)
323
+ --condense-model <id> Phase 3 context condensation: model id for the
324
+ condensation call (default: the session model, or the
325
+ _condensation key in .headlesscode/mode-models.json)
326
+ --llm-timeout-ms <n> Per-LLM-call abort timeout, ms (default: 300000 / 5 min
327
+ — reasoning models can take a while on a heavy turn)
328
+ --log-file <path> Also append structured logs to this file
329
+ --memory-dir <path> Store project memory (facts + session summaries) under
330
+ <path>. Enables Phase 3 memory. Default (when enabled):
331
+ $HEADLESSCODE_MEMORY_DIR or <workspace>/.headlesscode/memory
332
+ --no-memory Explicitly disable memory even if HEADLESSCODE_MEMORY_DIR is set
333
+ --max-cost-usd <n> Phase 6: per-session cost cap in USD (decimal, e.g. 0.05).
334
+ Default: $HEADLESSCODE_MAX_COST_USD; off when neither is set
335
+ --max-duration-ms <n> Phase 6: per-session wall-clock cap in ms. Default:
336
+ $HEADLESSCODE_MAX_DURATION_MS; off when neither is set.
337
+ When a cap trips the session aborts with reason "budget"
338
+ --dry-run Build the system prompt, validate .roomodes/rules loading,
339
+ then exit without calling the LLM (no API key needed)
340
+ --no-checkpoints Disable shadow-git checkpoints (on by default; see
341
+ \`headlesscode checkpoints --help\`). No effect for
342
+ read-only sessions (reviewer/QA), which never checkpoint
343
+ --checkpoint-dir <path> Shadow-git storage root override (default:
344
+ ~/.headlesscode/checkpoints — MUST be outside the
345
+ workspace; see docs/checkpoints.md)
346
+ --decision-timeout-ms <n> How long ask_followup_question blocks waiting for
347
+ a human/orchestrator answer before falling back
348
+ to autonomous decision, ms (default 1800000 / 30
349
+ min). Default: $HEADLESSCODE_DECISION_TIMEOUT_MS
350
+ --max-pause-ms <n> Max duration a dashboard-initiated pause may hold
351
+ the loop before it auto-resumes, ms (default
352
+ 7200000 / 2h — matches the orchestrator's stall
353
+ guard). Default: $HEADLESSCODE_MAX_PAUSE_MS
354
+ --allowed-commands <list> Comma-separated command prefixes the agent may run.
355
+ Default: $HEADLESSCODE_ALLOWED_COMMANDS, else
356
+ .headlesscode/permissions.json, else empty (=
357
+ allow everything except --denied-commands)
358
+ --denied-commands <list> Comma-separated command prefixes that are ALWAYS
359
+ refused (deny wins over allow; dangerous shell
360
+ substitutions are always blocked regardless).
361
+ Default: $HEADLESSCODE_DENIED_COMMANDS, else
362
+ .headlesscode/permissions.json, else empty
363
+ --protected-files <list> Comma-separated glob patterns of files the agent may
364
+ not write. Default: $HEADLESSCODE_PROTECTED_FILES,
365
+ else .headlesscode/permissions.json, else
366
+ ".env,.env.*,*.pem,*.key,id_rsa*"
367
+ --allow-protected-writes Escape hatch: permit writes to protected files
368
+ (default: OFF). Also settable via
369
+ "allowProtectedWrites": true in
370
+ .headlesscode/permissions.json
371
+ --stream Opt-in SSE streaming (streaming-and-reasoning):
372
+ stream token/reasoning deltas and emit
373
+ llm_stream_chunk events for live-typing view.
374
+ Default: OFF (blocking requests unchanged).
375
+ Also settable via $HEADLESSCODE_STREAM
376
+ --local-explore OPT-IN local exploration phase (experimental,
377
+ default OFF): run a bounded, strictly read-only
378
+ local Ollama pass (qwen3.5:9b — read_file +
379
+ list_files only) before the cloud model's first
380
+ turn and fold its findings into the cloud
381
+ context as a labeled synthetic message. Fails
382
+ open to cloud-only on any local error. Also
383
+ settable via $HEADLESSCODE_LOCAL_EXPLORE
384
+ --auto-approve-mode-switch switch_mode: auto-approve in-place mode switches
385
+ WITHOUT escalating to a human/orchestrator.
386
+ OFF by default — the approval gate is the
387
+ security boundary that keeps a restricted mode
388
+ (e.g. architect, read+md-only) from silently
389
+ granting itself a broader mode's edit
390
+ permissions. Also settable via
391
+ $HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH
392
+ --max-mode-switches <n> switch_mode: hard cap on total in-place mode
393
+ switches per session (default: 5 — see
394
+ DEFAULT_MAX_MODE_SWITCHES in src/engine/loop.ts).
395
+ Also settable via
396
+ $HEADLESSCODE_MAX_MODE_SWITCHES
397
+ --version Print version and exit
398
+ --help Show this help and exit
399
+
400
+ Environment:
401
+ HEADLESSCODE_OPENROUTER_API_KEY Required (except --dry-run)
402
+ OPENROUTER_MODEL Default model id
403
+ OPENROUTER_HTTP_REFERER Optional HTTP-Referer header
404
+ OPENROUTER_APP_TITLE Optional X-Title header
405
+ HEADLESSCODE_WORKSPACE_ROOT Default workspace root
406
+ HEADLESSCODE_MEMORY_DIR Default memory dir (memory enabled when set)
407
+ HEADLESSCODE_PROJECT Project scope for memory (default: workspace basename)
408
+ HEADLESSCODE_ALLOWED_COMMANDS Default --allowed-commands (comma-separated)
409
+ HEADLESSCODE_DENIED_COMMANDS Default --denied-commands (comma-separated)
410
+ HEADLESSCODE_PROTECTED_FILES Default --protected-files (comma-separated globs)
411
+ HEADLESSCODE_ALLOW_PROTECTED_WRITES Allow protected-file writes ("1"/"true")
412
+ HEADLESSCODE_MAX_RECURSION_DEPTH Default --max-recursion-depth (positive int)
413
+ HEADLESSCODE_CHILD_ITERATION_FRACTION Default --child-iteration-fraction (0 < f <= 1)
414
+ HEADLESSCODE_MAX_TOKENS Default --max-tokens (positive int)
415
+ HEADLESSCODE_STREAM Opt-in SSE streaming ("1"/"true"/"yes"/"on")
416
+ HEADLESSCODE_LOCAL_EXPLORE Opt-in local exploration phase ("1"/"true")
417
+ HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH Auto-approve switch_mode calls ("1"/"true"/"yes"/"on")
418
+ HEADLESSCODE_MAX_MODE_SWITCHES switch_mode: hard cap on total in-place
419
+ mode switches per session (positive int)
420
+ HEADLESSCODE_LOCAL_EXPLORE_MODEL Local model (default qwen3.5:9b)
421
+ HEADLESSCODE_LOCAL_EXPLORE_MAX_ITERATIONS Iteration cap (default 15)
422
+ HEADLESSCODE_LOCAL_EXPLORE_CONTEXT_TOKENS Context-token budget (default 131072)
423
+ HEADLESSCODE_LOCAL_EXPLORE_TIMEOUT_MS Per-call timeout ms (default 120000)
424
+ HEADLESSCODE_OLLAMA_URL Ollama base URL (default http://localhost:11434)
425
+ HEADLESSCODE_DECISION_PROXY Opt-in decision-proxy agent ("1"/"true") — an
426
+ LLM stand-in for the human on
427
+ ask_followup_question (headlesscode
428
+ decision-proxy subcommand; see
429
+ plans/decision-proxy-agent.md)
430
+ `
431
+
432
+ export function parseArgs(argv: string[]): { options: CliOptions; error?: string } {
433
+ const options: CliOptions = {
434
+ mode: "code",
435
+ noMemory: false,
436
+ dryRun: false,
437
+ version: false,
438
+ help: false,
439
+ noCheckpoints: false,
440
+ allowProtectedWrites: false,
441
+ stream: false,
442
+ localExplore: false,
443
+ autoApproveModeSwitch: false,
444
+ }
445
+
446
+ for (let i = 0; i < argv.length; i++) {
447
+ const arg = argv[i]
448
+ const eq = arg.indexOf("=")
449
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
450
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
451
+ const next = (): string | undefined => {
452
+ if (inlineValue !== undefined) {
453
+ return inlineValue
454
+ }
455
+ const v = argv[i + 1]
456
+ if (v === undefined || v.startsWith("--")) {
457
+ return undefined
458
+ }
459
+ i++
460
+ return v
461
+ }
462
+
463
+ switch (flag) {
464
+ case "--mode":
465
+ case "--task":
466
+ case "--task-file":
467
+ case "--workspace":
468
+ case "--model":
469
+ case "--log-file":
470
+ case "--session-id":
471
+ case "--require-artifact-path":
472
+ case "--require-artifact-sections": {
473
+ const value = next()
474
+ if (value === undefined) {
475
+ return { options, error: `Missing value for ${flag}` }
476
+ }
477
+ switch (flag) {
478
+ case "--mode":
479
+ options.mode = value
480
+ break
481
+ case "--task":
482
+ options.task = value
483
+ break
484
+ case "--task-file":
485
+ options.taskFile = value
486
+ break
487
+ case "--workspace":
488
+ options.workspace = value
489
+ break
490
+ case "--model":
491
+ options.model = value
492
+ break
493
+ case "--log-file":
494
+ options.logFile = value
495
+ break
496
+ case "--session-id":
497
+ options.sessionId = value
498
+ break
499
+ case "--require-artifact-path":
500
+ options.requireArtifactPath = value
501
+ break
502
+ case "--require-artifact-sections":
503
+ options.requireArtifactSections = value
504
+ .split("|")
505
+ .map((s) => s.trim())
506
+ .filter(Boolean)
507
+ break
508
+ }
509
+ break
510
+ }
511
+ case "--max-iterations":
512
+ case "--consecutive-error-limit":
513
+ case "--window-size":
514
+ case "--context-window":
515
+ case "--llm-timeout-ms":
516
+ case "--max-recursion-depth":
517
+ case "--max-mode-switches":
518
+ case "--max-tokens":
519
+ case "--require-artifact-min-citations": {
520
+ const value = next()
521
+ const num = value === undefined ? Number.NaN : Number(value)
522
+ if (!Number.isInteger(num) || num <= 0) {
523
+ return { options, error: `${flag} requires a positive integer` }
524
+ }
525
+ if (flag === "--max-iterations") {
526
+ options.maxIterations = num
527
+ } else if (flag === "--consecutive-error-limit") {
528
+ options.consecutiveErrorLimit = num
529
+ } else if (flag === "--window-size") {
530
+ options.windowSize = num
531
+ } else if (flag === "--context-window") {
532
+ options.contextWindowTokens = num
533
+ } else if (flag === "--max-recursion-depth") {
534
+ options.maxRecursionDepth = num
535
+ } else if (flag === "--max-mode-switches") {
536
+ options.maxModeSwitches = num
537
+ } else if (flag === "--max-tokens") {
538
+ options.maxTokens = num
539
+ } else if (flag === "--require-artifact-min-citations") {
540
+ options.requireArtifactMinCitations = num
541
+ } else {
542
+ options.llmTimeoutMs = num
543
+ }
544
+ break
545
+ }
546
+ case "--child-iteration-fraction": {
547
+ const value = next()
548
+ const num = value === undefined ? Number.NaN : Number(value)
549
+ if (!Number.isFinite(num) || num <= 0 || num > 1) {
550
+ return { options, error: "--child-iteration-fraction requires a fraction between 0 and 1 (e.g. 0.5)" }
551
+ }
552
+ options.childIterationFraction = num
553
+ break
554
+ }
555
+ case "--temperature": {
556
+ const value = next()
557
+ const num = value === undefined ? Number.NaN : Number(value)
558
+ if (!Number.isFinite(num) || num < 0 || num > 2) {
559
+ return { options, error: "--temperature requires a number between 0 and 2 (e.g. 0.3)" }
560
+ }
561
+ options.temperature = num
562
+ break
563
+ }
564
+ case "--condense-threshold": {
565
+ const value = next()
566
+ const num = value === undefined ? Number.NaN : Number(value)
567
+ if (!Number.isFinite(num) || num <= 0 || num >= 1) {
568
+ return { options, error: "--condense-threshold requires a fraction between 0 and 1 (e.g. 0.75)" }
569
+ }
570
+ options.condenseThreshold = num
571
+ break
572
+ }
573
+ case "--condense-early-fire": {
574
+ const value = next()
575
+ const num = value === undefined ? Number.NaN : Number(value)
576
+ if (!Number.isFinite(num) || num <= 0 || num >= 1) {
577
+ return { options, error: "--condense-early-fire requires a fraction between 0 and 1 (e.g. 0.6)" }
578
+ }
579
+ options.condenseEarlyFire = num
580
+ break
581
+ }
582
+ case "--condense-model": {
583
+ const value = next()
584
+ if (value === undefined) {
585
+ return { options, error: "Missing value for --condense-model" }
586
+ }
587
+ options.condenseModel = value
588
+ break
589
+ }
590
+ case "--max-cost-usd": {
591
+ const value = next()
592
+ const num = value === undefined ? Number.NaN : Number(value)
593
+ if (!Number.isFinite(num) || num <= 0) {
594
+ return { options, error: "--max-cost-usd requires a positive number (USD, decimal allowed)" }
595
+ }
596
+ options.maxCostUsd = num
597
+ break
598
+ }
599
+ case "--max-duration-ms": {
600
+ const value = next()
601
+ const num = value === undefined ? Number.NaN : Number(value)
602
+ if (!Number.isInteger(num) || num <= 0) {
603
+ return { options, error: "--max-duration-ms requires a positive integer" }
604
+ }
605
+ options.maxDurationMs = num
606
+ break
607
+ }
608
+ case "--decision-timeout-ms": {
609
+ const value = next()
610
+ const num = value === undefined ? Number.NaN : Number(value)
611
+ if (!Number.isInteger(num) || num <= 0) {
612
+ return { options, error: "--decision-timeout-ms requires a positive integer" }
613
+ }
614
+ options.decisionTimeoutMs = num
615
+ break
616
+ }
617
+ case "--max-pause-ms": {
618
+ const value = next()
619
+ const num = value === undefined ? Number.NaN : Number(value)
620
+ if (!Number.isInteger(num) || num <= 0) {
621
+ return { options, error: "--max-pause-ms requires a positive integer" }
622
+ }
623
+ options.maxPauseMs = num
624
+ break
625
+ }
626
+ case "--memory-dir":
627
+ case "--allowed-commands":
628
+ case "--denied-commands":
629
+ case "--protected-files": {
630
+ const value = next()
631
+ if (value === undefined) {
632
+ return { options, error: `Missing value for ${flag}` }
633
+ }
634
+ switch (flag) {
635
+ case "--memory-dir":
636
+ options.memoryDir = value
637
+ break
638
+ case "--allowed-commands":
639
+ options.allowedCommands = value
640
+ break
641
+ case "--denied-commands":
642
+ options.deniedCommands = value
643
+ break
644
+ case "--protected-files":
645
+ options.protectedFiles = value
646
+ break
647
+ }
648
+ break
649
+ }
650
+ case "--allow-protected-writes":
651
+ options.allowProtectedWrites = true
652
+ break
653
+ case "--stream":
654
+ options.stream = true
655
+ break
656
+ case "--local-explore":
657
+ options.localExplore = true
658
+ break
659
+ case "--auto-approve-mode-switch":
660
+ options.autoApproveModeSwitch = true
661
+ break
662
+ case "--checkpoint-dir": {
663
+ const value = next()
664
+ if (value === undefined) {
665
+ return { options, error: "Missing value for --checkpoint-dir" }
666
+ }
667
+ options.checkpointDir = value
668
+ break
669
+ }
670
+ case "--no-memory":
671
+ options.noMemory = true
672
+ break
673
+ case "--no-checkpoints":
674
+ options.noCheckpoints = true
675
+ break
676
+ case "--dry-run":
677
+ options.dryRun = true
678
+ break
679
+ case "--version":
680
+ options.version = true
681
+ break
682
+ case "--help":
683
+ case "-h":
684
+ options.help = true
685
+ break
686
+ default:
687
+ return { options, error: `Unknown argument: ${arg}` }
688
+ }
689
+ }
690
+
691
+ return { options }
692
+ }
693
+
694
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
695
+ // Phase 2 subcommand: `headlesscode orchestrate ...` — delegates to the
696
+ // orchestrator module (split → spawn → watch → review). Keeps the Phase 1
697
+ // run path untouched.
698
+ if (argv[0] === "orchestrate") {
699
+ return orchestrateMain(argv.slice(1))
700
+ }
701
+
702
+ // Issue #148 subcommand: `headlesscode pipeline ...` — run the stage-
703
+ // isolated research→filing pipeline (each stage a fresh session taking the
704
+ // prior stage's artifact as input).
705
+ if (argv[0] === "pipeline") {
706
+ const { pipelineMain } = await import("./orchestrator/cli.js")
707
+ return pipelineMain(argv.slice(1))
708
+ }
709
+
710
+ // Phase 5 subcommand: `headlesscode watch ...` — GitHub issue watcher
711
+ // (poll label → split → spawn → durable idempotency state).
712
+ if (argv[0] === "watch") {
713
+ return watchMain(argv.slice(1))
714
+ }
715
+
716
+ // Checkpoints subcommand: `headlesscode checkpoints ...` — list/restore/diff
717
+ // shadow-git checkpoints for a workspace (see src/checkpoints/).
718
+ if (argv[0] === "checkpoints") {
719
+ return checkpointsMain(argv.slice(1))
720
+ }
721
+
722
+ // Cost/token dashboard subcommand: `headlesscode dashboard ...` — local
723
+ // HTML dashboard with live per-session events + pause/resume control
724
+ // (Phase 3 — see src/dashboard/; no longer purely read-only).
725
+ if (argv[0] === "dashboard") {
726
+ return dashboardMain(argv.slice(1))
727
+ }
728
+
729
+ // Cross-repo cost-efficiency trend subcommand: `headlesscode trend ...` —
730
+ // a local, auto-refreshing page comparing multiple repos' cost-history
731
+ // side by side (see src/dashboard/trend.ts). Distinct from `dashboard`'s
732
+ // single-repo "Cost history" section: reads live on every request, no
733
+ // cached snapshot.
734
+ if (argv[0] === "trend") {
735
+ return trendMain(argv.slice(1))
736
+ }
737
+
738
+ // Codebase semantic-search index subcommand: `headlesscode index ...` —
739
+ // build/refresh <workspace>/.headlesscode/codesearch/index.jsonl for the
740
+ // codebase_search tool (see src/codesearch/). A separate, EXPLICIT step —
741
+ // never auto-triggered mid-session (it costs real money and takes time).
742
+ if (argv[0] === "index") {
743
+ return indexMain(argv.slice(1))
744
+ }
745
+
746
+ // Deterministic per-project codemap subcommand: `headlesscode codemap ...` —
747
+ // generate the module/import map (codemap.json/codemap.lock/codemap.html)
748
+ // into the central project store; `--watch` becomes a long-running poll
749
+ // loop (see src/codemap/ + docs/codemap.md). No LLM anywhere in the
750
+ // pipeline — mechanical, deterministic, cheap to regenerate.
751
+ if (argv[0] === "codemap") {
752
+ return codemapMain(argv.slice(1))
753
+ }
754
+
755
+ // One-command project registration subcommand: `headlesscode init ...` —
756
+ // resolve central data dir + detect stacks + ensure .gitignore excludes
757
+ // .headlesscode/ + build index + codemap in one step (see src/init/).
758
+ if (argv[0] === "init") {
759
+ return initMain(argv.slice(1))
760
+ }
761
+
762
+ // GitHub App repo provisioning subcommand: `headlesscode provision ...` —
763
+ // clone a repo the App installation can access into a local dir (token
764
+ // stripped from the remote), ready as a --workspace value. Also
765
+ // `--list-repos <id>` (see src/github/cli.ts + docs/github-app-setup.md).
766
+ if (argv[0] === "provision") {
767
+ return provisionMain(argv.slice(1))
768
+ }
769
+
770
+ // GitHub push-back subcommand: `headlesscode push-pr ...` — push a local
771
+ // branch to a repo the App installation can access (token scrubbed from
772
+ // .git/config immediately), then open a PR from it. The "write" half of
773
+ // provisioning (see src/github/cli.ts + docs/github-app-setup.md).
774
+ if (argv[0] === "push-pr") {
775
+ return pushPrMain(argv.slice(1))
776
+ }
777
+
778
+ // Decision-proxy subcommand: `headlesscode decision-proxy ...` — an
779
+ // OPT-IN (HEADLESSCODE_DECISION_PROXY=1) LLM stand-in for the human on
780
+ // ask_followup_question. Watches a workspace for .harness.needs-decision
781
+ // and writes .harness.decision-answer grounded in the session's original
782
+ // task text (see src/decision-proxy/ + plans/decision-proxy-agent.md).
783
+ if (argv[0] === "decision-proxy") {
784
+ return decisionProxyMain(argv.slice(1))
785
+ }
786
+
787
+ // Migration subcommand: `headlesscode migrate ...` — the explicit,
788
+ // human-triggered version of the one-time central-store migrations
789
+ // (shared instructions, checkpoint store, legacy workspace
790
+ // `.headlesscode/`). See src/migrate/ + src/project-store.ts.
791
+ if (argv[0] === "migrate") {
792
+ return migrateMain(argv.slice(1))
793
+ }
794
+
795
+ // Ad-hoc session log analysis: `headlesscode analyze-worktree ...` — the
796
+ // orchestrator already runs this automatically per group (see
797
+ // src/orchestrator/log-analysis.ts + cli.ts's onGroupUpdate); this
798
+ // subcommand lets a human point it at any worktree by hand.
799
+ if (argv[0] === "analyze-worktree") {
800
+ return analyzeCliMain(argv.slice(1))
801
+ }
802
+
803
+ // Cost/token history: `headlesscode cost-history ...` — read-side for
804
+ // the mandatory, automatic recording in watch.ts (recordCostIfTerminal).
805
+ if (argv[0] === "cost-history") {
806
+ return costHistoryCliMain(argv.slice(1))
807
+ }
808
+
809
+ // Central-store project registry: `headlesscode projects ...` — enumerate
810
+ // (`list`) and reclaim (`prune`) the central per-project store
811
+ // (~/.local/share/headlesscode/projects/; see src/projects/ + src/project-store.ts).
812
+ if (argv[0] === "projects") {
813
+ return projectsMain(argv.slice(1))
814
+ }
815
+
816
+ const { options, error } = parseArgs(argv)
817
+ if (error) {
818
+ process.stderr.write(`headlesscode: ${error}\n\n${USAGE}`)
819
+ return 2
820
+ }
821
+ if (options.help) {
822
+ process.stdout.write(USAGE)
823
+ return 0
824
+ }
825
+ if (options.version) {
826
+ process.stdout.write(`headlesscode ${VERSION}\n`)
827
+ return 0
828
+ }
829
+
830
+ const workspaceRoot = path.resolve(options.workspace ?? process.env.HEADLESSCODE_WORKSPACE_ROOT ?? process.cwd())
831
+ const logger = new Logger({ level: "info", filePath: options.logFile })
832
+
833
+ // Silent-death observability (issue #150): live-verified 2026-08-21
834
+ // (twice, across two different models) that this process can disappear
835
+ // entirely mid-run — no exit code, no error log, no trace, not even the
836
+ // wrapping shell's own `echo "exited with code $?"`. Confirmed via
837
+ // `grep -rn` across this file and loop.ts before this fix: zero
838
+ // process-level handlers existed for any of these events. Logger.error/
839
+ // warn write via fs.appendFileSync (synchronous — see logger.ts), so
840
+ // these are safe to call immediately before process.exit without an
841
+ // async flush race. This does NOT catch SIGKILL (uncatchable by
842
+ // definition — the suspected OOM-kill case in #150 may still be
843
+ // SIGKILL, not SIGTERM) or a hard crash inside a native addon, but it
844
+ // closes every JS-level silent-exit path: an uncaught throw, a rejected
845
+ // promise nobody awaited, or a graceful termination request.
846
+ process.on("uncaughtException", (err) => {
847
+ logger.error("[cli] uncaughtException — process terminating", {
848
+ message: err instanceof Error ? err.message : String(err),
849
+ stack: err instanceof Error ? err.stack : undefined,
850
+ })
851
+ process.exit(3)
852
+ })
853
+ process.on("unhandledRejection", (reason) => {
854
+ logger.error("[cli] unhandledRejection — process terminating", {
855
+ reason:
856
+ reason instanceof Error
857
+ ? (reason.stack ?? reason.message)
858
+ : (() => {
859
+ try {
860
+ return JSON.stringify(reason)
861
+ } catch {
862
+ return String(reason)
863
+ }
864
+ })(),
865
+ })
866
+ process.exit(3)
867
+ })
868
+ process.on("SIGTERM", () => {
869
+ logger.warn("[cli] SIGTERM received — process terminating", {
870
+ rssMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
871
+ })
872
+ process.exit(143)
873
+ })
874
+ // Cheap postmortem diagnostic for the #150 OOM hypothesis (unconfirmed —
875
+ // dmesg showed no OOM-killer entries when checked live, but access may
876
+ // have been permission-limited): RSS at session start costs one log
877
+ // line and needs no periodic timer, unlike full memory-pressure polling.
878
+ logger.info("[cli] session process started", {
879
+ pid: process.pid,
880
+ rssMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
881
+ })
882
+
883
+ // Fresh-session memory trap (issue #138): every `npx tsx src/cli.ts`
884
+ // invocation is a COMPLETELY fresh process with zero memory of any
885
+ // prior invocation's model reads/edits, even against the same
886
+ // --workspace and --task-file. A task file written/edited across a
887
+ // restart can easily (and wrongly) claim "you already read X, don't
888
+ // re-read it" — live-verified 2026-08-21: a local model trusted
889
+ // exactly that kind of false claim and hallucinated a plausible-but-
890
+ // wrong edit_file call from the claim alone. Cheapest mitigation (the
891
+ // issue's own "direction 1"): a marker file this process itself
892
+ // writes/updates on every run against a workspace, so the NEXT
893
+ // invocation can print a loud, impossible-to-miss note when one
894
+ // existed already. Doesn't stop a bad task file from lying — makes
895
+ // the failure mode visible in the log for whoever's supervising.
896
+ // Scoped to --task-file specifically: the trap is about a task file's
897
+ // own false claims, not sessions in general.
898
+ if (options.taskFile) {
899
+ const markerPath = path.join(workspaceRoot, ".headlesscode", "last-session.json")
900
+ try {
901
+ const prior = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as {
902
+ sessionId?: string
903
+ taskFile?: string
904
+ startedAt?: string
905
+ }
906
+ logger.warn(
907
+ "[cli] NOTE: this is a FRESH process with ZERO memory of any prior run against this workspace, even if the task file references one",
908
+ {
909
+ priorSessionId: prior.sessionId,
910
+ priorTaskFile: prior.taskFile,
911
+ priorStartedAt: prior.startedAt,
912
+ guidance:
913
+ "If the task file claims you already read/did something in an earlier turn, that claim is about a DIFFERENT process — verify everything yourself before acting on it.",
914
+ },
915
+ )
916
+ } catch {
917
+ // No marker (first run against this workspace) or unreadable/
918
+ // corrupt — either way, nothing to warn about, proceed silently.
919
+ }
920
+ try {
921
+ fs.mkdirSync(path.join(workspaceRoot, ".headlesscode"), { recursive: true })
922
+ fs.writeFileSync(
923
+ markerPath,
924
+ JSON.stringify({ sessionId: options.sessionId, taskFile: options.taskFile, startedAt: new Date().toISOString() }),
925
+ "utf-8",
926
+ )
927
+ } catch (err) {
928
+ // Non-fatal: the marker is a best-effort diagnostic, never a
929
+ // reason to abort a real session over a write failure.
930
+ logger.warn("[cli] failed to write last-session marker (non-fatal)", { error: String(err) })
931
+ }
932
+ }
933
+
934
+ // ── dry-run: no API key required ──────────────────────────────────────────
935
+ if (options.dryRun) {
936
+ try {
937
+ const customModes = await loadCustomModes(workspaceRoot)
938
+ const built = await buildSystemPrompt({
939
+ workspaceRoot,
940
+ mode: options.mode,
941
+ customModes,
942
+ })
943
+ // Dry-run advertises the same non-vendored tools a real session
944
+ // appends (code-intelligence + describe_image; browser_action is
945
+ // deliberately omitted here — the loop appends it, but this
946
+ // listing is a prompt/config check, not a live session).
947
+ const tools = appendCodeIntelTools(
948
+ appendDescribeImageTool(selectToolsForMode(options.mode, customModes)),
949
+ )
950
+ // Per-mode model assignment: dry-run resolves the model exactly
951
+ // like a real run (mode-models.json / _default / OPENROUTER_MODEL,
952
+ // with an explicit --model always winning) so the effective model
953
+ // is visible without an LLM call. undefined -> the client default.
954
+ const dryRunModel = resolveModelForMode({
955
+ workspaceRoot,
956
+ mode: options.mode,
957
+ explicitModel: options.model,
958
+ env: process.env,
959
+ })
960
+ // Reasoning effort (issue #30): resolved + validated here too so
961
+ // --dry-run catches a bad value (e.g. a typo in the env var or
962
+ // mode-models.json `_reasoning_effort` key) BEFORE the experiment
963
+ // burns any real LLM calls. A throw lands in the catch below.
964
+ const dryRunEffort = resolveReasoningEffortForMode({ workspaceRoot, env: process.env })
965
+ parseReasoningEffort(dryRunEffort)
966
+
967
+ process.stdout.write(built.prompt + "\n")
968
+ process.stdout.write(
969
+ `\n───── dry-run summary ─────\n` +
970
+ `mode: ${options.mode}\n` +
971
+ `model: ${dryRunModel ?? "deepseek/deepseek-v4-flash-0731 (client default)"}\n` +
972
+ `reasoning effort: ${dryRunEffort ?? "(unset — endpoint default)"}\n` +
973
+ `workspace: ${workspaceRoot}\n` +
974
+ `custom modes: ${customModes.length ? customModes.map((m) => m.slug).join(", ") : "(none, using built-ins)"}\n` +
975
+ `exposed tools: ${tools.map((t) => (t.type === "function" ? t.function.name : t.type)).join(", ")}\n` +
976
+ `system prompt: ${built.prompt.length} chars\n`,
977
+ )
978
+ logger.info("dry-run complete", { mode: options.mode, workspaceRoot })
979
+ return 0
980
+ } catch (err) {
981
+ process.stderr.write(`headlesscode: dry-run failed: ${err instanceof Error ? err.message : String(err)}\n`)
982
+ return 2
983
+ }
984
+ }
985
+
986
+ // ── real run: API key required ────────────────────────────────────────────
987
+ const apiKey = process.env.HEADLESSCODE_OPENROUTER_API_KEY
988
+ if (!apiKey) {
989
+ process.stderr.write(
990
+ "headlesscode: HEADLESSCODE_OPENROUTER_API_KEY is not set.\n" +
991
+ " Export it (e.g. export HEADLESSCODE_OPENROUTER_API_KEY=sk-or-...) or use --dry-run to\n" +
992
+ " validate the prompt/config without calling the LLM.\n",
993
+ )
994
+ return 2
995
+ }
996
+
997
+ if (!options.task && !options.taskFile) {
998
+ process.stderr.write(`headlesscode: provide a task with --task <text> or --task-file <path>\n\n${USAGE}`)
999
+ return 2
1000
+ }
1001
+ if (options.task && options.taskFile) {
1002
+ process.stderr.write("headlesscode: use either --task or --task-file, not both\n")
1003
+ return 2
1004
+ }
1005
+
1006
+ let taskText = options.task ?? ""
1007
+ if (options.taskFile) {
1008
+ const taskFilePath = path.resolve(workspaceRoot, options.taskFile)
1009
+ try {
1010
+ taskText = fs.readFileSync(taskFilePath, "utf-8")
1011
+ } catch (err) {
1012
+ process.stderr.write(
1013
+ `headlesscode: cannot read task file '${taskFilePath}': ${err instanceof Error ? err.message : String(err)}\n`,
1014
+ )
1015
+ return 2
1016
+ }
1017
+ }
1018
+ if (taskText.trim() === "") {
1019
+ process.stderr.write("headlesscode: task text is empty\n")
1020
+ return 2
1021
+ }
1022
+
1023
+ // Per-mode model assignment (plans/mode-model-assignment.md): the
1024
+ // resolved mode's config entry / _default / OPENROUTER_MODEL apply only
1025
+ // when no explicit --model flag was given (an explicit flag always wins).
1026
+ // `undefined` is passed through so the OpenRouter client's own built-in
1027
+ // default stays the single source of truth for the ultimate fallback.
1028
+ const model = resolveModelForMode({
1029
+ workspaceRoot,
1030
+ mode: options.mode,
1031
+ explicitModel: options.model,
1032
+ env: process.env,
1033
+ })
1034
+
1035
+ // Generalized 2026-08-21 (issue #142) beyond the original `code`-only
1036
+ // scope (plans/local-dual-model-code-agent.md, D2) — real need: two
1037
+ // separate local daemons on two different GPUs (a coder model and a
1038
+ // review model), each needing its own mode -> URL/model mapping.
1039
+ // HEADLESSCODE_LOCAL_BACKEND_MODES defaults to "code" alone, so
1040
+ // nobody's existing setup changes behavior unless they opt in.
1041
+ const codeModeBackend = process.env.HEADLESSCODE_CODE_MODE_BACKEND ?? "openrouter"
1042
+ const localBackendModes = new Set(
1043
+ (process.env.HEADLESSCODE_LOCAL_BACKEND_MODES ?? "code")
1044
+ .split(",")
1045
+ .map((s) => s.trim())
1046
+ .filter(Boolean),
1047
+ )
1048
+ const useLocalCodeBackend = localBackendModes.has(options.mode) && codeModeBackend === "ollama"
1049
+
1050
+ // The local daemon's own proxy (ollama_shim.py) deliberately runs a 600s
1051
+ // upstream request timeout — its own comment documents why: a shorter
1052
+ // shim timeout was once found to cut off calls before the harness's own
1053
+ // timeout would have. That fix only holds if the harness's own timeout
1054
+ // is longer than the shim's. Two SEPARATE timeout mechanisms need this:
1055
+ // loop.ts's llmTimeoutMs-driven AbortControllers (used below via
1056
+ // `llmTimeoutMs`), and OllamaClient's own independent abort timer
1057
+ // (ollama.ts's DEFAULT_OLLAMA_TIMEOUT_MS, passed as `timeoutMs` to the
1058
+ // constructor just below) — a 2026-08-20 comment on that constructor
1059
+ // call already documents discovering the SAME two-timeout trap for a
1060
+ // different reason (`--llm-timeout-ms` being silently ignored). Verified
1061
+ // live 2026-08-28: with the loop-level timeout alone raised to 630s, a
1062
+ // session still died at exactly 300000ms — OllamaClient's own timer
1063
+ // fired first every time since it was never told about the override.
1064
+ // Resolved here (before both consumers) so neither can silently fall
1065
+ // back to the wrong default the way the other already did once.
1066
+ const LOCAL_LLM_TIMEOUT_MS = 630_000
1067
+ const llmTimeoutMs = options.llmTimeoutMs ?? (useLocalCodeBackend ? LOCAL_LLM_TIMEOUT_MS : undefined)
1068
+
1069
+ // Issue #139 (updated for #142): warn whenever the local backend was
1070
+ // requested via env var but the CURRENT mode isn't in the allow-list
1071
+ // — was hardcoded to "code", now checks the real list.
1072
+ if (codeModeBackend === "ollama" && !localBackendModes.has(options.mode)) {
1073
+ process.stderr.write(
1074
+ `[headlesscode] NOTE: HEADLESSCODE_CODE_MODE_BACKEND=ollama is set, but --mode "${options.mode}" is not in HEADLESSCODE_LOCAL_BACKEND_MODES ("${[...localBackendModes].join(",")}") — this session will use the cloud OpenRouter model instead.\n`,
1075
+ )
1076
+ }
1077
+ // When the local backend is selected, every downstream consumer of
1078
+ // `model` (session-start logs, event-feed session_start, cost/usage
1079
+ // records, and — critically — the `request.model` sent on each
1080
+ // OllamaClient call, since OllamaClient.resolveModel() prefers a
1081
+ // non-empty request.model over its own defaultModel) must see the
1082
+ // LOCAL model id, not the OpenRouter-resolved one. Without this,
1083
+ // local sessions were tagged and logged as
1084
+ // e.g. "deepseek/deepseek-v4-flash-0731" throughout — cosmetic for
1085
+ // generation itself (the local daemon ignores the model string and
1086
+ // serves whatever GGUF is loaded) but wrong everywhere the model id
1087
+ // is recorded or reported (verified live 2026-08-21: session_start
1088
+ // logged the cloud model id while actually running against the
1089
+ // local Ollama daemon).
1090
+ const effectiveModel = useLocalCodeBackend
1091
+ ? (resolvePerModeEnv("HEADLESSCODE_CODE_MODE_MODEL", options.mode) ?? model)
1092
+ : model
1093
+ const client: LlmClient = useLocalCodeBackend
1094
+ ? new OllamaClient({
1095
+ defaultModel: effectiveModel,
1096
+ // Issue #142: per-mode URL override (e.g. a review daemon on a
1097
+ // different GPU than the code daemon) — falls back to the
1098
+ // existing global HEADLESSCODE_OLLAMA_URL, then OllamaClient's
1099
+ // own DEFAULT_OLLAMA_URL, when neither is set.
1100
+ baseUrl: resolvePerModeEnv("HEADLESSCODE_OLLAMA_URL", options.mode),
1101
+ // Without this, --llm-timeout-ms is silently ignored for the
1102
+ // Ollama backend: OllamaClient has its own independent abort
1103
+ // timer (ollama.ts's DEFAULT_OLLAMA_TIMEOUT_MS), separate
1104
+ // from loop.ts's llmTimeoutMs-driven AbortControllers —
1105
+ // verified live 2026-08-20: a trial dispatched with
1106
+ // --llm-timeout-ms 900000 still aborted at exactly 300000ms
1107
+ // because this constructor call never forwarded the option.
1108
+ // `llmTimeoutMs` (not `options.llmTimeoutMs`) so the local-
1109
+ // backend default resolved above reaches this timer too —
1110
+ // verified live 2026-08-28: passing the raw option alone
1111
+ // left this at the generic 300s default for every session
1112
+ // that didn't explicitly pass --llm-timeout-ms.
1113
+ timeoutMs: llmTimeoutMs,
1114
+ })
1115
+ : new OpenRouterClient({ apiKey, defaultModel: model })
1116
+ // A small local model's context is dominated by the full tool catalog
1117
+ // (see src/engine/lazy-tools.ts) — default lazy loading ON for local
1118
+ // sessions specifically, without touching the cloud path's prompt-cache-
1119
+ // friendly static catalog. An explicit env value always wins.
1120
+ if (useLocalCodeBackend && process.env.HEADLESSCODE_LAZY_TOOL_CATALOG === undefined) {
1121
+ process.env.HEADLESSCODE_LAZY_TOOL_CATALOG = "1"
1122
+ }
1123
+ // Same rationale (src/engine/prompt.ts's buildLeanSystemPrompt doc comment):
1124
+ // the vendored system prompt alone is ~9.5K tokens, GUI-oriented content a
1125
+ // headless local session doesn't need. Default on for local, off for cloud.
1126
+ if (useLocalCodeBackend && process.env.HEADLESSCODE_LEAN_SYSTEM_PROMPT === undefined) {
1127
+ process.env.HEADLESSCODE_LEAN_SYSTEM_PROMPT = "1"
1128
+ }
1129
+ // A local model that gives up mid-task and dumps prose was observed live
1130
+ // (2026-08-19 baseline run) getting recorded as `session succeeded` with
1131
+ // zero files touched — the bare-text-reply pragmatic-success fallback
1132
+ // (loop.ts, HeadlessSessionConfig.requireExplicitCompletion) exists for
1133
+ // cloud models that reliably signal completion through prose; a local
1134
+ // session can't trust that signal, so require attempt_completion instead.
1135
+ const requireExplicitCompletion =
1136
+ useLocalCodeBackend && !envBoolean("HEADLESSCODE_ALLOW_TEXT_ONLY_COMPLETION")
1137
+ // Works around a llama.cpp/llama-cpp-python grammar-constrained-decoding
1138
+ // bug (ggml-org/llama.cpp#20164) that corrupts tool calls when a
1139
+ // multi-parameter tool has any optional parameter — verified live
1140
+ // 2026-08-20 against Qwen2.5-Coder-14B (edit_file failed 3/3 on an
1141
+ // existing file; write_to_file, which has zero optional params,
1142
+ // succeeded every time). See prompt.ts's
1143
+ // patchEditFileToolForLocalModels doc comment. Cloud sessions aren't
1144
+ // grammar-constrained this way, so this only applies to the local
1145
+ // backend.
1146
+ const patchLocalToolSchemas =
1147
+ useLocalCodeBackend && !envBoolean("HEADLESSCODE_ALLOW_UNPATCHED_LOCAL_TOOL_SCHEMAS")
1148
+ // A local model was observed calling attempt_completion for real and
1149
+ // claiming success (e.g. "typecheck and tests passed") while the last
1150
+ // execute_command it actually ran was still failing, never re-verifying
1151
+ // in between (verified live 2026-08-20 against both Qwen2.5-Coder-14B
1152
+ // and Qwen3-14B). requireExplicitCompletion above only catches a
1153
+ // text-only non-call; this catches a real completion call whose claim
1154
+ // the session's own last command result already contradicts. See
1155
+ // loop.ts's HeadlessSessionConfig.verifyBeforeCompletion doc comment.
1156
+ const verifyBeforeCompletion =
1157
+ useLocalCodeBackend && !envBoolean("HEADLESSCODE_ALLOW_UNVERIFIED_COMPLETION")
1158
+ // A local (Qwen3.5-9B) session was observed live 2026-08-27 doing the
1159
+ // actual work correctly (a real, correct edit_file call) and then dying
1160
+ // anyway: its first attempt_completion was deferred (a prior
1161
+ // execute_command had failed), its next two execute_command retries
1162
+ // ALSO failed (nested-quote shell one-liners it wrote — a
1163
+ // jsonEscapingNote-class mistake, see prompt.ts — that ran fine when
1164
+ // re-run by hand outside the harness), and by the time it gave up and
1165
+ // wrote a prose explanation instead of retrying attempt_completion, that
1166
+ // was already its 3rd consecutive mistake — DEFAULT_CONSECUTIVE_ERROR_LIMIT
1167
+ // (3) counts tool errors and non-completing replies on the SAME counter,
1168
+ // so two ordinary tool mistakes leave a local model exactly one strike
1169
+ // from a hard stop even when the underlying task is already done. Cloud
1170
+ // models haven't shown this failure shape (see prompt.ts's jsonEscapingNote
1171
+ // doc comment — that failure was Qwen3-14B-specific too), so this is
1172
+ // scoped to the local backend only, same opt-in-override pattern as the
1173
+ // flags above. DEFAULT_TOOL_FAILURE_NUDGE_THRESHOLD (2) only needs to
1174
+ // stay strictly below this value (see its own doc comment) — 6 leaves
1175
+ // that comfortably true.
1176
+ const DEFAULT_LOCAL_CONSECUTIVE_ERROR_LIMIT = 6
1177
+ const consecutiveErrorLimit =
1178
+ options.consecutiveErrorLimit ??
1179
+ (useLocalCodeBackend ? DEFAULT_LOCAL_CONSECUTIVE_ERROR_LIMIT : undefined)
1180
+ // Issue #144: local inference is free — a fabricated dollar figure in
1181
+ // every log line is noise at best, misleading at worst.
1182
+ const trackCost = !useLocalCodeBackend || envBoolean("HEADLESSCODE_FORCE_COST_TRACKING")
1183
+ // Issue #143: unlike verifyBeforeCompletion above, this must NOT apply
1184
+ // to every useLocalCodeBackend mode — deepseek-reviewer/qa-agent are
1185
+ // read-only and their correct completion is often "read the files,
1186
+ // verdict: clean" with zero write/execute calls. Scoped to an explicit
1187
+ // allow-list (default: code + the orchestrator meta-mode, both of
1188
+ // which are expected to actually change something or file something
1189
+ // real), same opt-in pattern as HEADLESSCODE_LOCAL_BACKEND_MODES.
1190
+ const artifactRequiredModes = new Set(
1191
+ (process.env.HEADLESSCODE_REQUIRE_ARTIFACT_MODES ?? "code,multi-agent-orchestrator-headless")
1192
+ .split(",")
1193
+ .map((s) => s.trim())
1194
+ .filter(Boolean),
1195
+ )
1196
+ const requireArtifactBeforeCompletion =
1197
+ useLocalCodeBackend &&
1198
+ artifactRequiredModes.has(options.mode) &&
1199
+ !envBoolean("HEADLESSCODE_ALLOW_UNVERIFIED_COMPLETION")
1200
+ // A local model was observed regenerating an entire existing ~500-line
1201
+ // file from scratch via write_to_file for a one-function-add task
1202
+ // instead of a targeted diff, getting cut off mid-regeneration and
1203
+ // silently destroying everything after the cutoff (verified live
1204
+ // 2026-08-20 against both Qwen2.5-Coder-14B and Qwen3-14B, reproduced
1205
+ // with a synthetic request bypassing headlesscode entirely — see
1206
+ // plans/local-dual-model-code-agent-PROMPT-2026-08-20.md, "Tonight's
1207
+ // core finding"). See loop.ts's HeadlessSessionConfig.guardLargeOverwrites
1208
+ // doc comment and executor.ts's largeOverwriteRefusal.
1209
+ const guardLargeOverwrites =
1210
+ useLocalCodeBackend && !envBoolean("HEADLESSCODE_ALLOW_UNGUARDED_OVERWRITES")
1211
+ // The cloud-tuned condensation defaults (0.75 hard threshold, 0.6 early-fire
1212
+ // — see condense.ts) were measured firing needlessly aggressively against a
1213
+ // local model: a trial condensing at 38 messages against a 16384-token
1214
+ // --context-window left most of the daemon's real n_ctx unused (verified
1215
+ // live 2026-08-20 — see plans/local-dual-model-code-agent-PROMPT-2026-08-21.md,
1216
+ // "Why is condensation firing so heavily"). Two local-only changes, an
1217
+ // explicit --condense-threshold/--condense-early-fire flag always wins:
1218
+ // (1) the hard threshold moves from 0.75 to 0.92 — condense only once the
1219
+ // session is genuinely close to the real limit, not at 3/4 of it; (2) the
1220
+ // early-fire background path (designed to hide a CLOUD provider's
1221
+ // condensation-call latency behind the main loop) is disabled outright —
1222
+ // against a single locally-loaded model it just fires a second concurrent
1223
+ // generation request competing with the main loop for the same GPU, with
1224
+ // no latency to hide. Disabling it is `earlyFire === hardThreshold`
1225
+ // (fireBackgroundCondense's own off-switch, see loop.ts).
1226
+ const LOCAL_CONDENSE_THRESHOLD_FRACTION = 0.92
1227
+ const condenseThresholdFraction =
1228
+ options.condenseThreshold ?? (useLocalCodeBackend ? LOCAL_CONDENSE_THRESHOLD_FRACTION : undefined)
1229
+ const condenseEarlyFireFraction =
1230
+ options.condenseEarlyFire ?? (useLocalCodeBackend ? LOCAL_CONDENSE_THRESHOLD_FRACTION : undefined)
1231
+ // A local (Qwen3.5-9B+LoRA) condensation call was observed live
1232
+ // 2026-08-28 turning a correctly-hedged note ("X is an existing gate,
1233
+ // for reference") into a flat false claim ("X passed, ready to merge")
1234
+ // for a feature the session never touched — a lossy ~13:1 compression
1235
+ // under CONDENSE_SYSTEM_PROMPT's "never invent content" rule is only as
1236
+ // reliable as the model executing it, and this one wasn't. The
1237
+ // corrupted summary then re-entered history as trusted fact and the
1238
+ // session repeated the false completion claim until bounded failure
1239
+ // killed it. See HeadlessSessionConfig.disableLlmCondensation's doc
1240
+ // comment (loop.ts) for the full tradeoff: local inference has no
1241
+ // per-token cost pressure, so summarization's risk (a fabricated "fact"
1242
+ // the model can't distinguish from a real one) isn't worth taking when
1243
+ // truncateHistory's plain drop-oldest eviction — which keeps running
1244
+ // either way — only ever loses information, never invents it.
1245
+ const disableLlmCondensation =
1246
+ useLocalCodeBackend && !envBoolean("HEADLESSCODE_ALLOW_LLM_CONDENSATION")
1247
+
1248
+ // Phase 3 context condensation: a cheaper model for the condensation
1249
+ // call can be assigned via the `_condensation` key in
1250
+ // .headlesscode/mode-models.json (consulted BEFORE the mode entry —
1251
+ // its whole purpose is to override the session model for condensation,
1252
+ // so a file that sets both `code` and `_condensation` must use the
1253
+ // cheap model). An explicit --condense-model flag always wins; otherwise
1254
+ // the session model is used (same model = simplest, consistent quality).
1255
+ const condenseModel =
1256
+ options.condenseModel ??
1257
+ resolveModelForMode({
1258
+ workspaceRoot,
1259
+ mode: options.mode,
1260
+ explicitModel: undefined,
1261
+ extraKeys: ["_condensation"],
1262
+ env: process.env,
1263
+ })
1264
+
1265
+ // loop.ts's resolveContextWindowTokens() live-queries the OpenRouter
1266
+ // catalog for the real context window and falls back to
1267
+ // DEFAULT_CONTEXT_WINDOW_TOKENS (128000) when that lookup fails or isn't
1268
+ // applicable — which is unconditional for the local backend (there's no
1269
+ // OpenRouter catalog entry for a locally-loaded GGUF). Verified live
1270
+ // 2026-08-27: the code-daemon's actual configured window
1271
+ // (AIRUNNER_GGUF_N_CTX, checked via `docker inspect`) is 40960, well
1272
+ // under the assumed 128000 — the opposite-direction version of the
1273
+ // condenseThresholdFraction incident above (that one was a too-SMALL
1274
+ // assumed window firing condensation too early; an unset context window
1275
+ // here is too LARGE, so condensation at 92% of a wrong 128000 would fire
1276
+ // at ~118000 tokens, past the real 40960 limit, risking a hard daemon
1277
+ // failure/silent truncation instead of a graceful condense). No session
1278
+ // observed tonight actually reached anywhere near 40960 real tokens, so
1279
+ // this didn't cause any of tonight's local-model failures — but it's a
1280
+ // real latent gap for any longer local session. Not auto-detectable (the
1281
+ // Ollama-compat API doesn't expose it), so a documented env default,
1282
+ // same override precedence as every other local-only default above.
1283
+ const DEFAULT_LOCAL_CONTEXT_WINDOW_TOKENS = 40960
1284
+ const localContextWindowTokens = Number(process.env.HEADLESSCODE_CODE_MODE_CONTEXT_WINDOW ?? DEFAULT_LOCAL_CONTEXT_WINDOW_TOKENS)
1285
+ const contextWindowTokens =
1286
+ options.contextWindowTokens ??
1287
+ (useLocalCodeBackend && Number.isFinite(localContextWindowTokens) && localContextWindowTokens > 0
1288
+ ? localContextWindowTokens
1289
+ : undefined)
1290
+
1291
+ // Graded reasoning effort for deepseek/* models (issue #30 experiment):
1292
+ // the `_reasoning_effort` key in mode-models.json beats
1293
+ // $HEADLESSCODE_REASONING_EFFORT. Deliberately NOT a CLI flag — the
1294
+ // experiment compares env/config values and a flag would be one more
1295
+ // surface to keep in sync. Validated here so a typo fails at startup
1296
+ // (exit 2) with a clear message instead of mid-session on the first LLM
1297
+ // call (fail-loudly, same idiom as mode-models.ts).
1298
+ const reasoningEffort = resolveReasoningEffortForMode({ workspaceRoot, env: process.env })
1299
+ try {
1300
+ parseReasoningEffort(reasoningEffort)
1301
+ } catch (err) {
1302
+ process.stderr.write(`headlesscode: ${err instanceof Error ? err.message : String(err)}\n`)
1303
+ return 2
1304
+ }
1305
+
1306
+ // Phase 3 memory: OFF by default (preserves pre-Phase-3 behavior). Enabled
1307
+ // only by --memory-dir or $HEADLESSCODE_MEMORY_DIR; --no-memory forces off.
1308
+ const memory = resolveMemory(options, workspaceRoot)
1309
+ const project = process.env.HEADLESSCODE_PROJECT ?? path.basename(workspaceRoot)
1310
+
1311
+ // Phase 6 per-session budget: flags win over the env fallbacks
1312
+ // ($HEADLESSCODE_MAX_COST_USD / $HEADLESSCODE_MAX_DURATION_MS — set by
1313
+ // run-worker.sh / run-qa.sh for workers). Off when neither is set.
1314
+ const maxCostUsd = options.maxCostUsd ?? envNumber("HEADLESSCODE_MAX_COST_USD")
1315
+ const maxDurationMs = options.maxDurationMs ?? envNumber("HEADLESSCODE_MAX_DURATION_MS")
1316
+ const budget =
1317
+ maxCostUsd !== undefined || maxDurationMs !== undefined ? { maxCostUsd, maxDurationMs } : undefined
1318
+
1319
+ // Decision escalation: flag wins over the env fallback
1320
+ // ($HEADLESSCODE_DECISION_TIMEOUT_MS — set by run-worker.sh for workers).
1321
+ // Off (undefined) falls back to executor.ts's own default (30 min).
1322
+ const decisionTimeoutMs = options.decisionTimeoutMs ?? envNumber("HEADLESSCODE_DECISION_TIMEOUT_MS")
1323
+
1324
+ // Pause/resume (live worker monitoring): flag wins over the env fallback
1325
+ // ($HEADLESSCODE_MAX_PAUSE_MS). Off (undefined) falls back to loop.ts's
1326
+ // own default (2h — matching watch.ts's stall guard). Passing it through
1327
+ // explicitly keeps the CLI the single place that resolves config.
1328
+ const maxPauseMs = options.maxPauseMs ?? envNumber("HEADLESSCODE_MAX_PAUSE_MS")
1329
+
1330
+ // Recursive task decomposition (`new_task`): CLI flag wins over the env
1331
+ // fallback; undefined falls back to loop.ts's own defaults (depth 2 /
1332
+ // fraction 0.5). Passed through so children inherit the root's caps.
1333
+ const maxRecursionDepth = options.maxRecursionDepth ?? envNumber("HEADLESSCODE_MAX_RECURSION_DEPTH")
1334
+ const childIterationFraction =
1335
+ options.childIterationFraction ?? envNumber("HEADLESSCODE_CHILD_ITERATION_FRACTION")
1336
+
1337
+ // switch_mode (plans/switch-mode-headless.md): the approval gate is OFF by
1338
+ // default. Flag wins over the env fallback
1339
+ // ($HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH); undefined falls back to
1340
+ // loop.ts's own cap default (DEFAULT_MAX_MODE_SWITCHES = 5).
1341
+ const autoApproveModeSwitch =
1342
+ options.autoApproveModeSwitch || envBoolean("HEADLESSCODE_AUTO_APPROVE_MODE_SWITCH")
1343
+ const maxModeSwitches = options.maxModeSwitches ?? envNumber("HEADLESSCODE_MAX_MODE_SWITCHES")
1344
+
1345
+ // Flag wins over the env fallback; undefined falls back to loop.ts's own
1346
+ // default (DEFAULT_MAX_TOKENS = 32768) — sized for a CLOUD reasoning
1347
+ // model against a 128K+ context window (4x the heaviest real generation
1348
+ // observed there, ~8,000 tokens). Verified live 2026-08-28 (joeos issue
1349
+ // #26): applied unchanged to the local backend, this let a single
1350
+ // generation run to 29,664 output tokens — on a real llama-server
1351
+ // context window of only 65,536 total, that alone pushed the very next
1352
+ // request to 65,657 tokens and crashed the session outright ("exceeds
1353
+ // the available context size"), un-recoverably, unlike an iteration-cap
1354
+ // exhaustion (no auto-continuation exists for a hard crash). The
1355
+ // existing condensation threshold guard cannot prevent this class of
1356
+ // failure: it only checks the LAST completed request's size before
1357
+ // building the next one, with no way to know in advance that the
1358
+ // upcoming single response will be enormous. Same local-only-override
1359
+ // pattern already used for condenseThresholdFraction below: a runaway
1360
+ // generation's blast radius should be a small fraction of the REAL
1361
+ // local context window, not up to half of it.
1362
+ const LOCAL_MAX_TOKENS = 8192
1363
+ const maxTokens =
1364
+ options.maxTokens ?? envNumber("HEADLESSCODE_MAX_TOKENS") ?? (useLocalCodeBackend ? LOCAL_MAX_TOKENS : undefined)
1365
+
1366
+ // Permissions (command allow/deny + protected files): CLI flags > env vars
1367
+ // > <workspaceRoot>/.headlesscode/permissions.json > built-in defaults.
1368
+ // A malformed permissions.json fails loudly (mirrors HEADLESSCODE_PRICING_JSON).
1369
+ let permissions: PermissionsConfig
1370
+ try {
1371
+ permissions = resolvePermissions({
1372
+ workspaceRoot,
1373
+ overrides: {
1374
+ allowedCommands: options.allowedCommands,
1375
+ deniedCommands: options.deniedCommands,
1376
+ protectedFiles: options.protectedFiles,
1377
+ allowProtectedWrites: options.allowProtectedWrites ? true : null,
1378
+ },
1379
+ env: process.env,
1380
+ })
1381
+ } catch (err) {
1382
+ process.stderr.write(`headlesscode: ${err instanceof Error ? err.message : String(err)}\n`)
1383
+ return 2
1384
+ }
1385
+
1386
+ const session = new HeadlessSession({
1387
+ workspaceRoot,
1388
+ sessionId: options.sessionId,
1389
+ mode: options.mode,
1390
+ model: effectiveModel,
1391
+ taskText,
1392
+ maxIterations: options.maxIterations,
1393
+ maxRecursionDepth,
1394
+ childIterationFraction,
1395
+ consecutiveErrorLimit,
1396
+ windowSize: options.windowSize,
1397
+ contextWindowTokens,
1398
+ condenseThresholdFraction,
1399
+ condenseEarlyFireFraction,
1400
+ condenseModel,
1401
+ disableLlmCondensation,
1402
+ llmTimeoutMs,
1403
+ stream: options.stream,
1404
+ reasoningEffort,
1405
+ temperature: options.temperature,
1406
+ requireExplicitCompletion,
1407
+ patchLocalToolSchemas,
1408
+ verifyBeforeCompletion,
1409
+ trackCost,
1410
+ requireArtifactBeforeCompletion,
1411
+ requireArtifactPathPattern: options.requireArtifactPath,
1412
+ requireArtifactMinCitations: options.requireArtifactMinCitations,
1413
+ requireArtifactSections: options.requireArtifactSections,
1414
+ guardLargeOverwrites,
1415
+ llmClient: client,
1416
+ logger,
1417
+ memory,
1418
+ project,
1419
+ budget,
1420
+ checkpoints: !options.noCheckpoints,
1421
+ checkpointDir: options.checkpointDir,
1422
+ decisionTimeoutMs,
1423
+ autoApproveModeSwitch,
1424
+ maxModeSwitches,
1425
+ maxTokens,
1426
+ maxPauseMs,
1427
+ permissions,
1428
+ // Opt-in local exploration phase (default OFF). Flag OR env var — the
1429
+ // phase itself resolves the remaining HEADLESSCODE_LOCAL_EXPLORE_*
1430
+ // defaults at call time. Any local failure fails open to cloud-only.
1431
+ localExplore: options.localExplore || isLocalExploreEnabled(process.env),
1432
+ })
1433
+
1434
+ const result = await session.run()
1435
+
1436
+ // A session writes into workspaceRoot throughout the run (file edits,
1437
+ // its own .headlesscode/{events,usage,reports}, git's index as it
1438
+ // stages things) AND, when workspaceRoot is a worktree, into the
1439
+ // source repo's separate .git/worktrees/<name>/ admin dir — chowning
1440
+ // only at worktree *creation* time (see session-launch.ts's
1441
+ // ensureWorktree) misses all of this. Runs regardless of success/
1442
+ // failure — a failed session can leave root-owned files too. No-ops
1443
+ // when HEADLESSCODE_DASHBOARD_WORKTREE_OWNER isn't set (today's
1444
+ // default for a non-container / non-root run).
1445
+ chownWorktreeWorkspace(workspaceRoot)
1446
+
1447
+ if (session.memoryStats) {
1448
+ process.stdout.write(
1449
+ `[memory] project="${project}" recalled ${session.memoryStats.recalledFacts} fact(s) / ${session.memoryStats.recalledSessions} session(s); recorded ${session.memoryStats.recordedFacts} fact(s) / ${session.memoryStats.recordedSessions} session(s)\n`,
1450
+ )
1451
+ }
1452
+
1453
+ if (result.budgetUsage) {
1454
+ process.stdout.write(
1455
+ `[budget] cost $${result.budgetUsage.costUsd.toFixed(6)}, elapsed ${result.budgetUsage.elapsedMs}ms, iterations ${result.budgetUsage.iterations}, model ${result.budgetUsage.model}\n`,
1456
+ )
1457
+ }
1458
+
1459
+ if (result.status === "success") {
1460
+ logger.info("session succeeded", { iterations: result.iterations, toolCalls: result.toolCalls })
1461
+ process.stdout.write(result.result + "\n")
1462
+ return 0
1463
+ }
1464
+
1465
+ logger.error("session failed", { error: result.error, iterations: result.iterations, reason: result.reason })
1466
+ if (result.reason === "budget") {
1467
+ process.stderr.write(`headlesscode: task aborted by budget: ${result.error ?? "budget exceeded"}\n`)
1468
+ return 1
1469
+ }
1470
+ process.stderr.write(`headlesscode: task failed: ${result.error ?? "unknown error"}\n`)
1471
+ return 1
1472
+ }
1473
+
1474
+ /**
1475
+ * Per-mode env var lookup for issue #142: `HEADLESSCODE_<BASE>__<MODE>`
1476
+ * (mode slug uppercased, "-" -> "_") wins if set, else the plain global
1477
+ * `HEADLESSCODE_<BASE>`, else undefined. Shared by both the per-mode
1478
+ * Ollama URL and model lookups so they resolve identically.
1479
+ */
1480
+ export function resolvePerModeEnv(
1481
+ baseName: string,
1482
+ mode: string,
1483
+ env: NodeJS.ProcessEnv = process.env,
1484
+ ): string | undefined {
1485
+ const modeKey = mode.toUpperCase().replace(/-/g, "_")
1486
+ return env[`${baseName}__${modeKey}`] ?? env[baseName]
1487
+ }
1488
+
1489
+ /** Parse a positive finite number from env (undefined when unset/invalid). */
1490
+ function envNumber(name: string): number | undefined {
1491
+ const raw = process.env[name]
1492
+ if (raw === undefined || raw === "") {
1493
+ return undefined
1494
+ }
1495
+ const n = Number(raw)
1496
+ return Number.isFinite(n) && n > 0 ? n : undefined
1497
+ }
1498
+
1499
+ /** Parse an env boolean opt-in: "1"/"true"/"yes"/"on" → true; anything else (incl. unset) → false. */
1500
+ export function envBoolean(name: string): boolean {
1501
+ const raw = process.env[name]
1502
+ if (raw === undefined || raw === "") {
1503
+ return false
1504
+ }
1505
+ return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase())
1506
+ }
1507
+
1508
+ /**
1509
+ * Resolve the Phase 3 memory store. Default OFF (null) to preserve existing
1510
+ * behavior; enabled by --memory-dir or $HEADLESSCODE_MEMORY_DIR. --no-memory
1511
+ * forces OFF even when the env var is set.
1512
+ */
1513
+ function resolveMemory(options: CliOptions, workspaceRoot: string): MemoryStore | null {
1514
+ if (options.noMemory) {
1515
+ return null
1516
+ }
1517
+ const dir = options.memoryDir ?? process.env.HEADLESSCODE_MEMORY_DIR
1518
+ if (!dir) {
1519
+ return null
1520
+ }
1521
+ return new LocalMemoryStore({ dir: path.resolve(dir) })
1522
+ }
1523
+
1524
+ // Allow `tsx src/cli.ts` / `headlesscode` / `npm run cli` to run directly.
1525
+ if (import.meta.url === `file://${process.argv[1]}`) {
1526
+ main().then(
1527
+ (code) => {
1528
+ process.exitCode = code
1529
+ },
1530
+ (err) => {
1531
+ process.stderr.write(`headlesscode: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
1532
+ process.exitCode = 2
1533
+ },
1534
+ )
1535
+ }