Add local model backends: per-spawn dropdown pointing claude at alternative endpoints

Operators can register Anthropic-API-compatible endpoints (a local Qwen/Llama
behind LiteLLM or claude-code-router, an LLM gateway) on the dashboard's
Claude -> Models tab and pick one from a Model dropdown when spawning an agent.
The agent still launches as the same claude binary with the same hooks, skills,
connectors, plugins, and gates — only its ANTHROPIC_BASE_URL / ANTHROPIC_MODEL /
ANTHROPIC_AUTH_TOKEN env differs — and it stays pinned to its backend across
resumes. No selection keeps the worker's Claude subscription untouched.

- claude_models table (+ agents.model_id pin), migration 0012
- control.models resolves a row into the launch env (API keys Fernet-encrypted
  at rest, decrypted only in the control container; placeholder key when none is
  stored so the subscription OAuth token never reaches a local endpoint)
- /claude/models CRUD (admin-gated writes, key never returned), spawn route +
  worker + CLI (--model) pass the selection through, fail-fast on missing or
  disabled backends
- dashboard: Models tab, spawn-form dropdown, model badge in the agents table
- docs/local-models.md: why bare OpenAI-compatible servers break tool calling
  with Qwen-Coder, and working vLLM/LiteLLM/llama.cpp stacks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu
This commit is contained in:
Claude
2026-07-29 18:36:16 +00:00
parent c4e6ae0faa
commit 2d5c0e34d7
47 changed files with 1219 additions and 60 deletions
+12 -3
View File
@@ -253,8 +253,16 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
live status.
- **Claude** — the management page for the Claude Code install agents run on. The account
login lives here (see below), plus web-managed **skills**, **MCP connectors**,
**plugins**, and **permission overrides**. These are plain DB rows the control container
login lives here (see below), plus web-managed **model backends**, **skills**,
**MCP connectors**, **plugins**, and **permission overrides**. Model backends are
Anthropic-API-compatible endpoints (a local Qwen/Llama behind LiteLLM or
claude-code-router, an LLM gateway) offered in the spawn form's **Model** dropdown next
to the Claude subscription: the same `claude` binary is pointed at the endpoint via
`ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL` env at launch, so hooks, skills, connectors, and
gates apply unchanged, and the agent stays pinned to its backend across resumes. API
keys are stored encrypted (`HANDLER_SECRET_KEY`) and never returned. See
[`docs/local-models.md`](docs/local-models.md) for working local stacks (and why bare
OpenAI-compatible servers break tool calling). These are plain DB rows the control container
applies at every launch: skills sync to each worker's user-level `~/.claude/skills`
(marker-file managed, so hand-installed skills survive), enabled connectors become the
run's `--mcp-config` file (nothing lands in the repo tree), and plugins/permissions fold
@@ -271,7 +279,7 @@ The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/age
`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}`; Claude management as `/claude/skills`,
`PATCH`/`DELETE /projects/{id}`; Claude management as `/claude/models`, `/claude/skills`,
`/claude/connectors`, `/claude/plugins` (CRUD), and `GET`/`PUT /claude/permissions`
(reads with the normal token, writes admin-gated). Run the worker with `handler worker`
(the control image's default command).
@@ -310,6 +318,7 @@ operators at a shell):
```bash
handler spawn --project leeworks-api --name junior --role junior --worktree feat/auth --task "add login"
# [--model qwen3-coder] # run on a registered model backend (Claude page → Models)
handler list [--project leeworks-api]
handler attach --project leeworks-api --name junior
handler kill --project leeworks-api --name junior
+104
View File
@@ -0,0 +1,104 @@
# Local model backends (Qwen-Coder & friends)
Handler can run agents on locally-hosted models without changing anything about how an
agent works: it is still the same `claude` binary with the same generated
`settings.json`, hooks, skills, MCP connectors, plugins, and permission gates. The only
thing a **model backend** changes is the environment of that one agent's process:
| Variable | From |
|---|---|
| `ANTHROPIC_BASE_URL` | the backend's `base_url` |
| `ANTHROPIC_AUTH_TOKEN` | the backend's stored API key (decrypted at launch; a placeholder when none is stored, so the subscription OAuth token is never sent to a local endpoint) |
| `ANTHROPIC_MODEL` | the backend's `model` |
| `ANTHROPIC_SMALL_FAST_MODEL` | `small_fast_model`, falling back to `model` |
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` (skip sidecar calls a local endpoint won't serve; override via the row's env map) |
Register backends on the dashboard's **Claude → Models** tab (or `POST /claude/models`),
then pick one from the **Model** dropdown when spawning an agent. No selection = the
worker's logged-in Claude subscription, exactly as before. The agent is *pinned* to its
backend: resumes come back up on the same one, and deleting a backend makes resumes of
its agents fail loudly rather than silently falling back to the subscription.
## Why "tool calling not working" happens with Qwen-Coder
Claude Code speaks the **Anthropic Messages API** (`POST /v1/messages`): it sends tool
definitions in Anthropic's schema and expects structured `tool_use` content blocks back.
Local servers — Ollama, llama.cpp's `llama-server`, LM Studio, vLLM's default OpenAI
mode — speak the **OpenAI Chat Completions API** instead. Point `ANTHROPIC_BASE_URL` at
one of those and the request either 404s or, with a naive translator in between, the
model's tool calls come back as *plain text* (Qwen emits its own XML-ish
`<tool_call>` format) that Claude Code can't execute. That is the whole failure: the
model is fine, the dialect in the middle is wrong.
Two things must both be true:
1. **The endpoint must serve the Anthropic Messages API**, translating to whatever your
server speaks.
2. **The inference server must parse the model's native tool-call format into
structured tool calls** — for Qwen that means a Qwen-aware parser/template, not the
default one.
## Working stacks
### Recommended: vLLM (Qwen tool parser) + LiteLLM (Anthropic translation)
vLLM parses Qwen's tool-call format natively when told to:
```bash
# Qwen3-Coder
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--port 8000
# Qwen2.5-Coder uses the hermes parser instead:
# --tool-call-parser hermes
```
LiteLLM in front exposes the Anthropic `/v1/messages` endpoint:
```yaml
# litellm-config.yaml
model_list:
- model_name: qwen3-coder-30b
litellm_params:
model: hosted_vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct
api_base: http://127.0.0.1:8000/v1
general_settings:
master_key: sk-local-anything
```
```bash
litellm --config litellm-config.yaml --port 4000
```
Then register the backend in Handler: base URL `http://<host>:4000`, model
`qwen3-coder-30b`, API key `sk-local-anything`.
### llama.cpp / Ollama
- `llama-server` needs `--jinja` (and, for Qwen, a chat template with tool support —
recent official Qwen GGUFs ship one; older community quants often don't, which is
another common source of "tools don't work").
- Ollama supports OpenAI-style tool calling for models whose Modelfile template declares
it; check `ollama show <model> --template` mentions `.Tools` before blaming the proxy.
- Either way, they still only speak OpenAI-dialect — keep LiteLLM (use
`ollama_chat/<model>`, not `ollama/<model>`, for tool support) or
[claude-code-router](https://github.com/musistudio/claude-code-router) in front as
the Anthropic translator.
## Expectations and tips for small models
- **Keep the harness light.** Handler's agents run tool-heavy (hooks, MCP connectors,
skills). A 7B model will fumble that loop; Qwen3-Coder-30B-class models handle it
reasonably. Disable connectors the agent doesn't need and keep tasks small and
concrete.
- **Raise timeouts, cap output.** The row's env map is the escape hatch:
`API_TIMEOUT_MS=600000`, `CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192` are sensible for a
local 30B.
- **The gates don't relax.** The Stop/PreToolUse hooks still block un-tested,
un-pushed work regardless of which model produced it — that's the point of keeping
the same binary.
- **The subscription is untouched.** The web login, credential sync, and every agent
spawned without a model selection keep working exactly as before; backends are purely
additive.
+32 -1
View File
@@ -25,6 +25,7 @@ const emptySpawn = {
worktree: "",
subdir: "",
task: "",
model_id: "",
};
export function AgentsSection() {
@@ -35,6 +36,22 @@ export function AgentsSection() {
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
/* The seamless switch: Claude subscription by default, plus every enabled backend
* registered on the Claude page's Models tab. Same binary, hooks, and skills either
* way — only the ANTHROPIC_* env of the launched process differs. */
const modelOpts = useMemo(
() => [
{ value: "", label: "Claude (subscription)" },
...s.claudeModels
.filter((m) => m.enabled)
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
],
[s.claudeModels],
);
const modelName = useMemo(() => {
const byId = new Map(s.claudeModels.map((m) => [m.id, m.name]));
return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`);
}, [s.claudeModels]);
const agents = useMemo(
() => s.agents.filter((a) => a.project_id === s.selectedProjectId),
[s.agents, s.selectedProjectId],
@@ -87,6 +104,12 @@ export function AgentsSection() {
) : (
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
)}
<Select
label="Model"
value={form.model_id}
onChange={(v) => setForm({ ...form, model_id: v })}
options={modelOpts}
/>
</div>
<div className="mt14">
<Textarea
@@ -113,6 +136,7 @@ export function AgentsSection() {
<tr>
<th>Name</th>
<th>Role</th>
<th>Model</th>
<th>Status</th>
<th>Working dir</th>
<th>Created</th>
@@ -125,6 +149,13 @@ export function AgentsSection() {
<tr>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
{a.model_id != null ? (
<Badge tone="warning">{modelName(a.model_id)}</Badge>
) : (
<Badge tone="neutral">claude</Badge>
)}
</td>
<td>
<StatusBadge status={a.status} />
</td>
@@ -146,7 +177,7 @@ export function AgentsSection() {
</tr>
{(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
<tr>
<td colSpan={6} style={{ paddingTop: 0 }}>
<td colSpan={7} style={{ paddingTop: 0 }}>
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
{a.status === "crashed" ? "last output before crash" : "live output"}
{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
+166 -5
View File
@@ -8,9 +8,9 @@
import { useState } from "react";
import { useDashboard } from "@/components/store";
import type { ConnectorBody, PluginBody, SkillBody } from "@/components/store";
import type { ConnectorBody, ModelBody, PluginBody, SkillBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
import type { ClaudeConnector, ClaudePlugin, ClaudeSkill } from "@/lib/api";
import type { ClaudeConnector, ClaudeModel, ClaudePlugin, ClaudeSkill } from "@/lib/api";
import { ClaudeLoginPanel } from "@/components/sections/LoginSection";
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
@@ -499,6 +499,165 @@ function PluginsPanel() {
);
}
/* ---- Model backends ---------------------------------------------------------------- */
const emptyModel = {
name: "",
base_url: "",
model: "",
small_fast_model: "",
api_key: "",
env: "",
enabled: true,
};
function ModelsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyModel);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyModel);
setEditingId(null);
};
const save = async () => {
const body: ModelBody = {
name: form.name,
base_url: form.base_url.trim(),
model: form.model.trim(),
small_fast_model: form.small_fast_model.trim() || null,
api_key: form.api_key.trim() || null,
env: parseKeyValues(form.env),
enabled: form.enabled,
};
const ok =
editingId != null
? await s.updateClaudeModel(editingId, body)
: await s.createClaudeModel(body);
if (ok) reset();
};
const edit = (m: ClaudeModel) => {
setForm({
name: m.name,
base_url: m.base_url,
model: m.model,
small_fast_model: m.small_fast_model ?? "",
api_key: "", // write-only; blank = keep the stored key
env: formatKeyValues(m.env),
enabled: m.enabled,
});
setEditingId(m.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Alternative model backends the spawn dropdown offers next to the Claude
subscription the same <span className="mono">claude</span> binary pointed at a
different endpoint via <span className="mono">ANTHROPIC_BASE_URL</span>, so
skills, connectors, hooks, and gates apply unchanged. The endpoint must speak the{" "}
<b>Anthropic Messages API including tool use</b> a bare OpenAI-compatible
server (Ollama, llama.cpp, LM Studio) breaks tool calling; front it with LiteLLM
or claude-code-router and enable the backend&apos;s native tool parser. See{" "}
<span className="mono">docs/local-models.md</span> for working Qwen-Coder stacks.
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editingId != null ? `Edit model · ${form.name}` : "Add a model backend"}
</span>
</div>
<div className="form-grid">
<Input
label="Name (what the spawn dropdown shows)"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="qwen3-coder"
/>
<Input
label="Base URL (Anthropic-compatible /v1/messages endpoint)"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="http://llm.lan:4000"
/>
<Input
label="Model id (as the endpoint serves it)"
value={form.model}
onChange={(v) => setForm({ ...form, model: v })}
placeholder="qwen3-coder-30b"
/>
<Input
label="Small/fast model id (optional — defaults to the main model)"
value={form.small_fast_model}
onChange={(v) => setForm({ ...form, small_fast_model: v })}
placeholder="qwen3-1.7b"
/>
<Input
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
value={form.api_key}
onChange={(v) => setForm({ ...form, api_key: v })}
placeholder="sk-… (encrypted at rest, never shown again)"
/>
<Textarea
label="Extra env overrides (KEY=VALUE per line, optional)"
value={form.env}
onChange={(v) => setForm({ ...form, env: v })}
rows={3}
placeholder={"API_TIMEOUT_MS=600000\nCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192"}
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.base_url.trim() || !form.model.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add model"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeModels.length === 0 && (
<div className="empty">No model backends yet agents run on the Claude subscription.</div>
)}
{s.claudeModels.map((m) => (
<Card key={m.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{m.name}
</span>
<div className="hstack">
{m.has_api_key && <Badge tone="info">key stored</Badge>}
<Badge tone={m.enabled ? "success" : "neutral"}>
{m.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={m.enabled} onClick={() => s.updateClaudeModel(m.id, { enabled: !m.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(m)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeModel(m.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{m.model}
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} @ {m.base_url}
</div>
</Card>
))}
</>
);
}
/* ---- Permissions ------------------------------------------------------------------- */
const MODE_OPTS = [
@@ -594,6 +753,7 @@ function PermissionsPanel() {
const TABS = [
{ value: "account", label: "Account" },
{ value: "models", label: "Models" },
{ value: "skills", label: "Skills" },
{ value: "connectors", label: "Connectors" },
{ value: "plugins", label: "Plugins" },
@@ -608,9 +768,9 @@ export function ClaudeSection() {
<div className="section-head">
<div className="section-title">Claude</div>
<div className="section-desc">
Manage the Claude Code install agents run on: the account login, plus skills,
MCP connectors, plugins, and permissions. Changes apply to the next launch of
every agent.
Manage the Claude Code install agents run on: the account login, alternative
model backends, plus skills, MCP connectors, plugins, and permissions. Changes
apply to the next launch of every agent.
</div>
</div>
<div className="section-body">
@@ -618,6 +778,7 @@ export function ClaudeSection() {
<Tabs tabs={TABS} value={tab} onChange={setTab} />
</div>
{tab === "account" && <ClaudeLoginPanel />}
{tab === "models" && <ModelsPanel />}
{tab === "skills" && <SkillsPanel />}
{tab === "connectors" && <ConnectorsPanel />}
{tab === "plugins" && <PluginsPanel />}
+75 -3
View File
@@ -22,6 +22,7 @@ import {
type Approval,
type Checkmark,
type ClaudeConnector,
type ClaudeModel,
type ClaudePermissions,
type ClaudePlugin,
type ClaudeSkill,
@@ -146,6 +147,12 @@ interface StoreValue {
updateClaudePlugin: (id: number, b: Partial<PluginBody>) => Promise<boolean>;
deleteClaudePlugin: (id: number) => Promise<void>;
saveClaudePermissions: (b: PermissionsBody) => Promise<boolean>;
/* Model backends (Claude page Models tab + the spawn form's model dropdown). */
claudeModels: ClaudeModel[];
createClaudeModel: (b: ModelBody) => Promise<boolean>;
updateClaudeModel: (id: number, b: Partial<ModelBody>) => Promise<boolean>;
deleteClaudeModel: (id: number) => Promise<void>;
}
export interface SkillBody {
@@ -177,6 +184,18 @@ export interface PermissionsBody {
ask: string[];
}
export interface ModelBody {
name: string;
base_url: string;
model: string;
small_fast_model: string | null;
/* Write-only: encrypted at rest server-side, never echoed back. Null = no change. */
api_key: string | null;
clear_api_key?: boolean;
env: Record<string, string>;
enabled: boolean;
}
export interface SpawnBody {
name: string;
role: string;
@@ -184,6 +203,8 @@ export interface SpawnBody {
worktree: string;
subdir: string;
task: string;
/* Model backend id as a select value; "" = the Claude subscription. */
model_id: string;
}
export interface ProjectBody {
id: string;
@@ -291,6 +312,7 @@ export function DashboardProvider({
const [claudeConnectors, setClaudeConnectors] = useState<ClaudeConnector[]>([]);
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]);
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -429,6 +451,16 @@ export function DashboardProvider({
}
}, []);
/* Models load separately from the rest of the Claude page: the Agents section's spawn
* dropdown needs them too, without dragging skills/connectors along. */
const loadClaudeModels = useCallback(async () => {
try {
setClaudeModels(await clientRef.current.api<ClaudeModel[]>("/claude/models"));
} catch (e) {
swallow(e);
}
}, []);
const loadClaude = useCallback(async () => {
try {
const [skills, connectors, plugins, permissions] = await Promise.all([
@@ -441,10 +473,11 @@ export function DashboardProvider({
setClaudeConnectors(connectors);
setClaudePlugins(plugins);
setClaudePermissions(permissions);
await loadClaudeModels();
} catch (e) {
swallow(e);
}
}, []);
}, [loadClaudeModels]);
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
* so the nav counts and inbox stay live). */
@@ -463,13 +496,14 @@ export function DashboardProvider({
const s = sectionRef.current;
const run = selectedRunRef.current;
if (run) await loadRun(run.projectId, run.name);
if (s === "agents") await loadClaudeModels(); // the spawn form's model dropdown
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
if (s === "servers") await loadHosts();
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude]);
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
@@ -493,6 +527,7 @@ export function DashboardProvider({
(s: Section) => {
setSectionRaw(s);
setCmd({ text: "", error: false, busy: false });
if (s === "agents") void loadClaudeModels();
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
if (s === "servers") void loadHosts();
if (s === "activity") void loadCommands();
@@ -500,7 +535,7 @@ export function DashboardProvider({
if (s === "shared") void loadShared();
if (s === "claude") void loadClaude();
},
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels],
);
const selectProject = useCallback(
@@ -584,6 +619,7 @@ export function DashboardProvider({
};
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
if (f.model_id) body.model_id = Number(f.model_id);
const p = encodeURIComponent(selectedProjectRef.current);
const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
await loadAgents(projects);
@@ -1190,6 +1226,38 @@ export function DashboardProvider({
[claudeWrite],
);
const createClaudeModel = useCallback(
(b: ModelBody) =>
claudeWrite(
() =>
clientRef.current.api("/claude/models", {
method: "POST",
body: { ...b, name: b.name.trim() },
}),
`model '${b.name.trim()}' saved — pick it from the spawn dropdown`,
),
[claudeWrite],
);
const updateClaudeModel = useCallback(
(id: number, b: Partial<ModelBody>) =>
claudeWrite(
() => clientRef.current.api(`/claude/models/${id}`, { method: "PATCH", body: b }),
"model updated — applies to the next agent launch",
),
[claudeWrite],
);
const deleteClaudeModel = useCallback(
async (id: number) => {
await claudeWrite(
() => clientRef.current.api(`/claude/models/${id}`, { method: "DELETE" }),
"model removed — agents pinned to it can no longer resume",
);
},
[claudeWrite],
);
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
@@ -1269,6 +1337,10 @@ export function DashboardProvider({
updateClaudePlugin,
deleteClaudePlugin,
saveClaudePermissions,
claudeModels,
createClaudeModel,
updateClaudeModel,
deleteClaudeModel,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+18
View File
@@ -30,6 +30,9 @@ export interface Agent {
working_dir: string;
status: string;
role?: string | null;
/* Model backend the agent is pinned to (see ClaudeModel); null = the Claude
* subscription the worker is logged in to. */
model_id?: number | null;
/* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
* latest assistant text for headless runs. For a crashed agent this is the evidence
* frame — the last thing the process said. */
@@ -179,6 +182,21 @@ export interface ClaudePlugin {
created_at: string;
}
/* A registered model backend: an Anthropic-API-compatible endpoint (a local model
* behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next
* to the Claude subscription. The API key is write-only server-side (has_api_key only). */
export interface ClaudeModel {
id: number;
name: string;
base_url: string;
model: string;
small_fast_model?: string | null;
env?: Record<string, string> | null;
enabled: boolean;
has_api_key: boolean;
created_at: string;
}
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
export interface ClaudePermissions {
default_mode?: string | null;
+13
View File
@@ -86,6 +86,19 @@ def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_con
status.HTTP_400_BAD_REQUEST,
detail="a task is required: the headless runner has no idle-REPL mode",
)
if body.model_id is not None:
# Same fail-fast idea for the model dropdown: the worker re-checks at launch,
# but a stale/disabled selection should bounce now, not fail asynchronously.
model = repo.get_claude_model(conn, body.model_id)
if model is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"model {body.model_id} not found"
)
if not model["enabled"]:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"model backend '{model['name']}' is disabled",
)
payload = body.model_dump(exclude={"name"}, exclude_none=True)
return repo.enqueue_command(
conn,
+91
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from ... import secretstore
from ...config import get_settings
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
@@ -23,6 +24,9 @@ from ..schemas import (
ClaudeConnectorIn,
ClaudeConnectorOut,
ClaudeConnectorUpdateIn,
ClaudeModelIn,
ClaudeModelOut,
ClaudeModelUpdateIn,
ClaudePermissionsIn,
ClaudePermissionsOut,
ClaudePluginIn,
@@ -250,6 +254,93 @@ def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict:
return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"}
# ---- model backends -------------------------------------------------------------------
# Anthropic-API-compatible endpoints (a local model behind LiteLLM / claude-code-router,
# an LLM gateway) the spawn dropdown offers next to the Claude subscription. The control
# layer turns the selected row into that one agent's ANTHROPIC_* env at launch; the API
# key is encrypted at rest (HANDLER_SECRET_KEY) and never returned.
def _model_or_404(conn: Connection, model_id: int) -> dict:
row = repo.get_claude_model(conn, model_id)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"model {model_id} not found")
return row
def _model_out(row: dict) -> dict:
return {**row, "has_api_key": bool(row.get("api_key_enc"))}
def _encrypt_key_or_400(value: str) -> str:
try:
return secretstore.encrypt(value)
except secretstore.SecretStoreError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"cannot store the API key: {exc}"
) from exc
@router.get("/models", response_model=list[ClaudeModelOut])
def list_models(conn: Connection = Depends(db_conn)) -> list[dict]:
return [_model_out(m) for m in repo.list_claude_models(conn)]
@router.post(
"/models",
response_model=ClaudeModelOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_model(body: ClaudeModelIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_claude_model_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"model '{body.name}' exists")
api_key_enc = _encrypt_key_or_400(body.api_key) if body.api_key else None
return _model_out(
repo.create_claude_model(
conn,
body.name,
body.base_url,
body.model,
api_key_enc=api_key_enc,
small_fast_model=body.small_fast_model,
env=body.env,
enabled=body.enabled,
)
)
@router.patch(
"/models/{model_id}", response_model=ClaudeModelOut, dependencies=[Depends(require_admin)]
)
def update_model(
model_id: int, body: ClaudeModelUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
_model_or_404(conn, model_id)
fields = body.model_dump(exclude_unset=True)
if "name" in fields:
clash = repo.get_claude_model_by_name(conn, fields["name"])
if clash is not None and clash["id"] != model_id:
raise HTTPException(
status.HTTP_409_CONFLICT, detail=f"model '{fields['name']}' exists"
)
# api_key / clear_api_key are write-only verbs, translated to the encrypted column.
api_key = fields.pop("api_key", None)
clear = fields.pop("clear_api_key", False)
if api_key:
fields["api_key_enc"] = _encrypt_key_or_400(api_key)
elif clear:
fields["api_key_enc"] = None
return _model_out(repo.update_claude_model(conn, model_id, **fields))
@router.delete("/models/{model_id}", dependencies=[Depends(require_admin)])
def delete_model(model_id: int, conn: Connection = Depends(db_conn)) -> dict:
row = _model_or_404(conn, model_id)
repo.delete_claude_model(conn, model_id)
return {"deleted": row["name"]}
# ---- permissions ----------------------------------------------------------------------
+70 -2
View File
@@ -120,7 +120,7 @@ class AgentIn(BaseModel):
class AgentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
project_id: str
@@ -128,6 +128,8 @@ class AgentOut(BaseModel):
working_dir: str
status: str
role: Role | None = None
# The model backend this agent is pinned to (null = the Claude subscription).
model_id: int | None = None
# Latest output snapshot so the UI can show what a running agent is doing: the tmux
# pane tail for legacy agents, the latest assistant text for headless runs.
last_output: str | None = None
@@ -141,13 +143,19 @@ class AgentOut(BaseModel):
class SpawnIn(BaseModel):
"""Enqueue a spawn: the worker creates the agent row + tmux process in the control
container. ``worktree`` and ``subdir`` are mutually exclusive (worktree wins if both)."""
container. ``worktree`` and ``subdir`` are mutually exclusive (worktree wins if both).
``model_id`` selects a registered model backend (``/claude/models``); omit it to run
on the worker's logged-in Claude subscription."""
# ``model_id`` trips pydantic's ``model_`` protected-namespace warning; clear it.
model_config = ConfigDict(protected_namespaces=())
name: str
role: Role | None = None
worktree: str | None = None
subdir: str | None = None
task: str | None = None
model_id: int | None = None
class CommandOut(BaseModel):
@@ -505,6 +513,66 @@ class ClaudePluginOut(BaseModel):
created_at: datetime
class ClaudeModelIn(BaseModel):
"""A registered model backend: an **Anthropic-API-compatible** endpoint (a local
model behind LiteLLM / claude-code-router, an LLM gateway, …) the same ``claude``
binary can run against. ``api_key`` is write-only — encrypted at rest, never echoed
back. A plain OpenAI-compatible server won't do tool calling; see
``docs/local-models.md``."""
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
base_url: str = Field(min_length=1)
model: str = Field(min_length=1)
small_fast_model: str | None = None
api_key: str | None = None
env: dict[str, str] = Field(default_factory=dict)
enabled: bool = True
@field_validator("base_url")
@classmethod
def _check_base_url(cls, v: str) -> str:
v = v.strip().rstrip("/")
if not v.startswith(("http://", "https://")):
raise ValueError("base_url must be an http(s) URL")
return v
class ClaudeModelUpdateIn(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
base_url: str | None = None
model: str | None = Field(default=None, min_length=1)
small_fast_model: str | None = None
api_key: str | None = None
clear_api_key: bool = False
env: dict[str, str] | None = None
enabled: bool | None = None
@field_validator("base_url")
@classmethod
def _check_base_url(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip().rstrip("/")
if not v.startswith(("http://", "https://")):
raise ValueError("base_url must be an http(s) URL")
return v
class ClaudeModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
base_url: str
model: str
small_fast_model: str | None = None
env: dict[str, str] | None = None
enabled: bool
# The key never leaves the server; this says whether one is stored.
has_api_key: bool = False
created_at: datetime
class ClaudePermissionsIn(BaseModel):
"""The operator's permission overrides, merged over the env-configured baseline at
launch: ``default_mode`` (null = keep the baseline) plus extra allow/deny/ask rules."""
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[7839,["171","static/chunks/171-0a6dd93b551f1ca6.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
3:I[7839,["171","static/chunks/171-7eacb8f2c45d0186.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2506,["171","static/chunks/171-0a6dd93b551f1ca6.js","718","static/chunks/app/agents/page-ed5717b0ac0347b8.js"],"default",1]
3:I[2506,["171","static/chunks/171-7eacb8f2c45d0186.js","718","static/chunks/app/agents/page-65cc70b94cb9abd3.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2651,["171","static/chunks/171-0a6dd93b551f1ca6.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
3:I[2651,["171","static/chunks/171-7eacb8f2c45d0186.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[6529,["171","static/chunks/171-0a6dd93b551f1ca6.js","877","static/chunks/app/claude/page-6e97b9d68ff8eca2.js"],"default",1]
3:I[6529,["171","static/chunks/171-7eacb8f2c45d0186.js","877","static/chunks/app/claude/page-235e137771d99edc.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3807,["171","static/chunks/171-0a6dd93b551f1ca6.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
3:I[3807,["171","static/chunks/171-7eacb8f2c45d0186.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
5:I[4707,[],""]
6:I[6423,[],""]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"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,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",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],["$L7",null]]]]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"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,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2,7 +2,7 @@
3:I[6374,["626","static/chunks/app/login/page-b08c6695be5632dd.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3641,["171","static/chunks/171-0a6dd93b551f1ca6.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
3:I[3641,["171","static/chunks/171-7eacb8f2c45d0186.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[5124,["171","static/chunks/171-0a6dd93b551f1ca6.js","95","static/chunks/app/schedules/page-db675df274be2677.js"],"default",1]
3:I[5124,["171","static/chunks/171-7eacb8f2c45d0186.js","95","static/chunks/app/schedules/page-db675df274be2677.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[4646,["171","static/chunks/171-0a6dd93b551f1ca6.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
3:I[4646,["171","static/chunks/171-7eacb8f2c45d0186.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9475,["171","static/chunks/171-0a6dd93b551f1ca6.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
3:I[9475,["171","static/chunks/171-7eacb8f2c45d0186.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-0a6dd93b551f1ca6.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["1cIeZLB5pMn-RK3Yuxqs0",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7eacb8f2c45d0186.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["KmcUQ9fxOry3HfFR_U9HV",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",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],["$L7",null]]]]
7:[["$","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
+17
View File
@@ -20,6 +20,15 @@ from . import poller, reposync, skills_gen, spawn, worker
def _cmd_spawn(args: argparse.Namespace) -> int:
model_id = None
if args.model:
# --model takes the backend's *name* (what the dropdown shows); resolve to its id.
with connection() as conn:
row = repo.get_claude_model_by_name(conn, args.model)
if row is None:
print(f"error: model backend '{args.model}' not registered", file=sys.stderr)
return 1
model_id = row["id"]
try:
agent = spawn.spawn(
args.project,
@@ -28,6 +37,7 @@ def _cmd_spawn(args: argparse.Namespace) -> int:
worktree_branch=args.worktree,
task=args.task,
role=args.role,
model_id=model_id,
)
except spawn.SpawnError as exc:
print(f"error: {exc}", file=sys.stderr)
@@ -36,6 +46,8 @@ def _cmd_spawn(args: argparse.Namespace) -> int:
print(f" working_dir: {agent['working_dir']}")
if args.role:
print(f" role: {args.role}")
if args.model:
print(f" model: {args.model}")
if agent.get("forge_note"):
print(f" warning: {agent['forge_note']}", file=sys.stderr)
return 0
@@ -217,6 +229,11 @@ def build_parser() -> argparse.ArgumentParser:
group.add_argument("--worktree", metavar="BRANCH", help="git worktree on BRANCH")
group.add_argument("--dir", metavar="SUBDIR", help="subdirectory under project root")
p_spawn.add_argument("--task", help="initial task/prompt for the agent")
p_spawn.add_argument(
"--model",
metavar="NAME",
help="run on a registered model backend instead of the Claude subscription",
)
p_spawn.set_defaults(func=_cmd_spawn)
p_list = sub.add_parser("list", help="list agents")
+79
View File
@@ -0,0 +1,79 @@
"""Model backends: run the same ``claude`` binary against a non-subscription endpoint.
An operator-registered ``claude_models`` row is an **Anthropic-API-compatible** endpoint
(a local Qwen/Llama behind LiteLLM or claude-code-router, an LLM gateway, ). Selecting
one at spawn doesn't change how an agent is launched at all — it is still ``claude -p``
with the same generated settings, hooks, skills, connectors, and gates. The only
difference is the environment this module builds: ``ANTHROPIC_BASE_URL`` points the
binary at the endpoint, ``ANTHROPIC_MODEL`` / ``ANTHROPIC_SMALL_FAST_MODEL`` name what
it serves, and ``ANTHROPIC_AUTH_TOKEN`` carries the endpoint's key (decrypted here, in
the control container, from the encrypted column the API never returns it).
The endpoint must speak the Anthropic Messages API *including tool use*. A bare
OpenAI-compatible server (Ollama, llama.cpp, LM Studio, vLLM) is not enough on its own
that mismatch is exactly the "tool calling not working" failure with Qwen-Coder so put
a translating proxy in front and enable the backend's native tool parser; see
``docs/local-models.md`` for working stacks.
"""
from __future__ import annotations
from .. import secretstore
from ..db import repository as repo
# Injected when a backend stores no key: claude requires *some* credential once
# ANTHROPIC_BASE_URL is overridden, most local proxies accept anything, and falling
# through to the subscription OAuth token would send it to the local endpoint.
_PLACEHOLDER_KEY = "handler-local"
# Skip the sidecar calls a hobby-grade local endpoint won't implement; the main
# /v1/messages loop is unaffected. Overridable per-row via the ``env`` map.
_LOCAL_DEFAULTS = {"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"}
class ModelError(Exception):
"""Raised when a selected model backend cannot be resolved into an environment."""
def resolve_model_env(
conn, model_id: int | None, *, require_enabled: bool = False
) -> dict[str, str]:
"""The env overrides for ``model_id``, or ``{}`` for None (the Claude subscription).
``require_enabled`` is the spawn path (a disabled backend must not take new agents);
resumes pass False so an agent already pinned to a since-disabled backend can still
finish its work. A deleted row always raises resuming against nothing would
silently fall back to the subscription, which is the one thing the operator asked
this agent not to use.
"""
if model_id is None:
return {}
row = repo.get_claude_model(conn, model_id)
if row is None:
raise ModelError(
f"model backend id={model_id} no longer exists; it was removed after this "
"agent was spawned"
)
if require_enabled and not row["enabled"]:
raise ModelError(f"model backend '{row['name']}' is disabled")
if row.get("api_key_enc"):
try:
key = secretstore.decrypt(row["api_key_enc"])
except secretstore.SecretStoreError as exc:
raise ModelError(
f"model backend '{row['name']}': cannot decrypt its API key — {exc}"
) from exc
else:
key = _PLACEHOLDER_KEY
env = {
**_LOCAL_DEFAULTS,
"ANTHROPIC_BASE_URL": row["base_url"],
"ANTHROPIC_AUTH_TOKEN": key,
"ANTHROPIC_MODEL": row["model"],
"ANTHROPIC_SMALL_FAST_MODEL": row.get("small_fast_model") or row["model"],
}
# Row-level extras win over everything — the operator's escape hatch for endpoint
# quirks (API_TIMEOUT_MS, CLAUDE_CODE_MAX_OUTPUT_TOKENS, a different auth var, …).
for k, v in (row.get("env") or {}).items():
env[str(k)] = str(v)
return env
+20 -2
View File
@@ -21,6 +21,7 @@ from . import (
gitops,
headless,
mise,
models,
reposync,
settings_gen,
worktree,
@@ -70,6 +71,7 @@ def spawn(
worktree_branch: str | None = None,
task: str | None = None,
role: str | None = None,
model_id: int | None = None,
require_tests: bool = True,
mise_init: bool = False,
worker_id: str | None = None,
@@ -80,8 +82,10 @@ def spawn(
it off, because a project with no ``.mise.toml`` yet is exactly what it exists to fix.
``mise_init`` marks the launched agent (via ``HANDLER_MISE_INIT``) so its hooks enforce
the bootstrap contract create the test task, commit, and push instead of the normal
test gate. ``worker_id`` identifies the calling worker container (headless runs record
it on the run row; the CLI defaults to a pid-scoped id).
test gate. ``model_id`` pins the agent to a registered model backend (``claude_models``)
instead of the worker's Claude subscription — same binary, same hooks/skills/gates,
different ``ANTHROPIC_*`` env. ``worker_id`` identifies the calling worker container
(headless runs record it on the run row; the CLI defaults to a pid-scoped id).
"""
if not task:
# ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately
@@ -132,6 +136,12 @@ def spawn(
token = credentials.resolve_for_project(project, conn)
except credentials.CredentialError as exc:
raise SpawnError(str(exc)) from exc
# Same fail-fast contract as credentials: a selected model backend that is
# missing, disabled, or undecryptable refuses the spawn before any row exists.
try:
models.resolve_model_env(conn, model_id, require_enabled=True)
except models.ModelError as exc:
raise SpawnError(str(exc)) from exc
agent = repo.create_agent(
conn,
@@ -140,6 +150,7 @@ def spawn(
working_dir=working_dir,
status="working",
role=role,
model_id=model_id,
)
settings_path = settings_gen.write_settings(working_dir)
@@ -191,6 +202,13 @@ def _agent_env(
# A short read connection lets credential/host resolution consult the forge_hosts
# registry (falling back to the built-in host map when a host has no row).
with connection() as conn:
# Agent pinned to a model backend: point the claude binary at it. Resumes are a
# brand-new process, so this is what keeps an agent on the backend it started on
# (a since-disabled backend may still finish; a deleted one fails loudly).
try:
env.update(models.resolve_model_env(conn, agent.get("model_id")))
except models.ModelError as exc:
raise SpawnError(str(exc)) from exc
env.update(credentials.credential_env(token, project.get("git_remote"), conn))
if token:
_install_git_credentials(agent["working_dir"], project.get("git_remote"), conn)
+1
View File
@@ -57,6 +57,7 @@ def _cmd_spawn(command: dict) -> dict:
worktree_branch=p.get("worktree"),
task=p.get("task"),
role=p.get("role"),
model_id=p.get("model_id"),
worker_id=command.get("claimed_by"),
)
result = {
+61
View File
@@ -29,6 +29,7 @@ from .tables import (
checkmarks,
claude_config,
claude_connectors,
claude_models,
claude_plugins,
claude_skill_files,
claude_skills,
@@ -209,6 +210,7 @@ def create_agent(
working_dir: str,
status: str = "working",
role: str | None = None,
model_id: int | None = None,
) -> dict:
result = conn.execute(
agents.insert().values(
@@ -217,6 +219,7 @@ def create_agent(
working_dir=working_dir,
status=status,
role=role,
model_id=model_id,
created_at=_now(),
)
)
@@ -1137,6 +1140,64 @@ def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool:
return result.rowcount > 0
def list_claude_models(conn: Connection, enabled_only: bool = False) -> list[dict]:
stmt = select(claude_models)
if enabled_only:
stmt = stmt.where(claude_models.c.enabled.is_(True))
rows = conn.execute(stmt.order_by(claude_models.c.name)).all()
return [dict(r._mapping) for r in rows]
def get_claude_model(conn: Connection, model_id: int) -> dict | None:
row = conn.execute(select(claude_models).where(claude_models.c.id == model_id)).first()
return _row_to_dict(row)
def get_claude_model_by_name(conn: Connection, name: str) -> dict | None:
row = conn.execute(select(claude_models).where(claude_models.c.name == name)).first()
return _row_to_dict(row)
def create_claude_model(
conn: Connection,
name: str,
base_url: str,
model: str,
api_key_enc: str | None = None,
small_fast_model: str | None = None,
env: dict | None = None,
enabled: bool = True,
) -> dict:
result = conn.execute(
claude_models.insert().values(
name=name,
base_url=base_url,
api_key_enc=api_key_enc,
model=model,
small_fast_model=small_fast_model,
env=env,
enabled=enabled,
created_at=_now(),
)
)
return get_claude_model(conn, result.inserted_primary_key[0])
def update_claude_model(conn: Connection, model_id: int, **fields: Any) -> dict | None:
allowed = {"name", "base_url", "api_key_enc", "model", "small_fast_model", "env", "enabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(
claude_models.update().where(claude_models.c.id == model_id).values(**values)
)
return get_claude_model(conn, model_id)
def delete_claude_model(conn: Connection, model_id: int) -> bool:
result = conn.execute(claude_models.delete().where(claude_models.c.id == model_id))
return result.rowcount > 0
def get_claude_config(conn: Connection, key: str) -> Any | None:
"""The stored JSON value for a claude_config key, or None when unset."""
row = conn.execute(select(claude_config).where(claude_config.c.key == key)).first()
+26
View File
@@ -94,6 +94,11 @@ agents = Table(
# Optional workflow role (junior | senior | deploy) — informational, drives which
# forge skill an agent follows; the approval gate keys on identity, not role.
Column("role", String),
# Which model backend (claude_models row) this agent runs on; null = the worker's
# logged-in Claude subscription. Recorded at spawn so resumes — a brand-new process —
# come back up on the same backend. Deliberately no FK: deleting a backend must not
# orphan agent history (resume then fails with a clear message instead).
Column("model_id", BigInteger),
# A periodic snapshot of the agent's live tmux pane tail (last ~40 lines), refreshed by
# the control worker's poll loop. The tmux socket lives only in the control container,
# so this DB column is how the API/UI see what a running — or wedged — agent is doing.
@@ -403,6 +408,27 @@ claude_plugins = Table(
UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
)
# Alternative model backends the same ``claude`` binary can run against — an
# Anthropic-API-compatible endpoint (a local Qwen/Llama behind LiteLLM or
# claude-code-router, an LLM gateway, …). Selected per-spawn from a dashboard dropdown;
# the control layer injects the row as ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_MODEL`` /
# ``ANTHROPIC_AUTH_TOKEN`` env into that one agent's process, so hooks, skills,
# connectors, plugins, and permissions all apply unchanged. No row selected = the
# worker's logged-in Claude subscription, untouched.
claude_models = Table(
"claude_models",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("name", String, nullable=False, unique=True), # dropdown label, e.g. "qwen3-coder"
Column("base_url", String, nullable=False), # ANTHROPIC_BASE_URL — must speak /v1/messages
Column("api_key_enc", String), # encrypted (HANDLER_SECRET_KEY); never returned by the API
Column("model", String, nullable=False), # ANTHROPIC_MODEL — the id the endpoint serves
Column("small_fast_model", String), # ANTHROPIC_SMALL_FAST_MODEL; falls back to ``model``
Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last
Column("enabled", Boolean, nullable=False, server_default="1"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# Small JSON key/value store for the remaining Claude management state; first key is
# "permissions" — the operator's defaultMode override and extra allow/deny/ask rules,
# merged over the env-configured baseline by settings_gen at launch.
@@ -0,0 +1,51 @@
"""claude model backends: local/alternative models behind the same claude binary
Revision ID: 0012_claude_models
Revises: 0011_skill_install
Create Date: 2026-07-29
Adds ``claude_models`` operator-registered Anthropic-API-compatible endpoints (a local
Qwen/Llama behind LiteLLM or claude-code-router, an LLM gateway, ) selectable from a
per-spawn dropdown and ``agents.model_id``, which pins an agent to the backend it was
spawned on so resumes come back up against the same one. The control layer injects a
selected row as ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_MODEL`` / ``ANTHROPIC_AUTH_TOKEN``
env into that one agent's process; no row selected keeps the worker's logged-in Claude
subscription. ``model_id`` carries no FK on purpose: deleting a backend must not orphan
agent history.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp
revision: str = "0012_claude_models"
down_revision: str | None = "0011_skill_install"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"claude_models",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(), nullable=False, unique=True),
sa.Column("base_url", sa.String(), nullable=False),
sa.Column("api_key_enc", sa.String()),
sa.Column("model", sa.String(), nullable=False),
sa.Column("small_fast_model", sa.String()),
sa.Column("env", PortableJSON),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
op.add_column("agents", sa.Column("model_id", sa.BigInteger()))
def downgrade() -> None:
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.drop_column("model_id")
op.drop_table("claude_models")
+339
View File
@@ -0,0 +1,339 @@
"""Model backends: /claude/models CRUD + admin gating, the env the control layer builds
from a selected backend, and the spawn/resume/worker integration that pins an agent to it.
A backend never changes *how* an agent launches same ``claude -p``, same settings/
hooks only the ``ANTHROPIC_*`` environment, so the tests assert on the env the
``fake_launch`` seam records.
"""
from __future__ import annotations
import pytest
from handler.control import cli, models, spawn, worker
from handler.db import repository as repo
from handler.db.engine import connection, get_engine
@pytest.fixture
def lowpriv(env):
"""A valid bearer that is NOT the admin token (the shared-context write token)."""
return {"Authorization": f"Bearer {env['shared_token']}"}
@pytest.fixture
def secret_key(env, monkeypatch):
from cryptography.fernet import Fernet
from handler import config
key = Fernet.generate_key().decode()
monkeypatch.setenv("HANDLER_SECRET_KEY", key)
config.get_settings.cache_clear()
return key
def _register_project(root):
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
def _write_mise(root):
root.mkdir(parents=True, exist_ok=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
# --- repository ------------------------------------------------------------------------
def test_repository_model_crud(conn):
row = repo.create_claude_model(
conn, "qwen3-coder", "http://llm.local:4000", "qwen3-coder-30b"
)
assert row["enabled"] is True and row["api_key_enc"] is None
assert repo.get_claude_model_by_name(conn, "qwen3-coder")["id"] == row["id"]
updated = repo.update_claude_model(
conn, row["id"], small_fast_model="qwen3-1.7b", enabled=False
)
assert updated["small_fast_model"] == "qwen3-1.7b" and updated["enabled"] is False
assert repo.list_claude_models(conn, enabled_only=True) == []
assert len(repo.list_claude_models(conn)) == 1
assert repo.delete_claude_model(conn, row["id"]) is True
assert repo.get_claude_model(conn, row["id"]) is None
# --- API CRUD + gating -----------------------------------------------------------------
def test_model_api_crud_never_returns_key(client, auth, secret_key):
r = client.post(
"/claude/models",
json={
"name": "qwen3-coder",
"base_url": "http://llm.local:4000/", # trailing slash normalized away
"model": "qwen3-coder-30b",
"api_key": "sk-local-123",
"env": {"API_TIMEOUT_MS": "600000"},
},
headers=auth,
)
assert r.status_code == 201
body = r.json()
assert body["base_url"] == "http://llm.local:4000"
assert body["has_api_key"] is True
assert "sk-local-123" not in r.text and "api_key_enc" not in body
mid = body["id"]
# The stored column is ciphertext, not the key.
with get_engine().begin() as conn:
stored = repo.get_claude_model(conn, mid)["api_key_enc"]
assert stored and "sk-local-123" not in stored
# Duplicate name refused.
r = client.post(
"/claude/models",
json={"name": "qwen3-coder", "base_url": "http://x", "model": "m"},
headers=auth,
)
assert r.status_code == 409
# PATCH: clear the key; other fields survive.
r = client.patch(
f"/claude/models/{mid}", json={"clear_api_key": True, "enabled": False}, headers=auth
)
assert r.status_code == 200
assert r.json()["has_api_key"] is False and r.json()["enabled"] is False
r = client.delete(f"/claude/models/{mid}", headers=auth)
assert r.status_code == 200 and r.json()["deleted"] == "qwen3-coder"
def test_model_api_validation_and_admin_gating(client, auth, lowpriv):
# base_url must be http(s).
r = client.post(
"/claude/models",
json={"name": "bad", "base_url": "llm.local:4000", "model": "m"},
headers=auth,
)
assert r.status_code == 422
r = client.post(
"/claude/models",
json={"name": "ok", "base_url": "http://llm.local", "model": "m"},
headers=auth,
)
assert r.status_code == 201
mid = r.json()["id"]
# Reads take the normal token; writes need admin.
assert client.get("/claude/models", headers=lowpriv).status_code == 200
assert (
client.post(
"/claude/models",
json={"name": "x", "base_url": "http://y", "model": "m"},
headers=lowpriv,
).status_code
== 403
)
assert client.patch(f"/claude/models/{mid}", json={}, headers=lowpriv).status_code == 403
assert client.delete(f"/claude/models/{mid}", headers=lowpriv).status_code == 403
def test_model_api_key_without_secret_store_is_400(client, auth):
r = client.post(
"/claude/models",
json={"name": "k", "base_url": "http://x", "model": "m", "api_key": "sk-1"},
headers=auth,
)
assert r.status_code == 400
assert "HANDLER_SECRET_KEY" in r.json()["detail"]
# --- env resolution --------------------------------------------------------------------
def test_resolve_model_env_none_is_subscription(conn):
assert models.resolve_model_env(conn, None) == {}
def test_resolve_model_env_defaults_and_overrides(conn):
row = repo.create_claude_model(
conn,
"qwen3-coder",
"http://llm.local:4000",
"qwen3-coder-30b",
env={"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"},
)
env = models.resolve_model_env(conn, row["id"])
assert env["ANTHROPIC_BASE_URL"] == "http://llm.local:4000"
assert env["ANTHROPIC_MODEL"] == "qwen3-coder-30b"
# No small_fast_model configured -> the main model serves the fast lane too.
assert env["ANTHROPIC_SMALL_FAST_MODEL"] == "qwen3-coder-30b"
# No stored key -> placeholder, so the subscription OAuth token never leaves for a
# local endpoint.
assert env["ANTHROPIC_AUTH_TOKEN"] == "handler-local"
assert env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1"
assert env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] == "8192"
def test_resolve_model_env_decrypts_key(conn, secret_key):
from handler import secretstore
row = repo.create_claude_model(
conn,
"gateway",
"https://gw.corp",
"big-model",
api_key_enc=secretstore.encrypt("sk-real"),
small_fast_model="small-model",
)
env = models.resolve_model_env(conn, row["id"])
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-real"
assert env["ANTHROPIC_SMALL_FAST_MODEL"] == "small-model"
def test_resolve_model_env_missing_and_disabled(conn):
with pytest.raises(models.ModelError, match="no longer exists"):
models.resolve_model_env(conn, 999)
row = repo.create_claude_model(conn, "off", "http://x", "m", enabled=False)
with pytest.raises(models.ModelError, match="disabled"):
models.resolve_model_env(conn, row["id"], require_enabled=True)
# A resume (require_enabled=False) may still use a disabled backend.
assert models.resolve_model_env(conn, row["id"])["ANTHROPIC_MODEL"] == "m"
# --- spawn / resume / worker integration -----------------------------------------------
def test_spawn_with_model_injects_env_and_pins_agent(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
with get_engine().begin() as conn:
row = repo.create_claude_model(conn, "qwen3-coder", "http://llm.local:4000", "qwen")
agent = spawn.spawn("proj", "api", task="do it", model_id=row["id"])
call_env = fake_launch[0]["env"]
assert call_env["ANTHROPIC_BASE_URL"] == "http://llm.local:4000"
assert call_env["ANTHROPIC_MODEL"] == "qwen"
# Identity/credential env still rides along untouched.
assert call_env["HANDLER_PROJECT_ID"] == "proj"
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["model_id"] == row["id"]
assert agent["model_id"] == row["id"]
def test_spawn_without_model_keeps_subscription_env(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
spawn.spawn("proj", "api", task="do it")
assert "ANTHROPIC_BASE_URL" not in fake_launch[0]["env"]
assert "ANTHROPIC_AUTH_TOKEN" not in fake_launch[0]["env"]
def test_spawn_refuses_missing_or_disabled_model(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
with pytest.raises(spawn.SpawnError, match="no longer exists"):
spawn.spawn("proj", "api", task="do it", model_id=12345)
with get_engine().begin() as conn:
off = repo.create_claude_model(conn, "off", "http://x", "m", enabled=False)
# Fail-fast: no orphaned agent row behind the refused spawn.
assert repo.get_agent_by_name(conn, "proj", "api") is None
with pytest.raises(spawn.SpawnError, match="disabled"):
spawn.spawn("proj", "api", task="do it", model_id=off["id"])
assert fake_launch == []
def test_resume_comes_back_on_the_same_backend(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
with get_engine().begin() as conn:
row = repo.create_claude_model(conn, "qwen3-coder", "http://llm.local:4000", "qwen")
spawn.spawn("proj", "api", task="do it", model_id=row["id"])
with get_engine().begin() as conn:
agent = repo.get_agent_by_name(conn, "proj", "api")
repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
ok, _ = spawn.resume(agent, "keep going")
assert ok is True
assert fake_launch[-1]["env"]["ANTHROPIC_BASE_URL"] == "http://llm.local:4000"
def test_resume_fails_loudly_when_backend_deleted(env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
with get_engine().begin() as conn:
row = repo.create_claude_model(conn, "qwen3-coder", "http://llm.local:4000", "qwen")
spawn.spawn("proj", "api", task="do it", model_id=row["id"])
with get_engine().begin() as conn:
agent = repo.get_agent_by_name(conn, "proj", "api")
repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
repo.delete_claude_model(conn, row["id"])
with pytest.raises(spawn.SpawnError, match="no longer exists"):
spawn.resume(agent, "keep going")
def test_spawn_route_and_worker_pass_model_through(client, auth, env, fake_launch):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
with get_engine().begin() as conn:
row = repo.create_claude_model(conn, "qwen3-coder", "http://llm.local:4000", "qwen")
off = repo.create_claude_model(conn, "off", "http://x", "m", enabled=False)
# Fail-fast at enqueue time for a stale/disabled dropdown selection.
r = client.post(
"/projects/proj/agents/spawn",
json={"name": "a", "task": "t", "model_id": 999},
headers=auth,
)
assert r.status_code == 400 and "not found" in r.json()["detail"]
r = client.post(
"/projects/proj/agents/spawn",
json={"name": "a", "task": "t", "model_id": off["id"]},
headers=auth,
)
assert r.status_code == 400 and "disabled" in r.json()["detail"]
r = client.post(
"/projects/proj/agents/spawn",
json={"name": "api", "task": "do it", "model_id": row["id"]},
headers=auth,
)
assert r.status_code == 202
assert r.json()["payload"]["model_id"] == row["id"]
with connection() as conn:
command = repo.claim_next_command(conn, "w1")
result = worker.execute_command(command)
assert result["name"] == "api"
assert fake_launch[0]["env"]["ANTHROPIC_MODEL"] == "qwen"
# The agent row the API returns carries the pin, so the UI can badge it.
r = client.get("/projects/proj/agents", headers=auth)
assert r.json()[0]["model_id"] == row["id"]
def test_cli_spawn_resolves_model_by_name(env, fake_launch, capsys):
root = env["tmp"] / "proj"
_write_mise(root)
_register_project(root)
assert cli.main(["spawn", "--project", "proj", "--name", "a", "--task", "t",
"--model", "nope"]) == 1
assert "not registered" in capsys.readouterr().err
with get_engine().begin() as conn:
repo.create_claude_model(conn, "qwen3-coder", "http://llm.local:4000", "qwen")
assert cli.main(["spawn", "--project", "proj", "--name", "api", "--task", "t",
"--model", "qwen3-coder"]) == 0
assert fake_launch[0]["env"]["ANTHROPIC_BASE_URL"] == "http://llm.local:4000"