No description
  • TypeScript 98%
  • CSS 0.8%
  • Just 0.6%
  • Shell 0.3%
  • Dockerfile 0.1%
Find a file
Charles Jacquin 7b64ade888
All checks were successful
qa / dockerfile (push) Successful in 18s
qa / i18n-string-check (push) Successful in 18s
qa / db-schema (push) Successful in 21s
qa / sql-layer-check (push) Successful in 24s
qa / qa-1 (push) Successful in 3m40s
qa / qa (push) Successful in 0s
refactor(workdir): replace git worktrees with single checkout per (agent, repo)
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>
2026-05-12 18:53:26 +02:00
.cursor/skills/forgejo-deps-backfill chore: remove all legacy/backward-compat code 2026-05-07 20:44:53 +00:00
.forgejo/workflows chore(ci): bump forge-base to v0.2.3 (Playwright browser cache) (#1113) 2026-05-11 20:54:22 +00:00
.husky chore: sync pre-push hook to forge-base canonical (calls just qa) 2026-05-12 14:11:51 +00:00
apps refactor(workdir): replace git worktrees with single checkout per (agent, repo) 2026-05-12 18:53:26 +02:00
design refactor: rename foreman → architect across code, URLs, schema 2026-05-02 19:31:00 +02:00
docs chore(ci-logs): rip stillborn ci_logs mirror — pre-push test gate replaces it 2026-05-11 19:47:45 +02:00
flows/defaults feat(flows-yaml): inline legacy* prFlow callbacks; split cleanup_issue op (#1100) 2026-05-11 02:31:37 +00:00
ops/audit feat(audit): log external docker stop/rm on claude-hooks-* via auditd 2026-04-20 14:33:06 +00:00
packages/shared feat(ci-logs): runner-side CI log mirror for fix-ci dispatch (#1103) 2026-05-11 15:07:57 +00:00
scripts feat(agents): synthesize shell_output_delta for claude-code via container log stream (#994) 2026-05-08 19:39:00 +00:00
skills chore: remove all legacy/backward-compat code 2026-05-07 20:44:53 +00:00
specs feat(flows-yaml): shadow mode + cutover CLI + legacy + UI deletion (closes #1078) (#1083) 2026-05-10 21:28:54 +00:00
vendor/penpot-mcp-server refactor(service-config): URL consolidation series — PRs A + B + queued cleanup (#827) 2026-05-04 17:36:56 +00:00
.cursorignore fix(cursor): wrap cwd in symlink dir so SDK doesn't blow HTTP/2 frame (#1027) 2026-05-09 22:00:39 +00:00
.dockerignore chore(infra): migrate Docker base + CI workflows to forge-base v0.1.0 2026-05-03 13:11:09 +02:00
.gitignore test(web): migrate component tests to Vitest browser mode (#1014) 2026-05-09 17:04:26 +00:00
biome.json feat(flows-yaml): 9 defaults + JSON Schema pipeline + REST CRUD + main wiring (closes #1076) 2026-05-10 18:55:02 +00:00
bun.lock fix(web): swallow vitest browser-mode birpc-teardown race that flakes CI 2026-05-11 09:47:50 +00:00
claude-hooks.service fix(reconcile): docker start stopped-but-matching containers (#188) 2026-04-20 21:02:39 +00:00
CLAUDE.md docs(web): navigation primitives + nav spec link (#1042) 2026-05-10 14:51:33 +00:00
Dockerfile chore(image): bake Playwright Chromium + OS deps into agent image 2026-05-11 12:08:28 +02:00
justfile feat(flows-yaml): shadow mode + cutover CLI + legacy + UI deletion (closes #1078) (#1083) 2026-05-10 21:28:54 +00:00
package.json chore(biome): upgrade @biomejs/biome v1 → v2.4.13 (#408) 2026-04-26 23:32:53 +00:00
README.md chore: remove all legacy/backward-compat code 2026-05-07 20:44:53 +00:00
tsconfig.base.json feat(m18): reshape repo into Bun + Turbo workspace (#171) 2026-04-20 17:50:03 +00:00
tsconfig.json feat(m18): reshape repo into Bun + Turbo workspace (#171) 2026-04-20 17:50:03 +00:00
turbo.json feat(settings): inline settings tree in sidenav + flyout on collapsed rail (#1109) 2026-05-11 18:19:43 +00:00

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):

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 fresh pull_request_review_request event 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:4500 without 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 / mastergit push origin main patterns in a Bash tool call are denied at the shell.
  • Merge PRs — mcp__forgejo__merge_pull_request is refused outright. Operators approve + merge through the Forgejo UI, or ask code-lead via 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:

  1. Create the Forgejo account (architect), issue a personal access token with write:repository scope on every repo in the watched_repos table (Settings → Repos), and write it to ~/.config/claude-hooks/tokens/architect (mode 0600).

  2. 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
    
  3. Restart the service (or let ensureDefaultForTypes create the architect-default row on startup if it doesn't already exist). The architect has plugins: [], so just agent-plugins-install skips 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):

  1. 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.

  2. Add the repo to the watched_repos table via the dashboard (Settings → Repos → Add) with the matching forge enum (forgejo / github / gitlab). The CRUD validates owner/name shape and rejects malformed entries before write.

  3. Run just labels-bootstrap for just-this-repo (or restart the service). The label catalog comes from the label_catalog DB table seeded by the wizard from a code-side preset; every repo in watched_repos gets the canonical area:* + 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
    
  4. Register the Forgejo webhook on the new repo pointing at http://192.168.1.164:4500/webhook/forgejo with the shared webhook secret (same value as webhook_secret_file). The service's signature verification fails closed — webhooks without a valid signature are always rejected.

  5. 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/loom and 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:designdesigner, 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:

  1. Dashboard PR review — trigger: PR labelled area:dashboard or authored by designer. The skill reads the PR diff, compares apps/web/src/** changes to any linked Penpot frame, and runs a low-tech visual-regression pass: every hex literal (#abcdef or #abc) in the diff must appear in design/tokens.json. Raw hex violations are flagged. Verdict ships as mcp__forgejo__create_pull_review with APPROVED or REQUEST_CHANGES — a plain comment doesn't fire Forgejo's review lifecycle event and would stall the PR forever.

    • Pool scheduling: design-reviewer-default carries match_labels: ["area:dashboard"] (seeded from types.design-reviewer.default_match_labels in agents.json). A one-shot migration at service startup upgrades pre-existing rows that still have NULL — no manual SQL required.
    • Stateless: design-review is in the stateless-skill set, so each dispatch reads the current state fresh.
  2. Penpot handoff review — trigger: issue labelled area:design-review after a designer handoff comment. The skill exports every Penpot frame as PNG through the MCP, inspects each visually, posts a grouped-findings create_issue_comment, and removes the area:design-review label to keep the issue honest.

Host / container boundary

  • The claude-hooks process 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_TOKEN at 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.json inside 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:

  1. Closes Bun.serve/task, /webhook/forgejo, /breakdown, /architect/chat refuse at the TCP layer.
  2. Waits up to shutdown.drain_ms (default 60 s, configurable in the service_config.shutdown row via Settings → Service) for in-flight tasks to finish on their own.
  3. On timeout, calls currentAbort.abort() on every still-busy worker and marks its task_history row cancelled with reason shutdown.
  4. For container-mode workers, docker exec <container> kill -TERM the 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.service is 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 on TimeoutStopSec=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:

  1. 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.json
    

    To use a per-agent path instead, set the agent's agent_type_container.credentials_host_dir row via Settings → Agent types.

  2. 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 .
    
  3. Create and start the container

    just containers-rebuild reviewer
    

    This pulls (or uses the locally tagged) image, creates the container with the state volume, mounts the credentials read-only, and injects the Forgejo token.

  4. Smoke-test the container

    just containers-smoke reviewer
    

    Verify bun and the Claude Code CLI are reachable inside the container.

  5. Enable container mode for the agent

    In Settings → Agent types → reviewer, flip agent_type_container.enabled to true (the dashboard CRUD writes one row to the agent_type_container table at the appropriate scope).

  6. 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:

  1. Write the new token: echo '<new-token>' > ~/.config/claude-hooks/tokens/<name>
  2. 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:

  1. Bun.serve captures the real socket-level peer address (server.requestIP).
  2. handleRequest injects it as the internal x-claude-client-ip header (user-supplied copies are stripped first, so external callers cannot spoof a trusted IP).
  3. The auth middleware reads x-claude-client-ip and only trusts Remote-User when the IP is inside one of the trust_proxy CIDRs.

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: breakdownimplement (or designdesign_review on the design track) → prcireview (looping via REQUEST_CHANGES rounds) → approvedmergeclosed. 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/.