|
1 | 1 | # codehands-contracts |
2 | 2 |
|
3 | | -The shapes every Code Hands AI repo agrees on: session rows, session states, runner events and the `Runner` interface. |
| 3 | +The shapes every Code Hands AI repo agrees on: session rows, session statuses and the legal moves between them, runner events, and the `Runner` interface. |
4 | 4 |
|
5 | | -| File | What | Consumed by | |
| 5 | +This package holds types plus a few small constants and helpers. There is no runtime logic here. It exists so the parts can be built in separate repos by separate people and still fit together on Friday. |
| 6 | + |
| 7 | +**Owners:** Midhat and Tahira. Every repo that uses a contract approves changes to it. |
| 8 | +**Status:** v0.1.0, proposed Mon Oct 19 and frozen Fri Oct 23, 2026. |
| 9 | +**Part of:** [Code Hands AI](https://github.com/devweekends/codehands-docs). Read the design in that README first. |
| 10 | + |
| 11 | +## Why it exists |
| 12 | + |
| 13 | +Code Hands AI is split into one repo per part ([decision 0001](https://github.com/devweekends/codehands-docs/blob/main/decisions/0001-separate-repos.md)). Separate repos only work if every shape two repos share is written down in one place, typed and versioned. That place is this repo. |
| 14 | + |
| 15 | +```mermaid |
| 16 | +flowchart LR |
| 17 | + contracts["codehands-contracts"] |
| 18 | + desktop["codehands-desktop (Tania)"] |
| 19 | + daemon["daemon (Midhat)"] |
| 20 | + agent["codehands-agent: runner (Tahira)"] |
| 21 | + backend["codehands-backend: state API and sync (Memoona)"] |
| 22 | + contracts -. types .-> desktop |
| 23 | + contracts -. types .-> daemon |
| 24 | + contracts -. types .-> agent |
| 25 | + contracts -. types .-> backend |
| 26 | + daemon -- "C2: runner.start(...)" --> agent |
| 27 | + agent -- "C2: RunnerEvents" --> daemon |
| 28 | +``` |
| 29 | + |
| 30 | +## What is in v0.1.0 |
| 31 | + |
| 32 | +| File | Exports | Used by | |
| 33 | +| --- | --- | --- | |
| 34 | +| [src/session.ts](src/session.ts) | `SessionStatus`, `AgentKind`, `CommandKind`, `Device`, `Project`, `Session`, `Command`, `SESSION_TRANSITIONS`, `canTransition()` | Everyone | |
| 35 | +| [src/runner.ts](src/runner.ts) | `RUNNER_EVENT_TYPES`, `RunnerEventType`, `RunnerEvent`, `StartOptions`, `Runner` | The daemon and the runner | |
| 36 | +| [src/index.ts](src/index.ts) | Re-exports both files | | |
| 37 | + |
| 38 | +### Session statuses |
| 39 | + |
| 40 | +A session is one task on one repo. It has exactly seven statuses, and only the moves below are legal. `canTransition(from, to)` is the single check every repo uses, so the runner, the daemon and the apps can never disagree about what may happen next. |
| 41 | + |
| 42 | +```mermaid |
| 43 | +stateDiagram-v2 |
| 44 | + [*] --> queued |
| 45 | + queued --> running |
| 46 | + queued --> failed |
| 47 | + running --> stopped |
| 48 | + running --> awaiting_approval |
| 49 | + running --> failed |
| 50 | + stopped --> running: resume |
| 51 | + stopped --> failed |
| 52 | + awaiting_approval --> approved |
| 53 | + awaiting_approval --> rejected |
| 54 | + awaiting_approval --> failed |
| 55 | + approved --> [*] |
| 56 | + rejected --> [*] |
| 57 | + failed --> [*] |
| 58 | +``` |
| 59 | + |
| 60 | +| Status | Meaning | |
| 61 | +| --- | --- | |
| 62 | +| `queued` | Created, not started yet | |
| 63 | +| `running` | The agent is working in its own worktree | |
| 64 | +| `stopped` | Stopped by a person. The worktree is kept so it can resume | |
| 65 | +| `awaiting_approval` | The agent finished. A change is waiting for a person | |
| 66 | +| `approved` | The change was committed on the task's branch. Final | |
| 67 | +| `rejected` | The change was thrown away, worktree and branch deleted. Final | |
| 68 | +| `failed` | Something went wrong. The reason is on the `task.failed` event. Final | |
| 69 | + |
| 70 | +```ts |
| 71 | +import { canTransition } from "@codehands/contracts"; |
| 72 | + |
| 73 | +canTransition("running", "awaiting_approval"); // true |
| 74 | +canTransition("running", "approved"); // false: approve only after the agent finished |
| 75 | +``` |
| 76 | + |
| 77 | +### Runner events |
| 78 | + |
| 79 | +The runner turns whatever the agent CLI prints into a stream of `RunnerEvent`s. The daemon stores them and the apps draw them. |
| 80 | + |
| 81 | +```ts |
| 82 | +interface RunnerEvent { |
| 83 | + sessionId: string; |
| 84 | + seq: number; // 1, 2, 3 … per session, never reused, continues after a resume |
| 85 | + type: RunnerEventType; |
| 86 | + at: string; // ISO time |
| 87 | + payload: Record<string, unknown>; |
| 88 | + raw?: unknown; // the agent's original line, untouched |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +`seq` is what makes a resend harmless: a reader that already has event 14 ignores a second event 14, and a reader that reconnects asks for everything after the last `seq` it saw. |
| 93 | + |
| 94 | +| Type | When | Typical payload | |
6 | 95 | | --- | --- | --- | |
7 | | -| `src/session.ts` | `Session`, `Command`, `SessionStatus`, the legal state moves | api, cli, desktop, web, runner | |
8 | | -| `src/runner.ts` | `RunnerEvent`, event names, `StartOptions`, `Runner` | cli (daemon), runner | |
| 96 | +| `task.started` | The agent process is up | `agent`, `model`, `cwd`, `agentSessionId` | |
| 97 | +| `message` | The agent said something, or a tool was blocked | `text`, `denied?`, `tool?` | |
| 98 | +| `file.read` | The agent read or searched files | `path`, `pattern?`, `tool` | |
| 99 | +| `file.changed` | The agent edited or wrote a file | `path`, `tool` | |
| 100 | +| `command.started` | The agent ran a shell command | `command`, `tool` | |
| 101 | +| `command.finished` | That command ended | `command`, `isError`, `denied`, `output` | |
| 102 | +| `test.result` | The command was a test run | `command`, `passed` | |
| 103 | +| `task.finished` | The agent finished. Now awaiting approval | `total_cost_usd`, `usage`, `num_turns`, `result`, `diffStat` | |
| 104 | +| `task.failed` | The run cannot continue | `reason`, plus details | |
| 105 | +| `task.stopped` | A person stopped it | | |
| 106 | + |
| 107 | +Payloads are open on purpose in v0.1.0. The fields above are what the runner sends today; read them defensively. |
| 108 | + |
| 109 | +### The `Runner` interface |
| 110 | + |
| 111 | +This is the boundary between the daemon and the runner (contract C2). The daemon calls these methods; the runner owns the worktree and the agent process. |
| 112 | + |
| 113 | +```ts |
| 114 | +interface Runner { |
| 115 | + start(o: StartOptions): AsyncIterable<RunnerEvent>; |
| 116 | + stop(sessionId: string): Promise<void>; |
| 117 | + resume(sessionId: string, prompt?: string): AsyncIterable<RunnerEvent>; |
| 118 | + diff(sessionId: string): Promise<{ patch: string; stat: string }>; |
| 119 | + approve(sessionId: string, message: string): Promise<{ branch: string; commit: string }>; |
| 120 | + reject(sessionId: string): Promise<void>; |
| 121 | +} |
9 | 122 |
|
10 | | -**Status:** proposed Mon Oct 19, frozen Fri Oct 23, 2026. |
| 123 | +interface StartOptions { |
| 124 | + sessionId: string; // a UUID, also reused as Claude Code's --session-id |
| 125 | + repoPath: string; |
| 126 | + prompt: string; |
| 127 | + agent: "claude-code" | "codex"; |
| 128 | + model?: string; |
| 129 | + budgetUsd?: number; |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +`start` and `resume` return an async iterable, so the caller just loops: |
| 134 | + |
| 135 | +```ts |
| 136 | +for await (const event of runner.start(options)) { |
| 137 | + saveEvent(event); |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +## Contracts still to write |
| 142 | + |
| 143 | +The design in [codehands-docs](https://github.com/devweekends/codehands-docs#contracts) names four contracts. v0.1.0 covers C5 and the first half of C2. The rest arrive as minor versions. |
| 144 | + |
| 145 | +| ID | Contract | In v0.1.0 | Still to add | Owner | |
| 146 | +| --- | --- | --- | --- | --- | |
| 147 | +| L1 | Local DB: the tables in `~/.codehands/state.db` and which process may write each | No | Table types for `tasks`, `commands`, `sessions`, `events`, `provider_status`, and the writer of each | Midhat | |
| 148 | +| C2 | Runner | `Runner`, `StartOptions`, events | Model per phase, preferences and memory in `StartOptions`; a `provider.status` event | Tahira | |
| 149 | +| C3 | Cloud API | No | Sign up, log in, log out, device tokens; `push(changes)`, `pull(cursor)`; record versions | Memoona | |
| 150 | +| C5 | Sessions | Yes | `Project` keyed by repo identity (a hash of the origin URL) instead of a folder path | Midhat, Tahira | |
| 151 | + |
| 152 | +The provider failure states the C2 event will carry are fixed already: `rate_limited`, `auth_expired`, `usage_limit`, `subscription_inactive`, `down`. Their meaning is in the [docs README](https://github.com/devweekends/codehands-docs#provider-failures). |
11 | 153 |
|
12 | 154 | ## Using it from another repo |
13 | 155 |
|
| 156 | +While two repos are being built against each other, clone them side by side and point at the folder: |
| 157 | + |
| 158 | +```text |
| 159 | +CodeHandsAI/ |
| 160 | + codehands-contracts/ |
| 161 | + codehands-desktop/ "@codehands/contracts": "file:../codehands-contracts" |
| 162 | + codehands-agent/ |
| 163 | + runner/ "@codehands/contracts": "file:../../codehands-contracts" |
| 164 | +``` |
| 165 | + |
| 166 | +Build this repo once, because consumers import from `dist/`: |
| 167 | + |
| 168 | +```bash |
| 169 | +cd codehands-contracts |
| 170 | +pnpm install |
| 171 | +pnpm build |
| 172 | +``` |
| 173 | + |
| 174 | +Once a version is tagged, pin the tag. Never depend on `main`: |
| 175 | + |
14 | 176 | ```jsonc |
15 | | -// while developing, with both repos cloned side by side |
16 | | -"@codehands/contracts": "file:../codehands-contracts" |
17 | | -// after it is pushed, pin a tag |
18 | | -"@codehands/contracts": "github:imtahirabatool/codehands-contracts#v0.1.0" |
| 177 | +"@codehands/contracts": "github:devweekends/codehands-contracts#v0.1.0" |
19 | 178 | ``` |
20 | 179 |
|
21 | | -## Changing it |
| 180 | +The `prepare` script runs `tsc`, so installing from a git tag builds `dist/` for you. |
| 181 | + |
| 182 | +## Changing a contract |
| 183 | + |
| 184 | +A change here can break four repos at once, so it follows a fixed path. |
22 | 185 |
|
23 | | -1. Open a PR here. Every consuming repo's owner approves it. |
24 | | -2. Bump the version (minor for additions, major for renames or removals) and tag it. |
25 | | -3. Update the matching stand-in (the runner's fake runner, the fake daemon) in the same week. |
| 186 | +1. **Open a PR here.** Say which repos are affected and how. |
| 187 | +2. **Every consuming owner approves it.** After the Oct 23 freeze, changes are batched into the Wednesday sync. |
| 188 | +3. **Bump the version and tag it.** Minor (`v0.2.0`) for additions, major for renames or removals. |
| 189 | +4. **Update the matching stand-in the same week:** `createFakeRunner()` in codehands-agent for C2, the fake writer for L1, the mock server for C3. |
| 190 | +5. **Consumers bump their pinned tag** in their own PR, when they are ready. |
| 191 | + |
| 192 | +Prefer adding a field to renaming one. Optional fields let consumers move at their own pace. |
| 193 | + |
| 194 | +## Development |
| 195 | + |
| 196 | +Node 22 and pnpm 10. |
26 | 197 |
|
27 | 198 | ```bash |
28 | 199 | pnpm install |
29 | | -pnpm build |
30 | | -pnpm test |
| 200 | +pnpm build # tsc into dist/ |
| 201 | +pnpm typecheck # tsc --noEmit |
| 202 | +pnpm test # vitest: legal moves and event names |
31 | 203 | ``` |
| 204 | + |
| 205 | +CI runs `pnpm build` and `pnpm test` on every push and PR ([.github/workflows/ci.yml](.github/workflows/ci.yml)). `main` stays green. |
| 206 | + |
| 207 | +What belongs here: types, string unions, constant lists, and pure helpers like `canTransition`. What does not: anything that does I/O, imports a runtime dependency, or only one repo uses. |
0 commit comments