|
| 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). |
0 commit comments