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.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- 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
|
+
}
|