Files
codecraft/web/src/components/settings/AgentsEditor.tsx
T
Richard ZinkandClaude Opus 5.5 e7d4b9ff76 Reverse engineering tools per mode; raw firmware in decompile; skill
- AgentConfig.reverse_tools (Settings -> Agents & commands) gives any mode
  binary_info and decompile; the clean-room check of the tools applies to
  craft only (ToolCtx.clean_room = clean room && craft).
- decompile: optional processor (Ghidra language id) and base_address for raw
  images (BinaryLoader); separate cache per processor/address.
- Skill "reverse-engineering": identify, convert HEX, processor table for
  common chips, decompile, explore, notes in analysis/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:47:05 +01:00

300 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "../Icon";
import { useState } from "react";
import { del, patch } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
import { ModelSelect } from "../ModelSelect";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
type Patch = { path: string[]; value: unknown };
export interface ToolInfo {
name: string;
read_only: boolean;
description: string;
}
/** Tool checkboxes: nothing checked = all tools. */
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
const t = useT();
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
const extra = value.filter((v) => !tools.some((x) => x.name === v));
return (
<div className="tool-picker">
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
<div className="tool-grid">
{tools.map((x) => (
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
<span className="mono">{x.name}</span>
</label>
))}
{extra.map((n) => (
<label key={n} className="tool-item">
<input type="checkbox" checked onChange={() => toggle(n)} />
<span className="mono">{n}</span>
</label>
))}
</div>
</div>
);
}
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
documentation, summaries, step-by-step guides and translations.
Return only the finished text.`;
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
const t = useT();
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
const [form, setForm] = useState({
model: a.model ?? "",
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
description: raw?.description ?? "",
tools: (raw?.tools ?? []) as string[],
read_only: !!(raw?.read_only ?? a.read_only),
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode,
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
const save = async () => {
const base = ["agents", a.name];
const patches: Patch[] = [
{ path: [...base, "model"], value: form.model || null },
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
];
if (a.mode === "primary") {
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
patches.push({
path: [...base, "refine"],
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
});
}
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
}
try {
await patch("/config", { patches });
onSaved(true, t("settings.saved"));
} catch (e: any) {
onSaved(false, e.message);
}
};
const removeFile = async () => {
if (!(await confirmDialog({ message: t("agents.deleteFile", { path: a.path ?? a.name }), danger: true }))) return;
try {
await del(`/agents/${encodeURIComponent(a.name)}`);
onSaved(true, t("settings.saved"));
} catch (e: any) {
onSaved(false, e.message);
}
};
const reset = async () => {
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
try {
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
onSaved(true, t("settings.saved"));
} catch (e: any) {
onSaved(false, e.message);
}
};
return (
<div className="panel">
<h3>
<code>{a.name}</code>
<span className="chip">{a.mode === "primary" ? t("agents.modePrimary") : t("agents.modeSubagent")}</span>
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
<span className="spacer" />
{!builtin && (a.source === "config" || a.source === "file") && (
<button className="small ghost danger" onClick={a.source === "file" ? removeFile : reset} title={t("common.delete")} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
)}
<button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")}
</button>
</h3>
<div style={{ fontSize: 13, color: "var(--fg3)" }}>{a.description}</div>
<div className="row" style={{ marginTop: 8 }}>
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
<span className="chip">{a.model || t("agents.sessionModel")}</span>
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
{a.refine?.rounds ? <span className="chip">{t("agents.refineShort", { n: a.refine.rounds })}</span> : null}
</div>
{open && (
<>
<div className="form" style={{ marginTop: 12 }}>
<label>{t("agents.model")}</label>
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
<div className="hint">{t("agents.modelHint")}</div>
{a.mode !== "primary" && (
<>
<label>{t("agents.maxParallel")}</label>
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
</>
)}
{a.mode === "primary" && (
<>
<label>{t("agents.refineRounds")}</label>
<input type="number" min={0} max={10} value={form.refine_rounds} placeholder={t("agents.refineOff")} onChange={(e) => setForm({ ...form, refine_rounds: e.target.value })} />
<label>{t("agents.refineModel")}</label>
<ModelSelect value={form.refine_model} onChange={(v) => setForm({ ...form, refine_model: v })} emptyLabel={t("agents.sessionModel")} />
<label>{t("agents.refineCriteria")}</label>
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
<div className="hint">{t("agents.refineCriteriaHint")}</div>
<label className="check">
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
{t("agents.refineAfterTodos")}
</label>
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
</>
)}
{a.name !== "craft" && (
<>
<label className="check">
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
{t("agents.reverseTools")}
</label>
<div className="hint">{t("agents.reverseToolsHint")}</div>
</>
)}
<label>{t("agents.description")}</label>
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
{!builtin && (
<>
<label>{t("agents.mode")}</label>
<select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })}>
<option value="subagent">{t("agents.modeSubagent")}</option>
<option value="primary">{t("agents.modePrimary")}</option>
</select>
{form.mode === "primary" && (
<>
<label>{t("agents.style")}</label>
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
<option value="coding">{t("agents.styleCoding")}</option>
<option value="chat">{t("agents.styleChat")}</option>
</select>
<div className="hint">{t("agents.styleHint")}</div>
</>
)}
<label>{t("agents.readOnlyLabel")}</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
<span className="hint">{t("agents.readOnlyHint")}</span>
</label>
<label>{t("agents.tools")}</label>
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
<label>{t("agents.prompt")}</label>
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
</>
)}
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
<span className="spacer" />
{a.source === "config" && builtin && (
<button className="danger" onClick={reset}>
{t("agents.reset")}
</button>
)}
</div>
</>
)}
</div>
);
}
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
const t = useT();
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
// Personal modes are managed under "My modes".
const agents = all?.filter((a) => a.source !== "personal");
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
const [name, setName] = useState("");
const saved = (ok: boolean, text: string) => {
setNotice({ ok, text });
if (ok) {
reload();
reloadConfig();
}
};
const add = async (template: "writer" | "blank" | "mode") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value =
template === "writer"
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
: template === "mode"
? { mode: "primary" }
: { mode: "subagent", description: "", prompt: "" };
try {
await patch("/config", { patches: [{ path: ["agents", n], value }] });
setName("");
saved(true, t("settings.saved"));
} catch (e: any) {
saved(false, e.message);
}
};
const key = (a: AgentInfo) => a.name + JSON.stringify(rawAgents[a.name] ?? {}) + (a.model ?? "");
return (
<>
<div className="notice">{t("agents.intro")}</div>
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<div className="panel">
<h3>{t("agents.add")}</h3>
<div className="row">
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
+ {t("agents.addMode")}
</button>
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
<button onClick={() => add("blank")} disabled={!name.trim()}>
+ {t("agents.addBlank")}
</button>
</div>
</div>
<div className="two-col">
<div className="panel">
<h3>{t("agents.commands")}</h3>
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
</div>
<div className="panel">
<h3>{t("agents.skills")}</h3>
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
</div>
</div>
</>
);
}