diff --git a/README.md b/README.md index 712148b..2525181 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,232 @@ # handler -# Remote Control Wrapper — Plan of Action +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. -**Status:** Living document — update phase checkboxes and status in place as work completes. Don't append new copies of this file; overwrite it, the way a checkmark gets overwritten. -**Last updated:** 2026-07-07 (revision 7: accepted `forge`'s maturity as a risk worth taking — pin to a released version, fork/vendor if it ever stalls) +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. --- -## 1. What "done" looks like +## Why -Production release means: +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: -- **Runs anywhere.** No hard dependency on Gitea or Tailscale — git hosting and network exposure are both pluggable, not assumed. -- A **web UI**, not just tmux/CLI, for spawning, monitoring, and interacting with agents. -- **All communication goes through an API.** The UI is a client of it; tmux/CLI become optional local conveniences, not the source of truth. -- Every agent leaves a **checkmark** behind: a small, current-state record of where it stopped, what's needed next, and any open questions for you. -- Every checkmark links to an entry in a **big log**: the append-only, complete history of everything every agent has ever done. -- State lives in a **centralized database** that the CLI wrapper (backend) writes to and the API reads from — not scattered flat files or a git-hosting-specific store. Control layer and API containers hold nothing persistent themselves; they can restart, redeploy, or scale out without losing data because all of it lives in the database. -- **Multiple projects run concurrently, each isolated from the others by default.** One control layer can host any number of projects, each with its own agents, working directory, and history — with an explicit, opt-in mechanism for the edge cases where something genuinely needs to cross that boundary. +- **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. -## 2. Non-negotiable constraints +## Architecture -- Every agent process is a real `claude` binary invocation. No OAuth handling, no protocol reimplementation. -- **No hard dependency on Tailscale.** The API authenticates itself with a bearer token; users choose their own network exposure — Tailscale, a VPN, a reverse proxy, or plain localhost. The wrapper doesn't assume any of them. -- **No hard dependency on Gitea.** Git operations use plain `git` by default. Forge-specific niceties (PRs, issues, CI status) go through `forge` (git-pkgs/forge) as an optional, pluggable layer — one CLI that works the same against GitHub, GitLab, Gitea/Forgejo, or Bitbucket, never a requirement just to run the tool. -- **The database never stores raw credentials.** `projects.credential_ref` is a pointer (an env var name, a file path, a command to run) — never a token. The control layer resolves it to an actual secret only at spawn time, injected into that container's environment for that run. -- Design decisions above a stakes threshold get surfaced via `plan` mode + `AskUserQuestion`, not silently guessed. -- **Projects are isolated by default.** An agent only sees its own project's working directory, checkmarks, and log — nothing crosses that boundary unless something is explicitly marked shared. This is one operator running many of their own projects, not a multi-tenant service for other people — don't let the isolation model drift into looking like the latter. -- **Agents don't get to self-report "done."** A verification gate actually runs the project's own test task and blocks completion on failure — "done" in the database means a test run passed, not that the agent stopped talking. -- **A `git push` doesn't leave until a local build check passes too.** Same hard-block pattern as the test gate, and it runs after tests, not instead of them — cheap checks run before the more expensive one that would obviously fail anyway. -- If this ships open-source: no embedded credentials, no implied Anthropic affiliation, README states plainly that each user brings their own Claude Code login, their own git remote, and their own network layer. +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. -## 3. Control layer + API (build this first — this is the MVP) - -### 3.1 Data model — Postgres primary, SQLite fallback - -Two supported backends behind one data-access layer, not two separate code paths: - -- **Postgres (default for real deployments).** A live, centralized server is what actually makes "stateless containers" true — the control layer and API are just compute that can restart, redeploy, or scale to N replicas because none of them hold state locally. This is the deployment target for anyone running more than a single node, or wanting one datastore behind multiple agent hosts. -- **SQLite (minimal-infra fallback).** A single file, zero services to stand up — for a single-node/homelab-scale run where standing up Postgres is overkill. Explicitly a fallback, not the default: it doesn't give you the centralization the Postgres path does, and single-writer semantics limit it to one control-layer instance at a time. - -The schema is the same shape on both; only types differ slightly (Postgres gets proper `SERIAL`/`TIMESTAMPTZ`/`JSONB`, SQLite uses its looser dynamic typing). Pick one query layer/ORM that supports both dialects rather than hand-maintaining two schemas — see open questions. - -```sql --- Postgres -CREATE TABLE projects ( - id TEXT PRIMARY KEY, -- slug, e.g. "leeworks-api" - root_dir TEXT NOT NULL, - git_remote TEXT, - credential_ref TEXT, -- pointer to a secret, e.g. "env:LEEWORKS_TOKEN" — never the token itself, see 3.7 - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE agents ( - id BIGSERIAL PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id), - name TEXT NOT NULL, -- unique within a project, not globally - working_dir TEXT NOT NULL, - status TEXT NOT NULL, -- working | paused_for_input | blocked | done - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (project_id, name) -); - -CREATE TABLE checkmarks ( - agent_id BIGINT PRIMARY KEY REFERENCES agents(id), - checkpoint_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL, - where_it_stopped TEXT, - next_steps JSONB, - open_question TEXT, - log_entry_id BIGINT REFERENCES log_entries(id), - tests_status TEXT NOT NULL DEFAULT 'unknown', -- pass | fail | unknown — see 3.5 - tested_at TIMESTAMPTZ, - build_status TEXT NOT NULL DEFAULT 'unknown', -- pass | fail | unknown — see 3.6 - built_at TIMESTAMPTZ -); - -CREATE TABLE log_entries ( - id BIGSERIAL PRIMARY KEY, - agent_id BIGINT NOT NULL REFERENCES agents(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - session_id TEXT, - status TEXT NOT NULL, - summary TEXT, - decisions TEXT, - question TEXT, - answer TEXT, -- filled in on resume; only field ever touched post-insert - visibility TEXT NOT NULL DEFAULT 'project', -- project | global — see 3.4 - push_sha TEXT, -- set if this checkpoint pushed; null if it didn't - ci_status TEXT NOT NULL DEFAULT 'not_applicable', -- not_applicable | pending | pass | fail — see 3.6 - ci_checked_at TIMESTAMPTZ -); +``` + 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) ``` -```sql --- SQLite (minimal-infra fallback) — same shape, simpler types -CREATE TABLE projects ( - id TEXT PRIMARY KEY, - root_dir TEXT NOT NULL, - git_remote TEXT, - credential_ref TEXT, - created_at TEXT NOT NULL -); --- agents / checkmarks / log_entries: identical columns, JSONB becomes TEXT, --- BIGSERIAL becomes INTEGER PRIMARY KEY AUTOINCREMENT, TIMESTAMPTZ becomes TEXT. +- **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]" ``` -`checkmarks` is a literal upsert (`INSERT ... ON CONFLICT DO UPDATE` / `INSERT OR REPLACE`) keyed by `agent_id` — the "small file that gets overwritten," just as a row instead of a file. `log_entries` is insert-only except for the one `answer` backfill on resume, which is the DB equivalent of "linked directly to this checkmark." Agent identity is now `(project_id, name)`, not a bare name — two projects can each have an agent called `api` without colliding. +## Configure -### 3.2 Backend (CLI wrapper) — the only thing that writes +Configuration is entirely environment-driven (see [`.env.example`](.env.example)): -- Spawns/lists/attaches/kills agents within a project (tmux + `claude` binary, one working directory or git worktree per agent, nested under that project's root) -- Owns every write to the database: creates the agent row, upserts the checkmark, inserts log entries -- Hooked via `Stop`/`SessionEnd` (checkpoint), `PreToolUse` (defer `AskUserQuestion`), `Notification` (fires a generic webhook — the user points that at ntfy, Pushover, Slack, email, whatever; the wrapper doesn't pick for them) +| 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` | -### 3.3 API — the only thing that reads (plus writes the answer back) +## Run -- Thin HTTP layer over the same database (Postgres in centralized deployments, SQLite for minimal-infra): `GET /projects`, `POST /projects`, `GET /projects/:project/agents`, `POST /projects/:project/agents`, `GET /projects/:project/agents/:name/checkmark`, `GET /projects/:project/agents/:name/log`, `POST /projects/:project/agents/:name/answer`, `POST /projects/:project/agents/:name/resume` -- Auth: a generated bearer token checked on every request. No assumption about what network the request arrives over — Tailscale, a VPN, or a bare reverse proxy are all just transport underneath it. -- The UI (Phase 3) and any future integration are just clients of this — same contract as `curl`. +Apply migrations, then start the API: -### 3.4 Multi-project isolation and cross-project sharing +```bash +export DATABASE_URL="sqlite:///$PWD/handler.db" +export AUTH_TOKEN="$(openssl rand -hex 32)" -Every agent belongs to exactly one project. Isolation is the default; sharing is a deliberate, visible act, never an accident. - -**Isolation:** -- Filesystem: each project gets its own root directory; agents in that project work only within it (subdirectories or git worktrees underneath), never reaching into another project's tree. -- Naming: tmux sessions follow `project__agent`, matching the `(project_id, name)` uniqueness in the database — no cross-project collisions, and it's visually obvious which project a session belongs to from `tmux ls`. -- Config: git remote, credential reference, and any other per-project settings, live on the `projects` row — not global config — so projects can point at entirely different repos, hosts, or forges independently. -- API: every agent route is nested under `/projects/:project/...`. There's no endpoint that returns another project's data by accident. - -**The explicit sharing mechanism, for the edge cases:** -- `log_entries.visibility` — defaults to `project`, can be set to `global` when an agent (or you) decides a specific checkpoint genuinely matters beyond its own project. A `GET /shared/log` endpoint surfaces only the entries marked `global`, across all projects — a deliberate opt-in feed, not a merged view of everything. -- `shared_context` table — a small key-value store for standing facts multiple projects need to reference (a shared staging URL, a schema version, a convention decision), independent of any single checkpoint: - ```sql - CREATE TABLE shared_context ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - set_by_agent_id BIGINT REFERENCES agents(id), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - ``` - Any agent can read it (`GET /shared/context`); writing (`PUT /shared/context/:key`) is the one place worth gating behind an explicit flag or higher-trust token, since it's the one table every project implicitly trusts. - -Nothing here allows one project to read another project's checkmarks or private log entries directly — the only paths across the boundary are the two explicit ones above. - -### 3.5 Toolchains and test verification - -Environment reliability and result reliability are two different problems — solve both, don't conflate them. - -**Environment: mise, not nix.** A single generic base image (mise + the `claude` binary + `git` + `gh`) with no baked-in language runtimes — one image for every project regardless of stack. Each project carries its own `.mise.toml`, pinning exact tool versions (Rust, Python, Node, whatever) and a lockfile committed to the repo for strict, reproducible installs. On container start, `mise install --non-interactive` reads that lockfile and materializes the project's exact toolchain — the container doesn't need to know or care what language it's about to run. Nix would get similar pinning with more ceremony and a heavier runtime; since Docker already provides OS-level isolation, mise's language-level pinning is the right amount of tool for this. - -Every project defines a canonical task, regardless of stack: - -```toml -[tasks.test] -description = "Run the test suite" -run = "pytest" # or `cargo test`, `npm test`, whatever the project actually uses - -[tasks.verify] -depends = ["lint", "test"] +alembic upgrade head +uvicorn handler.api.app:app --host 0.0.0.0 --port 8000 ``` -**Result: a Stop hook, not self-reporting.** After each turn, a `Stop` hook runs `mise run test` (or `verify`) in the agent's working directory and checks the exit code. Nonzero → `decision: "block"`, with the failure output fed back to the agent, so the turn cannot end on a broken test suite. This is what "correct" actually rests on: it's the same command for every project because mise's task abstraction is the uniform interface, and it's enforced by the harness, not reported by the model. +The API is just a client contract — everything below works with plain `curl`: -The result feeds straight into the schema already in place: the hook sets `checkmarks.tests_status` and `tested_at` on every checkpoint, so `status = 'done'` in the database is only ever true alongside a passing test run — not a claim taken on faith. +```bash +TOKEN="Authorization: Bearer $AUTH_TOKEN" +BASE="http://127.0.0.1:8000" -### 3.6 Push gate and CI follow-through +# 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"}' -Tests catch broken code; they don't catch a broken container. A Dockerfile with a stale `COPY` path passes `pytest` fine and then fails to build — worth catching locally, before it costs a CI run. +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"}' -**Local build check — cheap, throwaway, no registry involved.** Since CI/CD (on its own, beefier runner) owns the real build-and-push, the local check only needs to prove the Dockerfile is sound: a `kaniko`/`buildah` build with no push target, output discarded. No Docker socket, no `--privileged`, nothing the agent's own sandbox needs elevated trust for — it's the same category of tool CI systems adopted specifically to avoid handing out real Docker-in-Docker access. This is `mise run build-image` alongside the existing `test` task, same interface, same project regardless of stack. +# 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" -**Enforcement — same mechanism as tests, one step later.** A `PreToolUse` hook matching `Bash(git push*)` runs the verification chain (tests, then the throwaway build — cheap first, expensive second) and returns `permissionDecision: "deny"` on the first failure, with the reason surfaced to the agent. A push that's already known to fail CI doesn't leave. `checkmarks.build_status`/`built_at` record the result the same way `tests_status` does. +# 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 '{}' +``` -**CI is still the authoritative gate — the wrapper just closes the loop on it.** Once a push clears the local checks and actually goes out, whatever CI system the host runs (GitHub Actions, Gitea/Forgejo Actions, GitLab CI) does the real build-and-push on its own runner, same as today. The wrapper records `push_sha` and sets `ci_status = 'pending'` on that log entry, then a background poller in the control layer — not a hook, since this can take minutes — checks the run tied to that commit with `forge ci list` / `forge ci log `. Same two commands regardless of which host the project is on; `forge` detects the forge type from the remote, so this isn't per-host integration work, just one poller calling one interface. Once the run resolves, the poller backfills `ci_status` and `ci_checked_at` on that same log entry — the DB equivalent of "recording why it did or didn't ultimately land," without ever exposing the wrapper to inbound webhook traffic. +## Control CLI -### 3.7 Credentials: forge and git +The `handler` command manages agent processes (the write side): -Two things need to authenticate per project — the `forge` CLI (for PRs, issues, CI status) and `git` itself (for push/pull) — and the design goal is one secret servicing both, not two to manage. +```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 +``` -**Resolution, not storage.** `projects.credential_ref` is a pointer (`env:VAR_NAME`, `file:/path`, `cmd:some command`), never the token itself — mirrors `forge`'s own `--token-cmd` pattern, just one level up. At spawn time, the control layer resolves the reference to an actual value and injects it into that agent's container environment only — nothing persisted in Postgres/SQLite, nothing baked into an image. +`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. -**One token, two consumers, where the host allows it.** `forge` reads the resolved token from the environment directly (`GITHUB_TOKEN`, `GITEA_TOKEN`, `FORGE_TOKEN`, per host) with zero extra config once it's injected. For git's own HTTPS auth, the wrapper writes a small credential-helper at container start that hands back that same value — most self-hosted forges (Gitea/Forgejo, GitLab) accept a PAT as the HTTP password, so one token covers both `forge pr create` and `git push` for a given project. +## API reference -**SSH deploy keys as the per-project alternative.** Some operators won't want a single token holding both git-write and forge-API scope. Where that matters, a project can use an SSH deploy key for git transport and a separately-scoped, more limited token for `forge`'s PR/issue/CI operations — configured per project, not mandated globally, since this is a real preference split among self-hosters rather than a settled question. +All routes require `Authorization: Bearer `. `GET /health` is unauthenticated. -## 4. Phased roadmap +| 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 | -### Phase 0 — Prerequisites -- [ ] `claude` binary installed and authenticated (per user) -- [ ] `git` installed; `forge` (git-pkgs/forge) optional -- [ ] `mise` installed in the base agent image; each project supplies its own `.mise.toml` with, at minimum, a `test` task -- [ ] A Postgres connection string (default target) — or nothing at all if running the SQLite fallback for a single-node/minimal-infra setup +## Hooks -### Phase 1 — Control layer + API (the MVP) -- [ ] Database schema + migrations for both backends, Postgres default / SQLite fallback, including `projects` and project-scoped agents (section 3.1) -- [ ] Control script: spawn/list/attach/kill, tmux + `claude` binary, one working dir/worktree per agent, namespaced by project — itself stateless, all state written straight to the database -- [ ] Hooks writing checkmark/log rows (`Stop`/`SessionEnd`, `PreToolUse` defer, `Notification` → generic webhook), all scoped to the owning project -- [ ] HTTP API over the same database, bearer-token auth, all agent routes nested under `/projects/:project/` -- [ ] Resume flow: API endpoint takes an answer, feeds it back via `claude --resume` -- [ ] Shared-context and shared-log endpoints for the explicit cross-project edge case (section 3.4) -- [ ] `Stop` hook verification gate: `mise install` on checkout, `mise run test` on every checkpoint, block completion on failure (section 3.5) -- [ ] `PreToolUse` hook on `git push*`: throwaway `kaniko`/`buildah` build (no registry) after tests pass, hard-deny the push on either failure (section 3.6) +Wired into each agent as `python -m handler.hooks `: -**Definition of done:** run several projects side by side, each with its own agents, working directories, toolchain, and history, entirely through `curl` + a token, against either a live Postgres instance or the SQLite fallback — no Gitea, no Tailscale, no UI required, no state stored anywhere the control layer or API containers themselves live, nothing crosses a project boundary unless explicitly shared, no agent reaches `done` without a passing test run to show for it, and no push leaves that a local build already proved would fail. +- **`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. -### Phase 2 — Forge integration -- [ ] Repo-scoped actions via `forge`: branch creation, PR open, issue linking — one interface across GitHub/GitLab/Gitea/Forgejo/Bitbucket instead of a per-host integration -- [ ] Pin `forge` to a specific released version, not `@latest` — accepted risk given the project's youth, mitigated by not floating on a moving target -- [ ] Credential resolution (section 3.7): `credential_ref` → injected env var at spawn, shared by `forge` and git's credential helper -- [ ] Background poller reading back CI run results via `forge ci list` / `forge ci log`, backfilling `ci_status`/`ci_checked_at` on the log entry that recorded the push (section 3.6) +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`. -### Phase 3 — Production UI -- [ ] Web frontend, API-backed only (same contract as `curl`) -- [ ] Project switcher, agent list per project, live checkmark view, log history, "answer this question" form, plus a view for the shared/global feed +## Development -**Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. +```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 +``` -### Phase 4 — Observability (moved back, now optional) -- [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate) -- [ ] Grafana/Loki wiring documented as an optional add-on for self-hosters who already run that stack — not a dependency for anyone else +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. -### Phase 5 — Open-source release -- [ ] Strip any remaining homelab-specific defaults into config -- [ ] README: bring-your-own Claude Code login, bring-your-own network, bring-your-own git host, bring-your-own credential source (env var, file, or command) -- [ ] Publish +## Project layout -## 5. Open questions +``` +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) +``` -- Project name — still Cutout / Handler / Dead Drop / Backchannel / Umbra. -- API implementation language/framework — wants a good story for *both* Postgres and SQLite (a query layer or ORM that speaks both dialects) plus minimal runtime deps, given "portable" is still a hard requirement. -- Default webhook target for `Notification` — ship a zero-config adapter (ntfy needs no account) or leave it fully bring-your-own from day one? -- Reference deployment for Postgres — user-supplied external instance only, or does the repo also ship a docker-compose/StatefulSet example for people with nowhere else to put it? (Either way, Postgres is the one stateful component in the system — everything else stays a disposable container.) -- Who can write to `shared_context` — any agent by default, or does it need a higher-trust token than the per-project routes get? -- Per-project API tokens vs one global token — least-privilege argues for scoping tokens to a project, but this is single-operator, not multi-tenant, so a global token may just be simpler and sufficient. -- Pre-bake common toolchains into the base image (faster container start, bigger image) vs. always `mise install` on checkout against a cached, mounted data dir (smaller image, first-run latency) — worth benchmarking rather than guessing. -- Is a `test` task in `.mise.toml` a hard requirement (container refuses to run the agent without one) or a soft convention the Stop hook just skips if missing? Leaning hard requirement, since a silent skip defeats the point of the gate. -- `forge` also ships as a Go library, not just a CLI — if the API implementation language lands on Go, worth importing it directly instead of shelling out to a subprocess. Relevant input to the language question above, not a decision on its own. -- One token for both `forge` and git push vs. separate SSH deploy key + scoped API token per project — reasonable defaults differ by how much a given operator trusts a single credential with both git-write and forge-API scope. -- Polling cadence for the CI status backfill — fixed interval with backoff, or piggyback on the agent's own next checkpoint? A dedicated poller is simpler to reason about but is one more always-on process in an otherwise mostly-idle system. +## 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. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..e1d0427 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,247 @@ +# Remote Control Wrapper — Plan of Action + +**Status:** Living document — update phase checkboxes and status in place as work completes. Don't append new copies of this file; overwrite it, the way a checkmark gets overwritten. +**Last updated:** 2026-07-08 (revision 8: Phase 1 MVP vertical slice implemented — see checkboxes and README.md) + +--- + +## 1. What "done" looks like + +Production release means: + +- **Runs anywhere.** No hard dependency on Gitea or Tailscale — git hosting and network exposure are both pluggable, not assumed. +- A **web UI**, not just tmux/CLI, for spawning, monitoring, and interacting with agents. +- **All communication goes through an API.** The UI is a client of it; tmux/CLI become optional local conveniences, not the source of truth. +- Every agent leaves a **checkmark** behind: a small, current-state record of where it stopped, what's needed next, and any open questions for you. +- Every checkmark links to an entry in a **big log**: the append-only, complete history of everything every agent has ever done. +- State lives in a **centralized database** that the CLI wrapper (backend) writes to and the API reads from — not scattered flat files or a git-hosting-specific store. Control layer and API containers hold nothing persistent themselves; they can restart, redeploy, or scale out without losing data because all of it lives in the database. +- **Multiple projects run concurrently, each isolated from the others by default.** One control layer can host any number of projects, each with its own agents, working directory, and history — with an explicit, opt-in mechanism for the edge cases where something genuinely needs to cross that boundary. + +## 2. Non-negotiable constraints + +- Every agent process is a real `claude` binary invocation. No OAuth handling, no protocol reimplementation. +- **No hard dependency on Tailscale.** The API authenticates itself with a bearer token; users choose their own network exposure — Tailscale, a VPN, a reverse proxy, or plain localhost. The wrapper doesn't assume any of them. +- **No hard dependency on Gitea.** Git operations use plain `git` by default. Forge-specific niceties (PRs, issues, CI status) go through `forge` (git-pkgs/forge) as an optional, pluggable layer — one CLI that works the same against GitHub, GitLab, Gitea/Forgejo, or Bitbucket, never a requirement just to run the tool. +- **The database never stores raw credentials.** `projects.credential_ref` is a pointer (an env var name, a file path, a command to run) — never a token. The control layer resolves it to an actual secret only at spawn time, injected into that container's environment for that run. +- Design decisions above a stakes threshold get surfaced via `plan` mode + `AskUserQuestion`, not silently guessed. +- **Projects are isolated by default.** An agent only sees its own project's working directory, checkmarks, and log — nothing crosses that boundary unless something is explicitly marked shared. This is one operator running many of their own projects, not a multi-tenant service for other people — don't let the isolation model drift into looking like the latter. +- **Agents don't get to self-report "done."** A verification gate actually runs the project's own test task and blocks completion on failure — "done" in the database means a test run passed, not that the agent stopped talking. +- **A `git push` doesn't leave until a local build check passes too.** Same hard-block pattern as the test gate, and it runs after tests, not instead of them — cheap checks run before the more expensive one that would obviously fail anyway. +- If this ships open-source: no embedded credentials, no implied Anthropic affiliation, README states plainly that each user brings their own Claude Code login, their own git remote, and their own network layer. + +## 3. Control layer + API (build this first — this is the MVP) + +### 3.1 Data model — Postgres primary, SQLite fallback + +Two supported backends behind one data-access layer, not two separate code paths: + +- **Postgres (default for real deployments).** A live, centralized server is what actually makes "stateless containers" true — the control layer and API are just compute that can restart, redeploy, or scale to N replicas because none of them hold state locally. This is the deployment target for anyone running more than a single node, or wanting one datastore behind multiple agent hosts. +- **SQLite (minimal-infra fallback).** A single file, zero services to stand up — for a single-node/homelab-scale run where standing up Postgres is overkill. Explicitly a fallback, not the default: it doesn't give you the centralization the Postgres path does, and single-writer semantics limit it to one control-layer instance at a time. + +The schema is the same shape on both; only types differ slightly (Postgres gets proper `SERIAL`/`TIMESTAMPTZ`/`JSONB`, SQLite uses its looser dynamic typing). Pick one query layer/ORM that supports both dialects rather than hand-maintaining two schemas — see open questions. + +```sql +-- Postgres +CREATE TABLE projects ( + id TEXT PRIMARY KEY, -- slug, e.g. "leeworks-api" + root_dir TEXT NOT NULL, + git_remote TEXT, + credential_ref TEXT, -- pointer to a secret, e.g. "env:LEEWORKS_TOKEN" — never the token itself, see 3.7 + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE agents ( + id BIGSERIAL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, -- unique within a project, not globally + working_dir TEXT NOT NULL, + status TEXT NOT NULL, -- working | paused_for_input | blocked | done + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, name) +); + +CREATE TABLE checkmarks ( + agent_id BIGINT PRIMARY KEY REFERENCES agents(id), + checkpoint_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, + where_it_stopped TEXT, + next_steps JSONB, + open_question TEXT, + log_entry_id BIGINT REFERENCES log_entries(id), + tests_status TEXT NOT NULL DEFAULT 'unknown', -- pass | fail | unknown — see 3.5 + tested_at TIMESTAMPTZ, + build_status TEXT NOT NULL DEFAULT 'unknown', -- pass | fail | unknown — see 3.6 + built_at TIMESTAMPTZ +); + +CREATE TABLE log_entries ( + id BIGSERIAL PRIMARY KEY, + agent_id BIGINT NOT NULL REFERENCES agents(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + session_id TEXT, + status TEXT NOT NULL, + summary TEXT, + decisions TEXT, + question TEXT, + answer TEXT, -- filled in on resume; only field ever touched post-insert + visibility TEXT NOT NULL DEFAULT 'project', -- project | global — see 3.4 + push_sha TEXT, -- set if this checkpoint pushed; null if it didn't + ci_status TEXT NOT NULL DEFAULT 'not_applicable', -- not_applicable | pending | pass | fail — see 3.6 + ci_checked_at TIMESTAMPTZ +); +``` + +```sql +-- SQLite (minimal-infra fallback) — same shape, simpler types +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + root_dir TEXT NOT NULL, + git_remote TEXT, + credential_ref TEXT, + created_at TEXT NOT NULL +); +-- agents / checkmarks / log_entries: identical columns, JSONB becomes TEXT, +-- BIGSERIAL becomes INTEGER PRIMARY KEY AUTOINCREMENT, TIMESTAMPTZ becomes TEXT. +``` + +`checkmarks` is a literal upsert (`INSERT ... ON CONFLICT DO UPDATE` / `INSERT OR REPLACE`) keyed by `agent_id` — the "small file that gets overwritten," just as a row instead of a file. `log_entries` is insert-only except for the one `answer` backfill on resume, which is the DB equivalent of "linked directly to this checkmark." Agent identity is now `(project_id, name)`, not a bare name — two projects can each have an agent called `api` without colliding. + +### 3.2 Backend (CLI wrapper) — the only thing that writes + +- Spawns/lists/attaches/kills agents within a project (tmux + `claude` binary, one working directory or git worktree per agent, nested under that project's root) +- Owns every write to the database: creates the agent row, upserts the checkmark, inserts log entries +- Hooked via `Stop`/`SessionEnd` (checkpoint), `PreToolUse` (defer `AskUserQuestion`), `Notification` (fires a generic webhook — the user points that at ntfy, Pushover, Slack, email, whatever; the wrapper doesn't pick for them) + +### 3.3 API — the only thing that reads (plus writes the answer back) + +- Thin HTTP layer over the same database (Postgres in centralized deployments, SQLite for minimal-infra): `GET /projects`, `POST /projects`, `GET /projects/:project/agents`, `POST /projects/:project/agents`, `GET /projects/:project/agents/:name/checkmark`, `GET /projects/:project/agents/:name/log`, `POST /projects/:project/agents/:name/answer`, `POST /projects/:project/agents/:name/resume` +- Auth: a generated bearer token checked on every request. No assumption about what network the request arrives over — Tailscale, a VPN, or a bare reverse proxy are all just transport underneath it. +- The UI (Phase 3) and any future integration are just clients of this — same contract as `curl`. + +### 3.4 Multi-project isolation and cross-project sharing + +Every agent belongs to exactly one project. Isolation is the default; sharing is a deliberate, visible act, never an accident. + +**Isolation:** +- Filesystem: each project gets its own root directory; agents in that project work only within it (subdirectories or git worktrees underneath), never reaching into another project's tree. +- Naming: tmux sessions follow `project__agent`, matching the `(project_id, name)` uniqueness in the database — no cross-project collisions, and it's visually obvious which project a session belongs to from `tmux ls`. +- Config: git remote, credential reference, and any other per-project settings, live on the `projects` row — not global config — so projects can point at entirely different repos, hosts, or forges independently. +- API: every agent route is nested under `/projects/:project/...`. There's no endpoint that returns another project's data by accident. + +**The explicit sharing mechanism, for the edge cases:** +- `log_entries.visibility` — defaults to `project`, can be set to `global` when an agent (or you) decides a specific checkpoint genuinely matters beyond its own project. A `GET /shared/log` endpoint surfaces only the entries marked `global`, across all projects — a deliberate opt-in feed, not a merged view of everything. +- `shared_context` table — a small key-value store for standing facts multiple projects need to reference (a shared staging URL, a schema version, a convention decision), independent of any single checkpoint: + ```sql + CREATE TABLE shared_context ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + set_by_agent_id BIGINT REFERENCES agents(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + ``` + Any agent can read it (`GET /shared/context`); writing (`PUT /shared/context/:key`) is the one place worth gating behind an explicit flag or higher-trust token, since it's the one table every project implicitly trusts. + +Nothing here allows one project to read another project's checkmarks or private log entries directly — the only paths across the boundary are the two explicit ones above. + +### 3.5 Toolchains and test verification + +Environment reliability and result reliability are two different problems — solve both, don't conflate them. + +**Environment: mise, not nix.** A single generic base image (mise + the `claude` binary + `git` + `gh`) with no baked-in language runtimes — one image for every project regardless of stack. Each project carries its own `.mise.toml`, pinning exact tool versions (Rust, Python, Node, whatever) and a lockfile committed to the repo for strict, reproducible installs. On container start, `mise install --non-interactive` reads that lockfile and materializes the project's exact toolchain — the container doesn't need to know or care what language it's about to run. Nix would get similar pinning with more ceremony and a heavier runtime; since Docker already provides OS-level isolation, mise's language-level pinning is the right amount of tool for this. + +Every project defines a canonical task, regardless of stack: + +```toml +[tasks.test] +description = "Run the test suite" +run = "pytest" # or `cargo test`, `npm test`, whatever the project actually uses + +[tasks.verify] +depends = ["lint", "test"] +``` + +**Result: a Stop hook, not self-reporting.** After each turn, a `Stop` hook runs `mise run test` (or `verify`) in the agent's working directory and checks the exit code. Nonzero → `decision: "block"`, with the failure output fed back to the agent, so the turn cannot end on a broken test suite. This is what "correct" actually rests on: it's the same command for every project because mise's task abstraction is the uniform interface, and it's enforced by the harness, not reported by the model. + +The result feeds straight into the schema already in place: the hook sets `checkmarks.tests_status` and `tested_at` on every checkpoint, so `status = 'done'` in the database is only ever true alongside a passing test run — not a claim taken on faith. + +### 3.6 Push gate and CI follow-through + +Tests catch broken code; they don't catch a broken container. A Dockerfile with a stale `COPY` path passes `pytest` fine and then fails to build — worth catching locally, before it costs a CI run. + +**Local build check — cheap, throwaway, no registry involved.** Since CI/CD (on its own, beefier runner) owns the real build-and-push, the local check only needs to prove the Dockerfile is sound: a `kaniko`/`buildah` build with no push target, output discarded. No Docker socket, no `--privileged`, nothing the agent's own sandbox needs elevated trust for — it's the same category of tool CI systems adopted specifically to avoid handing out real Docker-in-Docker access. This is `mise run build-image` alongside the existing `test` task, same interface, same project regardless of stack. + +**Enforcement — same mechanism as tests, one step later.** A `PreToolUse` hook matching `Bash(git push*)` runs the verification chain (tests, then the throwaway build — cheap first, expensive second) and returns `permissionDecision: "deny"` on the first failure, with the reason surfaced to the agent. A push that's already known to fail CI doesn't leave. `checkmarks.build_status`/`built_at` record the result the same way `tests_status` does. + +**CI is still the authoritative gate — the wrapper just closes the loop on it.** Once a push clears the local checks and actually goes out, whatever CI system the host runs (GitHub Actions, Gitea/Forgejo Actions, GitLab CI) does the real build-and-push on its own runner, same as today. The wrapper records `push_sha` and sets `ci_status = 'pending'` on that log entry, then a background poller in the control layer — not a hook, since this can take minutes — checks the run tied to that commit with `forge ci list` / `forge ci log `. Same two commands regardless of which host the project is on; `forge` detects the forge type from the remote, so this isn't per-host integration work, just one poller calling one interface. Once the run resolves, the poller backfills `ci_status` and `ci_checked_at` on that same log entry — the DB equivalent of "recording why it did or didn't ultimately land," without ever exposing the wrapper to inbound webhook traffic. + +### 3.7 Credentials: forge and git + +Two things need to authenticate per project — the `forge` CLI (for PRs, issues, CI status) and `git` itself (for push/pull) — and the design goal is one secret servicing both, not two to manage. + +**Resolution, not storage.** `projects.credential_ref` is a pointer (`env:VAR_NAME`, `file:/path`, `cmd:some command`), never the token itself — mirrors `forge`'s own `--token-cmd` pattern, just one level up. At spawn time, the control layer resolves the reference to an actual value and injects it into that agent's container environment only — nothing persisted in Postgres/SQLite, nothing baked into an image. + +**One token, two consumers, where the host allows it.** `forge` reads the resolved token from the environment directly (`GITHUB_TOKEN`, `GITEA_TOKEN`, `FORGE_TOKEN`, per host) with zero extra config once it's injected. For git's own HTTPS auth, the wrapper writes a small credential-helper at container start that hands back that same value — most self-hosted forges (Gitea/Forgejo, GitLab) accept a PAT as the HTTP password, so one token covers both `forge pr create` and `git push` for a given project. + +**SSH deploy keys as the per-project alternative.** Some operators won't want a single token holding both git-write and forge-API scope. Where that matters, a project can use an SSH deploy key for git transport and a separately-scoped, more limited token for `forge`'s PR/issue/CI operations — configured per project, not mandated globally, since this is a real preference split among self-hosters rather than a settled question. + +## 4. Phased roadmap + +### Phase 0 — Prerequisites +- [ ] `claude` binary installed and authenticated (per user) +- [ ] `git` installed; `forge` (git-pkgs/forge) optional +- [ ] `mise` installed in the base agent image; each project supplies its own `.mise.toml` with, at minimum, a `test` task +- [ ] A Postgres connection string (default target) — or nothing at all if running the SQLite fallback for a single-node/minimal-infra setup + +### Phase 1 — Control layer + API (the MVP) + +Implemented as a vertical slice — see `README.md` for what runs today. Live end-to-end +agent spawning against a real `claude` binary + tmux is deferred behind mocked seams +(`control.tmux`, `hooks.verify`, `control.spawn.resume`). + +- [x] Database schema + migrations for both backends, Postgres default / SQLite fallback, including `projects` and project-scoped agents (section 3.1) +- [x] Control script: spawn/list/attach/kill, tmux + `claude` binary, one working dir/worktree per agent, namespaced by project — itself stateless, all state written straight to the database +- [x] Hooks writing checkmark/log rows (`Stop`/`SessionEnd`, `PreToolUse` defer, `Notification` → generic webhook), all scoped to the owning project +- [x] HTTP API over the same database, bearer-token auth, all agent routes nested under `/projects/:project/` +- [x] Resume flow: API endpoint takes an answer, feeds it back via `claude --resume` (control seam) +- [x] Shared-context and shared-log endpoints for the explicit cross-project edge case (section 3.4) +- [x] `Stop` hook verification gate: `mise run test` on every checkpoint, block completion on failure (section 3.5). `mise install` on checkout deferred to live spawn wiring. +- [x] `PreToolUse` hook on `git push*`: throwaway build (no registry) after tests pass, hard-deny the push on either failure (section 3.6). Wired to `mise run build-image`; the real `kaniko`/`buildah` invocation is deferred behind the verify seam. + +**Definition of done:** run several projects side by side, each with its own agents, working directories, toolchain, and history, entirely through `curl` + a token, against either a live Postgres instance or the SQLite fallback — no Gitea, no Tailscale, no UI required, no state stored anywhere the control layer or API containers themselves live, nothing crosses a project boundary unless explicitly shared, no agent reaches `done` without a passing test run to show for it, and no push leaves that a local build already proved would fail. + +### Phase 2 — Forge integration +- [ ] Repo-scoped actions via `forge`: branch creation, PR open, issue linking — one interface across GitHub/GitLab/Gitea/Forgejo/Bitbucket instead of a per-host integration +- [ ] Pin `forge` to a specific released version, not `@latest` — accepted risk given the project's youth, mitigated by not floating on a moving target +- [ ] Credential resolution (section 3.7): `credential_ref` → injected env var at spawn, shared by `forge` and git's credential helper +- [ ] Background poller reading back CI run results via `forge ci list` / `forge ci log`, backfilling `ci_status`/`ci_checked_at` on the log entry that recorded the push (section 3.6) + +### Phase 3 — Production UI +- [ ] Web frontend, API-backed only (same contract as `curl`) +- [ ] Project switcher, agent list per project, live checkmark view, log history, "answer this question" form, plus a view for the shared/global feed + +**Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. + +### Phase 4 — Observability (moved back, now optional) +- [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate) +- [ ] Grafana/Loki wiring documented as an optional add-on for self-hosters who already run that stack — not a dependency for anyone else + +### Phase 5 — Open-source release +- [ ] Strip any remaining homelab-specific defaults into config +- [ ] README: bring-your-own Claude Code login, bring-your-own network, bring-your-own git host, bring-your-own credential source (env var, file, or command) +- [ ] Publish + +## 5. Open questions + +Decisions made for the Phase 1 MVP are marked **[resolved]**; the rest remain open. + +- Project name — still Cutout / Handler / Dead Drop / Backchannel / Umbra. **[resolved: `handler` for now — the package and repo name.]** +- API implementation language/framework — wants a good story for *both* Postgres and SQLite (a query layer or ORM that speaks both dialects) plus minimal runtime deps, given "portable" is still a hard requirement. **[resolved: Python + FastAPI + SQLAlchemy Core + Alembic.]** +- Default webhook target for `Notification` — ship a zero-config adapter (ntfy needs no account) or leave it fully bring-your-own from day one? **[resolved: fully bring-your-own via `WEBHOOK_URL`; no-op when unset.]** +- Reference deployment for Postgres — user-supplied external instance only, or does the repo also ship a docker-compose/StatefulSet example for people with nowhere else to put it? (Either way, Postgres is the one stateful component in the system — everything else stays a disposable container.) +- Who can write to `shared_context` — any agent by default, or does it need a higher-trust token than the per-project routes get? **[resolved: gated behind an optional higher-trust `SHARED_CONTEXT_WRITE_TOKEN`, falling back to the global token when unset.]** +- Per-project API tokens vs one global token — least-privilege argues for scoping tokens to a project, but this is single-operator, not multi-tenant, so a global token may just be simpler and sufficient. **[resolved: single global bearer token for the MVP.]** +- Pre-bake common toolchains into the base image (faster container start, bigger image) vs. always `mise install` on checkout against a cached, mounted data dir (smaller image, first-run latency) — worth benchmarking rather than guessing. +- Is a `test` task in `.mise.toml` a hard requirement (container refuses to run the agent without one) or a soft convention the Stop hook just skips if missing? Leaning hard requirement, since a silent skip defeats the point of the gate. **[resolved: hard requirement — `spawn` refuses a project with no `[tasks.test]`.]** +- `forge` also ships as a Go library, not just a CLI — if the API implementation language lands on Go, worth importing it directly instead of shelling out to a subprocess. Relevant input to the language question above, not a decision on its own. **[resolved moot: language is Python; `forge` will be shelled out to in Phase 2.]** +- One token for both `forge` and git push vs. separate SSH deploy key + scoped API token per project — reasonable defaults differ by how much a given operator trusts a single credential with both git-write and forge-API scope. +- Polling cadence for the CI status backfill — fixed interval with backoff, or piggyback on the agent's own next checkpoint? A dedicated poller is simpler to reason about but is one more always-on process in an otherwise mostly-idle system.