diff --git a/.gitignore b/.gitignore index e42ddb6..ff72444 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml .omc/ + +# Handler runtime artifacts +/handler.db diff --git a/README.md b/README.md index 51d3321..6ef589d 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ git remote, and your own network exposure. > control layer, HTTP API, database, migrations, and verification/approval hooks are > implemented and tested (106 tests, SQLite). Phase 2 adds credential resolution + > injection, role-based forge-workflow skills, a hard approval gate, and a CI-status -> poller. Live end-to-end agent spawning against a real `claude` binary + tmux is stubbed -> behind mockable seams (`tmux`, `verify`, `forge`, `gitops`, `spawn.resume`) and wired -> but not yet exercised against production binaries. See [`docs/PLAN.md`](docs/PLAN.md) -> for the full design and roadmap. +> poller. Agent runs are headless (`claude -p --output-format stream-json`, supervised +> by the worker, events persisted to the DB); the run/kill/resume paths are exercised +> end-to-end against a scripted fake claude binary, with a manual validation script +> (`scripts/validate_claude_headless.sh`) for the real one. See +> [`docs/PLAN.md`](docs/PLAN.md) for the full design and roadmap. --- @@ -45,19 +46,23 @@ the control layer and API are disposable compute that can restart or scale out f ``` writes reads (+ answer backfill) ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ - │ control layer │───────▶│ database │◀───────│ HTTP API │ + │ worker(s) │───────▶│ database │◀───────│ HTTP API │ │ (CLI + hooks) │ │ PG / SQLite │ │ (FastAPI) │ └──────────────────┘ └──────────────┘ └──────────────────┘ │ ▲ ▲ │ spawns │ Stop / PreToolUse / Notification hooks │ curl, UI, any client - ▼ │ write checkmark + log rows │ (bearer token) - tmux + claude binary (one working dir / worktree per agent) + ▼ │ + streamed run events, checkmark, log │ (bearer token) + claude -p --output-format stream-json (one working dir / worktree per agent) ``` -- **Control layer** (`handler.control`) — the only writer. Spawns/lists/attaches/kills - agents as `tmux` sessions running the `claude` binary, one working directory or git - worktree per agent, namespaced `project__agent`. Stateless; every write goes straight - to the database. +- **Control layer / workers** (`handler.control`) — the only writer. Runs each agent as + a **headless** `claude -p --output-format stream-json` subprocess (one working + directory or git worktree per agent), streams every stdout event into the database as + it happens, and reconciles agent status from the process itself (exit code + EOF — + positive liveness, no screen scraping). Stateless: repo state is pulled from git when + a task is claimed, claude session transcripts are archived to / materialized from the + DB for cross-worker `--resume`, and the claude login credential bundle is distributed + encrypted through the DB. tmux survives only to drive the interactive `/login` flow. - **Hooks** (`handler.hooks`) — run inside each agent via a generated `settings.json`. They write the checkpoint/log rows and enforce the test and push gates. - **API** (`handler.api`) — a thin, read-mostly HTTP layer over the same database (the @@ -77,10 +82,32 @@ The data model is defined once (SQLAlchemy Core) and renders correctly on both: Portable column types bridge the two, and the checkmark upsert uses native `INSERT … ON CONFLICT DO UPDATE` on both dialects. Migrations are Alembic, dual-dialect. +### Scaling workers horizontally + +Multiple worker containers can drain the same command queue concurrently (Postgres +`FOR UPDATE SKIP LOCKED`); each supervises up to `MAX_CONCURRENT_RUNS` claude processes +and skips claiming run-starting commands while full, leaving them for a less-loaded +worker. Workers heartbeat into the DB; if one dies mid-run, any surviving worker's +reaper marks its runs (and their agents) `crashed` — visible in the UI with the last +output preserved — and the operator resumes explicitly on whichever worker picks it up. + +Deployment invariants for multi-worker: + +- **No shared filesystems.** Git carries repo state (workers clone/pull on claim); + claude session transcripts live in `session_archives`; login credentials are + Fernet-encrypted into `runtime_secrets` and materialized by every worker. +- **Identical `PROJECTS_ROOT` on every worker** — claude keys its session storage to + the absolute working-dir path, so cross-worker `--resume` needs the same layout. +- **The same `HANDLER_SECRET_KEY` on every worker** (and the API) — without it, the + credential bundle can't be distributed and only the worker that ran `/login` can run + agents. +- The two-step web login is automatically pinned to one worker + (`commands.target_worker`), so it works unchanged with a fleet. + ## Requirements - Python 3.11+ -- `git` and `tmux` (for live spawning) +- `git` (for live spawning) and `tmux` (only for the web `/login` flow) - A `claude` binary, authenticated (for live spawning) - `mise` in each managed project, with a `.mise.toml` defining at least a `test` task - Postgres (default) — or nothing but a file path for the SQLite fallback diff --git a/frontend/components/sections/AgentsSection.tsx b/frontend/components/sections/AgentsSection.tsx index be18a88..19c4b61 100644 --- a/frontend/components/sections/AgentsSection.tsx +++ b/frontend/components/sections/AgentsSection.tsx @@ -144,11 +144,12 @@ export function AgentsSection() { - {a.status === "working" && a.last_output?.trim() && ( + {(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
)}
+ {/* Headless run event stream (empty for legacy tmux agents) */}
+ {agent?.session_id && (
+
+
+ Run events
+ {agent.worker_id ? (
+
+ on {agent.worker_id}
+
+ ) : null}
+
+ {s.events.length === 0 ? (
+ No events yet.
+ ) : (
+
+ {s.events.map((e) => (
+
+ ))}
+
+ )}
+
+ )}
+
{/* Log */}
Log · newest first
@@ -304,3 +340,99 @@ function RunDetail() {
>
);
}
+
+/* One stream-json event, rendered by type: assistant text as prose, tool calls as chips,
+ * the result as a cost/turns footer, worker notices as callouts, raw lines verbatim. */
+function EventLine({ e }: { e: AgentEvent }) {
+ const p = (e.payload ?? {}) as Record;
+ const xs = { fontSize: "var(--text-xs)" } as const;
+
+ if (e.type === "system") {
+ return (
+
+ ▸ session {p.subtype ?? "event"}
+ {p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""}
+ {Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""}
+
+ );
+ }
+ if (e.type === "assistant") {
+ const content = p.message?.content;
+ const blocks: any[] = Array.isArray(content) ? content : [];
+ const text = blocks
+ .filter((b) => b?.type === "text" && b.text)
+ .map((b) => b.text)
+ .join("\n");
+ const tools = blocks.filter((b) => b?.type === "tool_use");
+ return (
+
+ {text && (
+ {text}
+ )}
+ {tools.length > 0 && (
+
+ {tools.map((t, i) => (
+
+ {t.name}
+ {t.input ? `: ${oneLine(t.input)}` : ""}
+
+ ))}
+
+ )}
+
+ );
+ }
+ if (e.type === "result") {
+ const err = Boolean(p.is_error);
+ return (
+
+ {err ? "run errored" : "run finished"}
+
+ {p.num_turns != null ? `${p.num_turns} turns` : ""}
+ {p.total_cost_usd != null ? ` · $${Number(p.total_cost_usd).toFixed(4)}` : ""}
+
+ {typeof p.result === "string" && p.result && (
+
+ {p.result}
+
+ )}
+
+ );
+ }
+ if (e.type === "worker") {
+ return (
+
+ {p.notice ?? "runner notice"}
+ {p.stderr_tail ? (
+
+ {p.stderr_tail}
+
+ ) : null}
+
+ );
+ }
+ if (e.type === "raw") {
+ return (
+
+ {typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)}
+
+ );
+ }
+ // user (tool results) and anything future: a quiet one-liner, nothing lost, no noise.
+ return (
+
+ ▸ {e.type}
+
+ );
+}
+
+/* Compact single-line preview of a tool_use input object. */
+function oneLine(input: unknown): string {
+ const s =
+ typeof input === "string"
+ ? input
+ : (input as Record)?.command
+ ? String((input as Record).command)
+ : JSON.stringify(input);
+ return s.length > 80 ? `${s.slice(0, 77)}…` : s;
+}
diff --git a/frontend/components/store.tsx b/frontend/components/store.tsx
index aece0dd..62532d0 100644
--- a/frontend/components/store.tsx
+++ b/frontend/components/store.tsx
@@ -17,6 +17,7 @@ import {
AuthError,
createClient,
type Agent,
+ type AgentEvent,
type ApiError,
type Approval,
type Checkmark,
@@ -83,6 +84,9 @@ interface StoreValue {
log: LogEntry[];
logOffset: number;
pageLog: (dir: 1 | -1) => void;
+ /* Headless run event stream for the selected run, oldest-first, appended by cursor
+ * polls (empty for legacy tmux agents). */
+ events: AgentEvent[];
approvals: Approval[];
hosts: Host[];
@@ -204,6 +208,9 @@ export function DashboardProvider({
const [checkmarkMissing, setCheckmarkMissing] = useState(false);
const [log, setLog] = useState([]);
const [logOffset, setLogOffset] = useState(0);
+ const [events, setEvents] = useState([]);
+ const eventsRef = useRef([]);
+ eventsRef.current = events;
const [approvals, setApprovals] = useState([]);
const [hosts, setHosts] = useState([]);
const [commands, setCommands] = useState([]);
@@ -282,6 +289,17 @@ export function DashboardProvider({
} catch (e) {
swallow(e);
}
+ try {
+ // Cursor poll: only events newer than what we already hold come back.
+ const cur = eventsRef.current;
+ const after = cur.length ? cur[cur.length - 1].id : 0;
+ const fresh = await clientRef.current.api(
+ `${path}/events?after_id=${after}&limit=500`,
+ );
+ if (fresh.length) setEvents((prev) => [...prev, ...fresh]);
+ } catch (e) {
+ swallow(e);
+ }
}, []);
const loadApprovals = useCallback(async (projectId: string) => {
@@ -407,6 +425,8 @@ export function DashboardProvider({
setCheckmark(null);
setCheckmarkMissing(false);
setLog([]);
+ setEvents([]);
+ eventsRef.current = [];
void loadRun(projectId, name);
},
[loadRun],
@@ -925,6 +945,7 @@ export function DashboardProvider({
log,
logOffset,
pageLog,
+ events,
approvals,
hosts,
commands,
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index d54d333..1cd937b 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -30,10 +30,28 @@ export interface Agent {
working_dir: string;
status: string;
role?: string | null;
- /* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
- * agent is actually doing (and expose one wedged on an interactive prompt). */
+ /* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
+ * latest assistant text for headless runs. For a crashed agent this is the evidence
+ * frame — the last thing the process said. */
last_output?: string | null;
output_at?: string | null;
+ /* Headless runner: claude session UUID (null = legacy tmux agent) + supervising worker. */
+ session_id?: string | null;
+ worker_id?: string | null;
+ created_at: string;
+}
+
+/* One persisted stream-json event of a headless run (GET .../events, cursor-paged by id).
+ * `type` mirrors the stream (system/assistant/user/result) plus `worker` (runner notices)
+ * and `raw` (unparseable line kept verbatim). */
+export interface AgentEvent {
+ id: number;
+ agent_id: number;
+ run_id: number;
+ session_id?: string | null;
+ seq: number;
+ type: string;
+ payload?: Record | null;
created_at: string;
}
diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts
index e60fb80..05e219a 100644
--- a/frontend/lib/format.ts
+++ b/frontend/lib/format.ts
@@ -21,6 +21,7 @@ export function statusTone(status: string | null | undefined): Tone {
case "blocked":
case "rejected":
case "error":
+ case "crashed":
return "danger";
case "pending":
case "queued":
diff --git a/scripts/validate_claude_headless.sh b/scripts/validate_claude_headless.sh
new file mode 100755
index 0000000..ff46c7d
--- /dev/null
+++ b/scripts/validate_claude_headless.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# Phase-1 validation of the real `claude` binary's headless behavior (plan §0).
+# Run MANUALLY inside the control container (needs real claude + credentials); findings
+# go in the phase-1 PR description. Never wired into CI — this probes the real binary.
+#
+# CLAUDE_BIN=claude ./scripts/validate_claude_headless.sh /tmp/headless-validation
+#
+# Each check prints PASS/FAIL/INFO; the script keeps going so one failure doesn't hide
+# the rest. Items map to the plan's "must validate" list:
+# 1. --verbose requirement with -p --output-format stream-json
+# 2. exact stream-json event shapes for this CLI version
+# 3. --resume from a transcript materialized onto a clean $HOME (THE LINCHPIN)
+# 4. PreToolUse deny behavior under -p
+# 5. Stop-hook decision:block behavior under -p
+# 6. -p exit-code semantics + credential env var name
+
+set -uo pipefail
+
+CLAUDE_BIN="${CLAUDE_BIN:-claude}"
+WORK="${1:-/tmp/headless-validation}"
+SID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
+
+mkdir -p "$WORK/repo" "$WORK/home-a" "$WORK/home-b"
+cd "$WORK/repo"
+
+note() { printf '\n=== %s ===\n' "$*"; }
+result() { printf -- '--> %s\n' "$*"; }
+
+note "0. version"
+"$CLAUDE_BIN" --version
+
+note "1. does -p --output-format stream-json work WITHOUT --verbose?"
+if HOME="$WORK/home-a" "$CLAUDE_BIN" -p --output-format stream-json \
+ --session-id "$SID" -- 'Reply with the single word: ping' >"$WORK/no-verbose.out" 2>&1; then
+ result "PASS without --verbose (flag optional — keep passing it anyway)"
+else
+ result "FAIL without --verbose (required, as the runner assumes): $(tail -1 "$WORK/no-verbose.out")"
+fi
+
+note "2. event shapes (fresh run, --verbose)"
+SID2="$(python3 -c 'import uuid; print(uuid.uuid4())')"
+HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
+ --session-id "$SID2" -- 'Reply with the single word: pong' \
+ | tee "$WORK/stream.jsonl" \
+ | python3 -c '
+import json, sys
+for line in sys.stdin:
+ line = line.strip()
+ if not line: continue
+ try:
+ e = json.loads(line)
+ keys = ",".join(sorted(e.keys()))
+ print(f" type={e.get(\"type\")}/{e.get(\"subtype\")} keys=[{keys}]")
+ except Exception:
+ print(f" UNPARSEABLE: {line[:100]}")
+'
+result "exit code: $? — full stream saved to $WORK/stream.jsonl"
+
+note "3. cross-worker resume: materialize transcript onto a clean HOME (LINCHPIN)"
+MUNGED="$(python3 -c "import sys; print(sys.argv[1].replace('/', '-').replace('.', '-'))" "$WORK/repo")"
+SRC="$WORK/home-a/.claude/projects/$MUNGED"
+DST="$WORK/home-b/.claude/projects/$MUNGED"
+mkdir -p "$DST"
+if cp -r "$SRC/$SID2.jsonl" "$DST/" 2>/dev/null; then
+ [ -d "$SRC/$SID2" ] && cp -r "$SRC/$SID2" "$DST/"
+ if HOME="$WORK/home-b" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
+ --resume "$SID2" -- 'What word did you reply with before? Answer with just that word.' \
+ >"$WORK/resume.jsonl" 2>"$WORK/resume.err"; then
+ if grep -q 'pong' "$WORK/resume.jsonl"; then
+ result "PASS: resume on clean HOME retained context (found 'pong')"
+ else
+ result "PARTIAL: resume ran but context unclear — inspect $WORK/resume.jsonl"
+ fi
+ else
+ result "FAIL: resume on clean HOME errored — $(tail -1 "$WORK/resume.err") (fallback re-injection will be load-bearing)"
+ fi
+else
+ result "FAIL: no transcript found at $SRC — munge algorithm wrong for this version?"
+fi
+
+note "4. PreToolUse deny under -p"
+mkdir -p .claude
+cat > .claude/settings.json <<'EOF'
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {"type": "command",
+ "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"validation: always deny\"}}'"}
+ ]
+ }
+ ]
+ },
+ "permissions": {"defaultMode": "acceptEdits", "allow": ["Bash(echo *)"]}
+}
+EOF
+HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
+ --settings .claude/settings.json \
+ -- 'Run the bash command `echo hello` and tell me its output.' \
+ >"$WORK/deny.jsonl" 2>&1
+result "exit=$? — grep the stream for the deny reason:"
+grep -o 'validation: always deny' "$WORK/deny.jsonl" | head -1 \
+ && result "PASS: deny reason surfaced in stream" \
+ || result "INSPECT $WORK/deny.jsonl: deny reason not found"
+
+note "5. Stop hook decision:block under -p"
+cat > .claude/settings.json <<'EOF'
+{
+ "hooks": {
+ "Stop": [
+ {"hooks": [
+ {"type": "command",
+ "command": "if [ -f /tmp/.hv-stop-once ]; then echo '{}'; else touch /tmp/.hv-stop-once; echo '{\"decision\":\"block\",\"reason\":\"validation: say BLOCKED-ONCE then finish\"}'; fi"}
+ ]}
+ ]
+ }
+}
+EOF
+rm -f /tmp/.hv-stop-once
+HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
+ --settings .claude/settings.json -- 'Reply with the word: initial' \
+ >"$WORK/stopblock.jsonl" 2>&1
+result "exit=$? — expect a second turn mentioning BLOCKED-ONCE:"
+grep -o 'BLOCKED-ONCE' "$WORK/stopblock.jsonl" | head -1 \
+ && result "PASS: Stop block re-prompted under -p" \
+ || result "INSPECT $WORK/stopblock.jsonl: no evidence of re-prompt"
+rm -f /tmp/.hv-stop-once
+
+note "6. exit codes"
+HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
+ --resume "$(python3 -c 'import uuid; print(uuid.uuid4())')" -- 'x' \
+ >"$WORK/badresume.out" 2>&1
+result "resume of nonexistent session exit=$? (runner treats nonzero-before-assistant as resume failure)"
+result "INFO: check credential env name with: $CLAUDE_BIN setup-token --help (expect CLAUDE_CODE_OAUTH_TOKEN)"
+
+note "done — artifacts in $WORK"
diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py
index 7de33e3..0a0a46e 100644
--- a/src/handler/api/routes/agents.py
+++ b/src/handler/api/routes/agents.py
@@ -14,7 +14,15 @@ from sqlalchemy.exc import IntegrityError
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
-from ..schemas import AgentIn, AgentOut, CheckmarkOut, CommandOut, LogEntryOut, SpawnIn
+from ..schemas import (
+ AgentEventOut,
+ AgentIn,
+ AgentOut,
+ CheckmarkOut,
+ CommandOut,
+ LogEntryOut,
+ SpawnIn,
+)
from .common import resolve_agent
router = APIRouter(
@@ -63,13 +71,21 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn
dependencies=[Depends(require_admin)],
)
def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_conn)) -> dict:
- """Enqueue a spawn; the worker creates the agent row + tmux session and reports back."""
+ """Enqueue a spawn; the worker creates the agent row + claude process and reports back."""
_require_project(conn, project)
if repo.get_agent_by_name(conn, project, body.name) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=f"agent '{body.name}' already exists in project '{project}'",
)
+ if not body.task:
+ # A headless `claude -p` run with no prompt exits immediately having done
+ # nothing. Reject here (400) instead of letting the command fail asynchronously
+ # in the worker.
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail="a task is required: the headless runner has no idle-REPL mode",
+ )
payload = body.model_dump(exclude={"name"}, exclude_none=True)
return repo.enqueue_command(
conn,
@@ -114,6 +130,23 @@ def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn))
return checkmark
+@router.get("/{name}/events", response_model=list[AgentEventOut])
+def get_events(
+ project: str,
+ name: str,
+ after_id: int = Query(0, ge=0),
+ limit: int = Query(200, ge=1, le=1000),
+ conn: Connection = Depends(db_conn),
+) -> list[dict]:
+ """The headless run event stream, oldest-first, cursor-paged by row id.
+
+ The UI polls with ``after_id`` = the largest id it has seen, so each poll returns
+ only new events (an empty list for a legacy tmux agent or an idle one).
+ """
+ agent = resolve_agent(conn, project, name)
+ return repo.list_agent_events(conn, agent["id"], after_id=after_id, limit=limit)
+
+
@router.get("/{name}/log", response_model=list[LogEntryOut])
def get_log(
project: str,
diff --git a/src/handler/api/routes/login.py b/src/handler/api/routes/login.py
index a682d02..fbec472 100644
--- a/src/handler/api/routes/login.py
+++ b/src/handler/api/routes/login.py
@@ -43,10 +43,18 @@ def enqueue_login_start(conn: Connection = Depends(db_conn)) -> dict:
dependencies=[Depends(require_admin)],
)
def enqueue_login_submit(body: LoginSubmitIn, conn: Connection = Depends(db_conn)) -> dict:
- """Feed the pasted authorization code back into the waiting login session."""
+ """Feed the pasted authorization code back into the waiting login session.
+
+ Pinned to the worker that ran ``login_start`` — the live tmux login session exists
+ only in that container, so with multiple workers any other claimant would find
+ nothing to paste into. No prior login_start leaves the pin empty (single-worker
+ deployments behave exactly as before).
+ """
+ started = repo.get_latest_finished_command(conn, "login_start")
return repo.enqueue_command(
conn,
"login_submit",
payload={"code": body.code},
requested_by="operator:web",
+ target_worker=started["claimed_by"] if started else None,
)
diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py
index cbf825b..29fe621 100644
--- a/src/handler/api/schemas.py
+++ b/src/handler/api/schemas.py
@@ -128,10 +128,14 @@ class AgentOut(BaseModel):
working_dir: str
status: str
role: Role | None = None
- # Latest tmux pane-tail snapshot (worker poll loop) so the UI can show what a running
- # agent is doing — including one wedged on an interactive prompt no one can answer.
+ # Latest output snapshot so the UI can show what a running agent is doing: the tmux
+ # pane tail for legacy agents, the latest assistant text for headless runs.
last_output: str | None = None
output_at: datetime | None = None
+ # Headless runner: the claude session UUID (null = legacy tmux agent) and the worker
+ # container supervising (or last to supervise) this agent's runs.
+ session_id: str | None = None
+ worker_id: str | None = None
created_at: datetime
@@ -306,6 +310,25 @@ class LogEntryOut(BaseModel):
ci_checked_at: datetime | None = None
+class AgentEventOut(BaseModel):
+ """One persisted stream-json event of a headless run (the UI's live log panel).
+
+ ``type`` mirrors the stream's top-level type (system/assistant/user/result), plus
+ ``worker`` (runner-generated notices) and ``raw`` (unparseable line, kept verbatim).
+ """
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ agent_id: int
+ run_id: int
+ session_id: str | None = None
+ seq: int
+ type: str
+ payload: dict | None = None
+ created_at: datetime
+
+
class AnswerIn(BaseModel):
answer: str
# If omitted, the answer targets the agent's latest open question.
diff --git a/src/handler/api/static/404.html b/src/handler/api/static/404.html
index cc28808..7ebb5db 100644
--- a/src/handler/api/static/404.html
+++ b/src/handler/api/static/404.html
@@ -1 +1 @@
-404: This page could not be found. Handler · Claude Activity 404
This page could not be found.
\ No newline at end of file
+404: This page could not be found. Handler · Claude Activity 404
This page could not be found.
\ No newline at end of file
diff --git a/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js b/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js
deleted file mode 100644
index 942cb25..0000000
--- a/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js
+++ /dev/null
@@ -1 +0,0 @@
-(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3156:function(n,e,u){Promise.resolve().then(u.t.bind(u,7960,23))},7960:function(){}},function(n){n.O(0,[587,971,117,744],function(){return n(n.s=3156)}),_N_E=n.O()}]);
\ No newline at end of file
diff --git a/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js b/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js
new file mode 100644
index 0000000..607d9eb
--- /dev/null
+++ b/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js
@@ -0,0 +1 @@
+(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{2385:function(n,e,u){Promise.resolve().then(u.t.bind(u,7960,23))},7960:function(){}},function(n){n.O(0,[587,971,117,744],function(){return n(n.s=2385)}),_N_E=n.O()}]);
\ No newline at end of file
diff --git a/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js b/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js
new file mode 100644
index 0000000..493e613
--- /dev/null
+++ b/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js
@@ -0,0 +1 @@
+(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[931],{3963:function(e,t,s){Promise.resolve().then(s.bind(s,9859))},9859:function(e,t,s){"use strict";s.r(t),s.d(t,{default:function(){return er}});var a,n=s(7437),r=s(2265);let l=null!==(a=s(257).env.NEXT_PUBLIC_API_BASE)&&void 0!==a?a:"";class i extends Error{constructor(e="unauthorized"){super(e),this.name="AuthError"}}let c=(0,r.createContext)(null);function o(){let e=(0,r.useContext)(c);if(!e)throw Error("useDashboard outside provider");return e}function d(e){let{token:t,onUnauthorized:s,children:a}=e,o=(0,r.useMemo)(()=>(function(e,t){async function s(s,a){var n;let r=(null==a?void 0:a.body)!==void 0&&(null==a?void 0:a.body)!==null,c=await fetch(l+s,{method:null!==(n=null==a?void 0:a.method)&&void 0!==n?n:r?"POST":"GET",headers:{Authorization:"Bearer ".concat(e),...r?{"Content-Type":"application/json"}:{}},body:r?JSON.stringify(a.body):void 0});if(401===c.status)throw t(),new i;if(!c.ok){let e=c.statusText;try{let t=await c.json();t&&void 0!==t.detail&&(e="string"==typeof t.detail?t.detail:JSON.stringify(t.detail))}catch(e){}let t=Error(e);throw t.status=c.status,t}if(204===c.status)return;let o=await c.text();return o?JSON.parse(o):void 0}async function a(e,t){var a,n;let r=null!==(a=null==t?void 0:t.attempts)&&void 0!==a?a:60,l=null!==(n=null==t?void 0:t.intervalMs)&&void 0!==n?n:500;for(let t=0;tsetTimeout(e,l))}return null}return{api:s,trackCommand:a}})(t,s),[t,s]),d=(0,r.useRef)(o);d.current=o;let[u,h]=(0,r.useState)("runs"),[m,p]=(0,r.useState)([]),[x,v]=(0,r.useState)([]),[j,g]=(0,r.useState)(""),[y,f]=(0,r.useState)(null),[b,k]=(0,r.useState)(null),[w,N]=(0,r.useState)(!1),[_,C]=(0,r.useState)([]),[S,R]=(0,r.useState)(0),[I,T]=(0,r.useState)([]),A=(0,r.useRef)([]);A.current=I;let[z,E]=(0,r.useState)([]),[P,O]=(0,r.useState)([]),[M,L]=(0,r.useState)([]),[D,U]=(0,r.useState)([]),[F,B]=(0,r.useState)({log:[],context:[]}),[H,W]=(0,r.useState)({text:"",error:!1,busy:!1}),[q,G]=(0,r.useState)(""),[J,K]=(0,r.useState)(!0),[Y,V]=(0,r.useState)({status:"idle",url:"",message:""}),Q=(0,r.useRef)(u);Q.current=u;let X=(0,r.useRef)(j);X.current=j;let $=(0,r.useRef)(y);$.current=y;let Z=(0,r.useRef)(S);Z.current=S;let ee=e=>{e instanceof i||G(e.message)},et=(0,r.useCallback)(async()=>{try{let e=await d.current.api("/projects");p(e),G(""),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")})}catch(e){ee(e)}},[]),es=(0,r.useCallback)(async e=>{try{let t=await Promise.all(e.map(e=>d.current.api("/projects/".concat(encodeURIComponent(e.id),"/agents")).catch(()=>[])));v(t.flat())}catch(e){ee(e)}},[]),ea=(0,r.useCallback)(async(e,t)=>{let s="/projects/".concat(encodeURIComponent(e),"/agents/").concat(encodeURIComponent(t));try{let e=await d.current.api("".concat(s,"/checkmark"));k(e),N(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),N(!0)):ee(e)}try{let e=await d.current.api("".concat(s,"/log?limit=").concat(100,"&offset=").concat(Z.current));C(e)}catch(e){ee(e)}try{let e=A.current,t=e.length?e[e.length-1].id:0,a=await d.current.api("".concat(s,"/events?after_id=").concat(t,"&limit=500"));a.length&&T(e=>[...e,...a])}catch(e){ee(e)}},[]),en=(0,r.useCallback)(async e=>{if(!e){E([]);return}try{E(await d.current.api("/projects/".concat(encodeURIComponent(e),"/approvals")))}catch(e){ee(e)}},[]),er=(0,r.useCallback)(async()=>{try{O(await d.current.api("/hosts"))}catch(e){ee(e)}},[]),el=(0,r.useCallback)(async()=>{try{L(await d.current.api("/commands?limit=50"))}catch(e){ee(e)}},[]),ei=(0,r.useCallback)(async()=>{try{U(await d.current.api("/schedules"))}catch(e){ee(e)}},[]),ec=(0,r.useCallback)(async()=>{try{let[e,t]=await Promise.all([d.current.api("/shared/log"),d.current.api("/shared/context")]);B({log:e,context:t})}catch(e){ee(e)}},[]),eo=(0,r.useCallback)(async()=>{let e=await d.current.api("/projects").catch(e=>(ee(e),null));e&&(p(e),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")}),await es(e));let t=Q.current,s=$.current;s&&await ea(s.projectId,s.name),"approvals"===t&&await en(X.current),"servers"===t&&await er(),"activity"===t&&await el(),"schedules"===t&&await ei(),"shared"===t&&await ec()},[es,ea,en,er,el,ei,ec]);(0,r.useEffect)(()=>{let e=!0;(async()=>{K(!0),await eo(),e&&K(!1)})();let t=setInterval(()=>{document.hidden||eo()},5e3);return()=>{e=!1,clearInterval(t)}},[eo]);let ed=(0,r.useCallback)(e=>{h(e),W({text:"",error:!1,busy:!1}),"approvals"===e&&en(X.current),"servers"===e&&er(),"activity"===e&&el(),"schedules"===e&&ei(),"shared"===e&&ec()},[en,er,el,ei,ec]),eu=(0,r.useCallback)(e=>{g(e),"approvals"===Q.current&&en(e)},[en]),eh=(0,r.useCallback)((e,t)=>{f({projectId:e,name:t}),R(0),Z.current=0,k(null),N(!1),C([]),T([]),A.current=[],ea(e,t)},[ea]),em=(0,r.useCallback)(e=>{let t=Math.max(0,S+100*e);if(t===S)return;R(t),Z.current=t;let s=$.current;s&&ea(s.projectId,s.name)},[S,ea]),ep=(0,r.useCallback)(()=>{eo()},[eo]),ex=(0,r.useCallback)(async(e,t,s)=>{W({text:"".concat(s,": queued…"),error:!1,busy:!0});try{let a=await d.current.api(e,{method:"POST",body:t}),n=await d.current.trackCommand(a.id);if(!n)return W({text:"".concat(s,": still running (see Activity). Is the worker up?"),error:!1,busy:!1}),null;let r="done"===n.status,l=n.error||(n.result?JSON.stringify(n.result):"");return W({text:"".concat(s," ").concat(r?"done":"failed").concat(l?" — "+l:""),error:!r,busy:!1}),n}catch(e){if(e instanceof i)return null;return W({text:"".concat(s," failed: ").concat(e.message),error:!0,busy:!1}),null}},[]),ev=(0,r.useCallback)(async e=>{let t={name:e.name.trim(),role:e.role||null,task:e.task.trim()||null};"worktree"===e.placement&&e.worktree.trim()&&(t.worktree=e.worktree.trim()),"subdir"===e.placement&&e.subdir.trim()&&(t.subdir=e.subdir.trim());let s=encodeURIComponent(X.current),a=await ex("/projects/".concat(s,"/agents/spawn"),t,"spawn ".concat(t.name));return await es(m),(null==a?void 0:a.status)==="done"},[ex,es,m]),ej=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);await ex("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t),"/kill"),void 0,"kill ".concat(t)),await es(m)},[ex,es,m]),eg=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);try{var a;await d.current.api("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t)),{method:"DELETE"}),W({text:"agent '".concat(t,"' row deleted"),error:!1,busy:!1}),(null===(a=$.current)||void 0===a?void 0:a.name)===t&&f(null),await es(m)}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[es,m]),ey=(0,r.useCallback)(async(e,t)=>{let s=$.current;if(!s)return!1;let a="/projects/".concat(encodeURIComponent(s.projectId),"/agents/").concat(encodeURIComponent(s.name));try{return await d.current.api("".concat(a,"/answer"),{method:"POST",body:{answer:e}}),t?await ex("".concat(a,"/resume"),{answer:e},"resume"):W({text:"Answer saved (agent still paused).",error:!1,busy:!1}),await es(m),await ea(s.projectId,s.name),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ex,es,ea,m]),ef=(0,r.useCallback)(async e=>{try{var t,s;let a="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise},n=await d.current.api("/projects",{method:"POST",body:a});if(await et(),null!=n.sync_command_id){W({text:"repository '".concat(n.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.sync_command_id);e?"done"===e.status?W({text:"repository '".concat(n.id,"' registered and cloned"),error:!1,busy:!1}):W({text:"repository '".concat(n.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1}):W({text:"repository '".concat(n.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else W({text:"repository '".concat(n.id,"' registered"),error:!1,busy:!1});if(null!=n.mise_init_command_id){W({text:"repository '".concat(n.id,"': launching a mise-init agent to create .mise.toml…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.mise_init_command_id);e?"done"===e.status?W({text:"repository '".concat(n.id,"' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs)."),error:!1,busy:!1}):W({text:"repository '".concat(n.id,"' registered but the mise-init agent failed to launch — ").concat(null!==(s=e.error)&&void 0!==s?s:""),error:!0,busy:!1}):W({text:"repository '".concat(n.id,"' registered; mise-init still starting (see Activity). Is the worker up?"),error:!1,busy:!1})}return!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[et]),eb=(0,r.useCallback)(async e=>{await ex("/projects/".concat(encodeURIComponent(e),"/sync"),void 0,"pull ".concat(e))},[ex]),ek=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"PATCH",body:{root_dir:t.root_dir.trim(),git_remote:t.git_remote.trim()||null,credential_ref:t.credential_ref.trim()||null}}),W({text:"repository '".concat(e,"' updated"),error:!1,busy:!1}),await et(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[et]),ew=(0,r.useCallback)(async e=>{try{await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"DELETE"}),W({text:"repository '".concat(e,"' removed"),error:!1,busy:!1}),g(t=>t===e?"":t),await et()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[et]),eN=(0,r.useCallback)(async e=>{let t=encodeURIComponent(X.current);await ex("/projects/".concat(t,"/approvals"),{branch:e.branch.trim(),status:e.status,agent_name:e.agent_name.trim()||null,sha:e.sha.trim()||null,note:e.note.trim()||null},"".concat(e.status," ").concat(e.branch)),await en(X.current)},[ex,en]),e_=(0,r.useCallback)(async e=>{try{return await d.current.api("/hosts",{method:"POST",body:{hostname:e.hostname.trim(),forge_type:e.forge_type,token_env_var:e.token_env_var.trim()||null,base_url:e.base_url.trim()||null,token:e.token.trim()||null,generate_ssh_key:e.generate_ssh_key}}),W({text:"git server '".concat(e.hostname,"' added"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[er]),eC=(0,r.useCallback)(async(e,t)=>{try{let s={forge_type:t.forge_type,token_env_var:t.token_env_var.trim()||null,base_url:t.base_url.trim()||null};return t.token.trim()&&(s.token=t.token.trim()),t.generate_ssh_key&&(s.regenerate_ssh_key=!0),await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"PATCH",body:s}),W({text:"git server '".concat(e,"' updated"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[er]),eS=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e),"/schedules"),{method:"POST",body:{name_prefix:t.name_prefix.trim(),task:t.task.trim(),interval_seconds:t.interval_seconds,role:t.role||null}}),W({text:"schedule '".concat(t.name_prefix,"' created — first run on the worker's next pass"),error:!1,busy:!1}),await ei(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ei]),eR=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/schedules/".concat(e),{method:"PATCH",body:t}),await ei(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ei]),eI=(0,r.useCallback)(async e=>{try{await d.current.api("/schedules/".concat(e),{method:"DELETE"}),W({text:"schedule ".concat(e," removed"),error:!1,busy:!1}),await ei()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[ei]),eT=(0,r.useCallback)(async e=>{try{await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"DELETE"}),W({text:"git server '".concat(e,"' removed"),error:!1,busy:!1}),await er()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[er]),eA=(0,r.useCallback)(async()=>{await ex("/poll-ci",void 0,"poll-ci (all projects)"),await el()},[ex,el]),ez=(0,r.useCallback)(async()=>{V({status:"starting",url:"",message:"Opening `claude /login` in the control container and selecting the subscription account…"});try{let e=await d.current.api("/login/start",{method:"POST"}),t=await d.current.trackCommand(e.id,{attempts:180});if(!t){V({status:"error",url:"",message:"Still starting (see Activity). Is the control worker running?"});return}if("done"!==t.status){V({status:"error",url:"",message:t.error||"Failed to start login."});return}let s=t.result&&"string"==typeof t.result.url?t.result.url:"";if(!s){V({status:"error",url:"",message:"No login URL was returned by claude."});return}V({status:"awaiting",url:s,message:"Authorize in the window below (or open it in a new tab), then paste the code claude gives you."})}catch(e){if(e instanceof i)return;V({status:"error",url:"",message:e.message})}},[]),eE=(0,r.useCallback)(async e=>{let t=e.trim();if(!t)return!1;V(e=>({...e,status:"submitting",message:"Submitting the authorization code…"}));try{let e=await d.current.api("/login/submit",{method:"POST",body:{code:t}}),s=await d.current.trackCommand(e.id,{attempts:60});if(!s)return V(e=>({...e,status:"awaiting",message:"Submit still running (see Activity). Is the control worker running?"})),!1;if("done"===s.status)return V({status:"done",url:"",message:"Claude Code is now logged in on the host — new agents will use this account."}),!0;return V(e=>({...e,status:"awaiting",message:s.error||"Login was not confirmed. Re-check the code, or restart the flow."})),!1}catch(e){if(e instanceof i)return!1;return V(t=>({...t,status:"awaiting",message:e.message})),!1}},[]),eP=(0,r.useCallback)(()=>{V({status:"idle",url:"",message:""})},[]),eO=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/shared/context/".concat(encodeURIComponent(e)),{method:"PUT",body:{value:t}}),W({text:"shared context '".concat(e,"' set"),error:!1,busy:!1}),await ec(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ec]);return(0,n.jsx)(c.Provider,{value:{section:u,setSection:ed,projects:m,agents:x,selectedProjectId:j,selectProject:eu,selectedRun:y,selectRun:eh,checkmark:b,checkmarkMissing:w,log:_,logOffset:S,pageLog:em,events:I,approvals:z,hosts:P,commands:M,schedules:D,shared:F,cmd:H,lastError:q,loading:J,refresh:ep,spawnAgent:ev,killAgent:ej,deleteAgent:eg,submitAnswer:ey,createProject:ef,updateProject:ek,deleteProject:ew,syncProject:eb,submitApproval:eN,createHost:e_,updateHost:eC,deleteHost:eT,createSchedule:eS,updateSchedule:eR,deleteSchedule:eI,pollCi:eA,setSharedKey:eO,claudeLogin:Y,startClaudeLogin:ez,submitClaudeCode:eE,resetClaudeLogin:eP},children:a})}function u(e){switch((null!=e?e:"").toLowerCase()){case"pass":case"done":case"completed":case"approved":case"success":return"success";case"fail":case"failed":case"blocked":case"rejected":case"error":case"crashed":return"danger";case"pending":case"queued":case"running":case"working":case"paused_for_input":return"warning";case"not_applicable":case"unknown":case"":return"neutral";default:return"info"}}let h={paused_for_input:"Needs input",not_applicable:"N/A"};function m(e){if(!e)return"—";let t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function p(e){return e?e.slice(0,7):"—"}function x(e){if(!e)return"—";let t=new Date(e).getTime();if(Number.isNaN(t))return"—";let s=Math.max(0,Math.floor((Date.now()-t)/1e3));if(s<60)return"".concat(s,"s");let a=Math.floor(s/60);if(a<60)return"".concat(a,"m");let n=Math.floor(a/60);if(n<24)return"".concat(n,"h");let r=Math.floor(n/24);if(r<30)return"".concat(r,"d");let l=Math.floor(r/30);return l<12?"".concat(l,"mo"):"".concat(Math.floor(l/12),"y")}function v(e){let{tone:t="neutral",pill:s=!1,dot:a=!1,children:r}=e;return(0,n.jsxs)("span",{className:"badge badge-".concat(t).concat(s?" pill":""),children:[a&&(0,n.jsx)("span",{className:"dot"}),r]})}function j(e){let{status:t}=e;return(0,n.jsx)(v,{tone:u(t),children:function(e){let t=(null!=e?e:"").trim();if(!t)return"—";let s=t.toLowerCase();return h[s]?h[s]:s.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())}(t)})}function g(e){let{children:t,interactive:s=!1,onClick:a,className:r=""}=e;return(0,n.jsx)("div",{className:"card".concat(s?" interactive":""," ").concat(r).trim(),onClick:a,role:s?"button":void 0,tabIndex:s?0:void 0,children:t})}function y(e){let{variant:t="secondary",size:s="md",onClick:a,disabled:r,type:l="button",children:i}=e;return(0,n.jsx)("button",{type:l,className:"btn btn-".concat(t).concat("sm"===s?" btn-sm":""),onClick:a,disabled:r,children:i})}function f(e){let{label:t,children:s}=e;return(0,n.jsxs)("label",{className:"field",children:[t&&(0,n.jsx)("span",{className:"field-label",children:t}),s]})}function b(e){let{label:t,value:s,onChange:a,placeholder:r,type:l="text",disabled:i}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("input",{className:"input",type:l,value:s,placeholder:r,disabled:i,onChange:e=>a(e.target.value)})})}function k(e){let{label:t,value:s,onChange:a,placeholder:r,rows:l=3}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("textarea",{className:"textarea",value:s,rows:l,placeholder:r,onChange:e=>a(e.target.value)})})}function w(e){let{label:t,value:s,onChange:a,options:r}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("select",{className:"select",value:s,onChange:e=>a(e.target.value),children:r.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value))})})}function N(e){let{tabs:t,value:s,onChange:a}=e;return(0,n.jsx)("div",{className:"tabs",role:"tablist",children:t.map(e=>(0,n.jsx)("button",{role:"tab","aria-selected":s===e.value,className:"tab".concat(s===e.value?" active":""),onClick:()=>a(e.value),children:e.label},e.value))})}function _(e){let{value:t,label:s,sub:a,accent:r=!1}=e;return(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"stat-value".concat(r?" accent":""),children:t}),(0,n.jsx)("div",{className:"stat-label",children:s}),a&&(0,n.jsx)("div",{className:"stat-sub",children:a})]})}function C(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function S(e){let{on:t,onClick:s}=e;return(0,n.jsx)("button",{type:"button",className:"toggle".concat(t?" on":""),"aria-pressed":t,onClick:s,children:(0,n.jsx)("span",{className:"knob"})})}let R=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"},{value:"crashed",label:"Crashed"}];function I(){let e=o(),[t,s]=(0,r.useState)("all"),a=(0,r.useMemo)(()=>[...e.agents.filter(e=>{var s;return s=e.status,"all"===t||("needs"===t?"paused_for_input"===s:"working"===t?"working"===s||"running"===s:"done"===t?"done"===s||"completed"===s:"crashed"!==t||"crashed"===s||"blocked"===s)})].sort((e,t)=>e.created_at"paused_for_input"===e.status).length,c=e.agents.filter(e=>"working"===e.status||"running"===e.status).length;return(0,n.jsxs)("div",{className:"runs",children:[(0,n.jsx)("div",{className:"runs-stats",children:(0,n.jsxs)("div",{className:"stat-row",children:[(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.projects.length,label:"Repositories"})})]})}),(0,n.jsxs)("div",{className:"split",children:[(0,n.jsxs)("div",{className:"split-list",children:[(0,n.jsxs)("div",{className:"split-list-head",children:[(0,n.jsx)("div",{className:"section-title",style:{fontSize:"var(--text-lg)"},children:"Runs"}),(0,n.jsx)(N,{tabs:R,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(C,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(T,{agent:t,selected:(null==l?void 0:l.projectId)===t.project_id&&(null==l?void 0:l.name)===t.name,onSelect:()=>e.selectRun(t.project_id,t.name)},"".concat(t.project_id,"/").concat(t.name)))]})]}),(0,n.jsx)("div",{className:"split-detail",children:l?(0,n.jsx)(z,{}):(0,n.jsx)(A,{})})]})]})}function T(e){let{agent:t,selected:s,onSelect:a}=e;return(0,n.jsxs)("button",{className:"run-row".concat(s?" selected":""),onClick:a,children:[(0,n.jsxs)("div",{className:"run-row-top",children:[(0,n.jsx)("span",{className:"run-project",children:t.project_id}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:x(t.created_at)})]}),(0,n.jsxs)("div",{className:"truncate muted",style:{fontSize:"var(--text-sm)"},children:[t.name,t.role?" \xb7 ".concat(t.role):""]}),(0,n.jsx)("div",{className:"hstack",style:{gap:8},children:(0,n.jsx)(j,{status:t.status})})]})}function A(){return(0,n.jsx)("div",{style:{padding:"60px 32px",color:"var(--text-muted)"},children:"Select a run to see its checkmark, log, and any open question."})}function z(){var e;let t=o(),s=t.selectedRun,a=t.agents.find(e=>e.project_id===s.projectId&&e.name===s.name),l=t.checkmark,[i,c]=(0,r.useState)(""),[d,h]=(0,r.useState)(!1),x=(null==a?void 0:a.status)==="paused_for_input",g=async e=>{if(!i.trim())return;h(!0);let s=await t.submitAnswer(i.trim(),e);h(!1),s&&c("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{style:{padding:"24px 28px",borderBottom:"1px solid var(--border-default)",display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)("span",{style:{color:"var(--accent)",fontWeight:"var(--fw-bold)",fontSize:"var(--text-xl)"},children:s.projectId}),(0,n.jsx)("span",{className:"faint",children:"/"}),(0,n.jsx)("span",{style:{color:"var(--text-heading)",fontWeight:"var(--fw-semibold)",fontSize:"var(--text-lg)"},children:s.name}),(0,n.jsx)(j,{status:null==a?void 0:a.status}),(null==a?void 0:a.role)&&(0,n.jsx)(v,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>t.deleteAgent(s.projectId,s.name),children:"Delete row"})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:[null!==(e=null==a?void 0:a.working_dir)&&void 0!==e?e:"—"," \xb7 created ",m(null==a?void 0:a.created_at)]})]}),(0,n.jsxs)("div",{style:{padding:"20px 28px",display:"flex",flexDirection:"column",gap:16},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Checkmark"}),t.checkmarkMissing&&(0,n.jsx)(C,{tone:"info",children:"No checkpoint recorded yet."}),l&&!t.checkmarkMissing&&(0,n.jsxs)("dl",{className:"kv",children:[(0,n.jsx)("dt",{children:"Status"}),(0,n.jsx)("dd",{children:(0,n.jsx)(j,{status:l.status})}),(0,n.jsx)("dt",{children:"Where it stopped"}),(0,n.jsx)("dd",{children:l.where_it_stopped||"—"}),(0,n.jsx)("dt",{children:"Open question"}),(0,n.jsx)("dd",{children:l.open_question||"—"}),(0,n.jsx)("dt",{children:"Next steps"}),(0,n.jsx)("dd",{children:l.next_steps&&l.next_steps.length>0?(0,n.jsx)("ul",{children:l.next_steps.map((e,t)=>(0,n.jsx)("li",{children:e},t))}):"—"}),(0,n.jsx)("dt",{children:"Tests"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(v,{tone:u(l.tests_status),children:l.tests_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.tested_at?m(l.tested_at):""})]}),(0,n.jsx)("dt",{children:"Build"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(v,{tone:u(l.build_status),children:l.build_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.built_at?m(l.built_at):""})]}),(0,n.jsx)("dt",{children:"Checkpoint at"}),(0,n.jsx)("dd",{className:"faint",children:m(l.checkpoint_at)})]})]}),x&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Answer this question"}),(0,n.jsx)(C,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(k,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>g(!1),children:"Answer"}),(0,n.jsx)(y,{variant:"primary",disabled:d||!i.trim(),onClick:()=>g(!0),children:"Answer & Resume"})]})]}),(null==a?void 0:a.session_id)&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"eyebrow",children:["Run events",a.worker_id?(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)",marginLeft:8},children:["on ",a.worker_id]}):null]}),0===t.events.length?(0,n.jsx)("div",{className:"empty",children:"No events yet."}):(0,n.jsx)("div",{style:{display:"flex",flexDirection:"column",gap:6,maxHeight:420,overflow:"auto",padding:"10px 12px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6},children:t.events.map(e=>(0,n.jsx)(E,{e:e},e.id))})]}),(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Log \xb7 newest first"}),0===t.log.length?(0,n.jsx)("div",{className:"empty",children:"No log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"Q / A"}),(0,n.jsx)("th",{children:"Push"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:t.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsxs)("td",{children:[e.question&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"Q:"})," ",e.question]}),e.answer&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"A:"})," ",e.answer]}),!e.question&&!e.answer&&"—"]}),(0,n.jsx)("td",{className:"mono",children:p(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{tone:u(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:0===t.logOffset,onClick:()=>t.pageLog(-1),children:"‹ Newer"}),(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["offset ",t.logOffset]}),(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}function E(e){var t,s,a,r;let{e:l}=e,i=null!==(t=l.payload)&&void 0!==t?t:{},c={fontSize:"var(--text-xs)"};if("system"===l.type)return(0,n.jsxs)("div",{className:"faint mono",style:c,children:["▸ session ",null!==(s=i.subtype)&&void 0!==s?s:"event",i.session_id?" \xb7 ".concat(String(i.session_id).slice(0,8)):"",Array.isArray(i.tools)?" \xb7 ".concat(i.tools.length," tools"):""]});if("assistant"===l.type){let e=null===(a=i.message)||void 0===a?void 0:a.content,t=Array.isArray(e)?e:[],s=t.filter(e=>(null==e?void 0:e.type)==="text"&&e.text).map(e=>e.text).join("\n"),r=t.filter(e=>(null==e?void 0:e.type)==="tool_use");return(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:4},children:[s&&(0,n.jsx)("div",{style:{fontSize:"var(--text-sm)",whiteSpace:"pre-wrap"},children:s}),r.length>0&&(0,n.jsx)("div",{className:"hstack",style:{gap:6,flexWrap:"wrap"},children:r.map((e,t)=>(0,n.jsxs)(v,{tone:"info",children:[e.name,e.input?": ".concat(function(e){let t="string"==typeof e?e:(null==e?void 0:e.command)?String(e.command):JSON.stringify(e);return t.length>80?"".concat(t.slice(0,77),"…"):t}(e.input)):""]},t))})]})}if("result"===l.type){let e=!!i.is_error;return(0,n.jsxs)("div",{className:"hstack",style:{gap:8,flexWrap:"wrap"},children:[(0,n.jsx)(v,{tone:e?"danger":"success",children:e?"run errored":"run finished"}),(0,n.jsxs)("span",{className:"faint mono",style:c,children:[null!=i.num_turns?"".concat(i.num_turns," turns"):"",null!=i.total_cost_usd?" \xb7 $".concat(Number(i.total_cost_usd).toFixed(4)):""]}),"string"==typeof i.result&&i.result&&(0,n.jsx)("span",{className:"muted",style:{...c,whiteSpace:"pre-wrap",width:"100%"},children:i.result})]})}return"worker"===l.type?(0,n.jsxs)(C,{tone:"danger",children:[null!==(r=i.notice)&&void 0!==r?r:"runner notice",i.stderr_tail?(0,n.jsx)("pre",{className:"mono",style:{...c,margin:"6px 0 0",whiteSpace:"pre-wrap"},children:i.stderr_tail}):null]}):"raw"===l.type?(0,n.jsx)("div",{className:"faint mono",style:{...c,whiteSpace:"pre-wrap"},children:"string"==typeof i.line?i.line.trimEnd():JSON.stringify(i)}):(0,n.jsxs)("div",{className:"faint mono",style:c,children:["▸ ",l.type]})}let P={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:"",init_mise:!1};function O(){let e=o(),[t,s]=(0,r.useState)(P),[a,l]=(0,r.useState)(!1),i=(0,r.useMemo)(()=>{var t;let s=new Map;for(let a of e.agents)s.set(a.project_id,(null!==(t=s.get(a.project_id))&&void 0!==t?t:0)+1);return s},[e.agents]),c=(0,r.useMemo)(()=>[{value:"",label:e.hosts.length?"Pick a git server…":"No git servers configured"},...e.hosts.map(e=>({value:e.hostname,label:"".concat(e.hostname," (").concat(e.forge_type,")")}))],[e.hosts]),d=()=>{s(P),l(!1)},u=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},h=e=>{var t,a;s({...P,mode:"manual",id:e.id,root_dir:e.root_dir,git_remote:null!==(t=e.git_remote)&&void 0!==t?t:"",credential_ref:null!==(a=e.credential_ref)&&void 0!==a?a:""}),l(!0)},p=a?!!t.root_dir.trim():"server"===t.mode?!!t.git_server&&/^[\w.-]+\/[\w.-]+$/.test(t.repo.trim()):!!t.id.trim()&&!!t.root_dir.trim();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Repositories"}),(0,n.jsx)("div",{className:"section-desc",children:"Repos Handler manages. Each carries its own agents, history, and credentials."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit repository \xb7 ".concat(t.id):"Add a repository"})}),!a&&(0,n.jsx)("div",{style:{marginBottom:14},children:(0,n.jsx)(N,{tabs:[{value:"server",label:"From a git server"},{value:"manual",label:"Manual (existing checkout)"}],value:t.mode,onChange:e=>s({...t,mode:e})})}),a||"server"!==t.mode?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(b,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(b,{label:"Git remote",value:t.git_remote,onChange:e=>s({...t,git_remote:e}),placeholder:"git@github.com:user/repo.git (optional)"}),(0,n.jsx)(b,{label:"Credential ref",value:t.credential_ref,onChange:e=>s({...t,credential_ref:e}),placeholder:"env:VAR / file:/path / db:host:github.com"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Optional override — projects on a configured git server use its stored token automatically. credential_ref is a pointer, never the token (env: / file: / db:host:)."})]}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(w,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(b,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(b,{label:"ID / slug (optional — defaults to the repo name)",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"coolproj"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"The repo is always pulled: Handler derives the remote from the server (ssh when it has a deploy key, https via the stored token otherwise), clones it under PROJECTS_ROOT, and keeps it fresh before every run."})]}),!a&&(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer",marginTop:14},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.init_mise,onChange:e=>s({...t,init_mise:e.target.checked})}),(0,n.jsxs)("span",{style:{fontSize:"var(--text-sm)"},children:["Initialize mise — after the clone, run an agent that writes a"," ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," with a"," ",(0,n.jsx)("span",{className:"mono",children:"[tasks.test]"})," task for this repo’s stack, then commits and pushes it. Needed for repos that don’t define one yet."]})]}),!a&&t.init_mise&&"manual"===t.mode&&!t.git_remote.trim()&&(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"6px 0 0"},children:["A git remote is required to push the new ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," — add one above, or mise won’t be initialized."]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!p,onClick:u,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:d,children:"Cancel"})]})]}),0===e.projects.length&&(0,n.jsx)("div",{className:"empty",children:"No repositories registered."}),e.projects.map(t=>{var s,a;return(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(v,{tone:"info",pill:!0,children:[null!==(s=i.get(t.id))&&void 0!==s?s:0," ",(null!==(a=i.get(t.id))&&void 0!==a?a:0)===1?"agent":"agents"]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:4},children:[t.root_dir,t.git_remote?" \xb7 ".concat(t.git_remote):""]}),(0,n.jsxs)("div",{className:"hstack",style:{marginTop:12,justifyContent:"space-between"},children:[(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["cred ",t.credential_ref||"server default"," \xb7 added ",m(t.created_at)]}),(0,n.jsxs)("div",{className:"hstack",children:[t.git_remote&&(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>h(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let M=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],L=[{value:"worktree",label:"git worktree on branch"},{value:"subdir",label:"subdir under root"}],D={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function U(){let e=o(),[t,s]=(0,r.useState)(D),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=(0,r.useMemo)(()=>e.agents.filter(t=>t.project_id===e.selectedProjectId),[e.agents,e.selectedProjectId]),i=async()=>{await e.spawnAgent(t)&&s(D)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Agents"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn agents into a repository and manage running sessions."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Spawn an agent"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:M}),(0,n.jsx)(w,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:L}),"worktree"===t.placement?(0,n.jsx)(b,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(b,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Initial task",value:t.task,onChange:e=>s({...t,task:e}),rows:2,placeholder:"initial task / prompt (optional)"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name.trim(),onClick:i,children:"Spawn"})})]}),0===l.length?(0,n.jsx)("div",{className:"empty",children:"No agents in this repository."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Role"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Working dir"}),(0,n.jsx)("th",{children:"Created"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:l.map(t=>{var s;return(0,n.jsxs)(r.Fragment,{children:[(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(v,{tone:"info",children:t.role}):"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:t.status})}),(0,n.jsx)("td",{className:"mono faint",children:t.working_dir}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.created_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]}),("working"===t.status||"crashed"===t.status)&&(null===(s=t.last_output)||void 0===s?void 0:s.trim())&&(0,n.jsx)("tr",{children:(0,n.jsxs)("td",{colSpan:6,style:{paddingTop:0},children:[(0,n.jsxs)("div",{className:"faint",style:{fontSize:"var(--text-xs)",marginBottom:4},children:["crashed"===t.status?"last output before crash":"live output",t.output_at?" \xb7 ".concat(x(t.output_at)):""]}),(0,n.jsx)("pre",{className:"mono",style:{margin:0,padding:"8px 10px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6,fontSize:"var(--text-xs)",lineHeight:1.4,maxHeight:220,overflow:"auto",whiteSpace:"pre"},children:t.last_output})]})})]},t.id)})})]})})]})})]})}let F=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],B=[{value:"900",label:"every 15 minutes"},{value:"1800",label:"every 30 minutes"},{value:"3600",label:"every hour"},{value:"21600",label:"every 6 hours"},{value:"86400",label:"every day"},{value:"604800",label:"every week"}],H={name_prefix:"",task:"",interval:"3600",role:""};function W(){let e=o(),[t,s]=(0,r.useState)(H),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.createSchedule(e.selectedProjectId,{name_prefix:t.name_prefix,task:t.task,interval_seconds:Number(t.interval),role:t.role})&&s(H)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Schedules"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a file the prompt reads and overwrites."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"New schedule"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(w,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:B}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:F})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Prompt (the task every run starts with)",value:t.task,onChange:e=>s({...t,task:e}),rows:3,placeholder:"Read @notes.md and continue from where it left off. Before finishing, overwrite @notes.md with the current state so the next run can pick up from there."})}),(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:["Runs are named ",(0,n.jsxs)("span",{className:"mono",children:[t.name_prefix.trim()||"prefix","-YYYYMMDD-HHMMSS"]}),". The repo is pulled before every run; the first run fires on the worker's next pass."]}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name_prefix.trim()||!t.task.trim(),onClick:l,children:"Create schedule"})})]}),0===e.schedules.length?(0,n.jsx)("div",{className:"empty",children:"No schedules yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"On"}),(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Interval"}),(0,n.jsx)("th",{children:"Prompt"}),(0,n.jsx)("th",{children:"Next run"}),(0,n.jsx)("th",{children:"Last run"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:e.schedules.map(t=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{children:(0,n.jsx)(S,{on:t.enabled,onClick:()=>e.updateSchedule(t.id,{enabled:!t.enabled})})}),(0,n.jsxs)("td",{className:"mono",children:[t.name_prefix,t.role?(0,n.jsxs)(n.Fragment,{children:[" ",(0,n.jsx)(v,{tone:"info",children:t.role})]}):null]}),(0,n.jsx)("td",{className:"mono faint",children:t.project_id}),(0,n.jsx)("td",{className:"nowrap",children:function(e){let t=B.find(t=>Number(t.value)===e);return t?t.label:e%3600==0?"every ".concat(e/3600,"h"):e%60==0?"every ".concat(e/60,"m"):"every ".concat(e,"s")}(t.interval_seconds)}),(0,n.jsx)("td",{className:"faint",style:{maxWidth:340},children:(0,n.jsx)("span",{className:"truncate",style:{display:"block"},title:t.task,children:t.task})}),(0,n.jsx)("td",{className:"faint nowrap",children:t.enabled?m(t.next_run_at):"paused"}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.last_run_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let q=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],G={branch:"",status:"approved",agent_name:"",sha:"",note:""};function J(){let e=o(),[t,s]=(0,r.useState)(G),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.submitApproval(t),s(G)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Approvals"}),(0,n.jsx)("div",{className:"section-desc",children:"A merge is denied unless a standing approval exists — made by a different agent, pinned to the reviewed commit."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Record a verdict"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(w,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:q}),(0,n.jsx)(b,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(b,{label:"SHA",value:t.sha,onChange:e=>s({...t,sha:e}),placeholder:"pins the approval (optional)"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(b,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.branch.trim(),onClick:l,children:"Enqueue verdict"})})]}),0===e.approvals.length?(0,n.jsx)("div",{className:"empty",children:"No approvals recorded."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Branch"}),(0,n.jsx)("th",{children:"Verdict"}),(0,n.jsx)("th",{children:"By"}),(0,n.jsx)("th",{children:"SHA"}),(0,n.jsx)("th",{children:"Note"})]})}),(0,n.jsx)("tbody",{children:e.approvals.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.branch}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.approved_by_agent_id?"agent ".concat(e.approved_by_agent_id):e.actor||"—"}),(0,n.jsx)("td",{className:"mono",children:p(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let K=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],Y={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function V(e){let{value:t}=e,[s,a]=(0,r.useState)(!1),l=async()=>{try{await navigator.clipboard.writeText(t),a(!0),setTimeout(()=>a(!1),1500)}catch(e){}};return(0,n.jsxs)("div",{style:{marginTop:10},children:[(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsx)("span",{className:"eyebrow",children:"SSH public key — add it to the forge (deploy key)"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:l,children:s?"Copied":"Copy"})]}),(0,n.jsx)("pre",{className:"mono",style:{fontSize:"var(--text-xs)",whiteSpace:"pre-wrap",wordBreak:"break-all",margin:"6px 0 0",padding:8,border:"1px solid var(--border-default)",borderRadius:6,userSelect:"all"},children:t})]})}function Q(){let e=o(),[t,s]=(0,r.useState)(Y),[a,l]=(0,r.useState)(!1),i=()=>{s(Y),l(!1)},c=async()=>{(a?await e.updateHost(t.hostname,t):await e.createHost(t))&&i()},d=e=>{var t,a;s({hostname:e.hostname,forge_type:e.forge_type,token_env_var:null!==(t=e.token_env_var)&&void 0!==t?t:"",base_url:null!==(a=e.base_url)&&void 0!==a?a:"",token:"",generate_ssh_key:!1}),l(!0)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Git Servers"}),(0,n.jsxs)("div",{className:"section-desc",children:["Each server carries its own credentials: a forge token (encrypted at rest, used by agents' ",(0,n.jsx)("span",{className:"mono",children:"forge"})," + git) and an SSH deploy key — paste the public key into the forge. New repositories are added by picking a server and typing owner/name."]})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit server \xb7 ".concat(t.hostname):"Add a git server"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(w,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:K}),(0,n.jsx)(b,{label:a?"Forge token (blank = keep current)":"Forge token",type:"password",value:t.token,onChange:e=>s({...t,token:e}),placeholder:"stored encrypted; used by forge + git"}),(0,n.jsx)(b,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(b,{label:"Token env var override (optional)",value:t.token_env_var,onChange:e=>s({...t,token_env_var:e}),placeholder:"GITEA_TOKEN"}),(0,n.jsxs)("label",{className:"field",children:[(0,n.jsx)("span",{className:"field-label",children:"SSH deploy key"}),(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer"},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.generate_ssh_key,onChange:e=>s({...t,generate_ssh_key:e.target.checked})}),(0,n.jsx)("span",{style:{fontSize:"var(--text-sm)"},children:a?"Regenerate keypair (replaces the current key)":"Generate a keypair"})]})]})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:i,children:"Cancel"})]})]}),0===e.hosts.length&&(0,n.jsx)("div",{className:"empty",children:"No git servers registered (built-in host map still applies)."}),e.hosts.map(t=>(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"mono",style:{fontWeight:"var(--fw-bold)",fontSize:"var(--text-lg)",color:"var(--text-heading)"},children:t.hostname}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(v,{tone:"info",children:t.forge_type}),(0,n.jsx)(v,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(v,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteHost(t.hostname),children:"Remove"})]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:8},children:["token env ",t.token_env_var||"—",t.base_url?" \xb7 ".concat(t.base_url):""]}),t.ssh_public_key&&(0,n.jsx)(V,{value:t.ssh_public_key})]},t.hostname))]})]})}function X(){let e=o();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"section-head",children:(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"section-title",children:"Activity"}),(0,n.jsx)("div",{className:"section-desc",children:"Control commands the worker drains from the queue."})]}),(0,n.jsx)(y,{variant:"secondary",disabled:e.cmd.busy,onClick:()=>e.pollCi(),children:"Sweep CI now"})]})}),(0,n.jsx)("div",{className:"section-body",children:0===e.commands.length?(0,n.jsx)("div",{className:"empty",children:"No commands yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Type"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Result / Error"})]})}),(0,n.jsx)("tbody",{children:e.commands.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.type}),(0,n.jsx)("td",{className:"mono",children:e.project_id||"—"}),(0,n.jsx)("td",{className:"mono",children:e.agent_name||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:e.error||(e.result?JSON.stringify(e.result):"—")})]},e.id))})]})})})]})}function $(){let e=o(),[t,s]=(0,r.useState)(""),[a,l]=(0,r.useState)(""),i=async()=>{t.trim()&&a.trim()&&await e.setSharedKey(t.trim(),a.trim())&&(s(""),l(""))};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Shared"}),(0,n.jsx)("div",{className:"section-desc",children:"The cross-project global feed and shared facts."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Global feed"}),0===e.shared.log.length?(0,n.jsx)("div",{className:"empty",children:"No global log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:e.shared.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.agent_id}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.ci_status})})]},e.id))})]})})]}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Set a shared key"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(b,{label:"Value",value:a,onChange:l,placeholder:"value"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Requires the shared-context write token (or admin/global if unset)."}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:!t.trim()||!a.trim(),onClick:i,children:"Set"})})]}),e.shared.context.length>0&&(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Key"}),(0,n.jsx)("th",{children:"Value"}),(0,n.jsx)("th",{children:"Updated"})]})}),(0,n.jsx)("tbody",{children:e.shared.context.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:e.key}),(0,n.jsx)("td",{children:e.value}),(0,n.jsx)("td",{className:"faint nowrap",children:m(e.updated_at)})]},e.key))})]})})]})]})}function Z(e){let t=window.screenX+Math.max(0,(window.outerWidth-520)/2),s=window.screenY+Math.max(0,(window.outerHeight-760)/2);return window.open(e,"claude-login","popup=yes,width=".concat(520,",height=").concat(760,",left=").concat(Math.round(t),",top=").concat(Math.round(s)))}function ee(){let e=o(),{status:t,url:s,message:a}=e.claudeLogin,[l,i]=(0,r.useState)(""),c=(0,r.useRef)(null),d="starting"===t||"submitting"===t,u="awaiting"===t||"submitting"===t;(0,r.useEffect)(()=>{if("awaiting"===t&&s&&c.current&&!c.current.closed)try{c.current.location.href=s}catch(e){}if("done"===t||"error"===t){var e;null===(e=c.current)||void 0===e||e.close(),c.current=null}},[t,s]);let h=()=>{c.current=Z("about:blank"),e.startClaudeLogin()},m=async()=>{await e.submitClaudeCode(l)&&i("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Claude Login"}),(0,n.jsxs)("div",{className:"section-desc",children:["Log Claude Code in on the host so agents can run. This drives"," ",(0,n.jsx)("span",{className:"mono",children:"claude /login"})," in the control container and picks the Claude account with a subscription."]})]}),(0,n.jsxs)("div",{className:"section-body",style:{display:"flex",flexDirection:"column",gap:16},children:[a&&(0,n.jsx)(C,{tone:"error"===t?"danger":"done"===t?"success":"info",children:a}),"done"===t?(0,n.jsx)("div",{children:(0,n.jsx)(y,{variant:"secondary",onClick:e.resetClaudeLogin,children:"Log in again"})}):u?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(C,{tone:"info",children:"A Claude sign-in window should have opened. Authorize there, copy the code Claude shows you, and paste it below. If the window didn't open (popups blocked), use the button."}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,flexWrap:"wrap"},children:[(0,n.jsx)(y,{variant:"secondary",disabled:!s,onClick:()=>{s&&(c.current=Z(s))},children:"Open Claude sign-in window ↗"}),s&&(0,n.jsx)("a",{className:"btn btn-ghost",href:s,target:"_blank",rel:"noopener noreferrer",children:"Open in a new tab"}),(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Restart"})]}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,alignItems:"flex-end",flexWrap:"wrap"},children:[(0,n.jsx)("div",{style:{flex:"1 1 320px"},children:(0,n.jsx)(b,{label:"Authorization code",value:l,onChange:i,placeholder:"Paste the code from claude.com",disabled:"submitting"===t})}),(0,n.jsx)(y,{variant:"primary",disabled:"submitting"===t||!l.trim(),onClick:m,children:"submitting"===t?"Submitting…":"Finish login"})]})]}):(0,n.jsxs)("div",{className:"hstack",style:{gap:10},children:[(0,n.jsx)(y,{variant:"primary",disabled:d,onClick:h,children:"starting"===t?"Starting…":"Log in to Claude"}),"error"===t&&(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Retry"})]})]})]})}let et=[{key:"runs",label:"Runs",count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},{key:"repositories",label:"Repositories",count:e=>e.projects.length},{key:"agents",label:"Agents",count:e=>e.agents.length},{key:"schedules",label:"Schedules",count:e=>e.schedules.length},{key:"approvals",label:"Approvals",count:e=>e.approvals.length},{key:"servers",label:"Git Servers",count:e=>e.hosts.length},{key:"activity",label:"Activity",count:e=>e.commands.length},{key:"shared",label:"Shared",count:e=>e.shared.context.length},{key:"login",label:"Claude Login",count:()=>0,accent:e=>"done"!==e.claudeLogin.status}];function es(e){let{onSignOut:t}=e,s=o();return(0,n.jsxs)("div",{className:"app",children:[(0,n.jsxs)("aside",{className:"sidebar",children:[(0,n.jsxs)("div",{className:"brand",children:[(0,n.jsx)("span",{className:"logo"}),"Claude Monitor"]}),et.map(e=>{var t,a;let r=e.count(s),l=null!==(a=null===(t=e.accent)||void 0===t?void 0:t.call(e,s))&&void 0!==a&&a;return(0,n.jsxs)("button",{className:"nav-item".concat(s.section===e.key?" active":""),onClick:()=>s.setSection(e.key),children:[(0,n.jsx)("span",{children:e.label}),(0,n.jsx)("span",{className:"count",style:l?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,n.jsx)("div",{className:"sidebar-spacer"}),(0,n.jsxs)("div",{className:"sidebar-foot",children:[(0,n.jsxs)("button",{className:"nav-item",onClick:s.refresh,title:"Refresh now",children:[(0,n.jsx)("span",{children:"Refresh"}),(0,n.jsx)("span",{className:"count",children:"↻"})]}),(0,n.jsx)("button",{className:"nav-item",onClick:t,title:"Sign out / change token",children:(0,n.jsx)("span",{children:"Sign out"})})]})]}),(0,n.jsxs)("main",{className:"main",children:[s.cmd.text&&(0,n.jsx)("p",{className:"banner ".concat(s.cmd.error?"err":"ok"),style:{marginTop:16},children:s.cmd.text}),s.lastError&&(0,n.jsx)("p",{className:"banner err",style:{marginTop:12},children:s.lastError}),"runs"===s.section?(0,n.jsx)(I,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(O,{}),"agents"===s.section&&(0,n.jsx)(U,{}),"schedules"===s.section&&(0,n.jsx)(W,{}),"approvals"===s.section&&(0,n.jsx)(J,{}),"servers"===s.section&&(0,n.jsx)(Q,{}),"activity"===s.section&&(0,n.jsx)(X,{}),"shared"===s.section&&(0,n.jsx)($,{}),"login"===s.section&&(0,n.jsx)(ee,{})]})]})]})}function ea(e){let{error:t,onSubmit:s}=e,[a,l]=(0,r.useState)("");return(0,n.jsx)("div",{className:"gate",children:(0,n.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let t=a.trim();t&&s(t)},children:[(0,n.jsxs)("div",{className:"gate-brand",children:[(0,n.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,n.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,n.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:a,onChange:e=>l(e.target.value),autoFocus:!0}),t&&(0,n.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:t}),(0,n.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let en="handler_token";function er(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(en);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(en,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(en),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(en),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(es,{onSignOut:i})}):(0,n.jsx)(ea,{error:s,onSubmit:l})}},257:function(e,t,s){"use strict";var a,n;e.exports=(null==(a=s.g.process)?void 0:a.env)&&"object"==typeof(null==(n=s.g.process)?void 0:n.env)?s.g.process:s(4227)},4227:function(e){!function(){var t={229:function(e){var t,s,a,n=e.exports={};function r(){throw Error("setTimeout has not been defined")}function l(){throw Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(s){try{return t.call(null,e,0)}catch(s){return t.call(this,e,0)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(e){t=r}try{s="function"==typeof clearTimeout?clearTimeout:l}catch(e){s=l}}();var c=[],o=!1,d=-1;function u(){o&&a&&(o=!1,a.length?c=a.concat(c):d=-1,c.length&&h())}function h(){if(!o){var e=i(u);o=!0;for(var t=c.length;t;){for(a=c,c=[];++d1)for(var s=1;s(function(e,t){async function s(s,a){var n;let r=(null==a?void 0:a.body)!==void 0&&(null==a?void 0:a.body)!==null,c=await fetch(l+s,{method:null!==(n=null==a?void 0:a.method)&&void 0!==n?n:r?"POST":"GET",headers:{Authorization:"Bearer ".concat(e),...r?{"Content-Type":"application/json"}:{}},body:r?JSON.stringify(a.body):void 0});if(401===c.status)throw t(),new i;if(!c.ok){let e=c.statusText;try{let t=await c.json();t&&void 0!==t.detail&&(e="string"==typeof t.detail?t.detail:JSON.stringify(t.detail))}catch(e){}let t=Error(e);throw t.status=c.status,t}if(204===c.status)return;let o=await c.text();return o?JSON.parse(o):void 0}async function a(e,t){var a,n;let r=null!==(a=null==t?void 0:t.attempts)&&void 0!==a?a:60,l=null!==(n=null==t?void 0:t.intervalMs)&&void 0!==n?n:500;for(let t=0;tsetTimeout(e,l))}return null}return{api:s,trackCommand:a}})(t,s),[t,s]),d=(0,r.useRef)(o);d.current=o;let[u,h]=(0,r.useState)("runs"),[m,x]=(0,r.useState)([]),[p,j]=(0,r.useState)([]),[v,g]=(0,r.useState)(""),[y,b]=(0,r.useState)(null),[f,k]=(0,r.useState)(null),[w,N]=(0,r.useState)(!1),[_,C]=(0,r.useState)([]),[S,R]=(0,r.useState)(0),[I,T]=(0,r.useState)([]),[A,z]=(0,r.useState)([]),[E,P]=(0,r.useState)([]),[M,O]=(0,r.useState)([]),[L,U]=(0,r.useState)({log:[],context:[]}),[D,F]=(0,r.useState)({text:"",error:!1,busy:!1}),[B,H]=(0,r.useState)(""),[q,W]=(0,r.useState)(!0),[G,J]=(0,r.useState)({status:"idle",url:"",message:""}),K=(0,r.useRef)(u);K.current=u;let Y=(0,r.useRef)(v);Y.current=v;let V=(0,r.useRef)(y);V.current=y;let Q=(0,r.useRef)(S);Q.current=S;let X=e=>{e instanceof i||H(e.message)},$=(0,r.useCallback)(async()=>{try{let e=await d.current.api("/projects");x(e),H(""),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")})}catch(e){X(e)}},[]),Z=(0,r.useCallback)(async e=>{try{let t=await Promise.all(e.map(e=>d.current.api("/projects/".concat(encodeURIComponent(e.id),"/agents")).catch(()=>[])));j(t.flat())}catch(e){X(e)}},[]),ee=(0,r.useCallback)(async(e,t)=>{let s="/projects/".concat(encodeURIComponent(e),"/agents/").concat(encodeURIComponent(t));try{let e=await d.current.api("".concat(s,"/checkmark"));k(e),N(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),N(!0)):X(e)}try{let e=await d.current.api("".concat(s,"/log?limit=").concat(100,"&offset=").concat(Q.current));C(e)}catch(e){X(e)}},[]),et=(0,r.useCallback)(async e=>{if(!e){T([]);return}try{T(await d.current.api("/projects/".concat(encodeURIComponent(e),"/approvals")))}catch(e){X(e)}},[]),es=(0,r.useCallback)(async()=>{try{z(await d.current.api("/hosts"))}catch(e){X(e)}},[]),ea=(0,r.useCallback)(async()=>{try{P(await d.current.api("/commands?limit=50"))}catch(e){X(e)}},[]),en=(0,r.useCallback)(async()=>{try{O(await d.current.api("/schedules"))}catch(e){X(e)}},[]),er=(0,r.useCallback)(async()=>{try{let[e,t]=await Promise.all([d.current.api("/shared/log"),d.current.api("/shared/context")]);U({log:e,context:t})}catch(e){X(e)}},[]),el=(0,r.useCallback)(async()=>{let e=await d.current.api("/projects").catch(e=>(X(e),null));e&&(x(e),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")}),await Z(e));let t=K.current,s=V.current;s&&await ee(s.projectId,s.name),"approvals"===t&&await et(Y.current),"servers"===t&&await es(),"activity"===t&&await ea(),"schedules"===t&&await en(),"shared"===t&&await er()},[Z,ee,et,es,ea,en,er]);(0,r.useEffect)(()=>{let e=!0;(async()=>{W(!0),await el(),e&&W(!1)})();let t=setInterval(()=>{document.hidden||el()},5e3);return()=>{e=!1,clearInterval(t)}},[el]);let ei=(0,r.useCallback)(e=>{h(e),F({text:"",error:!1,busy:!1}),"approvals"===e&&et(Y.current),"servers"===e&&es(),"activity"===e&&ea(),"schedules"===e&&en(),"shared"===e&&er()},[et,es,ea,en,er]),ec=(0,r.useCallback)(e=>{g(e),"approvals"===K.current&&et(e)},[et]),eo=(0,r.useCallback)((e,t)=>{b({projectId:e,name:t}),R(0),Q.current=0,k(null),N(!1),C([]),ee(e,t)},[ee]),ed=(0,r.useCallback)(e=>{let t=Math.max(0,S+100*e);if(t===S)return;R(t),Q.current=t;let s=V.current;s&&ee(s.projectId,s.name)},[S,ee]),eu=(0,r.useCallback)(()=>{el()},[el]),eh=(0,r.useCallback)(async(e,t,s)=>{F({text:"".concat(s,": queued…"),error:!1,busy:!0});try{let a=await d.current.api(e,{method:"POST",body:t}),n=await d.current.trackCommand(a.id);if(!n)return F({text:"".concat(s,": still running (see Activity). Is the worker up?"),error:!1,busy:!1}),null;let r="done"===n.status,l=n.error||(n.result?JSON.stringify(n.result):"");return F({text:"".concat(s," ").concat(r?"done":"failed").concat(l?" — "+l:""),error:!r,busy:!1}),n}catch(e){if(e instanceof i)return null;return F({text:"".concat(s," failed: ").concat(e.message),error:!0,busy:!1}),null}},[]),em=(0,r.useCallback)(async e=>{let t={name:e.name.trim(),role:e.role||null,task:e.task.trim()||null};"worktree"===e.placement&&e.worktree.trim()&&(t.worktree=e.worktree.trim()),"subdir"===e.placement&&e.subdir.trim()&&(t.subdir=e.subdir.trim());let s=encodeURIComponent(Y.current),a=await eh("/projects/".concat(s,"/agents/spawn"),t,"spawn ".concat(t.name));return await Z(m),(null==a?void 0:a.status)==="done"},[eh,Z,m]),ex=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);await eh("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t),"/kill"),void 0,"kill ".concat(t)),await Z(m)},[eh,Z,m]),ep=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);try{var a;await d.current.api("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t)),{method:"DELETE"}),F({text:"agent '".concat(t,"' row deleted"),error:!1,busy:!1}),(null===(a=V.current)||void 0===a?void 0:a.name)===t&&b(null),await Z(m)}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[Z,m]),ej=(0,r.useCallback)(async(e,t)=>{let s=V.current;if(!s)return!1;let a="/projects/".concat(encodeURIComponent(s.projectId),"/agents/").concat(encodeURIComponent(s.name));try{return await d.current.api("".concat(a,"/answer"),{method:"POST",body:{answer:e}}),t?await eh("".concat(a,"/resume"),{answer:e},"resume"):F({text:"Answer saved (agent still paused).",error:!1,busy:!1}),await Z(m),await ee(s.projectId,s.name),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[eh,Z,ee,m]),ev=(0,r.useCallback)(async e=>{try{var t,s;let a="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise},n=await d.current.api("/projects",{method:"POST",body:a});if(await $(),null!=n.sync_command_id){F({text:"repository '".concat(n.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.sync_command_id);e?"done"===e.status?F({text:"repository '".concat(n.id,"' registered and cloned"),error:!1,busy:!1}):F({text:"repository '".concat(n.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1}):F({text:"repository '".concat(n.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else F({text:"repository '".concat(n.id,"' registered"),error:!1,busy:!1});if(null!=n.mise_init_command_id){F({text:"repository '".concat(n.id,"': launching a mise-init agent to create .mise.toml…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.mise_init_command_id);e?"done"===e.status?F({text:"repository '".concat(n.id,"' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs)."),error:!1,busy:!1}):F({text:"repository '".concat(n.id,"' registered but the mise-init agent failed to launch — ").concat(null!==(s=e.error)&&void 0!==s?s:""),error:!0,busy:!1}):F({text:"repository '".concat(n.id,"' registered; mise-init still starting (see Activity). Is the worker up?"),error:!1,busy:!1})}return!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[$]),eg=(0,r.useCallback)(async e=>{await eh("/projects/".concat(encodeURIComponent(e),"/sync"),void 0,"pull ".concat(e))},[eh]),ey=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"PATCH",body:{root_dir:t.root_dir.trim(),git_remote:t.git_remote.trim()||null,credential_ref:t.credential_ref.trim()||null}}),F({text:"repository '".concat(e,"' updated"),error:!1,busy:!1}),await $(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[$]),eb=(0,r.useCallback)(async e=>{try{await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"DELETE"}),F({text:"repository '".concat(e,"' removed"),error:!1,busy:!1}),g(t=>t===e?"":t),await $()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[$]),ef=(0,r.useCallback)(async e=>{let t=encodeURIComponent(Y.current);await eh("/projects/".concat(t,"/approvals"),{branch:e.branch.trim(),status:e.status,agent_name:e.agent_name.trim()||null,sha:e.sha.trim()||null,note:e.note.trim()||null},"".concat(e.status," ").concat(e.branch)),await et(Y.current)},[eh,et]),ek=(0,r.useCallback)(async e=>{try{return await d.current.api("/hosts",{method:"POST",body:{hostname:e.hostname.trim(),forge_type:e.forge_type,token_env_var:e.token_env_var.trim()||null,base_url:e.base_url.trim()||null,token:e.token.trim()||null,generate_ssh_key:e.generate_ssh_key}}),F({text:"git server '".concat(e.hostname,"' added"),error:!1,busy:!1}),await es(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[es]),ew=(0,r.useCallback)(async(e,t)=>{try{let s={forge_type:t.forge_type,token_env_var:t.token_env_var.trim()||null,base_url:t.base_url.trim()||null};return t.token.trim()&&(s.token=t.token.trim()),t.generate_ssh_key&&(s.regenerate_ssh_key=!0),await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"PATCH",body:s}),F({text:"git server '".concat(e,"' updated"),error:!1,busy:!1}),await es(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[es]),eN=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e),"/schedules"),{method:"POST",body:{name_prefix:t.name_prefix.trim(),task:t.task.trim(),interval_seconds:t.interval_seconds,role:t.role||null}}),F({text:"schedule '".concat(t.name_prefix,"' created — first run on the worker's next pass"),error:!1,busy:!1}),await en(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[en]),e_=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/schedules/".concat(e),{method:"PATCH",body:t}),await en(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[en]),eC=(0,r.useCallback)(async e=>{try{await d.current.api("/schedules/".concat(e),{method:"DELETE"}),F({text:"schedule ".concat(e," removed"),error:!1,busy:!1}),await en()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[en]),eS=(0,r.useCallback)(async e=>{try{await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"DELETE"}),F({text:"git server '".concat(e,"' removed"),error:!1,busy:!1}),await es()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[es]),eR=(0,r.useCallback)(async()=>{await eh("/poll-ci",void 0,"poll-ci (all projects)"),await ea()},[eh,ea]),eI=(0,r.useCallback)(async()=>{J({status:"starting",url:"",message:"Opening `claude /login` in the control container and selecting the subscription account…"});try{let e=await d.current.api("/login/start",{method:"POST"}),t=await d.current.trackCommand(e.id,{attempts:180});if(!t){J({status:"error",url:"",message:"Still starting (see Activity). Is the control worker running?"});return}if("done"!==t.status){J({status:"error",url:"",message:t.error||"Failed to start login."});return}let s=t.result&&"string"==typeof t.result.url?t.result.url:"";if(!s){J({status:"error",url:"",message:"No login URL was returned by claude."});return}J({status:"awaiting",url:s,message:"Authorize in the window below (or open it in a new tab), then paste the code claude gives you."})}catch(e){if(e instanceof i)return;J({status:"error",url:"",message:e.message})}},[]),eT=(0,r.useCallback)(async e=>{let t=e.trim();if(!t)return!1;J(e=>({...e,status:"submitting",message:"Submitting the authorization code…"}));try{let e=await d.current.api("/login/submit",{method:"POST",body:{code:t}}),s=await d.current.trackCommand(e.id,{attempts:60});if(!s)return J(e=>({...e,status:"awaiting",message:"Submit still running (see Activity). Is the control worker running?"})),!1;if("done"===s.status)return J({status:"done",url:"",message:"Claude Code is now logged in on the host — new agents will use this account."}),!0;return J(e=>({...e,status:"awaiting",message:s.error||"Login was not confirmed. Re-check the code, or restart the flow."})),!1}catch(e){if(e instanceof i)return!1;return J(t=>({...t,status:"awaiting",message:e.message})),!1}},[]),eA=(0,r.useCallback)(()=>{J({status:"idle",url:"",message:""})},[]),ez=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/shared/context/".concat(encodeURIComponent(e)),{method:"PUT",body:{value:t}}),F({text:"shared context '".concat(e,"' set"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[er]);return(0,n.jsx)(c.Provider,{value:{section:u,setSection:ei,projects:m,agents:p,selectedProjectId:v,selectProject:ec,selectedRun:y,selectRun:eo,checkmark:f,checkmarkMissing:w,log:_,logOffset:S,pageLog:ed,approvals:I,hosts:A,commands:E,schedules:M,shared:L,cmd:D,lastError:B,loading:q,refresh:eu,spawnAgent:em,killAgent:ex,deleteAgent:ep,submitAnswer:ej,createProject:ev,updateProject:ey,deleteProject:eb,syncProject:eg,submitApproval:ef,createHost:ek,updateHost:ew,deleteHost:eS,createSchedule:eN,updateSchedule:e_,deleteSchedule:eC,pollCi:eR,setSharedKey:ez,claudeLogin:G,startClaudeLogin:eI,submitClaudeCode:eT,resetClaudeLogin:eA},children:a})}function u(e){switch((null!=e?e:"").toLowerCase()){case"pass":case"done":case"completed":case"approved":case"success":return"success";case"fail":case"failed":case"blocked":case"rejected":case"error":return"danger";case"pending":case"queued":case"running":case"working":case"paused_for_input":return"warning";case"not_applicable":case"unknown":case"":return"neutral";default:return"info"}}let h={paused_for_input:"Needs input",not_applicable:"N/A"};function m(e){if(!e)return"—";let t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function x(e){return e?e.slice(0,7):"—"}function p(e){if(!e)return"—";let t=new Date(e).getTime();if(Number.isNaN(t))return"—";let s=Math.max(0,Math.floor((Date.now()-t)/1e3));if(s<60)return"".concat(s,"s");let a=Math.floor(s/60);if(a<60)return"".concat(a,"m");let n=Math.floor(a/60);if(n<24)return"".concat(n,"h");let r=Math.floor(n/24);if(r<30)return"".concat(r,"d");let l=Math.floor(r/30);return l<12?"".concat(l,"mo"):"".concat(Math.floor(l/12),"y")}function j(e){let{tone:t="neutral",pill:s=!1,dot:a=!1,children:r}=e;return(0,n.jsxs)("span",{className:"badge badge-".concat(t).concat(s?" pill":""),children:[a&&(0,n.jsx)("span",{className:"dot"}),r]})}function v(e){let{status:t}=e;return(0,n.jsx)(j,{tone:u(t),children:function(e){let t=(null!=e?e:"").trim();if(!t)return"—";let s=t.toLowerCase();return h[s]?h[s]:s.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())}(t)})}function g(e){let{children:t,interactive:s=!1,onClick:a,className:r=""}=e;return(0,n.jsx)("div",{className:"card".concat(s?" interactive":""," ").concat(r).trim(),onClick:a,role:s?"button":void 0,tabIndex:s?0:void 0,children:t})}function y(e){let{variant:t="secondary",size:s="md",onClick:a,disabled:r,type:l="button",children:i}=e;return(0,n.jsx)("button",{type:l,className:"btn btn-".concat(t).concat("sm"===s?" btn-sm":""),onClick:a,disabled:r,children:i})}function b(e){let{label:t,children:s}=e;return(0,n.jsxs)("label",{className:"field",children:[t&&(0,n.jsx)("span",{className:"field-label",children:t}),s]})}function f(e){let{label:t,value:s,onChange:a,placeholder:r,type:l="text",disabled:i}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("input",{className:"input",type:l,value:s,placeholder:r,disabled:i,onChange:e=>a(e.target.value)})})}function k(e){let{label:t,value:s,onChange:a,placeholder:r,rows:l=3}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("textarea",{className:"textarea",value:s,rows:l,placeholder:r,onChange:e=>a(e.target.value)})})}function w(e){let{label:t,value:s,onChange:a,options:r}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("select",{className:"select",value:s,onChange:e=>a(e.target.value),children:r.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value))})})}function N(e){let{tabs:t,value:s,onChange:a}=e;return(0,n.jsx)("div",{className:"tabs",role:"tablist",children:t.map(e=>(0,n.jsx)("button",{role:"tab","aria-selected":s===e.value,className:"tab".concat(s===e.value?" active":""),onClick:()=>a(e.value),children:e.label},e.value))})}function _(e){let{value:t,label:s,sub:a,accent:r=!1}=e;return(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"stat-value".concat(r?" accent":""),children:t}),(0,n.jsx)("div",{className:"stat-label",children:s}),a&&(0,n.jsx)("div",{className:"stat-sub",children:a})]})}function C(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function S(e){let{on:t,onClick:s}=e;return(0,n.jsx)("button",{type:"button",className:"toggle".concat(t?" on":""),"aria-pressed":t,onClick:s,children:(0,n.jsx)("span",{className:"knob"})})}let R=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"}];function I(){let e=o(),[t,s]=(0,r.useState)("all"),a=(0,r.useMemo)(()=>[...e.agents.filter(e=>{var s;return s=e.status,"all"===t||("needs"===t?"paused_for_input"===s:"working"===t?"working"===s||"running"===s:"done"!==t||"done"===s||"completed"===s)})].sort((e,t)=>e.created_at"paused_for_input"===e.status).length,c=e.agents.filter(e=>"working"===e.status||"running"===e.status).length;return(0,n.jsxs)("div",{className:"runs",children:[(0,n.jsx)("div",{className:"runs-stats",children:(0,n.jsxs)("div",{className:"stat-row",children:[(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.projects.length,label:"Repositories"})})]})}),(0,n.jsxs)("div",{className:"split",children:[(0,n.jsxs)("div",{className:"split-list",children:[(0,n.jsxs)("div",{className:"split-list-head",children:[(0,n.jsx)("div",{className:"section-title",style:{fontSize:"var(--text-lg)"},children:"Runs"}),(0,n.jsx)(N,{tabs:R,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(C,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(T,{agent:t,selected:(null==l?void 0:l.projectId)===t.project_id&&(null==l?void 0:l.name)===t.name,onSelect:()=>e.selectRun(t.project_id,t.name)},"".concat(t.project_id,"/").concat(t.name)))]})]}),(0,n.jsx)("div",{className:"split-detail",children:l?(0,n.jsx)(z,{}):(0,n.jsx)(A,{})})]})]})}function T(e){let{agent:t,selected:s,onSelect:a}=e;return(0,n.jsxs)("button",{className:"run-row".concat(s?" selected":""),onClick:a,children:[(0,n.jsxs)("div",{className:"run-row-top",children:[(0,n.jsx)("span",{className:"run-project",children:t.project_id}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:p(t.created_at)})]}),(0,n.jsxs)("div",{className:"truncate muted",style:{fontSize:"var(--text-sm)"},children:[t.name,t.role?" \xb7 ".concat(t.role):""]}),(0,n.jsx)("div",{className:"hstack",style:{gap:8},children:(0,n.jsx)(v,{status:t.status})})]})}function A(){return(0,n.jsx)("div",{style:{padding:"60px 32px",color:"var(--text-muted)"},children:"Select a run to see its checkmark, log, and any open question."})}function z(){var e;let t=o(),s=t.selectedRun,a=t.agents.find(e=>e.project_id===s.projectId&&e.name===s.name),l=t.checkmark,[i,c]=(0,r.useState)(""),[d,h]=(0,r.useState)(!1),p=(null==a?void 0:a.status)==="paused_for_input",g=async e=>{if(!i.trim())return;h(!0);let s=await t.submitAnswer(i.trim(),e);h(!1),s&&c("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{style:{padding:"24px 28px",borderBottom:"1px solid var(--border-default)",display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)("span",{style:{color:"var(--accent)",fontWeight:"var(--fw-bold)",fontSize:"var(--text-xl)"},children:s.projectId}),(0,n.jsx)("span",{className:"faint",children:"/"}),(0,n.jsx)("span",{style:{color:"var(--text-heading)",fontWeight:"var(--fw-semibold)",fontSize:"var(--text-lg)"},children:s.name}),(0,n.jsx)(v,{status:null==a?void 0:a.status}),(null==a?void 0:a.role)&&(0,n.jsx)(j,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>t.deleteAgent(s.projectId,s.name),children:"Delete row"})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:[null!==(e=null==a?void 0:a.working_dir)&&void 0!==e?e:"—"," \xb7 created ",m(null==a?void 0:a.created_at)]})]}),(0,n.jsxs)("div",{style:{padding:"20px 28px",display:"flex",flexDirection:"column",gap:16},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Checkmark"}),t.checkmarkMissing&&(0,n.jsx)(C,{tone:"info",children:"No checkpoint recorded yet."}),l&&!t.checkmarkMissing&&(0,n.jsxs)("dl",{className:"kv",children:[(0,n.jsx)("dt",{children:"Status"}),(0,n.jsx)("dd",{children:(0,n.jsx)(v,{status:l.status})}),(0,n.jsx)("dt",{children:"Where it stopped"}),(0,n.jsx)("dd",{children:l.where_it_stopped||"—"}),(0,n.jsx)("dt",{children:"Open question"}),(0,n.jsx)("dd",{children:l.open_question||"—"}),(0,n.jsx)("dt",{children:"Next steps"}),(0,n.jsx)("dd",{children:l.next_steps&&l.next_steps.length>0?(0,n.jsx)("ul",{children:l.next_steps.map((e,t)=>(0,n.jsx)("li",{children:e},t))}):"—"}),(0,n.jsx)("dt",{children:"Tests"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(j,{tone:u(l.tests_status),children:l.tests_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.tested_at?m(l.tested_at):""})]}),(0,n.jsx)("dt",{children:"Build"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(j,{tone:u(l.build_status),children:l.build_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.built_at?m(l.built_at):""})]}),(0,n.jsx)("dt",{children:"Checkpoint at"}),(0,n.jsx)("dd",{className:"faint",children:m(l.checkpoint_at)})]})]}),p&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Answer this question"}),(0,n.jsx)(C,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(k,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>g(!1),children:"Answer"}),(0,n.jsx)(y,{variant:"primary",disabled:d||!i.trim(),onClick:()=>g(!0),children:"Answer & Resume"})]})]}),(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Log \xb7 newest first"}),0===t.log.length?(0,n.jsx)("div",{className:"empty",children:"No log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"Q / A"}),(0,n.jsx)("th",{children:"Push"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:t.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsxs)("td",{children:[e.question&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"Q:"})," ",e.question]}),e.answer&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"A:"})," ",e.answer]}),!e.question&&!e.answer&&"—"]}),(0,n.jsx)("td",{className:"mono",children:x(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{tone:u(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:0===t.logOffset,onClick:()=>t.pageLog(-1),children:"‹ Newer"}),(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["offset ",t.logOffset]}),(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}let E={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:"",init_mise:!1};function P(){let e=o(),[t,s]=(0,r.useState)(E),[a,l]=(0,r.useState)(!1),i=(0,r.useMemo)(()=>{var t;let s=new Map;for(let a of e.agents)s.set(a.project_id,(null!==(t=s.get(a.project_id))&&void 0!==t?t:0)+1);return s},[e.agents]),c=(0,r.useMemo)(()=>[{value:"",label:e.hosts.length?"Pick a git server…":"No git servers configured"},...e.hosts.map(e=>({value:e.hostname,label:"".concat(e.hostname," (").concat(e.forge_type,")")}))],[e.hosts]),d=()=>{s(E),l(!1)},u=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},h=e=>{var t,a;s({...E,mode:"manual",id:e.id,root_dir:e.root_dir,git_remote:null!==(t=e.git_remote)&&void 0!==t?t:"",credential_ref:null!==(a=e.credential_ref)&&void 0!==a?a:""}),l(!0)},x=a?!!t.root_dir.trim():"server"===t.mode?!!t.git_server&&/^[\w.-]+\/[\w.-]+$/.test(t.repo.trim()):!!t.id.trim()&&!!t.root_dir.trim();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Repositories"}),(0,n.jsx)("div",{className:"section-desc",children:"Repos Handler manages. Each carries its own agents, history, and credentials."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit repository \xb7 ".concat(t.id):"Add a repository"})}),!a&&(0,n.jsx)("div",{style:{marginBottom:14},children:(0,n.jsx)(N,{tabs:[{value:"server",label:"From a git server"},{value:"manual",label:"Manual (existing checkout)"}],value:t.mode,onChange:e=>s({...t,mode:e})})}),a||"server"!==t.mode?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(f,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(f,{label:"Git remote",value:t.git_remote,onChange:e=>s({...t,git_remote:e}),placeholder:"git@github.com:user/repo.git (optional)"}),(0,n.jsx)(f,{label:"Credential ref",value:t.credential_ref,onChange:e=>s({...t,credential_ref:e}),placeholder:"env:VAR / file:/path / db:host:github.com"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Optional override — projects on a configured git server use its stored token automatically. credential_ref is a pointer, never the token (env: / file: / db:host:)."})]}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(w,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(f,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(f,{label:"ID / slug (optional — defaults to the repo name)",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"coolproj"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"The repo is always pulled: Handler derives the remote from the server (ssh when it has a deploy key, https via the stored token otherwise), clones it under PROJECTS_ROOT, and keeps it fresh before every run."})]}),!a&&(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer",marginTop:14},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.init_mise,onChange:e=>s({...t,init_mise:e.target.checked})}),(0,n.jsxs)("span",{style:{fontSize:"var(--text-sm)"},children:["Initialize mise — after the clone, run an agent that writes a"," ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," with a"," ",(0,n.jsx)("span",{className:"mono",children:"[tasks.test]"})," task for this repo’s stack, then commits and pushes it. Needed for repos that don’t define one yet."]})]}),!a&&t.init_mise&&"manual"===t.mode&&!t.git_remote.trim()&&(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"6px 0 0"},children:["A git remote is required to push the new ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," — add one above, or mise won’t be initialized."]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!x,onClick:u,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:d,children:"Cancel"})]})]}),0===e.projects.length&&(0,n.jsx)("div",{className:"empty",children:"No repositories registered."}),e.projects.map(t=>{var s,a;return(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(j,{tone:"info",pill:!0,children:[null!==(s=i.get(t.id))&&void 0!==s?s:0," ",(null!==(a=i.get(t.id))&&void 0!==a?a:0)===1?"agent":"agents"]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:4},children:[t.root_dir,t.git_remote?" \xb7 ".concat(t.git_remote):""]}),(0,n.jsxs)("div",{className:"hstack",style:{marginTop:12,justifyContent:"space-between"},children:[(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["cred ",t.credential_ref||"server default"," \xb7 added ",m(t.created_at)]}),(0,n.jsxs)("div",{className:"hstack",children:[t.git_remote&&(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>h(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let M=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],O=[{value:"worktree",label:"git worktree on branch"},{value:"subdir",label:"subdir under root"}],L={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function U(){let e=o(),[t,s]=(0,r.useState)(L),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=(0,r.useMemo)(()=>e.agents.filter(t=>t.project_id===e.selectedProjectId),[e.agents,e.selectedProjectId]),i=async()=>{await e.spawnAgent(t)&&s(L)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Agents"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn agents into a repository and manage running sessions."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Spawn an agent"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:M}),(0,n.jsx)(w,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:O}),"worktree"===t.placement?(0,n.jsx)(f,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(f,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Initial task",value:t.task,onChange:e=>s({...t,task:e}),rows:2,placeholder:"initial task / prompt (optional)"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name.trim(),onClick:i,children:"Spawn"})})]}),0===l.length?(0,n.jsx)("div",{className:"empty",children:"No agents in this repository."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Role"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Working dir"}),(0,n.jsx)("th",{children:"Created"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:l.map(t=>{var s;return(0,n.jsxs)(r.Fragment,{children:[(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(j,{tone:"info",children:t.role}):"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:t.status})}),(0,n.jsx)("td",{className:"mono faint",children:t.working_dir}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.created_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]}),"working"===t.status&&(null===(s=t.last_output)||void 0===s?void 0:s.trim())&&(0,n.jsx)("tr",{children:(0,n.jsxs)("td",{colSpan:6,style:{paddingTop:0},children:[(0,n.jsxs)("div",{className:"faint",style:{fontSize:"var(--text-xs)",marginBottom:4},children:["live output",t.output_at?" \xb7 ".concat(p(t.output_at)):""]}),(0,n.jsx)("pre",{className:"mono",style:{margin:0,padding:"8px 10px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6,fontSize:"var(--text-xs)",lineHeight:1.4,maxHeight:220,overflow:"auto",whiteSpace:"pre"},children:t.last_output})]})})]},t.id)})})]})})]})})]})}let D=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],F=[{value:"900",label:"every 15 minutes"},{value:"1800",label:"every 30 minutes"},{value:"3600",label:"every hour"},{value:"21600",label:"every 6 hours"},{value:"86400",label:"every day"},{value:"604800",label:"every week"}],B={name_prefix:"",task:"",interval:"3600",role:""};function H(){let e=o(),[t,s]=(0,r.useState)(B),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.createSchedule(e.selectedProjectId,{name_prefix:t.name_prefix,task:t.task,interval_seconds:Number(t.interval),role:t.role})&&s(B)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Schedules"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a file the prompt reads and overwrites."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"New schedule"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(w,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:F}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:D})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Prompt (the task every run starts with)",value:t.task,onChange:e=>s({...t,task:e}),rows:3,placeholder:"Read @notes.md and continue from where it left off. Before finishing, overwrite @notes.md with the current state so the next run can pick up from there."})}),(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:["Runs are named ",(0,n.jsxs)("span",{className:"mono",children:[t.name_prefix.trim()||"prefix","-YYYYMMDD-HHMMSS"]}),". The repo is pulled before every run; the first run fires on the worker's next pass."]}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name_prefix.trim()||!t.task.trim(),onClick:l,children:"Create schedule"})})]}),0===e.schedules.length?(0,n.jsx)("div",{className:"empty",children:"No schedules yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"On"}),(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Interval"}),(0,n.jsx)("th",{children:"Prompt"}),(0,n.jsx)("th",{children:"Next run"}),(0,n.jsx)("th",{children:"Last run"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:e.schedules.map(t=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{children:(0,n.jsx)(S,{on:t.enabled,onClick:()=>e.updateSchedule(t.id,{enabled:!t.enabled})})}),(0,n.jsxs)("td",{className:"mono",children:[t.name_prefix,t.role?(0,n.jsxs)(n.Fragment,{children:[" ",(0,n.jsx)(j,{tone:"info",children:t.role})]}):null]}),(0,n.jsx)("td",{className:"mono faint",children:t.project_id}),(0,n.jsx)("td",{className:"nowrap",children:function(e){let t=F.find(t=>Number(t.value)===e);return t?t.label:e%3600==0?"every ".concat(e/3600,"h"):e%60==0?"every ".concat(e/60,"m"):"every ".concat(e,"s")}(t.interval_seconds)}),(0,n.jsx)("td",{className:"faint",style:{maxWidth:340},children:(0,n.jsx)("span",{className:"truncate",style:{display:"block"},title:t.task,children:t.task})}),(0,n.jsx)("td",{className:"faint nowrap",children:t.enabled?m(t.next_run_at):"paused"}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.last_run_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let q=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],W={branch:"",status:"approved",agent_name:"",sha:"",note:""};function G(){let e=o(),[t,s]=(0,r.useState)(W),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.submitApproval(t),s(W)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Approvals"}),(0,n.jsx)("div",{className:"section-desc",children:"A merge is denied unless a standing approval exists — made by a different agent, pinned to the reviewed commit."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Record a verdict"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(w,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:q}),(0,n.jsx)(f,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(f,{label:"SHA",value:t.sha,onChange:e=>s({...t,sha:e}),placeholder:"pins the approval (optional)"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(f,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.branch.trim(),onClick:l,children:"Enqueue verdict"})})]}),0===e.approvals.length?(0,n.jsx)("div",{className:"empty",children:"No approvals recorded."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Branch"}),(0,n.jsx)("th",{children:"Verdict"}),(0,n.jsx)("th",{children:"By"}),(0,n.jsx)("th",{children:"SHA"}),(0,n.jsx)("th",{children:"Note"})]})}),(0,n.jsx)("tbody",{children:e.approvals.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.branch}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.approved_by_agent_id?"agent ".concat(e.approved_by_agent_id):e.actor||"—"}),(0,n.jsx)("td",{className:"mono",children:x(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let J=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],K={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function Y(e){let{value:t}=e,[s,a]=(0,r.useState)(!1),l=async()=>{try{await navigator.clipboard.writeText(t),a(!0),setTimeout(()=>a(!1),1500)}catch(e){}};return(0,n.jsxs)("div",{style:{marginTop:10},children:[(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsx)("span",{className:"eyebrow",children:"SSH public key — add it to the forge (deploy key)"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:l,children:s?"Copied":"Copy"})]}),(0,n.jsx)("pre",{className:"mono",style:{fontSize:"var(--text-xs)",whiteSpace:"pre-wrap",wordBreak:"break-all",margin:"6px 0 0",padding:8,border:"1px solid var(--border-default)",borderRadius:6,userSelect:"all"},children:t})]})}function V(){let e=o(),[t,s]=(0,r.useState)(K),[a,l]=(0,r.useState)(!1),i=()=>{s(K),l(!1)},c=async()=>{(a?await e.updateHost(t.hostname,t):await e.createHost(t))&&i()},d=e=>{var t,a;s({hostname:e.hostname,forge_type:e.forge_type,token_env_var:null!==(t=e.token_env_var)&&void 0!==t?t:"",base_url:null!==(a=e.base_url)&&void 0!==a?a:"",token:"",generate_ssh_key:!1}),l(!0)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Git Servers"}),(0,n.jsxs)("div",{className:"section-desc",children:["Each server carries its own credentials: a forge token (encrypted at rest, used by agents' ",(0,n.jsx)("span",{className:"mono",children:"forge"})," + git) and an SSH deploy key — paste the public key into the forge. New repositories are added by picking a server and typing owner/name."]})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit server \xb7 ".concat(t.hostname):"Add a git server"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(w,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:J}),(0,n.jsx)(f,{label:a?"Forge token (blank = keep current)":"Forge token",type:"password",value:t.token,onChange:e=>s({...t,token:e}),placeholder:"stored encrypted; used by forge + git"}),(0,n.jsx)(f,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(f,{label:"Token env var override (optional)",value:t.token_env_var,onChange:e=>s({...t,token_env_var:e}),placeholder:"GITEA_TOKEN"}),(0,n.jsxs)("label",{className:"field",children:[(0,n.jsx)("span",{className:"field-label",children:"SSH deploy key"}),(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer"},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.generate_ssh_key,onChange:e=>s({...t,generate_ssh_key:e.target.checked})}),(0,n.jsx)("span",{style:{fontSize:"var(--text-sm)"},children:a?"Regenerate keypair (replaces the current key)":"Generate a keypair"})]})]})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:i,children:"Cancel"})]})]}),0===e.hosts.length&&(0,n.jsx)("div",{className:"empty",children:"No git servers registered (built-in host map still applies)."}),e.hosts.map(t=>(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"mono",style:{fontWeight:"var(--fw-bold)",fontSize:"var(--text-lg)",color:"var(--text-heading)"},children:t.hostname}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(j,{tone:"info",children:t.forge_type}),(0,n.jsx)(j,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(j,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteHost(t.hostname),children:"Remove"})]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:8},children:["token env ",t.token_env_var||"—",t.base_url?" \xb7 ".concat(t.base_url):""]}),t.ssh_public_key&&(0,n.jsx)(Y,{value:t.ssh_public_key})]},t.hostname))]})]})}function Q(){let e=o();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"section-head",children:(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"section-title",children:"Activity"}),(0,n.jsx)("div",{className:"section-desc",children:"Control commands the worker drains from the queue."})]}),(0,n.jsx)(y,{variant:"secondary",disabled:e.cmd.busy,onClick:()=>e.pollCi(),children:"Sweep CI now"})]})}),(0,n.jsx)("div",{className:"section-body",children:0===e.commands.length?(0,n.jsx)("div",{className:"empty",children:"No commands yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Type"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Result / Error"})]})}),(0,n.jsx)("tbody",{children:e.commands.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.type}),(0,n.jsx)("td",{className:"mono",children:e.project_id||"—"}),(0,n.jsx)("td",{className:"mono",children:e.agent_name||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:e.error||(e.result?JSON.stringify(e.result):"—")})]},e.id))})]})})})]})}function X(){let e=o(),[t,s]=(0,r.useState)(""),[a,l]=(0,r.useState)(""),i=async()=>{t.trim()&&a.trim()&&await e.setSharedKey(t.trim(),a.trim())&&(s(""),l(""))};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Shared"}),(0,n.jsx)("div",{className:"section-desc",children:"The cross-project global feed and shared facts."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Global feed"}),0===e.shared.log.length?(0,n.jsx)("div",{className:"empty",children:"No global log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:e.shared.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.agent_id}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.ci_status})})]},e.id))})]})})]}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Set a shared key"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(f,{label:"Value",value:a,onChange:l,placeholder:"value"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Requires the shared-context write token (or admin/global if unset)."}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:!t.trim()||!a.trim(),onClick:i,children:"Set"})})]}),e.shared.context.length>0&&(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Key"}),(0,n.jsx)("th",{children:"Value"}),(0,n.jsx)("th",{children:"Updated"})]})}),(0,n.jsx)("tbody",{children:e.shared.context.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:e.key}),(0,n.jsx)("td",{children:e.value}),(0,n.jsx)("td",{className:"faint nowrap",children:m(e.updated_at)})]},e.key))})]})})]})]})}function $(e){let t=window.screenX+Math.max(0,(window.outerWidth-520)/2),s=window.screenY+Math.max(0,(window.outerHeight-760)/2);return window.open(e,"claude-login","popup=yes,width=".concat(520,",height=").concat(760,",left=").concat(Math.round(t),",top=").concat(Math.round(s)))}function Z(){let e=o(),{status:t,url:s,message:a}=e.claudeLogin,[l,i]=(0,r.useState)(""),c=(0,r.useRef)(null),d="starting"===t||"submitting"===t,u="awaiting"===t||"submitting"===t;(0,r.useEffect)(()=>{if("awaiting"===t&&s&&c.current&&!c.current.closed)try{c.current.location.href=s}catch(e){}if("done"===t||"error"===t){var e;null===(e=c.current)||void 0===e||e.close(),c.current=null}},[t,s]);let h=()=>{c.current=$("about:blank"),e.startClaudeLogin()},m=async()=>{await e.submitClaudeCode(l)&&i("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Claude Login"}),(0,n.jsxs)("div",{className:"section-desc",children:["Log Claude Code in on the host so agents can run. This drives"," ",(0,n.jsx)("span",{className:"mono",children:"claude /login"})," in the control container and picks the Claude account with a subscription."]})]}),(0,n.jsxs)("div",{className:"section-body",style:{display:"flex",flexDirection:"column",gap:16},children:[a&&(0,n.jsx)(C,{tone:"error"===t?"danger":"done"===t?"success":"info",children:a}),"done"===t?(0,n.jsx)("div",{children:(0,n.jsx)(y,{variant:"secondary",onClick:e.resetClaudeLogin,children:"Log in again"})}):u?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(C,{tone:"info",children:"A Claude sign-in window should have opened. Authorize there, copy the code Claude shows you, and paste it below. If the window didn't open (popups blocked), use the button."}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,flexWrap:"wrap"},children:[(0,n.jsx)(y,{variant:"secondary",disabled:!s,onClick:()=>{s&&(c.current=$(s))},children:"Open Claude sign-in window ↗"}),s&&(0,n.jsx)("a",{className:"btn btn-ghost",href:s,target:"_blank",rel:"noopener noreferrer",children:"Open in a new tab"}),(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Restart"})]}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,alignItems:"flex-end",flexWrap:"wrap"},children:[(0,n.jsx)("div",{style:{flex:"1 1 320px"},children:(0,n.jsx)(f,{label:"Authorization code",value:l,onChange:i,placeholder:"Paste the code from claude.com",disabled:"submitting"===t})}),(0,n.jsx)(y,{variant:"primary",disabled:"submitting"===t||!l.trim(),onClick:m,children:"submitting"===t?"Submitting…":"Finish login"})]})]}):(0,n.jsxs)("div",{className:"hstack",style:{gap:10},children:[(0,n.jsx)(y,{variant:"primary",disabled:d,onClick:h,children:"starting"===t?"Starting…":"Log in to Claude"}),"error"===t&&(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Retry"})]})]})]})}let ee=[{key:"runs",label:"Runs",count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},{key:"repositories",label:"Repositories",count:e=>e.projects.length},{key:"agents",label:"Agents",count:e=>e.agents.length},{key:"schedules",label:"Schedules",count:e=>e.schedules.length},{key:"approvals",label:"Approvals",count:e=>e.approvals.length},{key:"servers",label:"Git Servers",count:e=>e.hosts.length},{key:"activity",label:"Activity",count:e=>e.commands.length},{key:"shared",label:"Shared",count:e=>e.shared.context.length},{key:"login",label:"Claude Login",count:()=>0,accent:e=>"done"!==e.claudeLogin.status}];function et(e){let{onSignOut:t}=e,s=o();return(0,n.jsxs)("div",{className:"app",children:[(0,n.jsxs)("aside",{className:"sidebar",children:[(0,n.jsxs)("div",{className:"brand",children:[(0,n.jsx)("span",{className:"logo"}),"Claude Monitor"]}),ee.map(e=>{var t,a;let r=e.count(s),l=null!==(a=null===(t=e.accent)||void 0===t?void 0:t.call(e,s))&&void 0!==a&&a;return(0,n.jsxs)("button",{className:"nav-item".concat(s.section===e.key?" active":""),onClick:()=>s.setSection(e.key),children:[(0,n.jsx)("span",{children:e.label}),(0,n.jsx)("span",{className:"count",style:l?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,n.jsx)("div",{className:"sidebar-spacer"}),(0,n.jsxs)("div",{className:"sidebar-foot",children:[(0,n.jsxs)("button",{className:"nav-item",onClick:s.refresh,title:"Refresh now",children:[(0,n.jsx)("span",{children:"Refresh"}),(0,n.jsx)("span",{className:"count",children:"↻"})]}),(0,n.jsx)("button",{className:"nav-item",onClick:t,title:"Sign out / change token",children:(0,n.jsx)("span",{children:"Sign out"})})]})]}),(0,n.jsxs)("main",{className:"main",children:[s.cmd.text&&(0,n.jsx)("p",{className:"banner ".concat(s.cmd.error?"err":"ok"),style:{marginTop:16},children:s.cmd.text}),s.lastError&&(0,n.jsx)("p",{className:"banner err",style:{marginTop:12},children:s.lastError}),"runs"===s.section?(0,n.jsx)(I,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(P,{}),"agents"===s.section&&(0,n.jsx)(U,{}),"schedules"===s.section&&(0,n.jsx)(H,{}),"approvals"===s.section&&(0,n.jsx)(G,{}),"servers"===s.section&&(0,n.jsx)(V,{}),"activity"===s.section&&(0,n.jsx)(Q,{}),"shared"===s.section&&(0,n.jsx)(X,{}),"login"===s.section&&(0,n.jsx)(Z,{})]})]})]})}function es(e){let{error:t,onSubmit:s}=e,[a,l]=(0,r.useState)("");return(0,n.jsx)("div",{className:"gate",children:(0,n.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let t=a.trim();t&&s(t)},children:[(0,n.jsxs)("div",{className:"gate-brand",children:[(0,n.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,n.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,n.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:a,onChange:e=>l(e.target.value),autoFocus:!0}),t&&(0,n.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:t}),(0,n.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let ea="handler_token";function en(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(ea);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(ea,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(ea),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(ea),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(et,{onSignOut:i})}):(0,n.jsx)(es,{error:s,onSubmit:l})}},257:function(e,t,s){"use strict";var a,n;e.exports=(null==(a=s.g.process)?void 0:a.env)&&"object"==typeof(null==(n=s.g.process)?void 0:n.env)?s.g.process:s(4227)},4227:function(e){!function(){var t={229:function(e){var t,s,a,n=e.exports={};function r(){throw Error("setTimeout has not been defined")}function l(){throw Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(s){try{return t.call(null,e,0)}catch(s){return t.call(this,e,0)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(e){t=r}try{s="function"==typeof clearTimeout?clearTimeout:l}catch(e){s=l}}();var c=[],o=!1,d=-1;function u(){o&&a&&(o=!1,a.length?c=a.concat(c):d=-1,c.length&&h())}function h(){if(!o){var e=i(u);o=!0;for(var t=c.length;t;){for(a=c,c=[];++d1)for(var s=1;sHandler · Claude Activity
\ No newline at end of file
+Handler · Claude Activity
\ No newline at end of file
diff --git a/src/handler/api/static/index.txt b/src/handler/api/static/index.txt
index 22a12d7..847ed74 100644
--- a/src/handler/api/static/index.txt
+++ b/src/handler/api/static/index.txt
@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"]
-3:I[9859,["931","static/chunks/app/page-a511d608db773c67.js"],"default",1]
+3:I[9859,["931","static/chunks/app/page-0637b8cf149a88ec.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
-0:["tQnVLKX-Dr8H3frHzPrCa",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
+0:["ga21jhjKhYsutf8F3vo_-",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
diff --git a/src/handler/config.py b/src/handler/config.py
index 7f5718d..1684dc4 100644
--- a/src/handler/config.py
+++ b/src/handler/config.py
@@ -51,6 +51,27 @@ class Settings(BaseSettings):
forge_bin: str = "forge"
git_bin: str = "git"
+ # ---- Headless runner (claude -p --output-format stream-json): worker-owned
+ # subprocesses streaming events to the DB. Agent runs are always headless; tmux
+ # remains only for the interactive /login flow.
+ # How many concurrent claude runs one worker container supervises; commands that would
+ # start a run are left queued (for another worker) while all slots are busy.
+ max_concurrent_runs: int = 4
+ # Heartbeats older than this many seconds mark a worker dead; the reaper flips its
+ # running runs (and their agents) to ``crashed``.
+ worker_stale_after: float = 60.0
+ # Per-run spend cap passed as ``--max-budget-usd``. 0 disables the flag.
+ run_budget_usd: float = 0.0
+ # Refuse to upload a claude session archive larger than this (a runaway sidecar dir
+ # shouldn't balloon the DB); the run still works, only cross-worker resume degrades.
+ session_archive_max_bytes: int = 32 * 1024 * 1024
+ # settings.json ``permissions.defaultMode`` for headless runs. ``-p`` auto-denies
+ # anything that would prompt interactively, so this plus the allowlist below is what
+ # lets normal work proceed; the PreToolUse/Stop hooks stay the hard gate.
+ headless_permission_mode: str = "acceptEdits"
+ # Comma-separated permission allow rules added to generated settings for headless runs.
+ headless_allowed_tools: str = "Bash(git *),Bash(mise *)"
+
# The pinned `forge` version (README 3.6 / Phase 2: pin, never float on @latest).
# When set, spawn verifies the injected forge matches and records a mismatch; when
# empty the check is skipped. Operators align this with what their base image installs.
@@ -73,6 +94,10 @@ class Settings(BaseSettings):
def protected_branch_set(self) -> set[str]:
return {b.strip() for b in self.protected_branches.split(",") if b.strip()}
+ @property
+ def headless_allowed_tools_list(self) -> list[str]:
+ return [t.strip() for t in self.headless_allowed_tools.split(",") if t.strip()]
+
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
diff --git a/src/handler/control/cli.py b/src/handler/control/cli.py
index be9d20a..2c7285c 100644
--- a/src/handler/control/cli.py
+++ b/src/handler/control/cli.py
@@ -1,10 +1,11 @@
"""``handler`` CLI — the control layer's write side.
-Spawn/list/attach/kill manage agent processes. Phase 2 adds the forge-workflow control
-commands: ``approve``/``reject`` (the senior agent records its verdict, which the deploy
-gate checks), ``poll-ci`` (backfill CI verdicts), and ``forge-init`` (write the role
-skills into a managed repo). The DB is the source of truth for what agents exist; tmux is
-cross-checked for liveness. All commands are project-namespaced.
+Spawn/list/kill manage agent runs (headless ``claude -p`` processes supervised by the
+worker). Phase 2 adds the forge-workflow control commands: ``approve``/``reject`` (the
+senior agent records its verdict, which the deploy gate checks), ``poll-ci`` (backfill
+CI verdicts), and ``forge-init`` (write the role skills into a managed repo). The DB is
+the single source of truth: what agents exist AND whether their runs are live both come
+from it. All commands are project-namespaced.
"""
from __future__ import annotations
@@ -15,7 +16,7 @@ import sys
from ..db import repository as repo
from ..db.engine import connection
-from . import poller, reposync, skills_gen, spawn, tmux, worker
+from . import poller, reposync, skills_gen, spawn, worker
def _cmd_spawn(args: argparse.Namespace) -> int:
@@ -37,38 +38,27 @@ def _cmd_spawn(args: argparse.Namespace) -> int:
print(f" role: {args.role}")
if agent.get("forge_note"):
print(f" warning: {agent['forge_note']}", file=sys.stderr)
- print(f" tmux session: {tmux.session_name(args.project, args.name)}")
return 0
def _cmd_list(args: argparse.Namespace) -> int:
- live = set(tmux.list_sessions())
with connection() as conn:
+ live = {run["agent_id"] for run in repo.list_running_runs(conn)}
if args.project:
projects = [args.project] if repo.get_project(conn, args.project) else []
else:
projects = [p["id"] for p in repo.list_projects(conn)]
for project_id in projects:
for agent in repo.list_agents(conn, project_id):
- session = tmux.session_name(project_id, agent["name"])
- alive = "live" if session in live else "-"
+ alive = "live" if agent["id"] in live else "-"
role = agent.get("role") or "-"
+ worker_id = agent.get("worker_id") or "-"
print(
- f"{project_id}/{agent['name']}\t{role}\t{agent['status']}\t{alive}\t{session}"
+ f"{project_id}/{agent['name']}\t{role}\t{agent['status']}\t{alive}\t{worker_id}"
)
return 0
-def _cmd_attach(args: argparse.Namespace) -> int:
- session = tmux.session_name(args.project, args.name)
- if not tmux.has_session(session):
- print(f"error: no live session '{session}'", file=sys.stderr)
- return 1
- # Replace this process with an interactive tmux attach.
- os.execvp("tmux", ["tmux", "attach", "-t", session])
- return 0 # pragma: no cover - execvp does not return
-
-
def _cmd_kill(args: argparse.Namespace) -> int:
try:
spawn.kill(args.project, args.name)
@@ -233,11 +223,6 @@ def build_parser() -> argparse.ArgumentParser:
p_list.add_argument("--project", help="limit to one project")
p_list.set_defaults(func=_cmd_list)
- p_attach = sub.add_parser("attach", help="attach to an agent's tmux session")
- p_attach.add_argument("--project", required=True)
- p_attach.add_argument("--name", required=True)
- p_attach.set_defaults(func=_cmd_attach)
-
p_kill = sub.add_parser("kill", help="kill an agent's session")
p_kill.add_argument("--project", required=True)
p_kill.add_argument("--name", required=True)
diff --git a/src/handler/control/credsync.py b/src/handler/control/credsync.py
new file mode 100644
index 0000000..f76363f
--- /dev/null
+++ b/src/handler/control/credsync.py
@@ -0,0 +1,171 @@
+"""Distribute claude's OAuth credentials to every worker through the database.
+
+The interactive ``/login`` flow (see :mod:`~handler.control.login`) completes on exactly
+one worker container and writes credentials under that container's ``$HOME``. With
+multiple headless workers, the others need those files too — and the deployment rule is
+"no shared filesystems, the DB is the single source of truth". So:
+
+- ``upload()`` bundles the credential files, encrypts the bundle with the existing
+ Fernet secret store (``HANDLER_SECRET_KEY``), and upserts it into ``runtime_secrets``.
+ Called after a confirmed login, and after a run if claude refreshed the token on disk.
+- ``refresh()`` (workers, at startup and periodically) materializes the bundle locally
+ when the DB copy is newer than what this worker last saw. ``~/.claude.json`` is merged
+ (only credential-ish top-level keys are taken) so a worker's own onboarding/trust
+ state written by ``claude_config.ensure_onboarded`` survives; the pure credential file
+ is written verbatim.
+
+Everything is best-effort and silently off when no ``HANDLER_SECRET_KEY`` is configured
+(single-worker deployments work exactly as before).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+
+from .. import secretstore
+from ..db import repository as repo
+from ..db.engine import connection
+
+SECRET_KEY = "claude_credentials"
+
+# ~/.claude.json keys that belong to the *account*, not this machine's UI/trust state.
+_ACCOUNT_KEYS = ("oauthAccount", "userID", "organization", "account")
+
+
+def _credential_files() -> dict[str, str]:
+ """Relative path -> absolute path of every claude credential file we bundle."""
+ home = os.path.expanduser("~")
+ return {
+ ".claude.json": os.path.join(home, ".claude.json"),
+ ".claude/.credentials.json": os.path.join(home, ".claude", ".credentials.json"),
+ }
+
+
+def fingerprint() -> tuple:
+ """(path, mtime_ns, size) of the OAuth token file — cheap change detection.
+
+ Deliberately only ``.claude/.credentials.json``: claude touches ``~/.claude.json``
+ on every run (project entries, UI state), and treating those as "new credentials"
+ would ping-pong uploads between workers forever. A login that only rewrites
+ ``.claude.json`` is still published — the login flow calls :func:`upload` directly.
+ """
+ path = _credential_files()[".claude/.credentials.json"]
+ try:
+ st = os.stat(path)
+ except OSError:
+ return ()
+ return ((path, st.st_mtime_ns, st.st_size),)
+
+
+def upload() -> bool:
+ """Encrypt the local credential files into ``runtime_secrets``. Returns whether a
+ bundle was stored (False when disabled or there is nothing to store)."""
+ if not secretstore.enabled():
+ return False
+ bundle: dict[str, str] = {}
+ for rel, path in _credential_files().items():
+ try:
+ with open(path) as fh:
+ bundle[rel] = fh.read()
+ except OSError:
+ continue
+ if not bundle:
+ return False
+ value_enc = secretstore.encrypt(json.dumps(bundle))
+ with connection() as conn:
+ repo.upsert_runtime_secret(conn, SECRET_KEY, value_enc)
+ return True
+
+
+def _merge_claude_json(path: str, incoming: str) -> None:
+ """Take the account keys from ``incoming`` into ``path``, preserving local state.
+
+ A worker's ``~/.claude.json`` also carries per-directory trust + onboarding flags for
+ *its* clones; clobbering those would re-wedge spawns on the trust dialog.
+ """
+ try:
+ new_data = json.loads(incoming)
+ except (ValueError, TypeError):
+ return
+ if not isinstance(new_data, dict):
+ return
+ current: dict = {}
+ if os.path.exists(path):
+ try:
+ with open(path) as fh:
+ current = json.load(fh)
+ except (OSError, ValueError):
+ current = {}
+ if not isinstance(current, dict):
+ current = {}
+ if not current:
+ merged = new_data
+ else:
+ merged = current
+ for key in _ACCOUNT_KEYS:
+ if key in new_data:
+ merged[key] = new_data[key]
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ tmp = f"{path}.credsync.tmp"
+ with open(tmp, "w") as fh:
+ json.dump(merged, fh, indent=2)
+ os.replace(tmp, path)
+
+
+class _State:
+ """Per-process sync cursor: the DB updated_at we last materialized, and the local
+ file fingerprint after the last upload/materialize (to detect claude refreshing
+ the token mid-run so the next refresh() re-uploads)."""
+
+ def __init__(self) -> None:
+ self.seen_updated_at = None
+ self.last_fingerprint: tuple | None = None
+
+
+_state = _State()
+
+
+def refresh() -> str | None:
+ """One sync pass; returns "materialized", "uploaded", or None (no-op).
+
+ - A DB bundle newer than what we've seen wins: materialize it locally.
+ - Otherwise, local credential files that changed since our last pass (a login on
+ this worker, or claude refreshing a token during a run) get uploaded.
+ """
+ if not secretstore.enabled():
+ return None
+ with connection() as conn:
+ row = repo.get_runtime_secret(conn, SECRET_KEY)
+
+ if row is not None and row["updated_at"] != _state.seen_updated_at:
+ try:
+ bundle = json.loads(secretstore.decrypt(row["value_enc"]))
+ except (secretstore.SecretStoreError, ValueError):
+ return None
+ files = _credential_files()
+ for rel, content in bundle.items():
+ path = files.get(rel)
+ if path is None:
+ continue # never write outside the known credential set
+ if rel == ".claude.json":
+ _merge_claude_json(path, content)
+ else:
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ tmp = f"{path}.credsync.tmp"
+ with open(tmp, "w") as fh:
+ fh.write(content)
+ os.replace(tmp, path)
+ _state.seen_updated_at = row["updated_at"]
+ _state.last_fingerprint = fingerprint()
+ return "materialized"
+
+ current = fingerprint()
+ if current and current != _state.last_fingerprint:
+ if upload():
+ _state.last_fingerprint = current
+ with connection() as conn:
+ stored = repo.get_runtime_secret(conn, SECRET_KEY)
+ _state.seen_updated_at = stored["updated_at"] if stored else None
+ return "uploaded"
+ return None
diff --git a/src/handler/control/headless.py b/src/handler/control/headless.py
new file mode 100644
index 0000000..3582810
--- /dev/null
+++ b/src/handler/control/headless.py
@@ -0,0 +1,385 @@
+"""The headless runner: worker-owned ``claude -p`` subprocesses streaming to the DB.
+
+This is the tmux replacement seam for agent *runs* (tmux stays only for the interactive
+``/login`` flow). Each invocation is ``claude -p --output-format stream-json`` with a
+pre-assigned ``--session-id``; a :class:`RunSupervisor` owns the child process, appends
+every stdout JSON line to ``agent_events`` as it arrives, derives ``agents.last_output``
+from the latest assistant text (so the existing UI keeps working), and reconciles the
+agent's status from the *process* — exit code and EOF are positive liveness, replacing
+the old pane scraping that could neither see a dead process nor populate the log.
+
+Resume continuity without shared files: claude persists its session under
+``~/.claude/projects//``; the supervisor tars that into ``session_archives``
+(periodically and at exit), and whichever worker later claims a resume materializes the
+archive at the same munged path before running ``claude -p --resume``. Workers therefore
+need the same ``projects_root`` layout — a deployment invariant — but share nothing.
+
+``launch()`` is the function ``spawn``/``worker`` call and tests mock (via the
+``claude_bin`` setting pointing at a fake, same pattern as the tmux fakes).
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+import subprocess
+import tarfile
+import tempfile
+import threading
+import time
+import uuid
+from pathlib import Path
+
+from ..config import get_settings
+from ..db import repository as repo
+from ..db.engine import connection
+
+# Stream types we recognize from ``--output-format stream-json``; anything else (or an
+# unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own:
+# runner-generated notices (crashes, archive failures, degraded resumes).
+_KNOWN_TYPES = ("system", "assistant", "user", "result", "hook")
+
+# How long a SIGTERM'd claude gets to die before SIGKILL.
+_TERM_GRACE_SECONDS = 10.0
+
+
+def munged_project_dir(working_dir: str) -> str:
+ """The directory name claude uses for a cwd under ``~/.claude/projects/``.
+
+ Claude munges the absolute path by mapping ``/`` and ``.`` to ``-`` (verified against
+ real session state: ``/root/handler`` → ``-root-handler``,
+ ``/root/Talos/.claude/worktrees/x`` → ``-root-Talos--claude-worktrees-x``).
+ """
+ return working_dir.replace("/", "-").replace(".", "-")
+
+
+def session_dir(working_dir: str) -> Path:
+ """Where claude persists sessions for ``working_dir`` (under the *current* $HOME)."""
+ return Path(os.path.expanduser("~")) / ".claude" / "projects" / munged_project_dir(working_dir)
+
+
+def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str]:
+ """The headless spawn invocation. ``--verbose`` is required with stream-json in
+ print mode; ``--session-id`` pre-assigns the UUID so the session is addressable
+ (and archivable) from the first event."""
+ s = get_settings()
+ argv = [
+ s.claude_bin, "-p", "--verbose",
+ "--output-format", "stream-json",
+ "--session-id", session_id,
+ "--settings", settings_path,
+ ]
+ if s.run_budget_usd > 0:
+ argv += ["--max-budget-usd", str(s.run_budget_usd)]
+ argv += ["--", task]
+ return argv
+
+
+def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[str]:
+ """The headless resume invocation — a brand-new process continuing ``session_id``."""
+ s = get_settings()
+ argv = [
+ s.claude_bin, "-p", "--verbose",
+ "--output-format", "stream-json",
+ "--resume", session_id,
+ "--settings", settings_path,
+ ]
+ if s.run_budget_usd > 0:
+ argv += ["--max-budget-usd", str(s.run_budget_usd)]
+ argv += ["--", answer]
+ return argv
+
+
+def parse_stream_line(line: str) -> tuple[str, dict]:
+ """One stdout line → ``(event_type, payload)``. Never raises: malformed JSON or an
+ unrecognized shape comes back as ``("raw", {"line": ...})`` so the event log keeps
+ everything the process said, even across CLI format drift."""
+ text = line.strip()
+ if not text:
+ return "raw", {"line": line}
+ try:
+ payload = json.loads(text)
+ except (ValueError, TypeError):
+ return "raw", {"line": line}
+ if not isinstance(payload, dict):
+ return "raw", {"line": line}
+ etype = payload.get("type")
+ if not isinstance(etype, str) or not etype:
+ return "raw", payload
+ if etype not in _KNOWN_TYPES:
+ # Future/unknown top-level types still store under their own name — the UI
+ # ignores what it doesn't know, but nothing is lost.
+ return etype, payload
+ return etype, payload
+
+
+def assistant_text(payload: dict) -> str | None:
+ """The concatenated text blocks of an ``assistant`` event, or None when it carries
+ none (e.g. a pure tool_use turn). Feeds ``agents.last_output``."""
+ message = payload.get("message")
+ if not isinstance(message, dict):
+ return None
+ content = message.get("content")
+ if isinstance(content, str):
+ return content or None
+ if not isinstance(content, list):
+ return None
+ parts = [
+ block.get("text", "")
+ for block in content
+ if isinstance(block, dict) and block.get("type") == "text"
+ ]
+ text = "\n".join(p for p in parts if p)
+ return text or None
+
+
+def archive_session(working_dir: str, session_id: str, max_bytes: int | None = None) -> bytes | None:
+ """Tar.gz the session transcript + sidecar dir, or None when nothing exists yet or
+ the result would exceed ``max_bytes`` (the caller records a worker event; the run
+ itself is unaffected — only cross-worker resume degrades)."""
+ base = session_dir(working_dir)
+ jsonl = base / f"{session_id}.jsonl"
+ sidecar = base / session_id
+ if not jsonl.exists() and not sidecar.exists():
+ return None
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
+ if jsonl.exists():
+ tar.add(jsonl, arcname=jsonl.name)
+ if sidecar.is_dir():
+ tar.add(sidecar, arcname=sidecar.name)
+ data = buf.getvalue()
+ limit = max_bytes if max_bytes is not None else get_settings().session_archive_max_bytes
+ if limit and len(data) > limit:
+ return None
+ return data
+
+
+def materialize_session(working_dir: str, archive: bytes) -> None:
+ """Unpack a session archive where claude will look for it on ``--resume``.
+
+ ``filter="data"`` rejects path traversal and special members — the archive came from
+ our own DB, but a defense-in-depth default costs nothing.
+ """
+ base = session_dir(working_dir)
+ base.mkdir(parents=True, exist_ok=True)
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
+ tar.extractall(base, filter="data")
+
+
+class RunSupervisor:
+ """Owns one headless claude subprocess for its whole life.
+
+ A reader thread pumps stdout lines into ``agent_events``; the supervisor thread
+ watches the process, polls ``cancel_requested`` (cross-worker kill), uploads the
+ session archive periodically, and on exit reconciles run + agent status. Threads are
+ daemons: if the whole worker dies, the reaper — not us — settles the record.
+ """
+
+ def __init__(
+ self,
+ agent: dict,
+ run: dict,
+ argv: list[str],
+ cwd: str,
+ env: dict[str, str],
+ *,
+ cancel_poll: float = 5.0,
+ archive_interval: float = 60.0,
+ on_exit=None,
+ ) -> None:
+ self.agent = agent
+ self.run = run
+ self.argv = argv
+ self.cwd = cwd
+ self.env = env
+ self.cancel_poll = cancel_poll
+ self.archive_interval = archive_interval
+ self.on_exit = on_exit # worker's slot-release callback
+ self._seq = 0
+ self._result_payload: dict | None = None
+ self._canceled = False
+ self.thread: threading.Thread | None = None
+
+ def start(self) -> None:
+ self.thread = threading.Thread(
+ target=self._supervise, name=f"run-{self.run['id']}", daemon=True
+ )
+ self.thread.start()
+
+ # ---------------------------------------------------------------- internals
+
+ def _insert_event(self, etype: str, payload: dict) -> None:
+ self._seq += 1
+ with connection() as conn:
+ repo.insert_agent_event(
+ conn,
+ self.agent["id"],
+ self.run["id"],
+ seq=self._seq,
+ type=etype,
+ payload=payload,
+ session_id=self.run["session_id"],
+ )
+
+ def _pump_stdout(self, stream) -> None:
+ for line in stream:
+ etype, payload = parse_stream_line(line)
+ try:
+ self._insert_event(etype, payload)
+ if etype == "result":
+ self._result_payload = payload
+ elif etype == "assistant":
+ text = assistant_text(payload)
+ if text:
+ with connection() as conn:
+ repo.update_agent_output(conn, self.agent["id"], text)
+ except Exception: # noqa: BLE001 - a DB hiccup must not sever the pipe
+ continue
+
+ def _upload_archive(self) -> None:
+ try:
+ data = archive_session(self.cwd, self.run["session_id"])
+ if data is None:
+ return
+ with connection() as conn:
+ repo.upsert_session_archive(
+ conn, self.agent["id"], self.run["session_id"], data
+ )
+ except Exception as exc: # noqa: BLE001 - archiving is best-effort
+ try:
+ self._insert_event(
+ "worker", {"notice": "session archive failed", "error": str(exc)}
+ )
+ except Exception: # noqa: BLE001
+ pass
+
+ def _terminate(self, proc: subprocess.Popen) -> None:
+ self._canceled = True
+ proc.terminate()
+ try:
+ proc.wait(timeout=_TERM_GRACE_SECONDS)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+ def _supervise(self) -> None:
+ stderr_file = tempfile.TemporaryFile()
+ try:
+ proc = subprocess.Popen(
+ self.argv,
+ cwd=self.cwd,
+ env={**os.environ, **self.env},
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=stderr_file,
+ text=True,
+ )
+ except OSError as exc:
+ stderr_file.close()
+ self._settle(exit_code=None, stderr_tail=f"failed to launch claude: {exc}")
+ return
+ reader = threading.Thread(
+ target=self._pump_stdout, args=(proc.stdout,), daemon=True
+ )
+ reader.start()
+ last_archive = time.monotonic()
+ next_cancel_check = time.monotonic() + self.cancel_poll
+ while proc.poll() is None:
+ time.sleep(0.2)
+ now = time.monotonic()
+ if now >= next_cancel_check:
+ next_cancel_check = now + self.cancel_poll
+ with connection() as conn:
+ if repo.get_cancel_requested(conn, self.run["id"]):
+ self._terminate(proc)
+ break
+ if self.archive_interval > 0 and now - last_archive >= self.archive_interval:
+ self._upload_archive()
+ last_archive = now
+ proc.wait()
+ reader.join(timeout=30.0)
+ stderr_file.seek(0)
+ stderr_tail = stderr_file.read()[-4000:].decode("utf-8", "replace")
+ stderr_file.close()
+ self._settle(exit_code=proc.returncode, stderr_tail=stderr_tail)
+
+ def _settle(self, exit_code: int | None, stderr_tail: str) -> None:
+ """Reconcile run + agent status once the process is gone, then final-archive."""
+ result = self._result_payload
+ clean = (
+ exit_code == 0
+ and result is not None
+ and not result.get("is_error", False)
+ )
+ if self._canceled:
+ run_status = "canceled"
+ elif clean:
+ run_status = "completed"
+ else:
+ run_status = "failed"
+ try:
+ with connection() as conn:
+ finished = repo.finish_run(
+ conn, self.run["id"], run_status, exit_code=exit_code, result=result
+ )
+ # Hooks are the authority on agent status — they ran inside the run and
+ # may have set paused_for_input/blocked/done already. Only an agent still
+ # marked ``working`` needs the process's verdict.
+ agent = repo.get_agent_by_id(conn, self.agent["id"])
+ if finished and agent is not None and agent["status"] == "working":
+ if self._canceled:
+ repo.set_agent_status(conn, self.agent["id"], "done")
+ elif clean:
+ repo.set_agent_status(conn, self.agent["id"], "done")
+ else:
+ repo.set_agent_status(conn, self.agent["id"], "blocked")
+ if not clean and not self._canceled:
+ detail = {"notice": "run failed", "exit_code": exit_code}
+ if stderr_tail.strip():
+ detail["stderr_tail"] = stderr_tail
+ self._insert_event("worker", detail)
+ except Exception: # noqa: BLE001 - never let bookkeeping raise out of the thread
+ pass
+ self._upload_archive()
+ if self.on_exit is not None:
+ try:
+ self.on_exit(self)
+ except Exception: # noqa: BLE001
+ pass
+
+
+def launch(
+ agent: dict,
+ *,
+ kind: str,
+ prompt: str,
+ settings_path: str,
+ env: dict[str, str],
+ worker_id: str,
+ on_exit=None,
+) -> dict:
+ """Start a headless run for ``agent`` and return its ``agent_runs`` row.
+
+ ``kind`` is ``spawn`` (fresh session, new UUID) or ``resume`` (materialize the stored
+ archive, continue the agent's existing session). Fire-and-forget from the caller's
+ perspective — the returned run row is already ``running`` and a daemon supervisor
+ owns the process from here.
+ """
+ working_dir = agent["working_dir"]
+ if kind == "spawn":
+ session_id = str(uuid.uuid4())
+ argv = build_spawn_argv(prompt, settings_path, session_id)
+ else:
+ session_id = agent.get("session_id")
+ if not session_id:
+ raise ValueError(f"agent '{agent['name']}' has no session to resume")
+ argv = build_resume_argv(session_id, prompt, settings_path)
+ with connection() as conn:
+ run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
+ repo.set_agent_session(conn, agent["id"], session_id, worker_id)
+ supervisor = RunSupervisor(
+ agent, run, argv, cwd=working_dir, env=env, on_exit=on_exit
+ )
+ supervisor.start()
+ return run
diff --git a/src/handler/control/settings_gen.py b/src/handler/control/settings_gen.py
index c268e4a..6e89d02 100644
--- a/src/handler/control/settings_gen.py
+++ b/src/handler/control/settings_gen.py
@@ -12,6 +12,8 @@ import json
import os
import sys
+from ..config import get_settings
+
def _hook_command(event: str) -> str:
# Use the exact interpreter the control layer runs under, so the hook resolves the
@@ -20,7 +22,7 @@ def _hook_command(event: str) -> str:
def build_settings() -> dict:
- return {
+ settings = {
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}],
"SessionEnd": [
@@ -37,6 +39,16 @@ def build_settings() -> dict:
],
}
}
+ # ``claude -p`` never prompts — anything that would ask for permission is
+ # auto-denied. The allowlist is therefore what lets normal work (git, mise, the
+ # project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard
+ # gate either way, since a hook deny overrides any allow.
+ s = get_settings()
+ settings["permissions"] = {
+ "defaultMode": s.headless_permission_mode,
+ "allow": s.headless_allowed_tools_list,
+ }
+ return settings
def write_settings(working_dir: str) -> str:
diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py
index 21f1573..561c11c 100644
--- a/src/handler/control/spawn.py
+++ b/src/handler/control/spawn.py
@@ -15,14 +15,13 @@ from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import (
- claude_config,
credentials,
forge,
gitops,
+ headless,
mise,
reposync,
settings_gen,
- tmux,
worktree,
)
@@ -45,18 +44,6 @@ def require_test_task(working_dir: str) -> None:
)
-def _claude_command(task: str | None, settings_path: str) -> str:
- claude = get_settings().claude_bin
- argv = [claude, "--settings", settings_path]
- if task:
- argv.append(_shell_quote(task))
- return " ".join(argv)
-
-
-def _shell_quote(value: str) -> str:
- return "'" + value.replace("'", "'\\''") + "'"
-
-
def _install_git_credentials(
working_dir: str, git_remote: str | None, conn=None
) -> None:
@@ -84,6 +71,7 @@ def spawn(
role: str | None = None,
require_tests: bool = True,
mise_init: bool = False,
+ worker_id: str | None = None,
) -> dict:
"""Create and launch an agent. Returns the agent row.
@@ -91,8 +79,13 @@ def spawn(
it off, because a project with no ``.mise.toml`` yet is exactly what it exists to fix.
``mise_init`` marks the launched agent (via ``HANDLER_MISE_INIT``) so its hooks enforce
the bootstrap contract — create the test task, commit, and push — instead of the normal
- test gate.
+ test gate. ``worker_id`` identifies the calling worker container (headless runs record
+ it on the run row; the CLI defaults to a pid-scoped id).
"""
+ if not task:
+ # ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately
+ # having done nothing, so a task is a hard requirement.
+ raise SpawnError("an agent requires a task (headless claude has no idle mode)")
sync_note = None
with connection() as conn:
project = repo.get_project(conn, project_id)
@@ -147,13 +140,43 @@ def spawn(
)
settings_path = settings_gen.write_settings(working_dir)
+ env = _agent_env(project, agent, token, role=role, mise_init=mise_init)
+ # Verify the pinned forge version, if one is configured. Non-fatal: a version drift
+ # is recorded as a warning rather than blocking the spawn, since not every agent
+ # touches forge and the base image is the real pin (README 3.6, Phase 2).
+ forge_note = _check_forge_version(working_dir)
+
+ headless.launch(
+ agent,
+ kind="spawn",
+ prompt=task,
+ settings_path=settings_path,
+ env=env,
+ worker_id=worker_id or f"cli-{os.getpid()}",
+ )
+ agent = {**agent, "forge_note": forge_note, "sync_note": sync_note}
+ return agent
+
+
+def _agent_env(
+ project: dict,
+ agent: dict,
+ token: str | None,
+ *,
+ role: str | None = None,
+ mise_init: bool = False,
+) -> dict[str, str]:
+ """The environment an agent process (and therefore its hooks) runs with: identity,
+ ``DATABASE_URL``, and resolved forge/git credentials. Shared by spawn and the
+ headless resume path (a resume is a brand-new process needing the same env)."""
env = {
- "HANDLER_PROJECT_ID": project_id,
- "HANDLER_AGENT_NAME": name,
+ "HANDLER_PROJECT_ID": project["id"],
+ "HANDLER_AGENT_NAME": agent["name"],
"HANDLER_AGENT_ID": str(agent["id"]),
"DATABASE_URL": get_settings().database_url,
}
+ role = role or agent.get("role")
if role:
env["HANDLER_AGENT_ROLE"] = role
if mise_init:
@@ -164,28 +187,13 @@ def spawn(
with connection() as conn:
env.update(credentials.credential_env(token, project.get("git_remote"), conn))
if token:
- _install_git_credentials(working_dir, project.get("git_remote"), conn)
+ _install_git_credentials(agent["working_dir"], project.get("git_remote"), conn)
# SSH remotes: pin the agent's git to the server's deploy key, when one is stored.
try:
env.update(reposync.ssh_env(project.get("git_remote"), conn))
except reposync.SyncError as exc:
raise SpawnError(str(exc)) from exc
-
- # Verify the pinned forge version, if one is configured. Non-fatal: a version drift
- # is recorded as a warning rather than blocking the spawn, since not every agent
- # touches forge and the base image is the real pin (README 3.6, Phase 2).
- forge_note = _check_forge_version(working_dir)
-
- # Mark Claude Code onboarding complete + trust the working dir before launching, so the
- # detached agent boots straight to the REPL instead of wedging on the first-run theme
- # picker / trust prompt with no human at the tmux TTY to answer it.
- claude_config.ensure_onboarded(working_dir)
-
- session = tmux.session_name(project_id, name)
- command = _claude_command(task, settings_path)
- tmux.new_session(session, cwd=working_dir, command=command, env=env)
- agent = {**agent, "forge_note": forge_note, "sync_note": sync_note}
- return agent
+ return env
def _check_forge_version(working_dir: str) -> str | None:
@@ -199,24 +207,120 @@ def _check_forge_version(working_dir: str) -> str | None:
def kill(project_id: str, name: str) -> None:
+ """Stop an agent: flag its running run for cancel and mark the row done.
+
+ The owning worker's supervisor polls the cancel flag and SIGTERMs its own child
+ (cross-worker safe — nobody signals a process they don't own). No running run means
+ the process is already gone; the status update is all that's left to do.
+ """
with connection() as conn:
agent = repo.get_agent_by_name(conn, project_id, name)
if agent is None:
raise SpawnError(f"agent '{name}' not found in project '{project_id}'")
- session = tmux.session_name(project_id, name)
- if tmux.has_session(session):
- tmux.kill_session(session)
+ run = repo.get_latest_run(conn, agent["id"])
+ if run is not None and run["status"] == "running":
+ repo.request_run_cancel(conn, run["id"])
repo.set_agent_status(conn, agent["id"], "done")
-def resume(agent: dict, answer: str) -> tuple[bool, str]:
- """Feed an operator's answer back to a live agent.
+def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool, str]:
+ """Feed an operator's answer back to an agent as a new ``claude -p --resume`` run.
- The seam the API's ``/resume`` route calls (and the one tests mock). Sends the
- answer into the agent's tmux session so the waiting ``claude`` process receives it.
+ The seam the API's ``/resume`` route calls (and the one tests mock). The session
+ transcript is materialized from the DB archive first, so ANY worker can serve the
+ resume. Refuses while a run is still live (two concurrent processes on one session
+ would corrupt it). When no transcript survives anywhere — the owning worker died
+ before its first archive, or the row predates the headless runner — falls back to a
+ *fresh* session whose prompt re-injects context from the DB (checkmark + open
+ question + answer), recorded as a ``worker`` event so the UI shows the degraded
+ continuity.
"""
- session = tmux.session_name(agent["project_id"], agent["name"])
- if not tmux.has_session(session):
- return False, f"no live session '{session}' to resume"
- tmux.send_keys(session, answer)
- return True, f"answer delivered to session '{session}'"
+ worker_id = worker_id or f"cli-{os.getpid()}"
+ with connection() as conn:
+ run = repo.get_latest_run(conn, agent["id"])
+ if run is not None and run["status"] == "running":
+ return False, "agent already has a live run; wait for it to finish or kill it"
+ project = repo.get_project(conn, agent["project_id"])
+ archive = repo.get_session_archive(conn, agent["id"])
+ if project is None:
+ return False, f"project '{agent['project_id']}' not registered"
+
+ working_dir = agent["working_dir"]
+ settings_path = settings_gen.write_settings(working_dir)
+ try:
+ token = None
+ with connection() as conn:
+ token = credentials.resolve_for_project(project, conn)
+ except credentials.CredentialError as exc:
+ return False, str(exc)
+ env = _agent_env(project, agent, token)
+
+ if not agent.get("session_id"):
+ # Pre-headless agent row (or a spawn that never launched): nothing to --resume.
+ return _resume_reinjected(agent, answer, settings_path, env, worker_id)
+
+ transcript = headless.session_dir(working_dir) / f"{agent['session_id']}.jsonl"
+ if archive is not None:
+ try:
+ headless.materialize_session(working_dir, bytes(archive["archive"]))
+ except (OSError, ValueError) as exc:
+ return False, f"could not materialize session archive: {exc}"
+ elif not transcript.exists():
+ return _resume_reinjected(agent, answer, settings_path, env, worker_id)
+
+ run = headless.launch(
+ agent,
+ kind="resume",
+ prompt=answer,
+ settings_path=settings_path,
+ env=env,
+ worker_id=worker_id,
+ )
+ return True, f"headless resume run {run['id']} started for session {agent['session_id']}"
+
+
+def _resume_reinjected(
+ agent: dict, answer: str, settings_path: str, env: dict, worker_id: str
+) -> tuple[bool, str]:
+ """Degraded resume: no transcript anywhere, so start a fresh session with the
+ context rebuilt from the DB. Continuity is approximate — say so in the event log."""
+ with connection() as conn:
+ checkmark = repo.get_checkmark(conn, agent["id"])
+ recent = repo.get_log(conn, agent["id"], limit=5)
+ parts = [
+ "You are resuming work you started in an earlier session whose transcript is "
+ "unavailable. Reconstruct context from your checkpoint below, then continue.",
+ ]
+ if checkmark:
+ if checkmark.get("where_it_stopped"):
+ parts.append(f"Where you stopped: {checkmark['where_it_stopped']}")
+ if checkmark.get("next_steps"):
+ parts.append(f"Planned next steps: {checkmark['next_steps']}")
+ if checkmark.get("open_question"):
+ parts.append(f"You had asked: {checkmark['open_question']}")
+ for entry in reversed(recent):
+ if entry.get("summary"):
+ parts.append(f"Earlier log: {entry['summary']}")
+ parts.append(f"The operator's answer/instruction: {answer}")
+ run = headless.launch(
+ agent,
+ kind="spawn", # a genuinely new session (new UUID) — --resume has nothing to load
+ prompt="\n\n".join(parts),
+ settings_path=settings_path,
+ env=env,
+ worker_id=worker_id,
+ )
+ with connection() as conn:
+ repo.insert_agent_event(
+ conn,
+ agent["id"],
+ run["id"],
+ seq=0,
+ type="worker",
+ payload={
+ "notice": "resume without transcript — context re-injected from DB",
+ "previous_session_id": agent["session_id"],
+ },
+ session_id=run["session_id"],
+ )
+ return True, f"transcript unavailable; started fresh run {run['id']} with re-injected context"
diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py
index a29fb9d..94bd49f 100644
--- a/src/handler/control/tmux.py
+++ b/src/handler/control/tmux.py
@@ -1,7 +1,8 @@
-"""Thin tmux wrapper — the single mock seam for spawning.
+"""Thin tmux wrapper — now used ONLY by the interactive ``/login`` flow.
-Every tmux/claude invocation goes through these functions so tests can substitute a
-fake and never touch a real tmux server or ``claude`` binary.
+Agent runs are headless (``control.headless``); the one thing that still genuinely
+needs a TTY is driving claude's ``/login`` OAuth screens. Everything here goes through
+subprocess so the login tests can substitute a fake and never touch a real tmux server.
"""
from __future__ import annotations
@@ -11,14 +12,6 @@ import subprocess
from ..config import get_settings
-def session_name(project_id: str, agent_name: str) -> str:
- """``project__agent`` with tmux-illegal characters sanitized (README 3.4)."""
- safe = f"{project_id}__{agent_name}"
- for ch in (".", ":", " "):
- safe = safe.replace(ch, "-")
- return safe
-
-
def new_session(
name: str,
cwd: str,
@@ -57,18 +50,6 @@ def has_session(name: str) -> bool:
return result.returncode == 0
-def list_sessions() -> list[str]:
- tmux = get_settings().tmux_bin
- result = subprocess.run(
- [tmux, "list-sessions", "-F", "#{session_name}"],
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- return []
- return [line for line in result.stdout.splitlines() if line]
-
-
def kill_session(name: str) -> None:
tmux = get_settings().tmux_bin
subprocess.run([tmux, "kill-session", "-t", name], check=True)
diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py
index 2adc1bd..7368b99 100644
--- a/src/handler/control/worker.py
+++ b/src/handler/control/worker.py
@@ -1,11 +1,13 @@
-"""The control-container worker: executes commands the API enqueues.
+"""The control/worker container: executes commands the API enqueues and supervises
+headless claude runs.
-The API (in its own container) has no ``git``/``tmux``/``claude`` and does not own the
-tmux sessions, so it cannot run control actions directly. Instead it writes a ``queued``
-row to the ``commands`` table; this worker — running in the control container — claims each
-row, dispatches it to the *same* control functions the CLI uses (``spawn``/``poller``/
-``skills_gen``/``repo.record_approval``), and writes the result or error back. It also runs
-the periodic CI sweep, subsuming the old ``poll-ci --watch`` loop.
+The API (in its own container) has no ``git``/``claude``, so it cannot run control
+actions directly. Instead it writes a ``queued`` row to the ``commands`` table; any
+worker claims each row (multi-worker safe — ``FOR UPDATE SKIP LOCKED`` + slot-aware
+claim filters), dispatches it to the *same* control functions the CLI uses (``spawn``/
+``poller``/``skills_gen``/``repo.record_approval``), and writes the result or error
+back. It also heartbeats + reaps dead workers' runs, syncs claude credentials, and runs
+the periodic CI sweep.
Every command runs in isolation: one bad command is recorded as ``failed`` and never stops
the loop. ``execute_command`` is the pure dispatch seam (given a claimed command dict,
@@ -15,12 +17,24 @@ returns a JSON-safe result or raises); ``drain``/``run`` are the claim+finish pl
from __future__ import annotations
import os
+import secrets
+import socket
import time
from datetime import UTC, datetime, timedelta
+from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
-from . import gitops, login, poller, reposync, skills_gen, spawn, tmux
+from . import credsync, gitops, login, poller, reposync, skills_gen, spawn
+
+# Command types that launch a claude run and therefore need a free slot on this worker.
+# A worker with all slots busy leaves these queued for a less-loaded worker to claim.
+_RUN_COMMANDS = ("spawn", "resume", "mise_init")
+
+
+def make_worker_id() -> str:
+ """A stable-for-this-process, unique-across-containers worker id."""
+ return f"worker-{socket.gethostname()}-{os.getpid()}-{secrets.token_hex(2)}"
class CommandError(Exception):
@@ -43,6 +57,7 @@ def _cmd_spawn(command: dict) -> dict:
worktree_branch=p.get("worktree"),
task=p.get("task"),
role=p.get("role"),
+ worker_id=command.get("claimed_by"),
)
result = {
"agent_id": agent["id"],
@@ -73,11 +88,15 @@ def _cmd_resume(command: dict) -> dict:
agent = repo.get_agent_by_name(conn, command["project_id"], name)
if agent is None:
raise CommandError(f"agent '{name}' not found in project '{command['project_id']}'")
- ok, detail = spawn.resume(agent, answer)
- if ok:
- with connection() as conn:
- repo.set_agent_status(conn, agent["id"], "working")
- return {"resumed": ok, "detail": detail}
+ ok, detail = spawn.resume(agent, answer, worker_id=command.get("claimed_by"))
+ if not ok:
+ # A resume that couldn't be delivered is a FAILED command, not a quiet no-op —
+ # the operator's input must never vanish silently (the old tmux path typed into
+ # dead panes and reported success).
+ raise CommandError(detail)
+ with connection() as conn:
+ repo.set_agent_status(conn, agent["id"], "working")
+ return {"resumed": True, "detail": detail}
def _record_verdict(command: dict, status: str) -> dict:
@@ -219,6 +238,13 @@ def _cmd_login_submit(command: dict) -> dict:
# Surface the pane tail so the operator can see why claude rejected the code.
detail = result.get("output") or "claude did not confirm a successful login"
raise CommandError(f"login not confirmed — {detail}")
+ # Publish the fresh credentials so every other worker container can materialize
+ # them (multi-worker: the login ran here, but any worker may run the next agent).
+ try:
+ result["credentials_published"] = credsync.upload()
+ except Exception as exc: # noqa: BLE001 - login succeeded; publishing is best-effort
+ result["credentials_published"] = False
+ result["credsync_note"] = str(exc)
return result
@@ -274,6 +300,63 @@ def _run_one(command: dict) -> None:
repo.finish_command(conn, command["id"], "failed", error=str(exc))
+def heartbeat(worker_id: str) -> None:
+ """Refresh this worker's registry row — the reaper's proof-of-life."""
+ with connection() as conn:
+ active = len(repo.list_running_runs(conn, worker_id=worker_id))
+ repo.upsert_worker_heartbeat(
+ conn,
+ worker_id,
+ hostname=socket.gethostname(),
+ pid=os.getpid(),
+ max_runs=get_settings().max_concurrent_runs,
+ active_runs=active,
+ )
+
+
+def reap_dead_workers(now: datetime | None = None) -> int:
+ """Settle the record for workers that stopped heartbeating: their ``running`` runs
+ become ``crashed``, and agents those runs left in ``working`` become ``crashed`` too.
+
+ Any surviving worker may reap any dead one — ``finish_run``'s still-running guard
+ makes concurrent reapers idempotent. Deliberately NO auto-requeue: a half-done run
+ may have already pushed commits, so re-running it isn't safe to assume; the operator
+ sees the crashed badge (with the frozen last_output as evidence) and resumes
+ explicitly. Agents in ``paused_for_input``/``blocked`` keep their status — those are
+ still accurate descriptions of what the agent needs. Returns runs reaped.
+ """
+ now = now or datetime.now(UTC)
+ cutoff = now - timedelta(seconds=get_settings().worker_stale_after)
+ reaped = 0
+ with connection() as conn:
+ stale = repo.list_stale_workers(conn, cutoff)
+ for dead in stale:
+ with connection() as conn:
+ for run in repo.list_running_runs(conn, worker_id=dead["id"]):
+ if not repo.finish_run(conn, run["id"], "crashed"):
+ continue # another reaper won the race
+ agent = repo.get_agent_by_id(conn, run["agent_id"])
+ if agent is not None and agent["status"] == "working":
+ repo.set_agent_status(conn, agent["id"], "crashed")
+ repo.insert_agent_event(
+ conn,
+ run["agent_id"],
+ run["id"],
+ seq=0, # runner-generated, outside the supervisor's line counter
+ type="worker",
+ payload={
+ "notice": "worker died mid-run; run marked crashed",
+ "worker_id": dead["id"],
+ },
+ session_id=run["session_id"],
+ )
+ reaped += 1
+ # Row served its purpose; dropping it keeps the registry to live workers
+ # (and stops every future pass re-scanning long-dead ids).
+ repo.delete_worker(conn, dead["id"])
+ return reaped
+
+
def fire_due_schedules(now: datetime | None = None) -> int:
"""Enqueue a spawn command for every schedule whose ``next_run_at`` has passed.
@@ -313,43 +396,6 @@ def fire_due_schedules(now: datetime | None = None) -> int:
return fired
-# How many trailing pane lines to snapshot — enough to show the current screen (a menu, a
-# prompt, the tail of the last command) without bloating the row.
-_PANE_TAIL_LINES = 40
-
-
-def _pane_tail(pane: str, lines: int = _PANE_TAIL_LINES) -> str:
- """The last ``lines`` of a captured pane, trailing blank lines trimmed so an idle
- screen doesn't store as a wall of whitespace."""
- rows = (pane or "").splitlines()
- while rows and not rows[-1].strip():
- rows.pop()
- return "\n".join(rows[-lines:])
-
-
-def capture_agent_output() -> int:
- """Snapshot each working agent's live tmux pane tail into the DB.
-
- The tmux socket lives only in the control container, so this is the one channel the
- API/UI have onto what a running — or wedged — agent is actually doing: an agent stuck
- on claude's first-run theme picker surfaces as that screen instead of a misleading
- green 'working'. A missing session is skipped (its process is gone). Returns the count
- updated.
- """
- with connection() as conn:
- working = repo.list_agents_by_status(conn, "working")
- updated = 0
- for agent in working:
- session = tmux.session_name(agent["project_id"], agent["name"])
- if not tmux.has_session(session):
- continue
- tail = _pane_tail(tmux.capture_pane(session))
- with connection() as conn:
- repo.update_agent_output(conn, agent["id"], tail)
- updated += 1
- return updated
-
-
def drain(worker_id: str, limit: int | None = None) -> int:
"""Claim and run queued commands until the queue is empty (or ``limit`` reached).
@@ -360,7 +406,9 @@ def drain(worker_id: str, limit: int | None = None) -> int:
processed = 0
while limit is None or processed < limit:
with connection() as conn:
- command = repo.claim_next_command(conn, worker_id)
+ command = repo.claim_next_command(
+ conn, worker_id, types_excluded=_full_slot_exclusions(worker_id)
+ )
if command is None:
break
_run_one(command)
@@ -368,36 +416,66 @@ def drain(worker_id: str, limit: int | None = None) -> int:
return processed
+def _full_slot_exclusions(worker_id: str) -> tuple[str, ...]:
+ """Command types this worker must not claim right now.
+
+ Runs are supervised in-process, so a worker at ``max_concurrent_runs`` skips
+ claiming run-starting commands — they stay queued for a worker with a free slot. Slot
+ accounting is DB-driven (this worker's ``running`` runs), so it needs no in-memory
+ registry and survives restarts (a fresh process gets a fresh worker id; stale rows
+ belong to the old id and are the reaper's problem).
+ """
+ with connection() as conn:
+ active = len(repo.list_running_runs(conn, worker_id=worker_id))
+ if active >= get_settings().max_concurrent_runs:
+ return _RUN_COMMANDS
+ return ()
+
+
def run(
worker_id: str | None = None,
poll_interval: float = 2.0,
ci_interval: float = 30.0,
- capture_interval: float = 2.0,
+ credsync_interval: float = 30.0,
+ reap_interval: float = 15.0,
iterations: int | None = None,
) -> None:
- """The control-container main loop: drain the command queue, snapshot live agent
- output, and sweep CI periodically.
+ """The control-container main loop: drain the command queue, sync claude
+ credentials, heartbeat + reap dead workers, and sweep CI.
``iterations`` bounds the loop for tests; production runs unbounded. Sleeps
``poll_interval`` only when a pass found no commands, so bursts drain promptly.
"""
- worker_id = worker_id or f"worker-{os.getpid()}"
+ worker_id = worker_id or make_worker_id()
last_ci = 0.0
- last_capture = 0.0
+ last_credsync = 0.0
+ last_reap = 0.0
count = 0
while iterations is None or count < iterations:
+ try:
+ heartbeat(worker_id)
+ except Exception: # noqa: BLE001 - a heartbeat hiccup must not kill the worker
+ pass
+ if reap_interval > 0 and time.monotonic() - last_reap >= reap_interval:
+ try:
+ reap_dead_workers()
+ except Exception: # noqa: BLE001 - a reap hiccup must not kill the worker
+ pass
+ last_reap = time.monotonic()
try:
fire_due_schedules()
except Exception: # noqa: BLE001 - a schedule hiccup must not kill the worker
pass
did_work = drain(worker_id) > 0
now = time.monotonic()
- if capture_interval > 0 and now - last_capture >= capture_interval:
+ if credsync_interval > 0 and (last_credsync == 0.0 or now - last_credsync >= credsync_interval):
+ # First pass runs immediately: a fresh worker container must materialize the
+ # claude credentials before it claims its first spawn.
try:
- capture_agent_output()
- except Exception: # noqa: BLE001 - a capture hiccup must not kill the worker
+ credsync.refresh()
+ except Exception: # noqa: BLE001 - cred sync must not kill the worker
pass
- last_capture = now
+ last_credsync = now
if ci_interval > 0 and now - last_ci >= ci_interval:
try:
poller.sweep()
diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py
index c737be0..0f8e0aa 100644
--- a/src/handler/db/repository.py
+++ b/src/handler/db/repository.py
@@ -22,6 +22,8 @@ from typing import Any
from sqlalchemy import Connection, select
from .tables import (
+ agent_events,
+ agent_runs,
agents,
approvals,
checkmarks,
@@ -29,8 +31,11 @@ from .tables import (
forge_hosts,
log_entries,
projects,
+ runtime_secrets,
schedules,
+ session_archives,
shared_context,
+ workers,
)
from .upsert import upsert_checkmark
@@ -373,6 +378,10 @@ def _purge_agent_dependents(conn: Connection, agent_ids: list[int]) -> None:
conn.execute(approvals.delete().where(approvals.c.approved_by_agent_id.in_(agent_ids)))
conn.execute(checkmarks.delete().where(checkmarks.c.agent_id.in_(agent_ids)))
conn.execute(log_entries.delete().where(log_entries.c.agent_id.in_(agent_ids)))
+ # Headless-runner rows: events reference runs, so they go first.
+ conn.execute(agent_events.delete().where(agent_events.c.agent_id.in_(agent_ids)))
+ conn.execute(agent_runs.delete().where(agent_runs.c.agent_id.in_(agent_ids)))
+ conn.execute(session_archives.delete().where(session_archives.c.agent_id.in_(agent_ids)))
def delete_project(conn: Connection, project_id: str) -> bool:
@@ -424,8 +433,13 @@ def enqueue_command(
agent_name: str | None = None,
payload: dict | None = None,
requested_by: str | None = None,
+ target_worker: str | None = None,
) -> dict:
- """Insert a ``queued`` control command for the worker to pick up. Returns the row."""
+ """Insert a ``queued`` control command for the worker to pick up. Returns the row.
+
+ ``target_worker`` pins the command to one worker id (used by login_submit, which must
+ reach the container holding the live login tmux session); null = any worker.
+ """
result = conn.execute(
commands.insert().values(
project_id=project_id,
@@ -434,6 +448,7 @@ def enqueue_command(
payload=payload,
status="queued",
requested_by=requested_by,
+ target_worker=target_worker,
created_at=_now(),
)
)
@@ -457,20 +472,34 @@ def list_commands(
return [dict(r._mapping) for r in rows]
-def claim_next_command(conn: Connection, worker_id: str) -> dict | None:
+def claim_next_command(
+ conn: Connection,
+ worker_id: str,
+ types_excluded: tuple[str, ...] = (),
+) -> dict | None:
"""Atomically claim the oldest queued command, flipping it to ``running``.
Postgres uses ``FOR UPDATE SKIP LOCKED`` so multiple workers never grab the same row;
on SQLite (single writer per transaction) the guarded ``WHERE status='queued'`` update
plus a rowcount check is enough. Returns the claimed row, or ``None`` when the queue is
empty or another worker won the race.
+
+ A command whose ``target_worker`` is set is only claimable by that worker (the login
+ flow's two steps must land on the same container). ``types_excluded`` lets a worker
+ with no free run slots skip claiming commands that would launch a new run, leaving
+ them for a less-loaded worker.
"""
sel = (
select(commands.c.id)
- .where(commands.c.status == "queued")
+ .where(
+ commands.c.status == "queued",
+ (commands.c.target_worker.is_(None)) | (commands.c.target_worker == worker_id),
+ )
.order_by(commands.c.id.asc())
.limit(1)
)
+ if types_excluded:
+ sel = sel.where(commands.c.type.not_in(types_excluded))
if conn.dialect.name == "postgresql":
sel = sel.with_for_update(skip_locked=True)
row = conn.execute(sel).first()
@@ -659,3 +688,227 @@ def mark_schedule_run(
last_run_at=last_run_at, next_run_at=next_run_at, last_command_id=last_command_id
)
)
+
+
+# ------------------------------------------------- headless runner (workers/runs/events)
+
+
+def upsert_worker_heartbeat(
+ conn: Connection,
+ worker_id: str,
+ *,
+ hostname: str | None = None,
+ pid: int | None = None,
+ max_runs: int | None = None,
+ active_runs: int | None = None,
+) -> None:
+ """Register a worker or refresh its heartbeat — the reaper's liveness signal."""
+ now = _now()
+ result = conn.execute(
+ workers.update()
+ .where(workers.c.id == worker_id)
+ .values(
+ hostname=hostname, pid=pid, max_runs=max_runs,
+ active_runs=active_runs, heartbeat_at=now,
+ )
+ )
+ if result.rowcount == 0:
+ conn.execute(
+ workers.insert().values(
+ id=worker_id, hostname=hostname, pid=pid, max_runs=max_runs,
+ active_runs=active_runs, started_at=now, heartbeat_at=now,
+ )
+ )
+
+
+def list_stale_workers(conn: Connection, cutoff: datetime) -> list[dict]:
+ """Workers whose heartbeat predates ``cutoff`` — reaper input."""
+ rows = conn.execute(select(workers).where(workers.c.heartbeat_at < cutoff)).all()
+ return [dict(r._mapping) for r in rows]
+
+
+def delete_worker(conn: Connection, worker_id: str) -> bool:
+ """Drop a (dead) worker's registry row after its runs have been settled."""
+ result = conn.execute(workers.delete().where(workers.c.id == worker_id))
+ return result.rowcount > 0
+
+
+def create_run(
+ conn: Connection, agent_id: int, session_id: str, worker_id: str, kind: str
+) -> dict:
+ """Open an ``agent_runs`` row for a launching headless invocation."""
+ result = conn.execute(
+ agent_runs.insert().values(
+ agent_id=agent_id,
+ session_id=session_id,
+ worker_id=worker_id,
+ kind=kind,
+ status="running",
+ cancel_requested=False,
+ started_at=_now(),
+ )
+ )
+ return get_run(conn, result.inserted_primary_key[0])
+
+
+def get_run(conn: Connection, run_id: int) -> dict | None:
+ row = conn.execute(select(agent_runs).where(agent_runs.c.id == run_id)).first()
+ return _row_to_dict(row)
+
+
+def finish_run(
+ conn: Connection,
+ run_id: int,
+ status: str,
+ exit_code: int | None = None,
+ result: dict | None = None,
+) -> bool:
+ """Close a run — only if still ``running``, so a reaper's ``crashed`` verdict and the
+ supervisor's own exit reconciliation can race without the loser clobbering the winner."""
+ res = conn.execute(
+ agent_runs.update()
+ .where(agent_runs.c.id == run_id, agent_runs.c.status == "running")
+ .values(status=status, exit_code=exit_code, result=result, finished_at=_now())
+ )
+ return res.rowcount > 0
+
+
+def request_run_cancel(conn: Connection, run_id: int) -> bool:
+ """Flag a running run for cancellation; its owning supervisor polls and SIGTERMs."""
+ result = conn.execute(
+ agent_runs.update()
+ .where(agent_runs.c.id == run_id, agent_runs.c.status == "running")
+ .values(cancel_requested=True)
+ )
+ return result.rowcount > 0
+
+
+def get_cancel_requested(conn: Connection, run_id: int) -> bool:
+ row = conn.execute(
+ select(agent_runs.c.cancel_requested).where(agent_runs.c.id == run_id)
+ ).first()
+ return bool(row[0]) if row is not None else False
+
+
+def list_running_runs(conn: Connection, worker_id: str | None = None) -> list[dict]:
+ """Runs currently ``running`` — all of them, or one worker's (the reaper's sweep)."""
+ stmt = select(agent_runs).where(agent_runs.c.status == "running")
+ if worker_id is not None:
+ stmt = stmt.where(agent_runs.c.worker_id == worker_id)
+ rows = conn.execute(stmt.order_by(agent_runs.c.id)).all()
+ return [dict(r._mapping) for r in rows]
+
+
+def get_latest_run(conn: Connection, agent_id: int) -> dict | None:
+ row = conn.execute(
+ select(agent_runs)
+ .where(agent_runs.c.agent_id == agent_id)
+ .order_by(agent_runs.c.id.desc())
+ .limit(1)
+ ).first()
+ return _row_to_dict(row)
+
+
+def set_agent_session(
+ conn: Connection, agent_id: int, session_id: str, worker_id: str
+) -> None:
+ """Record the claude session UUID + supervising worker on the agent row."""
+ conn.execute(
+ agents.update()
+ .where(agents.c.id == agent_id)
+ .values(session_id=session_id, worker_id=worker_id)
+ )
+
+
+def insert_agent_event(
+ conn: Connection,
+ agent_id: int,
+ run_id: int,
+ seq: int,
+ type: str,
+ payload: dict | None = None,
+ session_id: str | None = None,
+) -> int:
+ result = conn.execute(
+ agent_events.insert().values(
+ agent_id=agent_id,
+ run_id=run_id,
+ session_id=session_id,
+ seq=seq,
+ type=type,
+ payload=payload,
+ created_at=_now(),
+ )
+ )
+ return result.inserted_primary_key[0]
+
+
+def list_agent_events(
+ conn: Connection, agent_id: int, after_id: int = 0, limit: int = 200
+) -> list[dict]:
+ """Events for an agent in insertion order, cursor-paged by row id (the UI polls with
+ ``after_id`` = the last id it has, so each poll returns only what's new)."""
+ rows = conn.execute(
+ select(agent_events)
+ .where(agent_events.c.agent_id == agent_id, agent_events.c.id > after_id)
+ .order_by(agent_events.c.id.asc())
+ .limit(limit)
+ ).all()
+ return [dict(r._mapping) for r in rows]
+
+
+def upsert_session_archive(
+ conn: Connection, agent_id: int, session_id: str, archive: bytes
+) -> None:
+ """Store (replace) the agent's latest claude session archive."""
+ now = _now()
+ result = conn.execute(
+ session_archives.update()
+ .where(session_archives.c.agent_id == agent_id)
+ .values(session_id=session_id, archive=archive, bytes=len(archive), updated_at=now)
+ )
+ if result.rowcount == 0:
+ conn.execute(
+ session_archives.insert().values(
+ agent_id=agent_id, session_id=session_id, archive=archive,
+ bytes=len(archive), updated_at=now,
+ )
+ )
+
+
+def get_session_archive(conn: Connection, agent_id: int) -> dict | None:
+ row = conn.execute(
+ select(session_archives).where(session_archives.c.agent_id == agent_id)
+ ).first()
+ return _row_to_dict(row)
+
+
+def upsert_runtime_secret(conn: Connection, key: str, value_enc: str) -> None:
+ """Store an encrypted control-plane secret (the caller encrypts via secretstore)."""
+ now = _now()
+ result = conn.execute(
+ runtime_secrets.update()
+ .where(runtime_secrets.c.key == key)
+ .values(value_enc=value_enc, updated_at=now)
+ )
+ if result.rowcount == 0:
+ conn.execute(
+ runtime_secrets.insert().values(key=key, value_enc=value_enc, updated_at=now)
+ )
+
+
+def get_runtime_secret(conn: Connection, key: str) -> dict | None:
+ row = conn.execute(select(runtime_secrets).where(runtime_secrets.c.key == key)).first()
+ return _row_to_dict(row)
+
+
+def get_latest_finished_command(conn: Connection, type: str) -> dict | None:
+ """The most recent ``done`` command of a type (login pinning reads login_start's
+ ``claimed_by`` to route login_submit to the same worker container)."""
+ row = conn.execute(
+ select(commands)
+ .where(commands.c.type == type, commands.c.status == "done")
+ .order_by(commands.c.id.desc())
+ .limit(1)
+ ).first()
+ return _row_to_dict(row)
diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py
index 3e62f04..de180a3 100644
--- a/src/handler/db/tables.py
+++ b/src/handler/db/tables.py
@@ -14,6 +14,7 @@ from sqlalchemy import (
Column,
ForeignKey,
Index,
+ LargeBinary,
MetaData,
String,
Table,
@@ -27,7 +28,9 @@ metadata = MetaData()
# Status vocabularies kept as free TEXT (README uses plain strings, not PG enums, so
# both dialects match). CheckConstraints make the allowed sets explicit and portable.
-AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done")
+# ``crashed`` is reserved for the reaper: it marks an agent whose owning worker went
+# silent mid-run — never a normal exit, which reconciles to done/blocked instead.
+AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done", "crashed")
GATE_STATUSES = ("pass", "fail", "unknown")
CI_STATUSES = ("not_applicable", "pending", "pass", "fail")
VISIBILITIES = ("project", "global")
@@ -55,6 +58,10 @@ COMMAND_TYPES = (
COMMAND_STATUSES = ("queued", "running", "done", "failed")
# Forge families a host can belong to (drives per-host token env conventions).
FORGE_TYPES = ("github", "gitlab", "gitea", "forgejo", "bitbucket")
+# One agent_runs row per headless ``claude -p`` invocation (spawn or resume).
+# ``canceled`` = an operator kill; ``crashed`` = the reaper found the owning worker dead.
+RUN_KINDS = ("spawn", "resume")
+RUN_STATUSES = ("running", "completed", "failed", "crashed", "canceled")
def _in(column: str, values: tuple[str, ...]) -> str:
@@ -87,8 +94,14 @@ agents = Table(
# A periodic snapshot of the agent's live tmux pane tail (last ~40 lines), refreshed by
# the control worker's poll loop. The tmux socket lives only in the control container,
# so this DB column is how the API/UI see what a running — or wedged — agent is doing.
+ # Headless runs reuse it, derived from the latest assistant text instead of a pane tail.
Column("last_output", String),
Column("output_at", PortableTimestamp),
+ # Headless runner (null on legacy tmux agents — the rollout discriminator): the claude
+ # session UUID pre-assigned at spawn (stable across resumes), and the worker currently
+ # (or last) supervising a run for this agent.
+ Column("session_id", String),
+ Column("worker_id", String),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("project_id", "name", name="uq_agents_project_name"),
CheckConstraint(_in("status", AGENT_STATUSES), name="ck_agents_status"),
@@ -192,6 +205,9 @@ commands = Table(
Column("error", String),
Column("requested_by", String), # actor label, e.g. "operator:web"
Column("claimed_by", String), # worker id that claimed the command
+ # Pins a command to one worker (null = any). login_submit must run on the worker that
+ # ran login_start — the live tmux login session exists only in that container.
+ Column("target_worker", String),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("claimed_at", PortableTimestamp),
Column("finished_at", PortableTimestamp),
@@ -227,6 +243,91 @@ forge_hosts = Table(
CheckConstraint(_in("forge_type", FORGE_TYPES), name="ck_forge_hosts_type"),
)
+# Control-plane key/value secrets, Fernet-encrypted like forge_hosts tokens (see
+# ``handler.secretstore``). Holds the claude OAuth credential bundle so every worker
+# container can materialize it locally — the login flow runs on ONE worker, but all of
+# them need to run ``claude``. Never exposed by any API route.
+runtime_secrets = Table(
+ "runtime_secrets",
+ metadata,
+ Column("key", String, primary_key=True),
+ Column("value_enc", String, nullable=False),
+ Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
+)
+
+# Worker registry + heartbeat (headless runner). Each worker container upserts its row
+# every loop pass; the reaper marks a worker's running runs (and their agents) ``crashed``
+# when ``heartbeat_at`` goes stale — the positive-liveness replacement for tmux scraping.
+workers = Table(
+ "workers",
+ metadata,
+ Column("id", String, primary_key=True), # "worker---"
+ Column("hostname", String),
+ Column("pid", BigInteger),
+ Column("max_runs", BigInteger),
+ Column("active_runs", BigInteger),
+ Column("started_at", PortableTimestamp, nullable=False, server_default=func.now()),
+ Column("heartbeat_at", PortableTimestamp, nullable=False, server_default=func.now()),
+)
+
+# One row per headless ``claude -p`` invocation. The spawn/resume *command* finishes at
+# launch (fire-and-forget, matching tmux semantics); the run row is what tracks the
+# process's actual life — status, exit code, and the final result event.
+agent_runs = Table(
+ "agent_runs",
+ metadata,
+ Column("id", PortableBigInt, primary_key=True, autoincrement=True),
+ Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False),
+ Column("session_id", String, nullable=False),
+ Column("worker_id", String, nullable=False),
+ Column("kind", String, nullable=False),
+ Column("status", String, nullable=False, server_default="running"),
+ # Cross-worker kill: any worker/API sets this; the owning supervisor polls it and
+ # SIGTERMs its own child — nobody signals a process they don't own.
+ Column("cancel_requested", Boolean, nullable=False, server_default="0"),
+ Column("exit_code", BigInteger),
+ Column("result", PortableJSON), # the stream's result event (cost/turns/is_error/text)
+ Column("started_at", PortableTimestamp, nullable=False, server_default=func.now()),
+ Column("finished_at", PortableTimestamp),
+ CheckConstraint(_in("kind", RUN_KINDS), name="ck_agent_runs_kind"),
+ CheckConstraint(_in("status", RUN_STATUSES), name="ck_agent_runs_status"),
+ Index("ix_agent_runs_status_worker", "status", "worker_id"),
+)
+
+# The persisted event stream — one row per stream-json stdout line of a run, in order.
+# This is what the UI's log/event panel reads; ``type`` mirrors the stream's top-level
+# type (system/assistant/user/result), plus ``hook`` (--include-hook-events), ``worker``
+# (runner-generated notices: crash marks, archive failures, fallback resumes) and ``raw``
+# (an unparseable line, stored verbatim — the parser never drops data).
+agent_events = Table(
+ "agent_events",
+ metadata,
+ Column("id", PortableBigInt, primary_key=True, autoincrement=True),
+ Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False),
+ Column("run_id", BigInteger, ForeignKey("agent_runs.id"), nullable=False),
+ Column("session_id", String),
+ Column("seq", BigInteger, nullable=False), # per-run stdout line counter
+ Column("type", String, nullable=False),
+ Column("payload", PortableJSON),
+ Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
+ Index("ix_agent_events_agent_id", "agent_id", "id"),
+)
+
+# Latest claude session archive per agent — the tar.gz of ``.jsonl`` + its sidecar
+# dir from ``~/.claude/projects//``. Uploaded by the supervising worker
+# (periodically and at run end) and materialized by whichever worker claims the next
+# resume, so ``--resume`` works cross-worker with no shared filesystem (README: DB is the
+# single source of truth; observed sizes are KBs-to-low-MBs).
+session_archives = Table(
+ "session_archives",
+ metadata,
+ Column("agent_id", BigInteger, ForeignKey("agents.id"), primary_key=True),
+ Column("session_id", String, nullable=False),
+ Column("archive", LargeBinary, nullable=False),
+ Column("bytes", BigInteger, nullable=False),
+ Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
+)
+
# Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues
# an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity
# audit trail like any other control action). Agent names must be unique per project, so
diff --git a/src/handler/migrations/versions/0008_headless_runs.py b/src/handler/migrations/versions/0008_headless_runs.py
new file mode 100644
index 0000000..eaf86be
--- /dev/null
+++ b/src/handler/migrations/versions/0008_headless_runs.py
@@ -0,0 +1,128 @@
+"""headless runner: workers, runs, events, session archives
+
+Revision ID: 0008_headless_runs
+Revises: 0007_agent_pane_output
+Create Date: 2026-07-21
+
+Dormant schema for the headless ``claude -p --output-format stream-json`` runner
+(nothing writes these until the runner is enabled):
+
+- ``workers`` — worker registry + heartbeat, the reaper's liveness input.
+- ``agent_runs`` — one row per headless invocation (spawn or resume), tracking the
+ process's real life: status, exit code, cancel flag, final result event.
+- ``agent_events`` — the persisted stream-json event log the UI reads.
+- ``session_archives`` — latest claude session tar.gz per agent, so ``--resume`` works
+ from any worker with no shared filesystem.
+- ``agents`` gains ``session_id`` (null = legacy tmux agent, the rollout discriminator)
+ and ``worker_id``; ``commands`` gains ``target_worker`` (pins login_submit to the
+ worker holding the live login session).
+- ``crashed`` joins the agent status vocabulary (reaper-set); the CHECK constraint
+ swaps go through ``batch_alter_table`` so SQLite recreates while Postgres alters in
+ place (same pattern as 0006's command type).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp
+
+revision: str = "0008_headless_runs"
+down_revision: str | None = "0007_agent_pane_output"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+OLD_AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done'"
+NEW_AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done', 'crashed'"
+RUN_KINDS = "'spawn', 'resume'"
+RUN_STATUSES = "'running', 'completed', 'failed', 'crashed', 'canceled'"
+
+
+def upgrade() -> None:
+ op.create_table(
+ "workers",
+ sa.Column("id", sa.String(), primary_key=True),
+ sa.Column("hostname", sa.String()),
+ sa.Column("pid", sa.BigInteger()),
+ sa.Column("max_runs", sa.BigInteger()),
+ sa.Column("active_runs", sa.BigInteger()),
+ sa.Column("started_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ sa.Column("heartbeat_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ )
+ op.create_table(
+ "agent_runs",
+ sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
+ sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False),
+ sa.Column("session_id", sa.String(), nullable=False),
+ sa.Column("worker_id", sa.String(), nullable=False),
+ sa.Column("kind", sa.String(), nullable=False),
+ sa.Column("status", sa.String(), nullable=False, server_default="running"),
+ sa.Column("cancel_requested", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("exit_code", sa.BigInteger()),
+ sa.Column("result", PortableJSON),
+ sa.Column("started_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ sa.Column("finished_at", PortableTimestamp),
+ sa.CheckConstraint(f"kind IN ({RUN_KINDS})", name="ck_agent_runs_kind"),
+ sa.CheckConstraint(f"status IN ({RUN_STATUSES})", name="ck_agent_runs_status"),
+ )
+ op.create_index(
+ "ix_agent_runs_status_worker", "agent_runs", ["status", "worker_id"]
+ )
+ op.create_table(
+ "agent_events",
+ sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
+ sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False),
+ sa.Column("run_id", sa.BigInteger(), sa.ForeignKey("agent_runs.id"), nullable=False),
+ sa.Column("session_id", sa.String()),
+ sa.Column("seq", sa.BigInteger(), nullable=False),
+ sa.Column("type", sa.String(), nullable=False),
+ sa.Column("payload", PortableJSON),
+ sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ )
+ op.create_index("ix_agent_events_agent_id", "agent_events", ["agent_id", "id"])
+ op.create_table(
+ "session_archives",
+ sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), primary_key=True),
+ sa.Column("session_id", sa.String(), nullable=False),
+ sa.Column("archive", sa.LargeBinary(), nullable=False),
+ sa.Column("bytes", sa.BigInteger(), nullable=False),
+ sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ )
+ op.add_column("agents", sa.Column("session_id", sa.String()))
+ op.add_column("agents", sa.Column("worker_id", sa.String()))
+ op.add_column("commands", sa.Column("target_worker", sa.String()))
+ with op.batch_alter_table("agents", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_agents_status", type_="check")
+ batch_op.create_check_constraint(
+ "ck_agents_status", f"status IN ({NEW_AGENT_STATUSES})"
+ )
+ with op.batch_alter_table("checkmarks", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_checkmarks_status", type_="check")
+ batch_op.create_check_constraint(
+ "ck_checkmarks_status", f"status IN ({NEW_AGENT_STATUSES})"
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("checkmarks", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_checkmarks_status", type_="check")
+ batch_op.create_check_constraint(
+ "ck_checkmarks_status", f"status IN ({OLD_AGENT_STATUSES})"
+ )
+ with op.batch_alter_table("agents", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_agents_status", type_="check")
+ batch_op.create_check_constraint(
+ "ck_agents_status", f"status IN ({OLD_AGENT_STATUSES})"
+ )
+ op.drop_column("commands", "target_worker")
+ op.drop_column("agents", "worker_id")
+ op.drop_column("agents", "session_id")
+ op.drop_table("session_archives")
+ op.drop_index("ix_agent_events_agent_id", table_name="agent_events")
+ op.drop_table("agent_events")
+ op.drop_index("ix_agent_runs_status_worker", table_name="agent_runs")
+ op.drop_table("agent_runs")
+ op.drop_table("workers")
diff --git a/src/handler/migrations/versions/0009_runtime_secrets.py b/src/handler/migrations/versions/0009_runtime_secrets.py
new file mode 100644
index 0000000..8d26401
--- /dev/null
+++ b/src/handler/migrations/versions/0009_runtime_secrets.py
@@ -0,0 +1,39 @@
+"""runtime_secrets: encrypted control-plane key/value store
+
+Revision ID: 0009_runtime_secrets
+Revises: 0008_headless_runs
+Create Date: 2026-07-21
+
+A small Fernet-encrypted key/value table (values encrypted with ``HANDLER_SECRET_KEY``
+before insert, same policy as ``forge_hosts.token_enc``). First use: the claude OAuth
+credential bundle — the interactive ``/login`` flow completes on one worker container,
+and every other worker materializes the bundle from here at startup/refresh so any of
+them can run headless ``claude -p`` without shared files.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+from handler.db.types import PortableTimestamp
+
+revision: str = "0009_runtime_secrets"
+down_revision: str | None = "0008_headless_runs"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "runtime_secrets",
+ sa.Column("key", sa.String(), primary_key=True),
+ sa.Column("value_enc", sa.String(), nullable=False),
+ sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("runtime_secrets")
diff --git a/tests/conftest.py b/tests/conftest.py
index 5adb9d7..d931253 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,8 @@
"""Shared fixtures. Everything runs on a fresh SQLite file per test, materialized via
a *real* ``alembic upgrade head`` — so the migration path itself is under test, not
-just ``create_all``. No live claude/tmux/mise is ever touched: the three seams
-(``control.tmux``, ``hooks.verify``, ``control.spawn.resume``) are faked.
+just ``create_all``. No live claude/tmux/mise is ever touched: the seams
+(``control.headless.launch`` for runs, ``control.tmux`` for the login flow,
+``hooks.verify``, ``control.spawn.resume``) are faked.
"""
from __future__ import annotations
@@ -111,20 +112,44 @@ def fake_tmux(monkeypatch):
def send_enter(name):
calls["send_enter"].append({"name": name})
- def list_sessions():
- return list(live)
-
monkeypatch.setattr(tmux, "new_session", new_session)
monkeypatch.setattr(tmux, "has_session", has_session)
monkeypatch.setattr(tmux, "kill_session", kill_session)
monkeypatch.setattr(tmux, "send_keys", send_keys)
monkeypatch.setattr(tmux, "send_text", send_text)
monkeypatch.setattr(tmux, "send_enter", send_enter)
- monkeypatch.setattr(tmux, "list_sessions", list_sessions)
return {"calls": calls, "live": live}
+@pytest.fixture
+def fake_launch(monkeypatch):
+ """Record ``headless.launch`` calls instead of spawning a claude subprocess.
+
+ Mirrors the real launch's DB side effects (run row + agent session/worker) so kill/
+ resume logic downstream of a fake spawn behaves like production, minus the process.
+ """
+ from handler.control import headless
+ from handler.db import repository as repo
+ from handler.db.engine import connection
+
+ calls: list[dict] = []
+
+ def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None):
+ session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}"
+ with connection() as conn:
+ run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
+ repo.set_agent_session(conn, agent["id"], session_id, worker_id)
+ calls.append(
+ {"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path,
+ "env": env, "worker_id": worker_id, "run": run}
+ )
+ return run
+
+ monkeypatch.setattr(headless, "launch", launch)
+ return calls
+
+
@pytest.fixture
def fake_gitops(monkeypatch):
"""Fake the git seam: record config/add/commit, return a controllable branch/sha."""
diff --git a/tests/fixtures/fake_claude.py b/tests/fixtures/fake_claude.py
new file mode 100755
index 0000000..126fe63
--- /dev/null
+++ b/tests/fixtures/fake_claude.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""A stand-in ``claude`` binary for headless-runner tests.
+
+Wired in via the existing ``claude_bin`` setting (the same seam the tmux fakes used).
+Parses the real headless argv, emits a scripted ``--output-format stream-json`` stream on
+stdout, and writes a genuine session transcript + sidecar under
+``$HOME/.claude/projects//`` so archive/materialize/resume paths exercise the
+real filesystem layout. Behavior is selected with ``FAKE_CLAUDE_MODE``:
+
+- ``success`` (default): init + assistant + result, exit 0.
+- ``error``: init + assistant + one garbage line, then exit 2 with no result event.
+- ``hang``: init, then sleep forever (the kill/cancel/reaper tests SIGTERM it).
+- ``slow``: like success with a pause between events (concurrency tests).
+- ``resume-fail``: a ``--resume`` invocation exits 1 before any assistant event
+ (exercises the context re-injection fallback).
+
+On ``--resume`` (outside resume-fail) the transcript materialized by the worker MUST
+already exist at the expected path — missing means cross-worker materialization broke,
+so the fake fails loudly, exit 3.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import signal
+import sys
+import time
+from pathlib import Path
+
+
+def _parse_argv(argv: list[str]) -> dict:
+ opts = {
+ "print": False,
+ "verbose": False,
+ "output_format": None,
+ "session_id": None,
+ "resume": None,
+ "settings": None,
+ "budget": None,
+ "prompt": None,
+ }
+ i = 0
+ while i < len(argv):
+ arg = argv[i]
+ if arg == "-p":
+ opts["print"] = True
+ elif arg == "--verbose":
+ opts["verbose"] = True
+ elif arg == "--output-format":
+ i += 1
+ opts["output_format"] = argv[i]
+ elif arg == "--session-id":
+ i += 1
+ opts["session_id"] = argv[i]
+ elif arg in ("--resume", "-r"):
+ i += 1
+ opts["resume"] = argv[i]
+ elif arg == "--settings":
+ i += 1
+ opts["settings"] = argv[i]
+ elif arg == "--max-budget-usd":
+ i += 1
+ opts["budget"] = argv[i]
+ elif arg == "--":
+ opts["prompt"] = " ".join(argv[i + 1 :])
+ break
+ else:
+ opts["prompt"] = arg
+ i += 1
+ return opts
+
+
+def _munged(cwd: str) -> str:
+ return cwd.replace("/", "-").replace(".", "-")
+
+
+def _emit(event: dict) -> None:
+ sys.stdout.write(json.dumps(event) + "\n")
+ sys.stdout.flush()
+
+
+def _write_transcript(session_id: str, prompt: str) -> None:
+ base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd())
+ base.mkdir(parents=True, exist_ok=True)
+ jsonl = base / f"{session_id}.jsonl"
+ with jsonl.open("a") as fh:
+ fh.write(json.dumps({"type": "user", "prompt": prompt}) + "\n")
+ fh.write(json.dumps({"type": "assistant", "text": f"handled: {prompt}"}) + "\n")
+ sidecar = base / session_id / "tool-results"
+ sidecar.mkdir(parents=True, exist_ok=True)
+ (sidecar / "result-1.txt").write_text("fake tool output\n")
+
+
+def main() -> int:
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
+ mode = os.environ.get("FAKE_CLAUDE_MODE", "success")
+ opts = _parse_argv(sys.argv[1:])
+ if not opts["print"] or opts["output_format"] != "stream-json":
+ sys.stderr.write("fake_claude: expected -p --output-format stream-json\n")
+ return 64
+
+ session_id = opts["session_id"] or opts["resume"] or "fake-session"
+ prompt = opts["prompt"] or ""
+
+ if mode == "resume-fail" and opts["resume"]:
+ sys.stderr.write("fake_claude: no conversation found to resume\n")
+ return 1
+
+ if opts["resume"]:
+ base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd())
+ if not (base / f"{session_id}.jsonl").exists():
+ sys.stderr.write(f"fake_claude: transcript missing at {base}\n")
+ return 3
+
+ _emit(
+ {
+ "type": "system",
+ "subtype": "init",
+ "session_id": session_id,
+ "cwd": os.getcwd(),
+ "tools": ["Bash", "Read", "Edit"],
+ }
+ )
+ if mode == "hang":
+ time.sleep(3600)
+ return 0
+ if mode == "slow":
+ time.sleep(float(os.environ.get("FAKE_CLAUDE_SLOW_SECONDS", "1.0")))
+
+ _emit(
+ {
+ "type": "assistant",
+ "session_id": session_id,
+ "message": {
+ "role": "assistant",
+ "content": [{"type": "text", "text": f"working on: {prompt}"}],
+ },
+ }
+ )
+ _write_transcript(session_id, prompt)
+
+ if mode == "error":
+ sys.stdout.write("this is not json\n")
+ sys.stdout.flush()
+ return 2
+
+ _emit(
+ {
+ "type": "result",
+ "subtype": "success",
+ "session_id": session_id,
+ "is_error": False,
+ "num_turns": 1,
+ "total_cost_usd": 0.01,
+ "result": f"done: {prompt}",
+ }
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py
index fb34d92..5fb48ef 100644
--- a/tests/test_control_spawn.py
+++ b/tests/test_control_spawn.py
@@ -1,4 +1,7 @@
-"""Control-layer spawn: the hard test-task gate, settings generation, identity env."""
+"""Control-layer spawn: the hard test-task gate, settings generation, identity env.
+
+Spawns go through the ``fake_launch`` seam (conftest) — the headless analogue of the old
+fake tmux: it records the launch and mirrors its DB side effects, no subprocess."""
from __future__ import annotations
@@ -24,36 +27,48 @@ def _write_mise(root, with_test=True):
(root / ".mise.toml").write_text(body)
-def test_spawn_refuses_without_test_task(env, fake_tmux):
+def test_spawn_refuses_without_test_task(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root, with_test=False)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
- spawn.spawn("proj", "api")
- assert fake_tmux["calls"]["new_session"] == []
+ spawn.spawn("proj", "api", task="do it")
+ assert fake_launch == []
-def test_spawn_refuses_without_mise_file(env, fake_tmux):
+def test_spawn_refuses_without_mise_file(env, fake_launch):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no mise config"):
+ spawn.spawn("proj", "api", task="do it")
+
+
+def test_spawn_refuses_without_task(env, fake_launch):
+ root = env["tmp"] / "proj"
+ _write_mise(root)
+ _register_project(root)
+ with pytest.raises(spawn.SpawnError, match="requires a task"):
spawn.spawn("proj", "api")
+ # Fail-fast: no orphaned agent row behind the refused spawn.
+ with get_engine().begin() as conn:
+ assert repo.get_agent_by_name(conn, "proj", "api") is None
+ assert fake_launch == []
-def test_spawn_accepts_dotless_mise_toml(env, fake_tmux):
+def test_spawn_accepts_dotless_mise_toml(env, fake_launch):
# mise also reads `mise.toml` (no leading dot); the gate must honor it too.
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
(root / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
_register_project(root)
- agent = spawn.spawn("proj", "api")
+ agent = spawn.spawn("proj", "api", task="do it")
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
-def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
+def test_spawn_creates_agent_settings_and_run(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
@@ -64,66 +79,99 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
- # settings.json wires all four hook events.
+ # settings.json wires all four hook events AND the headless permission allowlist
+ # (claude -p auto-denies anything that would prompt; the allowlist is what lets
+ # normal work proceed — the hooks stay the hard gate).
settings = json.loads((root / ".claude" / "settings.json").read_text())
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
pre = settings["hooks"]["PreToolUse"][0]
assert pre["matcher"] == "AskUserQuestion|Bash"
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
+ assert settings["permissions"]["defaultMode"] == "acceptEdits"
+ assert "Bash(git *)" in settings["permissions"]["allow"]
- # tmux session named project__agent, with identity + DATABASE_URL in env.
- call = fake_tmux["calls"]["new_session"][0]
- assert call["name"] == "proj__api"
+ # A headless run launched with identity + DATABASE_URL in env and the task as prompt.
+ call = fake_launch[0]
+ assert call["kind"] == "spawn"
+ assert call["prompt"] == "build the thing"
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
assert call["env"]["DATABASE_URL"] == env["url"]
+ # The run row + session id landed on the agent.
+ with get_engine().begin() as conn:
+ row = repo.get_agent_by_name(conn, "proj", "api")
+ assert row["session_id"] == call["run"]["session_id"]
+ assert repo.get_latest_run(conn, row["id"])["kind"] == "spawn"
-def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_tmux):
+def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_launch):
# A repo with no .mise.toml at all: the normal gate would refuse, but the mise-init
# bootstrap agent must launch anyway (creating that file is its whole job).
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
- agent = spawn.spawn("proj", "mise-init", require_tests=False, mise_init=True)
+ agent = spawn.spawn(
+ "proj", "mise-init", task="write the mise config", require_tests=False, mise_init=True
+ )
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "mise-init")["id"] == agent["id"]
- # The launched session carries HANDLER_MISE_INIT so its hooks enforce commit + push.
- call = fake_tmux["calls"]["new_session"][0]
- assert call["env"]["HANDLER_MISE_INIT"] == "1"
+ # The launched run carries HANDLER_MISE_INIT so its hooks enforce commit + push.
+ assert fake_launch[0]["env"]["HANDLER_MISE_INIT"] == "1"
-def test_spawn_still_gates_without_mise_init_flag(env, fake_tmux):
+def test_spawn_still_gates_without_mise_init_flag(env, fake_launch):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
_register_project(root)
# require_tests defaults on, so a normal spawn against a mise-less repo still refuses.
with pytest.raises(spawn.SpawnError, match="no mise config"):
- spawn.spawn("proj", "api")
- assert fake_tmux["calls"]["new_session"] == []
+ spawn.spawn("proj", "api", task="do it")
+ assert fake_launch == []
-def test_kill_sets_done_and_kills_session(env, fake_tmux):
+def test_kill_cancels_run_and_sets_done(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
- spawn.spawn("proj", "api")
+ spawn.spawn("proj", "api", task="do it")
spawn.kill("proj", "api")
- assert "proj__api" in fake_tmux["calls"]["kill_session"]
with get_engine().begin() as conn:
- assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
+ agent = repo.get_agent_by_name(conn, "proj", "api")
+ assert agent["status"] == "done"
+ # The running run was flagged; the owning supervisor terminates its own child.
+ assert repo.get_latest_run(conn, agent["id"])["cancel_requested"] is True
-def test_resume_sends_answer_to_live_session(env, fake_tmux):
+def test_resume_reinjects_when_no_transcript(env, fake_launch):
+ """A resume with no archive and no local transcript degrades to a fresh run whose
+ prompt carries the operator's answer (context re-injection)."""
root = env["tmp"] / "proj"
_write_mise(root, with_test=True)
_register_project(root)
- agent = spawn.spawn("proj", "api")
+ spawn.spawn("proj", "api", task="do it")
+ with get_engine().begin() as conn:
+ agent = repo.get_agent_by_name(conn, "proj", "api")
+ repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
ok, detail = spawn.resume(agent, "use Postgres")
assert ok is True
- assert fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"}
+ assert "re-injected" in detail
+ assert fake_launch[-1]["kind"] == "spawn"
+ assert "use Postgres" in fake_launch[-1]["prompt"]
+
+
+def test_resume_refused_while_run_live(env, fake_launch):
+ root = env["tmp"] / "proj"
+ _write_mise(root, with_test=True)
+ _register_project(root)
+ spawn.spawn("proj", "api", task="do it") # fake run stays 'running'
+ with get_engine().begin() as conn:
+ agent = repo.get_agent_by_name(conn, "proj", "api")
+
+ ok, detail = spawn.resume(agent, "answer")
+ assert ok is False
+ assert "live run" in detail
diff --git a/tests/test_control_spawn_forge.py b/tests/test_control_spawn_forge.py
index b50404c..8d29ff3 100644
--- a/tests/test_control_spawn_forge.py
+++ b/tests/test_control_spawn_forge.py
@@ -19,15 +19,15 @@ def _register(root, **kw):
repo.create_project(conn, "proj", str(root), **kw)
-def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gitops, monkeypatch):
+def test_spawn_injects_credentials_and_installs_helper(env, fake_launch, fake_gitops, monkeypatch):
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, git_remote="https://github.com/me/proj.git", credential_ref="env:PROJ_TOKEN")
- spawn.spawn("proj", "junior", role="junior")
+ spawn.spawn("proj", "junior", role="junior", task="do it")
- call = fake_tmux["calls"]["new_session"][0]
+ call = fake_launch[0]
# Token injected under the generic + host-specific names, never the raw ref stored.
assert call["env"]["FORGE_TOKEN"] == "s3cret"
assert call["env"]["GITHUB_TOKEN"] == "s3cret"
@@ -38,43 +38,43 @@ def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gito
assert "$FORGE_TOKEN" in helper[0]["value"]
-def test_spawn_ssh_remote_installs_no_https_helper(env, fake_tmux, fake_gitops, monkeypatch):
+def test_spawn_ssh_remote_installs_no_https_helper(env, fake_launch, fake_gitops, monkeypatch):
monkeypatch.setenv("PROJ_TOKEN", "s3cret")
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, git_remote="git@github.com:me/proj.git", credential_ref="env:PROJ_TOKEN")
- spawn.spawn("proj", "junior", role="junior")
+ spawn.spawn("proj", "junior", role="junior", task="do it")
# ssh remote -> token still injected, but no HTTPS credential helper installed.
- assert fake_tmux["calls"]["new_session"][0]["env"]["GITHUB_TOKEN"] == "s3cret"
+ assert fake_launch[0]["env"]["GITHUB_TOKEN"] == "s3cret"
assert fake_gitops["config"] == []
-def test_spawn_fails_fast_on_broken_credential_ref(env, fake_tmux, fake_gitops, monkeypatch):
+def test_spawn_fails_fast_on_broken_credential_ref(env, fake_launch, fake_gitops, monkeypatch):
monkeypatch.delenv("ABSENT_TOKEN", raising=False)
root = env["tmp"] / "proj"
_write_mise(root)
_register(root, credential_ref="env:ABSENT_TOKEN")
with pytest.raises(spawn.SpawnError, match="not set"):
- spawn.spawn("proj", "junior", role="junior")
+ spawn.spawn("proj", "junior", role="junior", task="do it")
# No agent row and no session left behind by the failed spawn.
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "junior") is None
- assert fake_tmux["calls"]["new_session"] == []
+ assert fake_launch == []
-def test_spawn_without_credential_ref_injects_no_token(env, fake_tmux, fake_gitops):
+def test_spawn_without_credential_ref_injects_no_token(env, fake_launch, fake_gitops):
root = env["tmp"] / "proj"
_write_mise(root)
_register(root)
- spawn.spawn("proj", "api")
- call = fake_tmux["calls"]["new_session"][0]
+ spawn.spawn("proj", "api", task="do it")
+ call = fake_launch[0]
assert "FORGE_TOKEN" not in call["env"]
# No token -> no credential helper installed.
assert fake_gitops["config"] == []
-def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_forge, monkeypatch):
+def test_spawn_reports_forge_version_mismatch(env, fake_launch, fake_gitops, fake_forge, monkeypatch):
monkeypatch.setenv("FORGE_VERSION", "9.9.9")
from handler import config
from handler.db import engine
@@ -88,5 +88,5 @@ def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_
fake_forge["version_ok"] = False
fake_forge["version_out"] = "forge 1.2.3"
- agent = spawn.spawn("proj", "api")
+ agent = spawn.spawn("proj", "api", task="do it")
assert "9.9.9" in agent["forge_note"]
diff --git a/tests/test_credsync.py b/tests/test_credsync.py
new file mode 100644
index 0000000..f7bb8bb
--- /dev/null
+++ b/tests/test_credsync.py
@@ -0,0 +1,103 @@
+"""Credential distribution through runtime_secrets: the login completes on one worker,
+every other worker materializes the encrypted bundle from the DB — no shared files."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+from cryptography.fernet import Fernet
+
+from handler.control import credsync
+from handler.db import repository as repo
+from handler.db.engine import get_engine
+
+
+@pytest.fixture
+def secret_env(env, monkeypatch):
+ from handler import config
+
+ monkeypatch.setenv("HANDLER_SECRET_KEY", Fernet.generate_key().decode())
+ config.get_settings.cache_clear()
+ credsync._state.__init__() # fresh sync cursor per test
+ yield env
+ config.get_settings.cache_clear()
+
+
+def _write_local_credentials(home: Path) -> None:
+ (home / ".claude").mkdir(parents=True, exist_ok=True)
+ (home / ".claude.json").write_text(
+ json.dumps({"oauthAccount": {"email": "op@example.com"}, "theme": "light"})
+ )
+ (home / ".claude" / ".credentials.json").write_text('{"token": "secret-oauth-token"}')
+
+
+def test_upload_stores_encrypted_bundle(secret_env, tmp_path):
+ _write_local_credentials(tmp_path)
+ assert credsync.upload() is True
+ with get_engine().begin() as conn:
+ row = repo.get_runtime_secret(conn, credsync.SECRET_KEY)
+ assert row is not None
+ # Ciphertext at rest — the raw token must not appear in the DB value.
+ assert "secret-oauth-token" not in row["value_enc"]
+
+
+def test_refresh_materializes_on_fresh_worker(secret_env, tmp_path, monkeypatch):
+ _write_local_credentials(tmp_path)
+ assert credsync.upload() is True
+
+ other_home = tmp_path / "worker-b"
+ other_home.mkdir()
+ monkeypatch.setenv("HOME", str(other_home))
+ credsync._state.__init__() # worker B's process state
+
+ assert credsync.refresh() == "materialized"
+ creds = json.loads((other_home / ".claude" / ".credentials.json").read_text())
+ assert creds["token"] == "secret-oauth-token"
+ data = json.loads((other_home / ".claude.json").read_text())
+ assert data["oauthAccount"]["email"] == "op@example.com"
+ # A second pass is a no-op — nothing changed anywhere.
+ assert credsync.refresh() is None
+
+
+def test_materialize_merges_claude_json_preserving_local_state(secret_env, tmp_path, monkeypatch):
+ _write_local_credentials(tmp_path)
+ credsync.upload()
+
+ other_home = tmp_path / "worker-c"
+ (other_home / ".claude").mkdir(parents=True)
+ (other_home / ".claude.json").write_text(
+ json.dumps(
+ {
+ "hasCompletedOnboarding": True,
+ "theme": "dark",
+ "projects": {"/projects/p/a": {"hasTrustDialogAccepted": True}},
+ }
+ )
+ )
+ monkeypatch.setenv("HOME", str(other_home))
+ credsync._state.__init__()
+
+ assert credsync.refresh() == "materialized"
+ data = json.loads((other_home / ".claude.json").read_text())
+ # Account arrived...
+ assert data["oauthAccount"]["email"] == "op@example.com"
+ # ...but this worker's own onboarding/trust state (claude_config's writes) survived.
+ assert data["theme"] == "dark"
+ assert data["projects"]["/projects/p/a"]["hasTrustDialogAccepted"] is True
+
+
+def test_refresh_uploads_local_change(secret_env, tmp_path):
+ _write_local_credentials(tmp_path)
+ assert credsync.refresh() == "uploaded" # bootstrap: local creds, empty DB
+ # A token refresh on disk (mtime/size change) re-publishes.
+ os.utime(tmp_path / ".claude" / ".credentials.json", ns=(1, 1))
+ assert credsync.refresh() == "uploaded"
+
+
+def test_disabled_without_secret_key(env, tmp_path):
+ _write_local_credentials(tmp_path)
+ assert credsync.upload() is False
+ assert credsync.refresh() is None
diff --git a/tests/test_headless_parser.py b/tests/test_headless_parser.py
new file mode 100644
index 0000000..0b5863b
--- /dev/null
+++ b/tests/test_headless_parser.py
@@ -0,0 +1,178 @@
+"""Pure-helper tests for the headless runner: stream parsing, path munging, argv
+construction, and the session archive round-trip. No subprocess is launched here —
+the process-level tests live in test_headless_run.py (phase 2)."""
+
+from __future__ import annotations
+
+import io
+import tarfile
+
+from handler.control import headless
+
+
+# ------------------------------------------------------------------ parse_stream_line
+
+
+def test_parse_valid_event_types():
+ for etype in ("system", "assistant", "user", "result", "hook"):
+ parsed_type, payload = headless.parse_stream_line(f'{{"type": "{etype}", "x": 1}}\n')
+ assert parsed_type == etype
+ assert payload == {"type": etype, "x": 1}
+
+
+def test_parse_unknown_type_keeps_its_name():
+ parsed_type, payload = headless.parse_stream_line('{"type": "telemetry", "n": 2}')
+ assert parsed_type == "telemetry"
+ assert payload["n"] == 2
+
+
+def test_parse_garbage_becomes_raw():
+ parsed_type, payload = headless.parse_stream_line("this is not json\n")
+ assert parsed_type == "raw"
+ assert payload == {"line": "this is not json\n"}
+
+
+def test_parse_non_dict_json_becomes_raw():
+ parsed_type, payload = headless.parse_stream_line('["a", "b"]')
+ assert parsed_type == "raw"
+
+
+def test_parse_missing_type_becomes_raw():
+ parsed_type, payload = headless.parse_stream_line('{"message": "no type field"}')
+ assert parsed_type == "raw"
+ assert payload == {"message": "no type field"}
+
+
+def test_parse_blank_line_becomes_raw():
+ parsed_type, _ = headless.parse_stream_line(" \n")
+ assert parsed_type == "raw"
+
+
+# -------------------------------------------------------------------- assistant_text
+
+
+def test_assistant_text_joins_text_blocks():
+ payload = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {"type": "text", "text": "first"},
+ {"type": "tool_use", "name": "Bash", "input": {}},
+ {"type": "text", "text": "second"},
+ ]
+ },
+ }
+ assert headless.assistant_text(payload) == "first\nsecond"
+
+
+def test_assistant_text_none_for_pure_tool_use():
+ payload = {
+ "type": "assistant",
+ "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]},
+ }
+ assert headless.assistant_text(payload) is None
+
+
+def test_assistant_text_string_content():
+ assert headless.assistant_text({"message": {"content": "plain"}}) == "plain"
+
+
+def test_assistant_text_missing_message():
+ assert headless.assistant_text({"type": "assistant"}) is None
+
+
+# ---------------------------------------------------------------- munged_project_dir
+
+
+def test_munge_matches_recorded_real_examples():
+ # Recorded from a real ~/.claude/projects/ (see plan): '/' and '.' both map to '-'.
+ assert headless.munged_project_dir("/root/handler") == "-root-handler"
+ assert (
+ headless.munged_project_dir("/root/Talos/.claude/worktrees/mise-tooling")
+ == "-root-Talos--claude-worktrees-mise-tooling"
+ )
+
+
+# ------------------------------------------------------------------- argv builders
+
+
+def test_spawn_argv_shape(env):
+ argv = headless.build_spawn_argv("do the task", "/wd/.claude/settings.json", "sid-1")
+ assert argv[0] == "claude"
+ assert "-p" in argv and "--verbose" in argv
+ assert argv[argv.index("--output-format") + 1] == "stream-json"
+ assert argv[argv.index("--session-id") + 1] == "sid-1"
+ assert argv[argv.index("--settings") + 1] == "/wd/.claude/settings.json"
+ assert "--max-budget-usd" not in argv # default budget is 0 = off
+ assert argv[-2:] == ["--", "do the task"]
+ assert "--resume" not in argv
+
+
+def test_resume_argv_shape(env):
+ argv = headless.build_resume_argv("sid-2", "the answer", "/wd/.claude/settings.json")
+ assert argv[argv.index("--resume") + 1] == "sid-2"
+ assert argv[-2:] == ["--", "the answer"]
+ assert "--session-id" not in argv
+
+
+def test_spawn_argv_includes_budget_when_set(env, monkeypatch):
+ monkeypatch.setenv("RUN_BUDGET_USD", "2.5")
+ from handler import config
+
+ config.get_settings.cache_clear()
+ argv = headless.build_spawn_argv("t", "/s.json", "sid")
+ assert argv[argv.index("--max-budget-usd") + 1] == "2.5"
+ config.get_settings.cache_clear()
+
+
+# ------------------------------------------------------------- archive round-trip
+
+
+def _write_fake_session(home, working_dir: str, session_id: str) -> None:
+ base = home / ".claude" / "projects" / headless.munged_project_dir(working_dir)
+ base.mkdir(parents=True)
+ (base / f"{session_id}.jsonl").write_text('{"type": "user", "prompt": "hi"}\n')
+ sidecar = base / session_id / "tool-results"
+ sidecar.mkdir(parents=True)
+ (sidecar / "r1.txt").write_text("tool output")
+
+
+def test_archive_and_materialize_round_trip(env, tmp_path, monkeypatch):
+ working_dir = "/projects/demo"
+ _write_fake_session(tmp_path, working_dir, "sid-rt")
+
+ data = headless.archive_session(working_dir, "sid-rt")
+ assert data is not None
+
+ # Materialize onto a *different* worker: a fresh HOME with no session state.
+ other_home = tmp_path / "other-worker"
+ other_home.mkdir()
+ monkeypatch.setenv("HOME", str(other_home))
+ headless.materialize_session(working_dir, data)
+
+ base = other_home / ".claude" / "projects" / headless.munged_project_dir(working_dir)
+ assert (base / "sid-rt.jsonl").read_text() == '{"type": "user", "prompt": "hi"}\n'
+ assert (base / "sid-rt" / "tool-results" / "r1.txt").read_text() == "tool output"
+
+
+def test_archive_none_when_no_session(env):
+ assert headless.archive_session("/projects/none", "missing-sid") is None
+
+
+def test_archive_refuses_oversize(env, tmp_path):
+ working_dir = "/projects/big"
+ _write_fake_session(tmp_path, working_dir, "sid-big")
+ assert headless.archive_session(working_dir, "sid-big", max_bytes=10) is None
+
+
+def test_archive_contains_only_session_members(env, tmp_path):
+ working_dir = "/projects/demo2"
+ _write_fake_session(tmp_path, working_dir, "sid-a")
+ # A sibling session must not leak into sid-a's archive.
+ base = tmp_path / ".claude" / "projects" / headless.munged_project_dir(working_dir)
+ (base / "sid-other.jsonl").write_text("{}\n")
+
+ data = headless.archive_session(working_dir, "sid-a")
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
+ names = tar.getnames()
+ assert all(n == "sid-a.jsonl" or n.startswith("sid-a") for n in names)
diff --git a/tests/test_headless_run.py b/tests/test_headless_run.py
new file mode 100644
index 0000000..e413ccf
--- /dev/null
+++ b/tests/test_headless_run.py
@@ -0,0 +1,268 @@
+"""End-to-end headless runner tests against the fake ``claude`` binary.
+
+Real subprocesses, real threads, real SQLite: ``headless.launch`` starts
+``tests/fixtures/fake_claude.py`` (selected via the ``claude_bin`` setting, the same
+seam the tmux fakes used), the supervisor streams its stdout into ``agent_events``, and
+the tests assert on what landed in the DB — exactly what the API/UI will read."""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+import pytest
+
+from handler.control import headless, settings_gen, spawn
+from handler.db import repository as repo
+from handler.db.engine import get_engine
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+FAKE_CLAUDE = str(REPO_ROOT / "tests" / "fixtures" / "fake_claude.py")
+
+
+@pytest.fixture
+def headless_env(env, monkeypatch):
+ from handler import config
+
+ monkeypatch.setenv("CLAUDE_BIN", FAKE_CLAUDE)
+ config.get_settings.cache_clear()
+ yield env
+ config.get_settings.cache_clear()
+
+
+def _make_agent(tmp_path, name="h1"):
+ working_dir = tmp_path / "projects" / "p" / name
+ working_dir.mkdir(parents=True)
+ with get_engine().begin() as conn:
+ repo.create_project(conn, "p", str(tmp_path / "projects" / "p"))
+ agent = repo.create_agent(conn, "p", name, str(working_dir))
+ return agent
+
+
+def _wait_for(predicate, timeout=20.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ result = predicate()
+ if result:
+ return result
+ time.sleep(0.1)
+ return None
+
+
+def _finished_run(run_id):
+ def check():
+ with get_engine().begin() as conn:
+ run = repo.get_run(conn, run_id)
+ return run if run["status"] != "running" else None
+
+ return check
+
+
+def test_spawn_run_streams_events_and_completes(headless_env, tmp_path):
+ agent = _make_agent(tmp_path)
+ run = headless.launch(
+ agent, kind="spawn", prompt="build the thing",
+ settings_path=str(tmp_path / "settings.json"), env={}, worker_id="w1",
+ )
+ finished = _wait_for(_finished_run(run["id"]))
+ assert finished is not None, "run never finished"
+ assert finished["status"] == "completed"
+ assert finished["exit_code"] == 0
+ assert finished["result"]["is_error"] is False
+
+ with get_engine().begin() as conn:
+ events = repo.list_agent_events(conn, agent["id"])
+ updated = repo.get_agent_by_id(conn, agent["id"])
+ archive = repo.get_session_archive(conn, agent["id"])
+
+ types = [e["type"] for e in events]
+ assert types == ["system", "assistant", "result"]
+ assert [e["seq"] for e in events] == [1, 2, 3]
+ # last_output is now derived from assistant text — the log the UI shows is real.
+ assert updated["last_output"] == "working on: build the thing"
+ assert updated["status"] == "done"
+ assert updated["session_id"] == run["session_id"]
+ assert updated["worker_id"] == "w1"
+ # The session archive was uploaded at exit for cross-worker resume.
+ assert archive is not None
+ assert archive["session_id"] == run["session_id"]
+
+
+def test_failed_run_marks_blocked_with_worker_event(headless_env, tmp_path, monkeypatch):
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "error")
+ agent = _make_agent(tmp_path, "h-err")
+ run = headless.launch(
+ agent, kind="spawn", prompt="boom",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1",
+ )
+ finished = _wait_for(_finished_run(run["id"]))
+ assert finished["status"] == "failed"
+ assert finished["exit_code"] == 2
+
+ with get_engine().begin() as conn:
+ events = repo.list_agent_events(conn, agent["id"])
+ updated = repo.get_agent_by_id(conn, agent["id"])
+ types = [e["type"] for e in events]
+ # The unparseable stdout line is preserved verbatim as a raw event, and the runner
+ # records why the run failed as a worker event.
+ assert "raw" in types
+ raw = next(e for e in events if e["type"] == "raw")
+ assert "this is not json" in raw["payload"]["line"]
+ worker_ev = next(e for e in events if e["type"] == "worker")
+ assert worker_ev["payload"]["exit_code"] == 2
+ assert updated["status"] == "blocked"
+
+
+def test_hook_written_status_survives_reconciliation(headless_env, tmp_path, monkeypatch):
+ """Hooks are the status authority: if one set paused_for_input during the run, the
+ supervisor's exit pass must not overwrite it with done."""
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "slow")
+ monkeypatch.setenv("FAKE_CLAUDE_SLOW_SECONDS", "1.5")
+ agent = _make_agent(tmp_path, "h-hook")
+ run = headless.launch(
+ agent, kind="spawn", prompt="ask me something",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1",
+ )
+ # Simulate a hook (inside the run) recording an open question.
+ with get_engine().begin() as conn:
+ repo.set_agent_status(conn, agent["id"], "paused_for_input")
+ finished = _wait_for(_finished_run(run["id"]))
+ assert finished["status"] == "completed"
+ with get_engine().begin() as conn:
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "paused_for_input"
+
+
+def test_cancel_terminates_hanging_run(headless_env, tmp_path, monkeypatch):
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang")
+ agent = _make_agent(tmp_path, "h-hang")
+ run = headless.launch(
+ agent, kind="spawn", prompt="hang forever",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1",
+ )
+ # Give the supervisor a moment to start the process, then flag the cancel the same
+ # way a cross-worker kill would.
+ _wait_for(lambda: _events_count(agent["id"]) >= 1)
+ with get_engine().begin() as conn:
+ assert repo.request_run_cancel(conn, run["id"]) is True
+ finished = _wait_for(_finished_run(run["id"]), timeout=30.0)
+ assert finished is not None, "cancel never terminated the run"
+ assert finished["status"] == "canceled"
+
+
+def _events_count(agent_id):
+ with get_engine().begin() as conn:
+ return len(repo.list_agent_events(conn, agent_id))
+
+
+def test_kill_headless_agent_requests_cancel(headless_env, tmp_path, monkeypatch):
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang")
+ agent = _make_agent(tmp_path, "h-kill")
+ run = headless.launch(
+ agent, kind="spawn", prompt="hang",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1",
+ )
+ _wait_for(lambda: _events_count(agent["id"]) >= 1)
+ spawn.kill("p", "h-kill")
+ finished = _wait_for(_finished_run(run["id"]), timeout=30.0)
+ assert finished["status"] == "canceled"
+ with get_engine().begin() as conn:
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "done"
+
+
+def test_cross_worker_resume_materializes_archive(headless_env, tmp_path, monkeypatch):
+ """The linchpin: worker B resumes a session it never ran, from the DB archive alone."""
+ agent = _make_agent(tmp_path, "h-resume")
+ run = headless.launch(
+ agent, kind="spawn", prompt="first pass",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="worker-a",
+ )
+ assert _wait_for(_finished_run(run["id"]))["status"] == "completed"
+
+ # "Worker B": a clean HOME with no local claude state at all.
+ other_home = tmp_path / "worker-b-home"
+ other_home.mkdir()
+ monkeypatch.setenv("HOME", str(other_home))
+
+ with get_engine().begin() as conn:
+ agent = repo.get_agent_by_id(conn, agent["id"]) # refetch: has session_id now
+ ok, detail = spawn.resume(agent, "the operator's answer", worker_id="worker-b")
+ assert ok, detail
+
+ with get_engine().begin() as conn:
+ resumed = repo.get_latest_run(conn, agent["id"])
+ assert resumed["kind"] == "resume"
+ assert resumed["worker_id"] == "worker-b"
+ finished = _wait_for(_finished_run(resumed["id"]))
+ # fake_claude exits 3 if the transcript was NOT materialized where claude looks.
+ assert finished["status"] == "completed", f"exit={finished['exit_code']}"
+ assert finished["session_id"] == run["session_id"] # same session, continued
+
+
+def test_resume_without_transcript_reinjects_context(headless_env, tmp_path, monkeypatch):
+ """Owning worker died before its first archive: resume degrades to a fresh session
+ with DB-rebuilt context, visibly marked as such."""
+ agent = _make_agent(tmp_path, "h-fallback")
+ with get_engine().begin() as conn:
+ repo.set_agent_session(conn, agent["id"], "lost-session-uuid", "worker-dead")
+ repo.upsert_checkmark_row(
+ conn, agent["id"], status="paused_for_input",
+ where_it_stopped="mid-refactor", open_question="which db?",
+ )
+ agent = repo.get_agent_by_id(conn, agent["id"])
+
+ ok, detail = spawn.resume(agent, "use postgres", worker_id="worker-b")
+ assert ok
+ assert "re-injected" in detail
+
+ with get_engine().begin() as conn:
+ new_run = repo.get_latest_run(conn, agent["id"])
+ events = repo.list_agent_events(conn, agent["id"])
+ assert new_run["kind"] == "spawn" # genuinely new session
+ assert new_run["session_id"] != "lost-session-uuid"
+ notice = next(e for e in events if e["type"] == "worker")
+ assert notice["payload"]["previous_session_id"] == "lost-session-uuid"
+ finished = _wait_for(_finished_run(new_run["id"]))
+ assert finished["status"] == "completed"
+ with get_engine().begin() as conn:
+ assistant = [
+ e for e in repo.list_agent_events(conn, agent["id"]) if e["type"] == "assistant"
+ ]
+ # The re-injected prompt (with the operator's answer) reached the fresh claude.
+ assert any("use postgres" in json.dumps(e["payload"]) for e in assistant)
+
+
+def test_resume_refused_while_run_live(headless_env, tmp_path, monkeypatch):
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang")
+ agent = _make_agent(tmp_path, "h-busy")
+ run = headless.launch(
+ agent, kind="spawn", prompt="hang",
+ settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1",
+ )
+ with get_engine().begin() as conn:
+ agent = repo.get_agent_by_id(conn, agent["id"])
+ ok, detail = spawn.resume(agent, "answer", worker_id="w1")
+ assert not ok
+ assert "live run" in detail
+ with get_engine().begin() as conn:
+ repo.request_run_cancel(conn, run["id"])
+ _wait_for(_finished_run(run["id"]), timeout=30.0)
+
+
+def test_settings_include_permissions_and_hooks(headless_env, tmp_path):
+ path = settings_gen.write_settings(str(tmp_path / "wd"))
+ data = json.loads(Path(path).read_text())
+ assert data["permissions"]["defaultMode"] == "acceptEdits"
+ assert "Bash(git *)" in data["permissions"]["allow"]
+ assert "hooks" in data # the hard gate is untouched
+
+
+def test_api_rejects_empty_task_headless_spawn(headless_env, client, auth):
+ client.post(
+ "/projects", json={"id": "p2", "root_dir": "/tmp/p2"}, headers=auth
+ )
+ resp = client.post(
+ "/projects/p2/agents/spawn", json={"name": "idle"}, headers=auth
+ )
+ assert resp.status_code == 400
+ assert "task is required" in resp.json()["detail"]
diff --git a/tests/test_integration_web_spawn.py b/tests/test_integration_web_spawn.py
index 06ff58b..01b7dae 100644
--- a/tests/test_integration_web_spawn.py
+++ b/tests/test_integration_web_spawn.py
@@ -1,13 +1,32 @@
"""End-to-end web management: the dashboard's HTTP calls -> command queue -> worker ->
-real ``spawn.spawn`` -> tmux seam. Proves the full container-split flow works with only the
-tmux/claude boundary faked, not the control layer itself."""
+real ``spawn.spawn`` -> a real headless subprocess (the fake claude binary). Proves the
+full container-split flow works with only the claude binary faked, not the control
+layer: events stream into the DB, the run reconciles, kill cancels."""
from __future__ import annotations
+import time
+from pathlib import Path
+
+import pytest
+
from handler.control import worker
from handler.db import repository as repo
from handler.db.engine import get_engine
+REPO_ROOT = Path(__file__).resolve().parents[1]
+FAKE_CLAUDE = str(REPO_ROOT / "tests" / "fixtures" / "fake_claude.py")
+
+
+@pytest.fixture
+def headless_env(env, monkeypatch):
+ from handler import config
+
+ monkeypatch.setenv("CLAUDE_BIN", FAKE_CLAUDE)
+ config.get_settings.cache_clear()
+ yield env
+ config.get_settings.cache_clear()
+
def _spawnable_project(root):
root.mkdir(parents=True, exist_ok=True)
@@ -16,10 +35,21 @@ def _spawnable_project(root):
repo.create_project(conn, "proj", str(root))
-def test_spawn_via_api_then_worker_creates_agent_and_session(client, auth, env, fake_tmux):
- _spawnable_project(env["tmp"] / "proj")
+def _wait(predicate, timeout=20.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ result = predicate()
+ if result:
+ return result
+ time.sleep(0.1)
+ return None
- # 1. The dashboard enqueues a spawn (202 + a queued command).
+
+def test_spawn_via_api_then_worker_runs_headless_claude(client, auth, headless_env):
+ _spawnable_project(headless_env["tmp"] / "proj")
+
+ # 1. The dashboard enqueues a spawn (202 + a queued command). A task is mandatory —
+ # headless claude has no idle-REPL mode.
r = client.post(
"/projects/proj/agents/spawn",
json={"name": "api", "task": "build the thing"},
@@ -32,29 +62,62 @@ def test_spawn_via_api_then_worker_creates_agent_and_session(client, auth, env,
# No agent yet — the worker hasn't run.
assert client.get("/projects/proj/agents", headers=auth).json() == []
- # 2. The control worker drains the queue (runs the real spawn.spawn).
+ # 2. The control worker drains the queue (real spawn.spawn -> real subprocess).
assert worker.drain("test-worker") == 1
- # 3. The command is done and the agent + tmux session now exist.
+ # 3. The command finished at launch (fire-and-forget)...
got = client.get(f"/commands/{command_id}", headers=auth).json()
assert got["status"] == "done"
assert got["result"]["name"] == "api"
- agents = client.get("/projects/proj/agents", headers=auth).json()
- assert [a["name"] for a in agents] == ["api"]
- assert fake_tmux["calls"]["new_session"][0]["name"] == "proj__api"
+ # ...and the run's whole life shows up via the API: events stream in, the agent
+ # reconciles to done, last_output is the assistant's text.
+ def finished():
+ agents = client.get("/projects/proj/agents", headers=auth).json()
+ return agents if agents and agents[0]["status"] == "done" else None
+
+ agents = _wait(finished)
+ assert agents is not None, "run never reconciled to done"
+ agent = agents[0]
+ assert agent["name"] == "api"
+ assert agent["session_id"]
+ assert agent["worker_id"] == "test-worker"
+ assert agent["last_output"] == "working on: build the thing"
+
+ events = client.get("/projects/proj/agents/api/events", headers=auth).json()
+ assert [e["type"] for e in events] == ["system", "assistant", "result"]
-def test_kill_via_api_then_worker(client, auth, env, fake_tmux):
- _spawnable_project(env["tmp"] / "proj")
- client.post("/projects/proj/agents/spawn", json={"name": "api"}, headers=auth)
+def test_spawn_without_task_is_rejected(client, auth, headless_env):
+ _spawnable_project(headless_env["tmp"] / "proj")
+ r = client.post("/projects/proj/agents/spawn", json={"name": "api"}, headers=auth)
+ assert r.status_code == 400
+ assert "task is required" in r.json()["detail"]
+
+
+def test_kill_via_api_then_worker(client, auth, headless_env, monkeypatch):
+ monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang")
+ _spawnable_project(headless_env["tmp"] / "proj")
+ client.post(
+ "/projects/proj/agents/spawn", json={"name": "api", "task": "hang"}, headers=auth
+ )
worker.drain("w")
+ # The hanging run is live; kill flags it and the supervisor SIGTERMs its child.
r = client.post("/projects/proj/agents/api/kill", headers=auth)
assert r.status_code == 202
worker.drain("w")
assert client.get(f"/commands/{r.json()['id']}", headers=auth).json()["status"] == "done"
- assert "proj__api" in fake_tmux["calls"]["kill_session"]
with get_engine().begin() as conn:
- assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
+ agent = repo.get_agent_by_name(conn, "proj", "api")
+ assert agent["status"] == "done"
+
+ def canceled():
+ with get_engine().begin() as conn:
+ run = repo.get_latest_run(conn, agent["id"])
+ return run if run["status"] != "running" else None
+
+ run = _wait(canceled, timeout=30.0)
+ assert run is not None, "kill never terminated the hanging run"
+ assert run["status"] == "canceled"
diff --git a/tests/test_reaper.py b/tests/test_reaper.py
new file mode 100644
index 0000000..db594f8
--- /dev/null
+++ b/tests/test_reaper.py
@@ -0,0 +1,116 @@
+"""Worker heartbeat + reaper: a worker that stops heartbeating gets its running runs
+(and their still-'working' agents) marked crashed by any surviving worker — the positive
+liveness that replaces tmux scraping. No auto-requeue: half-done runs may have pushed."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+from handler.control import worker
+from handler.db import repository as repo
+from handler.db.engine import get_engine
+
+
+def _seed_run(worker_id, agent_name="a", agent_status="working"):
+ with get_engine().begin() as conn:
+ if repo.get_project(conn, "p") is None:
+ repo.create_project(conn, "p", "/tmp/p")
+ agent = repo.create_agent(conn, "p", agent_name, f"/tmp/p/{agent_name}",
+ status=agent_status)
+ run = repo.create_run(conn, agent["id"], f"sid-{agent_name}", worker_id, "spawn")
+ return agent, run
+
+
+def _hb(worker_id, age_seconds=0.0):
+ with get_engine().begin() as conn:
+ repo.upsert_worker_heartbeat(conn, worker_id, hostname="h", pid=1)
+ if age_seconds:
+ from handler.db.tables import workers as workers_table
+
+ conn.execute(
+ workers_table.update()
+ .where(workers_table.c.id == worker_id)
+ .values(heartbeat_at=datetime.now(UTC) - timedelta(seconds=age_seconds))
+ )
+
+
+def test_reaper_marks_stale_workers_runs_crashed(env):
+ agent, run = _seed_run("w-dead")
+ _hb("w-dead", age_seconds=120) # stale: default worker_stale_after is 60s
+
+ assert worker.reap_dead_workers() == 1
+
+ with get_engine().begin() as conn:
+ assert repo.get_run(conn, run["id"])["status"] == "crashed"
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "crashed"
+ events = repo.list_agent_events(conn, agent["id"])
+ # The dead worker's registry row is dropped once settled.
+ assert repo.list_stale_workers(conn, datetime.now(UTC) + timedelta(days=1)) == []
+ assert events[0]["type"] == "worker"
+ assert events[0]["payload"]["worker_id"] == "w-dead"
+
+
+def test_reaper_leaves_fresh_workers_alone(env):
+ agent, run = _seed_run("w-live")
+ _hb("w-live") # fresh heartbeat
+
+ assert worker.reap_dead_workers() == 0
+ with get_engine().begin() as conn:
+ assert repo.get_run(conn, run["id"])["status"] == "running"
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "working"
+
+
+def test_reaper_preserves_paused_agent_status(env):
+ """paused_for_input still accurately describes what the agent needs — only agents
+ stuck in 'working' get flipped to crashed."""
+ agent, run = _seed_run("w-dead2", agent_name="paused", agent_status="paused_for_input")
+ _hb("w-dead2", age_seconds=120)
+
+ assert worker.reap_dead_workers() == 1
+ with get_engine().begin() as conn:
+ assert repo.get_run(conn, run["id"])["status"] == "crashed"
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "paused_for_input"
+
+
+def test_reaper_idempotent_against_races(env):
+ agent, run = _seed_run("w-dead3", agent_name="raced")
+ _hb("w-dead3", age_seconds=120)
+ assert worker.reap_dead_workers() == 1
+ # A second reaper (or the same one next pass) finds nothing left to settle.
+ assert worker.reap_dead_workers() == 0
+ with get_engine().begin() as conn:
+ assert len(repo.list_agent_events(conn, agent["id"])) == 1
+
+
+def test_heartbeat_registers_worker(env):
+ worker.heartbeat("w-me")
+ with get_engine().begin() as conn:
+ rows = repo.list_stale_workers(conn, datetime.now(UTC) + timedelta(seconds=1))
+ assert [w["id"] for w in rows] == ["w-me"]
+ assert rows[0]["hostname"]
+ assert rows[0]["active_runs"] == 0
+
+
+def test_events_route_serves_stream(env, client, auth):
+ agent, run = _seed_run("w-api", agent_name="api-agent")
+ with get_engine().begin() as conn:
+ for seq in range(1, 4):
+ repo.insert_agent_event(
+ conn, agent["id"], run["id"], seq=seq, type="assistant",
+ payload={"n": seq}, session_id=run["session_id"],
+ )
+
+ resp = client.get("/projects/p/agents/api-agent/events", headers=auth)
+ assert resp.status_code == 200
+ events = resp.json()
+ assert [e["seq"] for e in events] == [1, 2, 3]
+ assert events[0]["type"] == "assistant"
+
+ # Cursor paging: only events after the given id come back.
+ resp = client.get(
+ f"/projects/p/agents/api-agent/events?after_id={events[1]['id']}", headers=auth
+ )
+ assert [e["seq"] for e in resp.json()] == [3]
+
+ resp = client.get("/projects/p/agents/missing/events", headers=auth)
+ assert resp.status_code == 404
diff --git a/tests/test_repository_runs.py b/tests/test_repository_runs.py
new file mode 100644
index 0000000..3616320
--- /dev/null
+++ b/tests/test_repository_runs.py
@@ -0,0 +1,174 @@
+"""Repository coverage for the headless-runner tables (workers / agent_runs /
+agent_events / session_archives) and the new claim filters. The ``env`` fixture runs the
+real ``alembic upgrade head``, so migration 0008 itself is under test here too."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+from handler.db import repository as repo
+
+
+def _agent(conn, name="a1"):
+ repo.create_project(conn, "p", "/projects/p")
+ return repo.create_agent(conn, "p", name, "/projects/p")
+
+
+# ------------------------------------------------------------------------ agent_runs
+
+
+def test_run_lifecycle(conn):
+ agent = _agent(conn)
+ run = repo.create_run(conn, agent["id"], "sid-1", "worker-a", "spawn")
+ assert run["status"] == "running"
+ assert run["kind"] == "spawn"
+ assert run["cancel_requested"] is False
+
+ assert repo.finish_run(conn, run["id"], "completed", exit_code=0, result={"ok": True})
+ done = repo.get_run(conn, run["id"])
+ assert done["status"] == "completed"
+ assert done["exit_code"] == 0
+ assert done["result"] == {"ok": True}
+ assert done["finished_at"] is not None
+
+
+def test_finish_run_only_once(conn):
+ """The supervisor's verdict and a racing reaper can't clobber each other."""
+ agent = _agent(conn)
+ run = repo.create_run(conn, agent["id"], "sid", "w", "spawn")
+ assert repo.finish_run(conn, run["id"], "crashed") is True
+ assert repo.finish_run(conn, run["id"], "completed", exit_code=0) is False
+ assert repo.get_run(conn, run["id"])["status"] == "crashed"
+
+
+def test_cancel_request_roundtrip(conn):
+ agent = _agent(conn)
+ run = repo.create_run(conn, agent["id"], "sid", "w", "resume")
+ assert repo.get_cancel_requested(conn, run["id"]) is False
+ assert repo.request_run_cancel(conn, run["id"]) is True
+ assert repo.get_cancel_requested(conn, run["id"]) is True
+ # A finished run can't be re-flagged.
+ repo.finish_run(conn, run["id"], "canceled")
+ assert repo.request_run_cancel(conn, run["id"]) is False
+
+
+def test_list_running_runs_scoped_by_worker(conn):
+ agent = _agent(conn)
+ r1 = repo.create_run(conn, agent["id"], "s1", "worker-a", "spawn")
+ r2 = repo.create_run(conn, agent["id"], "s2", "worker-b", "spawn")
+ repo.finish_run(conn, r1["id"], "completed")
+ running = repo.list_running_runs(conn)
+ assert [r["id"] for r in running] == [r2["id"]]
+ assert repo.list_running_runs(conn, worker_id="worker-a") == []
+ assert [r["id"] for r in repo.list_running_runs(conn, worker_id="worker-b")] == [r2["id"]]
+
+
+def test_latest_run_and_agent_session(conn):
+ agent = _agent(conn)
+ repo.create_run(conn, agent["id"], "s1", "w", "spawn")
+ latest = repo.create_run(conn, agent["id"], "s1", "w", "resume")
+ assert repo.get_latest_run(conn, agent["id"])["id"] == latest["id"]
+
+ repo.set_agent_session(conn, agent["id"], "s1", "worker-a")
+ updated = repo.get_agent_by_id(conn, agent["id"])
+ assert updated["session_id"] == "s1"
+ assert updated["worker_id"] == "worker-a"
+
+
+def test_agent_status_crashed_allowed(conn):
+ """Migration 0008 widened ck_agents_status — 'crashed' must insert cleanly."""
+ agent = _agent(conn)
+ repo.set_agent_status(conn, agent["id"], "crashed")
+ assert repo.get_agent_by_id(conn, agent["id"])["status"] == "crashed"
+
+
+# ---------------------------------------------------------------------- agent_events
+
+
+def test_events_cursor_pagination(conn):
+ agent = _agent(conn)
+ run = repo.create_run(conn, agent["id"], "sid", "w", "spawn")
+ for seq in range(1, 4):
+ repo.insert_agent_event(
+ conn, agent["id"], run["id"], seq=seq, type="assistant",
+ payload={"n": seq}, session_id="sid",
+ )
+ first = repo.list_agent_events(conn, agent["id"], limit=2)
+ assert [e["payload"]["n"] for e in first] == [1, 2]
+ rest = repo.list_agent_events(conn, agent["id"], after_id=first[-1]["id"])
+ assert [e["payload"]["n"] for e in rest] == [3]
+ assert repo.list_agent_events(conn, agent["id"], after_id=rest[-1]["id"]) == []
+
+
+# ------------------------------------------------------------------ session_archives
+
+
+def test_session_archive_upsert_replaces(conn):
+ agent = _agent(conn)
+ repo.upsert_session_archive(conn, agent["id"], "s1", b"v1")
+ repo.upsert_session_archive(conn, agent["id"], "s2", b"v2-longer")
+ row = repo.get_session_archive(conn, agent["id"])
+ assert row["session_id"] == "s2"
+ assert bytes(row["archive"]) == b"v2-longer"
+ assert row["bytes"] == len(b"v2-longer")
+
+
+def test_session_archive_missing(conn):
+ agent = _agent(conn)
+ assert repo.get_session_archive(conn, agent["id"]) is None
+
+
+# ------------------------------------------------------------------------- workers
+
+
+def test_worker_heartbeat_upsert_and_staleness(conn):
+ repo.upsert_worker_heartbeat(conn, "worker-a", hostname="h1", pid=42, max_runs=4)
+ repo.upsert_worker_heartbeat(conn, "worker-a", hostname="h1", pid=42, active_runs=2)
+
+ future = datetime.now(UTC) + timedelta(seconds=1)
+ stale = repo.list_stale_workers(conn, cutoff=future)
+ assert [w["id"] for w in stale] == ["worker-a"]
+ assert stale[0]["active_runs"] == 2
+
+ past = datetime.now(UTC) - timedelta(minutes=5)
+ assert repo.list_stale_workers(conn, cutoff=past) == []
+
+
+# ------------------------------------------------------------------- claim filters
+
+
+def test_claim_respects_target_worker(conn):
+ repo.create_project(conn, "p", "/projects/p")
+ pinned = repo.enqueue_command(conn, "login_submit", payload={"code": "x"},
+ target_worker="worker-b")
+ # worker-a can't see worker-b's pinned command...
+ assert repo.claim_next_command(conn, "worker-a") is None
+ # ...but worker-b claims it.
+ claimed = repo.claim_next_command(conn, "worker-b")
+ assert claimed["id"] == pinned["id"]
+ assert claimed["status"] == "running"
+
+
+def test_claim_excludes_types_when_slots_full(conn):
+ repo.create_project(conn, "p", "/projects/p")
+ spawn_cmd = repo.enqueue_command(conn, "spawn", project_id="p", agent_name="a")
+ sync_cmd = repo.enqueue_command(conn, "sync", project_id="p")
+ # With run-starting types excluded, the older spawn is skipped for the sync.
+ claimed = repo.claim_next_command(
+ conn, "w", types_excluded=("spawn", "resume", "mise_init")
+ )
+ assert claimed["id"] == sync_cmd["id"]
+ # The spawn stays queued for a worker with a free slot.
+ assert repo.claim_next_command(conn, "w2")["id"] == spawn_cmd["id"]
+
+
+def test_delete_agent_cascades_headless_rows(conn):
+ agent = _agent(conn)
+ run = repo.create_run(conn, agent["id"], "sid", "w", "spawn")
+ repo.insert_agent_event(conn, agent["id"], run["id"], seq=1, type="system", payload={})
+ repo.upsert_session_archive(conn, agent["id"], "sid", b"data")
+
+ assert repo.delete_agent(conn, "p", agent["name"]) is True
+ assert repo.get_latest_run(conn, agent["id"]) is None
+ assert repo.list_agent_events(conn, agent["id"]) == []
+ assert repo.get_session_archive(conn, agent["id"]) is None
diff --git a/tests/test_worker.py b/tests/test_worker.py
index bdbd7e6..135570d 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -65,8 +65,8 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch):
_seed_project("api")
seen = {}
- def fake_resume(agent, ans):
- seen.update(name=agent["name"], ans=ans)
+ def fake_resume(agent, ans, worker_id=None):
+ seen.update(name=agent["name"], ans=ans, worker_id=worker_id)
return True, "ok"
monkeypatch.setattr(spawn, "resume", fake_resume)
@@ -74,11 +74,29 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch):
worker.drain("w")
assert _get(cmd["id"])["status"] == "done"
- assert seen == {"name": "api", "ans": "Postgres"}
+ assert seen == {"name": "api", "ans": "Postgres", "worker_id": "w"}
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "p", "api")["status"] == "working"
+def test_resume_command_fails_loudly_when_undeliverable(env, monkeypatch):
+ """An undeliverable answer must surface as a FAILED command — never a silent 'done'
+ (the original bug: send-keys into a dead tmux pane reported success)."""
+ _seed_project("api")
+ monkeypatch.setattr(
+ spawn, "resume", lambda agent, ans, worker_id=None: (False, "no live session")
+ )
+ cmd = _enqueue(type="resume", project_id="p", agent_name="api", payload={"answer": "x"})
+
+ worker.drain("w")
+ failed = _get(cmd["id"])
+ assert failed["status"] == "failed"
+ assert "no live session" in failed["error"]
+ with get_engine().begin() as conn:
+ # The agent must NOT be flipped to working when nothing was delivered.
+ assert repo.get_agent_by_name(conn, "p", "api")["status"] != "working"
+
+
def test_approve_command_records_operator_verdict_with_head_sha(env, fake_gitops):
_seed_project("senior")
cmd = _enqueue(
@@ -211,45 +229,6 @@ def test_bad_command_is_recorded_failed_not_raised(env):
assert "agent name" in failed["error"]
-def test_capture_agent_output_snapshots_working_agents(env, monkeypatch):
- with get_engine().begin() as conn:
- repo.create_project(conn, "p", "/tmp/p")
- agent = repo.create_agent(conn, "p", "api", "/tmp/p/api", status="working")
-
- monkeypatch.setattr(worker.tmux, "has_session", lambda name: True)
- monkeypatch.setattr(
- worker.tmux, "capture_pane", lambda name, escapes=False: "boot\nTheme picker\n\n\n"
- )
-
- assert worker.capture_agent_output() == 1
- with get_engine().begin() as conn:
- row = repo.get_agent_by_id(conn, agent["id"])
- # The tail is stored with trailing blank lines trimmed.
- assert row["last_output"] == "boot\nTheme picker"
- assert row["output_at"] is not None
-
-
-def test_capture_agent_output_skips_dead_sessions_and_nonworking(env, monkeypatch):
- with get_engine().begin() as conn:
- repo.create_project(conn, "p", "/tmp/p")
- repo.create_agent(conn, "p", "gone", "/tmp/p/gone", status="working")
- done = repo.create_agent(conn, "p", "done", "/tmp/p/done", status="done")
-
- captured = []
- monkeypatch.setattr(worker.tmux, "has_session", lambda name: False)
- monkeypatch.setattr(
- worker.tmux,
- "capture_pane",
- lambda name, escapes=False: captured.append(name) or "x",
- )
-
- # The working agent's session is dead (skipped); the done agent isn't queried at all.
- assert worker.capture_agent_output() == 0
- assert captured == []
- with get_engine().begin() as conn:
- assert repo.get_agent_by_id(conn, done["id"])["last_output"] is None
-
-
def test_drain_processes_multiple_then_stops(env, monkeypatch):
_seed_project()
monkeypatch.setattr(poller, "sweep", lambda project_id=None: {"checked": 0})
diff --git a/tests/test_worker_concurrency.py b/tests/test_worker_concurrency.py
new file mode 100644
index 0000000..9e9d0e2
--- /dev/null
+++ b/tests/test_worker_concurrency.py
@@ -0,0 +1,76 @@
+"""Slot-aware command claiming: a worker at max_concurrent_runs must leave run-starting
+commands queued (for a less-loaded worker) while still processing everything else. Slot
+accounting is DB-driven — this worker's ``running`` agent_runs rows — so it needs no
+in-memory registry and is exercised here without real subprocesses."""
+
+from __future__ import annotations
+
+import pytest
+
+from handler.control import spawn, worker
+from handler.db import repository as repo
+from handler.db.engine import get_engine
+
+
+@pytest.fixture
+def headless_env(env, monkeypatch):
+ from handler import config
+
+ monkeypatch.setenv("MAX_CONCURRENT_RUNS", "2")
+ config.get_settings.cache_clear()
+ yield env
+ config.get_settings.cache_clear()
+
+
+def _seed(conn_count_running_for=None):
+ with get_engine().begin() as conn:
+ repo.create_project(conn, "p", "/tmp/p")
+ agent = repo.create_agent(conn, "p", "a", "/tmp/p/a")
+ return agent
+
+
+def _running_run(agent_id, worker_id):
+ with get_engine().begin() as conn:
+ return repo.create_run(conn, agent_id, f"sid-{worker_id}", worker_id, "spawn")
+
+
+def test_full_worker_skips_run_commands_but_processes_others(headless_env, monkeypatch):
+ agent = _seed()
+ _running_run(agent["id"], "w-full")
+ _running_run(agent["id"], "w-full") # 2 running == MAX_CONCURRENT_RUNS
+
+ spawned = {}
+ monkeypatch.setattr(
+ spawn, "spawn",
+ lambda project_id, name, **kw: spawned.update(name=name, **kw)
+ or {"id": 1, "name": name, "working_dir": "/tmp/p/x", "forge_note": None},
+ )
+ with get_engine().begin() as conn:
+ spawn_cmd = repo.enqueue_command(conn, "spawn", project_id="p", agent_name="x")
+ kill_cmd = repo.enqueue_command(conn, "kill", project_id="p", agent_name="a")
+ monkeypatch.setattr(spawn, "kill", lambda p, n: None)
+
+ # The full worker processes the kill but leaves the spawn queued.
+ assert worker.drain("w-full") == 1
+ with get_engine().begin() as conn:
+ assert repo.get_command(conn, kill_cmd["id"])["status"] == "done"
+ assert repo.get_command(conn, spawn_cmd["id"])["status"] == "queued"
+ assert spawned == {}
+
+ # A worker with free slots picks the spawn up.
+ assert worker.drain("w-free") == 1
+ with get_engine().begin() as conn:
+ assert repo.get_command(conn, spawn_cmd["id"])["status"] == "done"
+ assert spawned["name"] == "x"
+ assert spawned["worker_id"] == "w-free"
+
+
+def test_slot_frees_when_run_finishes(headless_env, monkeypatch):
+ agent = _seed()
+ run1 = _running_run(agent["id"], "w1")
+ _running_run(agent["id"], "w1")
+ assert worker._full_slot_exclusions("w1") == worker._RUN_COMMANDS
+
+ with get_engine().begin() as conn:
+ repo.finish_run(conn, run1["id"], "completed", exit_code=0)
+ assert worker._full_slot_exclusions("w1") == ()