Skip to content

Commit 14863f6

Browse files
contracts v0.2.0: engine steps, approval.requested, provider status
- Events: step.started/finished, permission.denied, provider.status, approval.requested - ENGINE_STEPS, PROVIDER_STATES; StartOptions allows "codehands" and models? - Project.repoKey?, Session.updatedAt? (optional, local-first) - README: new events, approve/reject outcomes. Tests for event order.
1 parent bc021b0 commit 14863f6

5 files changed

Lines changed: 79 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,15 @@ interface RunnerEvent {
103103
| `task.finished` | The agent finished. Now awaiting approval | `total_cost_usd`, `usage`, `num_turns`, `result`, `diffStat` |
104104
| `task.failed` | The run cannot continue | `reason`, plus details |
105105
| `task.stopped` | A person stopped it | |
106+
| `step.started` | v0.2.0. The engine began a step | `step` |
107+
| `step.finished` | v0.2.0. The engine ended a step | `step`, `ok`, `costUsd?` |
108+
| `permission.denied` | v0.2.0. The agent tried something and was blocked | `tool`, `reason`, `input?` |
109+
| `provider.status` | v0.2.0. The model provider can't serve the run | `state`, `message`, `code?` (HTTP status or CLI error), `retryAt?` |
110+
| `approval.requested` | v0.2.0. Sent right after `task.finished`. The session is now `awaiting_approval` | `diffStat` |
106111

107-
Payloads are open on purpose in v0.1.0. The fields above are what the runner sends today; read them defensively.
112+
Payloads are open on purpose. The fields above are what the runner sends today; read them defensively.
113+
114+
`step` is one of `ENGINE_STEPS`: `analyze`, `plan`, `implement`, `test`, `review`, always in that order. `state` is one of `PROVIDER_STATES`: `rate_limited`, `auth_expired`, `usage_limit`, `subscription_inactive`, `down`.
108115

109116
### The `Runner` interface
110117

@@ -124,12 +131,24 @@ interface StartOptions {
124131
sessionId: string; // a UUID, also reused as Claude Code's --session-id
125132
repoPath: string;
126133
prompt: string;
127-
agent: "claude-code" | "codex";
128-
model?: string;
134+
agent: "claude-code" | "codex" | "codehands"; // "codehands" (the engine) since v0.2.0
135+
model?: string; // one model for the whole run
136+
models?: Partial<Record<StepName, string>>; // v0.2.0: per engine step, e.g. { plan: "claude-opus-5-5" }
129137
budgetUsd?: number;
130138
}
131139
```
132140

141+
A step with no entry in `models` uses `model`.
142+
143+
`approve` and `reject` send no event. The promise is the outcome:
144+
145+
| Call | Resolves | Throws |
146+
| --- | --- | --- |
147+
| `approve` | Committed on the task's branch, worktree removed. Status `approved` | The status stays `awaiting_approval`. The error says why (for example, the agent changed nothing) |
148+
| `reject` | Worktree and branch deleted. Status `rejected` | The status stays `awaiting_approval`. The error says why |
149+
150+
The daemon writes the new status and records the outcome itself.
151+
133152
`start` and `resume` return an async iterable, so the caller just loops:
134153

135154
```ts
@@ -140,16 +159,16 @@ for await (const event of runner.start(options)) {
140159

141160
## Contracts still to write
142161

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.
162+
The design in [codehands-docs](https://github.com/devweekends/codehands-docs#contracts) names four contracts. The rest arrive as minor versions.
144163

145-
| ID | Contract | In v0.1.0 | Still to add | Owner |
164+
| ID | Contract | Done so far | Still to add | Owner |
146165
| --- | --- | --- | --- | --- |
147166
| 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 |
167+
| C2 | Runner | `Runner`, `StartOptions`, events. v0.2.0: engine steps, model per step, `permission.denied`, `provider.status` | Preferences and memory in `StartOptions` | Tahira |
168+
| C3 | Cloud API | Yes: sign up, log in, log out, devices, `push`, `pull` (`cloud-api.ts`) | | Memoona |
169+
| C5 | Sessions | Yes. v0.2.0: `Project.repoKey`, `Session.updatedAt` | Make `repoKey` and `updatedAt` required once every writer sets them | Midhat, Tahira |
151170

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).
171+
The meaning of each provider failure state is in the [docs README](https://github.com/devweekends/codehands-docs#provider-failures).
153172

154173
## Using it from another repo
155174

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codehands/contracts",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Shared types, event names and session states for Code Hands AI",
55
"type": "module",
66
"main": "dist/index.js",

‎src/runner.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,29 @@ export const RUNNER_EVENT_TYPES = [
1414
"task.finished",
1515
"task.failed",
1616
"task.stopped",
17+
// v0.2.0
18+
"step.started", // { step: StepName }. Only the engine sends it
19+
"step.finished", // { step: StepName, ok: boolean, costUsd?: number }
20+
"permission.denied", // { tool, reason, input? }. The agent wanted to do something and was blocked
21+
"provider.status", // { state: ProviderState, message, code?, retryAt?: ISO time }. code: HTTP status or CLI error
22+
"approval.requested", // { diffStat }. Right after task.finished: the session is now awaiting_approval
1723
] as const;
1824
export type RunnerEventType = (typeof RUNNER_EVENT_TYPES)[number];
1925

26+
// The engine's steps, in order (agent: "codehands").
27+
export const ENGINE_STEPS = ["analyze", "plan", "implement", "test", "review"] as const;
28+
export type StepName = (typeof ENGINE_STEPS)[number];
29+
30+
// Why the model provider can't serve a run right now. Meanings: codehands-docs#provider-failures.
31+
export const PROVIDER_STATES = [
32+
"rate_limited",
33+
"auth_expired",
34+
"usage_limit",
35+
"subscription_inactive",
36+
"down",
37+
] as const;
38+
export type ProviderState = (typeof PROVIDER_STATES)[number];
39+
2040
export interface RunnerEvent {
2141
sessionId: string;
2242
seq: number; // 1, 2, 3 … per session. Lets the phone catch up after a reconnect
@@ -30,8 +50,9 @@ export interface StartOptions {
3050
sessionId: string; // UUID from the server, reused as Claude's --session-id
3151
repoPath: string;
3252
prompt: string;
33-
agent: Exclude<AgentKind, "codehands">;
34-
model?: string;
53+
agent: AgentKind; // v0.2.0: "codehands" (the engine) is allowed
54+
model?: string; // one model for the whole run
55+
models?: Partial<Record<StepName, string>>; // v0.2.0: model per engine step, falls back to model
3556
budgetUsd?: number;
3657
}
3758

‎src/session.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Rows the state API stores (Midhat) and the apps read (Tania, Memoona).
2+
// Local-first (v0.2.0): the real rows live in the daemon's ~/.codehands/state.db, the cloud keeps a synced copy.
23

34
export type SessionStatus =
45
| "queued"
@@ -13,7 +14,13 @@ export type AgentKind = "claude-code" | "codex" | "codehands";
1314
export type CommandKind = "start" | "stop" | "resume" | "approve" | "reject";
1415

1516
export interface Device { id: string; userId: string; name: string; lastSeenAt: string }
16-
export interface Project { id: string; deviceId: string; name: string; path: string }
17+
export interface Project {
18+
id: string;
19+
deviceId: string;
20+
name: string;
21+
path: string; // a folder on this machine. Never synced
22+
repoKey?: string; // v0.2.0: first 12 chars of sha1(origin url or path). Same on every machine, safe to sync
23+
}
1724

1825
export interface Session {
1926
id: string;
@@ -26,6 +33,7 @@ export interface Session {
2633
baseCommit?: string;
2734
diff?: string;
2835
createdAt: string;
36+
updatedAt?: string; // v0.2.0: set on every change. Sync keeps the newer copy
2937
}
3038

3139
export interface Command {

‎test/session.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { canTransition, RUNNER_EVENT_TYPES } from "../src/index.js";
2+
import { canTransition, ENGINE_STEPS, PROVIDER_STATES, RUNNER_EVENT_TYPES } from "../src/index.js";
33

44
describe("session transitions", () => {
55
it("allows the happy path", () => {
@@ -23,4 +23,21 @@ describe("event names", () => {
2323
it("has no duplicates", () => {
2424
expect(new Set(RUNNER_EVENT_TYPES).size).toBe(RUNNER_EVENT_TYPES.length);
2525
});
26+
27+
it("keeps every v0.1.0 event, in the same order", () => {
28+
expect(RUNNER_EVENT_TYPES.slice(0, 10)).toEqual([
29+
"task.started", "message", "file.read", "file.changed", "command.started",
30+
"command.finished", "test.result", "task.finished", "task.failed", "task.stopped",
31+
]);
32+
});
33+
});
34+
35+
describe("engine steps and provider states", () => {
36+
it("runs the engine steps in order", () => {
37+
expect(ENGINE_STEPS).toEqual(["analyze", "plan", "implement", "test", "review"]);
38+
});
39+
40+
it("has no duplicate provider states", () => {
41+
expect(new Set(PROVIDER_STATES).size).toBe(PROVIDER_STATES.length);
42+
});
2643
});

0 commit comments

Comments
 (0)