Turn the Claude Login page into a full Claude management page

The dashboard's Claude page now manages the whole Claude Code install agents
run on, not just the account login:

- Skills: operator-authored SKILL.md rows, synced to each worker's user-level
  ~/.claude/skills at every launch. Managed dirs carry a .handler-managed
  marker so deletions in the UI propagate while hand-installed skills survive.
- Connectors: MCP servers (stdio/http/sse) written per-launch as
  .claude/mcp-servers.json and passed to claude via --mcp-config, so nothing
  lands in the managed repo's tracked tree.
- Plugins: marketplace-pinned plugins folded into generated settings as
  extraKnownMarketplaces + enabledPlugins, installing on boot of headless runs.
- Permissions: defaultMode override plus allow/deny/ask rules merged over the
  env baseline into every generated settings.json.

All of it is plain DB state (new claude_skills / claude_connectors /
claude_plugins / claude_config tables, migration 0010) edited through the new
admin-gated /claude/* API routes and applied by the control container at spawn
and resume — changes reach the next launch of every agent with no redeploy.

The login flow moved into the page's Account tab unchanged; /login redirects
to /claude for old bookmarks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
This commit is contained in:
Claude
2026-07-23 13:32:41 +00:00
parent 301a697e74
commit 07d8c3aa19
65 changed files with 2240 additions and 99 deletions
+15 -7
View File
@@ -252,22 +252,30 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`)
- **Activity** — every enqueued command with its status (queued → running → done/failed) —
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
live status.
- **Claude Login** — log Claude Code in on the host from the browser (see below), so agents
spawn against a real authenticated `claude` with no shell access to the container.
- **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
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
into the generated per-agent `settings.json` — so a change in the UI reaches the next
launch of every agent, no redeploy.
The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`,
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`,
`POST /login/start`, `POST /login/submit`, and `GET /commands[/{id}]`; hosts as `/hosts`;
schedules as `/schedules` + `/projects/{id}/schedules`; project mutation as
`PATCH`/`DELETE /projects/{id}`. Run the worker with `handler worker` (the control image's
default command).
`PATCH`/`DELETE /projects/{id}`; Claude management as `/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).
### Claude login from the web UI
Agents *are* `claude` processes, so the control container needs a logged-in Claude Code.
Because that container has no interactive shell in normal operation, the **Claude Login**
pane logs it in from the browser — the same command-queue handoff every other control
action uses:
Because that container has no interactive shell in normal operation, the **Claude** page's
Account tab logs it in from the browser — the same command-queue handoff every other
control action uses:
1. **Log in to Claude** enqueues a `login_start` command. The worker opens `claude` in a
dedicated (wide) tmux session in the control container, navigates whatever onboarding a
+13
View File
@@ -0,0 +1,13 @@
/* Claude management page. The shell (sidebar, banners, store) comes from the root
* layout; this route contributes only its section, in the shared scroll frame. */
"use client";
import { ClaudeSection } from "@/components/sections/ClaudeSection";
export default function ClaudePage() {
return (
<div className="main-scroll">
<ClaudeSection />
</div>
);
}
+10 -9
View File
@@ -1,13 +1,14 @@
/* Claude Login page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
/* The old Claude Login route. The login flow now lives on the Claude management page
* (/claude, Account tab); this stub only redirects old bookmarks there. */
"use client";
import { LoginSection } from "@/components/sections/LoginSection";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
return (
<div className="main-scroll">
<LoginSection />
</div>
);
export default function LoginRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/claude");
}, [router]);
return null;
}
+4 -3
View File
@@ -1,5 +1,5 @@
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
* Servers / Activity / Shared / Claude Login) and the active route's page on the right,
* Servers / Activity / Shared / Claude) and the active route's page on the right,
* matching the design's hub layout. Each nav item is a real route, so pages are modular
* and independently loadable; this shell lives in the root layout and persists across
* navigation, keeping the store, polling loop, and auth alive between pages. Command
@@ -29,8 +29,9 @@ const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s
servers: { count: (s) => s.hosts.length },
activity: { count: (s) => s.commands.length },
shared: { count: (s) => s.shared.context.length },
login: {
count: () => 0,
claude: {
// Everything managed on the page: skills + connectors + plugins.
count: (s) => s.claudeSkills.length + s.claudeConnectors.length + s.claudePlugins.length,
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
@@ -0,0 +1,576 @@
/* Claude — the management page for the Claude Code install agents run on: the account
* login (moved here from the old Claude Login page), plus the operator-managed skills,
* MCP connectors, plugins, and permission overrides. Everything except the login is a
* plain DB write that the control container applies at the NEXT launch of every agent —
* skills sync to the workers' user-level ~/.claude/skills, connectors become each run's
* --mcp-config file, and plugins/permissions fold into the generated settings.json. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import type { ConnectorBody, 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 { ClaudeLoginPanel } from "@/components/sections/LoginSection";
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
function parseKeyValues(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
function formatKeyValues(map: Record<string, string> | null | undefined): string {
return Object.entries(map ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function parseLines(text: string): string[] {
return text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
/* ---- Skills ------------------------------------------------------------------------ */
const emptySkill = { name: "", description: "", content: "", enabled: true };
function SkillsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptySkill);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptySkill);
setEditingId(null);
};
const save = async () => {
const body: SkillBody = { ...form };
const ok =
editingId != null
? await s.updateClaudeSkill(editingId, body)
: await s.createClaudeSkill(body);
if (ok) reset();
};
const edit = (sk: ClaudeSkill) => {
setForm({
name: sk.name,
description: sk.description ?? "",
content: sk.content,
enabled: sk.enabled,
});
setEditingId(sk.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Custom Claude Code skills, synced to every worker&apos;s{" "}
<span className="mono">~/.claude/skills</span> at each launch. The description is
what makes Claude pick the skill up say when to use it.
</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 skill · ${form.name}` : "Add a skill"}
</span>
</div>
<div className="form-grid">
<Input
label="Name (slug — becomes the skill directory)"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="deploy-checklist"
/>
<Input
label="Description (when should Claude use it?)"
value={form.description}
onChange={(v) => setForm({ ...form, description: v })}
placeholder="Use when preparing or reviewing a deploy."
/>
</div>
<div style={{ marginTop: 10 }}>
<Textarea
label="SKILL.md body (markdown)"
value={form.content}
onChange={(v) => setForm({ ...form, content: v })}
rows={8}
placeholder={"# Deploy checklist\n\n1. ..."}
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.content.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add skill"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeSkills.length === 0 && <div className="empty">No custom skills yet.</div>}
{s.claudeSkills.map((sk) => (
<Card key={sk.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{sk.name}
</span>
<div className="hstack">
<Badge tone={sk.enabled ? "success" : "neutral"}>
{sk.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={sk.enabled} onClick={() => s.updateClaudeSkill(sk.id, { enabled: !sk.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(sk)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeSkill(sk.id)}>
Remove
</Button>
</div>
</div>
{sk.description && (
<div className="faint" style={{ fontSize: "var(--text-sm)", marginTop: 8 }}>
{sk.description}
</div>
)}
</Card>
))}
</>
);
}
/* ---- Connectors (MCP servers) ------------------------------------------------------ */
const TRANSPORT_OPTS = [
{ value: "stdio", label: "stdio (run a command)" },
{ value: "http", label: "http (remote server)" },
{ value: "sse", label: "sse (remote server, legacy)" },
];
const emptyConnector = {
name: "",
transport: "stdio" as ConnectorBody["transport"],
command: "",
args: "",
env: "",
url: "",
headers: "",
enabled: true,
};
function ConnectorsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyConnector);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyConnector);
setEditingId(null);
};
const save = async () => {
const body: ConnectorBody = {
name: form.name,
transport: form.transport,
command: form.command.trim() || null,
args: parseLines(form.args),
env: parseKeyValues(form.env),
url: form.url.trim() || null,
headers: parseKeyValues(form.headers),
enabled: form.enabled,
};
const ok =
editingId != null
? await s.updateClaudeConnector(editingId, body)
: await s.createClaudeConnector(body);
if (ok) reset();
};
const edit = (c: ClaudeConnector) => {
setForm({
name: c.name,
transport: c.transport,
command: c.command ?? "",
args: (c.args ?? []).join("\n"),
env: formatKeyValues(c.env),
url: c.url ?? "",
headers: formatKeyValues(c.headers),
enabled: c.enabled,
});
setEditingId(c.id);
};
const stdio = form.transport === "stdio";
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
MCP servers agents can reach. Passed to each run as its{" "}
<span className="mono">--mcp-config</span> file, so nothing lands in the
repository tree. stdio commands run inside the control container.
</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 connector · ${form.name}` : "Add a connector"}
</span>
</div>
<div className="form-grid">
<Input
label="Name"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="github"
/>
<Select
label="Transport"
value={form.transport}
onChange={(v) => setForm({ ...form, transport: v as ConnectorBody["transport"] })}
options={TRANSPORT_OPTS}
/>
{stdio ? (
<>
<Input
label="Command"
value={form.command}
onChange={(v) => setForm({ ...form, command: v })}
placeholder="npx"
/>
<Textarea
label="Arguments (one per line)"
value={form.args}
onChange={(v) => setForm({ ...form, args: v })}
rows={3}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
<Textarea
label="Environment (KEY=VALUE per line)"
value={form.env}
onChange={(v) => setForm({ ...form, env: v })}
rows={3}
placeholder="GITHUB_TOKEN=ghp_..."
/>
</>
) : (
<>
<Input
label="URL"
value={form.url}
onChange={(v) => setForm({ ...form, url: v })}
placeholder="https://mcp.example.com/mcp"
/>
<Textarea
label="Headers (KEY=VALUE per line)"
value={form.headers}
onChange={(v) => setForm({ ...form, headers: v })}
rows={3}
placeholder="Authorization=Bearer ..."
/>
</>
)}
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || (stdio ? !form.command.trim() : !form.url.trim())}
onClick={save}
>
{editingId != null ? "Save changes" : "Add connector"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudeConnectors.length === 0 && <div className="empty">No connectors yet.</div>}
{s.claudeConnectors.map((c) => (
<Card key={c.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{c.name}
</span>
<div className="hstack">
<Badge tone="info">{c.transport}</Badge>
<Badge tone={c.enabled ? "success" : "neutral"}>
{c.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={c.enabled} onClick={() => s.updateClaudeConnector(c.id, { enabled: !c.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(c)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeConnector(c.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{c.transport === "stdio"
? [c.command, ...(c.args ?? [])].join(" ")
: c.url}
</div>
</Card>
))}
</>
);
}
/* ---- Plugins ----------------------------------------------------------------------- */
const emptyPlugin = { name: "", marketplace: "", marketplace_repo: "", enabled: true };
function PluginsPanel() {
const s = useDashboard();
const [form, setForm] = useState(emptyPlugin);
const [editingId, setEditingId] = useState<number | null>(null);
const reset = () => {
setForm(emptyPlugin);
setEditingId(null);
};
const save = async () => {
const body: PluginBody = { ...form };
const ok =
editingId != null
? await s.updateClaudePlugin(editingId, body)
: await s.createClaudePlugin(body);
if (ok) reset();
};
const edit = (p: ClaudePlugin) => {
setForm({
name: p.name,
marketplace: p.marketplace,
marketplace_repo: p.marketplace_repo,
enabled: p.enabled,
});
setEditingId(p.id);
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Claude Code plugins, pinned to the marketplace serving them. Generated settings
declare the marketplace and enable the plugin, so headless runs install both on
boot.
</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 plugin · ${form.name}` : "Add a plugin"}
</span>
</div>
<div className="form-grid">
<Input
label="Plugin name"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
placeholder="code-reviewer"
/>
<Input
label="Marketplace key"
value={form.marketplace}
onChange={(v) => setForm({ ...form, marketplace: v })}
placeholder="acme-tools"
/>
<Input
label="Marketplace repo (owner/repo or git URL)"
value={form.marketplace_repo}
onChange={(v) => setForm({ ...form, marketplace_repo: v })}
placeholder="acme/claude-marketplace"
/>
</div>
<div className="hstack mt14">
<Button
variant="primary"
disabled={!form.name.trim() || !form.marketplace.trim() || !form.marketplace_repo.trim()}
onClick={save}
>
{editingId != null ? "Save changes" : "Add plugin"}
</Button>
{editingId != null && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.claudePlugins.length === 0 && <div className="empty">No plugins yet.</div>}
{s.claudePlugins.map((p) => (
<Card key={p.id}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{p.name}@{p.marketplace}
</span>
<div className="hstack">
<Badge tone={p.enabled ? "success" : "neutral"}>
{p.enabled ? "enabled" : "disabled"}
</Badge>
<Toggle on={p.enabled} onClick={() => s.updateClaudePlugin(p.id, { enabled: !p.enabled })} />
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteClaudePlugin(p.id)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
{p.marketplace_repo}
</div>
</Card>
))}
</>
);
}
/* ---- Permissions ------------------------------------------------------------------- */
const MODE_OPTS = [
{ value: "", label: "(keep server baseline)" },
{ value: "default", label: "default" },
{ value: "acceptEdits", label: "acceptEdits" },
{ value: "plan", label: "plan" },
{ value: "bypassPermissions", label: "bypassPermissions" },
];
function PermissionsPanel() {
const s = useDashboard();
const p = s.claudePermissions;
const [form, setForm] = useState<{ mode: string; allow: string; deny: string; ask: string } | null>(null);
// Seed the form from the loaded permissions once; afterwards the operator's draft wins.
if (form === null && p !== null) {
setForm({
mode: p.default_mode ?? "",
allow: p.allow.join("\n"),
deny: p.deny.join("\n"),
ask: p.ask.join("\n"),
});
return null;
}
if (form === null || p === null) {
return <div className="empty">Loading permissions</div>;
}
const save = () =>
s.saveClaudePermissions({
default_mode: form.mode || null,
allow: parseLines(form.allow),
deny: parseLines(form.deny),
ask: parseLines(form.ask),
});
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
Overrides merged over the server baseline into every generated{" "}
<span className="mono">settings.json</span>. Headless runs auto-deny anything that
would prompt, so allow rules are what let work proceed; the PreToolUse/Stop hooks
stay the hard gate regardless.
</div>
<Card>
<div className="form-grid">
<Select
label={`Default mode (baseline: ${p.base_mode})`}
value={form.mode}
onChange={(v) => setForm({ ...form, mode: v })}
options={MODE_OPTS}
/>
<div className="field">
<span className="field-label">Baseline allow rules (from server env)</span>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", padding: "6px 0" }}>
{p.base_allow.length ? p.base_allow.join(" · ") : "—"}
</div>
</div>
<Textarea
label="Extra allow rules (one per line)"
value={form.allow}
onChange={(v) => setForm({ ...form, allow: v })}
rows={4}
placeholder={"Bash(npm *)\nWebFetch(domain:docs.example.com)"}
/>
<Textarea
label="Deny rules (one per line)"
value={form.deny}
onChange={(v) => setForm({ ...form, deny: v })}
rows={4}
placeholder={"Bash(rm -rf *)\nRead(./secrets/**)"}
/>
<Textarea
label="Ask rules (one per line — headless runs deny these)"
value={form.ask}
onChange={(v) => setForm({ ...form, ask: v })}
rows={4}
placeholder="Bash(git push *)"
/>
</div>
<div className="hstack mt14">
<Button variant="primary" onClick={save}>
Save permissions
</Button>
</div>
</Card>
</>
);
}
/* ---- The page ---------------------------------------------------------------------- */
const TABS = [
{ value: "account", label: "Account" },
{ value: "skills", label: "Skills" },
{ value: "connectors", label: "Connectors" },
{ value: "plugins", label: "Plugins" },
{ value: "permissions", label: "Permissions" },
];
export function ClaudeSection() {
const [tab, setTab] = useState("account");
return (
<>
<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.
</div>
</div>
<div className="section-body">
<div style={{ marginBottom: 16 }}>
<Tabs tabs={TABS} value={tab} onChange={setTab} />
</div>
{tab === "account" && <ClaudeLoginPanel />}
{tab === "skills" && <SkillsPanel />}
{tab === "connectors" && <ConnectorsPanel />}
{tab === "plugins" && <PluginsPanel />}
{tab === "permissions" && <PermissionsPanel />}
</div>
</>
);
}
+8 -10
View File
@@ -1,4 +1,5 @@
/* Claude Login — drive the bundled `claude /login` OAuth flow on the host from the web UI.
/* Claude account login panel (the Account tab of the Claude page) — drives the bundled
* `claude /login` OAuth flow on the host from the web UI.
*
* Click "Log in to Claude" → a small OAuth-style popup window opens (like "Sign in with
* Google") and the worker drives `claude /login` in the control container, selecting the
@@ -27,7 +28,7 @@ function openLoginPopup(url: string): Window | null {
);
}
export function LoginSection() {
export function ClaudeLoginPanel() {
const s = useDashboard();
const { status, url, message } = s.claudeLogin;
const [code, setCode] = useState("");
@@ -66,16 +67,13 @@ export function LoginSection() {
return (
<>
<div className="section-head">
<div className="section-title">Claude Login</div>
<div className="section-desc">
Log Claude Code in on the host so agents can run. This drives{" "}
<span className="mono">claude /login</span> in the control container and picks the
Claude account with a subscription.
</div>
<div className="faint" style={{ fontSize: "var(--text-sm)" }}>
Log Claude Code in on the host so agents can run. This drives{" "}
<span className="mono">claude /login</span> in the control container and picks the
Claude account with a subscription.
</div>
<div className="section-body" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16, marginTop: 14 }}>
{message && (
<Callout tone={status === "error" ? "danger" : status === "done" ? "success" : "info"}>
{message}
+223 -3
View File
@@ -21,6 +21,10 @@ import {
type ApiError,
type Approval,
type Checkmark,
type ClaudeConnector,
type ClaudePermissions,
type ClaudePlugin,
type ClaudeSkill,
type Command,
type Host,
type LogEntry,
@@ -38,7 +42,7 @@ export type Section =
| "servers"
| "activity"
| "shared"
| "login";
| "claude";
/* The claude web-login flow, driven through the login_start / login_submit commands.
* idle → starting → awaiting (have URL) → submitting → done | error */
@@ -124,6 +128,51 @@ interface StoreValue {
startClaudeLogin: () => Promise<void>;
submitClaudeCode: (code: string) => Promise<boolean>;
resetClaudeLogin: () => void;
// Claude management (skills / connectors / plugins / permissions)
claudeSkills: ClaudeSkill[];
claudeConnectors: ClaudeConnector[];
claudePlugins: ClaudePlugin[];
claudePermissions: ClaudePermissions | null;
createClaudeSkill: (b: SkillBody) => Promise<boolean>;
updateClaudeSkill: (id: number, b: Partial<SkillBody>) => Promise<boolean>;
deleteClaudeSkill: (id: number) => Promise<void>;
createClaudeConnector: (b: ConnectorBody) => Promise<boolean>;
updateClaudeConnector: (id: number, b: Partial<ConnectorBody>) => Promise<boolean>;
deleteClaudeConnector: (id: number) => Promise<void>;
createClaudePlugin: (b: PluginBody) => Promise<boolean>;
updateClaudePlugin: (id: number, b: Partial<PluginBody>) => Promise<boolean>;
deleteClaudePlugin: (id: number) => Promise<void>;
saveClaudePermissions: (b: PermissionsBody) => Promise<boolean>;
}
export interface SkillBody {
name: string;
description: string;
content: string;
enabled: boolean;
}
export interface ConnectorBody {
name: string;
transport: "stdio" | "http" | "sse";
command: string | null;
args: string[];
env: Record<string, string>;
url: string | null;
headers: Record<string, string>;
enabled: boolean;
}
export interface PluginBody {
name: string;
marketplace: string;
marketplace_repo: string;
enabled: boolean;
}
export interface PermissionsBody {
default_mode: string | null;
allow: string[];
deny: string[];
ask: string[];
}
export interface SpawnBody {
@@ -236,6 +285,10 @@ export function DashboardProvider({
url: "",
message: "",
});
const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([]);
const [claudeConnectors, setClaudeConnectors] = useState<ClaudeConnector[]>([]);
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
@@ -374,6 +427,23 @@ export function DashboardProvider({
}
}, []);
const loadClaude = useCallback(async () => {
try {
const [skills, connectors, plugins, permissions] = await Promise.all([
clientRef.current.api<ClaudeSkill[]>("/claude/skills"),
clientRef.current.api<ClaudeConnector[]>("/claude/connectors"),
clientRef.current.api<ClaudePlugin[]>("/claude/plugins"),
clientRef.current.api<ClaudePermissions>("/claude/permissions"),
]);
setClaudeSkills(skills);
setClaudeConnectors(connectors);
setClaudePlugins(plugins);
setClaudePermissions(permissions);
} catch (e) {
swallow(e);
}
}, []);
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
* so the nav counts and inbox stay live). */
const tick = useCallback(async () => {
@@ -396,7 +466,8 @@ export function DashboardProvider({
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared]);
if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude]);
// 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.
@@ -425,8 +496,9 @@ export function DashboardProvider({
if (s === "activity") void loadCommands();
if (s === "schedules") void loadSchedules();
if (s === "shared") void loadShared();
if (s === "claude") void loadClaude();
},
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude],
);
const selectProject = useCallback(
@@ -933,6 +1005,140 @@ export function DashboardProvider({
setClaudeLogin({ status: "idle", url: "", message: "" });
}, []);
// ---- claude management (skills / connectors / plugins / permissions) ----
// Plain DB writes (no worker round-trip); every change applies to the NEXT launch of
// every agent, which the success banners say explicitly.
const claudeWrite = useCallback(
async (fn: () => Promise<unknown>, okText: string): Promise<boolean> => {
try {
await fn();
setCmd({ text: okText, error: false, busy: false });
await loadClaude();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadClaude],
);
const createClaudeSkill = useCallback(
(b: SkillBody) =>
claudeWrite(
() =>
clientRef.current.api("/claude/skills", {
method: "POST",
body: {
name: b.name.trim(),
description: b.description.trim() || null,
content: b.content,
enabled: b.enabled,
},
}),
`skill '${b.name.trim()}' saved — applies to the next agent launch`,
),
[claudeWrite],
);
const updateClaudeSkill = useCallback(
(id: number, b: Partial<SkillBody>) =>
claudeWrite(
() => clientRef.current.api(`/claude/skills/${id}`, { method: "PATCH", body: b }),
"skill updated — applies to the next agent launch",
),
[claudeWrite],
);
const deleteClaudeSkill = useCallback(
async (id: number) => {
await claudeWrite(
() => clientRef.current.api(`/claude/skills/${id}`, { method: "DELETE" }),
"skill removed — gone from workers at the next launch",
);
},
[claudeWrite],
);
const createClaudeConnector = useCallback(
(b: ConnectorBody) =>
claudeWrite(
() =>
clientRef.current.api("/claude/connectors", {
method: "POST",
body: { ...b, name: b.name.trim() },
}),
`connector '${b.name.trim()}' saved — applies to the next agent launch`,
),
[claudeWrite],
);
const updateClaudeConnector = useCallback(
(id: number, b: Partial<ConnectorBody>) =>
claudeWrite(
() => clientRef.current.api(`/claude/connectors/${id}`, { method: "PATCH", body: b }),
"connector updated — applies to the next agent launch",
),
[claudeWrite],
);
const deleteClaudeConnector = useCallback(
async (id: number) => {
await claudeWrite(
() => clientRef.current.api(`/claude/connectors/${id}`, { method: "DELETE" }),
"connector removed — applies to the next agent launch",
);
},
[claudeWrite],
);
const createClaudePlugin = useCallback(
(b: PluginBody) =>
claudeWrite(
() =>
clientRef.current.api("/claude/plugins", {
method: "POST",
body: {
name: b.name.trim(),
marketplace: b.marketplace.trim(),
marketplace_repo: b.marketplace_repo.trim(),
enabled: b.enabled,
},
}),
`plugin '${b.name.trim()}' saved — installs on the next agent launch`,
),
[claudeWrite],
);
const updateClaudePlugin = useCallback(
(id: number, b: Partial<PluginBody>) =>
claudeWrite(
() => clientRef.current.api(`/claude/plugins/${id}`, { method: "PATCH", body: b }),
"plugin updated — applies to the next agent launch",
),
[claudeWrite],
);
const deleteClaudePlugin = useCallback(
async (id: number) => {
await claudeWrite(
() => clientRef.current.api(`/claude/plugins/${id}`, { method: "DELETE" }),
"plugin removed — applies to the next agent launch",
);
},
[claudeWrite],
);
const saveClaudePermissions = useCallback(
(b: PermissionsBody) =>
claudeWrite(
() => clientRef.current.api("/claude/permissions", { method: "PUT", body: b }),
"permissions saved — apply to the next agent launch",
),
[claudeWrite],
);
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
@@ -997,6 +1203,20 @@ export function DashboardProvider({
startClaudeLogin,
submitClaudeCode,
resetClaudeLogin,
claudeSkills,
claudeConnectors,
claudePlugins,
claudePermissions,
createClaudeSkill,
updateClaudeSkill,
deleteClaudeSkill,
createClaudeConnector,
updateClaudeConnector,
deleteClaudeConnector,
createClaudePlugin,
updateClaudePlugin,
deleteClaudePlugin,
saveClaudePermissions,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+46
View File
@@ -140,6 +140,52 @@ export interface Schedule {
created_at: string;
}
/* ---- Claude management (the dashboard's Claude page) ---- */
export interface ClaudeSkill {
id: number;
name: string;
description?: string | null;
content: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
export type McpTransport = "stdio" | "http" | "sse";
export interface ClaudeConnector {
id: number;
name: string;
transport: McpTransport;
command?: string | null;
args?: string[] | null;
env?: Record<string, string> | null;
url?: string | null;
headers?: Record<string, string> | null;
enabled: boolean;
created_at: string;
}
export interface ClaudePlugin {
id: number;
name: string;
marketplace: string;
marketplace_repo: string;
enabled: boolean;
created_at: string;
}
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
export interface ClaudePermissions {
default_mode?: string | null;
allow: string[];
deny: string[];
ask: string[];
base_mode: string;
base_allow: string[];
}
export interface SharedContext {
key: string;
value: string;
+5 -2
View File
@@ -18,12 +18,15 @@ export const NAV_ROUTES: NavRoute[] = [
{ key: "servers", href: "/servers", label: "Git Servers" },
{ key: "activity", href: "/activity", label: "Activity" },
{ key: "shared", href: "/shared", label: "Shared" },
{ key: "login", href: "/login", label: "Claude Login" },
{ key: "claude", href: "/claude", label: "Claude" },
];
/* Map a browser path back to its section key. Trailing slashes (Next emits them under
* `trailingSlash: true`) are normalized away; anything unrecognized falls back to Runs. */
* `trailingSlash: true`) are normalized away; anything unrecognized falls back to Runs.
* "/login" still maps to the Claude page — the login flow moved into it, and the old
* route redirects there. */
export function sectionFromPath(pathname: string): Section {
const clean = pathname.replace(/\/+$/, "") || "/";
if (clean === "/login") return "claude";
return NAV_ROUTES.find((r) => r.href === clean)?.key ?? "runs";
}
+2
View File
@@ -17,6 +17,7 @@ from ..config import get_settings
from .routes import (
agents,
approvals,
claude,
commands,
hosts,
interaction,
@@ -48,6 +49,7 @@ def create_app() -> FastAPI:
app.include_router(approvals.router)
app.include_router(commands.router)
app.include_router(login.router)
app.include_router(claude.router)
app.include_router(hosts.router)
app.include_router(schedules.router)
app.include_router(shared.router)
+262
View File
@@ -0,0 +1,262 @@
"""Claude management: skills, MCP connectors, plugins, and permission overrides.
The dashboard's Claude page edits these rows directly — no worker round-trip, because
nothing here touches a live process. The control container reads the same tables at
every launch: skills sync to the worker's user-level ``~/.claude/skills``, connectors
become the run's ``--mcp-config`` file, and plugins/permissions fold into the generated
per-agent ``settings.json`` (``control.settings_gen`` / ``control.claude_gen``). Changes
therefore apply to the *next* launch of every agent, not to runs already in flight.
Reads take the normal token; writes take the admin token (they shape what every agent
is allowed to do). The login flow stays under ``/login`` — it needs the worker's tmux.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from ...config import get_settings
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import (
ClaudeConnectorIn,
ClaudeConnectorOut,
ClaudeConnectorUpdateIn,
ClaudePermissionsIn,
ClaudePermissionsOut,
ClaudePluginIn,
ClaudePluginOut,
ClaudePluginUpdateIn,
ClaudeSkillIn,
ClaudeSkillOut,
ClaudeSkillUpdateIn,
)
router = APIRouter(prefix="/claude", tags=["claude"], dependencies=[Depends(require_auth)])
# ---- skills ---------------------------------------------------------------------------
def _skill_or_404(conn: Connection, skill_id: int) -> dict:
skill = repo.get_claude_skill(conn, skill_id)
if skill is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"skill {skill_id} not found")
return skill
@router.get("/skills", response_model=list[ClaudeSkillOut])
def list_skills(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_claude_skills(conn)
@router.post(
"/skills",
response_model=ClaudeSkillOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_skill(body: ClaudeSkillIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_claude_skill_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"skill '{body.name}' exists")
return repo.create_claude_skill(
conn, body.name, body.content, description=body.description, enabled=body.enabled
)
@router.patch(
"/skills/{skill_id}", response_model=ClaudeSkillOut, dependencies=[Depends(require_admin)]
)
def update_skill(
skill_id: int, body: ClaudeSkillUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
_skill_or_404(conn, skill_id)
fields = body.model_dump(exclude_unset=True)
if "name" in fields:
clash = repo.get_claude_skill_by_name(conn, fields["name"])
if clash is not None and clash["id"] != skill_id:
raise HTTPException(
status.HTTP_409_CONFLICT, detail=f"skill '{fields['name']}' exists"
)
return repo.update_claude_skill(conn, skill_id, **fields)
@router.delete("/skills/{skill_id}", dependencies=[Depends(require_admin)])
def delete_skill(skill_id: int, conn: Connection = Depends(db_conn)) -> dict:
skill = _skill_or_404(conn, skill_id)
repo.delete_claude_skill(conn, skill_id)
return {"deleted": skill["name"]}
# ---- connectors (MCP servers) ---------------------------------------------------------
def _connector_or_404(conn: Connection, connector_id: int) -> dict:
connector = repo.get_claude_connector(conn, connector_id)
if connector is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"connector {connector_id} not found"
)
return connector
@router.get("/connectors", response_model=list[ClaudeConnectorOut])
def list_connectors(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_claude_connectors(conn)
@router.post(
"/connectors",
response_model=ClaudeConnectorOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_connector(body: ClaudeConnectorIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_claude_connector_by_name(conn, body.name) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"connector '{body.name}' exists")
return repo.create_claude_connector(
conn,
body.name,
body.transport,
command=body.command,
args=body.args,
env=body.env,
url=body.url,
headers=body.headers,
enabled=body.enabled,
)
@router.patch(
"/connectors/{connector_id}",
response_model=ClaudeConnectorOut,
dependencies=[Depends(require_admin)],
)
def update_connector(
connector_id: int, body: ClaudeConnectorUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
current = _connector_or_404(conn, connector_id)
fields = body.model_dump(exclude_unset=True)
if "name" in fields:
clash = repo.get_claude_connector_by_name(conn, fields["name"])
if clash is not None and clash["id"] != connector_id:
raise HTTPException(
status.HTTP_409_CONFLICT, detail=f"connector '{fields['name']}' exists"
)
# Re-check the transport/field pairing against the merged row, so a PATCH can't
# produce a stdio connector without a command or an http one without a url.
merged = {**current, **fields}
if merged["transport"] == "stdio":
if not (merged.get("command") or "").strip():
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, detail="a stdio connector needs a command"
)
elif not (merged.get("url") or "").strip().startswith(("http://", "https://")):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"an {merged['transport']} connector needs an http(s) url",
)
return repo.update_claude_connector(conn, connector_id, **fields)
@router.delete("/connectors/{connector_id}", dependencies=[Depends(require_admin)])
def delete_connector(connector_id: int, conn: Connection = Depends(db_conn)) -> dict:
connector = _connector_or_404(conn, connector_id)
repo.delete_claude_connector(conn, connector_id)
return {"deleted": connector["name"]}
# ---- plugins --------------------------------------------------------------------------
def _plugin_or_404(conn: Connection, plugin_id: int) -> dict:
plugin = repo.get_claude_plugin(conn, plugin_id)
if plugin is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"plugin {plugin_id} not found")
return plugin
@router.get("/plugins", response_model=list[ClaudePluginOut])
def list_plugins(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_claude_plugins(conn)
@router.post(
"/plugins",
response_model=ClaudePluginOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_plugin(body: ClaudePluginIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_claude_plugin_by_key(conn, body.name, body.marketplace) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=f"plugin '{body.name}@{body.marketplace}' exists",
)
return repo.create_claude_plugin(
conn, body.name, body.marketplace, body.marketplace_repo, enabled=body.enabled
)
@router.patch(
"/plugins/{plugin_id}", response_model=ClaudePluginOut, dependencies=[Depends(require_admin)]
)
def update_plugin(
plugin_id: int, body: ClaudePluginUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
current = _plugin_or_404(conn, plugin_id)
fields = body.model_dump(exclude_unset=True)
if "name" in fields or "marketplace" in fields:
merged = {**current, **fields}
clash = repo.get_claude_plugin_by_key(conn, merged["name"], merged["marketplace"])
if clash is not None and clash["id"] != plugin_id:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=f"plugin '{merged['name']}@{merged['marketplace']}' exists",
)
return repo.update_claude_plugin(conn, plugin_id, **fields)
@router.delete("/plugins/{plugin_id}", dependencies=[Depends(require_admin)])
def delete_plugin(plugin_id: int, conn: Connection = Depends(db_conn)) -> dict:
plugin = _plugin_or_404(conn, plugin_id)
repo.delete_claude_plugin(conn, plugin_id)
return {"deleted": f"{plugin['name']}@{plugin['marketplace']}"}
# ---- permissions ----------------------------------------------------------------------
def _permissions_out(stored: dict | None) -> dict:
s = get_settings()
stored = stored or {}
return {
"default_mode": stored.get("default_mode"),
"allow": stored.get("allow", []),
"deny": stored.get("deny", []),
"ask": stored.get("ask", []),
"base_mode": s.headless_permission_mode,
"base_allow": s.headless_allowed_tools_list,
}
@router.get("/permissions", response_model=ClaudePermissionsOut)
def get_permissions(conn: Connection = Depends(db_conn)) -> dict:
return _permissions_out(repo.get_claude_config(conn, "permissions"))
@router.put(
"/permissions",
response_model=ClaudePermissionsOut,
dependencies=[Depends(require_admin)],
)
def put_permissions(body: ClaudePermissionsIn, conn: Connection = Depends(db_conn)) -> dict:
stored = {
"default_mode": body.default_mode,
"allow": [r.strip() for r in body.allow if r.strip()],
"deny": [r.strip() for r in body.deny if r.strip()],
"ask": [r.strip() for r in body.ask if r.strip()],
}
repo.set_claude_config(conn, "permissions", stored)
return _permissions_out(stored)
+159
View File
@@ -351,6 +351,165 @@ class ResumeOut(BaseModel):
detail: str
# ---- Claude management (dashboard Claude page) -----------------------------------------
# Names become filesystem dirnames (skills) and JSON object keys (connectors,
# marketplaces), so keep them to a safe slug — no separators, no dot-prefix.
_SLUG_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]*$"
# settings.json permissions.defaultMode values claude accepts.
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
McpTransport = Literal["stdio", "http", "sse"]
# "owner/repo" marketplaces resolve as GitHub sources; anything else must be a git URL.
_GIT_URL_RE = re.compile(r"^(https?://|git@|ssh://)")
class ClaudeSkillIn(BaseModel):
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
description: str | None = None
content: str = Field(min_length=1)
enabled: bool = True
class ClaudeSkillUpdateIn(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
description: str | None = None
content: str | None = Field(default=None, min_length=1)
enabled: bool | None = None
class ClaudeSkillOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str | None = None
content: str
enabled: bool
created_at: datetime
updated_at: datetime
class ClaudeConnectorIn(BaseModel):
"""An MCP server agents may reach: ``stdio`` runs ``command`` in the control
container, ``http``/``sse`` point at ``url``. Written per-launch as the run's
``--mcp-config`` file."""
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
transport: McpTransport = "stdio"
command: str | None = None
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
enabled: bool = True
@model_validator(mode="after")
def _check_transport_fields(self) -> ClaudeConnectorIn:
if self.transport == "stdio":
if not (self.command or "").strip():
raise ValueError("a stdio connector needs a command")
elif not (self.url or "").strip() or not self.url.strip().startswith(
("http://", "https://")
):
raise ValueError(f"an {self.transport} connector needs an http(s) url")
return self
class ClaudeConnectorUpdateIn(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
transport: McpTransport | None = None
command: str | None = None
args: list[str] | None = None
env: dict[str, str] | None = None
url: str | None = None
headers: dict[str, str] | None = None
enabled: bool | None = None
class ClaudeConnectorOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
transport: str
command: str | None = None
args: list[str] | None = None
env: dict[str, str] | None = None
url: str | None = None
headers: dict[str, str] | None = None
enabled: bool
created_at: datetime
class ClaudePluginIn(BaseModel):
"""A plugin pinned to the marketplace serving it. ``marketplace_repo`` is
``owner/repo`` (GitHub) or a git URL; generated settings carry it as an extra known
marketplace with the plugin enabled, so headless runs install it on boot."""
name: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
marketplace: str = Field(min_length=1, max_length=64, pattern=_SLUG_PATTERN)
marketplace_repo: str = Field(min_length=1)
enabled: bool = True
@field_validator("marketplace_repo")
@classmethod
def _check_repo(cls, v: str) -> str:
v = v.strip()
if not (_REPO_RE.match(v) or _GIT_URL_RE.match(v)):
raise ValueError("marketplace_repo must be owner/repo or a git URL")
return v
class ClaudePluginUpdateIn(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN)
marketplace: str | None = Field(
default=None, min_length=1, max_length=64, pattern=_SLUG_PATTERN
)
marketplace_repo: str | None = Field(default=None, min_length=1)
enabled: bool | None = None
@field_validator("marketplace_repo")
@classmethod
def _check_repo(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
if not (_REPO_RE.match(v) or _GIT_URL_RE.match(v)):
raise ValueError("marketplace_repo must be owner/repo or a git URL")
return v
class ClaudePluginOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
marketplace: str
marketplace_repo: str
enabled: bool
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."""
default_mode: PermissionMode | None = None
allow: list[str] = Field(default_factory=list)
deny: list[str] = Field(default_factory=list)
ask: list[str] = Field(default_factory=list)
class ClaudePermissionsOut(ClaudePermissionsIn):
"""Stored overrides plus the env baseline they merge over, so the UI can show the
effective policy without guessing at server config."""
base_mode: str
base_allow: list[str] = Field(default_factory=list)
class SharedContextIn(BaseModel):
value: str
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
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3471:function(e,a,n){Promise.resolve().then(n.t.bind(n,7960,23)),Promise.resolve().then(n.bind(n,5520))},5520:function(e,a,n){"use strict";n.d(a,{AppFrame:function(){return p}});var t=n(7437),s=n(2265),l=n(9376),r=n(171),o=n(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{key:"servers",href:"/servers",label:"Git Servers"},{key:"activity",href:"/activity",label:"Activity"},{key:"shared",href:"/shared",label:"Shared"},{key:"claude",href:"/claude",label:"Claude"}];function c(e){var a,n;let t=e.replace(/\/+$/,"")||"/";return"/login"===t?"claude":null!==(n=null===(a=i.find(e=>e.href===t))||void 0===a?void 0:a.key)&&void 0!==n?n:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},claude:{count:e=>e.claudeSkills.length+e.claudeConnectors.length+e.claudePlugins.length,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:a,children:n}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,s.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var a,n,s;let l=u[e.key],r=null!==(n=null==l?void 0:l.count(d))&&void 0!==n?n:0,i=null!==(s=null==l?void 0:null===(a=l.accent)||void 0===a?void 0:a.call(l,d))&&void 0!==s&&s;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:a,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),n]})]})}function h(e){let{error:a,onSubmit:n}=e,[l,r]=(0,s.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let a=l.trim();a&&n(a)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),a&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:a}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:a}=e,[n,o]=(0,s.useState)(null),[i,u]=(0,s.useState)(""),p=(0,l.usePathname)();(0,s.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let g=(0,s.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),v=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return n?(0,t.jsx)(r._,{token:n,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:v,children:a})}):(0,t.jsx)(h,{error:i,onSubmit:g})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=3471)}),_N_E=e.O()}]);
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{1072:function(e,n,s){Promise.resolve().then(s.t.bind(s,7960,23)),Promise.resolve().then(s.bind(s,5520))},5520:function(e,n,s){"use strict";s.d(n,{AppFrame:function(){return p}});var t=s(7437),a=s(2265),l=s(9376),r=s(171),o=s(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{key:"servers",href:"/servers",label:"Git Servers"},{key:"activity",href:"/activity",label:"Activity"},{key:"shared",href:"/shared",label:"Shared"},{key:"login",href:"/login",label:"Claude Login"}];function c(e){var n,s;let t=e.replace(/\/+$/,"")||"/";return null!==(s=null===(n=i.find(e=>e.href===t))||void 0===n?void 0:n.key)&&void 0!==s?s:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},login:{count:()=>0,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:n,children:s}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,a.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var n,s,a;let l=u[e.key],r=null!==(s=null==l?void 0:l.count(d))&&void 0!==s?s:0,i=null!==(a=null==l?void 0:null===(n=l.accent)||void 0===n?void 0:n.call(l,d))&&void 0!==a&&a;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:n,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),s]})]})}function h(e){let{error:n,onSubmit:s}=e,[l,r]=(0,a.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let n=l.trim();n&&s(n)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),n&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:n}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:n}=e,[s,o]=(0,a.useState)(null),[i,u]=(0,a.useState)(""),p=(0,l.usePathname)();(0,a.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let v=(0,a.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),g=(0,a.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,a.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return s?(0,t.jsx)(r._,{token:s,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:g,children:n})}):(0,t.jsx)(h,{error:i,onSubmit:v})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=1072)}),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[626],{6258:function(e,u,n){Promise.resolve().then(n.bind(n,6374))},6374:function(e,u,n){"use strict";n.r(u),n.d(u,{default:function(){return s}});var t=n(2265),r=n(9376);function s(){let e=(0,r.useRouter)();return(0,t.useEffect)(()=>{e.replace("/claude")},[e]),null}},9376:function(e,u,n){"use strict";var t=n(5475);n.o(t,"usePathname")&&n.d(u,{usePathname:function(){return t.usePathname}}),n.o(t,"useRouter")&&n.d(u,{useRouter:function(){return t.useRouter}})}},function(e){e.O(0,[971,117,744],function(){return e(e.s=6258)}),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{2730:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(2730)}),_N_E=e.O()}]);
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{6994:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(6994)}),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[7839,["171","static/chunks/171-ba6c418b542791c8.js","263","static/chunks/app/activity/page-7be158338498be57.js"],"default",1]
3:I[7839,["171","static/chunks/171-58fbf67bb7636c62.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2506,["171","static/chunks/171-ba6c418b542791c8.js","718","static/chunks/app/agents/page-4ae3a1f91119de97.js"],"default",1]
3:I[2506,["171","static/chunks/171-58fbf67bb7636c62.js","718","static/chunks/app/agents/page-ed5717b0ac0347b8.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2651,["171","static/chunks/171-ba6c418b542791c8.js","163","static/chunks/app/approvals/page-423d906e199db97b.js"],"default",1]
3:I[2651,["171","static/chunks/171-58fbf67bb7636c62.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"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
+8
View File
@@ -0,0 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[6529,["171","static/chunks/171-58fbf67bb7636c62.js","877","static/chunks/app/claude/page-47f8d9b94703c22a.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3807,["171","static/chunks/171-ba6c418b542791c8.js","931","static/chunks/app/page-52e9c359249c3b73.js"],"default",1]
4:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
3:I[3807,["171","static/chunks/171-58fbf67bb7636c62.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
5:I[4707,[],""]
6:I[6423,[],""]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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:["9ucF5j1fWuiDF55MzWzbS",[[["",{"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
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9347,["171","static/chunks/171-ba6c418b542791c8.js","626","static/chunks/app/login/page-2f7fdd148631e2b5.js"],"default",1]
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-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"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-ba6c418b542791c8.js","27","static/chunks/app/repositories/page-5820d64699ff6a87.js"],"default",1]
3:I[3641,["171","static/chunks/171-58fbf67bb7636c62.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[5124,["171","static/chunks/171-ba6c418b542791c8.js","95","static/chunks/app/schedules/page-93cc8b9829ad18f2.js"],"default",1]
3:I[5124,["171","static/chunks/171-58fbf67bb7636c62.js","95","static/chunks/app/schedules/page-db675df274be2677.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[4646,["171","static/chunks/171-ba6c418b542791c8.js","664","static/chunks/app/servers/page-874e20e9ad86bc3f.js"],"default",1]
3:I[4646,["171","static/chunks/171-58fbf67bb7636c62.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9475,["171","static/chunks/171-ba6c418b542791c8.js","856","static/chunks/app/shared/page-e449c1a6ddda32bf.js"],"default",1]
3:I[9475,["171","static/chunks/171-58fbf67bb7636c62.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-01db7d62283ec2f5.js","171","static/chunks/171-ba6c418b542791c8.js","185","static/chunks/app/layout-cdc070e22a7fa32c.js"],"AppFrame"]
0:["ecJZL3f8VZAeTLKfyHRWs",[[["",{"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-58fbf67bb7636c62.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["9ucF5j1fWuiDF55MzWzbS",[[["",{"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
+118
View File
@@ -0,0 +1,118 @@
"""Materialize the web-managed Claude config for a launch: MCP connectors and skills.
The dashboard's Claude page edits database rows; this module is where those rows become
real files the ``claude`` process reads, applied by ``control.spawn`` before every
launch (spawn and resume both), so a change in the web UI reaches the next run of every
agent:
- **Connectors** become ``<working_dir>/.claude/mcp-servers.json`` passed to claude as
``--mcp-config`` (see ``control.headless``), so nothing lands in the managed repo's
tracked tree and a repo's own committed ``.mcp.json`` is never touched.
- **Skills** sync to the user-level ``~/.claude/skills/`` of the worker container that
runs the agent. Each managed skill dir carries a ``.handler-managed`` marker so the
sync can delete skills removed in the UI without ever touching skills someone
installed by hand (or the forge role skills committed into repos by ``skills_gen``).
"""
from __future__ import annotations
import json
import os
import shutil
from sqlalchemy import Connection
from ..db import repository as repo
from ..db.engine import connection
MCP_CONFIG_RELPATH = os.path.join(".claude", "mcp-servers.json")
_MANAGED_MARKER = ".handler-managed"
def mcp_config_path(working_dir: str) -> str:
return os.path.join(working_dir, MCP_CONFIG_RELPATH)
def _server_entry(connector: dict) -> dict:
"""One ``mcpServers`` value in claude's mcp-config shape."""
if connector["transport"] == "stdio":
entry: dict = {"command": connector["command"]}
if connector.get("args"):
entry["args"] = list(connector["args"])
if connector.get("env"):
entry["env"] = dict(connector["env"])
return entry
entry = {"type": connector["transport"], "url": connector["url"]}
if connector.get("headers"):
entry["headers"] = dict(connector["headers"])
return entry
def write_mcp_config(working_dir: str, connectors: list[dict]) -> str | None:
"""Write the launch's ``--mcp-config`` file from the enabled connectors; return its
path, or None (removing any previous one) when no connectors are enabled. The file
lives under ``.claude/`` next to the generated ``settings.json``, and is regenerated
every launch deleting a connector in the UI deletes it here too."""
path = mcp_config_path(working_dir)
if not connectors:
if os.path.exists(path):
os.remove(path)
return None
os.makedirs(os.path.dirname(path), exist_ok=True)
config = {"mcpServers": {c["name"]: _server_entry(c) for c in connectors}}
with open(path, "w") as fh:
json.dump(config, fh, indent=2)
return path
def _skills_root(home: str | None = None) -> str:
return os.path.join(home or os.path.expanduser("~"), ".claude", "skills")
def sync_user_skills(skills: list[dict], home: str | None = None) -> list[str]:
"""Sync the enabled web-managed skills into the user-level skills dir; return the
written SKILL.md paths.
Only dirs carrying the ``.handler-managed`` marker are ever deleted, so hand-installed
skills survive; a managed skill disabled or deleted in the UI disappears on the next
sync. Front-matter ``name``/``description`` come from the row; the body is the
operator's markdown verbatim."""
root = _skills_root(home)
os.makedirs(root, exist_ok=True)
keep = {s["name"] for s in skills}
for entry in os.listdir(root):
dir_path = os.path.join(root, entry)
if entry not in keep and os.path.isfile(os.path.join(dir_path, _MANAGED_MARKER)):
shutil.rmtree(dir_path, ignore_errors=True)
written: list[str] = []
for skill in skills:
skill_dir = os.path.join(root, skill["name"])
os.makedirs(skill_dir, exist_ok=True)
description = (skill.get("description") or skill["name"]).replace("\n", " ")
front = f"---\nname: {skill['name']}\ndescription: {description}\n---\n\n"
body = skill["content"]
if not body.endswith("\n"):
body += "\n"
path = os.path.join(skill_dir, "SKILL.md")
with open(path, "w") as fh:
fh.write(front + body)
with open(os.path.join(skill_dir, _MANAGED_MARKER), "w") as fh:
fh.write("managed by handler — edits here are overwritten at every launch\n")
written.append(path)
return written
def apply(working_dir: str, conn: Connection | None = None) -> dict:
"""Apply the whole web-managed config for one launch; returns a small summary."""
if conn is None:
with connection() as c:
connectors = repo.list_claude_connectors(c, enabled_only=True)
skills = repo.list_claude_skills(c, enabled_only=True)
else:
connectors = repo.list_claude_connectors(conn, enabled_only=True)
skills = repo.list_claude_skills(conn, enabled_only=True)
mcp_path = write_mcp_config(working_dir, connectors)
written = sync_user_skills(skills)
return {"mcp_config": mcp_path, "skills_written": len(written)}
+20 -5
View File
@@ -34,6 +34,7 @@ from pathlib import Path
from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import claude_gen
# Stream types we recognize from ``--output-format stream-json``; anything else (or an
# unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own:
@@ -59,10 +60,13 @@ def session_dir(working_dir: str) -> Path:
return Path(os.path.expanduser("~")) / ".claude" / "projects" / munged_project_dir(working_dir)
def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str]:
def build_spawn_argv(
task: str, settings_path: str, session_id: str, mcp_config: str | None = None
) -> list[str]:
"""The headless spawn invocation. ``--verbose`` is required with stream-json in
print mode; ``--session-id`` pre-assigns the UUID so the session is addressable
(and archivable) from the first event."""
(and archivable) from the first event. ``mcp_config`` is the generated connectors
file (``control.claude_gen``), when the operator has any enabled."""
s = get_settings()
argv = [
s.claude_bin, "-p", "--verbose",
@@ -70,13 +74,17 @@ def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str
"--session-id", session_id,
"--settings", settings_path,
]
if mcp_config:
argv += ["--mcp-config", mcp_config]
if s.run_budget_usd > 0:
argv += ["--max-budget-usd", str(s.run_budget_usd)]
argv += ["--", task]
return argv
def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[str]:
def build_resume_argv(
session_id: str, answer: str, settings_path: str, mcp_config: str | None = None
) -> list[str]:
"""The headless resume invocation — a brand-new process continuing ``session_id``."""
s = get_settings()
argv = [
@@ -85,6 +93,8 @@ def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[
"--resume", session_id,
"--settings", settings_path,
]
if mcp_config:
argv += ["--mcp-config", mcp_config]
if s.run_budget_usd > 0:
argv += ["--max-budget-usd", str(s.run_budget_usd)]
argv += ["--", answer]
@@ -380,14 +390,19 @@ def launch(
owns the process from here.
"""
working_dir = agent["working_dir"]
# The generated connectors file (control.claude_gen) rides along when present; its
# presence on disk is the contract, so the launch seam's signature stays stable.
mcp_config = claude_gen.mcp_config_path(working_dir)
if not os.path.exists(mcp_config):
mcp_config = None
if kind == "spawn":
session_id = str(uuid.uuid4())
argv = build_spawn_argv(prompt, settings_path, session_id)
argv = build_spawn_argv(prompt, settings_path, session_id, mcp_config)
else:
session_id = agent.get("session_id")
if not session_id:
raise ValueError(f"agent '{agent['name']}' has no session to resume")
argv = build_resume_argv(session_id, prompt, settings_path)
argv = build_resume_argv(session_id, prompt, settings_path, mcp_config)
with connection() as conn:
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
repo.set_agent_session(conn, agent["id"], session_id, worker_id)
+63 -8
View File
@@ -4,15 +4,27 @@
This is the declarative half of hook integration; the imperative half the agent
identity and ``DATABASE_URL`` is injected as environment via tmux (see
``control.spawn``), because hook stdin does not carry our identity.
The dashboard's Claude page feeds in here too: the operator's permission overrides and
plugins (``claude_config`` / ``claude_plugins`` rows) are merged over the env-configured
baseline on every generation, so a change in the web UI applies to the next launch of
every agent without a redeploy.
"""
from __future__ import annotations
import json
import os
import re
import sys
from sqlalchemy import Connection
from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
_OWNER_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
def _hook_command(event: str) -> str:
@@ -21,7 +33,15 @@ def _hook_command(event: str) -> str:
return f"{sys.executable} -m handler.hooks {event}"
def build_settings() -> dict:
def _marketplace_source(repo_ref: str) -> dict:
"""The settings-shaped source for a marketplace: ``owner/repo`` is a GitHub source,
anything else is a git URL (the API validates it as one)."""
if _OWNER_REPO_RE.match(repo_ref):
return {"source": "github", "repo": repo_ref}
return {"source": "git", "url": repo_ref}
def build_settings(conn: Connection | None = None) -> dict:
settings = {
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}],
@@ -44,18 +64,53 @@ def build_settings() -> dict:
# project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard
# gate either way, since a hook deny overrides any allow.
s = get_settings()
settings["permissions"] = {
"defaultMode": s.headless_permission_mode,
"allow": s.headless_allowed_tools_list,
}
mode = s.headless_permission_mode
allow = list(s.headless_allowed_tools_list)
deny: list[str] = []
ask: list[str] = []
plugins: list[dict] = []
if conn is not None:
stored = repo.get_claude_config(conn, "permissions") or {}
if stored.get("default_mode"):
mode = stored["default_mode"]
allow += [r for r in stored.get("allow", []) if r not in allow]
deny = list(stored.get("deny", []))
ask = list(stored.get("ask", []))
plugins = repo.list_claude_plugins(conn, enabled_only=True)
permissions: dict = {"defaultMode": mode, "allow": allow}
if deny:
permissions["deny"] = deny
if ask:
permissions["ask"] = ask
settings["permissions"] = permissions
# Web-managed plugins: declaring the marketplace + the enabled plugin makes a
# headless run install both on boot, no interactive `/plugin` flow needed.
if plugins:
settings["extraKnownMarketplaces"] = {
p["marketplace"]: {"source": _marketplace_source(p["marketplace_repo"])}
for p in plugins
}
settings["enabledPlugins"] = {
f"{p['name']}@{p['marketplace']}": True for p in plugins
}
return settings
def write_settings(working_dir: str) -> str:
"""Write ``.claude/settings.json`` under the agent's working dir; return its path."""
def write_settings(working_dir: str, conn: Connection | None = None) -> str:
"""Write ``.claude/settings.json`` under the agent's working dir; return its path.
Opens a short read connection when the caller doesn't hold one — the web-managed
permission overrides and plugins live in the database.
"""
claude_dir = os.path.join(working_dir, ".claude")
os.makedirs(claude_dir, exist_ok=True)
path = os.path.join(claude_dir, "settings.json")
if conn is None:
with connection() as c:
settings = build_settings(c)
else:
settings = build_settings(conn)
with open(path, "w") as fh:
json.dump(build_settings(), fh, indent=2)
json.dump(settings, fh, indent=2)
return path
+5
View File
@@ -15,6 +15,7 @@ from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import (
claude_gen,
credentials,
forge,
gitops,
@@ -142,6 +143,9 @@ def spawn(
)
settings_path = settings_gen.write_settings(working_dir)
# Materialize the web-managed Claude config (MCP connectors + user-level skills)
# so this launch picks up what the operator configured in the dashboard.
claude_gen.apply(working_dir)
env = _agent_env(project, agent, token, role=role, mise_init=mise_init)
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
@@ -249,6 +253,7 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool
working_dir = agent["working_dir"]
settings_path = settings_gen.write_settings(working_dir)
claude_gen.apply(working_dir)
try:
token = None
with connection() as conn:
+203
View File
@@ -27,6 +27,10 @@ from .tables import (
agents,
approvals,
checkmarks,
claude_config,
claude_connectors,
claude_plugins,
claude_skills,
commands,
forge_hosts,
log_entries,
@@ -927,6 +931,205 @@ def get_runtime_secret(conn: Connection, key: str) -> dict | None:
return _row_to_dict(row)
# --------------------------------------------------- claude management (web-managed)
def list_claude_skills(conn: Connection, enabled_only: bool = False) -> list[dict]:
stmt = select(claude_skills)
if enabled_only:
stmt = stmt.where(claude_skills.c.enabled.is_(True))
rows = conn.execute(stmt.order_by(claude_skills.c.name)).all()
return [dict(r._mapping) for r in rows]
def get_claude_skill(conn: Connection, skill_id: int) -> dict | None:
row = conn.execute(select(claude_skills).where(claude_skills.c.id == skill_id)).first()
return _row_to_dict(row)
def get_claude_skill_by_name(conn: Connection, name: str) -> dict | None:
row = conn.execute(select(claude_skills).where(claude_skills.c.name == name)).first()
return _row_to_dict(row)
def create_claude_skill(
conn: Connection,
name: str,
content: str,
description: str | None = None,
enabled: bool = True,
) -> dict:
now = _now()
result = conn.execute(
claude_skills.insert().values(
name=name,
description=description,
content=content,
enabled=enabled,
created_at=now,
updated_at=now,
)
)
return get_claude_skill(conn, result.inserted_primary_key[0])
def update_claude_skill(conn: Connection, skill_id: int, **fields: Any) -> dict | None:
allowed = {"name", "description", "content", "enabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
values["updated_at"] = _now()
conn.execute(
claude_skills.update().where(claude_skills.c.id == skill_id).values(**values)
)
return get_claude_skill(conn, skill_id)
def delete_claude_skill(conn: Connection, skill_id: int) -> bool:
result = conn.execute(claude_skills.delete().where(claude_skills.c.id == skill_id))
return result.rowcount > 0
def list_claude_connectors(conn: Connection, enabled_only: bool = False) -> list[dict]:
stmt = select(claude_connectors)
if enabled_only:
stmt = stmt.where(claude_connectors.c.enabled.is_(True))
rows = conn.execute(stmt.order_by(claude_connectors.c.name)).all()
return [dict(r._mapping) for r in rows]
def get_claude_connector(conn: Connection, connector_id: int) -> dict | None:
row = conn.execute(
select(claude_connectors).where(claude_connectors.c.id == connector_id)
).first()
return _row_to_dict(row)
def get_claude_connector_by_name(conn: Connection, name: str) -> dict | None:
row = conn.execute(
select(claude_connectors).where(claude_connectors.c.name == name)
).first()
return _row_to_dict(row)
def create_claude_connector(
conn: Connection,
name: str,
transport: str,
command: str | None = None,
args: list | None = None,
env: dict | None = None,
url: str | None = None,
headers: dict | None = None,
enabled: bool = True,
) -> dict:
result = conn.execute(
claude_connectors.insert().values(
name=name,
transport=transport,
command=command,
args=args,
env=env,
url=url,
headers=headers,
enabled=enabled,
created_at=_now(),
)
)
return get_claude_connector(conn, result.inserted_primary_key[0])
def update_claude_connector(conn: Connection, connector_id: int, **fields: Any) -> dict | None:
allowed = {"name", "transport", "command", "args", "env", "url", "headers", "enabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(
claude_connectors.update()
.where(claude_connectors.c.id == connector_id)
.values(**values)
)
return get_claude_connector(conn, connector_id)
def delete_claude_connector(conn: Connection, connector_id: int) -> bool:
result = conn.execute(
claude_connectors.delete().where(claude_connectors.c.id == connector_id)
)
return result.rowcount > 0
def list_claude_plugins(conn: Connection, enabled_only: bool = False) -> list[dict]:
stmt = select(claude_plugins)
if enabled_only:
stmt = stmt.where(claude_plugins.c.enabled.is_(True))
rows = conn.execute(
stmt.order_by(claude_plugins.c.marketplace, claude_plugins.c.name)
).all()
return [dict(r._mapping) for r in rows]
def get_claude_plugin(conn: Connection, plugin_id: int) -> dict | None:
row = conn.execute(select(claude_plugins).where(claude_plugins.c.id == plugin_id)).first()
return _row_to_dict(row)
def get_claude_plugin_by_key(conn: Connection, name: str, marketplace: str) -> dict | None:
row = conn.execute(
select(claude_plugins).where(
claude_plugins.c.name == name, claude_plugins.c.marketplace == marketplace
)
).first()
return _row_to_dict(row)
def create_claude_plugin(
conn: Connection,
name: str,
marketplace: str,
marketplace_repo: str,
enabled: bool = True,
) -> dict:
result = conn.execute(
claude_plugins.insert().values(
name=name,
marketplace=marketplace,
marketplace_repo=marketplace_repo,
enabled=enabled,
created_at=_now(),
)
)
return get_claude_plugin(conn, result.inserted_primary_key[0])
def update_claude_plugin(conn: Connection, plugin_id: int, **fields: Any) -> dict | None:
allowed = {"name", "marketplace", "marketplace_repo", "enabled"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(
claude_plugins.update().where(claude_plugins.c.id == plugin_id).values(**values)
)
return get_claude_plugin(conn, plugin_id)
def delete_claude_plugin(conn: Connection, plugin_id: int) -> bool:
result = conn.execute(claude_plugins.delete().where(claude_plugins.c.id == plugin_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()
return row._mapping["value"] if row is not None else None
def set_claude_config(conn: Connection, key: str, value: Any) -> None:
now = _now()
result = conn.execute(
claude_config.update().where(claude_config.c.key == key).values(value=value, updated_at=now)
)
if result.rowcount == 0:
conn.execute(claude_config.insert().values(key=key, value=value, updated_at=now))
def get_latest_claimed_command(conn: Connection, type: str) -> dict | None:
"""The most recent command of a type that some worker has claimed (running or
finished). Login pinning reads login_start's ``claimed_by`` to route login_submit to
+65
View File
@@ -328,6 +328,71 @@ session_archives = Table(
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# ---- Claude management (web-managed). What the operator configures in the dashboard's
# Claude page; the control container applies it to every launch — skills sync to the
# worker's user-level ~/.claude/skills, connectors become the --mcp-config file, and
# plugins/permissions fold into the generated per-agent settings.json (settings_gen).
# Transports an MCP connector can use, mirroring claude's .mcp.json server types.
MCP_TRANSPORTS = ("stdio", "http", "sse")
# Operator-authored Claude Code skills (SKILL.md bodies). Distinct from the forge role
# skills (skills_gen), which are committed into managed repos; these are user-level and
# synced to every worker at launch.
claude_skills = Table(
"claude_skills",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("name", String, nullable=False, unique=True), # slug; becomes the skill dirname
Column("description", String),
Column("content", String, nullable=False), # markdown body below the front-matter
Column("enabled", Boolean, nullable=False, server_default="1"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# MCP servers ("connectors") agents may reach. Written per-launch as an --mcp-config
# file, so nothing lands in the managed repo's tree.
claude_connectors = Table(
"claude_connectors",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("name", String, nullable=False, unique=True), # the mcpServers key
Column("transport", String, nullable=False),
Column("command", String), # stdio: the executable
Column("args", PortableJSON), # stdio: argv list
Column("env", PortableJSON), # stdio: environment map
Column("url", String), # http/sse: the endpoint
Column("headers", PortableJSON), # http/sse: header map (may carry auth)
Column("enabled", Boolean, nullable=False, server_default="1"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
CheckConstraint(_in("transport", MCP_TRANSPORTS), name="ck_claude_connectors_transport"),
)
# Claude Code plugins, pinned to the marketplace that serves them. Folded into generated
# settings as extraKnownMarketplaces + enabledPlugins so headless runs auto-install them.
claude_plugins = Table(
"claude_plugins",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("name", String, nullable=False), # the plugin's name within its marketplace
Column("marketplace", String, nullable=False), # marketplace key, e.g. "acme-tools"
Column("marketplace_repo", String, nullable=False), # "owner/repo" or a git URL
Column("enabled", Boolean, nullable=False, server_default="1"),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
)
# 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.
claude_config = Table(
"claude_config",
metadata,
Column("key", String, primary_key=True),
Column("value", PortableJSON, nullable=False),
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues
# an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity
# audit trail like any other control action). Agent names must be unique per project, so
@@ -0,0 +1,78 @@
"""claude management: skills, connectors, plugins, and permission overrides
Revision ID: 0010_claude_management
Revises: 0009_runtime_secrets
Create Date: 2026-07-23
The dashboard's Claude page becomes a management surface, not just the login flow. The
operator's skills, MCP connectors, plugins, and permission overrides live here; the
control container reads them at every launch skills sync to the worker's user-level
``~/.claude/skills``, connectors become the run's ``--mcp-config`` file, and plugins/
permissions fold into the generated per-agent ``settings.json``.
"""
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 = "0010_claude_management"
down_revision: str | None = "0009_runtime_secrets"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"claude_skills",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(), nullable=False, unique=True),
sa.Column("description", sa.String()),
sa.Column("content", sa.String(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
op.create_table(
"claude_connectors",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(), nullable=False, unique=True),
sa.Column("transport", sa.String(), nullable=False),
sa.Column("command", sa.String()),
sa.Column("args", PortableJSON),
sa.Column("env", PortableJSON),
sa.Column("url", sa.String()),
sa.Column("headers", PortableJSON),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.CheckConstraint(
"transport IN ('stdio', 'http', 'sse')", name="ck_claude_connectors_transport"
),
)
op.create_table(
"claude_plugins",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("marketplace", sa.String(), nullable=False),
sa.Column("marketplace_repo", sa.String(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
)
op.create_table(
"claude_config",
sa.Column("key", sa.String(), primary_key=True),
sa.Column("value", PortableJSON, nullable=False),
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("claude_config")
op.drop_table("claude_plugins")
op.drop_table("claude_connectors")
op.drop_table("claude_skills")
+303
View File
@@ -0,0 +1,303 @@
"""The Claude management surface: /claude API CRUD + admin gating, the generation that
applies it (settings permissions/plugins, the --mcp-config file, user-level skills), and
the spawn integration that ties them together.
Spawns go through the ``fake_launch`` seam; the conftest ``env`` fixture points $HOME at
the per-test tmp dir, so skill syncs never touch a real ``~/.claude``.
"""
from __future__ import annotations
import json
import os
import pytest
from handler.control import claude_gen, headless, settings_gen, spawn
from handler.db import repository as repo
from handler.db.engine import 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']}"}
# --- repository ------------------------------------------------------------------------
def test_repository_skill_crud_and_config_kv(conn):
skill = repo.create_claude_skill(conn, "deploy-notes", "# body", description="d")
assert skill["enabled"] is True
assert repo.get_claude_skill_by_name(conn, "deploy-notes")["id"] == skill["id"]
updated = repo.update_claude_skill(conn, skill["id"], content="# new", enabled=False)
assert updated["content"] == "# new" and updated["enabled"] is False
assert repo.list_claude_skills(conn, enabled_only=True) == []
assert len(repo.list_claude_skills(conn)) == 1
assert repo.delete_claude_skill(conn, skill["id"]) is True
assert repo.get_claude_skill(conn, skill["id"]) is None
assert repo.get_claude_config(conn, "permissions") is None
repo.set_claude_config(conn, "permissions", {"allow": ["Bash(npm *)"]})
repo.set_claude_config(conn, "permissions", {"allow": ["Bash(go *)"]}) # upsert
assert repo.get_claude_config(conn, "permissions") == {"allow": ["Bash(go *)"]}
# --- API CRUD + gating -----------------------------------------------------------------
def test_skill_api_crud(client, auth):
r = client.post(
"/claude/skills",
json={"name": "deploy-notes", "description": "when deploying", "content": "# steps"},
headers=auth,
)
assert r.status_code == 201
sid = r.json()["id"]
# Duplicate name refused.
dup = client.post(
"/claude/skills", json={"name": "deploy-notes", "content": "x"}, headers=auth
)
assert dup.status_code == 409
# Unsafe names (path separators, dot-prefix) never become dirnames.
bad = client.post(
"/claude/skills", json={"name": "../escape", "content": "x"}, headers=auth
)
assert bad.status_code == 422
patched = client.patch(f"/claude/skills/{sid}", json={"enabled": False}, headers=auth)
assert patched.status_code == 200 and patched.json()["enabled"] is False
assert client.get("/claude/skills", headers=auth).json()[0]["name"] == "deploy-notes"
assert client.delete(f"/claude/skills/{sid}", headers=auth).status_code == 200
assert client.get("/claude/skills", headers=auth).json() == []
def test_connector_api_crud_and_validation(client, auth):
# stdio without a command is refused.
bad = client.post(
"/claude/connectors", json={"name": "gh", "transport": "stdio"}, headers=auth
)
assert bad.status_code == 422
# http without an http(s) url is refused.
bad = client.post(
"/claude/connectors",
json={"name": "gh", "transport": "http", "url": "ftp://x"},
headers=auth,
)
assert bad.status_code == 422
r = client.post(
"/claude/connectors",
json={
"name": "github",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "tok"},
},
headers=auth,
)
assert r.status_code == 201
cid = r.json()["id"]
# A PATCH can't strand the row in an invalid pairing (http with no url).
bad = client.patch(f"/claude/connectors/{cid}", json={"transport": "http"}, headers=auth)
assert bad.status_code == 422
ok = client.patch(
f"/claude/connectors/{cid}",
json={"transport": "http", "url": "https://mcp.example.com/mcp"},
headers=auth,
)
assert ok.status_code == 200 and ok.json()["url"] == "https://mcp.example.com/mcp"
assert client.delete(f"/claude/connectors/{cid}", headers=auth).status_code == 200
def test_plugin_api_crud(client, auth):
r = client.post(
"/claude/plugins",
json={"name": "reviewer", "marketplace": "acme", "marketplace_repo": "acme/market"},
headers=auth,
)
assert r.status_code == 201
pid = r.json()["id"]
dup = client.post(
"/claude/plugins",
json={"name": "reviewer", "marketplace": "acme", "marketplace_repo": "acme/market"},
headers=auth,
)
assert dup.status_code == 409
bad = client.post(
"/claude/plugins",
json={"name": "x", "marketplace": "m", "marketplace_repo": "not a repo"},
headers=auth,
)
assert bad.status_code == 422
assert client.delete(f"/claude/plugins/{pid}", headers=auth).status_code == 200
def test_permissions_roundtrip_carries_baseline(client, auth):
base = client.get("/claude/permissions", headers=auth).json()
assert base["default_mode"] is None
assert base["base_mode"] == "acceptEdits"
assert "Bash(git *)" in base["base_allow"]
r = client.put(
"/claude/permissions",
json={"default_mode": "plan", "allow": ["Bash(npm *)", " "], "deny": ["WebFetch"]},
headers=auth,
)
assert r.status_code == 200
saved = client.get("/claude/permissions", headers=auth).json()
assert saved["default_mode"] == "plan"
assert saved["allow"] == ["Bash(npm *)"] # blanks dropped
assert saved["deny"] == ["WebFetch"]
bad = client.put("/claude/permissions", json={"default_mode": "yolo"}, headers=auth)
assert bad.status_code == 422
def test_claude_writes_need_admin(client, auth, lowpriv):
# Reads pass with any valid bearer...
assert client.get("/claude/skills", headers=lowpriv).status_code == 200
assert client.get("/claude/permissions", headers=lowpriv).status_code == 200
# ...writes need the admin token.
r = client.post("/claude/skills", json={"name": "s", "content": "x"}, headers=lowpriv)
assert r.status_code == 403
assert client.put("/claude/permissions", json={}, headers=lowpriv).status_code == 403
r = client.post(
"/claude/connectors",
json={"name": "c", "transport": "stdio", "command": "x"},
headers=lowpriv,
)
assert r.status_code == 403
# --- generation ------------------------------------------------------------------------
def test_settings_merge_db_permissions_and_plugins(env):
with get_engine().begin() as conn:
repo.set_claude_config(
conn,
"permissions",
{
"default_mode": "plan",
"allow": ["Bash(npm *)", "Bash(git *)"], # git already in the baseline
"deny": ["WebFetch"],
"ask": ["Bash(rm *)"],
},
)
repo.create_claude_plugin(conn, "reviewer", "acme", "acme/market")
repo.create_claude_plugin(conn, "linter", "tools", "https://git.corp/m.git")
repo.create_claude_plugin(conn, "off", "acme", "acme/market", enabled=False)
settings = settings_gen.build_settings(conn)
perms = settings["permissions"]
assert perms["defaultMode"] == "plan"
assert perms["allow"].count("Bash(git *)") == 1 # no duplicate from the merge
assert "Bash(npm *)" in perms["allow"]
assert perms["deny"] == ["WebFetch"]
assert perms["ask"] == ["Bash(rm *)"]
markets = settings["extraKnownMarketplaces"]
assert markets["acme"] == {"source": {"source": "github", "repo": "acme/market"}}
assert markets["tools"] == {"source": {"source": "git", "url": "https://git.corp/m.git"}}
assert settings["enabledPlugins"] == {"reviewer@acme": True, "linter@tools": True}
assert "hooks" in settings # the hard gate is untouched
def test_mcp_config_written_and_removed(env, tmp_path):
wd = str(tmp_path / "wd")
connectors = [
{
"name": "github",
"transport": "stdio",
"command": "npx",
"args": ["-y", "server-github"],
"env": {"GITHUB_TOKEN": "tok"},
},
{"name": "docs", "transport": "http", "url": "https://mcp.x/mcp", "headers": {"A": "b"}},
]
path = claude_gen.write_mcp_config(wd, connectors)
data = json.loads(open(path).read())
assert data["mcpServers"]["github"] == {
"command": "npx",
"args": ["-y", "server-github"],
"env": {"GITHUB_TOKEN": "tok"},
}
assert data["mcpServers"]["docs"] == {
"type": "http",
"url": "https://mcp.x/mcp",
"headers": {"A": "b"},
}
# No connectors left => the previously generated file is removed.
assert claude_gen.write_mcp_config(wd, []) is None
assert not os.path.exists(path)
def test_skills_sync_and_managed_cleanup(env, tmp_path):
home = str(tmp_path / "home")
# A hand-installed skill (no marker) must survive every sync.
manual = tmp_path / "home" / ".claude" / "skills" / "hand-made"
manual.mkdir(parents=True)
(manual / "SKILL.md").write_text("mine")
written = claude_gen.sync_user_skills(
[{"name": "deploy", "description": "when deploying", "content": "# steps"}], home=home
)
text = open(written[0]).read()
assert text.startswith("---\nname: deploy\ndescription: when deploying\n---\n")
assert "# steps" in text
# The managed skill disappears when no longer enabled; the manual one stays.
claude_gen.sync_user_skills([], home=home)
assert not os.path.exists(os.path.dirname(written[0]))
assert (manual / "SKILL.md").read_text() == "mine"
def test_argv_carries_mcp_config():
argv = headless.build_spawn_argv("task", "/s.json", "sid", "/wd/.claude/mcp-servers.json")
i = argv.index("--mcp-config")
assert argv[i + 1] == "/wd/.claude/mcp-servers.json"
assert "--mcp-config" not in headless.build_spawn_argv("task", "/s.json", "sid")
argv = headless.build_resume_argv("sid", "answer", "/s.json", "/m.json")
assert argv[argv.index("--mcp-config") + 1] == "/m.json"
# --- spawn integration -----------------------------------------------------------------
def test_spawn_applies_managed_config(env, fake_launch):
root = env["tmp"] / "proj"
root.mkdir(parents=True, exist_ok=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
repo.create_claude_connector(conn, "github", "stdio", command="npx")
repo.create_claude_connector(conn, "off", "stdio", command="x", enabled=False)
repo.create_claude_skill(conn, "deploy", "# steps")
repo.set_claude_config(conn, "permissions", {"default_mode": "plan"})
agent = spawn.spawn("proj", "api", task="do it")
wd = agent["working_dir"]
# Disabled connectors are excluded from the generated --mcp-config file.
mcp = json.loads(open(claude_gen.mcp_config_path(wd)).read())
assert list(mcp["mcpServers"]) == ["github"]
# Skills synced to the (test-scoped) user-level dir.
skill = os.path.join(str(env["tmp"]), ".claude", "skills", "deploy", "SKILL.md")
assert os.path.exists(skill)
# The generated settings carry the DB permission override.
settings = json.loads(open(os.path.join(wd, ".claude", "settings.json")).read())
assert settings["permissions"]["defaultMode"] == "plan"