feat(mvp): Phase 1 control layer + API vertical slice

Implements the Phase 1 MVP from the README: a stateless control layer + HTTP
API over a centralized database, with hook-enforced test/push gates.

- DB layer: SQLAlchemy Core, one schema rendering both Postgres (BIGSERIAL /
  TIMESTAMPTZ / JSONB) and SQLite (INTEGER PK / TEXT / JSON) via portable types;
  native ON CONFLICT DO UPDATE checkmark upsert on both dialects.
- Alembic dual-dialect migrations (render_as_batch for SQLite); tests run a real
  `alembic upgrade head`.
- FastAPI: projects/agents/checkmark/log/answer/resume + shared log/context
  routes, single global bearer token, higher-trust token gating shared-context
  writes, project isolation on every route.
- Hooks (`python -m handler.hooks <event>`): Stop test gate (block on red),
  PreToolUse AskUserQuestion defer + `git push` gate (tests then throwaway
  build), Notification generic webhook (no-op without WEBHOOK_URL). Identity via
  env injected at spawn; verify is the mock seam.
- Control CLI: spawn/list/attach/kill, hard `.mise.toml [tasks.test]` gate,
  generated per-agent settings.json, identity + DATABASE_URL injected via tmux;
  tmux is the mock seam.
- 45 tests (SQLite), ruff clean. Live claude/tmux/mise spawning deferred behind
  the mocked seams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY
This commit is contained in:
Claude
2026-07-07 18:16:54 +00:00
parent 2fafc91c0e
commit eba0e19ec9
51 changed files with 2982 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Handler configuration — copy to .env and fill in. Never commit real secrets.
# Database. SQLite fallback (single-node) or Postgres (centralized, default for real deploys).
# SQLite: sqlite:////absolute/path/to/handler.db
# Postgres: postgresql+psycopg://user:pass@host:5432/handler
DATABASE_URL=sqlite:////var/lib/handler/handler.db
# Single global bearer token gating every API route. Required for the API to start.
AUTH_TOKEN=change-me-to-a-long-random-string
# Optional higher-trust token gating PUT /shared/context/:key.
# Falls back to AUTH_TOKEN if unset.
# SHARED_CONTEXT_WRITE_TOKEN=
# Optional generic webhook target for the Notification hook (ntfy, Pushover, Slack, ...).
# Fully bring-your-own; the Notification hook is a no-op when unset.
# WEBHOOK_URL=https://ntfy.sh/my-topic
# Base directory under which per-project roots and agent worktrees live (isolation).
PROJECTS_ROOT=/var/lib/handler/projects
# Binary overrides (defaults shown). Point at fakes in tests/CI.
# CLAUDE_BIN=claude
# MISE_BIN=mise
# TMUX_BIN=tmux
+16
View File
@@ -0,0 +1,16 @@
# Handler dogfoods its own gate: this repo defines the canonical `test` task the
# control layer's Stop hook enforces. Any project Handler manages carries one of these.
[tools]
python = "3.11"
[tasks.test]
description = "Run the test suite"
run = "pytest"
[tasks.lint]
description = "Lint the codebase"
run = "ruff check ."
[tasks.verify]
description = "Lint then test"
depends = ["lint", "test"]
+41
View File
@@ -0,0 +1,41 @@
# Alembic config. The database URL is injected from handler.config in env.py,
# so one config serves both the Postgres and SQLite backends — set DATABASE_URL
# and run `alembic upgrade head`.
[alembic]
script_location = src/handler/migrations
prepend_sys_path = src
# sqlalchemy.url is intentionally left blank; env.py fills it from Settings.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+47
View File
@@ -0,0 +1,47 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "handler"
version = "0.1.0"
description = "Remote control wrapper for Claude Code agents across isolated projects"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"fastapi>=0.115,<0.116",
"uvicorn[standard]>=0.34,<0.35",
"sqlalchemy>=2.0,<2.1",
"alembic>=1.14,<1.15",
"psycopg[binary]>=3.2,<3.3",
"pydantic>=2.10,<3.0",
"pydantic-settings>=2.7,<3.0",
"httpx>=0.28,<0.29",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9.0",
"respx>=0.22,<0.23",
"ruff>=0.9,<0.10",
]
[project.scripts]
handler = "handler.control.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/handler"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
src = ["src", "tests"]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B"]
# B008: FastAPI's Depends()/Query() in argument defaults is the framework's idiom.
ignore = ["B008"]
+8
View File
@@ -0,0 +1,8 @@
"""Handler — remote control wrapper for Claude Code agents.
See README.md for the full design. Phase 1 (this package) is the control layer +
API: a centralized database, a stateless HTTP read API, a tmux + ``claude`` control
layer as the sole writer, and hook-enforced test/push gates.
"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""HTTP API — the read layer over the same database (plus the answer backfill)."""
+32
View File
@@ -0,0 +1,32 @@
"""FastAPI application factory.
Run with: ``uvicorn handler.api.app:create_app --factory``. The UI and any future
integration are just clients of this — same contract as ``curl``.
"""
from __future__ import annotations
from fastapi import FastAPI
from .routes import agents, interaction, projects, shared
def create_app() -> FastAPI:
app = FastAPI(
title="Handler API",
version="0.1.0",
summary="Read layer over the Handler control database.",
)
@app.get("/health", tags=["meta"])
def health() -> dict:
return {"status": "ok"}
app.include_router(projects.router)
app.include_router(agents.router)
app.include_router(interaction.router)
app.include_router(shared.router)
return app
app = create_app()
+64
View File
@@ -0,0 +1,64 @@
"""Shared dependencies: bearer auth and a per-request DB connection.
Auth is a single global token (README 3.3), compared in constant time. Shared-context
writes may require a separate higher-trust token (README 3.4), falling back to the
global token when unset.
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import Connection
from ..config import Settings, get_settings
from ..db.engine import connection
_bearer = HTTPBearer(auto_error=False)
def db_conn() -> Iterator[Connection]:
with connection() as conn:
yield conn
def _check(provided: str | None, expected: str) -> bool:
if not expected or not provided:
return False
return secrets.compare_digest(provided, expected)
def require_auth(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
token = creds.credentials if creds else None
# The shared-context write token is higher-trust, so it also grants normal access;
# a single request carries one bearer, and it should never be rejected for being the
# more privileged one.
valid = _check(token, settings.auth_token) or _check(
token, settings.effective_shared_write_token
)
if not valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_shared_write(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
"""Gate for shared_context writes — the one table every project implicitly trusts."""
token = creds.credentials if creds else None
if not _check(token, settings.effective_shared_write_token):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="shared-context write requires the shared-context write token",
headers={"WWW-Authenticate": "Bearer"},
)
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+78
View File
@@ -0,0 +1,78 @@
"""Agent listing/registration and the read views (checkmark, log).
The agent *row* is registered here (the API mirror listed in README 3.3); the agent
*process* is spawned by the control CLI. All routes are nested under
``/projects/{project}`` so nothing crosses a project boundary.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError
from ...db import repository as repo
from ..deps import db_conn, require_auth
from ..schemas import AgentIn, AgentOut, CheckmarkOut, LogEntryOut
from .common import resolve_agent
router = APIRouter(
prefix="/projects/{project}/agents",
tags=["agents"],
dependencies=[Depends(require_auth)],
)
def _require_project(conn: Connection, project: str) -> None:
if repo.get_project(conn, project) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found")
@router.get("", response_model=list[AgentOut])
def list_agents(project: str, conn: Connection = Depends(db_conn)) -> list[dict]:
_require_project(conn, project)
return repo.list_agents(conn, project)
@router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED)
def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn)) -> dict:
_require_project(conn, project)
if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=f"agent '{body.name}' exists in project '{project}'",
)
try:
return repo.create_agent(
conn,
project_id=project,
name=body.name,
working_dir=body.working_dir,
status=body.status,
)
except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="agent exists") from exc
@router.get("/{name}/checkmark", response_model=CheckmarkOut)
def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict:
agent = resolve_agent(conn, project, name)
checkmark = repo.get_checkmark(conn, agent["id"])
if checkmark is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"agent '{name}' has no checkmark yet",
)
return checkmark
@router.get("/{name}/log", response_model=list[LogEntryOut])
def get_log(
project: str,
name: str,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
conn: Connection = Depends(db_conn),
) -> list[dict]:
agent = resolve_agent(conn, project, name)
return repo.get_log(conn, agent["id"], limit=limit, offset=offset)
+25
View File
@@ -0,0 +1,25 @@
"""Small route helpers shared across agent-scoped endpoints."""
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy import Connection
from ...db import repository as repo
def resolve_agent(conn: Connection, project: str, name: str) -> dict:
"""Fetch an agent by ``(project, name)`` or 404.
Enforces project isolation (README 3.4): the lookup is always project-scoped, so
there is no path that returns another project's agent by accident.
"""
if repo.get_project(conn, project) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found")
agent = repo.get_agent_by_name(conn, project, name)
if agent is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"agent '{name}' not found in project '{project}'",
)
return agent
+86
View File
@@ -0,0 +1,86 @@
"""Answer + resume — the async replacement for a human sitting at the tmux TTY.
``answer`` writes the operator's reply into the log entry that recorded the question
(the sole API mutation of ``log_entries``). ``resume`` then feeds that answer back to
the agent via ``claude --resume``, routed through the control-layer seam so it stays
mockable and the API/control boundary is explicit. They are two endpoints (README 3.3)
so the operator can answer many questions, then resume.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from ...control import spawn
from ...db import repository as repo
from ..deps import db_conn, require_auth
from ..schemas import AnswerIn, AnswerOut, ResumeIn, ResumeOut
from .common import resolve_agent
router = APIRouter(
prefix="/projects/{project}/agents/{name}",
tags=["interaction"],
dependencies=[Depends(require_auth)],
)
@router.post("/answer", response_model=AnswerOut)
def answer(
project: str,
name: str,
body: AnswerIn,
conn: Connection = Depends(db_conn),
) -> AnswerOut:
agent = resolve_agent(conn, project, name)
if body.log_entry_id is not None:
log_entry_id = body.log_entry_id
else:
open_q = repo.get_latest_open_question(conn, agent["id"])
if open_q is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail="no open question to answer; pass log_entry_id explicitly",
)
log_entry_id = open_q["id"]
updated = repo.update_log_answer(conn, log_entry_id, body.answer)
if not updated:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="log entry not found")
# The question is answered but the agent is not resumed yet; leave status as
# paused_for_input until /resume actually feeds it back.
return AnswerOut(log_entry_id=log_entry_id, answered=True)
@router.post("/resume", response_model=ResumeOut)
def resume(
project: str,
name: str,
body: ResumeIn,
conn: Connection = Depends(db_conn),
) -> ResumeOut:
agent = resolve_agent(conn, project, name)
answer_text = body.answer
if answer_text is None:
open_q = repo.get_latest_open_question(conn, agent["id"])
# The just-answered question no longer counts as open, so pull the most recent
# answered entry if no explicit answer was supplied.
if open_q is not None and open_q.get("answer"):
answer_text = open_q["answer"]
else:
recent = repo.get_log(conn, agent["id"], limit=1)
if recent and recent[0].get("answer"):
answer_text = recent[0]["answer"]
if not answer_text:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="no answer available to resume with; answer first or pass one",
)
ok, detail = spawn.resume(agent, answer_text)
if ok:
repo.set_agent_status(conn, agent["id"], "working")
return ResumeOut(agent=name, resumed=ok, detail=detail)
+34
View File
@@ -0,0 +1,34 @@
"""Project registration + listing (control-plane; the process spawn is the CLI's job)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError
from ...db import repository as repo
from ..deps import db_conn, require_auth
from ..schemas import ProjectIn, ProjectOut
router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)])
@router.get("", response_model=list[ProjectOut])
def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_projects(conn)
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_project(conn, body.id) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"project '{body.id}' exists")
try:
return repo.create_project(
conn,
project_id=body.id,
root_dir=body.root_dir,
git_remote=body.git_remote,
credential_ref=body.credential_ref,
)
except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc
+51
View File
@@ -0,0 +1,51 @@
"""The two explicit cross-project paths (README 3.4): the global log feed and the
shared-context key/value store. Reads use the normal token; writing a shared-context
key requires the higher-trust token.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection
from ...db import repository as repo
from ..deps import db_conn, require_auth, require_shared_write
from ..schemas import LogEntryOut, SharedContextIn, SharedContextOut
router = APIRouter(prefix="/shared", tags=["shared"], dependencies=[Depends(require_auth)])
@router.get("/log", response_model=list[LogEntryOut])
def shared_log(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
conn: Connection = Depends(db_conn),
) -> list[dict]:
"""Only entries explicitly marked ``global`` — a deliberate opt-in feed."""
return repo.get_shared_log(conn, limit=limit, offset=offset)
@router.get("/context", response_model=list[SharedContextOut])
def shared_context(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.get_shared_context(conn)
@router.get("/context/{key}", response_model=SharedContextOut)
def shared_context_key(key: str, conn: Connection = Depends(db_conn)) -> dict:
row = repo.get_shared_context_key(conn, key)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"key '{key}' not set")
return row
@router.put(
"/context/{key}",
response_model=SharedContextOut,
dependencies=[Depends(require_shared_write)],
)
def put_shared_context(
key: str,
body: SharedContextIn,
conn: Connection = Depends(db_conn),
) -> dict:
return repo.set_shared_context(conn, key, body.value, agent_id=None)
+112
View File
@@ -0,0 +1,112 @@
"""Pydantic request/response models. ``from_attributes`` lets us hand a DB row
mapping straight in; timestamps serialize as ISO-8601.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class ProjectIn(BaseModel):
id: str
root_dir: str
git_remote: str | None = None
credential_ref: str | None = None
class ProjectOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
root_dir: str
git_remote: str | None = None
credential_ref: str | None = None
created_at: datetime
class AgentIn(BaseModel):
name: str
working_dir: str
status: str = "working"
class AgentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
project_id: str
name: str
working_dir: str
status: str
created_at: datetime
class CheckmarkOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
agent_id: int
checkpoint_at: datetime
status: str
where_it_stopped: str | None = None
next_steps: list[str] | None = None
open_question: str | None = None
log_entry_id: int | None = None
tests_status: str
tested_at: datetime | None = None
build_status: str
built_at: datetime | None = None
class LogEntryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
agent_id: int
created_at: datetime
session_id: str | None = None
status: str
summary: str | None = None
decisions: str | None = None
question: str | None = None
answer: str | None = None
visibility: str
push_sha: str | None = None
ci_status: str
ci_checked_at: datetime | None = None
class AnswerIn(BaseModel):
answer: str
# If omitted, the answer targets the agent's latest open question.
log_entry_id: int | None = None
class AnswerOut(BaseModel):
log_entry_id: int
answered: bool
class ResumeIn(BaseModel):
# Optional explicit answer to feed back; if omitted, the stored answer is used.
answer: str | None = None
class ResumeOut(BaseModel):
agent: str
resumed: bool
detail: str
class SharedContextIn(BaseModel):
value: str
class SharedContextOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
key: str
value: str
set_by_agent_id: int | None = None
updated_at: datetime
+48
View File
@@ -0,0 +1,48 @@
"""Single source of env-driven configuration.
Every entrypoint — the API app, the control CLI, and each hook subprocess — reads
the same :class:`Settings`. A spawned agent's hooks reach the same database purely
by inheriting ``DATABASE_URL`` in their environment (see ``control.spawn``).
"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Datastore. Drives dialect selection everywhere; nothing else branches on
# "is it sqlite" except db.upsert.
database_url: str = "sqlite:///./handler.db"
# The single global bearer token gating every API route (README 3.3).
auth_token: str = ""
# Optional higher-trust token for PUT /shared/context/:key. Falls back to
# auth_token when unset (README 3.4 open question, resolved to "gate it").
shared_context_write_token: str | None = None
# Optional generic webhook target for the Notification hook. No-op when unset.
webhook_url: str | None = None
# Base directory under which per-project roots / agent worktrees live.
projects_root: str = "./projects"
# Binary overrides so tests/CI can point at fakes.
claude_bin: str = "claude"
mise_bin: str = "mise"
tmux_bin: str = "tmux"
@property
def effective_shared_write_token(self) -> str:
"""Token required to write shared_context; defaults to the global token."""
return self.shared_context_write_token or self.auth_token
@lru_cache
def get_settings() -> Settings:
return Settings()
+3
View File
@@ -0,0 +1,3 @@
"""Control layer — the CLI wrapper, the only thing that spawns agents and writes
their rows. Stateless: all state goes straight to the database.
"""
+108
View File
@@ -0,0 +1,108 @@
"""``handler`` CLI — spawn/list/attach/kill.
The DB is the source of truth for what agents exist; tmux is cross-checked for
liveness. All commands are project-namespaced.
"""
from __future__ import annotations
import argparse
import os
import sys
from ..db import repository as repo
from ..db.engine import connection
from . import spawn, tmux
def _cmd_spawn(args: argparse.Namespace) -> int:
try:
agent = spawn.spawn(
args.project,
args.name,
subdir=args.dir,
worktree_branch=args.worktree,
task=args.task,
)
except spawn.SpawnError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"spawned agent '{agent['name']}' (id={agent['id']}) in project '{args.project}'")
print(f" working_dir: {agent['working_dir']}")
print(f" tmux session: {tmux.session_name(args.project, args.name)}")
return 0
def _cmd_list(args: argparse.Namespace) -> int:
live = set(tmux.list_sessions())
with connection() as conn:
if args.project:
projects = [args.project] if repo.get_project(conn, args.project) else []
else:
projects = [p["id"] for p in repo.list_projects(conn)]
for project_id in projects:
for agent in repo.list_agents(conn, project_id):
session = tmux.session_name(project_id, agent["name"])
alive = "live" if session in live else "-"
print(f"{project_id}/{agent['name']}\t{agent['status']}\t{alive}\t{session}")
return 0
def _cmd_attach(args: argparse.Namespace) -> int:
session = tmux.session_name(args.project, args.name)
if not tmux.has_session(session):
print(f"error: no live session '{session}'", file=sys.stderr)
return 1
# Replace this process with an interactive tmux attach.
os.execvp("tmux", ["tmux", "attach", "-t", session])
return 0 # pragma: no cover - execvp does not return
def _cmd_kill(args: argparse.Namespace) -> int:
try:
spawn.kill(args.project, args.name)
except spawn.SpawnError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"killed '{args.project}/{args.name}'")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="handler", description="Handler control layer")
sub = parser.add_subparsers(dest="command", required=True)
p_spawn = sub.add_parser("spawn", help="spawn an agent")
p_spawn.add_argument("--project", required=True)
p_spawn.add_argument("--name", required=True)
group = p_spawn.add_mutually_exclusive_group()
group.add_argument("--worktree", metavar="BRANCH", help="git worktree on BRANCH")
group.add_argument("--dir", metavar="SUBDIR", help="subdirectory under project root")
p_spawn.add_argument("--task", help="initial task/prompt for the agent")
p_spawn.set_defaults(func=_cmd_spawn)
p_list = sub.add_parser("list", help="list agents")
p_list.add_argument("--project", help="limit to one project")
p_list.set_defaults(func=_cmd_list)
p_attach = sub.add_parser("attach", help="attach to an agent's tmux session")
p_attach.add_argument("--project", required=True)
p_attach.add_argument("--name", required=True)
p_attach.set_defaults(func=_cmd_attach)
p_kill = sub.add_parser("kill", help="kill an agent's session")
p_kill.add_argument("--project", required=True)
p_kill.add_argument("--name", required=True)
p_kill.set_defaults(func=_cmd_kill)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+49
View File
@@ -0,0 +1,49 @@
"""Generate the per-agent Claude Code ``settings.json`` that wires each hook event to
``python -m handler.hooks <event>``.
This is the declarative half of hook integration; the imperative half — the agent
identity and ``DATABASE_URL`` — is injected as environment via tmux (see
``control.spawn``), because hook stdin does not carry our identity.
"""
from __future__ import annotations
import json
import os
import sys
def _hook_command(event: str) -> str:
# Use the exact interpreter the control layer runs under, so the hook resolves the
# same handler package and virtualenv inside the tmux session.
return f"{sys.executable} -m handler.hooks {event}"
def build_settings() -> dict:
return {
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}],
"SessionEnd": [
{"hooks": [{"type": "command", "command": _hook_command("session_end")}]}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|Bash",
"hooks": [{"type": "command", "command": _hook_command("pre_tool_use")}],
}
],
"Notification": [
{"hooks": [{"type": "command", "command": _hook_command("notification")}]}
],
}
}
def write_settings(working_dir: str) -> str:
"""Write ``.claude/settings.json`` under the agent's working dir; return its path."""
claude_dir = os.path.join(working_dir, ".claude")
os.makedirs(claude_dir, exist_ok=True)
path = os.path.join(claude_dir, "settings.json")
with open(path, "w") as fh:
json.dump(build_settings(), fh, indent=2)
return path
+117
View File
@@ -0,0 +1,117 @@
"""Spawn orchestration: the ``.mise.toml`` gate, the agent row, the generated
settings, identity/env injection, and the tmux launch — plus the resume seam the API
calls.
Order matters: the hard ``test``-task gate is checked *before* any row is written or
process launched, so a project without a canonical test task never gets an agent
(README 3.5, resolved as a hard requirement).
"""
from __future__ import annotations
import os
import tomllib
from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import settings_gen, tmux, worktree
class SpawnError(Exception):
"""Raised when an agent cannot be spawned (missing project, no test task, ...)."""
def require_test_task(working_dir: str) -> None:
"""Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``."""
mise_path = os.path.join(working_dir, ".mise.toml")
if not os.path.exists(mise_path):
raise SpawnError(
f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task "
"before an agent can run against it"
)
with open(mise_path, "rb") as fh:
data = tomllib.load(fh)
tasks = data.get("tasks", {})
if "test" not in tasks:
raise SpawnError(
f".mise.toml in {working_dir} has no [tasks.test]: the verification gate "
"requires a canonical test task"
)
def _claude_command(task: str | None, settings_path: str) -> str:
claude = get_settings().claude_bin
argv = [claude, "--settings", settings_path]
if task:
argv.append(_shell_quote(task))
return " ".join(argv)
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\\''") + "'"
def spawn(
project_id: str,
name: str,
*,
subdir: str | None = None,
worktree_branch: str | None = None,
task: str | None = None,
) -> dict:
"""Create and launch an agent. Returns the agent row."""
with connection() as conn:
project = repo.get_project(conn, project_id)
if project is None:
raise SpawnError(f"project '{project_id}' not registered")
if repo.get_agent_by_name(conn, project_id, name) is not None:
raise SpawnError(f"agent '{name}' already exists in project '{project_id}'")
working_dir = worktree.resolve_working_dir(
project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch
)
# Hard gate before any state is written or process launched.
require_test_task(working_dir)
agent = repo.create_agent(
conn, project_id=project_id, name=name, working_dir=working_dir, status="working"
)
settings_path = settings_gen.write_settings(working_dir)
env = {
"HANDLER_PROJECT_ID": project_id,
"HANDLER_AGENT_NAME": name,
"HANDLER_AGENT_ID": str(agent["id"]),
"DATABASE_URL": get_settings().database_url,
}
session = tmux.session_name(project_id, name)
command = _claude_command(task, settings_path)
tmux.new_session(session, cwd=working_dir, command=command, env=env)
return agent
def kill(project_id: str, name: str) -> None:
with connection() as conn:
agent = repo.get_agent_by_name(conn, project_id, name)
if agent is None:
raise SpawnError(f"agent '{name}' not found in project '{project_id}'")
session = tmux.session_name(project_id, name)
if tmux.has_session(session):
tmux.kill_session(session)
repo.set_agent_status(conn, agent["id"], "done")
def resume(agent: dict, answer: str) -> tuple[bool, str]:
"""Feed an operator's answer back to a live agent.
The seam the API's ``/resume`` route calls (and the one tests mock). Sends the
answer into the agent's tmux session so the waiting ``claude`` process receives it.
"""
session = tmux.session_name(agent["project_id"], agent["name"])
if not tmux.has_session(session):
return False, f"no live session '{session}' to resume"
tmux.send_keys(session, answer)
return True, f"answer delivered to session '{session}'"
+65
View File
@@ -0,0 +1,65 @@
"""Thin tmux wrapper — the single mock seam for spawning.
Every tmux/claude invocation goes through these functions so tests can substitute a
fake and never touch a real tmux server or ``claude`` binary.
"""
from __future__ import annotations
import subprocess
from ..config import get_settings
def session_name(project_id: str, agent_name: str) -> str:
"""``project__agent`` with tmux-illegal characters sanitized (README 3.4)."""
safe = f"{project_id}__{agent_name}"
for ch in (".", ":", " "):
safe = safe.replace(ch, "-")
return safe
def new_session(name: str, cwd: str, command: str, env: dict[str, str]) -> None:
"""Launch a detached tmux session running ``command`` in ``cwd`` with ``env`` set.
``tmux -e`` sets session environment, so the ``claude`` process (and therefore its
hooks) inherit the agent identity + ``DATABASE_URL``.
"""
tmux = get_settings().tmux_bin
argv = [tmux, "new-session", "-d", "-s", name, "-c", cwd]
for key, value in env.items():
argv += ["-e", f"{key}={value}"]
argv.append(command)
subprocess.run(argv, check=True)
def has_session(name: str) -> bool:
tmux = get_settings().tmux_bin
result = subprocess.run(
[tmux, "has-session", "-t", name],
capture_output=True,
)
return result.returncode == 0
def list_sessions() -> list[str]:
tmux = get_settings().tmux_bin
result = subprocess.run(
[tmux, "list-sessions", "-F", "#{session_name}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [line for line in result.stdout.splitlines() if line]
def kill_session(name: str) -> None:
tmux = get_settings().tmux_bin
subprocess.run([tmux, "kill-session", "-t", name], check=True)
def send_keys(name: str, keys: str) -> None:
"""Send a line of input to a live session (used by the resume seam)."""
tmux = get_settings().tmux_bin
subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True)
+55
View File
@@ -0,0 +1,55 @@
"""Per-agent working directory setup — a subdirectory under the project root, or a
git worktree. The isolation invariant: the resulting path is always under the project
root (README 3.4), never reaching into another project's tree.
"""
from __future__ import annotations
import os
import subprocess
class IsolationError(Exception):
"""Raised when a requested working dir would escape the project root."""
def _under(root: str, path: str) -> bool:
root_abs = os.path.realpath(root)
path_abs = os.path.realpath(path)
return path_abs == root_abs or path_abs.startswith(root_abs + os.sep)
def resolve_working_dir(
project_root: str,
agent_name: str,
*,
subdir: str | None = None,
worktree_branch: str | None = None,
) -> str:
"""Return (and, for worktrees, create) the agent's working directory.
- ``subdir``: an existing/created subdirectory under the project root.
- ``worktree_branch``: ``git worktree add <root>/<agent> <branch>``.
- neither: the project root itself.
"""
if subdir and worktree_branch:
raise ValueError("pass at most one of subdir / worktree_branch")
if worktree_branch:
target = os.path.join(project_root, agent_name)
if not _under(project_root, target):
raise IsolationError(f"{target} escapes project root {project_root}")
subprocess.run(
["git", "-C", project_root, "worktree", "add", target, worktree_branch],
check=True,
)
return target
if subdir:
target = os.path.join(project_root, subdir)
if not _under(project_root, target):
raise IsolationError(f"{target} escapes project root {project_root}")
os.makedirs(target, exist_ok=True)
return target
return project_root
+1
View File
@@ -0,0 +1 @@
"""Data-access layer: one schema, two dialects (Postgres + SQLite)."""
+49
View File
@@ -0,0 +1,49 @@
"""Engine construction + connection helper.
The engine is built from ``Settings.database_url``. For SQLite we register a
connect-time listener issuing ``PRAGMA foreign_keys=ON`` — SQLite leaves FK
enforcement off by default, which would make every FK (including the
checkmarks<->log_entries cycle) cosmetic.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from functools import lru_cache
from sqlalchemy import Connection, Engine, create_engine, event
from ..config import get_settings
def _make_engine(url: str) -> Engine:
connect_args: dict = {}
if url.startswith("sqlite"):
# Allow use across threads (FastAPI request threads, test client).
connect_args["check_same_thread"] = False
engine = create_engine(url, connect_args=connect_args, future=True)
if engine.dialect.name == "sqlite":
@event.listens_for(engine, "connect")
def _fk_pragma(dbapi_conn, _record): # noqa: ANN001
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
return engine
@lru_cache
def get_engine() -> Engine:
return _make_engine(get_settings().database_url)
@contextmanager
def connection() -> Iterator[Connection]:
"""A transactional connection (commit on success, rollback on error)."""
engine = get_engine()
with engine.begin() as conn:
yield conn
+206
View File
@@ -0,0 +1,206 @@
"""Data-access layer — every read and every write, one statement per function.
Writer discipline (README 3.2 / 3.3): the backend (control layer + hooks) is the only
thing that writes agent/checkmark/log rows; the API only reads, plus the single
``update_log_answer`` backfill on resume, plus control-plane registration
(``create_project`` / ``create_agent``, which the API mirrors). This is enforced by
import convention — the API package imports only the read functions and
``update_log_answer``; control/hooks import the write functions. A single global token
means we can't enforce it at the DB-permission level, so it is a code-organization
guarantee.
All functions take a live :class:`~sqlalchemy.Connection`; timestamps are set here as
UTC-aware datetimes rather than relying on server defaults, so SQLite and Postgres
agree on the exact value.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import Connection, select
from .tables import agents, checkmarks, log_entries, projects, shared_context
from .upsert import upsert_checkmark
def _now() -> datetime:
return datetime.now(UTC)
def _row_to_dict(row) -> dict[str, Any] | None:
return dict(row._mapping) if row is not None else None
# --------------------------------------------------------------------------- reads
def list_projects(conn: Connection) -> list[dict]:
rows = conn.execute(select(projects).order_by(projects.c.id)).all()
return [dict(r._mapping) for r in rows]
def get_project(conn: Connection, project_id: str) -> dict | None:
row = conn.execute(select(projects).where(projects.c.id == project_id)).first()
return _row_to_dict(row)
def list_agents(conn: Connection, project_id: str) -> list[dict]:
rows = conn.execute(
select(agents).where(agents.c.project_id == project_id).order_by(agents.c.name)
).all()
return [dict(r._mapping) for r in rows]
def get_agent_by_name(conn: Connection, project_id: str, name: str) -> dict | None:
row = conn.execute(
select(agents).where(agents.c.project_id == project_id, agents.c.name == name)
).first()
return _row_to_dict(row)
def get_checkmark(conn: Connection, agent_id: int) -> dict | None:
row = conn.execute(select(checkmarks).where(checkmarks.c.agent_id == agent_id)).first()
return _row_to_dict(row)
def get_log(conn: Connection, agent_id: int, limit: int = 100, offset: int = 0) -> list[dict]:
rows = conn.execute(
select(log_entries)
.where(log_entries.c.agent_id == agent_id)
.order_by(log_entries.c.id.desc())
.limit(limit)
.offset(offset)
).all()
return [dict(r._mapping) for r in rows]
def get_latest_open_question(conn: Connection, agent_id: int) -> dict | None:
"""The most recent log entry that recorded a question and has no answer yet."""
row = conn.execute(
select(log_entries)
.where(
log_entries.c.agent_id == agent_id,
log_entries.c.question.is_not(None),
log_entries.c.answer.is_(None),
)
.order_by(log_entries.c.id.desc())
.limit(1)
).first()
return _row_to_dict(row)
def get_shared_log(conn: Connection, limit: int = 100, offset: int = 0) -> list[dict]:
"""Only entries an agent (or the operator) explicitly marked ``global``."""
rows = conn.execute(
select(log_entries)
.where(log_entries.c.visibility == "global")
.order_by(log_entries.c.id.desc())
.limit(limit)
.offset(offset)
).all()
return [dict(r._mapping) for r in rows]
def get_shared_context(conn: Connection) -> list[dict]:
rows = conn.execute(select(shared_context).order_by(shared_context.c.key)).all()
return [dict(r._mapping) for r in rows]
def get_shared_context_key(conn: Connection, key: str) -> dict | None:
row = conn.execute(select(shared_context).where(shared_context.c.key == key)).first()
return _row_to_dict(row)
# -------------------------------------------------------------------------- writes
def create_project(
conn: Connection,
project_id: str,
root_dir: str,
git_remote: str | None = None,
credential_ref: str | None = None,
) -> dict:
conn.execute(
projects.insert().values(
id=project_id,
root_dir=root_dir,
git_remote=git_remote,
credential_ref=credential_ref,
created_at=_now(),
)
)
return get_project(conn, project_id)
def create_agent(
conn: Connection,
project_id: str,
name: str,
working_dir: str,
status: str = "working",
) -> dict:
result = conn.execute(
agents.insert().values(
project_id=project_id,
name=name,
working_dir=working_dir,
status=status,
created_at=_now(),
)
)
agent_id = result.inserted_primary_key[0]
row = conn.execute(select(agents).where(agents.c.id == agent_id)).first()
return dict(row._mapping)
def set_agent_status(conn: Connection, agent_id: int, status: str) -> None:
conn.execute(agents.update().where(agents.c.id == agent_id).values(status=status))
def insert_log_entry(conn: Connection, agent_id: int, status: str, **fields: Any) -> int:
values = {"agent_id": agent_id, "status": status, "created_at": _now(), **fields}
result = conn.execute(log_entries.insert().values(**values))
return result.inserted_primary_key[0]
def update_log_answer(conn: Connection, log_entry_id: int, answer: str) -> bool:
"""The one post-insert mutation on log_entries — the answer backfill on resume."""
result = conn.execute(
log_entries.update()
.where(log_entries.c.id == log_entry_id)
.values(answer=answer)
)
return result.rowcount > 0
def upsert_checkmark_row(conn: Connection, agent_id: int, **fields: Any) -> None:
"""Overwrite the agent's checkmark (see :func:`db.upsert.upsert_checkmark`)."""
values = {"agent_id": agent_id, **fields}
values.setdefault("checkpoint_at", _now())
upsert_checkmark(conn, values)
def set_shared_context(conn: Connection, key: str, value: str, agent_id: int | None) -> dict:
"""Upsert one shared-context key (the one table every project implicitly trusts)."""
dialect = conn.dialect.name
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert as ins
else:
from sqlalchemy.dialects.sqlite import insert as ins
stmt = ins(shared_context).values(
key=key, value=value, set_by_agent_id=agent_id, updated_at=_now()
)
stmt = stmt.on_conflict_do_update(
index_elements=["key"],
set_={
"value": stmt.excluded.value,
"set_by_agent_id": stmt.excluded.set_by_agent_id,
"updated_at": stmt.excluded.updated_at,
},
)
conn.execute(stmt)
return get_shared_context_key(conn, key)
+115
View File
@@ -0,0 +1,115 @@
"""The schema — one ``MetaData``, six tables, mapping README section 3.1 exactly.
SQLAlchemy Core (not the ORM): the workload is a handful of explicit statements, and
Core keeps the same schema rendering correctly on both dialects with no session
lifecycle to manage across the API, CLI, and hook subprocesses.
"""
from __future__ import annotations
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
ForeignKey,
MetaData,
String,
Table,
UniqueConstraint,
func,
)
from .types import PortableBigInt, PortableJSON, PortableTimestamp
metadata = MetaData()
# Status vocabularies kept as free TEXT (README uses plain strings, not PG enums, so
# both dialects match). CheckConstraints make the allowed sets explicit and portable.
AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done")
GATE_STATUSES = ("pass", "fail", "unknown")
CI_STATUSES = ("not_applicable", "pending", "pass", "fail")
VISIBILITIES = ("project", "global")
def _in(column: str, values: tuple[str, ...]) -> str:
joined = ", ".join(f"'{v}'" for v in values)
return f"{column} IN ({joined})"
projects = Table(
"projects",
metadata,
Column("id", String, primary_key=True), # slug, e.g. "leeworks-api"
Column("root_dir", String, nullable=False),
Column("git_remote", String),
# Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7.
Column("credential_ref", String),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
agents = Table(
"agents",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("project_id", String, ForeignKey("projects.id"), nullable=False),
Column("name", String, nullable=False), # unique within a project, not globally
Column("working_dir", String, nullable=False),
Column("status", String, nullable=False),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("project_id", "name", name="uq_agents_project_name"),
CheckConstraint(_in("status", AGENT_STATUSES), name="ck_agents_status"),
)
log_entries = Table(
"log_entries",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("session_id", String),
Column("status", String, nullable=False),
Column("summary", String),
Column("decisions", String),
Column("question", String),
Column("answer", String), # filled in on resume; only field ever touched post-insert
Column("visibility", String, nullable=False, server_default="project"),
Column("push_sha", String), # set if this checkpoint pushed; null otherwise
Column("ci_status", String, nullable=False, server_default="not_applicable"),
Column("ci_checked_at", PortableTimestamp),
CheckConstraint(_in("visibility", VISIBILITIES), name="ck_log_visibility"),
CheckConstraint(_in("ci_status", CI_STATUSES), name="ck_log_ci_status"),
)
checkmarks = Table(
"checkmarks",
metadata,
# agent_id is PK *and* FK: "the small file that gets overwritten," one row per agent.
Column("agent_id", BigInteger, ForeignKey("agents.id"), primary_key=True),
Column("checkpoint_at", PortableTimestamp, nullable=False),
Column("status", String, nullable=False),
Column("where_it_stopped", String),
Column("next_steps", PortableJSON),
Column("open_question", String),
# use_alter breaks the checkmarks <-> log_entries create-order cycle.
Column(
"log_entry_id",
BigInteger,
ForeignKey("log_entries.id", use_alter=True, name="fk_checkmarks_log_entry"),
),
Column("tests_status", String, nullable=False, server_default="unknown"),
Column("tested_at", PortableTimestamp),
Column("build_status", String, nullable=False, server_default="unknown"),
Column("built_at", PortableTimestamp),
CheckConstraint(_in("status", AGENT_STATUSES), name="ck_checkmarks_status"),
CheckConstraint(_in("tests_status", GATE_STATUSES), name="ck_checkmarks_tests"),
CheckConstraint(_in("build_status", GATE_STATUSES), name="ck_checkmarks_build"),
)
shared_context = Table(
"shared_context",
metadata,
Column("key", String, primary_key=True),
Column("value", String, nullable=False),
Column("set_by_agent_id", BigInteger, ForeignKey("agents.id")),
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
+58
View File
@@ -0,0 +1,58 @@
"""Portable column types so one schema definition emits the right physical type
on both dialects.
- ``PortableJSON`` -> JSONB on Postgres, JSON-as-TEXT on SQLite.
- ``PortableTimestamp`` -> TIMESTAMPTZ on Postgres, ISO-8601 TEXT on SQLite,
always UTC-aware in Python. Naive datetimes are normalized to UTC on bind so
the two dialects agree.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, DateTime, Integer, TypeDecorator
from sqlalchemy.dialects import postgresql
from sqlalchemy.types import JSON
# JSONB on Postgres, JSON (stored as TEXT, round-tripping dict/list) on SQLite.
PortableJSON = JSON().with_variant(postgresql.JSONB(), "postgresql")
# BIGSERIAL/BIGINT on Postgres, INTEGER on SQLite. Only ``INTEGER PRIMARY KEY`` aliases
# SQLite's rowid and autoincrements — a bare ``BIGINT PRIMARY KEY`` would be NULLable and
# would not auto-assign. Use this for autoincrementing PKs.
PortableBigInt = BigInteger().with_variant(Integer(), "sqlite")
class PortableTimestamp(TypeDecorator):
"""A timezone-aware timestamp that behaves identically on PG and SQLite.
SQLAlchemy stores aware datetimes as ISO-8601 text on SQLite and as
``TIMESTAMPTZ`` on Postgres. We normalize every bound value to UTC so a naive
datetime never silently becomes local-time on one backend and UTC on the other.
"""
impl = DateTime(timezone=True)
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(postgresql.TIMESTAMP(timezone=True))
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value, dialect):
if value is None:
return None
if not isinstance(value, datetime):
return value
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, datetime) and value.tzinfo is None:
# SQLite hands back naive datetimes; they are UTC by our convention.
return value.replace(tzinfo=UTC)
return value
+38
View File
@@ -0,0 +1,38 @@
"""The one place dialect branching lives: the checkmark upsert.
``checkmarks`` is a literal upsert keyed by ``agent_id`` — "the small file that gets
overwritten," as a row. We use native ``INSERT ... ON CONFLICT DO UPDATE`` on *both*
dialects (SQLite >= 3.24, from 2018; Python 3.11 bundles far newer). Deliberately not
``INSERT OR REPLACE``: that deletes and reinserts the row, firing FK cascades and
losing row identity.
"""
from __future__ import annotations
from sqlalchemy import Connection
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from .tables import checkmarks
def upsert_checkmark(conn: Connection, values: dict) -> None:
"""Insert or overwrite the checkmark for ``values['agent_id']``.
Every non-PK column present in ``values`` is overwritten on conflict, so a
checkpoint fully replaces the prior small-state record.
"""
if "agent_id" not in values:
raise ValueError("upsert_checkmark requires 'agent_id'")
dialect = conn.dialect.name
if dialect == "postgresql":
stmt = pg_insert(checkmarks).values(**values)
elif dialect == "sqlite":
stmt = sqlite_insert(checkmarks).values(**values)
else: # pragma: no cover - only two backends are supported
raise RuntimeError(f"unsupported dialect for upsert: {dialect}")
update_cols = {k: stmt.excluded[k] for k in values if k != "agent_id"}
stmt = stmt.on_conflict_do_update(index_elements=["agent_id"], set_=update_cols)
conn.execute(stmt)
+7
View File
@@ -0,0 +1,7 @@
"""Claude Code hook entrypoints — the backend's write path.
Invoked as ``python -m handler.hooks <event>`` from the per-agent settings.json. Each
hook reads the event JSON on stdin, resolves its agent identity from the environment
(injected at spawn), writes checkmark/log rows, and returns the event's response
contract on stdout.
"""
+46
View File
@@ -0,0 +1,46 @@
"""Hook dispatch: ``python -m handler.hooks <event>``.
Events: ``stop``, ``session_end``, ``pre_tool_use``, ``notification``. Reads the event
JSON on stdin, resolves the acting agent, dispatches, and exits 0. A resolution failure
or unexpected error exits nonzero with a stderr message but never crashes the agent's
turn in a way that loses data.
"""
from __future__ import annotations
import sys
from ..db.engine import connection
from . import checkpoint, gate, notify
from .context import read_input, resolve_identity
_EVENTS = {"stop", "session_end", "pre_tool_use", "notification"}
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if not argv or argv[0] not in _EVENTS:
print(f"usage: python -m handler.hooks {{{'|'.join(sorted(_EVENTS))}}}", file=sys.stderr)
return 2
event = argv[0]
hook_input = read_input(event)
with connection() as conn:
ident = resolve_identity(conn, hook_input)
if ident is None:
print("handler hook: could not resolve agent identity", file=sys.stderr)
return 1
if event in ("stop", "session_end"):
checkpoint.handle(conn, ident, hook_input)
elif event == "pre_tool_use":
gate.handle(conn, ident, hook_input)
elif event == "notification":
notify.handle(conn, ident, hook_input)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
"""Stop / SessionEnd — the checkpoint + verification gate (README 3.5).
On ``Stop`` the gate runs the project's own ``test`` task and blocks the turn on
failure, so a turn cannot end on a broken suite. The result feeds straight into the
schema: ``status = 'done'`` is only ever recorded alongside a passing test run — not a
claim taken on faith. ``SessionEnd`` cannot be blocked, so it just records a final
checkpoint with the end reason.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Connection
from ..db import repository as repo
from . import verify
from .context import HookInput, Identity, emit
def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
working_dir = ident.working_dir or hook_input.cwd or "."
ok, output = verify.run_test(working_dir)
now = datetime.now(UTC)
status = "done" if ok else "blocked"
tests_status = "pass" if ok else "fail"
summary = "checkpoint: tests passed" if ok else "checkpoint blocked: tests failed"
log_id = repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status=status,
session_id=hook_input.session_id,
summary=summary,
decisions=(output[-4000:] if output else None),
)
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status=status,
where_it_stopped=summary,
log_entry_id=log_id,
tests_status=tests_status,
tested_at=now,
)
repo.set_agent_status(conn, ident.agent_id, status)
if not ok:
# Guard against an infinite block loop: if we already re-invoked once, record
# the failure but let the turn end rather than blocking forever.
if hook_input.stop_hook_active:
return {}
return {
"decision": "block",
"reason": (
"The test gate failed; the turn cannot end on a broken suite. "
f"`mise run test` output:\n{output[-4000:]}"
),
}
return {}
def handle_session_end(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
now = datetime.now(UTC)
reason = hook_input.reason or "session ended"
log_id = repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status="blocked",
session_id=hook_input.session_id,
summary=f"session ended: {reason}",
)
# Record the checkpoint but do not run the gate (the session is already ending).
existing = repo.get_checkmark(conn, ident.agent_id)
status = existing["status"] if existing else "blocked"
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status=status,
where_it_stopped=f"session ended: {reason}",
log_entry_id=log_id,
)
return {}
def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
if hook_input.event == "session_end":
result = handle_session_end(conn, ident, hook_input)
else:
result = handle_stop(conn, ident, hook_input)
emit(result)
return result
+97
View File
@@ -0,0 +1,97 @@
"""Hook input parsing + identity resolution.
Claude Code hook stdin carries the session context (``session_id``, ``cwd``,
``hook_event_name``, per-event extras) but *not* our agent identity — that arrives via
the environment injected at spawn (``HANDLER_AGENT_ID`` etc.). A ``cwd``->working_dir
fallback resolves the agent if the env is somehow missing.
"""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import Connection, select
from ..db.tables import agents
@dataclass
class HookInput:
raw: dict[str, Any]
event: str
@property
def session_id(self) -> str | None:
return self.raw.get("session_id")
@property
def cwd(self) -> str | None:
return self.raw.get("cwd")
@property
def tool_name(self) -> str | None:
return self.raw.get("tool_name")
@property
def tool_input(self) -> dict[str, Any]:
return self.raw.get("tool_input") or {}
@property
def message(self) -> str | None:
return self.raw.get("message")
@property
def stop_hook_active(self) -> bool:
return bool(self.raw.get("stop_hook_active"))
@property
def reason(self) -> str | None:
return self.raw.get("reason")
@dataclass
class Identity:
agent_id: int
project_id: str
agent_name: str
working_dir: str | None = None
extra: dict = field(default_factory=dict)
def read_input(event: str) -> HookInput:
data = sys.stdin.read()
parsed = json.loads(data) if data.strip() else {}
return HookInput(raw=parsed, event=event)
def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None:
"""Resolve the acting agent from env, falling back to cwd->working_dir lookup."""
agent_id = os.environ.get("HANDLER_AGENT_ID")
project_id = os.environ.get("HANDLER_PROJECT_ID")
agent_name = os.environ.get("HANDLER_AGENT_NAME")
if agent_id and project_id and agent_name:
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
working_dir = row._mapping["working_dir"] if row else None
return Identity(int(agent_id), project_id, agent_name, working_dir)
# Fallback: match by working_dir == cwd.
if hook_input.cwd:
row = conn.execute(
select(agents).where(agents.c.working_dir == hook_input.cwd)
).first()
if row is not None:
m = row._mapping
return Identity(m["id"], m["project_id"], m["name"], m["working_dir"])
return None
def emit(payload: dict) -> None:
"""Write a JSON hook response to stdout."""
json.dump(payload, sys.stdout)
sys.stdout.write("\n")
+135
View File
@@ -0,0 +1,135 @@
"""PreToolUse — defer AskUserQuestion, and gate ``git push`` (README 3.6).
Claude Code's PreToolUse matcher matches on ``tool_name`` only, so this hook is wired
for ``AskUserQuestion|Bash`` and inspects the command itself to decide what to do:
- ``AskUserQuestion``: there is no human at the tmux TTY, so the question is *deferred*
— persisted to the log + checkmark and the tool call denied, handing control to the
async answer/resume flow.
- ``Bash`` running ``git push``: run the verification chain (tests first, then the
throwaway image build) and deny the push on the first failure, so a push already
known to fail CI doesn't leave.
"""
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from sqlalchemy import Connection
from ..db import repository as repo
from . import verify
from .context import HookInput, Identity, emit
_GIT_PUSH = re.compile(r"\bgit\s+push\b")
def _deny(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
def _allow(reason: str = "") -> dict:
out: dict = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
}
}
return out
def _question_text(tool_input: dict) -> str:
"""Flatten an AskUserQuestion payload into a human-readable question string."""
questions = tool_input.get("questions")
if isinstance(questions, list) and questions:
parts = []
for q in questions:
if isinstance(q, dict) and q.get("question"):
parts.append(str(q["question"]))
if parts:
return "\n".join(parts)
# Fall back to the whole payload so nothing is lost.
return json.dumps(tool_input)
def handle_ask_user_question(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
question = _question_text(hook_input.tool_input)
now = datetime.now(UTC)
log_id = repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status="paused_for_input",
session_id=hook_input.session_id,
summary="agent asked the operator a question",
question=question,
)
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status="paused_for_input",
open_question=question,
log_entry_id=log_id,
)
repo.set_agent_status(conn, ident.agent_id, "paused_for_input")
return _deny(
"Question deferred to the operator; answer it via the API "
"(POST .../answer then POST .../resume)."
)
def handle_git_push(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
working_dir = ident.working_dir or hook_input.cwd or "."
now = datetime.now(UTC)
# Cheap check first: tests. Only on success do we pay for the image build.
tests_ok, tests_out = verify.run_test(working_dir)
if not tests_ok:
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status="blocked",
tests_status="fail",
tested_at=now,
)
return _deny(f"Push blocked: tests failed.\n{tests_out[-3000:]}")
build_ok, build_out = verify.run_build(working_dir)
repo.upsert_checkmark_row(
conn,
agent_id=ident.agent_id,
checkpoint_at=now,
status="working",
tests_status="pass",
tested_at=now,
build_status="pass" if build_ok else "fail",
built_at=now,
)
if not build_ok:
return _deny(f"Push blocked: image build failed.\n{build_out[-3000:]}")
return _allow("tests and image build passed")
def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
tool = hook_input.tool_name
if tool == "AskUserQuestion":
result = handle_ask_user_question(conn, ident, hook_input)
elif tool == "Bash" and _GIT_PUSH.search(hook_input.tool_input.get("command", "")):
result = handle_git_push(conn, ident, hook_input)
else:
# Not our concern — stay out of the way, let normal permission flow proceed.
result = {}
if result:
emit(result)
return result
+46
View File
@@ -0,0 +1,46 @@
"""Notification -> generic webhook (README 3.2).
Fully bring-your-own: if ``WEBHOOK_URL`` is unset the hook is a no-op. The webhook is
never allowed to block the agent — failures are swallowed. A log row is written either
way so the "big log" stays complete.
"""
from __future__ import annotations
from datetime import UTC, datetime
import httpx
from sqlalchemy import Connection
from ..config import get_settings
from ..db import repository as repo
from .context import HookInput, Identity
def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
message = hook_input.message or ""
repo.insert_log_entry(
conn,
agent_id=ident.agent_id,
status="working",
session_id=hook_input.session_id,
summary=f"notification: {message}"[:2000],
)
url = get_settings().webhook_url
if not url:
return {}
payload = {
"project": ident.project_id,
"agent": ident.agent_name,
"message": message,
"session_id": hook_input.session_id,
"ts": datetime.now(UTC).isoformat(),
}
try:
httpx.post(url, json=payload, timeout=5.0)
except httpx.HTTPError:
# Bring-your-own target; never block the agent on delivery failure.
pass
return {}
+44
View File
@@ -0,0 +1,44 @@
"""The verification helpers — the mock seam for the gates.
``run_test`` / ``run_build`` shell ``mise run <task>`` in the agent's working dir and
report ``(ok, output)``. Hook decision logic is tested by faking these two functions,
so no live ``mise``/``kaniko`` is needed. A missing ``mise`` is treated as a failure
with a clear reason the test task is a hard requirement, so a silent skip would
defeat the gate.
"""
from __future__ import annotations
import subprocess
from ..config import get_settings
_TIMEOUT = 1800 # seconds; long enough for a real suite/build, bounded so a hang fails.
def _run_mise_task(task: str, cwd: str) -> tuple[bool, str]:
mise = get_settings().mise_bin
try:
result = subprocess.run(
[mise, "run", task],
cwd=cwd,
capture_output=True,
text=True,
timeout=_TIMEOUT,
)
except FileNotFoundError:
return False, f"'{mise}' not found: cannot run the '{task}' gate"
except subprocess.TimeoutExpired:
return False, f"'{task}' timed out after {_TIMEOUT}s"
output = (result.stdout or "") + (result.stderr or "")
return result.returncode == 0, output.strip()
def run_test(cwd: str) -> tuple[bool, str]:
return _run_mise_task("test", cwd)
def run_build(cwd: str) -> tuple[bool, str]:
"""Throwaway image build (kaniko/buildah, no registry) via ``mise run build-image``."""
return _run_mise_task("build-image", cwd)
+70
View File
@@ -0,0 +1,70 @@
"""Alembic environment — one config for both dialects.
The URL comes from ``handler.config.Settings`` (env ``DATABASE_URL``), and
``target_metadata`` is the single schema in ``handler.db.tables``. Because the
columns use ``with_variant`` / dialect-aware types, the same migration script emits
correct DDL for both Postgres and SQLite. ``render_as_batch`` is enabled for SQLite so
any future ``ALTER`` migration works (SQLite can't ALTER most things; batch mode does a
table-copy).
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from handler.config import get_settings
from handler.db.tables import metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = metadata
def _is_sqlite(url: str) -> bool:
return url.startswith("sqlite")
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=_is_sqlite(url or ""),
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
if is_sqlite:
connection.exec_driver_sql("PRAGMA foreign_keys=ON")
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,110 @@
"""initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-07-07
Hand-written (not autogenerated) so the create order and FK handling are explicit and
render correctly on both Postgres and SQLite. Tables are created in dependency order
projects -> agents -> log_entries -> checkmarks -> shared_context so no forward FK
reference needs deferring.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done'"
GATE_STATUSES = "'pass', 'fail', 'unknown'"
CI_STATUSES = "'not_applicable', 'pending', 'pass', 'fail'"
VISIBILITIES = "'project', 'global'"
def upgrade() -> None:
op.create_table(
"projects",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("root_dir", sa.String(), nullable=False),
sa.Column("git_remote", sa.String()),
sa.Column("credential_ref", sa.String()),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
op.create_table(
"agents",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("project_id", sa.String(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("working_dir", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("project_id", "name", name="uq_agents_project_name"),
sa.CheckConstraint(f"status IN ({AGENT_STATUSES})", name="ck_agents_status"),
)
op.create_table(
"log_entries",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.Column("session_id", sa.String()),
sa.Column("status", sa.String(), nullable=False),
sa.Column("summary", sa.String()),
sa.Column("decisions", sa.String()),
sa.Column("question", sa.String()),
sa.Column("answer", sa.String()),
sa.Column("visibility", sa.String(), nullable=False, server_default="project"),
sa.Column("push_sha", sa.String()),
sa.Column("ci_status", sa.String(), nullable=False, server_default="not_applicable"),
sa.Column("ci_checked_at", PortableTimestamp),
sa.CheckConstraint(f"visibility IN ({VISIBILITIES})", name="ck_log_visibility"),
sa.CheckConstraint(f"ci_status IN ({CI_STATUSES})", name="ck_log_ci_status"),
)
op.create_table(
"checkmarks",
sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), primary_key=True),
sa.Column("checkpoint_at", PortableTimestamp, nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("where_it_stopped", sa.String()),
sa.Column("next_steps", PortableJSON),
sa.Column("open_question", sa.String()),
sa.Column(
"log_entry_id",
sa.BigInteger(),
sa.ForeignKey("log_entries.id", name="fk_checkmarks_log_entry"),
),
sa.Column("tests_status", sa.String(), nullable=False, server_default="unknown"),
sa.Column("tested_at", PortableTimestamp),
sa.Column("build_status", sa.String(), nullable=False, server_default="unknown"),
sa.Column("built_at", PortableTimestamp),
sa.CheckConstraint(f"status IN ({AGENT_STATUSES})", name="ck_checkmarks_status"),
sa.CheckConstraint(f"tests_status IN ({GATE_STATUSES})", name="ck_checkmarks_tests"),
sa.CheckConstraint(f"build_status IN ({GATE_STATUSES})", name="ck_checkmarks_build"),
)
op.create_table(
"shared_context",
sa.Column("key", sa.String(), primary_key=True),
sa.Column("value", sa.String(), nullable=False),
sa.Column("set_by_agent_id", sa.BigInteger(), sa.ForeignKey("agents.id")),
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("shared_context")
op.drop_table("checkmarks")
op.drop_table("log_entries")
op.drop_table("agents")
op.drop_table("projects")
+107
View File
@@ -0,0 +1,107 @@
"""Shared fixtures. Everything runs on a fresh SQLite file per test, materialized via
a *real* ``alembic upgrade head`` so the migration path itself is under test, not
just ``create_all``. No live claude/tmux/mise is ever touched: the three seams
(``control.tmux``, ``hooks.verify``, ``control.spawn.resume``) are faked.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
REPO_ROOT = Path(__file__).resolve().parents[1]
def _reset_caches() -> None:
from handler import config
from handler.db import engine
config.get_settings.cache_clear()
engine.get_engine.cache_clear()
@pytest.fixture
def env(tmp_path, monkeypatch):
"""Point every entrypoint at a fresh SQLite db + a known token, migrated."""
db_path = tmp_path / "handler.db"
url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", url)
monkeypatch.setenv("AUTH_TOKEN", "test-token")
monkeypatch.setenv("SHARED_CONTEXT_WRITE_TOKEN", "shared-token")
monkeypatch.setenv("PROJECTS_ROOT", str(tmp_path / "projects"))
monkeypatch.delenv("WEBHOOK_URL", raising=False)
_reset_caches()
cfg = Config(str(REPO_ROOT / "alembic.ini"))
cfg.set_main_option("script_location", str(REPO_ROOT / "src" / "handler" / "migrations"))
command.upgrade(cfg, "head")
yield {"url": url, "token": "test-token", "shared_token": "shared-token", "tmp": tmp_path}
_reset_caches()
@pytest.fixture
def engine(env):
from handler.db.engine import get_engine
return get_engine()
@pytest.fixture
def conn(engine):
with engine.begin() as c:
yield c
@pytest.fixture
def client(env):
from fastapi.testclient import TestClient
from handler.api.app import create_app
return TestClient(create_app())
@pytest.fixture
def auth(env):
return {"Authorization": f"Bearer {env['token']}"}
@pytest.fixture
def fake_tmux(monkeypatch):
"""Record tmux calls instead of spawning; report sessions as live by default."""
calls: dict[str, list] = {"new_session": [], "kill_session": [], "send_keys": []}
live: set[str] = set()
from handler.control import tmux
def new_session(name, cwd, command, env):
calls["new_session"].append(
{"name": name, "cwd": cwd, "command": command, "env": env}
)
live.add(name)
def has_session(name):
return name in live
def kill_session(name):
calls["kill_session"].append(name)
live.discard(name)
def send_keys(name, keys):
calls["send_keys"].append({"name": name, "keys": keys})
def list_sessions():
return list(live)
monkeypatch.setattr(tmux, "new_session", new_session)
monkeypatch.setattr(tmux, "has_session", has_session)
monkeypatch.setattr(tmux, "kill_session", kill_session)
monkeypatch.setattr(tmux, "send_keys", send_keys)
monkeypatch.setattr(tmux, "list_sessions", list_sessions)
return {"calls": calls, "live": live}
+20
View File
@@ -0,0 +1,20 @@
"""Bearer auth on every route."""
from __future__ import annotations
def test_missing_token_is_401(client):
assert client.get("/projects").status_code == 401
def test_wrong_token_is_401(client):
r = client.get("/projects", headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
def test_valid_token_is_200(client, auth):
assert client.get("/projects", headers=auth).status_code == 200
def test_health_needs_no_auth(client):
assert client.get("/health").status_code == 200
+76
View File
@@ -0,0 +1,76 @@
"""Answer + resume routes, including the mocked control seam."""
from __future__ import annotations
from handler.control import spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
def _seed_agent_with_question(env):
"""Seed a project + agent + an open question directly in the DB."""
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api", status="paused_for_input")
log_id = repo.insert_log_entry(
conn, a["id"], status="paused_for_input", question="Which DB?"
)
return a, log_id
def test_answer_backfills_latest_open_question(client, auth, env):
_seed_agent_with_question(env)
r = client.post(
"/projects/proj/agents/api/answer",
json={"answer": "Postgres"},
headers=auth,
)
assert r.status_code == 200
assert r.json()["answered"] is True
with get_engine().begin() as conn:
a = repo.get_agent_by_name(conn, "proj", "api")
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
def test_answer_with_no_open_question_is_404(client, auth, env):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
r = client.post(
"/projects/proj/agents/api/answer", json={"answer": "x"}, headers=auth
)
assert r.status_code == 404
def test_resume_calls_control_seam(client, auth, env, monkeypatch):
_seed_agent_with_question(env)
client.post(
"/projects/proj/agents/api/answer", json={"answer": "Postgres"}, headers=auth
)
calls = []
def fake_resume(agent, answer):
calls.append((agent["name"], answer))
return True, "delivered"
monkeypatch.setattr(spawn, "resume", fake_resume)
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
assert r.status_code == 200
assert r.json()["resumed"] is True
assert calls == [("api", "Postgres")]
with get_engine().begin() as conn:
a = repo.get_agent_by_name(conn, "proj", "api")
assert a["status"] == "working"
def test_resume_without_answer_is_400(client, auth, env, monkeypatch):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
monkeypatch.setattr(spawn, "resume", lambda a, ans: (True, "x"))
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
assert r.status_code == 400
+64
View File
@@ -0,0 +1,64 @@
"""Project + agent routes, and project isolation (README 3.4)."""
from __future__ import annotations
def _mk_project(client, auth, pid="proj", root="/tmp/proj"):
return client.post("/projects", json={"id": pid, "root_dir": root}, headers=auth)
def test_create_and_list_project(client, auth):
r = _mk_project(client, auth)
assert r.status_code == 201
assert r.json()["id"] == "proj"
listing = client.get("/projects", headers=auth).json()
assert [p["id"] for p in listing] == ["proj"]
def test_duplicate_project_conflicts(client, auth):
_mk_project(client, auth)
assert _mk_project(client, auth).status_code == 409
def test_create_and_list_agent(client, auth):
_mk_project(client, auth)
r = client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api"},
headers=auth,
)
assert r.status_code == 201
agents = client.get("/projects/proj/agents", headers=auth).json()
assert [a["name"] for a in agents] == ["api"]
def test_agent_under_missing_project_is_404(client, auth):
r = client.get("/projects/ghost/agents", headers=auth)
assert r.status_code == 404
def test_project_isolation_same_agent_name(client, auth):
# Two projects can each have an agent named "api"; neither leaks into the other.
_mk_project(client, auth, "a", "/tmp/a")
_mk_project(client, auth, "b", "/tmp/b")
client.post(
"/projects/a/agents",
json={"name": "api", "working_dir": "/tmp/a/api"},
headers=auth,
)
a_agents = client.get("/projects/a/agents", headers=auth).json()
b_agents = client.get("/projects/b/agents", headers=auth).json()
assert [x["name"] for x in a_agents] == ["api"]
assert b_agents == []
# The agent is invisible under project b.
assert client.get("/projects/b/agents/api/checkmark", headers=auth).status_code == 404
def test_checkmark_404_before_any_checkpoint(client, auth):
_mk_project(client, auth)
client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api"},
headers=auth,
)
assert client.get("/projects/proj/agents/api/checkmark", headers=auth).status_code == 404
+40
View File
@@ -0,0 +1,40 @@
"""Shared-context + shared-log endpoints and the write-token gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.db.engine import get_engine
def test_put_shared_context_requires_write_token(client, auth, env):
# The normal token is not enough to write shared context.
r = client.put("/shared/context/schema_version", json={"value": "v3"}, headers=auth)
assert r.status_code == 403
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
r = client.put(
"/shared/context/schema_version", json={"value": "v3"}, headers=write_headers
)
assert r.status_code == 200
assert r.json()["value"] == "v3"
def test_read_shared_context_uses_normal_token(client, auth, env):
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
client.put("/shared/context/k", json={"value": "v"}, headers=write_headers)
assert client.get("/shared/context", headers=auth).status_code == 200
assert client.get("/shared/context/k", headers=auth).json()["value"] == "v"
assert client.get("/shared/context/missing", headers=auth).status_code == 404
def test_shared_log_returns_only_global(client, auth, env):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
repo.insert_log_entry(
conn, a["id"], status="working", summary="global-note", visibility="global"
)
entries = client.get("/shared/log", headers=auth).json()
assert [e["summary"] for e in entries] == ["global-note"]
+91
View File
@@ -0,0 +1,91 @@
"""Control-layer spawn: the hard test-task gate, settings generation, identity env."""
from __future__ import annotations
import json
import pytest
from handler.control import spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
def _register_project(root):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
def _write_mise(root, with_test=True):
root.mkdir(parents=True, exist_ok=True)
body = "[tasks.lint]\nrun = 'ruff check .'\n"
if with_test:
body = "[tasks.test]\nrun = 'pytest'\n" + body
(root / ".mise.toml").write_text(body)
def test_spawn_refuses_without_test_task(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=False)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
spawn.spawn("proj", "api")
assert fake_tmux["calls"]["new_session"] == []
def test_spawn_refuses_without_mise_file(env, fake_tmux):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no .mise.toml"):
spawn.spawn("proj", "api")
def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
agent = spawn.spawn("proj", "api", task="build the thing")
# Agent row created.
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
# settings.json wires all four hook events.
settings = json.loads((root / ".claude" / "settings.json").read_text())
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
pre = settings["hooks"]["PreToolUse"][0]
assert pre["matcher"] == "AskUserQuestion|Bash"
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
# tmux session named project__agent, with identity + DATABASE_URL in env.
call = fake_tmux["calls"]["new_session"][0]
assert call["name"] == "proj__api"
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
assert call["env"]["DATABASE_URL"] == env["url"]
def test_kill_sets_done_and_kills_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
spawn.spawn("proj", "api")
spawn.kill("proj", "api")
assert "proj__api" in fake_tmux["calls"]["kill_session"]
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
def test_resume_sends_answer_to_live_session(env, fake_tmux):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
agent = spawn.spawn("proj", "api")
ok, detail = spawn.resume(agent, "use Postgres")
assert ok is True
assert fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"}
+39
View File
@@ -0,0 +1,39 @@
"""Portable types round-trip correctly on SQLite (aware datetimes, JSON lists)."""
from __future__ import annotations
from datetime import UTC, datetime
from handler.db import repository as repo
def _seed_agent(conn):
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_timestamp_roundtrips_as_utc_aware(conn):
agent = _seed_agent(conn)
ts = datetime(2026, 7, 7, 12, 30, tzinfo=UTC)
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=ts, status="working")
row = repo.get_checkmark(conn, agent["id"])
assert row["checkpoint_at"] == ts
assert row["checkpoint_at"].tzinfo is not None
def test_naive_timestamp_is_normalized_to_utc(conn):
agent = _seed_agent(conn)
naive = datetime(2026, 7, 7, 12, 30) # no tzinfo
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=naive, status="working")
row = repo.get_checkmark(conn, agent["id"])
assert row["checkpoint_at"] == naive.replace(tzinfo=UTC)
def test_json_list_roundtrips(conn):
agent = _seed_agent(conn)
steps = ["write tests", "wire the poller", "document the token flow"]
repo.upsert_checkmark_row(
conn, agent["id"], status="working", next_steps=steps
)
row = repo.get_checkmark(conn, agent["id"])
assert row["next_steps"] == steps
+52
View File
@@ -0,0 +1,52 @@
"""The highest-value DB test: the checkmark upsert overwrites in place (ON CONFLICT DO
UPDATE), keeping a single row with preserved identity never delete+reinsert.
"""
from __future__ import annotations
from sqlalchemy import func, select
from handler.db import repository as repo
from handler.db.tables import checkmarks
def _seed_agent(conn):
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_upsert_overwrites_single_row(conn):
agent = _seed_agent(conn)
repo.upsert_checkmark_row(
conn,
agent["id"],
status="working",
where_it_stopped="first stop",
tests_status="unknown",
)
repo.upsert_checkmark_row(
conn,
agent["id"],
status="done",
where_it_stopped="second stop",
tests_status="pass",
)
count = conn.execute(select(func.count()).select_from(checkmarks)).scalar_one()
assert count == 1
row = repo.get_checkmark(conn, agent["id"])
assert row["status"] == "done"
assert row["where_it_stopped"] == "second stop"
assert row["tests_status"] == "pass"
assert row["agent_id"] == agent["id"]
def test_upsert_only_touches_supplied_columns_via_defaults(conn):
agent = _seed_agent(conn)
repo.upsert_checkmark_row(conn, agent["id"], status="working")
row = repo.get_checkmark(conn, agent["id"])
# Unsupplied gate columns fall back to their schema defaults.
assert row["tests_status"] == "unknown"
assert row["build_status"] == "unknown"
+65
View File
@@ -0,0 +1,65 @@
"""Stop / SessionEnd checkpoint gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def test_stop_blocks_on_failing_tests(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "test gate failed" in result["reason"]
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "fail"
assert cm["status"] == "blocked"
# A blocked turn never records "done".
assert repo.get_agent_by_name(conn, "p", "a")["status"] == "blocked"
def test_stop_allows_done_on_passing_tests(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result == {} # no block
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "pass"
assert cm["status"] == "done"
assert cm["log_entry_id"] is not None
def test_stop_does_not_reblock_when_already_active(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "still failing"))
hi = HookInput({"session_id": "s1", "stop_hook_active": True}, "stop")
result = checkpoint.handle_stop(conn, ident, hi)
assert result == {} # recorded, but not an infinite block
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_session_end_records_without_gate(conn, monkeypatch):
ident = _seed(conn)
# Even if tests would fail, SessionEnd must not run the gate or block.
monkeypatch.setattr(
verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("gate ran"))
)
result = checkpoint.handle_session_end(
conn, ident, HookInput({"reason": "clear"}, "session_end")
)
assert result == {}
assert repo.get_checkmark(conn, ident.agent_id)["where_it_stopped"].startswith(
"session ended"
)
+41
View File
@@ -0,0 +1,41 @@
"""The `python -m handler.hooks <event>` dispatch: stdin parsing + identity from env."""
from __future__ import annotations
import io
from handler.db import repository as repo
from handler.db.engine import get_engine
from handler.hooks import __main__ as hook_main
from handler.hooks import verify
def _seed(env):
with get_engine().begin() as conn:
repo.create_project(conn, "p", "/tmp/p")
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
def test_dispatch_stop_via_stdin_and_env(env, monkeypatch, capsys):
agent = _seed(env)
monkeypatch.setenv("HANDLER_AGENT_ID", str(agent["id"]))
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
monkeypatch.setenv("HANDLER_AGENT_NAME", "a")
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr("sys.stdin", io.StringIO('{"session_id": "s1"}'))
rc = hook_main.main(["stop"])
assert rc == 0
with get_engine().begin() as conn:
assert repo.get_checkmark(conn, agent["id"])["tests_status"] == "pass"
def test_dispatch_unknown_event_is_usage_error(env):
assert hook_main.main(["frobnicate"]) == 2
def test_dispatch_unresolvable_identity_returns_1(env, monkeypatch):
monkeypatch.delenv("HANDLER_AGENT_ID", raising=False)
monkeypatch.setattr("sys.stdin", io.StringIO('{"cwd": "/nowhere"}'))
assert hook_main.main(["stop"]) == 1
+79
View File
@@ -0,0 +1,79 @@
"""PreToolUse: AskUserQuestion defer + git-push gate."""
from __future__ import annotations
from handler.db import repository as repo
from handler.hooks import gate, verify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def _decision(result):
return result["hookSpecificOutput"]["permissionDecision"]
def test_ask_user_question_is_deferred(conn):
ident = _seed(conn)
hi = HookInput(
{
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{"question": "Which DB?"}]},
"session_id": "s1",
},
"pre_tool_use",
)
result = gate.handle_ask_user_question(conn, ident, hi)
assert _decision(result) == "deny"
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["status"] == "paused_for_input"
assert cm["open_question"] == "Which DB?"
assert repo.get_latest_open_question(conn, ident.agent_id)["question"] == "Which DB?"
def test_git_push_denied_when_tests_fail(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
# Build must not even run when tests fail (cheap check first).
monkeypatch.setattr(
verify, "run_build", lambda cwd: (_ for _ in ()).throw(AssertionError("built"))
)
hi = HookInput(
{"tool_name": "Bash", "tool_input": {"command": "git push origin main"}},
"pre_tool_use",
)
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "deny"
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_git_push_denied_when_build_fails(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr(verify, "run_build", lambda cwd: (False, "COPY failed"))
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "deny"
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "pass"
assert cm["build_status"] == "fail"
def test_git_push_allowed_when_both_pass(conn, monkeypatch):
ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
monkeypatch.setattr(verify, "run_build", lambda cwd: (True, "built"))
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
result = gate.handle_git_push(conn, ident, hi)
assert _decision(result) == "allow"
def test_non_push_bash_is_ignored(conn):
ident = _seed(conn)
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
assert gate.handle(conn, ident, hi) == {}
+46
View File
@@ -0,0 +1,46 @@
"""Notification hook: webhook POST only when WEBHOOK_URL is set; log always written."""
from __future__ import annotations
import httpx
import respx
from handler.db import repository as repo
from handler.hooks import notify
from handler.hooks.context import HookInput, Identity
def _seed(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
return Identity(a["id"], "p", "a", "/tmp/p/a")
def test_notify_noop_without_webhook(conn, env):
ident = _seed(conn)
hi = HookInput({"message": "needs input", "session_id": "s1"}, "notification")
# WEBHOOK_URL is unset in the env fixture -> no HTTP call, but the log is recorded.
notify.handle(conn, ident, hi)
assert "notification: needs input" in repo.get_log(conn, ident.agent_id)[0]["summary"]
@respx.mock
def test_notify_posts_when_webhook_set(conn, env, monkeypatch):
monkeypatch.setenv("WEBHOOK_URL", "https://ntfy.example/topic")
from handler import config
config.get_settings.cache_clear()
route = respx.post("https://ntfy.example/topic").mock(return_value=httpx.Response(200))
ident = _seed(conn)
hi = HookInput({"message": "hello", "session_id": "s1"}, "notification")
notify.handle(conn, ident, hi)
assert route.called
sent = route.calls[0].request
import json
body = json.loads(sent.content)
assert body["project"] == "p"
assert body["agent"] == "a"
assert body["message"] == "hello"
+54
View File
@@ -0,0 +1,54 @@
"""DAL read/write functions and the answer backfill."""
from __future__ import annotations
from handler.db import repository as repo
def test_project_and_agent_crud(conn):
repo.create_project(conn, "proj", "/tmp/proj", git_remote="git@x:proj.git")
assert repo.get_project(conn, "proj")["root_dir"] == "/tmp/proj"
assert [p["id"] for p in repo.list_projects(conn)] == ["proj"]
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
assert a["status"] == "working"
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == a["id"]
assert repo.get_agent_by_name(conn, "proj", "missing") is None
def test_log_insert_and_answer_backfill(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
log_id = repo.insert_log_entry(
conn, a["id"], status="paused_for_input", question="Which DB?"
)
open_q = repo.get_latest_open_question(conn, a["id"])
assert open_q["id"] == log_id
assert repo.update_log_answer(conn, log_id, "Postgres") is True
# Once answered, it is no longer an open question.
assert repo.get_latest_open_question(conn, a["id"]) is None
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
def test_shared_context_upsert(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.set_shared_context(conn, "staging_url", "https://a", a["id"])
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://a"
repo.set_shared_context(conn, "staging_url", "https://b", a["id"])
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://b"
assert len(repo.get_shared_context(conn)) == 1
def test_shared_log_only_global(conn):
repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
repo.insert_log_entry(
conn, a["id"], status="working", summary="shared", visibility="global"
)
shared = repo.get_shared_log(conn)
assert [e["summary"] for e in shared] == ["shared"]