Skip to content

Commit 0729eea

Browse files
Add a repo README for the runner: how a run works, API, setup, tests, safety rules
runner/README.md now points to it.
1 parent 4363d43 commit 0729eea

2 files changed

Lines changed: 241 additions & 39 deletions

File tree

‎README.md‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# codehands-agent
2+
3+
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.
4+
5+
**Owner:** Tahira
6+
**Used by:** the daemon (Midhat), which loads the runner as a library
7+
**Contract:** C2 in [codehands-contracts](https://github.com/devweekends/codehands-contracts)
8+
**Part of:** [Code Hands AI](https://github.com/devweekends/codehands-docs). Read the design in that README first.
9+
10+
## Where it sits
11+
12+
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.
13+
14+
```mermaid
15+
flowchart LR
16+
app["Desktop app"] -- "writes a task" --> db[("~/.codehands/state.db")]
17+
db --> daemon["Daemon"]
18+
daemon -- "runner.start(options)" --> runner["Runner (this repo)"]
19+
runner -- "RunnerEvents" --> daemon
20+
daemon -- "sessions, events" --> db
21+
runner -- "git worktree add" --> wt[("~/.codehands/worktrees/…")]
22+
runner -- "spawns, reads stdout" --> cli["claude -p"]
23+
cli -- "edits files in" --> wt
24+
```
25+
26+
## What a run looks like
27+
28+
```mermaid
29+
sequenceDiagram
30+
participant D as Daemon
31+
participant R as Runner
32+
participant G as git
33+
participant C as claude -p
34+
D->>R: start({ sessionId, repoPath, prompt, agent, model, budgetUsd })
35+
R->>G: rev-parse HEAD (base commit)
36+
R->>G: worktree add -b codehands/<slug>-<id8> ~/.codehands/worktrees/<repoKey>/<id8>
37+
R->>C: spawn in the worktree, --output-format stream-json
38+
loop every stdout line
39+
C-->>R: one JSON line
40+
R-->>D: task.started, message, file.read, file.changed, command.*, test.result
41+
end
42+
C-->>R: result line, process exits
43+
R->>G: add -A, diff --cached --stat <base>
44+
R-->>D: task.finished with diffStat (status: awaiting_approval)
45+
D->>R: diff(sessionId)
46+
R-->>D: { patch, stat }
47+
alt approve
48+
D->>R: approve(sessionId, message)
49+
R->>G: commit on the branch, remove the worktree, keep the branch
50+
R-->>D: { branch, commit }
51+
else reject
52+
D->>R: reject(sessionId)
53+
R->>G: remove the worktree, delete the branch
54+
end
55+
```
56+
57+
When it ends:
58+
59+
- **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.
60+
- **Rejected:** the worktree and the branch are gone, as if nothing happened.
61+
- **Failed:** a `task.failed` event says why. Approve and reject are refused.
62+
63+
## Status
64+
65+
Week 1 (Oct 19 to 23, 2026).
66+
67+
| Piece | File | State |
68+
| --- | --- | --- |
69+
| Fake runner, the stand-in the daemon builds against | [runner/src/fake-runner.ts](runner/src/fake-runner.ts) | Done |
70+
| Session records and legal state moves | [runner/src/session-store.ts](runner/src/session-store.ts) | Done |
71+
| `seq` 1, 2, 3 … per session | [runner/src/events.ts](runner/src/events.ts) | Done |
72+
| Claude Code arguments and stream-json parser | [runner/src/agents/claude-code.ts](runner/src/agents/claude-code.ts) | Done, tested against a recorded run |
73+
| Worktrees: branch names, add, remove, prune | [runner/src/git/worktree.ts](runner/src/git/worktree.ts) | Done |
74+
| Diff since the base commit, commit on approve | [runner/src/git/diff.ts](runner/src/git/diff.ts) | Done |
75+
| `start`, `diff`, `approve`, `reject` | [runner/src/runner.ts](runner/src/runner.ts) | Done, tested with real runs |
76+
| `stop`: kill the whole process tree, keep the worktree | [runner/src/process/kill-tree.ts](runner/src/process/kill-tree.ts) | To do. Throws `not implemented` |
77+
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | To do. Throws `not implemented`. The `--resume` arguments exist |
78+
| Codex: `codex exec --json` to events | [runner/src/agents/codex.ts](runner/src/agents/codex.ts) | To do. `start()` with `agent: "codex"` fails cleanly for now |
79+
| Repo notes read before a run, summary appended after | [runner/src/memory/notes.ts](runner/src/memory/notes.ts) | To do. `repoKey()` is done |
80+
81+
Next, from the [design](https://github.com/devweekends/codehands-docs#who-owns-what): 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.
82+
83+
## Using it
84+
85+
```ts
86+
import { randomUUID } from "node:crypto";
87+
import { createRunner, createFakeRunner } from "@codehands/runner";
88+
89+
const runner = process.env.CODEHANDS_FAKE ? createFakeRunner() : createRunner();
90+
const sessionId = randomUUID();
91+
92+
for await (const event of runner.start({
93+
sessionId,
94+
repoPath: "/path/to/repo",
95+
prompt: "Fix the failing test. Run npm test to check.",
96+
agent: "claude-code",
97+
model: "sonnet",
98+
budgetUsd: 0.5,
99+
})) {
100+
console.log(event.seq, event.type, event.payload);
101+
}
102+
103+
const { stat, patch } = await runner.diff(sessionId);
104+
const { branch, commit } = await runner.approve(sessionId, "Fix the date bug");
105+
// or: await runner.reject(sessionId);
106+
```
107+
108+
### Exports
109+
110+
| Export | What |
111+
| --- | --- |
112+
| `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 |
113+
| `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 |
114+
| `EventSequence` | Hands out events with the next `seq` |
115+
| `SessionStore`, `SessionRecord` | In-memory session records with the legal-move check from the contracts |
116+
| `branchName(prompt, sessionId)`, `slugify(text)` | `codehands/fix-the-failing-test-3f2a9c1e` |
117+
118+
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.
119+
120+
### How Claude Code's output becomes events
121+
122+
Each stdout line from `claude -p --output-format stream-json --verbose` is one JSON object. The parser in [claude-code.ts](runner/src/agents/claude-code.ts) maps it like this:
123+
124+
| Claude Code line | Event |
125+
| --- | --- |
126+
| `system` / `init` | `task.started` |
127+
| `system` / `permission_denied` | `message` with `denied: true` |
128+
| `assistant` text block | `message` |
129+
| `assistant` tool use: Read, Glob, Grep | `file.read` |
130+
| `assistant` tool use: Edit, Write, MultiEdit, NotebookEdit | `file.changed` |
131+
| `assistant` tool use: Bash, PowerShell | `command.started` |
132+
| `user` tool result for a command | `command.finished`, plus `test.result` when the command was a test run |
133+
| `result` | `task.finished`, or `task.failed` when it is an error |
134+
| A line that is not JSON | `message` with the raw text |
135+
| `rate_limit_event`, thinking, other system lines | Skipped |
136+
137+
Some details that matter:
138+
139+
- One line can hold several content blocks, so one line can become several events.
140+
- Tool results can arrive out of order, so each is matched to its tool use by id.
141+
- A test counts as failed if its output shows failures even when the exit code says 0, because `npm test | tail` hides the exit code.
142+
- Every event keeps the original line in `raw`. Cost and usage are passed through exactly as Claude reports them.
143+
- `task.finished` is held back until the process has exited, so it can carry `diffStat`.
144+
145+
## On disk
146+
147+
| Path | What |
148+
| --- | --- |
149+
| `~/.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 |
150+
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes (planned) |
151+
| `codehands/<slug>-<first 8 of sessionId>` | The task's branch, in your repo |
152+
153+
`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.
154+
155+
## Safety rules
156+
157+
The agent runs on your machine with no sandbox until the Docker box lands in week 2. Until then:
158+
159+
- **Run it only on our own repos.**
160+
- Claude runs with `--permission-mode acceptEdits` and a short allow list: `Read, Edit, Write, Glob, Grep`, and only `npm test` in Bash or PowerShell. Anything else is blocked and shows up as a `message` with `denied: true`.
161+
- **Never** `--dangerously-skip-permissions`.
162+
- Every run passes `--max-budget-usd` (default $0.50).
163+
- `--strict-mcp-config` keeps your own connectors (Gmail, GitHub and so on) out of agent runs.
164+
- API keys never go into events, logs or diffs.
165+
- The runner never writes `CLAUDE.md` or `AGENTS.md` into the worktree. They would show up in the diff.
166+
167+
## Set up
168+
169+
You need Node 22, pnpm 10, Git, and for real runs the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI, logged in.
170+
171+
The runner depends on the contracts by folder in week 1, so clone both side by side:
172+
173+
```bash
174+
git clone https://github.com/devweekends/codehands-contracts.git
175+
git clone https://github.com/devweekends/codehands-agent.git
176+
177+
cd codehands-contracts && pnpm install && pnpm build
178+
cd ../codehands-agent/runner && pnpm install
179+
```
180+
181+
```text
182+
CodeHandsAI/
183+
codehands-contracts/ built first, dist/ is what the runner imports
184+
codehands-agent/
185+
runner/ the package: "@codehands/contracts": "file:../../codehands-contracts"
186+
```
187+
188+
## Commands
189+
190+
Run these in `runner/`.
191+
192+
| Command | What | Costs money |
193+
| --- | --- | --- |
194+
| `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 |
195+
| `pnpm fake` | Prints the fake runner's events, one JSON object per line. This is exactly what the daemon receives | No |
196+
| `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 |
197+
| `pnpm build` | Compiles to `dist/` | No |
198+
| `pnpm typecheck` | Type check only | No |
199+
200+
`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.
201+
202+
The scripts in `runner/scripts/`:
203+
204+
| Script | What |
205+
| --- | --- |
206+
| `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` |
207+
| `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 |
208+
209+
## Tests
210+
211+
| File | Covers |
212+
| --- | --- |
213+
| [claude-parser.test.ts](runner/test/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 |
214+
| [runner.test.ts](runner/test/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 |
215+
| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing |
216+
| [worktree.test.ts](runner/test/worktree.test.ts) | Branch names |
217+
218+
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.
219+
220+
## Common problems
221+
222+
| Symptom | Cause and fix |
223+
| --- | --- |
224+
| `task.failed`: "is not a git repo or has no commits yet" | Worktrees need a commit to branch from. Commit something first |
225+
| `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 |
226+
| `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 |
227+
| Cannot find module `@codehands/contracts` | Build the contracts repo first, and check it is cloned next to `codehands-agent` |
228+
| 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 |
229+
| "nothing to approve: the agent made no changes" | The agent changed no files. Reject the session instead |
230+
231+
## Contributing
232+
233+
- One branch per Linear issue, for example `tahira/cod-12-runner-start`. Put the issue id in the PR title.
234+
- `main` is always green. CI runs build and test on every PR.
235+
- A change to `StartOptions`, `RunnerEvent` or `Runner` is a contract change. It goes to [codehands-contracts](https://github.com/devweekends/codehands-contracts#changing-a-contract) first, and `createFakeRunner()` is updated the same week.
236+
- The week by week plan is in [roadmap/tahira.md](https://github.com/devweekends/codehands-docs/blob/main/roadmap/tahira.md).

‎runner/README.md‎

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,9 @@
1-
# codehands-runner
1+
# @codehands/runner
22

3-
Given a repo and a prompt, the runner makes a private copy of the repo (a git worktree on a new branch). It runs Claude Code or Codex there with no screen and turns their output into events. Someone can then stop, resume, approve or reject the change.
4-
5-
Owner: Tahira · Consumed by: the daemon in `codehands-cli` (Midhat) · Contract: [`codehands-contracts`](../codehands-contracts)
6-
7-
## Layout
8-
9-
| Path | What | When |
10-
| --- | --- | --- |
11-
| `src/fake-runner.ts` | Stand-in that emits 12 events, 300 ms apart. Midhat plugs it in today. Also the demo backup | Mon, done |
12-
| `src/session-store.ts` | Session records and the legal state moves | Mon, done |
13-
| `src/events.ts` | `seq` 1, 2, 3 … per session | Mon, done |
14-
| `src/agents/claude-code.ts` | `claude -p` arguments, done. Stream-json line → events, to do | Tue |
15-
| `src/git/worktree.ts` | Branch names, done. Worktree add, remove and prune, to do | Tue, Wed |
16-
| `src/runner.ts` | `start`, then `diff`, `approve`, `reject`, `stop`, then `resume` | Tue to Thu |
17-
| `src/git/diff.ts` | `git add -A`, `diff --cached --binary`, commit | Wed |
18-
| `src/process/kill-tree.ts` | Stop politely, then kill the whole process tree | Wed |
19-
| `src/agents/codex.ts` | `codex exec --json` → events | Thu pm, Fri |
20-
| `src/memory/notes.ts` | Per-repo notes in, a summary appended after each run | Fri |
21-
| `scripts/spike.sh` | Monday's hand spike, saves `test/fixtures/spike-1.jsonl` | Mon |
22-
| `scripts/reset-fixture.sh` | Puts the fixture repo back to its starting state before a rehearsal | Wed night |
23-
24-
## Run it
25-
26-
Clone `codehands-contracts` next to this repo and build it first:
3+
The runner package. Everything about it (what it does, the API, set up, commands, tests, safety rules) is in the [repo README](../README.md).
274

285
```bash
29-
cd ../codehands-contracts && pnpm install && pnpm build
30-
cd ../codehands-runner && pnpm install
31-
pnpm test # parser tests replay recorded JSONL, no API cost
32-
pnpm fake # print the fake runner's events
6+
pnpm install
7+
pnpm test # no API cost
8+
pnpm fake # the fake runner's events
339
```
34-
35-
Add the runtime dependencies when you start on Tuesday: `pnpm add execa tree-kill`.
36-
37-
## Rules this week
38-
39-
- Only run on our own repos. There is no sandbox until week 2.
40-
- `acceptEdits` plus a short `--allowedTools` list. Never use `--dangerously-skip-permissions`. Always pass `--max-budget-usd`.
41-
- API keys never go into events, logs or the diff.
42-
43-
The full week is in `codehands-docs/weeks/week-1/tahira.md`.

0 commit comments

Comments
 (0)