mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 06:46:25 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)}` : ""}
|
||||
|
||||
@@ -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'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 />}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user