Wires the phase-1 headless machinery behind runner=headless (default
stays tmux; legacy agents, session_id null, keep the tmux paths):
- spawn: branches tmux vs headless.launch; extracts _agent_env (shared
with resume - a headless resume is a new process needing identity/
credential env); headless spawns require a task (no idle-REPL mode),
enforced at spawn and as a 400 in the API
- resume: headless path materializes the session archive from the DB
onto whichever worker claimed the command, then claude -p --resume;
falls back to a fresh session with DB-re-injected context (visible
worker event) when no transcript survives anywhere; refuses while a
run is live. Undeliverable resumes now raise -> command FAILED,
fixing silent input loss on both runners
- kill: headless path flags cancel_requested; the owning supervisor
SIGTERMs its own child (cross-worker safe)
- worker: stable per-container ids, DB-driven run slots (full workers
skip claiming spawn/resume/mise_init, leaving them for less-loaded
workers), credsync refresh in the main loop
- settings_gen: permissions block (defaultMode + allowlist) for
headless runs - -p auto-denies anything that would prompt; hooks
remain the hard gate
- credsync + migration 0009 (runtime_secrets): login publishes the
Fernet-encrypted claude credential bundle; every worker materializes
it (merge-safe for local trust state); login_submit pinned to the
login_start worker via commands.target_worker
Suite 270 -> 290 green, including the cross-worker resume linchpin
(clean-HOME materialize + --resume against the fake binary).
`git worktree add <path> <branch>` only checks out an existing ref, so
spawning an agent on a fresh feature branch failed with `fatal: invalid
reference` (exit 128) — the branch spawn asks for never exists yet, since
spawn starts from the remote's latest state.
- Detect whether the branch exists; use `git worktree add -b` to create it
when it doesn't, and a plain checkout (git DWIMs remote tracking) when it does.
- Slugify the agent name for the worktree directory so free-form names (a PR
title) no longer put spaces/colons/parens in repo-root paths.
- Surface git's stderr via a WorktreeError instead of a bare "exit status 128";
spawn re-raises it as SpawnError.
Adds tests/test_worktree.py covering new-branch creation, existing-branch
checkout, slugification, the isolation guard, and the clear-error path.
The mise-init agent wedged on launch and the UI reported it green. Three
distinct problems, fixed together:
1. Onboarding wedge (the proximate bug). A freshly-installed claude opens
interactive setup — theme picker, then a folder-trust prompt — before
the REPL. A detached tmux agent has no one to answer it, so it sat on
the theme picker forever while agents.status said 'working'. New
control.claude_config.ensure_onboarded() marks onboarding complete and
trusts the working dir in ~/.claude.json (merge-only, so the login
flow's oauthAccount survives); spawn() calls it before launching.
2. Config-name gate. control/mise.py only recognized `.mise.toml`, so a
repo shipping `mise.toml` (no dot) — or config under `.config/mise/` —
failed the [tasks.test] gate even when healthy. It now accepts the
filenames mise itself reads and scans them all for the test task.
3. "Done" != done (the design gap). A spawned agent's real state lives in
its tmux pane, but the socket is control-container-only, so the API
couldn't see it. The worker now snapshots each working agent's pane
tail (last ~40 lines) into two new agents columns (last_output,
output_at, migration 0007) on its existing poll loop; the API
serializes them and AgentsSection renders a live-output <pre> under
each running agent. A wedged agent now shows the theme picker instead
of a misleading green badge.
Tests: home-dir writes are isolated to tmp in conftest; added coverage for
claude_config seeding/merge, the mise filename set, the worker capture
(including dead-session skip), and the API serialization.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
Removing a repo returned 500. `delete_project` deleted only the projects
row, but `commands`, `agents`, `approvals`, and `schedules` all carry a
foreign key to `projects.id` with no ON DELETE CASCADE — and every real
project has at least the `sync` command queued at registration referencing
it, so Postgres rejected the delete with a ForeignKeyViolation
(commands_project_id_fkey). The existing tests only deleted dependent-free
projects, so it went unnoticed (and SQLite, though it has FK enforcement on
here, was never exercised with a referencing row).
delete_project now clears dependents in FK-safe order: agent-owned rows
(checkmarks before log_entries per the use_alter cycle, approvals authored
by those agents, and shared_context attribution nulled since it's a global
table), then schedules (which reference commands via last_command_id),
then the project-scoped approvals/commands/agents, then the project.
delete_agent had the same latent bug — a spawned agent always accrues a
checkmark + log entries via the hooks, which the log_entries/checkmarks FKs
would block — so it shares the same _purge_agent_dependents helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
Some repos an operator wants to manage don't yet define the `.mise.toml`
`[tasks.test]` task the spawn gate hard-requires — a chicken-and-egg,
since you can't run an agent to author that file without it. This adds a
one-click bootstrap.
Ticking "Initialize mise" on the add step enqueues a `mise_init` command
after the clone. The worker launches a dedicated agent that detects the
repo's stack, writes a `.mise.toml` with a canonical `[tasks.test]` task,
and commits + pushes it. That agent runs with the test-task gate off
(creating the task is the point) and a `HANDLER_MISE_INIT` marker on, so
its hooks enforce a bootstrap contract instead of the normal test gate:
- Stop hook blocks the turn until `.mise.toml` defines `[tasks.test]` and
the change is committed (clean tree) and pushed (no commits ahead of an
upstream) — so claude cannot end before the work has actually landed.
- git-push hook lets the bootstrap push through, skipping the test/build
gate (there may be no working suite yet) so the file reaches the remote.
Backend: `mise_init` command type (+ migration 0006), a shared
`control.mise` helper for the test-task check, `spawn(require_tests=,
mise_init=)`, gitops `is_clean`/`ahead_count`, and `init_mise` on the
project-create API (only acts when a git remote exists to push to).
Frontend: the checkbox, plumbed through the store, following the launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
Reproduced the failure against a real claude 2.1 in tmux. Two root causes,
both now fixed (the URL was never wrong — claude genuinely emits
`claude.com/cai/oauth/authorize`, so extraction was fine):
1. Submit race (the actual failure). `send_keys` sent the code and Enter
together; for a long real code the Enter is processed before Ink commits the
paste, so nothing submits — the session sits at "Paste code here > ****…",
exactly what the activity log showed. Fix: deliver the code as a bracketed
paste (tmux set-buffer/paste-buffer, new tmux.send_text), let it settle, then
send Enter separately (tmux.send_enter). Verified end-to-end: the separate
Enter submits and claude proceeds to the exchange.
2. Fragile onboarding. A fresh claude shows a theme picker, then the
login-method menu, before any URL — the old blind /login+Enter+Enter only
reached the menu by luck. Fix: start() now reads the pane each pass and reacts
— accept theme/trust/continue prompts, pick the default subscription option on
the login-method menu, and send /login once only when already onboarded at the
REPL.
Also: confirm login by watching ~/.claude.json (where claude stores the account
on Linux) plus a success-text fallback, and fail fast on an "OAuth error /
Press Enter to retry" screen instead of waiting out the poll. Tests updated to
the real TUI screen text. Suite green (200).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
Testing showed the wide-window fix captured the full URL, but login still
failed at submit ("login not confirmed"): the old check snapshotted the pane
once after 3s and only matched a few success strings, so an in-progress or
differently-worded exchange read as failure. Two hardening changes:
- login_submit now polls (up to 40s) and confirms by the authoritative signal —
claude's credentials file changing on disk (any of the known locations /
~/.claude/*credential*) — with success-text and clean-exit as fallbacks.
- login_start captures with escape sequences (-e) and accepts only a *complete*
OAuth URL (https:// + client_id + redirect_uri + state). This recovers the
real href when claude renders the link as an OSC-8 hyperlink (whose visible
text can be garbled, e.g. the "ttps://claude.com/cai/..." seen in testing) and
refuses partial/garbled captures. On timeout the error now includes the actual
last screen so a wrong menu/onboarding state is diagnosable.
tmux.capture_pane gains an `escapes` flag. Tests cover URL completeness,
OSC-8 href recovery, and credentials-file confirmation. Suite green (199).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
Testing the web login surfaced a truncated authorization URL
(…client_id=9d1c250a-e61b-44d9-88) and a "missing redirect_uri" error: the
login session ran at the default 80 columns, so claude clipped the long URL and
capture-pane read it back cut off.
- Launch the login session with a very wide, tall window (500x50) via new
optional width/height on tmux.new_session, so claude prints the URL on one
unclipped line.
- Harden URL extraction to stop at box-drawing glyphs (U+2500–U+257F) in case
the TUI renders the link flush against a border.
Tests: assert the wide window is requested, and that extraction keeps a full
redirect_uri/PKCE URL intact and strips a trailing box border. Suite green (197).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
Two changes so an operator can stand up and authenticate Handler entirely
from the browser, with a self-contained control image.
Bundle executables in the control image (Dockerfile.control)
- Node.js (NodeSource) + the Claude Code CLI, mise (official apt repo), and
forge (git-pkgs/forge, built in a Go stage) join the existing git/tmux/ssh.
No more bring-your-own binaries: live agent spawning, the verification gate,
CI resolution, and the login flow all work out of the box. Installed under
/usr so the /var/lib/handler VOLUME never masks them; mise apt source pinned
to $TARGETARCH for the multi-arch (amd64/arm64) build.
Claude login from the web UI
- New login_start / login_submit command types (migration 0005) drive the
interactive `claude /login` through the same enqueue→worker handoff every
other control action uses — the API container has no claude binary.
- control/login.py opens `claude` in a dedicated tmux session, sends /login,
selects the subscription account, and scrapes the claude.com authorization
URL (tmux.capture_pane, -pJ so a wrapped URL rejoins); a second command feeds
back the pasted code. Fully mockable via the tmux seam.
- API: POST /login/start, POST /login/submit (admin-gated).
- Dashboard: a "Claude Login" pane — a button that starts the flow, embeds the
URL in an iframe (with a new-tab fallback, since claude.com may refuse
framing), and takes the code to finish.
Also un-ignores frontend/lib/ (a broad Python `lib/` rule was swallowing the
UI's own api client + formatters, breaking rebuilds from a fresh clone) and
reconstructs those two source files; rebuilt static export committed.
Tests: control/login unit tests (tmux faked), worker dispatch, and API route
tests. Full suite green (195 tests), ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
Git servers (forge_hosts) become full credential owners:
- an encrypted forge token (Fernet, HANDLER_SECRET_KEY) stored per server and
never returned by the API (has_token flag only); used automatically by every
project on that host and addressable as db:host:<hostname> — the reserved
db: credential scheme is now live
- a per-server ed25519 SSH deploy key: generated server-side, public half
shown in the dashboard to paste into the forge, private half encrypted at
rest and materialized 0600 only in the control container (GIT_SSH_COMMAND /
core.sshCommand)
Project registration gets a git-server mode: pick a registered server, type
owner/name, and the API derives the remote (ssh when the server has a deploy
key, https otherwise), computes root_dir under PROJECTS_ROOT, and enqueues a
new 'sync' command the worker executes (clone, or ff-only pull). Spawn always
pulls first, so runs start from the remote's latest state; POST /projects/:p/sync
and 'handler sync' re-pull on demand.
Schedules: recurring agent spawns (prefix, prompt, interval, role). The worker
fires due schedules as ordinary queued spawn commands with timestamped agent
names, so runs are fresh stateless agents and appear in the Activity audit
trail; missed intervals collapse into one catch-up run.
Dashboard: Git Servers pane shows the SSH public key (copy button) and takes a
write-only token; Repositories gains the server-first add form and a Pull now
button; new Schedules pane. Rebuilt static export. Also restores the missing
frontend/lib (api client + format helpers) the components import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
Rebuild the bundled web UI as a Next.js (React + TypeScript) static export
implementing the Claude Activity Dashboard design: a left-nav "Control Center"
hub over Runs, Repositories, Agents, Approvals, Git Servers, Activity, and
Shared, styled with the Leeworks design-system tokens (flat, dark, border-led).
The dashboard is a pure client of the existing API (same contract as curl):
the browser prompts for the token once, stores it in localStorage, attaches it
to every call, and renders all API values as React text so agent-authored
strings stay inert. Control actions enqueue a command and poll it to a terminal
state, matching the worker model.
The build output is committed to src/handler/api/static/ so the wheel ships it
and FastAPI serves it same-origin. app.py now mounts the export at "/" after the
API routers (a non-shadowing fallback: unmatched paths 404, no SPA rewrite).
UI-serving tests updated for the export; frontend source lives in frontend/.
Claude-Session: https://claude.ai/code/session_01ATgVWRjFzG8nHEnwgZpJWD
Co-authored-by: Claude <noreply@anthropic.com>
Make credentials/hosts, projects, agents, and approvals manageable from the
dashboard. The API and control layer are separate containers, so the API can't
run control actions directly (no git/tmux/claude, doesn't own the tmux
sessions). Instead the API enqueues a command and a worker in the control
container executes it and writes the result back.
Data model (migration 0003):
- `commands` queue/audit table; `forge_hosts` registry; `approvals` gains a
nullable approver id + `actor` so operator verdicts are first-class.
Control worker:
- `control/worker.py` claims commands and dispatches to the existing control
functions (spawn/kill/resume/record_approval/write_skills/poller.sweep),
plus a periodic CI sweep. New `handler worker` CLI subcommand; it becomes the
control image's default command (subsumes `poll-ci --watch`).
API:
- `require_admin` gate + `ADMIN_TOKEN`; project GET/PATCH/DELETE; agent
spawn/kill/delete; resume now enqueues (fixes a cross-container bug where the
API tried to send tmux keys to a session in the control container); new
approvals/commands/hosts routes; forge-init and poll-ci enqueue endpoints.
Credentials/hosts:
- host->token-env lookup consults the `forge_hosts` registry first (built-in
map is the fallback); `resolve()` refactored to a scheme dispatch reserving
`db:` for a future encrypted store. Web input restricts credential_ref to
env:/file:/db: (cmd: stays CLI-only — it would run arbitrary commands).
Dashboard:
- New tabs for projects, agents (spawn/kill with live command-status polling),
approvals, hosts, and an activity/audit view; shared context is now writable.
Tests: +33 (queue atomicity, worker dispatch, CRUD, hosts, admin gating,
cmd: rejection, host-aware credentials, and an API->queue->worker->spawn
end-to-end). README gains a Web management section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrhrBToauu4L2qG6jdnuFP
Add a no-build, same-origin web frontend so an operator can open a URL,
see every agent's state, and answer a paused question with no terminal
(Phase 3 DoD). The UI is a client of the existing API — no endpoint,
schema, or auth change — so the 106 existing tests pass unchanged.
- app.py serves the bundled UI from / and /static, gated on UI_ENABLED
(default on); optional CORS_ORIGINS (default empty => no middleware)
for hosting the UI on a separate origin. Dedicated /static prefix +
explicit / route so API routes are never shadowed. Zero new runtime
deps (StaticFiles/CORSMiddleware ship with Starlette).
- static/: vanilla fetch + plain CSS + vendored alpine.min.js (v3.14.8,
no CDN). Token captured once into localStorage; all API values render
via x-text (never x-html) to block agent-authored markup injection.
Project switcher, agent list, checkmark panel, paginated log, shared
feed, and Answer / Answer & Resume. Polling scoped to the selected
agent to avoid an N+1 over the fleet.
- config.py: ui_enabled, cors_origins (+ cors_origin_list); documented
in .env.example.
- tests/test_api_ui.py: serving, unauthenticated shell, non-shadowing
401 regression, CORS toggle, UI_ENABLED=false. 114 tests, ruff clean.
The static assets ship in the wheel by default (they live inside the
packaged src/handler tree) — no force-include needed.
Phase 2 configures forge for the agents (operator only sets a credential_ref +
optional version pin) and lets them drive a junior→senior→deploy workflow:
- Credential resolution/injection (control/credentials.py): credential_ref pointers
(env:/file:/cmd:) resolved only at spawn, injected as FORGE_TOKEN + host var, with a
forge-host-scoped git credential helper reading the token from env (never on disk / in
the DB). Resolution is a fail-fast spawn gate.
- Role-based forge skills committed into the managed repo (control/skills_gen.py,
`handler forge-init`): forge-junior/senior/deploy + a workflow overview.
- Hard approval gate (hooks/gate.py, approvals table, migration 0002): merge/deploy —
and direct pushes to protected branches — are denied unless a DIFFERENT agent has an
`approved` record for the branch, pinned to the reviewed commit (approved_sha). Senior
records verdicts via `handler approve`/`reject`.
- forge/git seams (control/forge.py, control/gitops.py) matching the Phase 1 seam pattern.
- CI status poller (control/poller.py, `handler poll-ci [--watch]`) backfilling
ci_status/ci_checked_at via `forge ci list`.
- Fix: migrations/env.py commits explicitly after run_migrations — pysqlite on Py 3.12+
was rolling back the final migration's DDL + alembic_version stamp (latent in Phase 1).
Reviewed via a separate code-reviewer pass; gate-bypass and credential-scoping findings
addressed. 106 tests, ruff clean, verified end-to-end against real git + migrations.
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