mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 15:26:24 +00:00
15ff713417
Move the original plan-of-action out of README.md into docs/PLAN.md (verbatim, with Phase 1 checkboxes ticked and MVP-resolved open questions annotated). Replace the README with a proper project readme grounded in the implemented MVP: what it is, architecture, install/configure/run, curl walkthrough, control CLI, API reference, hooks, development, and layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY
233 lines
11 KiB
Markdown
233 lines
11 KiB
Markdown
# handler
|
|
|
|
A remote control wrapper for [Claude Code](https://claude.com/claude-code) agents.
|
|
Run many `claude` agents across many projects — each isolated, each leaving behind a
|
|
**checkmark** (its current state) and an entry in a **big log** (the complete history) —
|
|
all backed by a centralized database, driven entirely through an HTTP API.
|
|
|
|
Every agent process is a real `claude` binary invocation. There is no hard dependency on
|
|
any particular git host or network layer: you bring your own Claude Code login, your own
|
|
git remote, and your own network exposure.
|
|
|
|
> **Status: Phase 1 MVP.** The control layer, HTTP API, database, migrations, and
|
|
> verification hooks are implemented and tested (45 tests, SQLite). Live end-to-end agent
|
|
> spawning against a real `claude` binary + tmux is stubbed behind mockable seams and
|
|
> wired but not yet exercised against production binaries. See
|
|
> [`docs/PLAN.md`](docs/PLAN.md) for the full design and roadmap.
|
|
|
|
---
|
|
|
|
## Why
|
|
|
|
One operator running several of their own projects wants to fan work out to background
|
|
Claude Code agents and keep a reliable, queryable picture of what each one is doing —
|
|
without babysitting a wall of tmux panes. `handler` gives every agent:
|
|
|
|
- **A checkmark** — one small, always-current row: where it stopped, what's next, any
|
|
open question for you. Overwritten on every checkpoint, like a file you keep saving.
|
|
- **A big log** — the append-only history of everything every agent has ever done.
|
|
- **A verification gate** — an agent never reaches `done` on its own say-so. A `Stop`
|
|
hook runs the project's own test task and blocks the turn on failure, so `done` in the
|
|
database means *a test run passed*.
|
|
- **A push gate** — a `git push` doesn't leave until tests pass *and* a throwaway image
|
|
build succeeds locally, so a push already known to fail CI never goes out.
|
|
- **Isolation** — each project has its own working directory, agents, history, and
|
|
credentials; nothing crosses the boundary unless you explicitly share it.
|
|
|
|
## Architecture
|
|
|
|
Three components over one database. The database is the only thing that holds state, so
|
|
the control layer and API are disposable compute that can restart or scale out freely.
|
|
|
|
```
|
|
writes reads (+ answer backfill)
|
|
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
|
|
│ control layer │───────▶│ database │◀───────│ HTTP API │
|
|
│ (CLI + hooks) │ │ PG / SQLite │ │ (FastAPI) │
|
|
└──────────────────┘ └──────────────┘ └──────────────────┘
|
|
│ ▲ ▲
|
|
│ spawns │ Stop / PreToolUse / Notification hooks │ curl, UI, any client
|
|
▼ │ write checkmark + log rows │ (bearer token)
|
|
tmux + claude binary (one working dir / worktree per agent)
|
|
```
|
|
|
|
- **Control layer** (`handler.control`) — the only writer. Spawns/lists/attaches/kills
|
|
agents as `tmux` sessions running the `claude` binary, one working directory or git
|
|
worktree per agent, namespaced `project__agent`. Stateless; every write goes straight
|
|
to the database.
|
|
- **Hooks** (`handler.hooks`) — run inside each agent via a generated `settings.json`.
|
|
They write the checkpoint/log rows and enforce the test and push gates.
|
|
- **API** (`handler.api`) — a thin, read-mostly HTTP layer over the same database (the
|
|
one write it does is backfilling an operator's answer). Bearer-token auth on every
|
|
route; every agent route is nested under `/projects/:project/` so nothing leaks across
|
|
a project boundary.
|
|
|
|
### One schema, two backends
|
|
|
|
The data model is defined once (SQLAlchemy Core) and renders correctly on both:
|
|
|
|
- **Postgres** (default for real deployments) — `BIGSERIAL`, `TIMESTAMPTZ`, `JSONB`.
|
|
A live central server is what makes the stateless-container story true.
|
|
- **SQLite** (minimal-infra fallback) — a single file, zero services. Same schema shape,
|
|
simpler types (`INTEGER PRIMARY KEY`, TEXT, JSON).
|
|
|
|
Portable column types bridge the two, and the checkmark upsert uses native
|
|
`INSERT … ON CONFLICT DO UPDATE` on both dialects. Migrations are Alembic, dual-dialect.
|
|
|
|
## Requirements
|
|
|
|
- Python 3.11+
|
|
- `git` and `tmux` (for live spawning)
|
|
- A `claude` binary, authenticated (for live spawning)
|
|
- `mise` in each managed project, with a `.mise.toml` defining at least a `test` task
|
|
- Postgres (default) — or nothing but a file path for the SQLite fallback
|
|
|
|
## Install
|
|
|
|
```bash
|
|
python -m venv .venv && source .venv/bin/activate
|
|
pip install -e ".[dev]"
|
|
```
|
|
|
|
## Configure
|
|
|
|
Configuration is entirely environment-driven (see [`.env.example`](.env.example)):
|
|
|
|
| Variable | Purpose | Default |
|
|
|---|---|---|
|
|
| `DATABASE_URL` | `sqlite:////abs/path.db` or `postgresql+psycopg://…` | `sqlite:///./handler.db` |
|
|
| `AUTH_TOKEN` | Global bearer token gating every API route | *(required for the API)* |
|
|
| `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` |
|
|
| `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op |
|
|
| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees | `./projects` |
|
|
| `CLAUDE_BIN` / `MISE_BIN` / `TMUX_BIN` | Binary overrides | `claude` / `mise` / `tmux` |
|
|
|
|
## Run
|
|
|
|
Apply migrations, then start the API:
|
|
|
|
```bash
|
|
export DATABASE_URL="sqlite:///$PWD/handler.db"
|
|
export AUTH_TOKEN="$(openssl rand -hex 32)"
|
|
|
|
alembic upgrade head
|
|
uvicorn handler.api.app:app --host 0.0.0.0 --port 8000
|
|
```
|
|
|
|
The API is just a client contract — everything below works with plain `curl`:
|
|
|
|
```bash
|
|
TOKEN="Authorization: Bearer $AUTH_TOKEN"
|
|
BASE="http://127.0.0.1:8000"
|
|
|
|
# Register a project and an agent
|
|
curl -s -X POST $BASE/projects -H "$TOKEN" -H 'Content-Type: application/json' \
|
|
-d '{"id":"leeworks-api","root_dir":"/srv/projects/leeworks"}'
|
|
|
|
curl -s -X POST $BASE/projects/leeworks-api/agents -H "$TOKEN" -H 'Content-Type: application/json' \
|
|
-d '{"name":"api","working_dir":"/srv/projects/leeworks/api"}'
|
|
|
|
# Read an agent's checkmark and log
|
|
curl -s $BASE/projects/leeworks-api/agents/api/checkmark -H "$TOKEN"
|
|
curl -s $BASE/projects/leeworks-api/agents/api/log -H "$TOKEN"
|
|
|
|
# Answer a paused question, then resume the agent
|
|
curl -s -X POST $BASE/projects/leeworks-api/agents/api/answer -H "$TOKEN" \
|
|
-H 'Content-Type: application/json' -d '{"answer":"use Postgres"}'
|
|
curl -s -X POST $BASE/projects/leeworks-api/agents/api/resume -H "$TOKEN" \
|
|
-H 'Content-Type: application/json' -d '{}'
|
|
```
|
|
|
|
## Control CLI
|
|
|
|
The `handler` command manages agent processes (the write side):
|
|
|
|
```bash
|
|
handler spawn --project leeworks-api --name api --worktree feature/auth --task "add login"
|
|
handler list [--project leeworks-api]
|
|
handler attach --project leeworks-api --name api
|
|
handler kill --project leeworks-api --name api
|
|
```
|
|
|
|
`spawn` refuses any project whose working directory has no `.mise.toml` with a
|
|
`[tasks.test]` task — the verification gate is a hard requirement, not a convention. It
|
|
resolves the working directory (a subdirectory or a fresh git worktree, always under the
|
|
project root), writes a per-agent `.claude/settings.json` wiring the hooks, and launches a
|
|
`tmux` session with the agent's identity and `DATABASE_URL` injected into its environment.
|
|
|
|
## API reference
|
|
|
|
All routes require `Authorization: Bearer <AUTH_TOKEN>`. `GET /health` is unauthenticated.
|
|
|
|
| Method & path | Purpose |
|
|
|---|---|
|
|
| `GET /projects` · `POST /projects` | List / register projects |
|
|
| `GET /projects/:p/agents` · `POST …` | List / register agents (project-scoped) |
|
|
| `GET /projects/:p/agents/:name/checkmark` | The agent's current-state checkmark |
|
|
| `GET /projects/:p/agents/:name/log` | The agent's log history (paginated) |
|
|
| `POST /projects/:p/agents/:name/answer` | Backfill the operator's answer to an open question |
|
|
| `POST /projects/:p/agents/:name/resume` | Feed the answer back via `claude --resume` |
|
|
| `GET /shared/log` | Cross-project feed of entries explicitly marked `global` |
|
|
| `GET /shared/context` · `GET /shared/context/:key` | Read shared key/value facts |
|
|
| `PUT /shared/context/:key` | Write a shared fact — requires the shared-write token |
|
|
|
|
## Hooks
|
|
|
|
Wired into each agent as `python -m handler.hooks <event>`:
|
|
|
|
- **`Stop` / `SessionEnd`** — checkpoint. On `Stop`, run `mise run test`; on failure,
|
|
return `decision: "block"` with the output so the turn cannot end on red. Records
|
|
`tests_status` / `tested_at`; `status = 'done'` only ever accompanies a pass.
|
|
- **`PreToolUse`** — two jobs. An `AskUserQuestion` is *deferred*: the question is
|
|
persisted, the checkmark set to `paused_for_input`, and the tool call denied so control
|
|
hands off to the async answer/resume flow. A `Bash` command running `git push` triggers
|
|
the push gate — tests first, then a throwaway image build (`mise run build-image`) — and
|
|
is denied on the first failure.
|
|
- **`Notification`** — POSTs a small JSON payload to `WEBHOOK_URL` (no-op when unset).
|
|
Never blocks the agent on delivery failure.
|
|
|
|
Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`,
|
|
`HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `DATABASE_URL`), since hook stdin doesn't
|
|
carry it; the wiring itself lives in the generated `settings.json`.
|
|
|
|
## Development
|
|
|
|
```bash
|
|
pytest # 45 tests, entirely on SQLite — no live claude/tmux/mise needed
|
|
ruff check . # lint
|
|
# or, via the project's own mise tasks:
|
|
mise run verify # lint + test
|
|
```
|
|
|
|
The suite drives every API route through FastAPI's `TestClient`, exercises all four hook
|
|
types, and runs a real `alembic upgrade head` per test so the migration path itself is
|
|
covered. Three seams — `control.tmux`, `hooks.verify`, and `control.spawn.resume` — are
|
|
the mock points that stand in for live `claude`/`tmux`/`mise`, and the drop-in points for
|
|
wiring them up for real.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
src/handler/
|
|
config.py # env-driven settings, shared by every entrypoint
|
|
db/ # SQLAlchemy Core schema, engine, portable types, upsert, DAL
|
|
api/ # FastAPI app, auth deps, pydantic schemas, routes
|
|
control/ # CLI, tmux/worktree/settings-gen seams, spawn orchestration
|
|
hooks/ # Stop/SessionEnd, PreToolUse gate, Notification, verify seam
|
|
migrations/ # Alembic env + versions
|
|
tests/ # DB, API, hook, and control tests (SQLite)
|
|
docs/PLAN.md # full design + phased roadmap (the original plan of action)
|
|
```
|
|
|
|
## Roadmap
|
|
|
|
Phase 1 (this MVP) is the control layer + API. Still ahead: **Phase 2** forge integration
|
|
and credential resolution (PRs/issues/CI status via `forge`, one interface across GitHub /
|
|
GitLab / Gitea / Forgejo / Bitbucket), **Phase 3** a web UI, **Phase 4** optional
|
|
observability, and **Phase 5** open-source release. Details and design rationale live in
|
|
[`docs/PLAN.md`](docs/PLAN.md).
|
|
|
|
## License
|
|
|
|
MIT.
|