diff --git a/.gitignore b/.gitignore
index c4971fa..e42ddb6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,9 @@ downloads/
eggs/
.eggs/
lib/
+# ...but the frontend's own source lib/ (api client + formatters) must be tracked, or the
+# UI can't be rebuilt from a fresh clone. This Python-packaging `lib/` rule swallowed it.
+!frontend/lib/
lib64/
parts/
sdist/
diff --git a/Dockerfile.control b/Dockerfile.control
index 4077c92..c05620e 100644
--- a/Dockerfile.control
+++ b/Dockerfile.control
@@ -5,6 +5,13 @@
# database, and the /var/lib/handler data volume with the API image (see Dockerfile),
# but runs the control process instead of uvicorn.
+# ---- forge build stage: compile the git-forge CLI (git-pkgs/forge, Go) ----
+# Built here and copied into the runtime image as a single static binary, so the runtime
+# stage needs no Go toolchain. `forge` gives the CI poller its cross-forge `ci list`.
+FROM golang:1.22-bookworm AS forge-builder
+ENV CGO_ENABLED=0
+RUN go install github.com/git-pkgs/forge/cmd/forge@latest
+
# ---- build stage: install the package + deps into an isolated venv ----
FROM python:3.11-slim AS builder
@@ -22,14 +29,41 @@ RUN pip install .
# ---- runtime stage ----
FROM python:3.11-slim
-# git + tmux are the live-spawning dependencies the control layer shells out to
-# (README "Requirements"); openssh-client covers git-over-ssh remotes. The `claude`
-# and `forge` binaries are bring-your-own — layer or mount them in for live agent
-# spawning and CI resolution; the poller degrades gracefully when forge is absent.
+# Every executable the control layer shells out to is now bundled — no bring-your-own
+# binaries — so the container can spawn live agents, run the verification gate, resolve CI,
+# and drive the claude web-login flow out of the box:
+# git / openssh-client — clone/push over https + ssh remotes
+# tmux — one detached session per agent (and per login attempt)
+# node + claude — the Claude Code CLI the agents *are*, and the /login flow the
+# dashboard drives (see control/login.py)
+# mise — the per-project task runner the test/build gates invoke
+# forge — the cross-forge CLI the CI poller reads run status from
+# Node comes from NodeSource (>=18 is required by Claude Code); mise from its official apt
+# repo; forge from the build stage above. Installed under /usr/{bin,local/bin} — outside the
+# /var/lib/handler VOLUME — so the volume mount never masks them at runtime. The image is
+# built for amd64 and arm64: NodeSource + forge detect the arch, and the mise apt source is
+# pinned to $TARGETARCH (buildx sets it; the Debian arch names match) so the arm64 build
+# doesn't pull an amd64-only list.
+ARG TARGETARCH=amd64
RUN apt-get update \
- && apt-get install -y --no-install-recommends git tmux openssh-client \
+ && apt-get install -y --no-install-recommends \
+ git tmux openssh-client curl ca-certificates gnupg \
+ && install -dm 755 /etc/apt/keyrings \
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && npm install -g @anthropic-ai/claude-code \
+ && npm cache clean --force \
+ && curl -fsSL https://mise.jdx.dev/gpg-key.pub \
+ | gpg --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \
+ && echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=${TARGETARCH}] https://mise.jdx.dev/deb stable main" \
+ > /etc/apt/sources.list.d/mise.list \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends mise \
&& rm -rf /var/lib/apt/lists/*
+# The cross-forge CLI compiled in the build stage above (github.com/git-pkgs/forge).
+COPY --from=forge-builder /go/bin/forge /usr/local/bin/forge
+
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
# SQLite fallback lives on the /var/lib/handler volume; point DATABASE_URL at the
diff --git a/README.md b/README.md
index 9841d67..0fe4532 100644
--- a/README.md
+++ b/README.md
@@ -155,9 +155,10 @@ the `/var/lib/handler` data volume:
| `ghcr.io/0xwheatyz/handler` | [`Dockerfile`](Dockerfile) | the API (`uvicorn`) — also applies migrations on start | [`docker.yml`](.github/workflows/docker.yml) |
| `ghcr.io/0xwheatyz/handler/control` | [`Dockerfile.control`](Dockerfile.control) | the control worker (`handler worker`) | [`docker-control.yml`](.github/workflows/docker-control.yml) |
-The control image bakes in `git` + `tmux`; the `claude` and `forge` binaries are
-bring-your-own (layer or mount them in for live agent spawning — the CI poller degrades
-gracefully without `forge`). The **worker** drains the control-command queue the API
+The control image bakes in **every executable the control layer shells out to** — `git`,
+`tmux`, `openssh-client`, `node` + the `claude` CLI, `mise`, and `forge` — so live agent
+spawning, the verification gate, CI resolution, and the [web login](#claude-login-from-the-web-ui)
+flow all work with zero bring-your-own binaries. The **worker** drains the control-command queue the API
enqueues (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval
(subsuming `poll-ci --watch`), so the whole system is drivable from the dashboard — see
[Web management](#web-management).
@@ -224,12 +225,38 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
- **Activity** — every enqueued command with its status (queued → running → done/failed) —
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
live status.
+- **Claude Login** — log Claude Code in on the host from the browser (see below), so agents
+ spawn against a real authenticated `claude` with no shell access to the container.
The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`,
-`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`, and
-`GET /commands[/{id}]`; hosts as `/hosts`; schedules as `/schedules` +
-`/projects/{id}/schedules`; project mutation as `PATCH`/`DELETE /projects/{id}`. Run the
-worker with `handler worker` (the control image's default command).
+`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`,
+`POST /login/start`, `POST /login/submit`, and `GET /commands[/{id}]`; hosts as `/hosts`;
+schedules as `/schedules` + `/projects/{id}/schedules`; project mutation as
+`PATCH`/`DELETE /projects/{id}`. Run the worker with `handler worker` (the control image's
+default command).
+
+### Claude login from the web UI
+
+Agents *are* `claude` processes, so the control container needs a logged-in Claude Code.
+Because that container has no interactive shell in normal operation, the **Claude Login**
+pane logs it in from the browser — the same command-queue handoff every other control
+action uses:
+
+1. **Log in to Claude** enqueues a `login_start` command. The worker opens `claude` in a
+ dedicated tmux session in the control container, sends `/login`, selects the **Claude
+ account with subscription** option, and scrapes the pane for the `claude.com`
+ authorization URL — returned in the command result.
+2. The UI opens that URL in an embedded frame (with a new-tab link as a fallback, since
+ claude.com may refuse to be framed). You authorize and Claude gives you a code.
+3. **Finish login** enqueues a `login_submit` command carrying the code; the worker feeds
+ it into the still-open session, waits for claude to exchange it, and reports success.
+
+The login session lives in the control container, and Claude's credentials land under the
+`handler` user's home on the `/var/lib/handler` volume — so the login **persists** across
+restarts and is shared by every agent the worker spawns. The flow is admin-gated
+(`ADMIN_TOKEN`) and driven entirely through `POST /login/start` and `POST /login/submit`.
+The interactive claude TUI is timing-sensitive; the waits in `control.login` are generous
+and overridable if a slow host needs more.
## Control CLI
diff --git a/docker-compose.yml b/docker-compose.yml
index c1f6958..e4683f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,9 +33,9 @@ services:
# Shares the database and the handler-data volume with the API. It waits for the API
# (which owns migrations), so RUN_MIGRATIONS is off here to avoid a startup race. Run
# one-shot control commands against the same image with, e.g.,
- # `docker compose run --rm control handler list`. Live agent spawning also needs
- # `git`/`tmux` (baked in) plus bring-your-own `claude`/`forge` binaries — layer or mount
- # those in.
+ # `docker compose run --rm control handler list`. Every executable it shells out to —
+ # `git`, `tmux`, `node`+`claude`, `mise`, `forge` — is bundled in the image, so live
+ # agent spawning and the claude web-login flow work with no bring-your-own binaries.
control:
image: ghcr.io/0xwheatyz/handler/control:latest
build:
diff --git a/frontend/components/Dashboard.tsx b/frontend/components/Dashboard.tsx
index 5b0192a..3868382 100644
--- a/frontend/components/Dashboard.tsx
+++ b/frontend/components/Dashboard.tsx
@@ -12,6 +12,7 @@ import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
import { GitServersSection } from "@/components/sections/GitServersSection";
import { ActivitySection } from "@/components/sections/ActivitySection";
import { SharedSection } from "@/components/sections/SharedSection";
+import { LoginSection } from "@/components/sections/LoginSection";
interface NavDef {
key: Section;
@@ -34,6 +35,13 @@ const NAV: NavDef[] = [
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
+ {
+ key: "login",
+ label: "Claude Login",
+ count: () => 0,
+ // Draw the eye to it until Claude is logged in on the host this session.
+ accent: (s) => s.claudeLogin.status !== "done",
+ },
];
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
@@ -97,6 +105,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
{s.section === "servers" && }
{s.section === "activity" && }
{s.section === "shared" && }
+ {s.section === "login" && }
)}
diff --git a/frontend/components/sections/LoginSection.tsx b/frontend/components/sections/LoginSection.tsx
new file mode 100644
index 0000000..8dfc582
--- /dev/null
+++ b/frontend/components/sections/LoginSection.tsx
@@ -0,0 +1,118 @@
+/* Claude Login — drive the bundled `claude /login` OAuth flow on the host from the web UI.
+ *
+ * Click "Log in to Claude" → the worker opens `claude /login` in the control container,
+ * selects the subscription account, and returns the claude.com authorization URL. That URL
+ * is shown in an embedded frame (and as a new-tab link, since claude.com may refuse to be
+ * framed); after authorizing, paste the code back to finish. All state lives in the store's
+ * `claudeLogin` machine (login_start / login_submit commands). */
+"use client";
+
+import { useState } from "react";
+import { useDashboard } from "@/components/store";
+import { Button, Callout, Input } from "@/components/ui";
+
+export function LoginSection() {
+ const s = useDashboard();
+ const { status, url, message } = s.claudeLogin;
+ const [code, setCode] = useState("");
+
+ const busy = status === "starting" || status === "submitting";
+ const awaiting = status === "awaiting" || status === "submitting";
+
+ const submit = async () => {
+ const ok = await s.submitClaudeCode(code);
+ if (ok) setCode("");
+ };
+
+ return (
+ <>
+
+
Claude Login
+
+ Log Claude Code in on the host so agents can run. This drives{" "}
+ claude /login in the control container and picks the
+ Claude account with a subscription.
+
+ If the frame stays blank, claude.com is refusing to be embedded — use the
+ new-tab link above instead. The login session stays open until you submit the
+ code or restart.
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/frontend/components/store.tsx b/frontend/components/store.tsx
index 1974691..687d9a6 100644
--- a/frontend/components/store.tsx
+++ b/frontend/components/store.tsx
@@ -36,7 +36,24 @@ export type Section =
| "approvals"
| "servers"
| "activity"
- | "shared";
+ | "shared"
+ | "login";
+
+/* The claude web-login flow, driven through the login_start / login_submit commands.
+ * idle → starting → awaiting (have URL) → submitting → done | error */
+export type ClaudeLoginStatus =
+ | "idle"
+ | "starting"
+ | "awaiting"
+ | "submitting"
+ | "done"
+ | "error";
+
+export interface ClaudeLoginState {
+ status: ClaudeLoginStatus;
+ url: string;
+ message: string;
+}
export interface RunAgent extends Agent {}
@@ -97,6 +114,12 @@ interface StoreValue {
deleteSchedule: (id: number) => Promise;
pollCi: () => Promise;
setSharedKey: (key: string, value: string) => Promise;
+
+ // Claude web-login
+ claudeLogin: ClaudeLoginState;
+ startClaudeLogin: () => Promise;
+ submitClaudeCode: (code: string) => Promise;
+ resetClaudeLogin: () => void;
}
export interface SpawnBody {
@@ -189,6 +212,11 @@ export function DashboardProvider({
const [cmd, setCmd] = useState({ text: "", error: false, busy: false });
const [lastError, setLastError] = useState("");
const [loading, setLoading] = useState(true);
+ const [claudeLogin, setClaudeLogin] = useState({
+ status: "idle",
+ url: "",
+ message: "",
+ });
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -747,6 +775,89 @@ export function DashboardProvider({
await loadCommands();
}, [enqueueAndTrack, loadCommands]);
+ // ---- claude web-login (login_start / login_submit through the command queue) ----
+ const startClaudeLogin = useCallback(async () => {
+ setClaudeLogin({
+ status: "starting",
+ url: "",
+ message: "Opening `claude /login` in the control container and selecting the subscription account…",
+ });
+ try {
+ const command = await clientRef.current.api("/login/start", { method: "POST" });
+ // login_start boots claude, drives the menu, and scrapes the URL — allow ~90s
+ // (worker claim latency + boot waits + URL timeout).
+ const final = await clientRef.current.trackCommand(command.id, { attempts: 180 });
+ if (!final) {
+ setClaudeLogin({
+ status: "error",
+ url: "",
+ message: "Still starting (see Activity). Is the control worker running?",
+ });
+ return;
+ }
+ if (final.status !== "done") {
+ setClaudeLogin({ status: "error", url: "", message: final.error || "Failed to start login." });
+ return;
+ }
+ const url = final.result && typeof final.result.url === "string" ? final.result.url : "";
+ if (!url) {
+ setClaudeLogin({ status: "error", url: "", message: "No login URL was returned by claude." });
+ return;
+ }
+ setClaudeLogin({
+ status: "awaiting",
+ url,
+ 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 AuthError) return;
+ setClaudeLogin({ status: "error", url: "", message: (e as Error).message });
+ }
+ }, []);
+
+ const submitClaudeCode = useCallback(async (code: string) => {
+ const trimmed = code.trim();
+ if (!trimmed) return false;
+ setClaudeLogin((c) => ({ ...c, status: "submitting", message: "Submitting the authorization code…" }));
+ try {
+ const command = await clientRef.current.api("/login/submit", {
+ method: "POST",
+ body: { code: trimmed },
+ });
+ const final = await clientRef.current.trackCommand(command.id, { attempts: 60 });
+ if (!final) {
+ setClaudeLogin((c) => ({
+ ...c,
+ status: "awaiting",
+ message: "Submit still running (see Activity). Is the control worker running?",
+ }));
+ return false;
+ }
+ if (final.status === "done") {
+ setClaudeLogin({
+ status: "done",
+ url: "",
+ message: "Claude Code is now logged in on the host — new agents will use this account.",
+ });
+ return true;
+ }
+ setClaudeLogin((c) => ({
+ ...c,
+ status: "awaiting",
+ message: final.error || "Login was not confirmed. Re-check the code, or restart the flow.",
+ }));
+ return false;
+ } catch (e) {
+ if (e instanceof AuthError) return false;
+ setClaudeLogin((c) => ({ ...c, status: "awaiting", message: (e as Error).message }));
+ return false;
+ }
+ }, []);
+
+ const resetClaudeLogin = useCallback(() => {
+ setClaudeLogin({ status: "idle", url: "", message: "" });
+ }, []);
+
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
@@ -806,6 +917,10 @@ export function DashboardProvider({
deleteSchedule,
pollCi,
setSharedKey,
+ claudeLogin,
+ startClaudeLogin,
+ submitClaudeCode,
+ resetClaudeLogin,
};
return {children};
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..4cb2dc0
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,204 @@
+/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
+ * pydantic schemas in src/handler/api/schemas.py). The browser calls the API same-origin
+ * with relative paths; set NEXT_PUBLIC_API_BASE to point `npm run dev` at another origin.
+ *
+ * NOTE: this file lives under frontend/lib/, now un-ignored in .gitignore so the source
+ * ships and the build works from a fresh clone (the built export under
+ * src/handler/api/static/ is what the package serves). */
+
+const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
+
+export type CommandStatus = "queued" | "running" | "done" | "failed";
+
+export interface Project {
+ id: string;
+ root_dir: string;
+ git_remote?: string | null;
+ credential_ref?: string | null;
+ created_at: string;
+ /* Present on the registration response in git-server mode: the enqueued clone. */
+ sync_command_id?: number | null;
+}
+
+export interface Agent {
+ id: number;
+ project_id: string;
+ name: string;
+ working_dir: string;
+ status: string;
+ role?: string | null;
+ created_at: string;
+}
+
+export interface Checkmark {
+ agent_id: number;
+ checkpoint_at: string;
+ status: string;
+ where_it_stopped?: string | null;
+ next_steps?: string[] | null;
+ open_question?: string | null;
+ log_entry_id?: number | null;
+ tests_status: string;
+ tested_at?: string | null;
+ build_status: string;
+ built_at?: string | null;
+}
+
+export interface LogEntry {
+ id: number;
+ agent_id: number;
+ created_at: string;
+ session_id?: string | null;
+ status: string;
+ summary?: string | null;
+ decisions?: string | null;
+ question?: string | null;
+ answer?: string | null;
+ visibility: string;
+ push_sha?: string | null;
+ ci_status: string;
+ ci_checked_at?: string | null;
+}
+
+export interface Approval {
+ id: number;
+ project_id: string;
+ branch: string;
+ approved_sha?: string | null;
+ pr_ref?: string | null;
+ status: string;
+ approved_by_agent_id?: number | null;
+ actor?: string | null;
+ note?: string | null;
+ created_at: string;
+}
+
+export interface Host {
+ hostname: string;
+ forge_type: string;
+ token_env_var?: string | null;
+ base_url?: string | null;
+ ssh_public_key?: string | null;
+ has_token: boolean;
+ created_at: string;
+}
+
+export interface Command {
+ id: number;
+ project_id?: string | null;
+ agent_name?: string | null;
+ type: string;
+ payload?: Record | null;
+ status: CommandStatus;
+ result?: Record | null;
+ error?: string | null;
+ requested_by?: string | null;
+ claimed_by?: string | null;
+ created_at: string;
+ claimed_at?: string | null;
+ finished_at?: string | null;
+}
+
+export interface Schedule {
+ id: number;
+ project_id: string;
+ name_prefix: string;
+ task: string;
+ role?: string | null;
+ worktree?: string | null;
+ subdir?: string | null;
+ interval_seconds: number;
+ enabled: boolean;
+ next_run_at: string;
+ last_run_at?: string | null;
+ last_command_id?: number | null;
+ created_at: string;
+}
+
+export interface SharedContext {
+ key: string;
+ value: string;
+ set_by_agent_id?: number | null;
+ updated_at: string;
+}
+
+/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
+ * quiet while the app re-prompts for a token. */
+export class AuthError extends Error {
+ constructor(message = "unauthorized") {
+ super(message);
+ this.name = "AuthError";
+ }
+}
+
+/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
+export interface ApiError extends Error {
+ status: number;
+}
+
+interface ApiOptions {
+ method?: string;
+ body?: unknown;
+}
+
+interface TrackOptions {
+ attempts?: number;
+ intervalMs?: number;
+}
+
+export interface ApiClient {
+ api: (path: string, opts?: ApiOptions) => Promise;
+ /* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
+ * budget (worker down or a very slow command). */
+ trackCommand: (id: number, opts?: TrackOptions) => Promise;
+}
+
+export function createClient(token: string, onUnauthorized: () => void): ApiClient {
+ async function api(path: string, opts?: ApiOptions): Promise {
+ const hasBody = opts?.body !== undefined && opts?.body !== null;
+ const res = await fetch(BASE + path, {
+ method: opts?.method ?? (hasBody ? "POST" : "GET"),
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
+ },
+ body: hasBody ? JSON.stringify(opts!.body) : undefined,
+ });
+
+ if (res.status === 401) {
+ onUnauthorized();
+ throw new AuthError();
+ }
+ if (!res.ok) {
+ let detail: string = res.statusText;
+ try {
+ const j = await res.json();
+ if (j && typeof j.detail !== "undefined") {
+ detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
+ }
+ } catch {
+ /* non-JSON error body; keep statusText */
+ }
+ const err = new Error(detail) as ApiError;
+ err.status = res.status;
+ throw err;
+ }
+
+ if (res.status === 204) return undefined as T;
+ const text = await res.text();
+ return (text ? JSON.parse(text) : undefined) as T;
+ }
+
+ async function trackCommand(id: number, opts?: TrackOptions): Promise {
+ const attempts = opts?.attempts ?? 60;
+ const intervalMs = opts?.intervalMs ?? 500;
+ for (let i = 0; i < attempts; i++) {
+ const cmd = await api(`/commands/${id}`);
+ if (cmd.status === "done" || cmd.status === "failed") return cmd;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return null;
+ }
+
+ return { api, trackCommand };
+}
diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts
new file mode 100644
index 0000000..e60fb80
--- /dev/null
+++ b/frontend/lib/format.ts
@@ -0,0 +1,92 @@
+/* Formatting + status helpers shared by the UI. Pure functions, no API access.
+ *
+ * NOTE: this file lives under frontend/lib/, which the repo's top-level .gitignore used to
+ * swallow (a broad `lib/` rule) — it is now un-ignored (see .gitignore) so the source ships
+ * and `npm run build` works from a fresh clone. The *built* export under
+ * src/handler/api/static/ is still what the Python package serves. */
+
+export type Tone = "neutral" | "info" | "success" | "warning" | "danger";
+
+/* Map a raw handler status string (agent status, gate status, CI status) to a badge tone. */
+export function statusTone(status: string | null | undefined): Tone {
+ switch ((status ?? "").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";
+ }
+}
+
+const LABELS: Record = {
+ paused_for_input: "Needs input",
+ not_applicable: "N/A",
+};
+
+/* A tidy, human-readable label for a status string. */
+export function statusLabel(status: string | null | undefined): string {
+ const raw = (status ?? "").trim();
+ if (!raw) return "—";
+ const key = raw.toLowerCase();
+ if (LABELS[key]) return LABELS[key];
+ return key
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/* Full local date + time, e.g. "Jul 13, 2026, 2:04 PM". "—" for empty. */
+export function fmtFull(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return String(iso);
+ return d.toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+/* First 7 chars of a commit sha. "—" for empty. */
+export function shortSha(sha: string | null | undefined): string {
+ if (!sha) return "—";
+ return sha.slice(0, 7);
+}
+
+/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. */
+export function timeAgo(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const then = new Date(iso).getTime();
+ if (Number.isNaN(then)) return "—";
+ const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
+ if (secs < 60) return `${secs}s`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo`;
+ return `${Math.floor(months / 12)}y`;
+}
diff --git a/src/handler/api/app.py b/src/handler/api/app.py
index ad2cb70..1c274f4 100644
--- a/src/handler/api/app.py
+++ b/src/handler/api/app.py
@@ -20,6 +20,7 @@ from .routes import (
commands,
hosts,
interaction,
+ login,
projects,
schedules,
shared,
@@ -46,6 +47,7 @@ def create_app() -> FastAPI:
app.include_router(interaction.router)
app.include_router(approvals.router)
app.include_router(commands.router)
+ app.include_router(login.router)
app.include_router(hosts.router)
app.include_router(schedules.router)
app.include_router(shared.router)
diff --git a/src/handler/api/routes/login.py b/src/handler/api/routes/login.py
new file mode 100644
index 0000000..a682d02
--- /dev/null
+++ b/src/handler/api/routes/login.py
@@ -0,0 +1,52 @@
+"""Claude Code web-login: enqueue the two-step ``/login`` flow the worker runs.
+
+The API container has no ``claude`` binary and doesn't own the tmux sessions, so — like
+spawn/kill — logging Claude Code in is a control command the worker executes in the
+control container:
+
+- ``POST /login/start`` enqueues ``login_start``; its result carries the ``url`` the UI
+ opens (in an iframe) for the operator to authorize.
+- ``POST /login/submit`` enqueues ``login_submit`` with the pasted ``code``; its result
+ carries ``success``.
+
+Both are admin-gated (they act on the host's Claude credentials). The UI polls
+``GET /commands/{id}`` for each, exactly as it does for spawns.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy import Connection
+
+from ...db import repository as repo
+from ..deps import db_conn, require_admin, require_auth
+from ..schemas import CommandOut, LoginSubmitIn
+
+router = APIRouter(tags=["login"], dependencies=[Depends(require_auth)])
+
+
+@router.post(
+ "/login/start",
+ response_model=CommandOut,
+ status_code=status.HTTP_202_ACCEPTED,
+ dependencies=[Depends(require_admin)],
+)
+def enqueue_login_start(conn: Connection = Depends(db_conn)) -> dict:
+ """Open ``claude /login`` in the control container and return the authorization URL."""
+ return repo.enqueue_command(conn, "login_start", requested_by="operator:web")
+
+
+@router.post(
+ "/login/submit",
+ response_model=CommandOut,
+ status_code=status.HTTP_202_ACCEPTED,
+ 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."""
+ return repo.enqueue_command(
+ conn,
+ "login_submit",
+ payload={"code": body.code},
+ requested_by="operator:web",
+ )
diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py
index 0412553..74f04a2 100644
--- a/src/handler/api/schemas.py
+++ b/src/handler/api/schemas.py
@@ -154,6 +154,12 @@ class CommandOut(BaseModel):
finished_at: datetime | None = None
+class LoginSubmitIn(BaseModel):
+ """The authorization code the operator pastes back after logging in at claude.com."""
+
+ code: str = Field(min_length=1)
+
+
class HostIn(BaseModel):
hostname: str
forge_type: ForgeType
diff --git a/src/handler/api/static/404.html b/src/handler/api/static/404.html
index 2b3d6e4..d6e2e5f 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/twm_FqRjMWdGYZ-lbTh7o/_buildManifest.js b/src/handler/api/static/_next/static/J55muUx2ya8M2SQERVlYt/_buildManifest.js
similarity index 100%
rename from src/handler/api/static/_next/static/twm_FqRjMWdGYZ-lbTh7o/_buildManifest.js
rename to src/handler/api/static/_next/static/J55muUx2ya8M2SQERVlYt/_buildManifest.js
diff --git a/src/handler/api/static/_next/static/twm_FqRjMWdGYZ-lbTh7o/_ssgManifest.js b/src/handler/api/static/_next/static/J55muUx2ya8M2SQERVlYt/_ssgManifest.js
similarity index 100%
rename from src/handler/api/static/_next/static/twm_FqRjMWdGYZ-lbTh7o/_ssgManifest.js
rename to src/handler/api/static/_next/static/J55muUx2ya8M2SQERVlYt/_ssgManifest.js
diff --git a/src/handler/api/static/_next/static/chunks/app/page-aaee823ffe4c78c3.js b/src/handler/api/static/_next/static/chunks/app/page-aaee823ffe4c78c3.js
new file mode 100644
index 0000000..b0b7f1d
--- /dev/null
+++ b/src/handler/api/static/_next/static/chunks/app/page-aaee823ffe4c78c3.js
@@ -0,0 +1 @@
+(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[931],{2482: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 es}});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,x]=(0,r.useState)([]),[p,j]=(0,r.useState)([]),[v,g]=(0,r.useState)(""),[b,y]=(0,r.useState)(null),[f,k]=(0,r.useState)(null),[N,w]=(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)([]),[L,O]=(0,r.useState)([]),[M,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 V=(0,r.useRef)(v);V.current=v;let Y=(0,r.useRef)(b);Y.current=b;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),w(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),w(!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=Y.current;s&&await ee(s.projectId,s.name),"approvals"===t&&await et(V.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(V.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)=>{y({projectId:e,name:t}),R(0),Q.current=0,k(null),w(!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=Y.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(V.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=Y.current)||void 0===a?void 0:a.name)===t&&y(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=Y.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{let s="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null},a=await d.current.api("/projects",{method:"POST",body:s});if(await $(),null!=a.sync_command_id){F({text:"repository '".concat(a.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(a.sync_command_id);if(e){if("done"===e.status)F({text:"repository '".concat(a.id,"' registered and cloned"),error:!1,busy:!1});else{var t;F({text:"repository '".concat(a.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1})}}else F({text:"repository '".concat(a.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else F({text:"repository '".concat(a.id,"' registered"),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]),eb=(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}},[$]),ey=(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(V.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(V.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]),eN=(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]),ew=(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:b,selectRun:eo,checkmark:f,checkmarkMissing:N,log:_,logOffset:S,pageLog:ed,approvals:I,hosts:A,commands:E,schedules:L,shared:M,cmd:D,lastError:B,loading:q,refresh:eu,spawnAgent:em,killAgent:ex,deleteAgent:ep,submitAnswer:ej,createProject:ev,updateProject:eb,deleteProject:ey,syncProject:eg,submitApproval:ef,createHost:ek,updateHost:eN,deleteHost:eS,createSchedule:ew,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){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)(p,{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 v(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 g(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 y(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 f(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 k(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 w(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 _(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function C(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 S=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"}];function R(){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)(w,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(w,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(w,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(w,{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:S,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(_,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(I,{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)(A,{}):(0,n.jsx)(T,{})})]})]})}function I(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:function(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")}(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 T(){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 A(){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),v=(null==a?void 0:a.status)==="paused_for_input",b=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)(p,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(g,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(g,{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)(_,{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)(p,{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)(p,{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)})]})]}),v&&(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)(_,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(f,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(g,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>b(!1),children:"Answer"}),(0,n.jsx)(g,{variant:"primary",disabled:d||!i.trim(),onClick:()=>b(!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)(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:x(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{tone:u(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(g,{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)(g,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}let z={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:""};function E(){let e=o(),[t,s]=(0,r.useState)(z),[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(z),l(!1)},u=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},h=e=>{var t,a;s({...z,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)(v,{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)(y,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(y,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(y,{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)(y,{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)(k,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(y,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(y,{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."})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(g,{variant:"primary",disabled:e.cmd.busy||!x,onClick:u,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(g,{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)(v,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(p,{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)(g,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(g,{size:"sm",variant:"secondary",onClick:()=>h(t),children:"Edit"}),(0,n.jsx)(g,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let P=[{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"}],O={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function M(){let e=o(),[t,s]=(0,r.useState)(O),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(O)};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)(k,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(v,{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)(y,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(k,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:P}),(0,n.jsx)(k,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:L}),"worktree"===t.placement?(0,n.jsx)(y,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(y,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(f,{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)(g,{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=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(p,{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)(g,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(g,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(g,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]},t.id))})]})})]})})]})}let U=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],D=[{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"}],F={name_prefix:"",task:"",interval:"3600",role:""};function B(){let e=o(),[t,s]=(0,r.useState)(F),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(F)};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)(k,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(v,{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)(y,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(k,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:D}),(0,n.jsx)(k,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:U})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(f,{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)(g,{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)(C,{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)(p,{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=D.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)(g,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let H=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],q={branch:"",status:"approved",agent_name:"",sha:"",note:""};function W(){let e=o(),[t,s]=(0,r.useState)(q),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.submitApproval(t),s(q)};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)(k,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(v,{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)(y,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(k,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:H}),(0,n.jsx)(y,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(y,{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)(y,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(g,{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:x(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let G=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],J={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function K(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)(g,{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)(J),[a,l]=(0,r.useState)(!1),i=()=>{s(J),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)(v,{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)(y,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(k,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:G}),(0,n.jsx)(y,{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)(y,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(y,{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)(g,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(g,{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)(v,{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)(p,{tone:"info",children:t.forge_type}),(0,n.jsx)(p,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(p,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(g,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(g,{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)(K,{value:t.ssh_public_key})]},t.hostname))]})]})}function Y(){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)(g,{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 Q(){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)(v,{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)(y,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(y,{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)(g,{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 X(){let e=o(),{status:t,url:s,message:a}=e.claudeLogin,[l,i]=(0,r.useState)(""),c="starting"===t||"submitting"===t,d=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)(_,{tone:"error"===t?"danger":"done"===t?"success":"info",children:a}),"done"===t?(0,n.jsx)("div",{children:(0,n.jsx)(g,{variant:"secondary",onClick:e.resetClaudeLogin,children:"Log in again"})}):"awaiting"===t||"submitting"===t?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"hstack",style:{gap:10,flexWrap:"wrap"},children:[(0,n.jsx)("a",{className:"btn btn-secondary",href:s,target:"_blank",rel:"noopener noreferrer",children:"Open login page in a new tab ↗"}),(0,n.jsx)(g,{variant:"ghost",disabled:c,onClick:e.startClaudeLogin,children:"Restart"})]}),(0,n.jsx)("div",{style:{border:"1px solid var(--border-default)",borderRadius:10,overflow:"hidden",height:460,background:"var(--surface-1, #111)"},children:(0,n.jsx)("iframe",{title:"Claude login",src:s,style:{width:"100%",height:"100%",border:"none"},sandbox:"allow-forms allow-scripts allow-same-origin allow-popups"})}),(0,n.jsx)("div",{className:"faint",style:{fontSize:"var(--text-xs)"},children:"If the frame stays blank, claude.com is refusing to be embedded — use the new-tab link above instead. The login session stays open until you submit the code or 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)(y,{label:"Authorization code",value:l,onChange:i,placeholder:"Paste the code from claude.com",disabled:"submitting"===t})}),(0,n.jsx)(g,{variant:"primary",disabled:"submitting"===t||!l.trim(),onClick:d,children:"submitting"===t?"Submitting…":"Finish login"})]})]}):(0,n.jsxs)("div",{className:"hstack",style:{gap:10},children:[(0,n.jsx)(g,{variant:"primary",disabled:c,onClick:e.startClaudeLogin,children:"starting"===t?"Starting…":"Log in to Claude"}),"error"===t&&(0,n.jsx)(g,{variant:"ghost",disabled:c,onClick:e.startClaudeLogin,children:"Retry"})]})]})]})}let $=[{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 Z(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"]}),$.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)(R,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(E,{}),"agents"===s.section&&(0,n.jsx)(M,{}),"schedules"===s.section&&(0,n.jsx)(B,{}),"approvals"===s.section&&(0,n.jsx)(W,{}),"servers"===s.section&&(0,n.jsx)(V,{}),"activity"===s.section&&(0,n.jsx)(Y,{}),"shared"===s.section&&(0,n.jsx)(Q,{}),"login"===s.section&&(0,n.jsx)(X,{})]})]})]})}function ee(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 et="handler_token";function es(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(et);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(et,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(et),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(et),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(Z,{onSignOut:i})}):(0,n.jsx)(ee,{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){var a,n;let r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},c={Authorization:"Bearer ".concat(e)};void 0!==r.body&&(c["Content-Type"]="application/json");let o=await fetch("".concat(l).concat(s),{method:null!==(a=r.method)&&void 0!==a?a:"GET",headers:c,body:void 0!==r.body?JSON.stringify(r.body):void 0});if(401===o.status)throw t(),new i("unauthorized");if(!o.ok){let e="".concat(o.status);try{let t=await o.json();e=null!==(n=t.detail)&&void 0!==n?n:e,Array.isArray(e)&&(e=e.map(e=>"object"==typeof e&&e&&"msg"in e?e.msg:JSON.stringify(e)).join("; "))}catch(e){}let t=Error(String(e));throw t.status=o.status,t}return 204===o.status?null:await o.json()}let a=e=>new Promise(t=>setTimeout(t,e));async function n(e){for(let t=0;t<40;t++){let t=await s("/commands/".concat(e));if("done"===t.status||"failed"===t.status)return t;await a(600)}return null}return{api:s,trackCommand:n}})(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),[N,w]=(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)([]),[U,D]=(0,r.useState)({log:[],context:[]}),[B,F]=(0,r.useState)({text:"",error:!1,busy:!1}),[H,L]=(0,r.useState)(""),[q,W]=(0,r.useState)(!0),G=(0,r.useRef)(u);G.current=u;let K=(0,r.useRef)(v);K.current=v;let J=(0,r.useRef)(y);J.current=y;let V=(0,r.useRef)(S);V.current=S;let Y=e=>{e instanceof i||L(e.message)},Q=(0,r.useCallback)(async()=>{try{let e=await d.current.api("/projects");x(e),L(""),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){Y(e)}},[]),X=(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){Y(e)}},[]),$=(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),w(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),w(!0)):Y(e)}try{let e=await d.current.api("".concat(s,"/log?limit=").concat(100,"&offset=").concat(V.current));C(e)}catch(e){Y(e)}},[]),Z=(0,r.useCallback)(async e=>{if(!e){T([]);return}try{T(await d.current.api("/projects/".concat(encodeURIComponent(e),"/approvals")))}catch(e){Y(e)}},[]),ee=(0,r.useCallback)(async()=>{try{z(await d.current.api("/hosts"))}catch(e){Y(e)}},[]),et=(0,r.useCallback)(async()=>{try{P(await d.current.api("/commands?limit=50"))}catch(e){Y(e)}},[]),es=(0,r.useCallback)(async()=>{try{O(await d.current.api("/schedules"))}catch(e){Y(e)}},[]),ea=(0,r.useCallback)(async()=>{try{let[e,t]=await Promise.all([d.current.api("/shared/log"),d.current.api("/shared/context")]);D({log:e,context:t})}catch(e){Y(e)}},[]),en=(0,r.useCallback)(async()=>{let e=await d.current.api("/projects").catch(e=>(Y(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 X(e));let t=G.current,s=J.current;s&&await $(s.projectId,s.name),"approvals"===t&&await Z(K.current),"servers"===t&&await ee(),"activity"===t&&await et(),"schedules"===t&&await es(),"shared"===t&&await ea()},[X,$,Z,ee,et,es,ea]);(0,r.useEffect)(()=>{let e=!0;(async()=>{W(!0),await en(),e&&W(!1)})();let t=setInterval(()=>{document.hidden||en()},5e3);return()=>{e=!1,clearInterval(t)}},[en]);let er=(0,r.useCallback)(e=>{h(e),F({text:"",error:!1,busy:!1}),"approvals"===e&&Z(K.current),"servers"===e&&ee(),"activity"===e&&et(),"schedules"===e&&es(),"shared"===e&&ea()},[Z,ee,et,es,ea]),el=(0,r.useCallback)(e=>{g(e),"approvals"===G.current&&Z(e)},[Z]),ei=(0,r.useCallback)((e,t)=>{b({projectId:e,name:t}),R(0),V.current=0,k(null),w(!1),C([]),$(e,t)},[$]),ec=(0,r.useCallback)(e=>{let t=Math.max(0,S+100*e);if(t===S)return;R(t),V.current=t;let s=J.current;s&&$(s.projectId,s.name)},[S,$]),eo=(0,r.useCallback)(()=>{en()},[en]),ed=(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}},[]),eu=(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(K.current),a=await ed("/projects/".concat(s,"/agents/spawn"),t,"spawn ".concat(t.name));return await X(m),(null==a?void 0:a.status)==="done"},[ed,X,m]),eh=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);await ed("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t),"/kill"),void 0,"kill ".concat(t)),await X(m)},[ed,X,m]),em=(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=J.current)||void 0===a?void 0:a.name)===t&&b(null),await X(m)}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[X,m]),ex=(0,r.useCallback)(async(e,t)=>{let s=J.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 ed("".concat(a,"/resume"),{answer:e},"resume"):F({text:"Answer saved (agent still paused).",error:!1,busy:!1}),await X(m),await $(s.projectId,s.name),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[ed,X,$,m]),ep=(0,r.useCallback)(async e=>{try{let s="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null},a=await d.current.api("/projects",{method:"POST",body:s});if(await Q(),null!=a.sync_command_id){F({text:"repository '".concat(a.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(a.sync_command_id);if(e){if("done"===e.status)F({text:"repository '".concat(a.id,"' registered and cloned"),error:!1,busy:!1});else{var t;F({text:"repository '".concat(a.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1})}}else F({text:"repository '".concat(a.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else F({text:"repository '".concat(a.id,"' registered"),error:!1,busy:!1});return!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[Q]),ej=(0,r.useCallback)(async e=>{await ed("/projects/".concat(encodeURIComponent(e),"/sync"),void 0,"pull ".concat(e))},[ed]),ev=(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 Q(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[Q]),eg=(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 Q()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[Q]),ey=(0,r.useCallback)(async e=>{let t=encodeURIComponent(K.current);await ed("/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 Z(K.current)},[ed,Z]),eb=(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 ee(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[ee]),ef=(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 ee(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[ee]),ek=(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 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("/schedules/".concat(e),{method:"PATCH",body:t}),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=>{try{await d.current.api("/schedules/".concat(e),{method:"DELETE"}),F({text:"schedule ".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]),e_=(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 ee()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[ee]),eC=(0,r.useCallback)(async()=>{await ed("/poll-ci",void 0,"poll-ci (all projects)"),await et()},[ed,et]),eS=(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 ea(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[ea]);return(0,n.jsx)(c.Provider,{value:{section:u,setSection:er,projects:m,agents:p,selectedProjectId:v,selectProject:el,selectedRun:y,selectRun:ei,checkmark:f,checkmarkMissing:N,log:_,logOffset:S,pageLog:ec,approvals:I,hosts:A,commands:E,schedules:M,shared:U,cmd:B,lastError:H,loading:q,refresh:eo,spawnAgent:eu,killAgent:eh,deleteAgent:em,submitAnswer:ex,createProject:ep,updateProject:ev,deleteProject:eg,syncProject:ej,submitApproval:ey,createHost:eb,updateHost:ef,deleteHost:e_,createSchedule:ek,updateSchedule:eN,deleteSchedule:ew,pollCi:eC,setSharedKey:eS},children:a})}function u(e){if(!e)return"—";let t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function h(e){return e?e.slice(0,7):"—"}function m(e){switch(e){case"done":case"completed":case"approved":case"pass":return"success";case"paused_for_input":case"needs-attention":case"pending":return"warning";case"failed":case"error":case"rejected":case"fail":return"danger";case"working":case"running":case"queued":case"claimed":return"info";default:return"neutral"}}function x(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 p(e){let{status:t}=e;return(0,n.jsx)(x,{tone:m(t),children:t?"paused_for_input"===t?"needs input":t.replace(/_/g," "):"unknown"})}function j(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 v(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 g(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 y(e){let{label:t,value:s,onChange:a,placeholder:r,type:l="text",disabled:i}=e;return(0,n.jsx)(g,{label:t,children:(0,n.jsx)("input",{className:"input",type:l,value:s,placeholder:r,disabled:i,onChange:e=>a(e.target.value)})})}function b(e){let{label:t,value:s,onChange:a,placeholder:r,rows:l=3}=e;return(0,n.jsx)(g,{label:t,children:(0,n.jsx)("textarea",{className:"textarea",value:s,rows:l,placeholder:r,onChange:e=>a(e.target.value)})})}function f(e){let{label:t,value:s,onChange:a,options:r}=e;return(0,n.jsx)(g,{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 k(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 N(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 w(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function _(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 C=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"}];function S(){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)(N,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(N,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(N,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(N,{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)(k,{tabs:C,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(w,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(R,{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)(T,{}):(0,n.jsx)(I,{})})]})]})}function R(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:function(e){if(!e)return"—";let t=new Date(e).getTime();if(Number.isNaN(t))return String(e);let s=Math.max(0,Math.round((Date.now()-t)/6e4));if(s<1)return"just now";if(s<60)return"".concat(s,"m ago");let a=Math.floor(s/60),n=s%60;return a<24?"".concat(a,"h").concat(n?" ".concat(n,"m"):""," ago"):"".concat(Math.floor(a/24),"d ago")}(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)(p,{status:t.status})})]})}function I(){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 T(){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,j]=(0,r.useState)(!1),g=(null==a?void 0:a.status)==="paused_for_input",y=async e=>{if(!i.trim())return;j(!0);let s=await t.submitAnswer(i.trim(),e);j(!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)(p,{status:null==a?void 0:a.status}),(null==a?void 0:a.role)&&(0,n.jsx)(x,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(v,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(v,{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 ",u(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)(w,{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)(p,{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)(x,{tone:m(l.tests_status),children:l.tests_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.tested_at?u(l.tested_at):""})]}),(0,n.jsx)("dt",{children:"Build"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(x,{tone:m(l.build_status),children:l.build_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.built_at?u(l.built_at):""})]}),(0,n.jsx)("dt",{children:"Checkpoint at"}),(0,n.jsx)("dd",{className:"faint",children:u(l.checkpoint_at)})]})]}),g&&(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)(w,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(b,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(v,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>y(!1),children:"Answer"}),(0,n.jsx)(v,{variant:"primary",disabled:d||!i.trim(),onClick:()=>y(!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:u(e.created_at)}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{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:h(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(x,{tone:m(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(v,{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)(v,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}let A={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:""};function z(){let e=o(),[t,s]=(0,r.useState)(A),[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(A),l(!1)},h=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},m=e=>{var t,a;s({...A,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)(j,{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)(k,{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)(y,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(y,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(y,{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)(y,{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)(f,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(y,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(y,{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."})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(v,{variant:"primary",disabled:e.cmd.busy||!p,onClick:h,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(v,{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)(j,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(x,{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 ",u(t.created_at)]}),(0,n.jsxs)("div",{className:"hstack",children:[t.git_remote&&(0,n.jsx)(v,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(v,{size:"sm",variant:"secondary",onClick:()=>m(t),children:"Edit"}),(0,n.jsx)(v,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let E=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],P=[{value:"worktree",label:"git worktree on branch"},{value:"subdir",label:"subdir under root"}],M={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function O(){let e=o(),[t,s]=(0,r.useState)(M),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(M)};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)(f,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(j,{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)(y,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(f,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:E}),(0,n.jsx)(f,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:P}),"worktree"===t.placement?(0,n.jsx)(y,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(y,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(b,{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)(v,{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=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(x,{tone:"info",children:t.role}):"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{status:t.status})}),(0,n.jsx)("td",{className:"mono faint",children:t.working_dir}),(0,n.jsx)("td",{className:"faint nowrap",children:u(t.created_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(v,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(v,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(v,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]},t.id))})]})})]})})]})}let U=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],D=[{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 F(){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)(f,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(j,{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)(y,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(f,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:D}),(0,n.jsx)(f,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:U})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(b,{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)(v,{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)(_,{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)(x,{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=D.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?u(t.next_run_at):"paused"}),(0,n.jsx)("td",{className:"faint nowrap",children:u(t.last_run_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsx)(v,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let H=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],L={branch:"",status:"approved",agent_name:"",sha:"",note:""};function q(){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=async()=>{await e.submitApproval(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:"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)(f,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(j,{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)(y,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(f,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:H}),(0,n.jsx)(y,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(y,{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)(y,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(v,{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:u(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.branch}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{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:h(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let W=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],G={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function K(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)(v,{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 J(){let e=o(),[t,s]=(0,r.useState)(G),[a,l]=(0,r.useState)(!1),i=()=>{s(G),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)(j,{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)(y,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(f,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:W}),(0,n.jsx)(y,{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)(y,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(y,{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)(v,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(v,{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)(j,{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)(x,{tone:"info",children:t.forge_type}),(0,n.jsx)(x,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(x,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(v,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(v,{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)(K,{value:t.ssh_public_key})]},t.hostname))]})]})}function V(){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)(v,{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:u(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)(p,{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 Y(){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:u(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.agent_id}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(p,{status:e.ci_status})})]},e.id))})]})})]}),(0,n.jsxs)(j,{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)(y,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(y,{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)(v,{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:u(e.updated_at)})]},e.key))})]})})]})]})}let Q=[{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}];function X(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"]}),Q.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)(S,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(z,{}),"agents"===s.section&&(0,n.jsx)(O,{}),"schedules"===s.section&&(0,n.jsx)(F,{}),"approvals"===s.section&&(0,n.jsx)(q,{}),"servers"===s.section&&(0,n.jsx)(J,{}),"activity"===s.section&&(0,n.jsx)(V,{}),"shared"===s.section&&(0,n.jsx)(Y,{})]})]})]})}function $(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 Z="handler_token";function ee(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(Z);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(Z,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(Z),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(Z),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(X,{onSignOut:i})}):(0,n.jsx)($,{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 516ac99..c65405e 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[8423,["931","static/chunks/app/page-b4a814bde4e81779.js"],"default",1]
+3:I[9859,["931","static/chunks/app/page-aaee823ffe4c78c3.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
-0:["twm_FqRjMWdGYZ-lbTh7o",[[["",{"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:["J55muUx2ya8M2SQERVlYt",[[["",{"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/control/login.py b/src/handler/control/login.py
new file mode 100644
index 0000000..3b26b86
--- /dev/null
+++ b/src/handler/control/login.py
@@ -0,0 +1,145 @@
+"""Drive the bundled ``claude`` binary's ``/login`` OAuth flow from the web UI.
+
+The dashboard has no ``claude`` (it runs in the API container); the control container
+does. So logging Claude Code in is a two-step control command, mirroring the answer/resume
+handoff:
+
+1. ``login_start`` opens an interactive ``claude`` session in a dedicated tmux window,
+ sends ``/login``, selects the **Claude account with subscription** option, and scrapes
+ the pane for the ``claude.com`` / ``claude.ai`` authorization URL. The URL is returned
+ to the UI (which opens it in an iframe) and the tmux session is *left alive*.
+2. ``login_submit`` sends the authorization code the operator pastes back into that same
+ still-alive session, waits for claude to exchange it, and reports success.
+
+Everything shells out through the :mod:`~handler.control.tmux` seam, so the whole flow is
+unit-testable with a fake tmux and never needs a real ``claude`` binary — the same pattern
+the spawn/resume tests use.
+
+The interactive claude TUI is inherently timing-sensitive; the waits below are generous
+and overridable so an operator can tune them for a slow host. If claude's first run shows
+onboarding (theme/trust prompts) before the ``/login`` menu, bump ``boot_wait``.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import time
+
+from ..config import get_settings
+from . import tmux
+
+# One well-known session name: ``login_start`` (re)creates it, ``login_submit`` reuses it.
+LOGIN_SESSION = "handler__login"
+
+# Any http(s) URL in the pane; we then prefer the OAuth/authorize link among them.
+_URL_RE = re.compile(r"https?://[^\s\"'<>`|]+")
+_OAUTH_HINTS = ("oauth", "authorize", "claude.ai", "claude.com", "console.anthropic")
+_SUCCESS_HINTS = (
+ "login successful",
+ "logged in",
+ "successfully authenticated",
+ "authentication successful",
+ "you are now logged in",
+)
+
+
+class LoginError(Exception):
+ """Raised when the claude login flow cannot be started or completed."""
+
+
+def _home() -> str:
+ return os.path.expanduser("~") or "/tmp"
+
+
+def _sleep(seconds: float) -> None:
+ """Indirection so tests can patch out real waiting."""
+ time.sleep(seconds)
+
+
+def _extract_url(pane: str) -> str | None:
+ """Pull the login URL out of a captured pane, preferring the OAuth link."""
+ if not pane:
+ return None
+ candidates = [c.rstrip(".,);]") for c in _URL_RE.findall(pane)]
+ for c in candidates:
+ if any(hint in c.lower() for hint in _OAUTH_HINTS):
+ return c
+ return candidates[0] if candidates else None
+
+
+def start(
+ *,
+ boot_wait: float = 4.0,
+ menu_wait: float = 1.5,
+ url_timeout: float = 30.0,
+ poll_interval: float = 0.5,
+) -> dict:
+ """Open ``claude`` in tmux, drive ``/login`` to the subscription account, return the URL.
+
+ Leaves the tmux session alive for :func:`submit_code`. Raises :class:`LoginError` if
+ no authorization URL appears within ``url_timeout`` seconds.
+ """
+ claude = get_settings().claude_bin
+ # A stale session from a previous, abandoned attempt would swallow our keystrokes.
+ if tmux.has_session(LOGIN_SESSION):
+ tmux.kill_session(LOGIN_SESSION)
+
+ tmux.new_session(LOGIN_SESSION, cwd=_home(), command=claude, env={})
+ _sleep(boot_wait) # let claude boot to its prompt
+
+ tmux.send_keys(LOGIN_SESSION, "/login")
+ _sleep(menu_wait)
+ # The login menu's first, default-highlighted option is the subscription account;
+ # a bare Enter selects it (send_keys always appends Enter).
+ tmux.send_keys(LOGIN_SESSION, "")
+ _sleep(menu_wait)
+
+ deadline = time.monotonic() + url_timeout
+ url: str | None = None
+ while url is None and time.monotonic() < deadline:
+ url = _extract_url(tmux.capture_pane(LOGIN_SESSION))
+ if url is None:
+ _sleep(poll_interval)
+ if url is None:
+ # Don't leave a half-driven session lying around on failure.
+ if tmux.has_session(LOGIN_SESSION):
+ tmux.kill_session(LOGIN_SESSION)
+ raise LoginError(
+ "timed out waiting for the claude login URL — is the 'claude' binary installed "
+ "in the control container and does '/login' open the subscription flow?"
+ )
+ return {"session": LOGIN_SESSION, "url": url}
+
+
+def submit_code(code: str, *, settle_wait: float = 3.0) -> dict:
+ """Feed the pasted authorization ``code`` into the live login session.
+
+ Returns ``{"success": bool, "output": }``. Kills the session on success.
+ Raises :class:`LoginError` if there is no active login session to submit to.
+ """
+ code = (code or "").strip()
+ if not code:
+ raise LoginError("no authorization code provided")
+ if not tmux.has_session(LOGIN_SESSION):
+ raise LoginError("no active claude login session — start the login flow again")
+
+ tmux.send_keys(LOGIN_SESSION, code)
+ _sleep(settle_wait)
+
+ pane = tmux.capture_pane(LOGIN_SESSION)
+ success = _looks_successful(pane)
+ if success and tmux.has_session(LOGIN_SESSION):
+ tmux.kill_session(LOGIN_SESSION)
+ return {"success": success, "output": _tail(pane)}
+
+
+def _looks_successful(pane: str) -> bool:
+ low = (pane or "").lower()
+ return any(hint in low for hint in _SUCCESS_HINTS)
+
+
+def _tail(pane: str, lines: int = 12) -> str:
+ """The last few non-blank pane lines, for surfacing success/failure in the UI."""
+ kept = [ln for ln in (pane or "").splitlines() if ln.strip()]
+ return "\n".join(kept[-lines:])
diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py
index a216fb2..02ad735 100644
--- a/src/handler/control/tmux.py
+++ b/src/handler/control/tmux.py
@@ -63,3 +63,21 @@ def send_keys(name: str, keys: str) -> None:
"""Send a line of input to a live session (used by the resume seam)."""
tmux = get_settings().tmux_bin
subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True)
+
+
+def capture_pane(name: str) -> str:
+ """Return the visible text of a session's pane.
+
+ ``-p`` prints to stdout, ``-J`` joins wrapped lines so a long URL split across the
+ pane width comes back on one logical line (the login flow relies on this to recover
+ the claude.com authorization link). Returns an empty string if the session is gone.
+ """
+ tmux = get_settings().tmux_bin
+ result = subprocess.run(
+ [tmux, "capture-pane", "-t", name, "-p", "-J"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ return ""
+ return result.stdout
diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py
index 980e248..550bbf6 100644
--- a/src/handler/control/worker.py
+++ b/src/handler/control/worker.py
@@ -20,7 +20,7 @@ from datetime import UTC, datetime, timedelta
from ..db import repository as repo
from ..db.engine import connection
-from . import gitops, poller, reposync, skills_gen, spawn
+from . import gitops, login, poller, reposync, skills_gen, spawn
class CommandError(Exception):
@@ -154,6 +154,30 @@ def _cmd_poll_ci(command: dict) -> dict:
return poller.sweep(project_id=command.get("project_id"))
+def _cmd_login_start(command: dict) -> dict:
+ """Open the claude ``/login`` flow and return the claude.com authorization URL."""
+ try:
+ return login.start()
+ except login.LoginError as exc:
+ raise CommandError(str(exc)) from exc
+
+
+def _cmd_login_submit(command: dict) -> dict:
+ """Feed the pasted authorization code back into the live login session."""
+ code = _payload(command).get("code")
+ if not code:
+ raise CommandError("login_submit requires a 'code' in the payload")
+ try:
+ result = login.submit_code(code)
+ except login.LoginError as exc:
+ raise CommandError(str(exc)) from exc
+ if not result.get("success"):
+ # 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}")
+ return result
+
+
def _cmd_sync(command: dict) -> dict:
project_id = command.get("project_id")
if not project_id:
@@ -177,6 +201,8 @@ _DISPATCH = {
"forge_init": _cmd_forge_init,
"poll_ci": _cmd_poll_ci,
"sync": _cmd_sync,
+ "login_start": _cmd_login_start,
+ "login_submit": _cmd_login_submit,
}
diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py
index 9cbcaa7..68acdf6 100644
--- a/src/handler/db/tables.py
+++ b/src/handler/db/tables.py
@@ -35,7 +35,22 @@ APPROVAL_STATUSES = ("approved", "rejected")
# The control actions the API enqueues and the control-container worker executes.
# ``sync`` clones a project's repo into its root_dir (or fast-forward pulls an existing
# clone) using the git server's stored credentials — the API can't run git itself.
-COMMAND_TYPES = ("spawn", "kill", "resume", "approve", "reject", "forge_init", "poll_ci", "sync")
+# ``login_start``/``login_submit`` drive the bundled ``claude`` binary's ``/login`` OAuth
+# flow from the web UI: the worker opens an interactive claude session in the control
+# container, returns the claude.com authorization URL, and later feeds back the pasted
+# code — the API container has no ``claude`` and can't run it directly.
+COMMAND_TYPES = (
+ "spawn",
+ "kill",
+ "resume",
+ "approve",
+ "reject",
+ "forge_init",
+ "poll_ci",
+ "sync",
+ "login_start",
+ "login_submit",
+)
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")
diff --git a/src/handler/migrations/versions/0005_claude_login_commands.py b/src/handler/migrations/versions/0005_claude_login_commands.py
new file mode 100644
index 0000000..8061873
--- /dev/null
+++ b/src/handler/migrations/versions/0005_claude_login_commands.py
@@ -0,0 +1,42 @@
+"""claude web-login command types
+
+Revision ID: 0005_claude_login_commands
+Revises: 0004_git_servers_schedules
+Create Date: 2026-07-13
+
+Adds the ``login_start`` and ``login_submit`` command types so the dashboard can drive
+the bundled ``claude`` binary's ``/login`` OAuth flow inside the control container: the
+worker opens an interactive ``claude`` session, selects the subscription account, returns
+the claude.com authorization URL for the operator to open, and later feeds back the code
+they paste. The commands CHECK constraint change goes through ``batch_alter_table`` so
+SQLite recreates the table while Postgres alters in place (same pattern as 0004's
+``sync``).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0005_claude_login_commands"
+down_revision: str | None = "0004_git_servers_schedules"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+OLD_COMMAND_TYPES = (
+ "'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'poll_ci', 'sync'"
+)
+NEW_COMMAND_TYPES = OLD_COMMAND_TYPES + ", 'login_start', 'login_submit'"
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("commands", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_commands_type", type_="check")
+ batch_op.create_check_constraint("ck_commands_type", f"type IN ({NEW_COMMAND_TYPES})")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("commands", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_commands_type", type_="check")
+ batch_op.create_check_constraint("ck_commands_type", f"type IN ({OLD_COMMAND_TYPES})")
diff --git a/tests/test_api_login.py b/tests/test_api_login.py
new file mode 100644
index 0000000..8a3e18b
--- /dev/null
+++ b/tests/test_api_login.py
@@ -0,0 +1,49 @@
+"""The web-login API surface: enqueue login_start / login_submit, admin-gated."""
+
+from __future__ import annotations
+
+
+def _admin(env):
+ # ADMIN_TOKEN is unset in the test env, so the admin gate falls back to AUTH_TOKEN.
+ return {"Authorization": f"Bearer {env['token']}"}
+
+
+def test_login_start_enqueues_command(client, env):
+ r = client.post("/login/start", headers=_admin(env))
+ assert r.status_code == 202
+ body = r.json()
+ assert body["type"] == "login_start"
+ assert body["status"] == "queued"
+ assert body["requested_by"] == "operator:web"
+
+
+def test_login_submit_enqueues_command_with_code(client, env):
+ r = client.post("/login/submit", headers=_admin(env), json={"code": "auth-xyz"})
+ assert r.status_code == 202
+ body = r.json()
+ assert body["type"] == "login_submit"
+ assert body["payload"] == {"code": "auth-xyz"}
+
+
+def test_login_submit_rejects_blank_code(client, env):
+ r = client.post("/login/submit", headers=_admin(env), json={"code": ""})
+ assert r.status_code == 422
+
+
+def test_login_start_requires_auth(client):
+ assert client.post("/login/start").status_code in (401, 403)
+
+
+def test_login_endpoints_require_admin_token(client, env, monkeypatch):
+ # With a distinct admin token set, the plain auth token must be refused.
+ monkeypatch.setenv("ADMIN_TOKEN", "admin-secret")
+ from handler import config
+
+ config.get_settings.cache_clear()
+ try:
+ r = client.post("/login/start", headers={"Authorization": f"Bearer {env['token']}"})
+ assert r.status_code == 403
+ ok = client.post("/login/start", headers={"Authorization": "Bearer admin-secret"})
+ assert ok.status_code == 202
+ finally:
+ config.get_settings.cache_clear()
diff --git a/tests/test_control_login.py b/tests/test_control_login.py
new file mode 100644
index 0000000..dc0ae8f
--- /dev/null
+++ b/tests/test_control_login.py
@@ -0,0 +1,117 @@
+"""The claude web-login seam: driving ``claude /login`` through tmux and scraping the URL.
+
+Uses the shared ``fake_tmux`` fixture (extended here with a scripted ``capture_pane``) and
+patches out the real sleeps, so no live claude/tmux is touched — the same approach as the
+spawn tests.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from handler.control import login, tmux
+
+
+@pytest.fixture
+def no_sleep(monkeypatch):
+ monkeypatch.setattr(login, "_sleep", lambda *_a, **_k: None)
+
+
+def _pane(monkeypatch, *frames):
+ """Make ``capture_pane`` return each frame in turn, then repeat the last one."""
+ seq = list(frames)
+
+ def capture(_name):
+ return seq[0] if len(seq) == 1 else seq.pop(0)
+
+ monkeypatch.setattr(tmux, "capture_pane", capture)
+
+
+AUTH_URL = "https://claude.ai/oauth/authorize?code=true&client_id=abc&state=xyz"
+
+
+def test_extract_url_prefers_oauth_link():
+ pane = f"Visit https://example.com/help or\n{AUTH_URL}\nand paste the code."
+ assert login._extract_url(pane) == AUTH_URL
+
+
+def test_extract_url_strips_trailing_punctuation():
+ assert login._extract_url(f"Open ({AUTH_URL}).") == AUTH_URL
+
+
+def test_extract_url_none_when_no_link():
+ assert login._extract_url("no link here") is None
+
+
+def test_start_launches_claude_selects_subscription_and_returns_url(
+ env, fake_tmux, no_sleep, monkeypatch
+):
+ _pane(monkeypatch, "booting…", f"Open this URL to log in:\n{AUTH_URL}")
+
+ result = login.start(url_timeout=1.0)
+
+ assert result == {"session": login.LOGIN_SESSION, "url": AUTH_URL}
+ # A fresh claude session was launched…
+ launched = fake_tmux["calls"]["new_session"]
+ assert len(launched) == 1
+ assert launched[0]["name"] == login.LOGIN_SESSION
+ assert launched[0]["command"] == "claude"
+ # …then /login was sent, followed by a bare Enter selecting the subscription option.
+ sent = [c["keys"] for c in fake_tmux["calls"]["send_keys"]]
+ assert sent[:2] == ["/login", ""]
+ # The session is left alive for submit_code.
+ assert login.LOGIN_SESSION in fake_tmux["live"]
+
+
+def test_start_kills_a_stale_session_first(env, fake_tmux, no_sleep, monkeypatch):
+ fake_tmux["live"].add(login.LOGIN_SESSION) # a leftover from an abandoned attempt
+ _pane(monkeypatch, f"{AUTH_URL}")
+
+ login.start(url_timeout=1.0)
+
+ assert login.LOGIN_SESSION in fake_tmux["calls"]["kill_session"]
+
+
+def test_start_times_out_and_cleans_up_when_no_url(env, fake_tmux, no_sleep, monkeypatch):
+ _pane(monkeypatch, "still thinking, no url yet")
+
+ with pytest.raises(login.LoginError, match="timed out"):
+ login.start(url_timeout=0.05, poll_interval=0.0)
+
+ # It shouldn't leave a half-driven session lying around.
+ assert login.LOGIN_SESSION not in fake_tmux["live"]
+
+
+def test_submit_code_sends_code_and_reports_success(env, fake_tmux, no_sleep, monkeypatch):
+ fake_tmux["live"].add(login.LOGIN_SESSION)
+ _pane(monkeypatch, "Login successful. Welcome back!")
+
+ result = login.submit_code("my-auth-code")
+
+ assert result["success"] is True
+ assert "Login successful" in result["output"]
+ assert {"name": login.LOGIN_SESSION, "keys": "my-auth-code"} in fake_tmux["calls"]["send_keys"]
+ # A confirmed login tears the session down.
+ assert login.LOGIN_SESSION not in fake_tmux["live"]
+
+
+def test_submit_code_reports_failure_without_killing_session(
+ env, fake_tmux, no_sleep, monkeypatch
+):
+ fake_tmux["live"].add(login.LOGIN_SESSION)
+ _pane(monkeypatch, "Invalid code, please try again")
+
+ result = login.submit_code("wrong")
+
+ assert result["success"] is False
+ assert login.LOGIN_SESSION in fake_tmux["live"] # left up for a retry
+
+
+def test_submit_code_without_session_raises(env, fake_tmux, no_sleep):
+ with pytest.raises(login.LoginError, match="no active"):
+ login.submit_code("code")
+
+
+def test_submit_code_rejects_blank(env, fake_tmux, no_sleep):
+ with pytest.raises(login.LoginError, match="no authorization code"):
+ login.submit_code(" ")
diff --git a/tests/test_worker.py b/tests/test_worker.py
index 6d1b345..8323335 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -6,7 +6,7 @@ machinery (already covered by test_control_spawn)."""
from __future__ import annotations
-from handler.control import poller, spawn, worker
+from handler.control import login, poller, spawn, worker
from handler.db import repository as repo
from handler.db.engine import get_engine
@@ -107,6 +107,53 @@ def test_poll_ci_command_returns_summary(env, monkeypatch):
assert done["result"] == {"checked": 0, "resolved": 0, "pending": 0}
+def test_login_start_command_returns_url(env, monkeypatch):
+ monkeypatch.setattr(
+ login, "start", lambda: {"session": "handler__login", "url": "https://claude.ai/oauth"}
+ )
+ cmd = _enqueue(type="login_start")
+
+ worker.drain("w")
+ done = _get(cmd["id"])
+ assert done["status"] == "done"
+ assert done["result"]["url"] == "https://claude.ai/oauth"
+
+
+def test_login_submit_command_feeds_code(env, monkeypatch):
+ seen = {}
+
+ def fake_submit(code):
+ seen["code"] = code
+ return {"success": True, "output": "Login successful"}
+
+ monkeypatch.setattr(login, "submit_code", fake_submit)
+ cmd = _enqueue(type="login_submit", payload={"code": "auth-123"})
+
+ worker.drain("w")
+ done = _get(cmd["id"])
+ assert done["status"] == "done"
+ assert done["result"]["success"] is True
+ assert seen["code"] == "auth-123"
+
+
+def test_login_submit_failure_is_recorded_failed(env, monkeypatch):
+ monkeypatch.setattr(
+ login, "submit_code", lambda code: {"success": False, "output": "Invalid code"}
+ )
+ cmd = _enqueue(type="login_submit", payload={"code": "bad"})
+
+ worker.drain("w")
+ failed = _get(cmd["id"])
+ assert failed["status"] == "failed"
+ assert "Invalid code" in failed["error"]
+
+
+def test_login_submit_without_code_is_failed(env):
+ cmd = _enqueue(type="login_submit", payload={})
+ assert worker.drain("w") == 1
+ assert _get(cmd["id"])["status"] == "failed"
+
+
def test_bad_command_is_recorded_failed_not_raised(env):
# spawn with no agent name -> CommandError -> the worker records 'failed', keeps going.
_seed_project()