A small, purpose-built, genuinely headless coding-agent harness. It reuses what makes Zoo Code (an Apache-2.0 Roo Code fork) good — its system prompts, native tool schemas, and mode/rules configuration format — without carrying along the VS Code GUI dependency.
This repo implements:
- The headless runtime engine — vendoring the portable Apache-2.0 core and the headless runtime (OpenRouter client, tool executor, tool-call parser, orchestration loop, CLI).
- A headless orchestration layer — split an issue into worker groups, run
each as a harness subprocess in its own git worktree, review, and merge
(see
docs/phase2-orchestration.md). - A memory subsystem — per-project knowledge facts + rolling session
summaries, with local semantic recall and a pluggable
MemoryStorecontract for a remote backend. - Headless QA — run the target repo's
qa-agentmode as a second harness session (seedocs/phase4-qa.md) and a human-approval deploy gate in front of the repo'sdeploy-production.sh(seedocs/phase4-deploy-gate.md). - A GitHub issue watcher (see
docs/phase5-issue-watcher.md) — file an issue with a target label and a poll loop automatically splits and spawns it through the existing orchestration pipeline, with durable idempotency so a restart never double-spawns. - Cloud-scaling guardrails — a hard concurrent-session cap, a per-session
cost/time/iteration budget, and a
CloudProviderabstraction (container/VM per issue behind the same lifecycle as the local worktree flow) with an evaluation-only cloud-provider sketch (seedocs/phase6-cloud.md). No live cloud resources are launched; the caps/budgets land before any scaling.
⚠️ Security warning: default-allow arbitrary command execution.headlesscodeis a headless coding agent: by default it runs arbitrary shell commands with your full user privileges (no sandbox, no approval prompts) and can read and modify your files — including~/.ssh,~/.aws, and any other credentials your user can access. Run it only on machines and with tasks you trust, and isolate it (container/VM/dedicated user) whenever untrusted content is involved. SeeSECURITY.mdfor the full disclosure and the optional permissions layer that provides defense in depth (not a security boundary).
Drive a coding agent headlessly against real git repos: read a target repo's
.roomodes and .roo/rules-<slug>/ files, build a system prompt from mode +
rules, call an LLM API (OpenRouter in Phase 1) with the tool schema, execute
tool calls via plain fs/child_process (no vscode.* anywhere), and loop
until completion. Non-interactive by design.
headlesscode is not yet published to the npm registry, so install it from a
checkout:
git clone https://github.com/Capsize-Games/headlesscode.git
cd headlesscode
npm install
# Required (except for --dry-run):
export HEADLESSCODE_OPENROUTER_API_KEY=sk-or-...
# Optional:
export OPENROUTER_MODEL=deepseek/deepseek-v4-flash-0731 # default model
export OPENROUTER_HTTP_REFERER=https://example.com # OpenRouter app header
export OPENROUTER_APP_TITLE="headlesscode" # OpenRouter X-Title header
export HEADLESSCODE_WORKSPACE_ROOT=/path/to/target/repo # default workspace root
# Run a task against a target repo, straight from the checkout:
node bin/headlesscode.mjs --task "Fix the bug in src/index.ts" --workspace /path/to/target/repoTo avoid re-typing node <checkout>/bin/headlesscode.mjs from every project,
install a headlesscode command onto your PATH once:
scripts/install-cli.shThis writes a wrapper to ~/.local/bin/headlesscode (override with
HEADLESSCODE_BIN_DIR) that runs this checkout's src/cli.ts via its local
tsx, without cd-ing — so --repo/--workspace still default to whatever
directory you're standing in when you invoke it. Re-run the script any time
after git pull to point it at a moved checkout; the wrapper itself doesn't
need updating for ordinary code changes.
# from any repo, no HEADLESSCODE_ROOT plumbing needed:
headlesscode orchestrate --repo . --issue 42The rest of this README uses the plain headlesscode form for brevity —
substitute node <checkout>/bin/headlesscode.mjs if you haven't run
scripts/install-cli.sh yet.
--dry-run builds the full system prompt and validates configuration loading
without calling the LLM — useful for CI and for checking that a target repo's
.roomodes / .roo/rules-<slug>/ / AGENTS.md are picked up:
headlesscode --dry-run --mode code --workspace /path/to/target/repoIt prints the assembled system prompt plus a summary line (mode, custom modes loaded, exposed tools, prompt size). Exit code 0 means prompt building + mode / rules loading succeeded; non-zero means a config error.
export HEADLESSCODE_OPENROUTER_API_KEY=sk-or-...
export HEADLESSCODE_WORKSPACE_ROOT=~/Projects/some-target-repo
# Point the agent at an already-scoped issue, in an isolated worktree:
headlesscode \
--mode code \
--task "Implement issue #29: add retry logic to the HTTP client (see .roo/rules for project conventions)." \
--workspace ~/Projects/some-target-repo \
--max-iterations 50 \
--log-file ./headlesscode-session.logThe agent reads files, runs commands, writes code, and finishes by calling
attempt_completion (or by giving a final text answer). The final result is
printed to stdout. Exit code 0 = success; 1 = task failed (max iterations or
consecutive-mistake limit); 2 = usage/config error (e.g. missing
HEADLESSCODE_OPENROUTER_API_KEY).
To use headlesscode against a project that isn't set up as a headlesscode project yet, register it in one step:
headlesscode init --workspace ~/Projects/your-projectThis detects the project's stack(s) (which drives per-session instruction
selection), makes sure the project's .gitignore excludes .headlesscode/
session artifacts, and builds the codebase-search index and the codemap — no
manual index/codemap/.gitignore steps needed. The index step calls the
embedding API and costs real money unless you pass --skip-index or
--embedding-backend ollama. See headlesscode init --help for the full
options.
headlesscode --task "<task text>" [options]
headlesscode --task-file <path> [options]
headlesscode --dry-run [options]
headlesscode orchestrate --repo <path> --issue <n> [--issue <n> ...] [options]
headlesscode watch --owner <o> --repo <path> --label <name> [options]
--mode <slug> Mode to run in (built-in or from .roomodes). Default: code
--task <text> The task description for the agent
--task-file <path> Read the task from a file (relative to workspace)
--workspace <root> Workspace root (default: $HEADLESSCODE_WORKSPACE_ROOT or cwd)
--model <id> OpenRouter model id (default: $OPENROUTER_MODEL or deepseek/deepseek-v4-flash-0731)
--max-iterations <n> Loop iteration cap (default: 50)
--consecutive-error-limit <n> Consecutive mistakes before giving up (default: 3)
--max-cost-usd <n> Phase 6: per-session cost cap in USD (decimal). Default
$HEADLESSCODE_MAX_COST_USD; off when neither is set
--max-duration-ms <n> Phase 6: per-session wall-clock cap in ms. Default
$HEADLESSCODE_MAX_DURATION_MS; off when neither is set.
A tripped cap aborts with reason "budget"
--log-file <path> Also append structured logs to this file
--memory-dir <path> Phase 3 memory: store facts + session summaries under <path>
(enabled; default $HEADLESSCODE_MEMORY_DIR or
<workspace>/.headlesscode/memory). Memory is OFF unless set.
--no-memory Explicitly disable memory even if HEADLESSCODE_MEMORY_DIR is set
--allowed-commands <list> Comma-separated command prefixes the agent may run.
Default: $HEADLESSCODE_ALLOWED_COMMANDS, else
.headlesscode/permissions.json, else empty (=
allow everything except --denied-commands; see
SECURITY.md)
--denied-commands <list> Comma-separated command prefixes that are ALWAYS
refused (deny wins over allow; dangerous shell
substitutions are always blocked regardless).
Default: $HEADLESSCODE_DENIED_COMMANDS, else
.headlesscode/permissions.json, else empty
--protected-files <list> Comma-separated glob patterns of files the agent may
not write. Default: $HEADLESSCODE_PROTECTED_FILES,
else .headlesscode/permissions.json, else
".env,.env.*,*.pem,*.key,id_rsa*"
--allow-protected-writes Escape hatch: permit writes to protected files
(default: OFF). Also settable via
"allowProtectedWrites": true in
.headlesscode/permissions.json
--dry-run Build the system prompt + validate config, then exit (no API key)
--version / --help
orchestrate subcommand (Phase 2 — parallel worktrees, headless workers):
--repo <path> Target repo root (required)
--issue <n> Issue number to include (repeatable)
--issues-json <file> Read issues from a JSON array of {number,title,body}
(used when gh is unavailable, or for tests)
--file-issues With --issues-json: file a REAL GitHub issue for
each synthetic entry (gh issue create --repo
<origin-owner>/<origin-repo>), swap in the real
number returned, and print one confirmation line
per created issue. A real, visible write to
GitHub — opt-in, never automatic. Requires
--issues-json
--batch <name> Batch id in the state file (default round-<date>)
--review-mode <slug> Mode slug for review sessions (default deepseek-reviewer)
--no-review Spawn + watch only; skip the reviewer
--qa Phase 4: run a headless QA session (--mode qa-agent)
on each group after its review passes; record
qa {status,verdict,evidence} in the state file
--qa-mode <slug> Mode slug for QA sessions (default qa-agent; the
target repo's .roomodes + .roo/rules-<slug>/ are
spliced automatically)
--deploy Phase 4: after all groups done + reviewed + QA passed,
run the human-approval deploy gate
(scripts/deploy-gate.sh) — a hard stop that never runs
the repo's deploy-production.sh without explicit human
approval (interactive on a TTY, token/file otherwise)
--deploy-args <str> Deploy args forwarded to the deploy script after the
gate approves (space-separated flags; also DEPLOY_ARGS env)
--poll-interval-ms <n> Watcher poll interval (default 5000)
--max-concurrent-sessions <n> Phase 6 global cap on concurrent sessions across
processes (default $HEADLESSCODE_MAX_CONCURRENT_
SESSIONS or 3). At/over the cap this run ABORTS
with a clear message, exit 1
--dry-run Print the split plan + spawn commands, spawn nothing
watch subcommand (Phase 5 — GitHub issue watcher, poll-based intake):
--owner <o> GitHub owner (required)
--repo <path> Local clone of the target repo (required; worktrees
are spawned under <path>/.worktrees/). The GitHub repo
name defaults to the directory basename (--gh-repo overrides)
--label <name> The label that triggers processing, e.g. needs-agent
--poll-interval-ms <n> Sweep interval in continuous mode (default 60000)
--run-once One sweep then exit 0 (or 1 if any spawn failed)
--max-per-sweep <n> Max NEW issues spawned per sweep (default 5); the rest
stay 'pending' in state and are picked up next sweep
--max-concurrent-sessions <n> Phase 6 GLOBAL cap on concurrent sessions across
processes (default $HEADLESSCODE_MAX_CONCURRENT_
SESSIONS or 3). Interplay: maxPerSweep bounds one
sweep's burst; this bounds the total fleet — issues
beyond it stay 'pending' until slots free up
--state-file <path> Durable idempotency state (default
<repo>/.worktrees/.watcher-state.json)
--mode <slug> / --memory-dir <path>
Forwarded to the spawner (ORCHESTRATOR_MODE /
HEADLESSCODE_MEMORY_DIR)
--qa / --deploy Pass-through: recorded per batch for the follow-up
orchestrate completion run
--dry-run Sweep + print the spawn plan, spawn nothing, write no
state (still needs a GitHub token for listIssues)
--retry-failed Retry previously-failed spawns next sweep
src/llm/openrouter.ts— OpenRouter chat-completions client using nativefetch(no axios/node-fetch/openai SDK). ReadsHEADLESSCODE_OPENROUTER_API_KEY, optionalOPENROUTER_HTTP_REFERER/OPENROUTER_APP_TITLE; model defaultdeepseek/deepseek-v4-flash-0731(envOPENROUTER_MODELoverrides). Non-2xx → typedOpenRouterErrorwith status + body excerpt; supportsAbortSignaltimeouts.src/tools/executor.ts— headless executor forread_file,write_to_file,execute_command,list_files(plainfs/child_process), plusattempt_completion/ask_followup_questionhandlers and "not implemented" stubs for every other vendored tool schema. All file operations are resolved relative to the workspace root and rejected if they escape it (path-traversal guard viapath.resolve+ containment check). Command results are truncated (~30k chars) to keep context bounded.src/tools/output-summarizer.ts— OPT-IN local summarization of oversizedexecute_commandoutput: whenHEADLESSCODE_LOCAL_SUMMARIZATION=1, a result that would exceed the 30k char cap is compressed by a local Ollama chat model before reaching the cloud model, with a[Output summarized by local model…]transparency header. OFF by default; on any failure it falls back to today's exact blunt truncation (never an error, never a hang). Endpoint/model configurable viaHEADLESSCODE_OLLAMA_URL(defaulthttp://localhost:11434) andHEADLESSCODE_SUMMARIZATION_MODEL(defaultqwen3:8b). Deliberately limited to command output — file/diff content always stays verbatim.src/engine/parser.ts— OpenAI function-calling parser: JSON.parsestool_calls[].function.argumentswith a best-effort partial-JSON fallback; parse failures are marked and fed back as errors.src/engine/prompt.ts— wraps the vendoredSYSTEM_PROMPTbuilder: loads project.roomodes(same zod schema as Zoo Code'sCustomModesManager), passes the workspace ascwdso.roo/rules-*/AGENTS.mdsplice in, and selects the mode's exposed tools.src/engine/loop.ts—HeadlessSession, the orchestration loop. System + user → LLM → assistant (with tool_calls) → parse → execute →toolrole message → repeat. Terminates onattempt_completion(itsargs.resultis the final answer) or a text-only reply; fails bounded on max iterations orconsecutiveErrorLimitconsecutive mistakes (tool errors / parse errors / identical repeated calls). History truncation is a Phase 1 placeholder: system + first user always kept, sliding window of the last ~40 messages. The loop accepts an injectedllmClient(DI) so tests use a fake; the CLI wiresOpenRouterClient.src/engine/logger.ts— structured logger (timestamped lines to stdout/stderr, optional file).src/memory/— memory subsystem:src/memory/types.ts— theMemoryFact/SessionSummaryschema (per-project scoped, kindsconvention|decision|failure|knowledgewhere "things that didn't work" arefailure) and the two contracts:MemoryStore(the pluggable storage boundary) andEmbedder. Hard data-isolation requirement documented: the harness knowledge is a dedicated schema, never reachable through any customer tenant route.src/memory/local.ts—LocalMemoryStore: the fully working file backend (facts/<project>.jsonl+sessions/<project>.jsonl, append-only, idempotentaddFactby content hash,queryRecall= keyword matches (high weight) + local-embedder cosine similarity, deterministic ordering).src/memory/embed.ts—createLocalEmbedder(): zero-dependency, deterministic lexical-hash embedder (lowercase word + char-bigram tokens → fixed-dim L2-normalized vector). Placeholder for a real local embedding model behind the sameEmbedderinterface.src/memory/summarizer.ts—extractSessionSummary(deterministic; files/commands derived from the tool history, facts via keyword heuristics) +buildRollingSummary(compact markdown recap of the last N sessions, so a session never needs the infinite raw history).src/memory/uwuchat.ts— a remoteMemoryStoreimplementation stub for a future hosted memory API. Throws "not implemented" until its base-URL/token env vars are set.- Memory is wired into
HeadlessSessionas an opt-in config (memoryproject); when unset the loop behaves exactly as before. When set, the loop injects a## PROJECT MEMORYsection (recalled facts + rolling recap) into the first user message and records the session + extracted facts afterwards — and memory failures are always non-fatal.
src/orchestrator/— Phase 2 orchestration layer:split.ts(issue-splitting heuristics, deterministic + unit-tested),state.ts(.worktrees/.orchestrator-state.jsonread/write),reviewer.ts(adversarial fresh-context review run with a read-only executor),watch.ts(completion polling of.harness.donemarkers + stall guard), andcli.ts(theorchestratesubcommand — split → spawn viascripts/spawn-parallel-worktrees.sh→ watch → review → QA → deploy gate).src/qa/qa.ts— Phase 4 headless QA:runQa()runs a second harness session against a worktree in the target repo'sqa-agentmode (auto-spliced from.roomodes+.roo/rules-qa-agent/), with a generic checklist fallback when the repo has no such mode. Read + command tools only (nowrite_to_file) — QA verifies and reports, it never edits. Verdict parsing is fail-closed (pass/fail/error, defaultfail).src/deploy/gate.ts+src/deploy/gate-cli.ts— Phase 4 human-approval deploy gate: the pure, unit-tested decision functiondecideApproval(interactive y/N, one-time approval file, orDEPLOY_APPROVAL_TOKENmatching<repo>/.deploy-approval; never auto-approves) plus a thin CLI the bash wrapper calls.src/watcher/— Phase 5 GitHub issue watcher:github.ts(native-fetch GitHub REST client with label filter, PR filtering, pagination,GITHUB_API_BASE_URLoverride for tests/mocks),state.ts(durable idempotency state file — write-aheadspawned→done/failed,pendingfor capped issues, restart-safe),watch.ts(the poll loop: list by label → split → spawn via the existing bash spawner, bounded bymaxPerSweepand the Phase 6 global cap), andcli.ts(thewatchsubcommand — continuous or--run-once,--dry-run,--retry-failed).src/budget/— Phase 6 guardrails:cost.ts(model pricing table +estimateCost,HEADLESSCODE_PRICING_JSONoverride, conservative fallback for unlisted models),budget.ts(SessionBudget+BudgetTracker:tick()before each LLM call,record()after with usage tokens,check()snapshot,BudgetExceededError), andconcurrency.ts(ConcurrencyLimiter— fail-fast in-process semaphore — plusactiveSessionCountreading the durable orchestrator/watcher state files for a cross-process view). Wired intoHeadlessSession(budgetconfig,budgetUsageon results), the base CLI (--max-cost-usd/--max-duration-ms),orchestrate(aborts at the cap), the watcher (defers cap-exceeding issues topending), andrun-worker.sh/run-qa.sh(env forwarding).src/cloud/— Phase 6 ephemeral compute abstraction: theCloudProviderlifecycle interface (spawnWorktreeSession→waitReady→runHarness→collectResults→teardown) withLocalProcessProvideras the current local behavior behind it (reusesspawn-parallel-worktrees.sh+run-worker.sh), so a container/VM-per-issue backend slots in without touching the orchestration layer. A cloud-provider sketch is documented (evaluation only — no live setup; seedocs/phase6-cloud.md).src/cli.ts— theheadlesscodebin entry (+orchestrateandwatchsubcommand dispatch).scripts/run-worker.sh— launches one headless harness worker per worktree (pid, log, exit code,.harness.donemarker).scripts/run-qa.sh— Phase 4 QA wrapper mirroring run-worker.sh: launches one harness QA session per worktree (.qa-task.md,.qa.pid,qa.log,.qa.exit,.qa.done/).scripts/deploy-gate.sh— Phase 4 gate wrapper: path safety, deployment summary, interactive + token/file approval, then (and only then) invokes the repo'sscripts/deploy-production.shwith forwarded deploy args. Exit 3 = human DENIED (hard stop).scripts/spawn-parallel-worktrees.sh— spawns one git worktree + harness worker per group (worktree/.env/branch conventions,run-worker.sh+ state-file writes, no GUI involved).
The loop exposes to the model exactly the tools the executor can actually run
for the selected mode: the intersection of the vendored mode tool groups
(getToolsForMode) with the Phase 1 executable set
(read_file, write_to_file, execute_command, list_files,
attempt_completion, ask_followup_question). Stub-only tools (apply_diff,
search_files, …) stay registered in the executor purely as a safety net
(clear "not implemented" error) but are NOT advertised to the model, so it
doesn't waste turns calling them.
npm run typecheck # npx tsc --noEmit (whole repo incl. vendored core)
npm run smoke # vendored prompt builder smoke test (no network)
npm test # unit tests with fake LLM clients (no network/key)
bash scripts/e2e/run.sh # Phase 1 integration (mock OpenRouter)
bash scripts/e2e-phase2/run.sh # Phase 2 integration (spawn + watch + review)
bash scripts/e2e-phase4/run.sh # Phase 4 integration (QA + deploy gate, fake deploy)
bash scripts/e2e-phase5/run.sh # Phase 5 integration (watcher vs fake GitHub server,
# stubbed spawner: state transitions + no double-spawn)
bash scripts/e2e-phase6/run.sh # Phase 6 integration (budget abort via mock OpenRouter
# + concurrency-cap sweep via fake GitHub + stubbed spawner)
npm run cli -- --dry-run --workspace . # build this repo's system promptThe engine tests (src/engine/__tests__/loop.test.ts) run the full loop with a
fake LlmClient injected via the HeadlessSession constructor — no network,
no API key required. Phase 2 adds src/orchestrator/__tests__/ (split
heuristics, state round-trip, reviewer verdict parsing), and the e2e scripts
drive the real CLI through a local mock OpenRouter server, including a
2-worktree parallel spawn, completion-marker polling, and a read-only review
invocation. Phase 5 adds src/watcher/__tests__/ (github client with an
injected fetch, watcher-state idempotency semantics, and the watch loop with
an injected gh client + spawner covering spawn/idempotency/cap/failure/
dry-run/abort) and scripts/e2e-phase5/run.sh (watcher against a fake GitHub
server with a stubbed spawner).
- ✅ Phase 1 Subtask 1 — vendored portable Zoo Code core
(
src/vendor/zoo-code/, read-only dependency). - ✅ Phase 1 Subtask 2 — runtime engine (OpenRouter client, tool executor, parser, orchestration loop, CLI, tests).
- ✅ Phase 2 — headless orchestration layer: drop-in
spawn-parallel-worktrees.sh+run-worker.sh(harness subprocess per worktree,.harness.donecompletion markers), issue-splitting heuristics port,.orchestrator-state.jsonstate management, headless reviewer, and theorchestrateCLI subcommand (seedocs/phase2-orchestration.md). - ✅ Phase 3 — memory subsystem: per-project knowledge facts + rolling session
summaries (
src/memory/), local deterministic embedder for semantic recall, opt-inHeadlessSession/CLI wiring (--memory-dir,--no-memory), and a pluggableMemoryStorecontract with a remote-backend client stub. - ✅ Phase 4 — QA + deploy gate: headless QA runs the target repo's
qa-agentmode (--qa/--qa-mode), verdict parsing is fail-closed, results land in the state file's per-groupqafield; the human-approval deploy gate (--deploy,scripts/deploy-gate.sh+src/deploy/gate.ts) is a hard stop in front ofdeploy-production.shthat never auto-approves (seedocs/phase4-qa.mdanddocs/phase4-deploy-gate.md). - ✅ Phase 5 — GitHub issue watcher: poll-based intake (
watchsubcommand) — detect issues by label via the GitHub REST API (GH_TOKEN), fan each out throughsplitIssues+ the existingspawn-parallel-worktrees.sh, track idempotency durably (.worktrees/.watcher-state.json, write-ahead ordering,pendingcap deferral, restart-safe), optional--dry-run/--run-once/--retry-failed; webhook upgrade designed but not built as a server (seedocs/phase5-issue-watcher.md). - ✅ Phase 6 — cloud scaling, guardrails-first: per-session cost/time/iteration
budget (
src/budget/,--max-cost-usd/--max-duration-ms, budgetUsage on results, worker env forwarding) + a hard concurrent-session cap (HEADLESSCODE_MAX_CONCURRENT_SESSIONS, orchestrate aborts / watcher defers topending) + theCloudProviderabstraction withLocalProcessProviderand an evaluation-only cloud-provider sketch (seedocs/phase6-cloud.md). No live cloud launched; a container/VM-per-issue backend slots in behind the same interface. - ⏳ Phase 3 (remaining) — token-based condensation.
This project contains Apache-2.0-licensed code derived from
Zoo Code
(Zoo-Code-Org/Zoo-Code, commit ca9b60f), itself a Roo Code fork. Prompt
text, tool schemas, and mode/rules loading logic are reused under the terms of
the Apache License 2.0. See LICENSE and
ATTRIBUTION.md.