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

+94 -25
View File
@@ -1,9 +1,8 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { useEffect, useState } from "react";
import { fmtCompact, useT } from "../i18n";
import { useStore, type Draft } from "../store";
import type { Part } from "../types";
type ToolResult = Extract<Part, { type: "tool_result" }>;
/** Animated three-dot pulse. */
export function Dots() {
@@ -20,43 +19,113 @@ export function Spinner({ small }: { small?: boolean }) {
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
}
/** What the agent is doing right now, derived from the streaming message. */
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
/** Rotating star like a terminal agent's spinner. */
function Star() {
const [i, setI] = useState(0);
useEffect(() => {
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
return () => clearInterval(h);
}, []);
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
}
function useNow(active: boolean) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!active) return;
const h = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(h);
}, [active]);
return now;
}
function fmtElapsed(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000));
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
/** Generated tokens so far, estimated from the streamed characters. */
function generatedTokens(draft?: Draft) {
if (!draft) return 0;
let chars = 0;
for (const p of draft.parts) {
if (p.type === "text" || p.type === "reasoning") chars += p.text.length;
else if (p.type === "tool_call") chars += JSON.stringify(p.input ?? {}).length;
}
return Math.round(chars / 4);
}
/** What the agent is doing right now – playful text, elapsed time and tokens. */
export function ActivityLine({
sessionId,
draft,
results,
pendingTool,
waiting,
permissionPending,
}: {
sessionId: string;
draft?: Draft;
results: Map<string, ToolResult>;
/** A tool call of the last (finished) model step that has no result yet. */
pendingTool?: { name: string; input: any; since: number };
waiting?: string;
permissionPending: boolean;
}) {
const t = useT();
let label: string;
const step = useStore((s) => s.steps[sessionId]);
const now = useNow((!!step || !!pendingTool) && !permissionPending);
if (permissionPending) {
label = t("activity.waitingForYou");
} else if (waiting) {
return (
<div className="activity paused" role="status">
<span className="pause-icon" aria-hidden="true" />
<span>{t("activity.waitingForYou")}</span>
</div>
);
}
if (waiting) {
const [kind, ...rest] = waiting.split(":");
const what = rest.join(":");
label = kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what });
} else {
const last = draft?.parts[draft.parts.length - 1];
const runningTool = draft?.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as
| Extract<Part, { type: "tool_call" }>
| undefined;
if (runningTool) {
label = runningTool.name === "task" ? t("activity.subagent", { name: runningTool.input?.description ?? "" }) : t("activity.tool", { tool: runningTool.name });
} else if (last?.type === "text") {
label = t("activity.writing");
} else {
label = t("activity.thinking");
}
return (
<div className="activity" role="status">
<Dots />
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span>
</div>
);
}
// A tool is running between two model steps.
if (!draft && pendingTool) {
const label = pendingTool.name === "task" ? t("activity.subagent", { name: pendingTool.input?.description ?? "" }) : t("activity.tool", { tool: pendingTool.name });
return (
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
<span className="activity-meta">({fmtElapsed(now - pendingTool.since)})</span>
</div>
);
}
const live = draft && step && draft.id === step.messageId ? draft : undefined;
const generated = generatedTokens(live);
const phrases = t("activity.phrases").split("|");
// A stable starting phrase per step, changing every 4 seconds.
const seed = step ? [...step.messageId].reduce((a, c) => a + c.charCodeAt(0), 0) : 0;
const elapsed = step ? now - step.startedAt : 0;
const label = `${phrases[(seed + Math.floor(elapsed / 4000)) % phrases.length]}…`;
const prefill = step && generated === 0;
return (
<div className={`activity ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
<span>{label}</span>
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
{step && (
<span className="activity-meta">
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
</span>
)}
</div>
);
}