Run a coding agent where the work actually happens: in a terminal, a CI job,
or an isolated git worktree. headlesscode brings the useful parts of
Zoo Code to a plain Node.js process,
without requiring a VS Code window.
It is made for real repositories and real engineering loops. The harness reads the target project's modes and rules, gives the model a controlled tool set, keeps work separated in git worktrees, and leaves behind logs and state that a person can inspect.
- Run repeatable repository tasks. Give it a task or GitHub issue and let it work through files, commands, and tests from a non-interactive process.
- Keep parallel work organized. Split issues into worker groups, run them in separate worktrees, review the results, and optionally run QA before a human-approved deploy.
- Remember the project. Opt-in local memory stores project facts and rolling session summaries, with a pluggable storage boundary for a future remote backend.
- Bound the expensive parts. Per-session cost, duration, iteration, and fleet-concurrency limits are built into the orchestration and watcher paths.
- Experiment with improvement. The
improvecommand runs a bounded recursive self-improvement loop against an external evaluator, with the default worker using a local Qwen 3.5 9B model through Ollama.
The project also includes a GitHub issue watcher and an evaluation-only cloud provider interface. No live cloud resources are launched by the current implementation.
Security warning: default-allow arbitrary command execution. By default,
headlesscoderuns arbitrary shell commands with the invoking user's privileges. It can read and modify files, including credentials such as~/.sshand~/.aws, without approval prompts. Use it only with trusted tasks and isolate it with a container, VM, or dedicated user when untrusted content is involved. The optional permissions layer is defense in depth, not a security boundary. SeeSECURITY.md.
Install from npm:
npm install -g headlesscodeOr run it without installing, via npx:
npx headlesscode --task "Fix the bug in src/index.ts" --workspace /path/to/target/repo# 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
headlesscode --task "Fix the bug in src/index.ts" --workspace /path/to/target/repoTo work from a source checkout instead (for contributing):
git clone https://github.com/Capsize-Games/headlesscode.git
cd headlesscode
npm install
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).
headlesscode improve is a bounded research loop for improving the harness
itself. A supervisor creates isolated candidate worktrees, asks the local Qwen
3.5 9B worker to make focused changes, runs regression plus visible and hidden
evaluations, and keeps the archive, score, and selection decision outside the
candidate worktree. Each generation also produces a report for human review.
# Inspect the planned experiment without creating worktrees or calling a model:
npx tsx src/cli.ts improve --repo . --dry-run
# Run one small generation with the local Ollama model:
npx tsx src/cli.ts improve --repo . --population 2 --generations 1This is an experiment, not unattended model training and not a security boundary. The current loop records model-candidate and training interfaces, trajectory datasets, parent-selection policies, and resource-tagged jobs, but does not yet train adapters, schedule multiple trajectories, or provide OS-level candidate isolation. Those limits are tracked in the RSI roadmap.
The implemented loop is intentionally honest about what remains. Follow-up work is tracked in GitHub:
- OS-level candidate sandbox
- Cryptographically verifiable evaluator and artifacts
- Resource-aware resumable scheduler
- Adaptive multi-trajectory search
- Validated curriculum fixtures
- Adversarial evaluation and cross-model supervision
- Real LoRA or QLoRA backend
See docs/recursive-self-improvement.md
for the design and docs/rsi-progress.md for the
record of the first bounded runs.
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.