mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 05:26:23 +00:00
Add local model backends: per-spawn dropdown pointing claude at alternative endpoints
Operators can register Anthropic-API-compatible endpoints (a local Qwen/Llama behind LiteLLM or claude-code-router, an LLM gateway) on the dashboard's Claude -> Models tab and pick one from a Model dropdown when spawning an agent. The agent still launches as the same claude binary with the same hooks, skills, connectors, plugins, and gates — only its ANTHROPIC_BASE_URL / ANTHROPIC_MODEL / ANTHROPIC_AUTH_TOKEN env differs — and it stays pinned to its backend across resumes. No selection keeps the worker's Claude subscription untouched. - claude_models table (+ agents.model_id pin), migration 0012 - control.models resolves a row into the launch env (API keys Fernet-encrypted at rest, decrypted only in the control container; placeholder key when none is stored so the subscription OAuth token never reaches a local endpoint) - /claude/models CRUD (admin-gated writes, key never returned), spawn route + worker + CLI (--model) pass the selection through, fail-fast on missing or disabled backends - dashboard: Models tab, spawn-form dropdown, model badge in the agents table - docs/local-models.md: why bare OpenAI-compatible servers break tool calling with Qwen-Coder, and working vLLM/LiteLLM/llama.cpp stacks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu
This commit is contained in:
@@ -25,6 +25,7 @@ const emptySpawn = {
|
||||
worktree: "",
|
||||
subdir: "",
|
||||
task: "",
|
||||
model_id: "",
|
||||
};
|
||||
|
||||
export function AgentsSection() {
|
||||
@@ -35,6 +36,22 @@ export function AgentsSection() {
|
||||
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
|
||||
[s.projects],
|
||||
);
|
||||
/* The seamless switch: Claude subscription by default, plus every enabled backend
|
||||
* registered on the Claude page's Models tab. Same binary, hooks, and skills either
|
||||
* way — only the ANTHROPIC_* env of the launched process differs. */
|
||||
const modelOpts = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "Claude (subscription)" },
|
||||
...s.claudeModels
|
||||
.filter((m) => m.enabled)
|
||||
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
|
||||
],
|
||||
[s.claudeModels],
|
||||
);
|
||||
const modelName = useMemo(() => {
|
||||
const byId = new Map(s.claudeModels.map((m) => [m.id, m.name]));
|
||||
return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`);
|
||||
}, [s.claudeModels]);
|
||||
const agents = useMemo(
|
||||
() => s.agents.filter((a) => a.project_id === s.selectedProjectId),
|
||||
[s.agents, s.selectedProjectId],
|
||||
@@ -87,6 +104,12 @@ export function AgentsSection() {
|
||||
) : (
|
||||
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
|
||||
)}
|
||||
<Select
|
||||
label="Model"
|
||||
value={form.model_id}
|
||||
onChange={(v) => setForm({ ...form, model_id: v })}
|
||||
options={modelOpts}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt14">
|
||||
<Textarea
|
||||
@@ -113,6 +136,7 @@ export function AgentsSection() {
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Working dir</th>
|
||||
<th>Created</th>
|
||||
@@ -125,6 +149,13 @@ export function AgentsSection() {
|
||||
<tr>
|
||||
<td className="mono">{a.name}</td>
|
||||
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
|
||||
<td>
|
||||
{a.model_id != null ? (
|
||||
<Badge tone="warning">{modelName(a.model_id)}</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">claude</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={a.status} />
|
||||
</td>
|
||||
@@ -146,7 +177,7 @@ export function AgentsSection() {
|
||||
</tr>
|
||||
{(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ paddingTop: 0 }}>
|
||||
<td colSpan={7} style={{ paddingTop: 0 }}>
|
||||
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
|
||||
{a.status === "crashed" ? "last output before crash" : "live output"}
|
||||
{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import type { ConnectorBody, PluginBody, SkillBody } from "@/components/store";
|
||||
import type { ConnectorBody, ModelBody, PluginBody, SkillBody } from "@/components/store";
|
||||
import { Badge, Button, Card, Input, Select, Tabs, Textarea, Toggle } from "@/components/ui";
|
||||
import type { ClaudeConnector, ClaudePlugin, ClaudeSkill } from "@/lib/api";
|
||||
import type { ClaudeConnector, ClaudeModel, ClaudePlugin, ClaudeSkill } from "@/lib/api";
|
||||
import { ClaudeLoginPanel } from "@/components/sections/LoginSection";
|
||||
|
||||
/* KEY=VALUE-per-line <-> map helpers for connector env/headers. */
|
||||
@@ -499,6 +499,165 @@ function PluginsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Model backends ---------------------------------------------------------------- */
|
||||
|
||||
const emptyModel = {
|
||||
name: "",
|
||||
base_url: "",
|
||||
model: "",
|
||||
small_fast_model: "",
|
||||
api_key: "",
|
||||
env: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function ModelsPanel() {
|
||||
const s = useDashboard();
|
||||
const [form, setForm] = useState(emptyModel);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setForm(emptyModel);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const body: ModelBody = {
|
||||
name: form.name,
|
||||
base_url: form.base_url.trim(),
|
||||
model: form.model.trim(),
|
||||
small_fast_model: form.small_fast_model.trim() || null,
|
||||
api_key: form.api_key.trim() || null,
|
||||
env: parseKeyValues(form.env),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
const ok =
|
||||
editingId != null
|
||||
? await s.updateClaudeModel(editingId, body)
|
||||
: await s.createClaudeModel(body);
|
||||
if (ok) reset();
|
||||
};
|
||||
|
||||
const edit = (m: ClaudeModel) => {
|
||||
setForm({
|
||||
name: m.name,
|
||||
base_url: m.base_url,
|
||||
model: m.model,
|
||||
small_fast_model: m.small_fast_model ?? "",
|
||||
api_key: "", // write-only; blank = keep the stored key
|
||||
env: formatKeyValues(m.env),
|
||||
enabled: m.enabled,
|
||||
});
|
||||
setEditingId(m.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
||||
Alternative model backends the spawn dropdown offers next to the Claude
|
||||
subscription — the same <span className="mono">claude</span> binary pointed at a
|
||||
different endpoint via <span className="mono">ANTHROPIC_BASE_URL</span>, so
|
||||
skills, connectors, hooks, and gates apply unchanged. The endpoint must speak the{" "}
|
||||
<b>Anthropic Messages API including tool use</b> — a bare OpenAI-compatible
|
||||
server (Ollama, llama.cpp, LM Studio) breaks tool calling; front it with LiteLLM
|
||||
or claude-code-router and enable the backend's native tool parser. See{" "}
|
||||
<span className="mono">docs/local-models.md</span> for working Qwen-Coder stacks.
|
||||
</div>
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
{editingId != null ? `Edit model · ${form.name}` : "Add a model backend"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input
|
||||
label="Name (what the spawn dropdown shows)"
|
||||
value={form.name}
|
||||
onChange={(v) => setForm({ ...form, name: v })}
|
||||
placeholder="qwen3-coder"
|
||||
/>
|
||||
<Input
|
||||
label="Base URL (Anthropic-compatible /v1/messages endpoint)"
|
||||
value={form.base_url}
|
||||
onChange={(v) => setForm({ ...form, base_url: v })}
|
||||
placeholder="http://llm.lan:4000"
|
||||
/>
|
||||
<Input
|
||||
label="Model id (as the endpoint serves it)"
|
||||
value={form.model}
|
||||
onChange={(v) => setForm({ ...form, model: v })}
|
||||
placeholder="qwen3-coder-30b"
|
||||
/>
|
||||
<Input
|
||||
label="Small/fast model id (optional — defaults to the main model)"
|
||||
value={form.small_fast_model}
|
||||
onChange={(v) => setForm({ ...form, small_fast_model: v })}
|
||||
placeholder="qwen3-1.7b"
|
||||
/>
|
||||
<Input
|
||||
label={editingId != null ? "API key (blank = keep stored key)" : "API key (optional)"}
|
||||
value={form.api_key}
|
||||
onChange={(v) => setForm({ ...form, api_key: v })}
|
||||
placeholder="sk-… (encrypted at rest, never shown again)"
|
||||
/>
|
||||
<Textarea
|
||||
label="Extra env overrides (KEY=VALUE per line, optional)"
|
||||
value={form.env}
|
||||
onChange={(v) => setForm({ ...form, env: v })}
|
||||
rows={3}
|
||||
placeholder={"API_TIMEOUT_MS=600000\nCLAUDE_CODE_MAX_OUTPUT_TOKENS=8192"}
|
||||
/>
|
||||
</div>
|
||||
<div className="hstack mt14">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!form.name.trim() || !form.base_url.trim() || !form.model.trim()}
|
||||
onClick={save}
|
||||
>
|
||||
{editingId != null ? "Save changes" : "Add model"}
|
||||
</Button>
|
||||
{editingId != null && (
|
||||
<Button variant="ghost" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{s.claudeModels.length === 0 && (
|
||||
<div className="empty">No model backends yet — agents run on the Claude subscription.</div>
|
||||
)}
|
||||
|
||||
{s.claudeModels.map((m) => (
|
||||
<Card key={m.id}>
|
||||
<div className="card-head">
|
||||
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
||||
{m.name}
|
||||
</span>
|
||||
<div className="hstack">
|
||||
{m.has_api_key && <Badge tone="info">key stored</Badge>}
|
||||
<Badge tone={m.enabled ? "success" : "neutral"}>
|
||||
{m.enabled ? "enabled" : "disabled"}
|
||||
</Badge>
|
||||
<Toggle on={m.enabled} onClick={() => s.updateClaudeModel(m.id, { enabled: !m.enabled })} />
|
||||
<Button size="sm" variant="secondary" onClick={() => edit(m)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => s.deleteClaudeModel(m.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
||||
{m.model}
|
||||
{m.small_fast_model ? ` (fast: ${m.small_fast_model})` : ""} @ {m.base_url}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Permissions ------------------------------------------------------------------- */
|
||||
|
||||
const MODE_OPTS = [
|
||||
@@ -594,6 +753,7 @@ function PermissionsPanel() {
|
||||
|
||||
const TABS = [
|
||||
{ value: "account", label: "Account" },
|
||||
{ value: "models", label: "Models" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "connectors", label: "Connectors" },
|
||||
{ value: "plugins", label: "Plugins" },
|
||||
@@ -608,9 +768,9 @@ export function ClaudeSection() {
|
||||
<div className="section-head">
|
||||
<div className="section-title">Claude</div>
|
||||
<div className="section-desc">
|
||||
Manage the Claude Code install agents run on: the account login, plus skills,
|
||||
MCP connectors, plugins, and permissions. Changes apply to the next launch of
|
||||
every agent.
|
||||
Manage the Claude Code install agents run on: the account login, alternative
|
||||
model backends, plus skills, MCP connectors, plugins, and permissions. Changes
|
||||
apply to the next launch of every agent.
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
@@ -618,6 +778,7 @@ export function ClaudeSection() {
|
||||
<Tabs tabs={TABS} value={tab} onChange={setTab} />
|
||||
</div>
|
||||
{tab === "account" && <ClaudeLoginPanel />}
|
||||
{tab === "models" && <ModelsPanel />}
|
||||
{tab === "skills" && <SkillsPanel />}
|
||||
{tab === "connectors" && <ConnectorsPanel />}
|
||||
{tab === "plugins" && <PluginsPanel />}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type Approval,
|
||||
type Checkmark,
|
||||
type ClaudeConnector,
|
||||
type ClaudeModel,
|
||||
type ClaudePermissions,
|
||||
type ClaudePlugin,
|
||||
type ClaudeSkill,
|
||||
@@ -146,6 +147,12 @@ interface StoreValue {
|
||||
updateClaudePlugin: (id: number, b: Partial<PluginBody>) => Promise<boolean>;
|
||||
deleteClaudePlugin: (id: number) => Promise<void>;
|
||||
saveClaudePermissions: (b: PermissionsBody) => Promise<boolean>;
|
||||
|
||||
/* Model backends (Claude page Models tab + the spawn form's model dropdown). */
|
||||
claudeModels: ClaudeModel[];
|
||||
createClaudeModel: (b: ModelBody) => Promise<boolean>;
|
||||
updateClaudeModel: (id: number, b: Partial<ModelBody>) => Promise<boolean>;
|
||||
deleteClaudeModel: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SkillBody {
|
||||
@@ -177,6 +184,18 @@ export interface PermissionsBody {
|
||||
ask: string[];
|
||||
}
|
||||
|
||||
export interface ModelBody {
|
||||
name: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
small_fast_model: string | null;
|
||||
/* Write-only: encrypted at rest server-side, never echoed back. Null = no change. */
|
||||
api_key: string | null;
|
||||
clear_api_key?: boolean;
|
||||
env: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SpawnBody {
|
||||
name: string;
|
||||
role: string;
|
||||
@@ -184,6 +203,8 @@ export interface SpawnBody {
|
||||
worktree: string;
|
||||
subdir: string;
|
||||
task: string;
|
||||
/* Model backend id as a select value; "" = the Claude subscription. */
|
||||
model_id: string;
|
||||
}
|
||||
export interface ProjectBody {
|
||||
id: string;
|
||||
@@ -291,6 +312,7 @@ export function DashboardProvider({
|
||||
const [claudeConnectors, setClaudeConnectors] = useState<ClaudeConnector[]>([]);
|
||||
const [claudePlugins, setClaudePlugins] = useState<ClaudePlugin[]>([]);
|
||||
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissions | null>(null);
|
||||
const [claudeModels, setClaudeModels] = useState<ClaudeModel[]>([]);
|
||||
|
||||
// Keep polling loop reading fresh values without re-subscribing every render.
|
||||
const sectionRef = useRef(section);
|
||||
@@ -429,6 +451,16 @@ export function DashboardProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Models load separately from the rest of the Claude page: the Agents section's spawn
|
||||
* dropdown needs them too, without dragging skills/connectors along. */
|
||||
const loadClaudeModels = useCallback(async () => {
|
||||
try {
|
||||
setClaudeModels(await clientRef.current.api<ClaudeModel[]>("/claude/models"));
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadClaude = useCallback(async () => {
|
||||
try {
|
||||
const [skills, connectors, plugins, permissions] = await Promise.all([
|
||||
@@ -441,10 +473,11 @@ export function DashboardProvider({
|
||||
setClaudeConnectors(connectors);
|
||||
setClaudePlugins(plugins);
|
||||
setClaudePermissions(permissions);
|
||||
await loadClaudeModels();
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
}, [loadClaudeModels]);
|
||||
|
||||
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
|
||||
* so the nav counts and inbox stay live). */
|
||||
@@ -463,13 +496,14 @@ export function DashboardProvider({
|
||||
const s = sectionRef.current;
|
||||
const run = selectedRunRef.current;
|
||||
if (run) await loadRun(run.projectId, run.name);
|
||||
if (s === "agents") await loadClaudeModels(); // the spawn form's model dropdown
|
||||
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
|
||||
if (s === "servers") await loadHosts();
|
||||
if (s === "activity") await loadCommands();
|
||||
if (s === "schedules") await loadSchedules();
|
||||
if (s === "shared") await loadShared();
|
||||
if (s === "claude") await loadClaude();
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude]);
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels]);
|
||||
|
||||
// Initial load + polling loop. The first tick populates projects *and* agents (and the
|
||||
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
|
||||
@@ -493,6 +527,7 @@ export function DashboardProvider({
|
||||
(s: Section) => {
|
||||
setSectionRaw(s);
|
||||
setCmd({ text: "", error: false, busy: false });
|
||||
if (s === "agents") void loadClaudeModels();
|
||||
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
|
||||
if (s === "servers") void loadHosts();
|
||||
if (s === "activity") void loadCommands();
|
||||
@@ -500,7 +535,7 @@ export function DashboardProvider({
|
||||
if (s === "shared") void loadShared();
|
||||
if (s === "claude") void loadClaude();
|
||||
},
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude],
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels],
|
||||
);
|
||||
|
||||
const selectProject = useCallback(
|
||||
@@ -584,6 +619,7 @@ export function DashboardProvider({
|
||||
};
|
||||
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
|
||||
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
|
||||
if (f.model_id) body.model_id = Number(f.model_id);
|
||||
const p = encodeURIComponent(selectedProjectRef.current);
|
||||
const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
|
||||
await loadAgents(projects);
|
||||
@@ -1190,6 +1226,38 @@ export function DashboardProvider({
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const createClaudeModel = useCallback(
|
||||
(b: ModelBody) =>
|
||||
claudeWrite(
|
||||
() =>
|
||||
clientRef.current.api("/claude/models", {
|
||||
method: "POST",
|
||||
body: { ...b, name: b.name.trim() },
|
||||
}),
|
||||
`model '${b.name.trim()}' saved — pick it from the spawn dropdown`,
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const updateClaudeModel = useCallback(
|
||||
(id: number, b: Partial<ModelBody>) =>
|
||||
claudeWrite(
|
||||
() => clientRef.current.api(`/claude/models/${id}`, { method: "PATCH", body: b }),
|
||||
"model updated — applies to the next agent launch",
|
||||
),
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const deleteClaudeModel = useCallback(
|
||||
async (id: number) => {
|
||||
await claudeWrite(
|
||||
() => clientRef.current.api(`/claude/models/${id}`, { method: "DELETE" }),
|
||||
"model removed — agents pinned to it can no longer resume",
|
||||
);
|
||||
},
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const setSharedKey = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
try {
|
||||
@@ -1269,6 +1337,10 @@ export function DashboardProvider({
|
||||
updateClaudePlugin,
|
||||
deleteClaudePlugin,
|
||||
saveClaudePermissions,
|
||||
claudeModels,
|
||||
createClaudeModel,
|
||||
updateClaudeModel,
|
||||
deleteClaudeModel,
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface Agent {
|
||||
working_dir: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
/* Model backend the agent is pinned to (see ClaudeModel); null = the Claude
|
||||
* subscription the worker is logged in to. */
|
||||
model_id?: number | null;
|
||||
/* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
|
||||
* latest assistant text for headless runs. For a crashed agent this is the evidence
|
||||
* frame — the last thing the process said. */
|
||||
@@ -179,6 +182,21 @@ export interface ClaudePlugin {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* A registered model backend: an Anthropic-API-compatible endpoint (a local model
|
||||
* behind LiteLLM / claude-code-router, an LLM gateway) the spawn dropdown offers next
|
||||
* to the Claude subscription. The API key is write-only server-side (has_api_key only). */
|
||||
export interface ClaudeModel {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
small_fast_model?: string | null;
|
||||
env?: Record<string, string> | null;
|
||||
enabled: boolean;
|
||||
has_api_key: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* Stored overrides + the env baseline they merge over at launch (read-only here). */
|
||||
export interface ClaudePermissions {
|
||||
default_mode?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user