-
Objective
-
-
Maximum rounds setMaxRounds(Number(event.target.value))} />
-
Maximum tokens setMaxTokens(Number(event.target.value))} />
-
Concurrency setConcurrency(Number(event.target.value))} />
-
Request delay (ms) setRequestDelay(Number(event.target.value))} />
+ const techniqueSummary = selected == null ? `All ${techniques.length || ""} techniques`.trim() : `${selected.length} techniques`;
+ return
+ {active ? "Current engagement" : "New engagement"} {active ? "Launch controls are available when this run ends" : "Set the objective, then start the agent loop"} {active ? "In progress" : "Ready"}
+
+
+ Objective
+ {working ? "Starting" : "Start loop"}
-
+
+ Run settings {maxRounds} rounds · {maxTokens.toLocaleString()} tokens · {concurrency} concurrent · {requestDelay} ms · {techniqueSummary}
+
- {working ? "Starting" : "Start engagement"} {message && {message} }
+
+
+
+ {message &&
{message} }
;
}
@@ -436,33 +499,92 @@ function SteeringBar({ execution }: { execution: ExecutionSummary | null }) {
};
return (
- Steer the attacker {execution?.current_round ? `Round ${execution.current_round}` : "No active round"} {status && {status} }
+ Steer the attacker {execution?.current_round ? `Round ${execution.current_round}` : execution ? "Waiting for the first round" : "Available when the loop starts"} {status && {status} }
Steering message
+ }} placeholder={execution ? "Steer or command the attacker. Ctrl Enter sends." : "Draft steering guidance here; it can be sent after the loop starts."} />
{sending ? "Sending" : "Send"}
);
}
-export function AgentView({ execution, onRefresh }: { execution: ExecutionSummary | null; onRefresh: () => void }) {
- const steerable = Boolean(execution && ["queued", "running", "pausing", "paused"].includes(execution.status));
+const LOOP_KINDS = new Set(["start", "round", "message", "tool_call", "tool_result", "result", "verdict", "feedback", "operator", "error", "control", "done"]);
+
+function AgentLoop({ execution, events, streamState }: { execution: ExecutionSummary | null; events: EventEnvelope[]; streamState: string }) {
+ const activity = useMemo(() => projectActivityEvents(events, execution?.objective || ""), [events, execution?.objective]);
+ const loopEvents = useMemo(() => activity.filter((event) => LOOP_KINDS.has(event.kind)), [activity]);
+ const active = Boolean(execution && ["queued", "running", "pausing", "paused"].includes(execution.status));
+ const latest = loopEvents[loopEvents.length - 1];
+ const roles = [
+ { id: "attacker", label: "Attack", model: execution?.attacker || "Not selected", detail: "Plans and adapts the next attempt" },
+ { id: "target", label: "Target", model: execution?.target || "Not selected", detail: "Receives the attack and responds" },
+ { id: "judge", label: "Judge", model: execution?.judge || "Not selected", detail: "Evaluates evidence and guides the loop" },
+ ];
+ const latestFor = (role: string) => [...loopEvents].reverse().find((event) => actorLabel(event).toLowerCase() === role);
+
+ return
+
+ Agent loop
Attack → Target → Judge
+ {execution ? : ● Idle }{execution ? `stream ${streamState}` : "awaiting objective"}
+
+
+ {roles.map((role, index) => {
+ const roleEvent = latestFor(role.id);
+ const isCurrent = active && latest && actorLabel(latest).toLowerCase() === role.id;
+ return
+ {String(index + 1).padStart(2, "0")} {role.label} {isCurrent ? "Active" : roleEvent ? formatTime(roleEvent.timestamp) : "Waiting"}
+ {role.model}
+ {roleEvent ? eventTitle(roleEvent) : role.detail}
+ ;
+ })}
+
+ Conversation stream {loopEvents.length} exchanges{execution?.current_round ? ` · round ${execution.current_round}` : ""}
+
+ {!execution && }
+ {execution && !loopEvents.length && }
+ {loopEvents.map((event) => {
+ const actor = actorLabel(event);
+ const copy = event.text || eventTitle(event);
+ return
+
+
+ ●
+ {actor} {event.round ? `Round ${event.round}` : formatTime(event.timestamp)}
+ {event.kind.replace(/_/g, " ")} {copy}
+ {event.verdict ? : {formatTime(event.timestamp)} }
+
+
+
{copy}
+
{eventMeta(event) || `Event #${event.sequence}`}
+ {hasValue(event.data) &&
Structured detail }
+
+
+ ;
+ })}
+
+ ;
+}
+
+export function AgentView({ execution, enabled = true, onRefresh }: { execution: ExecutionSummary | null; enabled?: boolean; onRefresh: () => void }) {
+ const { events, streamState } = useExecutionEvents(execution, enabled);
return
;
}
export function LiveView({ execution, enabled = true }: { execution: ExecutionSummary | null; enabled?: boolean }) {
- const [events, setEvents] = useState
([]);
const [selected, setSelected] = useState(null);
const [liveTail, setLiveTail] = useState(true);
const liveTailRef = useRef(true);
const [unread, setUnread] = useState(0);
- const [streamState, setStreamState] = useState("idle");
+ const { events, streamState } = useExecutionEvents(execution, enabled, () => {
+ if (!liveTailRef.current) setUnread((current) => current + 1);
+ });
const activityEvents = useMemo(
() => projectActivityEvents(events, execution?.objective || ""),
[events, execution?.objective],
@@ -474,43 +596,9 @@ export function LiveView({ execution, enabled = true }: { execution: ExecutionSu
useEffect(() => { liveTailRef.current = liveTail; if (liveTail) setUnread(0); }, [liveTail]);
useEffect(() => {
- if (!enabled) return;
- setEvents([]);
setSelected(null);
setUnread(0);
- if (!execution) return;
- if (execution.source === "legacy" && execution.id !== "legacy-active") {
- setStreamState("loading");
- v2Api.legacyEvents(execution.run_id || execution.id).then((loaded) => {
- setEvents(loaded);
- setSelected(loaded.length ? loaded[loaded.length - 1] : null);
- setStreamState("complete");
- }).catch(() => setStreamState("unavailable"));
- return;
- }
- if (execution.source === "legacy") { setStreamState("legacy-live"); return; }
- const controller = new AbortController();
- let reconnect = 0;
- let timer = 0;
- const connect = async () => {
- setStreamState("connected");
- try {
- await v2Api.streamEvents(execution.id, reconnect, (event) => {
- reconnect = Math.max(reconnect, event.sequence);
- setEvents((current) => current.some((item) => item.id === event.id) ? current : [...current, event].sort((a, b) => a.sequence - b.sequence));
- if (!liveTailRef.current) setUnread((current) => current + 1);
- }, controller.signal);
- if (!controller.signal.aborted && ["running", "pausing", "paused"].includes(execution.status)) timer = window.setTimeout(connect, 1400);
- } catch {
- if (!controller.signal.aborted) {
- setStreamState("reconnecting");
- timer = window.setTimeout(connect, 1800);
- }
- }
- };
- connect();
- return () => { controller.abort(); window.clearTimeout(timer); };
- }, [execution?.id, execution?.source, execution?.status, enabled]);
+ }, [execution?.id]);
useEffect(() => {
setSelected((current) => {
diff --git a/wallbreaker/dashboard/web/src/v2/V2App.tsx b/wallbreaker/dashboard/web/src/v2/V2App.tsx
index 664984e..eafb731 100644
--- a/wallbreaker/dashboard/web/src/v2/V2App.tsx
+++ b/wallbreaker/dashboard/web/src/v2/V2App.tsx
@@ -145,7 +145,7 @@ export function V2App() {
-
+
setInitialCapability("")} />
diff --git a/wallbreaker/dashboard/web/src/v2/eventProjection.ts b/wallbreaker/dashboard/web/src/v2/eventProjection.ts
index 222bee3..371202c 100644
--- a/wallbreaker/dashboard/web/src/v2/eventProjection.ts
+++ b/wallbreaker/dashboard/web/src/v2/eventProjection.ts
@@ -67,49 +67,53 @@ function conversationEntry(event: EventEnvelope): ConversationEntry | null {
export function projectActivityEvents(events: EventEnvelope[], objective = ""): EventEnvelope[] {
const projected: EventEnvelope[] = [];
const ordered = [...events].sort((left, right) => left.sequence - right.sequence);
+ let currentRound: number | undefined;
for (const source of ordered) {
- const actor = inferEventActor(source);
- if (source.kind === "text") {
+ const sourceRound = source.round ?? (typeof source.data?.round === "number" ? source.data.round : undefined);
+ if (source.kind === "round" && sourceRound) currentRound = sourceRound;
+ const correlatedSource = sourceRound || !currentRound ? source : { ...source, round: currentRound };
+ const actor = inferEventActor(correlatedSource);
+ if (correlatedSource.kind === "text") {
const previous = projected[projected.length - 1];
- if (previous?.kind === "message" && previous.actor === actor && previous.round === source.round) {
- previous.text = `${previous.text || ""}${source.text || source.summary || ""}`;
- previous.raw = [...(Array.isArray(previous.raw) ? previous.raw : [previous.raw]), source.raw];
+ if (previous?.kind === "message" && previous.actor === actor && previous.round === correlatedSource.round) {
+ previous.text = `${previous.text || ""}${correlatedSource.text || correlatedSource.summary || ""}`;
+ previous.raw = [...(Array.isArray(previous.raw) ? previous.raw : [previous.raw]), correlatedSource.raw];
} else {
projected.push({
- ...source,
+ ...correlatedSource,
kind: "message",
actor,
summary: actor === "attacker" ? "Attacker response" : `${actor} message`,
- text: source.text || source.summary,
- raw: [source.raw],
+ text: correlatedSource.text || correlatedSource.summary,
+ raw: [correlatedSource.raw],
});
}
continue;
}
- if (source.kind === "usage") {
- const usage = record(source.data);
+ if (correlatedSource.kind === "usage") {
+ const usage = record(correlatedSource.data);
const prior = [...projected].reverse().find((item) => item.kind === "message" && inferEventActor(item) === "attacker");
if (prior) {
- const inputTokens = source.input_tokens ?? Number(usage.input_tokens ?? usage.input);
- const outputTokens = source.output_tokens ?? Number(usage.output_tokens ?? usage.output);
+ const inputTokens = correlatedSource.input_tokens ?? Number(usage.input_tokens ?? usage.input);
+ const outputTokens = correlatedSource.output_tokens ?? Number(usage.output_tokens ?? usage.output);
prior.input_tokens = Number.isFinite(inputTokens) ? inputTokens : undefined;
prior.output_tokens = Number.isFinite(outputTokens) ? outputTokens : undefined;
- prior.data = { ...(prior.data || {}), usage: source.raw };
+ prior.data = { ...(prior.data || {}), usage: correlatedSource.raw };
}
continue;
}
- if (TELEMETRY_KINDS.has(source.kind)) continue;
+ if (TELEMETRY_KINDS.has(correlatedSource.kind)) continue;
- let kind = source.kind;
- let summary = source.summary;
- if (kind === "tool_start") { kind = "tool_call"; summary ||= String(source.data?.name || "Tool call"); }
+ let kind = correlatedSource.kind;
+ let summary = correlatedSource.summary;
+ if (kind === "tool_start") { kind = "tool_call"; summary ||= String(correlatedSource.data?.name || "Tool call"); }
if (kind === "start") summary ||= "Run started";
- if (kind === "round") summary ||= `Round ${source.round || source.data?.round || ""}`.trim();
+ if (kind === "round") summary ||= `Round ${correlatedSource.round || correlatedSource.data?.round || ""}`.trim();
if (kind === "done") summary ||= "Run completed";
- projected.push({ ...source, kind, actor, summary });
+ projected.push({ ...correlatedSource, kind, actor, summary });
}
const conversation: ConversationEntry[] = objective.trim()
From 288dd12c81acb2e5d52af7c637e474aff25f9b41 Mon Sep 17 00:00:00 2001
From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:58:19 +0100
Subject: [PATCH 17/21] fix(reports): resolve indexed run names
---
tests/test_dashboard_v2.py | 3 ++-
wallbreaker/dashboard/server.py | 8 +++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py
index f01cc22..4b1ec66 100644
--- a/tests/test_dashboard_v2.py
+++ b/tests/test_dashboard_v2.py
@@ -127,13 +127,14 @@ def test_v2_report_uses_canonical_run_log(tmp_path):
encoding="utf-8",
)
with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client:
- response = client.get("/api/v2/reports/run-20260801-120000.jsonl")
+ response = client.get("/api/v2/reports/run-20260801-120000")
assert response.status_code == 200
payload = response.json()
assert payload["scorecard"]["strict_hits"] == 1
assert payload["scorecard"]["graded_fires"] == 1
assert "Evaluate target" in payload["markdown"]
assert payload["findings"][0]["technique"] == "pair"
+ assert client.get("/api/v2/reports/run-20260801-120000.jsonl").status_code == 200
def test_v2_runs_headless_tui_catalog_capability(tmp_path):
diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py
index d504dee..1ddd745 100644
--- a/wallbreaker/dashboard/server.py
+++ b/wallbreaker/dashboard/server.py
@@ -120,7 +120,13 @@ def _safe_run_path(sessions: Path, name: str) -> Path | None:
if ".." in name or "/" in name or "\\" in name:
return None
path = sessions / name
- return path if path.is_file() else None
+ if path.is_file():
+ return path
+ if not Path(name).suffix:
+ jsonl_path = sessions / f"{name}.jsonl"
+ if jsonl_path.is_file():
+ return jsonl_path
+ return None
def _load_records_with_lines(path: Path) -> tuple[list[dict], list[str], list[int]]:
From d8135cc612e175595ca28c376b527da4a5501ba9 Mon Sep 17 00:00:00 2001
From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com>
Date: Sat, 1 Aug 2026 10:00:40 +0100
Subject: [PATCH 18/21] fix(findings): load evidence across historical runs
---
wallbreaker/dashboard/web/src/v2/Views.tsx | 13 ++++++++++++-
wallbreaker/dashboard/web/src/v2/api.ts | 1 +
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/wallbreaker/dashboard/web/src/v2/Views.tsx b/wallbreaker/dashboard/web/src/v2/Views.tsx
index 7689615..4ce9ec7 100644
--- a/wallbreaker/dashboard/web/src/v2/Views.tsx
+++ b/wallbreaker/dashboard/web/src/v2/Views.tsx
@@ -142,15 +142,26 @@ export function ArsenalView() {
export function FindingsView() {
const [findings, setFindings] = useState(null);
+ const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [verdict, setVerdict] = useState("all");
const [selected, setSelected] = useState(null);
- useEffect(() => { v2Api.findings().then(setFindings).catch(() => setFindings([])); }, []);
+ useEffect(() => {
+ setError("");
+ v2Api.findingRuns().then((runs) => {
+ const names = runs.filter((run) => Number(run.findings || 0) > 0).map((run) => run.name);
+ return names.length ? v2Api.findings(names) : [];
+ }).then(setFindings).catch((reason) => {
+ setError(errorMessage(reason));
+ setFindings([]);
+ });
+ }, []);
if (!findings) return ;
const verdicts = [...new Set(findings.map((item) => item.label).filter(Boolean) as string[])];
const filtered = findings.filter((item) => (verdict === "all" || item.label === verdict) && (!query || `${item.technique || ""} ${item.reason || ""} ${item.response || ""} ${item.run || ""}`.toLowerCase().includes(query.toLowerCase())));
return
+ {error && setError("")} />}
setQuery(event.target.value)} placeholder="Search evidence, technique, run" /> setVerdict(event.target.value)}>All verdicts {verdicts.map((value) => {value} )}
{!filtered.length && }
{filtered.map((item, index) =>
setSelected(item)}>{item.technique || "Unclassified"}
{item.reason || item.response || "Recorded finding"} {item.run || "Unknown run"}{item.ts ? ` / ${item.ts}` : ""} )}
diff --git a/wallbreaker/dashboard/web/src/v2/api.ts b/wallbreaker/dashboard/web/src/v2/api.ts
index ba2b888..aad661b 100644
--- a/wallbreaker/dashboard/web/src/v2/api.ts
+++ b/wallbreaker/dashboard/web/src/v2/api.ts
@@ -286,6 +286,7 @@ export const v2Api = {
runs: () => request("/api/runs"),
run: (name: string) => request>(`/api/runs/${encodeURIComponent(name)}`),
findings: (runs?: string[]) => request(`/api/findings${runs?.length ? `?runs=${encodeURIComponent(runs.join(","))}` : ""}`),
+ findingRuns: () => request("/api/findings/runs"),
providers: () => request("/api/providers"),
testProvider: (name: string) => request>(`/api/providers/${encodeURIComponent(name)}/test`, { method: "POST" }),
settings: () => request("/api/settings"),
From 64b977c52b675bb35e996444718ebc5a68d91dee Mon Sep 17 00:00:00 2001
From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com>
Date: Sat, 1 Aug 2026 11:17:03 +0100
Subject: [PATCH 19/21] feat(live): add historical run selector
---
wallbreaker/dashboard/web/src/v2.css | 12 ++-
wallbreaker/dashboard/web/src/v2/LiveView.tsx | 79 +++++++++++++++++--
2 files changed, 83 insertions(+), 8 deletions(-)
diff --git a/wallbreaker/dashboard/web/src/v2.css b/wallbreaker/dashboard/web/src/v2.css
index 1b90163..7d5fb69 100644
--- a/wallbreaker/dashboard/web/src/v2.css
+++ b/wallbreaker/dashboard/web/src/v2.css
@@ -129,8 +129,15 @@
.v2-verdict-partial, .v2-verdict-inconclusive { border-color: #624b21; background: #261e0d; color: var(--v2-amber); }
.v2-live { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--v2-bg); }
-.v2-live-dashboard { grid-template-rows: minmax(0, 1fr); }
+.v2-live-dashboard { grid-template-rows: auto minmax(0, 1fr); }
.v2-agent { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(250px, 1fr) auto; overflow: hidden; background: var(--v2-bg); }
+.v2-live-selector { display: flex; min-height: 49px; align-items: center; gap: 12px; padding: 7px 12px; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; }
+.v2-live-selector > div { display: grid; min-width: 145px; gap: 2px; }
+.v2-live-selector strong { color: var(--v2-text); font-size: 10px; }
+.v2-live-selector > div span, .v2-live-selector-meta { color: var(--v2-muted); font: 8px var(--v2-mono); }
+.v2-live-selector label { min-width: 0; flex: 1; }
+.v2-live-selector select { width: 100%; min-height: 31px; padding: 0 10px; border: 1px solid var(--v2-line); border-radius: 4px; background: #111416; color: var(--v2-text); font: 9px var(--v2-mono); }
+.v2-live-selector select:focus { border-color: var(--v2-teal); outline: 0; }
.v2-live:has(> .v2-launcher[open]) { overflow-y: auto; grid-template-rows: auto auto minmax(440px, 1fr) auto; }
.v2-run-strip { display: grid; min-width: 0; min-height: 71px; grid-template-columns: minmax(125px, 1.1fr) minmax(110px, .85fr) minmax(110px, 1fr) minmax(125px, .9fr) 82px minmax(118px, .85fr) 95px auto; align-items: stretch; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; }
.v2-strip-field, .v2-strip-progress, .v2-strip-state { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; padding: 0 12px; border-right: 1px solid var(--v2-line-soft); }
@@ -751,6 +758,9 @@
}
@media (max-width: 640px) {
+ .v2-live-selector { align-items: stretch; flex-direction: column; gap: 5px; }
+ .v2-live-selector > div { min-width: 0; }
+ .v2-live-selector-meta { display: none; }
.v2-agent-launch-primary { grid-template-columns: 1fr; }
.v2-agent-launch-primary > .v2-button { width: 100%; }
.v2-agent-advanced > summary { grid-template-columns: 1fr; gap: 2px; padding-block: 6px; }
diff --git a/wallbreaker/dashboard/web/src/v2/LiveView.tsx b/wallbreaker/dashboard/web/src/v2/LiveView.tsx
index d1cb7e7..c00cc4f 100644
--- a/wallbreaker/dashboard/web/src/v2/LiveView.tsx
+++ b/wallbreaker/dashboard/web/src/v2/LiveView.tsx
@@ -19,6 +19,13 @@ interface TechniqueChoice { name: string; description?: string; control?: boolea
type InspectorTab = "overview" | "conversation" | "payload" | "evaluation" | "raw";
+interface HistoricalRunOption {
+ run_name: string;
+ first_timestamp?: string;
+ last_timestamp?: string;
+ event_count?: number;
+}
+
const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "conversation", label: "Conversation" },
@@ -81,6 +88,41 @@ function useExecutionEvents(
return { events, streamState };
}
+function historicalExecution(run: HistoricalRunOption): ExecutionSummary {
+ return {
+ id: `legacy:${run.run_name}`,
+ run_id: run.run_name,
+ title: run.run_name,
+ status: "succeeded",
+ source: "legacy",
+ created_at: run.first_timestamp,
+ finished_at: run.last_timestamp,
+ };
+}
+
+function LiveRunSelector({
+ execution,
+ runs,
+ selectedRun,
+ onSelect,
+}: {
+ execution: ExecutionSummary | null;
+ runs: HistoricalRunOption[];
+ selectedRun: string;
+ onSelect: (runName: string) => void;
+}) {
+ const currentValue = selectedRun || (execution ? "__current__" : "");
+ return
+ Run to observe {selectedRun ? "Historical evidence" : execution ? "Current execution" : "Choose a retained run"}
+ Select current or historical run onSelect(event.target.value === "__current__" ? "" : event.target.value)}>
+ {!execution && Select a historical run }
+ {execution && Current execution · {execution.title || execution.id} }
+ {runs.map((run) => {run.run_name} · {run.event_count || 0} events )}
+
+ {runs.length} retained runs
+ ;
+}
+
function eventStatus(event: EventEnvelope): "pass" | "fail" | "bypass" | "inconclusive" {
const value = `${event.verdict || ""} ${event.kind}`.toLowerCase();
if (value.includes("bypass") || value.includes("complied")) return "bypass";
@@ -578,27 +620,49 @@ export function AgentView({ execution, enabled = true, onRefresh }: { execution:
}
export function LiveView({ execution, enabled = true }: { execution: ExecutionSummary | null; enabled?: boolean }) {
+ const [historicalRuns, setHistoricalRuns] = useState([]);
+ const [historicalRun, setHistoricalRun] = useState("");
+ const selectedExecution = historicalRun
+ ? historicalExecution(historicalRuns.find((run) => run.run_name === historicalRun) || { run_name: historicalRun })
+ : execution;
const [selected, setSelected] = useState(null);
const [liveTail, setLiveTail] = useState(true);
const liveTailRef = useRef(true);
const [unread, setUnread] = useState(0);
- const { events, streamState } = useExecutionEvents(execution, enabled, () => {
+ const { events, streamState } = useExecutionEvents(selectedExecution, enabled, () => {
if (!liveTailRef.current) setUnread((current) => current + 1);
});
const activityEvents = useMemo(
- () => projectActivityEvents(events, execution?.objective || ""),
- [events, execution?.objective],
+ () => projectActivityEvents(events, selectedExecution?.objective || ""),
+ [events, selectedExecution?.objective],
);
const rawEvents = useMemo(
- () => correlateRawEvents(events, execution?.objective || ""),
- [events, execution?.objective],
+ () => correlateRawEvents(events, selectedExecution?.objective || ""),
+ [events, selectedExecution?.objective],
);
+ useEffect(() => {
+ if (!enabled) return;
+ v2Api.historyRuns(1000).then((payload) => {
+ const runs = payload.items.map((row) => ({
+ run_name: String(row.run_name || ""),
+ first_timestamp: String(row.first_timestamp || ""),
+ last_timestamp: String(row.last_timestamp || ""),
+ event_count: Number(row.event_count || 0),
+ })).filter((run) => run.run_name);
+ setHistoricalRuns(runs);
+ setHistoricalRun((current) => current || (!execution ? runs[0]?.run_name || "" : ""));
+ }).catch(() => setHistoricalRuns([]));
+ }, [enabled, execution?.id]);
+ useEffect(() => {
+ if (execution?.source === "legacy") setHistoricalRun(execution.run_id || execution.id);
+ else if (execution) setHistoricalRun("");
+ }, [execution?.id, execution?.source, execution?.run_id]);
useEffect(() => { liveTailRef.current = liveTail; if (liveTail) setUnread(0); }, [liveTail]);
useEffect(() => {
setSelected(null);
setUnread(0);
- }, [execution?.id]);
+ }, [selectedExecution?.id]);
useEffect(() => {
setSelected((current) => {
@@ -611,9 +675,10 @@ export function LiveView({ execution, enabled = true }: { execution: ExecutionSu
return (
+
-
+
{ setUnread(0); setLiveTail(true); }} />
From 06f03852723958c1f78f9e32676b5f714c268350 Mon Sep 17 00:00:00 2001
From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:41:59 +0100
Subject: [PATCH 20/21] docs: add WebUI V2 setup guide
---
CHANGELOG.md | 14 ++++
CONTRIBUTING.md | 10 ++-
README.md | 27 ++++---
README_RICHERUI.md | 16 ++--
docs/SETUP.md | 194 +++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 241 insertions(+), 20 deletions(-)
create mode 100644 docs/SETUP.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0afa06d..95926c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## Unreleased — WebUI V2 unified operator surface
+
+- Added a shared typed capability catalog so TUI behavior is the canonical contract and
+ every registered operation remains discoverable from V2.
+- Added server-owned queued executions with pause, resume, steering, attacker switching,
+ cancellation, and reconnectable sequenced events.
+- Added canonical JSONL history with a rebuildable SQLite search/correlation index.
+- Added `/v2` alongside `/legacy`, with dedicated Agent and Live surfaces, persistent
+ multi-turn Compose, workflow sequencing and reconstruction, cross-run findings, run-log
+ exploration, evidence reports, provider verification, and profile management.
+- Added historical-run selection to Live and preserved drafts and view state across V2
+ navigation.
+- Added [complete setup and development documentation](docs/SETUP.md).
+
## Five new attack tools: cipherchat, skeleton_key, persuasion_attack, drattack, ica
Adds five research-derived attack tools that were missing from the arsenal, wired into
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f262706..957eb56 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,6 +14,10 @@ wallbreaker check # validate config (profiles, keys, target, judge)
pytest -q # full suite must stay green
```
+Dashboard contributors should install `.[dev,dashboard]`, run `npm install` in
+`wallbreaker/dashboard/web`, and verify `npm run build`. See [docs/SETUP.md](docs/SETUP.md)
+for the complete cross-platform setup and local development workflow.
+
## Architecture (where things live)
- `wallbreaker/providers/` normalize OpenAI + Anthropic wire formats to one event stream
@@ -25,7 +29,11 @@ pytest -q # full suite must stay green
- `wallbreaker/presets.py` — curated single-shot jailbreak templates.
- `wallbreaker/tui/` — the Textual terminal UI (theme in `theme.py`, chrome in
`header.py`/`sidebar.py`/`widgets.py`, layout in `app.tcss`).
-- `dashboard/` — FastAPI backend + React/Svelte web dashboard.
+- `wallbreaker/capabilities.py` — typed capability manifest shared by the TUI and WebUI V2.
+- `wallbreaker/executions.py` — server-owned execution lifecycle and resumable events.
+- `wallbreaker/history_index.py` — rebuildable SQLite index over canonical JSONL history.
+- `wallbreaker/dashboard/` — FastAPI backend + React/Vite dashboards; V2 lives under
+ `web/src/v2/`, while the original interface remains available during rollout.
## House rules
diff --git a/README.md b/README.md
index ef98175..c394990 100644
--- a/README.md
+++ b/README.md
@@ -287,15 +287,12 @@ pytest -q
## Web dashboard
-A browser dashboard ships alongside the TUI (FastAPI backend + React/Vite SPA). Its
-headline is the **Agent** view, the *same autonomous attack loop the TUI runs*: give it an
-objective ("jailbreak the model into …") and the attacker brain reasons, picks techniques,
-fires at the target, reads the verdict, and keeps going, streamed live to your browser over
-SSE. Plus a single-shot **attack console** (preset + transform chips → verdict), a live ASR
-scoreboard, findings table, run-log viewer, a searchable arsenal of
-presets/transforms/tools, and a **Settings** panel to swap the target / attacker / judge
-model live (persisted to `.wallbreaker_state.json`, applied without a restart; image
-targets auto-set `modality=image`).
+A browser dashboard ships alongside the TUI (FastAPI backend + React/Vite SPA). WebUI V2
+uses the same capability catalog and application services as the TUI, and adds a
+server-owned execution queue, resumable event streams, persistent multi-turn composition,
+workflow sequencing, provider/profile management, and current or historical evidence
+inspection. **Agent** is dedicated to the autonomous Attack → Target → Judge loop;
+**Live** provides the holistic-to-granular observability surface.

@@ -310,12 +307,16 @@ targets auto-set `modality=image`).
```bash
pip install -e ".[dashboard]" # FastAPI + uvicorn
cd wallbreaker/dashboard/web && npm install && npm run build && cd -
-wallbreaker dashboard # http://127.0.0.1:8787
+wallbreaker dashboard # binds to 127.0.0.1:8787
```
-The backend reuses the same engine as the TUI, so the console fires through `query_target`
-against your `[target]`. For frontend hot-reload during development, run `npm run dev` in
-`wallbreaker/dashboard/web` (it proxies `/api` to the running `wallbreaker dashboard`).
+Open WebUI V2 at
. The original dashboard remains available at
+ during the parity rollout. The backend reuses the same
+engine as the TUI. For frontend hot-reload, run `npm run dev` in
+`wallbreaker/dashboard/web`; it proxies `/api` to the dashboard backend.
+
+See the [setup guide](docs/SETUP.md) for Windows instructions, provider configuration,
+history storage, development workflow, network-exposure safeguards, and troubleshooting.
## Responsible use
diff --git a/README_RICHERUI.md b/README_RICHERUI.md
index 97d79bd..a15ab02 100644
--- a/README_RICHERUI.md
+++ b/README_RICHERUI.md
@@ -1,8 +1,12 @@
-# Wallbreaker Richer UI Guide
+# Wallbreaker browser UI guide
-This guide covers the browser-dashboard features added on the `richer-ui` branch. It is
-intended as a companion to the main [README](README.md), which remains the reference for
-the red-team harness, CLI, tools, and safety policy.
+This guide describes the original browser dashboard retained at `/legacy`. WebUI V2 is
+the active unified operator surface at `/v2`; use the [setup guide](docs/SETUP.md) for
+installation, current navigation, local history, development, and troubleshooting.
+
+The original interface remains available during the V2 parity rollout. Its provider and
+profile management APIs are also used by V2, but its page names and workflows below should
+not be read as the V2 information architecture.
## Install and launch
@@ -17,7 +21,7 @@ cd ../../..
wallbreaker dashboard
```
-Open . For frontend development, keep `wallbreaker dashboard`
+Open . Open V2 at . For frontend development, keep `wallbreaker dashboard`
running and start Vite in another terminal:
```bash
@@ -25,7 +29,7 @@ cd wallbreaker/dashboard/web
npm run dev
```
-The dashboard has nine views: **Agent**, **Overview**, **Attack console**, **Findings**,
+The original dashboard has nine views: **Agent**, **Overview**, **Attack console**, **Findings**,
**Run logs**, **Arsenal**, **Profiles**, **Advanced**, and **Settings**. Use the arrow beside the Wallbreaker logo to
collapse or expand the navigation rail. The choice is remembered in the browser.
diff --git a/docs/SETUP.md b/docs/SETUP.md
new file mode 100644
index 0000000..d939387
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,194 @@
+# Wallbreaker setup
+
+This guide installs Wallbreaker for local terminal and browser use. Wallbreaker is an
+authorized LLM security-testing harness; only connect it to systems you own or have
+explicit permission to evaluate.
+
+## Requirements
+
+- Python 3.11 or newer
+- Git
+- Node.js 18 or newer and npm, if you want the browser interface
+- Credentials for the model providers you intend to use, unless you use a supported
+ keyless local CLI provider
+
+## Install
+
+Clone your fork (or the upstream repository), then create an isolated Python environment:
+
+```bash
+git clone https://github.com/YOUR_ACCOUNT/wallbreaker.git
+cd wallbreaker
+python -m venv .venv
+```
+
+Activate it on macOS or Linux:
+
+```bash
+. .venv/bin/activate
+```
+
+Activate it on Windows PowerShell:
+
+```powershell
+.\.venv\Scripts\Activate.ps1
+```
+
+Install the terminal application, development tools, and dashboard backend:
+
+```bash
+python -m pip install -e ".[dev,dashboard]"
+```
+
+Optional extras are available for barcode and steganography tools:
+
+```bash
+python -m pip install -e ".[dev,dashboard,barcodes,stego]"
+```
+
+## Configure providers and roles
+
+Copy the example configuration and keep the resulting local file out of source control:
+
+```bash
+cp config.example.toml config.toml
+```
+
+On Windows PowerShell, use:
+
+```powershell
+Copy-Item config.example.toml config.toml
+```
+
+Edit `config.toml` to define at least one attacker profile and the target and judge
+roles. Prefer `api_key_env` plus environment variables or the dashboard's credential
+editor over committing literal keys. Validate the result before launching:
+
+```bash
+wallbreaker check
+```
+
+The browser interface can also create, edit, test, enable, and disable providers and
+manage attacker, target, and judge profiles. Known credential fields are redacted from
+API responses and execution history.
+
+## Run the terminal interface
+
+```bash
+wallbreaker
+```
+
+Useful alternatives include:
+
+```bash
+wallbreaker --profile PROFILE_NAME
+wallbreaker --auto "authorized evaluation objective"
+wallbreaker --resume
+```
+
+Terminal sessions autosave under `sessions/`.
+
+## Build and run the browser interface
+
+Install the frontend dependencies and create the production bundle:
+
+```bash
+cd wallbreaker/dashboard/web
+npm install
+npm run build
+cd ../../..
+```
+
+Start the backend from the repository root:
+
+```bash
+wallbreaker dashboard
+```
+
+Open these local URLs:
+
+- WebUI V2:
+- Original dashboard:
+
+V2 separates operation from observation. **Agent** runs and steers the Attack → Target
+→ Judge loop, while **Live** observes either the current execution or a selected
+historical run. Compose, Workflows, Arsenal, Findings, Runs and Logs, Reports, Models,
+and Settings expose the rest of the operator surface.
+
+The dashboard binds to loopback by default and has no multi-user authentication. To bind
+to another interface you must both choose the host and acknowledge the exposure:
+
+```bash
+wallbreaker dashboard --host 0.0.0.0 --allow-network
+```
+
+Do this only behind an access-controlled boundary. Run history can contain prompts,
+responses, reasoning, tool arguments, and generated artifacts.
+
+## Frontend development
+
+Keep `wallbreaker dashboard` running, then start the Vite development server in another
+terminal:
+
+```bash
+cd wallbreaker/dashboard/web
+npm run dev
+```
+
+Vite proxies `/api` to `http://127.0.0.1:8787`. Frontend source changes hot-reload.
+After Python backend changes, restart `wallbreaker dashboard`. To update the production
+bundle served on port 8787, run `npm run build` again and refresh the browser.
+
+## History and local state
+
+| Path | Purpose |
+|---|---|
+| `config.toml` | Provider definitions, profiles, and active role configuration |
+| `.env` | Optional locally managed provider credentials |
+| `.wallbreaker_state.json` | Non-secret runtime preferences and UI references |
+| `.wallbreaker_models.sqlite3` | Rebuildable provider model catalog |
+| `sessions/run-*.jsonl` | Canonical portable execution history |
+| `sessions/.wallbreaker_history.sqlite3` | Rebuildable search and correlation index |
+
+JSONL is the source of truth. The SQLite history index may be deleted and rebuilt from
+V2's Runs and Logs screen or through `POST /api/v2/history/rebuild`. Archive or remove
+canonical run files only when you intend to remove that evidence.
+
+## Verify the installation
+
+Run the Python suite with the project environment and build the frontend:
+
+```bash
+python -m pytest tests
+cd wallbreaker/dashboard/web
+npm run build
+```
+
+The full Python suite needs the project environment because the TUI, dashboard, image,
+and steganography tests use optional dependencies installed there.
+
+## Troubleshooting
+
+### The API runs but the browser UI is missing
+
+Build the frontend with `npm run build`, then refresh. Without a production bundle the
+backend returns a message explaining that only its API is available.
+
+### Every provider request fails
+
+Run `wallbreaker check`, then use **Models → Test provider**. A real test must authenticate
+and query the configured provider; an authentication error is not a successful connection.
+Check the key variable, base URL, protocol, authentication style, model path, and model ID.
+Native Anthropic normally uses `x-api-key`; some compatible proxies require `bearer`.
+
+### A run is absent from Live, Findings, or Reports
+
+Confirm its `run-*.jsonl` file is in the directory passed through `--sessions` (default:
+`sessions/`). In **Runs and Logs**, rebuild the history index. Malformed JSONL records are
+retained as visible parse errors rather than silently discarded.
+
+### Browser state appears stale
+
+Hard-refresh after rebuilding the frontend. V2 keeps drafts, selected views, conversation
+state, and workflow state while navigating; resetting or archiving a conversation is an
+explicit action.
From ef050dd5e2b636ce50b462f337856a092eaa86e5 Mon Sep 17 00:00:00 2001
From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:48:16 +0100
Subject: [PATCH 21/21] docs: add WebUI V2 visual showcase
---
README.md | 2 +
docs/WEBUI_V2_SHOWCASE.md | 128 +++++++++++++++++++++++++++++
docs/images/webui-v2/agent.png | Bin 0 -> 71208 bytes
docs/images/webui-v2/arsenal.png | Bin 0 -> 110152 bytes
docs/images/webui-v2/compose.png | Bin 0 -> 71918 bytes
docs/images/webui-v2/live.png | Bin 0 -> 55218 bytes
docs/images/webui-v2/models.png | Bin 0 -> 78408 bytes
docs/images/webui-v2/reports.png | Bin 0 -> 114173 bytes
docs/images/webui-v2/runs.png | Bin 0 -> 138983 bytes
docs/images/webui-v2/workflows.png | Bin 0 -> 92065 bytes
10 files changed, 130 insertions(+)
create mode 100644 docs/WEBUI_V2_SHOWCASE.md
create mode 100644 docs/images/webui-v2/agent.png
create mode 100644 docs/images/webui-v2/arsenal.png
create mode 100644 docs/images/webui-v2/compose.png
create mode 100644 docs/images/webui-v2/live.png
create mode 100644 docs/images/webui-v2/models.png
create mode 100644 docs/images/webui-v2/reports.png
create mode 100644 docs/images/webui-v2/runs.png
create mode 100644 docs/images/webui-v2/workflows.png
diff --git a/README.md b/README.md
index c394990..34eb10a 100644
--- a/README.md
+++ b/README.md
@@ -317,6 +317,8 @@ engine as the TUI. For frontend hot-reload, run `npm run dev` in
See the [setup guide](docs/SETUP.md) for Windows instructions, provider configuration,
history storage, development workflow, network-exposure safeguards, and troubleshooting.
+See the [WebUI V2 showcase](docs/WEBUI_V2_SHOWCASE.md) for a visual tour of the unified
+operator surface.
## Responsible use
diff --git a/docs/WEBUI_V2_SHOWCASE.md b/docs/WEBUI_V2_SHOWCASE.md
new file mode 100644
index 0000000..2ede161
--- /dev/null
+++ b/docs/WEBUI_V2_SHOWCASE.md
@@ -0,0 +1,128 @@
+# Wallbreaker WebUI V2 showcase
+
+WebUI V2 is a unified operator surface for running, steering, observing, and reviewing
+authorized LLM security evaluations. It places the TUI's Attack → Target → Judge loop at
+the center, then adds persistent composition, reusable workflows, historical visibility,
+evidence reporting, and model administration.
+
+The screenshots below were captured from the local V2 interface at 1440 pixels wide.
+Provider credentials and detailed historical payload content are not shown.
+
+## Run and steer the agent loop
+
+The **Agent** workspace focuses on the live loop. The operator sets an objective, starts
+the engagement, follows each Attack → Target → Judge stage, watches the conversation
+stream, and can steer the attacker without leaving the page. Advanced run settings stay
+collapsed until needed.
+
+
+
+Key capabilities:
+
+- Persistent objective draft and compact run settings
+- Explicit Attack, Target, and Judge stage state
+- Streaming multi-turn conversation
+- Pause, stop, and live steering controls
+- Round, token, timing, and connection status
+
+## Observe current and historical engagements
+
+**Live** is the evidence observatory rather than the run launcher. The same surface can
+follow the current execution or inspect any retained historical run. Its overview moves
+from run-level totals into activity events and synchronized event detail.
+
+
+
+The observatory provides:
+
+- Current or historical run selection
+- Semantic activity and raw event modes
+- Search and actor/event-type filters
+- Correlated event, conversation, payload, evaluation, and raw detail
+- Resumable live-tail updates for active executions
+
+## Compose persistent multi-turn target conversations
+
+**Compose** is a controlled delivery workspace. The first request opens a durable target
+conversation; subsequent deliveries are contextual follow-ups until the operator
+explicitly resets and archives the thread.
+
+
+
+Operators can preview the exact transformed payload, select presets and transforms,
+override the initial system prompt, set the token budget, and retain the complete target
+conversation between navigation changes.
+
+## Build and reuse workflows
+
+**Workflows** turns individual capabilities into configurable sequences. Operators add
+steps from the shared capability catalog, configure their arguments, reorder the sequence,
+save an alias, clone a workflow, and run it as a server-owned execution.
+
+
+
+The analysis mode can also reconstruct applicable events from historical agent runs.
+Individual events can be inspected and reusable steps selected before cloning them into an
+editable sequence.
+
+## Search the complete Arsenal
+
+**Arsenal** provides one searchable inventory of presets, transforms, tools, and schemas.
+Selecting an item opens its exact template, metadata, or argument contract in the detail
+panel.
+
+
+
+The catalog and workflow palette are generated from the same shared capability manifest,
+preventing UI-only command drift.
+
+## Investigate runs from summary to raw evidence
+
+**Runs and Logs** preserves the complete chronological record. Runs are searchable; the
+selected run can be viewed as a readable stream, a timeline, or raw JSONL. Event types and
+actors can be selected or excluded, and the visible result can be exported.
+
+
+
+JSONL remains the canonical portable history. A disposable SQLite index adds full-text
+search and structured correlation and can be rebuilt from this screen.
+
+## Summarize and export evidence
+
+**Reports** turns retained history into an operator-ready evidence portfolio. It supports
+all indexed runs or an individual run, with Markdown and structured evidence exports.
+
+
+
+The dashboard brings together run counts, graded responses, strict bypasses, attack
+success rate, per-run comparison, verdict distribution, technique performance, and the
+generated narrative report.
+
+## Manage models and verify providers
+
+**Models** exposes provider health and role configuration without revealing secret values.
+Credential verification makes a real authenticated provider request, while provider
+management supports creation, editing, discovery, enable/disable state, and removal.
+
+
+
+Attacker, target, and judge assignments remain visible in the global top bar. Named
+profiles and custom provider/model combinations can be managed without leaving the WebUI.
+
+## Unified capability summary
+
+| Area | Primary purpose |
+|---|---|
+| Agent | Run and steer the autonomous Attack → Target → Judge loop |
+| Live | Observe current or historical activity from overview to raw evidence |
+| Compose | Build exact payloads and maintain multi-turn target conversations |
+| Workflows | Sequence, configure, alias, clone, and replay capabilities |
+| Arsenal | Search presets, transforms, tools, and schemas |
+| Findings | Investigate bypass and partial-compliance evidence across runs |
+| Runs and Logs | Filter, correlate, inspect, and export canonical history |
+| Reports | Compare outcomes and generate portable evidence reports |
+| Models | Verify providers and manage models and role profiles |
+| Settings | Configure runtime behavior and local operator preferences |
+
+For installation and local operation, see the [setup guide](SETUP.md). For the complete
+harness feature inventory, see the [project README](../README.md).
diff --git a/docs/images/webui-v2/agent.png b/docs/images/webui-v2/agent.png
new file mode 100644
index 0000000000000000000000000000000000000000..3827906d326a1f8b78b6c3d4f81fd0b002d13c8e
GIT binary patch
literal 71208
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