Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+30 -57
View File
@@ -12,8 +12,12 @@ import { ShareDialog } from "./ShareDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
import { Markdown } from "./Markdown";
import { ImageRefs, Markdown } from "./Markdown";
import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -53,7 +57,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
);
}
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
return (
<>
{parts.map((p, i) => {
@@ -61,13 +65,14 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
case "text":
return (
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
<Markdown text={p.text} />
<Markdown text={p.text} baseDir={baseDir} />
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
</div>
);
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
default:
return null;
}
@@ -102,46 +107,6 @@ function BackgroundResult({ text }: { text: string }) {
);
}
function StatsPanel({ stats }: { stats: SessionStats }) {
const t = useT();
const a = stats.total;
return (
<div className="panel" style={{ margin: "8px 0" }}>
<table className="grid">
<thead>
<tr>
<th>{t("stats.model")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.input")}</th>
<th className="num">{t("stats.cacheRead")}</th>
<th className="num">{t("stats.output")}</th>
<th className="num">{t("stats.ttft")}</th>
<th className="num">{t("stats.outputSpeed")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
<tr key={r.model}>
<td>{r.model}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
</div>
</div>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
@@ -195,7 +160,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
try {
await patch(`/sessions/${sessionId}`, p);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -210,20 +175,21 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
setText("");
stick.current = true;
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const rewind = async (m: Message) => {
if (!confirm(t("chat.rewindConfirm"))) return;
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m);
const rewindDone = async () => {
const m = rewinding!;
setRewinding(undefined);
await loadSession(sessionId);
setText(txt);
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
};
const remove = async () => {
if (!confirm(t("chat.deleteConfirm"))) return;
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
@@ -237,6 +203,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const todos = Array.isArray(session.todos) ? session.todos : [];
const openTodos = todos.filter((x) => x.status !== "completed");
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
const lastMsg = messages[messages.length - 1];
const pendingCall = lastIsAssistant
? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract<Part, { type: "tool_call" }> | undefined)
: undefined;
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
@@ -279,6 +250,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
@@ -289,7 +261,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
⇄
</button>
)}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
@@ -308,13 +280,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
}}
>
<div className="chat">
{showStats && stats && <StatsPanel stats={stats} />}
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m) => {
if (m.role === "assistant") {
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
</div>
);
}
@@ -367,11 +339,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
})}
{draft && (
<div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live />
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{(busy || waiting) && (
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
@@ -409,6 +381,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
</div>
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>