The runner. Given a repo and a prompt, it makes a private copy of the repo (a git worktree on a new branch), runs Claude Code there with no screen, and turns its output into events. A person then reads the change and approves it, which commits it on the branch, or rejects it, which deletes it. Your own checkout is never touched.
Owner: Tahira Used by: the daemon (Midhat), which loads the runner as a library Contract: C2 in codehands-contracts Part of: Code Hands AI. Read the design in that README first.
Everything runs on your machine. The runner is not a server and has no process of its own. The daemon imports it, calls start(), and stores every event it yields in the local database, where the desktop app reads them.
flowchart LR
app["Desktop app"] -- "writes a task" --> db[("~/.codehands/state.db")]
db --> daemon["Daemon"]
daemon -- "runner.start(options)" --> runner["Runner (this repo)"]
runner -- "RunnerEvents" --> daemon
daemon -- "sessions, events" --> db
runner -- "git worktree add" --> wt[("~/.codehands/worktrees/…")]
runner -- "spawns, reads stdout" --> cli["claude -p"]
cli -- "edits files in" --> wt
sequenceDiagram
participant D as Daemon
participant R as Runner
participant G as git
participant C as claude -p
D->>R: start({ sessionId, repoPath, prompt, agent, model, budgetUsd })
R->>G: rev-parse HEAD (base commit)
R->>G: worktree add -b codehands/<slug>-<id8> ~/.codehands/worktrees/<repoKey>/<id8>
R->>C: spawn in the worktree, --output-format stream-json
loop every stdout line
C-->>R: one JSON line
R-->>D: task.started, message, file.read, file.changed, command.*, test.result
end
C-->>R: result line, process exits
R->>G: add -A, diff --cached --stat <base>
R-->>D: task.finished with diffStat (status: awaiting_approval)
D->>R: diff(sessionId)
R-->>D: { patch, stat }
alt approve
D->>R: approve(sessionId, message)
R->>G: commit on the branch, remove the worktree, keep the branch
R-->>D: { branch, commit }
else reject
D->>R: reject(sessionId)
R->>G: remove the worktree, delete the branch
end
When it ends:
- Approved: the commit sits on
codehands/<slug>-<id8>in your repo, one commit on top of the base. Your current branch did not move. Merge or push it like any branch. - Rejected: the worktree and the branch are gone, as if nothing happened.
- Failed: a
task.failedevent says why. Approve and reject are refused.
Week 1 (Oct 19 to 23, 2026).
| Piece | File | State |
|---|---|---|
| Fake runner, the stand-in the daemon builds against | runner/src/fake-runner.ts | Done |
| Session records and legal state moves | runner/src/session-store.ts | Done |
seq 1, 2, 3 … per session |
runner/src/events.ts | Done |
| Claude Code arguments and stream-json parser | runner/src/agents/claude-code.ts | Done, tested against a recorded run |
| Worktrees: branch names, add, remove, prune | runner/src/git/worktree.ts | Done |
| Diff since the base commit, commit on approve | runner/src/git/diff.ts | Done |
start, diff, approve, reject |
runner/src/runner.ts | Done, tested with real runs |
stop: kill the whole process tree, keep the worktree |
runner/src/process/kill-tree.ts | Done. task.stopped comes out of the start() stream; stop() resolves once the session is stopped |
resume: same worktree, seq continues |
runner/src/runner.ts | Done. Runs claude -p --resume <sessionId> through the same runAgent() as start(), so it ends in awaiting_approval or task.failed |
Codex: codex exec --json to events |
runner/src/agents/codex.ts | To do. start() with agent: "codex" fails cleanly for now |
| Repo notes read before a run, summary appended after | runner/src/memory/notes.ts | To do. repoKey() is done |
Next, from the design: a model per phase, naming provider errors as one provider.status event (rate_limited, auth_expired, usage_limit, subscription_inactive, down), memory and user preferences, and two models on one task.
import { randomUUID } from "node:crypto";
import { createRunner, createFakeRunner } from "@codehands/runner";
const runner = process.env.CODEHANDS_FAKE ? createFakeRunner() : createRunner();
const sessionId = randomUUID();
for await (const event of runner.start({
sessionId,
repoPath: "/path/to/repo",
prompt: "Fix the failing test. Run npm test to check.",
agent: "claude-code",
model: "sonnet",
budgetUsd: 0.5,
})) {
console.log(event.seq, event.type, event.payload);
}
const { stat, patch } = await runner.diff(sessionId);
const { branch, commit } = await runner.approve(sessionId, "Fix the date bug");
// or: await runner.reject(sessionId);| Export | What |
|---|---|
createRunner(options?) |
The real runner. worktreeRoot overrides ~/.codehands/worktrees. agentCommand swaps the program it runs, which is how the tests replay a recording instead of calling Claude |
createFakeRunner({ delayMs? }) |
Emits 12 scripted events, 300 ms apart, and supports every method including stop and resume. Costs nothing, needs no repo. Build against it, and keep it as the demo backup |
EventSequence |
Hands out events with the next seq |
SessionStore, SessionRecord |
In-memory session records with the legal-move check from the contracts |
branchName(prompt, sessionId), slugify(text) |
codehands/fix-the-failing-test-3f2a9c1e |
The runner keeps session records in memory only. The daemon owns the real database. A runner that restarts forgets its sessions; stop, resume and crash recovery are where that changes.
Each stdout line from claude -p --output-format stream-json --verbose is one JSON object. The parser in claude-code.ts maps it like this:
| Claude Code line | Event |
|---|---|
system / init |
task.started |
system / permission_denied |
message with denied: true |
assistant text block |
message |
assistant tool use: Read, Glob, Grep |
file.read |
assistant tool use: Edit, Write, MultiEdit, NotebookEdit |
file.changed |
assistant tool use: Bash, PowerShell |
command.started |
user tool result for a command |
command.finished, plus test.result when the command was a test run |
result |
task.finished, or task.failed when it is an error |
| A line that is not JSON | message with the raw text |
rate_limit_event, thinking, other system lines |
Skipped |
Some details that matter:
- One line can hold several content blocks, so one line can become several events.
- Tool results can arrive out of order, so each is matched to its tool use by id.
- A test counts as failed if its output shows failures even when the exit code says 0, because
npm test | tailhides the exit code. - Every event keeps the original line in
raw. Cost and usage are passed through exactly as Claude reports them. task.finishedis held back until the process has exited, so it can carrydiffStat.
| Path | What |
|---|---|
~/.codehands/worktrees/<repoKey>/<first 8 of sessionId> |
The task's worktree. Outside your repo so worktrees never nest, and short because of Windows path limits |
~/.codehands/memory/<repoKey>/notes.md |
Per-repo notes (planned) |
codehands/<slug>-<first 8 of sessionId> |
The task's branch, in your repo |
repoKey is the first 12 characters of the SHA-1 of the repo's origin URL, or of its absolute path when there is no origin. Two clones of the same repo share a key.
The agent runs on your machine with no sandbox until the Docker box lands in week 2. Until then:
- Run it only on our own repos.
- Claude runs with
--permission-mode acceptEditsand a short allow list:Read, Edit, Write, Glob, Grep, and onlynpm testin Bash or PowerShell. Anything else is blocked and shows up as amessagewithdenied: true. - Never
--dangerously-skip-permissions. - Every run passes
--max-budget-usd(default $0.50). --strict-mcp-configkeeps your own connectors (Gmail, GitHub and so on) out of agent runs.- API keys never go into events, logs or diffs.
- The runner never writes
CLAUDE.mdorAGENTS.mdinto the worktree. They would show up in the diff.
You need Node 22, pnpm 10, Git, and for real runs the Claude Code CLI, logged in.
The runner depends on the contracts by folder in week 1, so clone both side by side:
git clone https://github.com/devweekends/codehands-contracts.git
git clone https://github.com/devweekends/codehands-agent.git
cd codehands-contracts && pnpm install && pnpm build
cd ../codehands-agent/runner && pnpm installCodeHandsAI/
codehands-contracts/ built first, dist/ is what the runner imports
codehands-agent/
runner/ the package: "@codehands/contracts": "file:../../codehands-contracts"
Run these in runner/.
| Command | What | Costs money |
|---|---|---|
pnpm test |
Every test. The parser replays a recorded run, the runner tests make real git repos in a temp folder and use a stand-in for claude |
No |
pnpm fake |
Prints the fake runner's events, one JSON object per line. This is exactly what the daemon receives | No |
pnpm try [repoPath] [prompt] [--approve | --reject] |
One real run through the runner, one line per event, then the diff stat, then approve, reject or keep | Yes, capped at $0.50 |
pnpm build |
Compiles to dist/ |
No |
pnpm typecheck |
Type check only | No |
pnpm try defaults to the fixture repo at ~/codehands-fixtures/date-bug and the prompt "Fix the failing test. Run npm test to check." Without a flag it asks what to do at the end.
The scripts in runner/scripts/:
| Script | What |
|---|---|
spike.sh [fixture] |
The week 1 hand spike. Runs claude -p once in a fresh worktree and saves the output to test/fixtures/spike-1.jsonl |
reset-fixture.sh [fixture] |
Puts the fixture repo back: removes every worktree and codehands/* branch, resets and cleans. Run it after pnpm try and before every rehearsal |
| File | Covers |
|---|---|
| claude-parser.test.ts | The whole recorded run replays in order; each mapping above; blocked commands; failed tests hidden behind exit code 0; error results; broken lines; raw on every event |
| runner.test.ts | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves main alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped |
| fake-runner.test.ts | Event order and seq; approve only after finishing; stop then resume with seq continuing |
| worktree.test.ts | Branch names |
To record a new fixture, run scripts/spike.sh (or a Codex equivalent), read every line of the output, then write tests that replay it. Never call a real agent from a test.
| Symptom | Cause and fix |
|---|---|
task.failed: "is not a git repo or has no commits yet" |
Worktrees need a commit to branch from. Commit something first |
task.failed: "the agent exited without a result" |
claude is not installed, not on PATH, or not logged in. stderr on the event says which |
fatal: '<branch>' is already checked out or a worktree already exists |
A previous run was left behind. Run scripts/reset-fixture.sh, or git worktree prune in the repo |
Cannot find module @codehands/contracts |
Build the contracts repo first, and check it is cloned next to codehands-agent |
| Child processes keep running after a stop on Windows | Killing the parent does not kill its children (claude → npm test → node). That is what stopProcessTree is for |
| "nothing to approve: the agent made no changes" | The agent changed no files. Reject the session instead |
- One branch per Linear issue, for example
tahira/cod-12-runner-start. Put the issue id in the PR title. mainis always green. CI runs build and test on every PR.- A change to
StartOptions,RunnerEventorRunneris a contract change. It goes to codehands-contracts first, andcreateFakeRunner()is updated the same week. - The week by week plan is in roadmap/tahira.md.