Add web terminal, git credentials, OpenSCAD, review loops, automations

- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { post } from "../../api";
import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
export function Account() {
const t = useT();
@@ -21,6 +22,7 @@ export function Account() {
};
return (
<>
<div className="panel">
<h3>
{t("account.title")}: {me?.username}
@@ -43,5 +45,7 @@ export function Account() {
</button>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<GitAccount />
</>
);
}
+41 -3
View File
@@ -1,5 +1,6 @@
import { Icon } from "../Icon";
import { useState } from "react";
import { patch } from "../../api";
import { del, patch } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
@@ -22,6 +23,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
tools: (raw?.tools ?? []).join(", "),
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 ?? "",
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
@@ -31,6 +35,13 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{ 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 } : null,
});
}
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
@@ -45,6 +56,16 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
}
};
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 {
@@ -63,6 +84,11 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<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>
@@ -72,6 +98,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<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 && (
<>
@@ -85,6 +112,17 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<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>{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>}
@@ -107,9 +145,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{t("common.save")}
</button>
<span className="spacer" />
{a.source === "config" && (
{a.source === "config" && builtin && (
<button className="danger" onClick={reset}>
{builtin ? t("agents.reset") : t("common.delete")}
{t("agents.reset")}
</button>
)}
</div>
@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Cred {
host: string;
username: string;
token: string;
protocol?: string;
}
interface GitSettings {
name: string;
email: string;
credentials: Cred[];
}
/** Git identity and credentials for agents, terminal and git client (per user). */
export function GitAccount() {
const t = useT();
const [s, setS] = useState<GitSettings>();
const [saving, setSaving] = useState(false);
useEffect(() => {
get<GitSettings>("/account/git")
.then((v) => setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] }))
.catch((e) => toast(e.message));
}, []);
if (!s) return null;
const setCred = (i: number, patch: Partial<Cred>) => setS({ ...s, credentials: s.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
const save = async () => {
setSaving(true);
try {
const v = await put<GitSettings>("/account/git", s);
setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] });
toast(t("gitcred.saved"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="panel">
<h3>
<Icon name="fork" /> {t("gitcred.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("gitcred.hint")}
</div>
<div className="form">
<label>{t("gitcred.name")}</label>
<input value={s.name} onChange={(e) => setS({ ...s, name: e.target.value })} placeholder="Jane Doe" autoComplete="name" />
<label>{t("gitcred.email")}</label>
<input value={s.email} onChange={(e) => setS({ ...s, email: e.target.value })} placeholder="jane@example.com" autoComplete="email" />
</div>
<h4 style={{ margin: "16px 0 8px" }}>{t("gitcred.credentials")}</h4>
<div className="gitcred-list">
{s.credentials.length === 0 && <div className="empty" style={{ padding: 8 }}>{t("gitcred.none")}</div>}
{s.credentials.map((c, i) => (
<div key={i} className="gitcred-row">
<input value={c.host} placeholder="git.example.com" onChange={(e) => setCred(i, { host: e.target.value })} aria-label={t("gitcred.host")} />
<input value={c.username} placeholder={t("gitcred.user")} onChange={(e) => setCred(i, { username: e.target.value })} aria-label={t("gitcred.user")} autoComplete="off" />
<input
type="password"
value={c.token}
placeholder={t("gitcred.token")}
onFocus={(e) => c.token.startsWith("•") && e.target.select()}
onChange={(e) => setCred(i, { token: e.target.value })}
aria-label={t("gitcred.token")}
autoComplete="new-password"
/>
<button className="ghost small" onClick={() => setS({ ...s, credentials: s.credentials.filter((_, j) => j !== i) })} aria-label={t("common.remove")}>
<Icon name="trash" />
</button>
</div>
))}
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
<button className="small" onClick={() => setS({ ...s, credentials: [...s.credentials, { host: "", username: "", token: "" }] })}>
<Icon name="plus" /> {t("gitcred.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save} disabled={saving}>
{t("common.save")}
</button>
</div>
</div>
);
}