- TypeScript 98%
- CSS 0.8%
- Just 0.6%
- Shell 0.3%
- Dockerfile 0.1%
|
All checks were successful
Each agent is isolated in its own Docker container with a FIFO queue, so git worktrees provided no concurrency benefit. The %2F encoding in worktree paths was also causing Chromium to URL-decode path separators in vitest browser-mode /@fs/... URLs, breaking session resolution. New layout: <cacheRoot>/checkout/<agent>/<owner>/<name> — clone once, git checkout -f <branch> per task. acquireCheckout replaces the old ensureCacheClone + fetchLatest + acquireWorktree pipeline. Container side uses inContainerCheckoutPath (no branch encoding needed). All tests updated; sweeper worktree-pruning phase removed; cleanup no longer invokes releaseWorktree. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| .cursor/skills/forgejo-deps-backfill | ||
| .forgejo/workflows | ||
| .husky | ||
| apps | ||
| design | ||
| docs | ||
| flows/defaults | ||
| ops/audit | ||
| packages/shared | ||
| scripts | ||
| skills | ||
| specs | ||
| vendor/penpot-mcp-server | ||
| .cursorignore | ||
| .dockerignore | ||
| .gitignore | ||
| biome.json | ||
| bun.lock | ||
| claude-hooks.service | ||
| CLAUDE.md | ||
| Dockerfile | ||
| justfile | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
| tsconfig.json | ||
| turbo.json | ||
claude-hooks
HTTP service that ingests Forgejo webhooks, runs flow-graph dispatch, and
spawns Claude Agent SDK workers in per-agent Docker containers. Runs on
the desktop (192.168.1.164:4500) under systemctl --user.
Workspace layout
Bun + Turbo monorepo — one install at the root links every workspace:
apps/
server/ # Bun HTTP service (the runtime)
web/ # Vite + React 19 dashboard (t3code stack)
packages/
shared/ # Cross-app TypeScript types (TaskRecord, ResolvedAgent, SSE envelopes)
Both apps depend on @claude-hooks/shared via workspace:*. The server
boots on port 4500 and serves the React SPA on /app/*. Requests to /
and /dashboard receive 302 → /app/ so the SPA boots and runs its
entry beforeLoad hook — it probes /onboarding/should-redirect for a
fresh fleet, then redirects to /$locale/planner/board with the active
locale from settings (default en).
Dashboard
The React 19 + TanStack + Tailwind 4 dashboard is the sole UI. It
carries a hardened SSE reliability path (heartbeat + debounced
"disconnected" banner + /health probe).
Production (systemd service running on 192.168.1.164:4500):
- Dashboard: http://192.168.1.164:4500/ (302 →
/app/, then the SPA routes to the board)
If /app/* returns a 503 with a "run cd apps/web && bun run build"
hint, the SPA bundle hasn't been built yet — the server points at
apps/web/dist/ and that directory is empty on a clean checkout.
just dev builds as a side effect; just start expects the dist
bundle to already exist.
Dev loop (Vite on port 5173, proxies REST/SSE to 4500):
just dev # turbo runs server + Vite together
# then open http://127.0.0.1:5173/app/planner/board
SPA pages
All routes live under the locale prefix (/app/<locale>/…; en is the
default). The /$locale/ landing redirects to /$locale/planner/board.
| Path | Description |
|---|---|
/app/planner/board |
Assignment board — default landing. Drag-to-assign Kanban with one column per agent type + an Unassigned gutter. Live via SSE. |
/app/planner |
Architect chat — sessions list + streaming turns from /architect/stream/:task_id. Slash-command palette (/spec, /breakdown, /assign) and @file / @specs/foo.md autocomplete. |
/app/workspace |
Unified workspace — combined specs editor + architect chat surface. URL params: ?session=<id>, ?spec=<name>, ?repo=<owner/name>. |
/app/specs |
Spec browser — list and open specs/*.md from the watched repos. |
/app/flows |
Flow-graph editor — view, create, and edit dispatch flow definitions. |
/app/agents |
Agent CRUD — create / edit / delete instances with volume-wipe confirm. |
/app/issue/:owner/:repo/:issueNumber |
Issue detail — per-issue pipeline stages, task history, and event log. |
/app/settings |
Settings hub — entry point for all configuration surfaces. |
/app/settings/service |
Service config (forge URL, container image, pipeline thresholds, auth). |
/app/settings/service/watchdogs |
Per-stage stall thresholds. |
/app/settings/service/ai-providers |
AI provider configuration and failover. |
/app/settings/service/forge |
Per-forge OAuth credentials. |
/app/settings/service/container |
Container image and runtime settings. |
/app/settings/service/design |
Penpot / design-reviewer settings. |
/app/settings/agents |
Agent-type config and per-instance overrides. |
/app/settings/agents/per-agent-secrets |
Per-agent secret injection. |
/app/settings/agents/config-history |
Agent config change history. |
/app/settings/agent-types |
Agent-type defaults (models, skills, plugins). |
/app/settings/agent-config |
Global agent configuration. |
/app/settings/repos |
Watched repos — add/remove, rotate webhook secrets. |
/app/settings/labels |
Label catalog management. |
/app/settings/secrets |
Encrypted secret store. |
/app/settings/language |
UI language selection (Paraglide i18n). |
/app/settings/appearance |
Theme / colour-scheme preferences. |
/app/settings/voice-input |
Voice input configuration (requires local speaches service). |
/app/login |
Login page (Authelia-backed). |
/app/onboarding |
First-run wizard — forge credentials, webhook registration, initial agent setup. |
Assignment board
/app/planner/board is the drag-to-assign Kanban view. One column per
agent type (code-lead / dev / reviewer / designer / design-reviewer),
plus a synthetic Unassigned gutter that surfaces open type:user-story
issues without an assignee. Cards group by status (Running → Queued →
Idle-assigned → Unassigned), header reports busy/capacity saturation.
Drag-drop is plain HTML5; dropping a card invokes POST /board/assign
which PATCHes the Forgejo issue's assignees — Forgejo then fires the
normal issues.assigned webhook so dispatch routing stays in one place.
Optimistic UI: the card moves immediately, rolls back on the server's
non-2xx. Filters (repo / milestone / label / only-unassigned) bind to URL
search params so views are shareable. Live updates via /events invalidate
the TanStack Query cache; a 30 s poll backstops idle tabs.
Spotting stuck PRs
The issue detail view (/app/issue/:owner/:repo/:issueNumber) surfaces
the canonical stage pipeline — Breakdown → Implement → PR → CI → Review ↺ → Approved → Merge → Closed — derived by
GET /issues/pipeline. Stage states:
| State | Description |
|---|---|
| success | Stage completed cleanly |
| running | Agent actively working |
| pending | Waiting to start |
| failure | Stage errored |
| stalled | Running/pending past the configured threshold |
| round > 1 | Reviewer loop (REQUEST_CHANGES count) |
| force-merge | Terminator fired after max review rounds |
Per-stage stall thresholds are configurable in Settings → Service →
Watchdogs (ci_threshold_ms default 15 min, review_threshold_ms
default 10 min, implement_threshold_ms default 30 min).
Quick actions for a stalled PR:
- Bounce — POSTs to
/pipeline/bounce-review; the server DELETE + re-POSTs every requested reviewer on the PR so Forgejo fires a freshpull_request_review_requestevent and normal routing takes over. Auth-gated (operator-only). - Re-trigger CI is an external deep-link to the Forgejo Actions run, not an auto-retrigger — safety rail.
Quick start
just setup # bun install at the root — links every workspace
just dev # turbo run dev — server (4500) + web (5173) in parallel
just start # run server in production (matches systemd unit)
just qa # typecheck + lint + format check + tests across the workspace
Architecture
Forgejo (+ GitHub / GitLab via multi-forge)
│ webhook → POST /webhook/forgejo
▼
claude-hooks (desktop host)
├── FIFO task queue (one task at a time per worker)
├── per-role Worker (code-lead, dev, reviewer, designer, design-reviewer)
├── persistent cache clone + git worktrees per (agent, repo, issue)
├── spawns Claude Agent SDK inside the agent's container (docker exec)
├── agent reads issue, implements, pushes PR
├── stores result in SQLite task_history
└── fires SSE + callback webhook on completion
Containerised workers
Each code-flow agent runs inside a dedicated long-lived Docker container:
| Container name | Volume | Purpose |
|---|---|---|
claude-hooks-code-lead |
claude-hooks-code-lead-state |
Code Lead agent |
claude-hooks-dev |
claude-hooks-dev-state |
Dev agent |
claude-hooks-reviewer |
claude-hooks-reviewer-state |
Reviewer agent |
claude-hooks-designer |
claude-hooks-designer-state |
Designer agent (Penpot MCP) |
claude-hooks-design-reviewer |
claude-hooks-design-reviewer-state |
Design-reviewer agent (Penpot MCP) |
The designer / design-reviewer containers embed the Penpot MCP
server (patched for Authelia basic-auth + pre-seeded OIDC cookie — the
stock binary crashes against this instance). The code-only agents
(code-lead / dev / reviewer) deliberately do not embed Penpot MCP.
The architect agent is the odd one out — it runs in the host process, not in a container. See the Architect agent section below.
Architect agent
The architect is a host-mode singleton agent that writes specs,
dry-runs /breakdown, and hands work off to the code-lead / dev /
reviewer pool. It sits inside the claude-hooks service process
so it can:
- Read + write files under
process.cwd()(the repo the service is checked out from — typically/home/charles/Workspace/claude-hooks). - Call the service's own HTTP API on
127.0.0.1:4500without looping through a container network. - Share the service's env (Forgejo URL, the architect's own Forgejo token injected at dispatch time, etc.).
What it cannot do (enforced by canUseTool in
apps/server/src/domain/agent/architect.ts):
- Push directly to
main/master—git push origin mainpatterns in a Bash tool call are denied at the shell. - Merge PRs —
mcp__forgejo__merge_pull_requestis refused outright. Operators approve + merge through the Forgejo UI, or askcode-leadvia a dispatched task.
Dispatch surface. The Forgejo webhook paths are deliberately
blocked from reaching the architect — resolveAgentByUser /
resolveAgentByType skip host-mode types, so an operator typo'ing
architect as an issue assignee is a silent no-op. The only way to
drive the architect is through the /architect/* chat endpoints (URL
prefix preserved for backward compat).
Provisioning the architect's credentials on first deployment:
-
Create the Forgejo account (
architect), issue a personal access token withwrite:repositoryscope on every repo in thewatched_repostable (Settings → Repos), and write it to~/.config/claude-hooks/tokens/architect(mode0600). -
Seed the architect's Claude Code credentials dir by copying the shared
~/.config/claude-hooks/claude-credentials/contents into~/.config/claude-hooks/agent-env/architect/:just agent-env-sync architect -
Restart the service (or let
ensureDefaultForTypescreate thearchitect-defaultrow on startup if it doesn't already exist). The architect hasplugins: [], sojust agent-plugins-installskips it automatically — no plugin install step needed.
Chat API (URL prefix /architect/* preserved for backward compat):
| Method | Path | Body / params |
|---|---|---|
| POST | /architect/chat |
{ session_id?, message } — dispatches one turn. Returns { session_id, task_id }. |
| GET | /architect/sessions |
List sessions (id, title, turn count, preview). |
| GET | /architect/sessions/:id |
Full transcript (user + assistant messages in order). |
| DELETE | /architect/sessions/:id |
Drop the session permanently. |
| GET | /architect/stream/:task_id |
SSE stream filtered to the given task's events (same envelope as /events). |
Example — start a new session, stream the response:
curl -s -X POST http://127.0.0.1:4500/architect/chat \
-H 'Content-Type: application/json' \
-d '{"message":"Draft specs/my-feature.md from these notes…"}'
# → { "session_id": "<uuid>", "task_id": "<uuid>", "worker": "architect-default" }
curl -N http://127.0.0.1:4500/architect/stream/<task_id>
# → streams: init → progress → tool_call → assistant → result
Singleton guarantee: the Agents CRUD endpoint (POST /agents)
refuses a second architect instance with 409 Conflict. The first
row is seeded automatically on the first boot via ensureDefaultForTypes.
Multi-repo support
The service watches every row in the watched_repos table (operator-
editable via Settings → Repos). Every webhook payload's
repository.full_name is matched against that list at the webhook
entry point — unknown repos are rejected with 404 and never reach a
handler. Each row carries a well-formed owner/name and a forge enum
(forgejo / github / gitlab); the dashboard validation rejects
malformed entries before they hit the DB.
An empty watched_repos table is the back-compat escape hatch — the
guard is skipped and every repo is accepted. New deployments should
always populate it.
Adding a repo to the fleet
Runbook for pointing claude-hooks at a new repo (e.g. charles/loom):
-
Ensure the webhook's Forgejo token can see the repo. The code-lead / dev / reviewer tokens are read at container start from
~/.config/claude-hooks/tokens/<type>; rotate or re-scope the token if the current one lacks access. -
Add the repo to the
watched_repostable via the dashboard (Settings → Repos → Add) with the matching forge enum (forgejo/github/gitlab). The CRUD validatesowner/nameshape and rejects malformed entries before write. -
Run
just labels-bootstrapfor just-this-repo (or restart the service). The label catalog comes from thelabel_catalogDB table seeded by the wizard from a code-side preset; every repo inwatched_reposgets the canonicalarea:*+type:*set in one pass. Existing labels with the same name are left alone (color / description preserved — operators may have hand-edited).just labels-bootstrap charles/loom # ad-hoc, no restart systemctl --user restart claude-hooks # full reload -
Register the Forgejo webhook on the new repo pointing at
http://192.168.1.164:4500/webhook/forgejowith the shared webhook secret (same value aswebhook_secret_file). The service's signature verification fails closed — webhooks without a valid signature are always rejected. -
Verify routing: assign an issue in the new repo to an agent user (e.g.
dev). The service logs should show[webhook] event=issues action=assigned repo=charles/loomand the task dispatches normally. An unknown repo logs[webhook] unknown repo "<name>" — not in configured repos list, rejecting with 404— handy for confirming the gate fired.
The agent pool is global — there is no per-repo pool sizing or
repo-specific skills. Routing is on labels (area:design →
designer, etc.) and assignees, same as before.
Label-based routing
Tickets labelled area:design dispatch to designer instead of the
default assignee-based route. Once designer posts its handoff
comment it attaches area:design-review, which dispatches
design-reviewer via the same issues.labeled path; on completion the
reviewer removes the label. Designer-authored code PRs still route
their review_requested event to design-reviewer for backward
compat. PRs labelled area:dashboard also route to design-reviewer
regardless of author (label match beats author mapping in
reviewerForPr). Everything without an area:design* / area:dashboard
label keeps today's assignee-based routing.
Design review flow
Two triggers, one agent (design-reviewer), one skill
(skills/design-review.md) — the skill branches on context:
-
Dashboard PR review — trigger: PR labelled
area:dashboardor authored bydesigner. The skill reads the PR diff, comparesapps/web/src/**changes to any linked Penpot frame, and runs a low-tech visual-regression pass: every hex literal (#abcdefor#abc) in the diff must appear indesign/tokens.json. Raw hex violations are flagged. Verdict ships asmcp__forgejo__create_pull_reviewwithAPPROVEDorREQUEST_CHANGES— a plain comment doesn't fire Forgejo's review lifecycle event and would stall the PR forever.- Pool scheduling:
design-reviewer-defaultcarriesmatch_labels: ["area:dashboard"](seeded fromtypes.design-reviewer.default_match_labelsinagents.json). A one-shot migration at service startup upgrades pre-existing rows that still haveNULL— no manual SQL required. - Stateless:
design-reviewis in the stateless-skill set, so each dispatch reads the current state fresh.
- Pool scheduling:
-
Penpot handoff review — trigger: issue labelled
area:design-reviewafter adesignerhandoff comment. The skill exports every Penpot frame as PNG through the MCP, inspects each visually, posts a grouped-findingscreate_issue_comment, and removes thearea:design-reviewlabel to keep the issue honest.
Host / container boundary
- The
claude-hooksprocess runs on the host and drives orchestration. - Agent code (Claude Agent SDK) runs inside the container via
docker exec. - Each container has an empty
$HOME(no host credentials visible inside). - The Forgejo token for each agent is injected as
FORGEJO_ACCESS_TOKENat container start, read from~/.config/claude-hooks/tokens/<name>on the host. Token files are never bind-mounted into containers. - Claude Code OAuth credentials are bind-mounted read-only from a dedicated host
path into
$CLAUDE_CONFIG_DIR/.credentials.jsoninside the container. Rotating credentials on the host propagates without a container restart.
State persistence
Each agent's cache/, worktrees/, and session data live in the named volume.
Worktrees and sessions survive container restarts; a container stop/start does
not lose work in progress (though an in-flight task will fail and can be
retried).
Systemd service
The service manages container lifecycle automatically:
ExecStartPre → just agents-sync (reconcile fleet to SQLite agents table)
ExecStart → bun run apps/server/src/main.ts (main claude-hooks process; also reconciles at startup)
ExecStopPost → just containers-down (stop all agent containers)
agents-sync walks every row in the agents SQLite table and brings Docker
in line: creates missing containers, removes orphans whose row was deleted,
recreates any whose image or credentials bind source drifted. The service
itself runs the same reconcile at startup (after config load, before the
sweeper fires), so the ExecStartPre is a best-effort seed for the common
case where the table and the fleet already agree.
Creating a new agent instance
Creating a row in the agents table from the dashboard (A6) triggers
reconcileOne automatically — within a second or two the corresponding
claude-hooks-<instance-name> container and claude-hooks-<instance-name>-state
volume are created and the new worker starts accepting tasks. Deleting a row
tears the container down (the named state volume persists unless the operator
runs a separate wipe recipe).
If the dashboard path is bypassed — e.g. you inserted a row by hand via
bun run, or the HTTP service was down when the CRUD mutation landed — run
the manual fallback:
just agents-sync
It's idempotent and safe to run while the service is up.
TimeoutStopSec=300 gives in-flight tasks up to five minutes to finish before
systemd force-kills the process.
systemctl --user restart claude-hooks (or any SIGTERM / SIGINT) triggers
the graceful-shutdown handler in apps/server/src/shared/shutdown.ts
before systemd's grace timer expires:
- Closes
Bun.serve—/task,/webhook/forgejo,/breakdown,/architect/chatrefuse at the TCP layer. - Waits up to
shutdown.drain_ms(default 60 s, configurable in theservice_config.shutdownrow via Settings → Service) for in-flight tasks to finish on their own. - On timeout, calls
currentAbort.abort()on every still-busy worker and marks itstask_historyrowcancelledwith reasonshutdown. - For container-mode workers,
docker exec <container> kill -TERMthe in-container Claude CLI so it stops chewing Pro-Max quota with no host-side listener.
Task history no longer shows running forever after a restart, and the
dashboard banner driven by the service_shutdown SSE envelope lets the
operator see the drain in progress. To tune the budget, edit the
service_config.shutdown.drain_ms row via Settings → Service:
// service_config row payload
{
"shutdown": { "drain_ms": 120000 } // 2 min drain; clamped to [1000, 270000] ms
}
The ceiling stays safely under TimeoutStopSec=300 so the force-abort +
container-cleanup phase always completes before systemd SIGKILLs us.
Note: This is a user unit (
WantedBy=default.target). In systemd user scope,Requires=docker.serviceis declared for documentation and ordering but is not enforced as a hard dependency — the unit will not be auto-stopped if Docker dies mid-session. Graceful drain relies onTimeoutStopSec=300, not on systemd stopping the unit when Docker exits.
Install / manage
just install # copy unit, daemon-reload, enable, start
just status # systemctl --user status claude-hooks
just logs # journalctl --user -u claude-hooks
just uninstall # stop, disable, remove unit
Container management
Recipes that operate on the agent containers (orchestration only — use
#18 image recipes for building/publishing the image):
just containers-up # start all agent containers
just containers-down # stop all agent containers (volumes persist)
just containers-rebuild # pull latest image, recreate all containers one at a time
just containers-rebuild dev # pull latest image, recreate only the dev container
just containers-logs # print last 50 lines from every container
just containers-logs dev # tail -f logs for the dev container
just containers-shell dev # docker exec -it bash into the dev container
First-time container pilot
Bring up a single agent (e.g. reviewer) in container mode for the first time:
-
Provision per-agent credentials file
Claude Code OAuth credentials must live in a dedicated path, separate from the interactive user's
~/.claude/:mkdir -p ~/.config/claude-hooks/claude-credentials cp ~/.claude/.credentials.json \ ~/.config/claude-hooks/claude-credentials/.credentials.json chmod 600 ~/.config/claude-hooks/claude-credentials/.credentials.jsonTo use a per-agent path instead, set the agent's
agent_type_container.credentials_host_dirrow via Settings → Agent types. -
Build the image locally (if you haven't published one yet)
docker buildx build --platform linux/amd64 -t forge.jacquin.app/charles/claude-hooks:latest . -
Create and start the container
just containers-rebuild reviewerThis pulls (or uses the locally tagged) image, creates the container with the state volume, mounts the credentials read-only, and injects the Forgejo token.
-
Smoke-test the container
just containers-smoke reviewerVerify
bunand the Claude Code CLI are reachable inside the container. -
Enable container mode for the agent
In Settings → Agent types → reviewer, flip
agent_type_container.enabledtotrue(the dashboard CRUD writes one row to theagent_type_containertable at the appropriate scope). -
Restart the service
systemctl --user restart claude-hooks
Token rotation
Forgejo tokens are read from ~/.config/claude-hooks/tokens/<name> on the
host at container start. To rotate a token:
- Write the new token:
echo '<new-token>' > ~/.config/claude-hooks/tokens/<name> - Recreate only that container:
just containers-rebuild <name>
The other agents are unaffected.
Image updates (rolling update procedure)
To update all containers to the latest image:
just containers-rebuild
To update a single agent without affecting others:
just containers-rebuild dev
containers-rebuild stops and removes the target container, pulls the latest
image, and recreates the container with the same volume and token. The other
running containers are not touched — their in-flight tasks continue normally.
Patched forgejo-mcp in the image
The image builds forgejo-mcp from source at v${FORGEJO_MCP_VERSION}
(upstream goern/forgejo-mcp) with two local patches applied during the
build, under patches/. They fix upstream bugs where
merge_pull_request silently returns success on HTTP rejects and
update_issue drops the assignee argument. The patched binary reports
its version as ${FORGEJO_MCP_VERSION}+claude-hooks.<n>. Remove the
patches and the builder stage in Dockerfile once upstream ships the
fixes.
Debugging with auditd
When a claude-hooks-* container vanishes without a corresponding
service-side reconcileOne call, service logs and docker inspect
often don't name the caller — something external is invoking
docker stop / docker rm and we need to attribute it. The repo
ships an auditd rule that logs every docker CLI execute by root or
uid 1000 with its PID, argv, exe, and cwd, tagged with the audit key
claude-hooks-docker.
Requires the audit package on the host (pacman -S audit on Arch)
and a running auditd service.
Install
just audit-install
The recipe rewrites @@DOCKER_PATH@@ in
ops/audit/claude-hooks-docker.rules
to the host's real command -v docker path, installs the rendered
file to /etc/audit/rules.d/claude-hooks-docker.rules (requires sudo),
then reloads with augenrules --load. Idempotent — safe to re-run
after editing the source rule file.
Read events
One-shot inspection (today's events, interpreted):
sudo ausearch -k claude-hooks-docker -ts today -i
Each event group carries a SYSCALL (time, pid, uid, auid, exe),
CWD, EXECVE (full argv — look for a1=stop/a1=rm and an
a2=claude-hooks-… hit), and PROCTITLE. The auid is the original
login uid, so sudo'd invocations trace back to the real operator.
Follow new events in (near) real time:
just audit-tail
Polls ausearch --checkpoint every 2 s and prints only what's
accumulated since the previous iteration. First invocation limits the
initial scan to today so it doesn't dump the full history. Checkpoint
file lives at ${XDG_STATE_HOME:-~/.local/state}/claude-hooks/audit-tail.checkpoint.
Uninstall
just audit-uninstall
Removes /etc/audit/rules.d/claude-hooks-docker.rules and reloads.
Leaves the checkpoint file alone — delete it manually if you want a
clean slate on re-install.
Scope
The rule fires on every docker CLI invocation by uid 0 or uid 1000;
filtering to stop/rm + claude-hooks-* happens post-hoc via
ausearch or grep over the argv fields. Kernel rules can't filter
on argv, and logging everything keeps the rule file trivially
auditable. Expect some docker ps / inspect noise — it's cheap.
Securing the web UI
Claude-hooks trusts the existing Authelia instance (the same auth layer
Forgejo sits behind) rather than a homegrown token scheme. The web app and
all mutating API endpoints (/task, /cancel, /breakdown, /agents
POST/PATCH/DELETE, /architect/*) require an authenticated operator; the
read-only monitor endpoints (/health, /events, /queue, /history,
/stats, /usage) remain open on the LAN.
Reverse proxy + Authelia setup
Create a new vhost (e.g. claude.jacquin.app) in your Nginx / Caddy /
Traefik config that points at 192.168.1.164:4500 and passes every request
through Authelia:
# nginx — sample vhost (adapt to your proxy stack)
server {
server_name claude.jacquin.app;
location / {
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
proxy_set_header Remote-User $user;
proxy_pass http://192.168.1.164:4500;
proxy_set_header Host $host;
}
location = /authelia {
internal;
proxy_pass http://127.0.0.1:9091/api/verify;
# … standard Authelia verify sub-request config …
}
}
In your Authelia access_control rules, add a rule that requires the
operator group (or the specific username) to access claude.jacquin.app:
# authelia configuration.yml — access_control section
rules:
- domain: claude.jacquin.app
policy: one_factor
subject: "user:charles"
No new identity provider or user accounts are needed — the same credentials used for Forgejo apply.
Service-side config (service_config.auth row)
Edit Settings → Service and fill in the auth block, which writes a
JSON payload to the service_config row at scope='global':
{
"auth": {
// Forgejo / Authelia username of the authorised operator
"operator_user": "charles",
// IPv4 CIDRs of the reverse proxy that sets Remote-User.
// Must be non-empty — an empty list fails the boot check.
"trust_proxy": ["10.0.0.0/24"],
// Authelia logout URL surfaced in the web UI header
"authelia_logout_url": "https://auth.jacquin.app/logout"
}
}
Startup check: the loader refuses to boot if trust_proxy is empty when
the auth block is present — an empty list would make all mutating routes
unreachable without a clear error message.
Without authentication: omitting the auth block leaves mutating routes
open on the LAN (no proxy identity gate). Add the block only after the
vhost + Authelia rule are in place.
How the server trusts Remote-User
Remote-User is a plain HTTP header — any client on the LAN could set it.
The server counters this by verifying the TCP connection source IP against
trust_proxy before honoring the header:
Bun.servecaptures the real socket-level peer address (server.requestIP).handleRequestinjects it as the internalx-claude-client-ipheader (user-supplied copies are stripped first, so external callers cannot spoof a trusted IP).- The auth middleware reads
x-claude-client-ipand only trustsRemote-Userwhen the IP is inside one of thetrust_proxyCIDRs.
Verifying the header chain end-to-end
From a trusted proxy host (e.g. the Nginx server in 10.0.0.0/24):
# Should succeed (200 / task queued)
curl -X POST http://192.168.1.164:4500/task \
-H 'Remote-User: charles' \
-H 'Content-Type: application/json' \
-d '{"repo":"charles/claude-hooks","issue_number":1,"task":"hello","role":"dev"}'
From a machine outside trust_proxy (e.g. your laptop at 192.168.1.x):
# Should be rejected (403) even with the correct header
curl -X POST http://192.168.1.164:4500/task \
-H 'Remote-User: charles' \
-H 'Content-Type: application/json' \
-d '{"repo":"charles/claude-hooks","issue_number":1,"task":"hello","role":"dev"}'
GET /whoami (always open) lets you inspect what the server sees:
curl http://192.168.1.164:4500/whoami
# → {"user":null,"auth_enabled":true,"logout_url":"https://auth.jacquin.app/logout"}
Migration note
If you have an existing installation using the on-host cache (pre-containerisation), that state is not migrated into the new Docker volumes. The volumes start empty and are populated as agents pick up new tasks. To avoid confusion, stop the old service and remove the old cache before installing the containerised version:
systemctl --user stop claude-hooks
rm -rf ~/.cache/claude-hooks # abandon old state
just containers-rebuild # create containers (image + tokens + credentials)
just install # install updated unit
Release pipeline
Tag pushes (v*) fire .forgejo/workflows/release.yml. The pipeline
builds both arch binaries, attaches them to a Forgejo release, then
builds + pushes a multi-arch container image and runs runtime smoke
tests (bun, git, forgejo-mcp, claude --version) plus a
credential-file audit against the just-pushed image. On failure the
image tags are deleted from the registry and the release is flipped to
prerelease with a "DO NOT USE" banner.
The publish-image job needs Docker daemon access and is gated on a
dedicated release-only runner label (runs-on: [docker, release]). PR
CI (qa.yml) stays on plain docker and cannot land on the
socket-mounted runner. See docs/runner-setup.md
for runner registration, config.yml snippets, the security rationale,
and the release-candidate dry-run procedure.
API
Core task API
| Method | Path | Description |
|---|---|---|
| POST | /task |
Submit a coding task (returns task_id) |
| GET | /task/:id |
Get task result (success/failure/running/queued) |
| POST | /task/:id/steer |
Mid-flight operator steering — inject a message into the running agent |
| POST | /task/:id/redispatch |
Re-dispatch a failed or cancelled task |
| GET | /health |
Service health + queue depth |
| GET | /queue |
Current task + queued tasks per worker |
| POST | /cancel |
Abort the current running task |
| GET | /history |
Recent task history (last 50, in-memory) |
| GET | /storage |
Disk usage for cache clones / worktrees / sessions |
| GET | /events |
SSE stream of real-time task events |
| POST | /reset |
Drop worktree + session for a (agent, repo, issue) |
| POST | /sweep |
Run one pass of the expiration sweeper on demand |
Agent management
| Method | Path | Description |
|---|---|---|
| GET | /agents |
List every SQLite row + type defaults + live worker state |
| POST | /agents |
Create a new instance row + reconcile container |
| PATCH | /agents/:name |
Update override fields + reconcile container |
| DELETE | /agents/:name |
Cancel task, remove container, drop row |
| GET | /agents/health |
Aggregate fleet saturation, queue depth, cost |
| GET | /agents/:name/provider-events |
Failover ledger for one instance |
| POST | /agents/:name/reset-tier |
Reset to tier 1, clear paused, clear failure metadata |
| POST | /agents/:name/pause |
Pause the agent (no new tasks accepted) |
| POST | /agents/:name/unpause |
Resume a paused agent |
| GET | /agents/models |
Provider-supplied model catalogue (?provider=<id>) |
Breakdown
| Method | Path | Description |
|---|---|---|
| POST | /breakdown |
Run the breakdown skill on a specs/*.md file |
Stats and pipeline
| Method | Path | Description |
|---|---|---|
| GET | /stats |
Cost / turn / success-rate aggregates |
| GET | /usage |
Token-consumption rollup against the Pro-Max weekly quota |
| GET | /issues/pipeline |
Per-issue pipeline stage model |
| GET | /issues/ready |
Open issues with all blockers closed + no /hold |
| GET | /issues/deps |
k-hop dependency DAG for one issue |
| POST | /pipeline/bounce-review |
Rekick a stalled reviewer assignment |
Watchdog
| Method | Path | Description |
|---|---|---|
| GET | /watchdog/status |
Dead-letter PRs and watchdog state (open) |
| POST | /watchdog/retry/:repo/:number |
Remove a dead-letter entry (auth-gated) |
| POST | /watchdog/snooze/:repo/:number |
Snooze a dead-letter PR for 24 h (auth-gated) |
| GET | /watchdog/recovery/diff |
Git diff for a stash branch (auth-gated) |
| DELETE | /watchdog/recovery |
Force-delete stash branch (auth-gated) |
Settings and configuration
| Method | Path | Description |
|---|---|---|
| GET | /settings |
Read service settings |
| PATCH | /settings |
Update service settings |
| GET | /whoami |
Inspect what the server sees for auth |
| GET | /watched-repos |
List all watched repos |
| POST | /watched-repos/:owner/:name/rotate-secret |
Rotate the webhook secret for a repo |
POST /breakdown
Dispatch the breakdown skill against a spec document (specs/<name>.md)
in a watched repo. The code-lead pool turns each logical ## section into a
type:user-story issue — one per section — following the operator's
~/.claude/CLAUDE.md issue-authoring conventions.
curl -X POST http://192.168.1.164:4500/breakdown \
-H 'Content-Type: application/json' \
-d '{"repo":"charles/claude-hooks","spec_path":"specs/multi-tenant.md","tracking_issue":47}'
Body
| Field | Required | Description |
|---|---|---|
repo |
yes | Must match a row in watched_repos — no cross-repo |
spec_path |
no | Defaults to specs/ (walk all .md in the dir) |
tracking_issue |
no | Issue on which the skill posts its summary comment |
milestone |
no | Milestone title attached to every created issue |
dry_run |
no | true ⇒ post the proposed issues as a checklist, don't create |
max_issues |
no | Per-dispatch cap; defaults to 15 — overflow lives in a follow-up batch |
Response: 202 { task_id, worker, repo, tracking_issue } on success,
400 on invalid JSON / cross-repo / missing repo, or 503 when no
code-lead instance is configured.
The same guardrails also apply to the webhook /breakdown slash
command — posting a comment starting with /breakdown on any issue in
a watched repo dispatches the same skill. Trailing args are positional
(/breakdown specs/foo.md) or key=value
(/breakdown spec_path=specs/foo.md milestone="Milestone 16"), and the
commenting issue becomes the tracking issue.
GET /stats
Returns cost, turn, and success-rate aggregates from the persistent task history, grouped by agent, repo, and day.
GET /stats[?window=30d][&agent=<name>][&repo=<owner/name>]
Query params
| Param | Values | Default | Description |
|---|---|---|---|
window |
7d | 30d | 90d | all |
30d |
Time range (finished_at filter) |
agent |
instance name, e.g. code-lead-default |
— | Filter to one agent |
repo |
owner/name |
— | Filter to one repo |
Response shape
{
"window": { "from": "<iso>", "to": "<iso>", "days": 30 },
"totals": { "tasks": 123, "cost_usd": 4.56, "turns": 789, "success_rate": 0.92 },
"by_agent": [
{
"name": "code-lead-default", "type": "code-lead",
"tasks": 40, "cost_usd": 2.10, "turns": 320,
"success_rate": 0.95, "avg_turns_per_task": 8.0, "avg_cost_per_task": 0.0525
}
],
"by_repo": [
{ "repo": "charles/claude-hooks", "tasks": 120, "cost_usd": 4.50, "success_rate": 0.93 }
],
"by_day": [
{ "day": "2026-04-20", "tasks": 12, "cost_usd": 0.42, "success_rate": 0.83 }
]
}
success_rate = successful / (successful + failed). Cancelled tasks are
excluded from the denominator. window.days is null for ?window=all.
Only finalized tasks (success, failure, cancelled) appear; running
and queued tasks are excluded.
GET /usage
Token-consumption rollup in the style of the Anthropic console's
"Weekly token usage" panel. Sums input / output / cache-read /
cache-creation tokens across finalized tasks for the current rolling
window and ranks each agent by token share. Pro Max has a weekly
session quota with no per-token billing — so the default window
(week) tracks the same reset cadence Anthropic applies on the server.
GET /usage[?window=week|day|all]
Query params
| Param | Values | Default | Description |
|---|---|---|---|
window |
week | day | all |
week (or service_config.usage_reset) |
Rolling window anchor |
Response shape
{
"window": {
"reset_at": "2026-04-27T00:00:00.000Z",
"since": "2026-04-20T00:00:00.000Z",
"kind": "week"
},
"totals": {
"tasks": 42, "turns": 300,
"input_tokens": 3000000, "output_tokens": 1200000,
"cache_read": 800000, "cache_creation": 50000
},
"by_agent": [
{
"name": "code-lead-default", "type": "code-lead",
"model": "claude-opus-4-7[1m]",
"tasks": 20, "turns": 180,
"input_tokens": 2000000, "output_tokens": 800000
}
],
"by_day": [
{ "day": "2026-04-20", "tasks": 5, "turns": 30,
"input_tokens": 1000000, "output_tokens": 400000 }
],
"threshold_tokens": null
}
The weekly window anchors on Monday 00:00 UTC (matches the Anthropic
Pro Max reset cadence) and reset_at is the following Monday.
?window=day swaps the anchor to today 00:00 UTC; ?window=all drops
the lower bound entirely and returns reset_at: null.
threshold_tokens echoes the optional
service_config.usage_threshold_tokens row. When set, the dashboard
draws the 50 / 80 / 95 % threshold rings and colours the hero number
green / yellow / orange / red. null means no cap is configured.
Configuration (service_config row via Settings → Service, both
optional):
{
"usage_reset": "week", // "week" | "day" | "all" — overrides the default window
"usage_threshold_tokens": 150000000 // soft-cap reference in tokens; null to disable
}
Only finalized tasks (success, failure, cancelled) are counted;
running and queued tasks have no finished_at and are excluded.
GET /issues/pipeline
Derived view that returns the canonical stage model for every issue the
service has touched. The shape is pure projection over existing state —
task_history rows for implementation / design / merge / breakdown
stages, plus live Forgejo queries for PR / CI / review signals — so no
new persistent state is added. Consumed by the issue detail view and the
assignment board.
GET /issues/pipeline[?repo=<owner>/<name>][&milestone=<id>][&state=open|closed|all][&limit=<n>]
Query params
| Param | Values | Default | Description |
|---|---|---|---|
repo |
owner/name |
every row in watched_repos |
Scope to one repo |
milestone |
integer | — | Filter to one Forgejo milestone id |
state |
open | closed | all |
open |
Issue state filter |
limit |
integer (max 500) | 100 | Per-repo page size |
Response shape
{
"issues": [
{
"repo": "charles/claude-hooks",
"issue_number": 174,
"title": "feat: …",
"labels": ["area:webhook", "type:user-story"],
"milestone": "Pipeline monitor",
"assignee": "code-lead",
"current_stage": "review",
"stages": [
{
"stage": "implement",
"state": "success",
"task_ids": ["abc123", "def456"],
"agent": "code-lead-default",
"started_at": "2026-04-20T12:00:00.000Z",
"finished_at": "2026-04-20T12:05:00.000Z",
"duration_ms": 300000,
"cost_usd": 0.42,
"turns": 18,
"round": null,
"force_merge": false,
"link": null,
"stalled_since": null
}
],
"updated_at": "2026-04-20T12:30:00.000Z",
"pr_number": 195
}
],
"generated_at": "2026-04-20T12:30:05.000Z"
}
Canonical stages: breakdown → implement (or design →
design_review on the design track) → pr → ci → review (looping
via REQUEST_CHANGES rounds) → approved → merge → closed. Each
stage carries a state of pending / running / success /
failure / skipped / stalled.
Server-side cache: 5 s TTL keyed by (repo, milestone, state, limit). Multiple dashboard tabs share one Forgejo round-trip. SSE
pipeline_stage events bypass the cache — they describe a live
transition the next poll hasn't yet observed.
SSE envelope (additive to existing events):
{
"type": "pipeline_stage",
"ts": 1714478405000,
"repo": "charles/claude-hooks",
"issue_number": 174,
"stage": "implement",
"state": "running",
"agent": "code-lead-default",
"task_id": "abc123"
}
Configuration (service_config.pipeline row via Settings →
Service, optional):
{
"pipeline": {
"stall_threshold_ms": 600000 // default: 10 min — age before pending → stalled
}
}
GET /issues/ready
Read-only feed of unassigned open issues whose blockers are all
closed and that carry no /hold override. The dependency-propagation
path auto-assigns on issues.closed, so this list is usually short —
it surfaces issues that existed as ready before the service booted (no
close event ever fired for them) or ones an operator just /unassigned.
Feeds the assignment board's "Ready" column.
GET /issues/ready[?repo=<owner>/<name>]
Query params
| Param | Values | Default | Description |
|---|---|---|---|
repo |
owner/name |
every row in watched_repos |
Scope to one repo |
Response shape
{
"issues": [
{
"repo": "charles/claude-hooks",
"number": 196,
"title": "feat(deps): auto-detect blocker closures + ready-to-assign comment",
"labels": ["area:webhook", "type:user-story"],
"assignees": [],
"suggested_type": "code-lead",
"suggested_reasoning": "area:webhook → code-lead (architecture-touching)",
"blockers": [175, 183]
}
],
"generated_at": "2026-04-20T12:30:05.000Z"
}
No cache — the working set is tens of issues and staleness hurts more than a per-repo round-trip.
just deps-backfill <owner>/<repo>
One-shot CLI that walks every open issue in the target repo, parses
Blocks on #N / Depends on #N phrases out of the body, and calls
POST /issues/{N}/dependencies for each parsed edge that isn't already
declared natively. Idempotent — re-running against a clean repo is a
no-op. Use this to promote body-only dependency mentions (where the issue
text lists Depends on #N but Forgejo has no native dependency edge yet)
to the native API so the propagator's primary path picks them up.
just deps-backfill charles/claude-hooks
FORGEJO_TOKEN=xxx just deps-backfill charles/claude-hooks
just deps-backfill charles/claude-hooks "$(cat ~/my-token)"
Token resolution mirrors just labels-bootstrap: 2nd arg →
FORGEJO_TOKEN env → first readable token under
~/.config/claude-hooks/tokens/.