splitagent 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3067 @@
1
+ /* SplitAgent desktop front-end. */
2
+
3
+ const state = {
4
+ bootstrap: null,
5
+ config: {},
6
+ project: {},
7
+ findings: [],
8
+ mitigations: [],
9
+ running: false,
10
+ chatting: false,
11
+ mode: "audit",
12
+ round: 0,
13
+ totalRounds: 0,
14
+ rounds: [],
15
+ phase: "idle",
16
+ resilience: 100,
17
+ events: 0,
18
+ reportPaths: [],
19
+ currentMsg: {},
20
+ rawText: {},
21
+ tools: {},
22
+ thinking: { red: "", blue: "", assistant: "" },
23
+ runningTools: { red: 0, blue: 0, assistant: 0 },
24
+ toolLog: { red: [], blue: [], assistant: [] },
25
+ chatTurns: 0,
26
+ objective: "",
27
+ models: [],
28
+ connected: [],
29
+ popular: [],
30
+ modelsLoaded: false,
31
+ palette: { items: [], index: 0, filtered: [], onPick: null },
32
+ wizard: { step: 0, kind: "web", depth: "standard" },
33
+ };
34
+
35
+ const $ = (sel, root = document) => root.querySelector(sel);
36
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
37
+
38
+ function reportJsError(message) {
39
+ try {
40
+ if (window.pywebview && window.pywebview.api && window.pywebview.api.log_js_error) {
41
+ window.pywebview.api.log_js_error(String(message));
42
+ }
43
+ } catch (err) {
44
+ /* ignore */
45
+ }
46
+ }
47
+ window.addEventListener("error", (e) =>
48
+ reportJsError(`${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`)
49
+ );
50
+ window.addEventListener("unhandledrejection", (e) => reportJsError(`unhandled: ${e.reason}`));
51
+
52
+ // Drives the live response timers.
53
+ setInterval(() => {
54
+ Object.keys(state.currentMsg || {}).forEach((agent) => {
55
+ const wrap = state.currentMsg[agent];
56
+ if (!wrap) return;
57
+ const el = wrap.querySelector(".msg-timer");
58
+ if (!el || !el.classList.contains("running")) return;
59
+ const seconds = (Date.now() - Number(el.dataset.start || Date.now())) / 1000;
60
+ el.textContent = `Thinking… ${seconds.toFixed(1)}s`;
61
+ });
62
+ }, 100);
63
+
64
+ const AGENTS = {
65
+ red: { name: "Red Agent", cls: "red" },
66
+ blue: { name: "Blue Agent", cls: "blue" },
67
+ assistant: { name: "Copilot", cls: "accent" },
68
+ core: { name: "System", cls: "core" },
69
+ };
70
+
71
+ /* ── bridge ─────────────────────────────────────────────── */
72
+ function api(name, ...args) {
73
+ return new Promise((resolve) => {
74
+ if (!window.pywebview || !window.pywebview.api || !window.pywebview.api[name]) {
75
+ resolve({ ok: false, error: "bridge not ready" });
76
+ return;
77
+ }
78
+ try {
79
+ Promise.resolve(window.pywebview.api[name](...args)).then(resolve, (err) =>
80
+ resolve({ ok: false, error: String(err) })
81
+ );
82
+ } catch (err) {
83
+ resolve({ ok: false, error: String(err) });
84
+ }
85
+ });
86
+ }
87
+
88
+ /* ── helpers ────────────────────────────────────────────── */
89
+ function escapeHtml(value) {
90
+ return String(value == null ? "" : value)
91
+ .replace(/&/g, "&")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;")
94
+ .replace(/"/g, "&quot;");
95
+ }
96
+
97
+ function md(src) {
98
+ if (!src) return "";
99
+ const blocks = [];
100
+ let text = String(src).replace(/```[^\n]*\n?([\s\S]*?)```/g, (m, code) => {
101
+ blocks.push(code.replace(/\n$/, ""));
102
+ return `\u0000B${blocks.length - 1}\u0000`;
103
+ });
104
+ text = escapeHtml(text);
105
+ text = text.replace(/^###\s?(.*)$/gm, "<h3>$1</h3>");
106
+ text = text.replace(/^##\s?(.*)$/gm, "<h2>$1</h2>");
107
+ text = text.replace(/^#\s?(.*)$/gm, "<h1>$1</h1>");
108
+ text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
109
+ text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
110
+ text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
111
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" data-external="$2">$1</a>');
112
+ text = text.replace(/(?:^|\n)((?:[-*]\s.+\n?)+)/g, (m, group) => {
113
+ const items = group
114
+ .trim()
115
+ .split("\n")
116
+ .map((line) => `<li>${line.replace(/^[-*]\s/, "")}</li>`)
117
+ .join("");
118
+ return `\n<ul>${items}</ul>`;
119
+ });
120
+ text = text
121
+ .split(/\n{2,}/)
122
+ .map((chunk) => {
123
+ if (/^\s*<(h[1-3]|ul|pre|blockquote)/.test(chunk) || /^\u0000B\d+\u0000\s*$/.test(chunk.trim())) {
124
+ return chunk;
125
+ }
126
+ return `<p>${chunk.replace(/\n/g, "<br>")}</p>`;
127
+ })
128
+ .join("");
129
+ text = text.replace(/\u0000B(\d+)\u0000/g, (m, i) => `<pre><code>${escapeHtml(blocks[Number(i)])}</code></pre>`);
130
+ return text;
131
+ }
132
+
133
+ function nowTime() {
134
+ return new Date().toTimeString().slice(0, 8);
135
+ }
136
+
137
+ let toastTimer = null;
138
+ function toast(message, isError = false) {
139
+ const el = $("#toast");
140
+ el.textContent = message;
141
+ el.classList.toggle("err", !!isError);
142
+ el.classList.remove("hidden");
143
+ clearTimeout(toastTimer);
144
+ toastTimer = setTimeout(() => el.classList.add("hidden"), 3200);
145
+ }
146
+
147
+ /* ── mode ───────────────────────────────────────────────── */
148
+ function setMode(mode) {
149
+ state.mode = mode;
150
+ $$("#mode-switch .mode-btn").forEach((b) =>
151
+ b.classList.toggle("active", b.dataset.mode === mode)
152
+ );
153
+ $("#view-audit").classList.toggle("hidden", mode !== "audit");
154
+ $("#view-chat").classList.toggle("hidden", mode !== "chat");
155
+ $("#st-mode").textContent = mode;
156
+ }
157
+
158
+ /* ── pinned activity feed ───────────────────────────────────
159
+ The Thinking / Exploring / working hints must always sit at the bottom of
160
+ the stream, right under the latest content, instead of scrolling away above
161
+ a long message. We keep one sticky container per stream and park the
162
+ activity lines in it. */
163
+ function activityHostFor(agent) {
164
+ const stream = streamFor(agent);
165
+ let host = stream.querySelector(".activity-host");
166
+ if (!host) {
167
+ host = document.createElement("div");
168
+ host.className = "activity-host";
169
+ stream.appendChild(host);
170
+ }
171
+ stream.appendChild(host); // hoist to the very end
172
+ return host;
173
+ }
174
+
175
+ /* ── stream routing ─────────────────────────────────────── */
176
+ function streamFor(agent) {
177
+ return agent === "assistant" ? $("#chat-stream") : $("#stream");
178
+ }
179
+ function scrollStream(agent) {
180
+ const el = streamFor(agent);
181
+ el.scrollTop = el.scrollHeight;
182
+ }
183
+ function clearEmptyFor(agent) {
184
+ const id = agent === "assistant" ? "#chat-empty" : "#stream-empty";
185
+ const el = $(id);
186
+ if (el) el.remove();
187
+ }
188
+
189
+ function addDivider(agent, label) {
190
+ finalize(agent);
191
+ clearEmptyFor(agent);
192
+ const div = document.createElement("div");
193
+ div.className = `divider ${AGENTS[agent] ? AGENTS[agent].cls : "core"}`;
194
+ div.innerHTML = `<span class="tag">${escapeHtml(label)}</span>`;
195
+ streamFor(agent).appendChild(div);
196
+ scrollStream(agent);
197
+ }
198
+
199
+ function ensureMsg(agent) {
200
+ if (state.currentMsg[agent]) return state.currentMsg[agent];
201
+ clearEmptyFor(agent);
202
+ const meta = AGENTS[agent] || AGENTS.core;
203
+ const wrap = document.createElement("div");
204
+ wrap.className = "msg";
205
+ wrap.innerHTML = `
206
+ <div class="msg-head">
207
+ <span class="msg-name ${meta.cls}">${meta.name}</span>
208
+ <span class="msg-time">${nowTime()}</span>
209
+ </div>
210
+ <div class="msg-body"></div>`;
211
+ streamFor(agent).appendChild(wrap);
212
+ state.currentMsg[agent] = wrap;
213
+ state.rawText[agent] = "";
214
+ startTimer(agent);
215
+ return wrap;
216
+ }
217
+
218
+ function appendText(agent, text) {
219
+ const wrap = ensureMsg(agent);
220
+ state.rawText[agent] += text;
221
+ $(".msg-body", wrap).textContent = state.rawText[agent];
222
+ scrollStream(agent);
223
+ }
224
+
225
+ function finalize(agent) {
226
+ const wrap = state.currentMsg[agent];
227
+ if (!wrap) return;
228
+ $(".msg-body", wrap).innerHTML = md(state.rawText[agent]);
229
+ stopTimer(agent);
230
+ state.currentMsg[agent] = null;
231
+ state.rawText[agent] = "";
232
+ }
233
+
234
+ /* One activity line per agent. The screen must never fill with "Thinking" and
235
+ "Exploring" at once: a single line flips between them depending on what the
236
+ agent is doing right now. The full reasoning stays available behind the
237
+ chevron. */
238
+ function setActivity(agent, mode, text) {
239
+ const host = activityHostFor(agent);
240
+ let line = $("#thinking-" + agent, host);
241
+ if (!line) {
242
+ line = document.createElement("div");
243
+ line.id = "thinking-" + agent;
244
+ line.className = "thinking-line";
245
+ line.innerHTML = `
246
+ <span class="tl-label">Thinking</span>
247
+ <span class="tl-text shimmer"></span>
248
+ <span class="tl-chevron">\u203a</span>`;
249
+ line.addEventListener("click", () => {
250
+ const body = $("#thinking-body-" + agent, host);
251
+ if (!body) return;
252
+ const shown = body.classList.toggle("show");
253
+ line.querySelector(".tl-chevron").textContent = shown ? "\u203e" : "\u203a";
254
+ });
255
+ clearEmptyFor(agent);
256
+ host.appendChild(line);
257
+ const body = document.createElement("div");
258
+ body.id = "thinking-body-" + agent;
259
+ body.className = "thinking-body";
260
+ host.appendChild(body);
261
+ }
262
+ const label = $(".tl-label", line);
263
+ const body = $(".tl-text", line);
264
+ label.textContent = mode;
265
+ label.classList.toggle("shimmer", true);
266
+ body.classList.toggle("shimmer", true);
267
+ if (text != null) body.textContent = text;
268
+ line.classList.add("busy");
269
+ line.dataset.mode = mode;
270
+ scrollStream(agent);
271
+ }
272
+
273
+ function addThinking(agent, text) {
274
+ if (!text) return;
275
+ state.thinking[agent] = (state.thinking[agent] || "") + text;
276
+ // Keep the freshest line as the visible excerpt, and let Exploring win while
277
+ // tools are actually running.
278
+ const heading = text.trim().split("\n").filter(Boolean).pop() || "Thinking";
279
+ const running = (state.runningTools[agent] || 0) > 0;
280
+ setActivity(agent, running ? "Exploring" : "Thinking", running ? "running tools…" : heading.slice(-150));
281
+ const host = activityHostFor(agent);
282
+ const body = $("#thinking-body-" + agent, host);
283
+ if (body) body.textContent = state.thinking[agent];
284
+ }
285
+
286
+ function finalizeThinking(agent) {
287
+ clearWorking(agent);
288
+ const parent = streamFor(agent);
289
+ const line = $("#thinking-" + agent, parent);
290
+ if (!line) return;
291
+ const host = parent.querySelector(".activity-host");
292
+ if (!host || !host.contains(line)) return;
293
+ line.classList.remove("busy");
294
+ $(".tl-label", line).textContent = "Thought";
295
+ const label = $(".tl-label", line);
296
+ const text = $(".tl-text", line);
297
+ if (label) label.classList.remove("shimmer");
298
+ if (text) text.classList.remove("shimmer");
299
+ state.thinking[agent] = "";
300
+ }
301
+
302
+ /* Grouped "Exploring · 3 reads, 1 search" surface, exactly like OpenCode:
303
+ one collapsible header per tool group; individual calls only appear when
304
+ the group is expanded. */
305
+ function toolVerb(name) {
306
+ if (/(scan|recon|dns|port)/.test(name)) return { group: "Recon", noun: "scan" };
307
+ if (/(http|audit|crawl|probe|request)/.test(name)) return { group: "Web", noun: "request" };
308
+ if (/(test_|exploit)/.test(name)) return { group: "Probes", noun: "test" };
309
+ if (/(log|harden|firewall|patch|verify)/.test(name)) return { group: "Defence", noun: "control" };
310
+ if (/(finding|mitigation|context)/.test(name)) return { group: "Context", noun: "note" };
311
+ return { group: "Tools", noun: "call" };
312
+ }
313
+
314
+ function updateRunSummary(agent) {
315
+ clearEmptyFor(agent);
316
+ const host = activityHostFor(agent);
317
+ let el = $("#run-summary-" + agent, host);
318
+ if (!el) {
319
+ el = document.createElement("div");
320
+ el.id = "run-summary-" + agent;
321
+ el.className = "run-summary";
322
+ el.innerHTML = `
323
+ <span class="rs-title">
324
+ <span class="rs-label"></span>
325
+ <span class="rs-counts"></span>
326
+ </span>
327
+ <span class="rs-chevron">\u203a</span>`;
328
+ el.addEventListener("click", () => {
329
+ el.classList.toggle("open");
330
+ const log = $("#rs-log-" + agent, host);
331
+ if (log) log.classList.toggle("show", el.classList.contains("open"));
332
+ });
333
+ host.appendChild(el);
334
+ const log = document.createElement("div");
335
+ log.id = "rs-log-" + agent;
336
+ log.className = "rs-log";
337
+ host.appendChild(log);
338
+ }
339
+
340
+ const running = state.runningTools[agent] || 0;
341
+ const log = state.toolLog[agent] || [];
342
+ const counts = {};
343
+ log.forEach((name) => {
344
+ const { noun } = toolVerb(name);
345
+ counts[noun] = (counts[noun] || 0) + 1;
346
+ });
347
+ const summary = Object.entries(counts)
348
+ .map(([noun, n]) => `${n} ${noun}${n === 1 ? "" : "s"}`)
349
+ .join(", ");
350
+ const label = running > 0 ? "Exploring" : "Explored";
351
+ // When tools finish, the same activity line flips back to Thinking.
352
+ if (running === 0) {
353
+ const line = $("#thinking-" + agent, activityHostFor(agent));
354
+ if (line) {
355
+ $(".tl-label", line).textContent = "Thinking";
356
+ $(".tl-text", line).textContent = "considering next steps…";
357
+ }
358
+ }
359
+
360
+ $(".rs-label", el).textContent = label;
361
+ $(".rs-counts", el).textContent = summary ? " · " + summary : "";
362
+ el.classList.toggle("busy", running > 0);
363
+
364
+ scrollStream(agent);
365
+ }
366
+
367
+ function addTool(agent, data) {
368
+ finalize(agent);
369
+ clearEmptyFor(agent);
370
+ const block = document.createElement("div");
371
+ block.className = "tool";
372
+ const args = JSON.stringify(data.arguments || {});
373
+ const now = nowTime();
374
+ block.innerHTML = `
375
+ <div class="tool-head">
376
+ <span class="tool-status running"></span>
377
+ <span class="tool-name">${escapeHtml(data.tool || "tool")}</span>
378
+ <span class="tool-args">${escapeHtml(args.length > 160 ? args.slice(0, 160) + "..." : args)}</span>
379
+ <span class="tool-count">${escapeHtml(now)}</span>
380
+ <span class="tool-chevron">\u203a</span>
381
+ </div>
382
+ <div class="tool-body"><pre class="tool-out">running...</pre></div>`;
383
+ // Always key by a stable id: if the backend omitted call_id, derive one from
384
+ // the tool + a sequence so finishTool can still find and settle this call.
385
+ const callKey =
386
+ data.call_id != null && data.call_id !== ""
387
+ ? String(data.call_id)
388
+ : `${data.tool || "tool"}#${(state._toolSeq = (state._toolSeq || 0) + 1)}`;
389
+ state.tools[callKey] = block;
390
+ block.dataset.call = callKey;
391
+ // FIFO of in-flight calls per agent, so a result without a call_id can still
392
+ // settle the oldest pending tool instead of leaking the running counter.
393
+ state.pendingTools = state.pendingTools || {};
394
+ (state.pendingTools[agent] = state.pendingTools[agent] || []).push(callKey);
395
+ // Detail lives inside the collapsed group, never directly in the timeline.
396
+ block.classList.add("hidden");
397
+ const groupItem = document.createElement("div");
398
+ groupItem.className = "rs-log-item";
399
+ groupItem.dataset.call = callKey;
400
+ const compactArgs = compactJson(data.arguments || {});
401
+ groupItem.innerHTML = `
402
+ <div class="rs-log-row">
403
+ <span class="tool-status running"></span>
404
+ <span class="rs-log-name">${escapeHtml(data.tool || "tool")}</span>
405
+ <span class="rs-log-args">${escapeHtml(compactArgs)}</span>
406
+ <span class="rs-log-chevron">\u203a</span>
407
+ </div>
408
+ <div class="rs-log-detail"></div>`;
409
+ $(".rs-log-row", groupItem).addEventListener("click", (e) => {
410
+ e.stopPropagation();
411
+ groupItem.classList.toggle("open");
412
+ const detail = $(".rs-log-detail", groupItem);
413
+ const card = state.tools[callKey];
414
+ if (card) card.classList.toggle("open", groupItem.classList.contains("open"));
415
+ if (detail.classList.contains("show")) {
416
+ detail.classList.remove("show");
417
+ if (card) card.remove();
418
+ } else {
419
+ detail.classList.add("show");
420
+ if (card) {
421
+ // The card is hidden while parked in state; reveal it once it is the
422
+ // expanded detail. (.hidden uses !important, so it must be removed.)
423
+ card.classList.remove("hidden");
424
+ detail.appendChild(card);
425
+ } else {
426
+ // No card kept for this call: surface whatever output the row has.
427
+ detail.textContent = detail.dataset.output || "";
428
+ }
429
+ }
430
+ });
431
+ getRsLog(agent).appendChild(groupItem);
432
+ state.runningTools[agent] = (state.runningTools[agent] || 0) + 1;
433
+ state.toolLog[agent] = [...(state.toolLog[agent] || []), data.tool || "tool"];
434
+ updateRunSummary(agent);
435
+ scrollStream(agent);
436
+ }
437
+
438
+ function compactJson(value) {
439
+ const text = JSON.stringify(value || {});
440
+ return text === "{}" ? "" : text.length > 120 ? text.slice(0, 120) + "..." : text;
441
+ }
442
+
443
+ /* The group container is created once per agent; its contents are populated
444
+ incrementally so the timeline stays compact. */
445
+ function getRsLog(agent) {
446
+ const parent = streamFor(agent);
447
+ let log = $("#rs-log-" + agent, parent);
448
+ if (!log) {
449
+ updateRunSummary(agent);
450
+ log = $("#rs-log-" + agent, parent);
451
+ }
452
+ return log;
453
+ }
454
+
455
+ function finishTool(agent, data) {
456
+ // Resolve the call: by id when present, otherwise the oldest in-flight call
457
+ // for this agent (the backend omits call_id on some paths).
458
+ let callKey = data.call_id != null && data.call_id !== "" ? String(data.call_id) : null;
459
+ const pending = (state.pendingTools && state.pendingTools[agent]) || [];
460
+ if (!callKey || !state.tools[callKey]) {
461
+ callKey = pending.shift() || callKey;
462
+ } else {
463
+ const idx = pending.indexOf(callKey);
464
+ if (idx !== -1) pending.splice(idx, 1);
465
+ }
466
+ const block = callKey ? state.tools[callKey] : null;
467
+ if (!block) return;
468
+ const status = $(".tool-status", block);
469
+ const out = $(".tool-out", block);
470
+ const text = String(data.output == null ? "" : data.output);
471
+ const isError = /"error"|^\s*error|Traceback|out_of_scope/.test(text.slice(0, 120));
472
+ status.className = "tool-status " + (isError ? "error" : "done");
473
+ status.textContent = isError ? "!" : "\u2713";
474
+ out.textContent = text;
475
+ block.dataset.output = text;
476
+ // Mirror the state on the group row.
477
+ const row = document.querySelector(
478
+ `.rs-log-item[data-call="${CSS.escape(callKey)}"]`
479
+ );
480
+ if (row) {
481
+ const rowStatus = $(".tool-status", row);
482
+ rowStatus.className = "tool-status " + (isError ? "error" : "done");
483
+ rowStatus.textContent = isError ? "!" : "\u2713";
484
+ row.classList.toggle("error", isError);
485
+ }
486
+ state.runningTools[agent] = Math.max(0, (state.runningTools[agent] || 0) - 1);
487
+ updateRunSummary(agent);
488
+ }
489
+
490
+ function addInlineFinding(data) {
491
+ const parent = streamFor("red");
492
+ clearEmptyFor("red");
493
+ const card = document.createElement("div");
494
+ card.className = `inline-finding ${data.severity || "info"}`;
495
+ card.innerHTML = `
496
+ <div class="if-head">
497
+ <span class="sev ${data.severity || "info"}">${(data.severity || "info").toUpperCase()}</span>
498
+ <span class="if-title">${escapeHtml(data.title || "")}</span>
499
+ </div>
500
+ <div class="if-meta">${escapeHtml(data.id || "")}</div>`;
501
+ parent.appendChild(card);
502
+ parent.scrollTop = parent.scrollHeight;
503
+ }
504
+
505
+ function addCompactionCard(data) {
506
+ clearEmptyFor("red");
507
+ finalize("red");
508
+ const el = document.createElement("div");
509
+ el.className = "compaction";
510
+ el.innerHTML = `
511
+ <div class="cp-head">
512
+ <span class="cp-badge">CONTEXT CHECKPOINT</span>
513
+ <span class="cp-meta">compacted ~${data.removed} tokens · kept ~${data.preserved}</span>
514
+ </div>
515
+ <pre class="cp-summary">${escapeHtml(data.summary || "")}</pre>
516
+ <div class="cp-note">Earlier details are cleared from the model context but remain in the full trace and session file.</div>`;
517
+ streamFor("red").appendChild(el);
518
+ streamFor("red").scrollTop = streamFor("red").scrollHeight;
519
+ }
520
+
521
+ function addTrace(kind, data) {
522
+ state.trace = state.trace || [];
523
+ state.trace.push({ kind, data, ts: nowTime() });
524
+ if (state.trace.length > 400) state.trace.shift();
525
+ renderTrace();
526
+ }
527
+
528
+ function traceDetail(kind, data) {
529
+ if (kind === "tool_call") {
530
+ return `${data.tool}(${JSON.stringify(data.arguments || {})})`;
531
+ }
532
+ if (kind === "tool_result") {
533
+ return String(data.output || "");
534
+ }
535
+ if (kind === "context") {
536
+ return `estimated ${data.estimated} / usable ${data.usable} tokens (${data.percent}%) · messages ${data.messages}`;
537
+ }
538
+ if (kind === "usage") {
539
+ return Object.entries(data)
540
+ .map(([k, v]) => `${k}=${v}`)
541
+ .join(" ");
542
+ }
543
+ if (kind === "prune") {
544
+ return `freed ~${data.removed} tokens · protected ${data.protected}`;
545
+ }
546
+ if (kind === "compaction") {
547
+ return `removed ~${data.removed} · preserved ~${data.preserved}\n${data.summary || ""}`;
548
+ }
549
+ return JSON.stringify(data || {});
550
+ }
551
+
552
+ function renderTrace() {
553
+ const panel = $("#panel-trace");
554
+ const trace = state.trace || [];
555
+ let list = panel.querySelector(".trace-list");
556
+ if (!trace.length) {
557
+ if (list) list.innerHTML = "";
558
+ if (!panel.querySelector(".trace-empty")) {
559
+ const empty = document.createElement("div");
560
+ empty.className = "trace-empty";
561
+ empty.textContent = "Nothing captured yet.";
562
+ panel.appendChild(empty);
563
+ }
564
+ return;
565
+ }
566
+ const emptyEl = panel.querySelector(".trace-empty");
567
+ if (emptyEl) emptyEl.remove();
568
+ if (!list) {
569
+ list = document.createElement("div");
570
+ list.className = "trace-list";
571
+ panel.appendChild(list);
572
+ }
573
+ // Keep only the newest 400 rows; the buffer is capped the same way, so the
574
+ // DOM can never drift out of sync with state.trace.
575
+ if (list.childElementCount > trace.length) {
576
+ while (list.childElementCount > trace.length) list.removeChild(list.firstChild);
577
+ }
578
+ const start = list.childElementCount;
579
+ for (let i = start; i < trace.length; i += 1) {
580
+ const entry = trace[i];
581
+ const el = document.createElement("div");
582
+ el.className = `trace-item kind-${entry.kind}`;
583
+ el.innerHTML = `
584
+ <div class="ti-head">
585
+ <span class="ti-kind">${escapeHtml(entry.kind)}</span>
586
+ <span class="ti-time">${escapeHtml(entry.ts)}</span>
587
+ </div>
588
+ <pre>${escapeHtml(traceDetail(entry.kind, entry.data))}</pre>`;
589
+ list.appendChild(el);
590
+ }
591
+ // The scroll container is the review body, not the panel itself.
592
+ const scroller = panel.closest(".review-body") || panel;
593
+ scroller.scrollTop = scroller.scrollHeight;
594
+ }
595
+
596
+ async function dumpTrace() {
597
+ const text = JSON.stringify(
598
+ {
599
+ session: state.sessionId || null,
600
+ policy: state.policy || null,
601
+ context: state.context || null,
602
+ trace: state.trace || [],
603
+ },
604
+ null,
605
+ 2
606
+ );
607
+ const res = await api("save_trace", text);
608
+ if (res && res.ok) {
609
+ toast("Trace saved");
610
+ api("reveal", res.path);
611
+ } else {
612
+ toast((res && res.error) || "could not save trace", true);
613
+ }
614
+ }
615
+
616
+ /* ── Workspace ──────────────────────────────────────────── */
617
+ function renderWorkspace(data) {
618
+ if (!data) return;
619
+ state.workspace = data;
620
+ const name = String(data.root || "").split(/[\\/]/).filter(Boolean).pop() || "workspace";
621
+ $("#ws-name").textContent = name;
622
+ const counts = data.file_counts || {};
623
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
624
+ $("#ws-badge").textContent = `${total} files`;
625
+ $("#btn-workspace").title = `${data.root}\ninstallers: ${(data.installers || []).join(", ") || "none"}`;
626
+ }
627
+
628
+ async function openWorkspace() {
629
+ const res = await api("workspace_open");
630
+ if (!res || !res.ok) {
631
+ toast((res && res.error) || "could not open the workspace", true);
632
+ return;
633
+ }
634
+ const info = await api("workspace_info");
635
+ if (info && info.ok) renderWorkspace(info);
636
+ toast("Workspace opened");
637
+ }
638
+
639
+ /* ── Toolbox ────────────────────────────────────────────── */
640
+ function renderToolbox(status) {
641
+ if (!status) return;
642
+ state.toolbox = status;
643
+ const container = status.container || "toolbox";
644
+ $("#tb-name").textContent = container;
645
+ const badge = $("#tb-badge");
646
+ if (status.running) {
647
+ const count = Object.values(status.tools || {}).filter(Boolean).length;
648
+ badge.textContent = `${count} tools`;
649
+ badge.className = "ws-badge on";
650
+ } else if (status.usable && status.image) {
651
+ badge.textContent = "stopped";
652
+ badge.className = "ws-badge off";
653
+ } else if (!status.docker_cli) {
654
+ badge.textContent = "no docker";
655
+ badge.className = "ws-badge off";
656
+ } else {
657
+ badge.textContent = "not built";
658
+ badge.className = "ws-badge off";
659
+ }
660
+ $("#btn-toolbox").title =
661
+ `mode ${status.effective_mode} · ${status.message}` +
662
+ (status.edition ? `\nedition ${status.edition}` : "");
663
+ }
664
+
665
+ async function refreshToolbox() {
666
+ const res = await api("toolbox_status");
667
+ if (res && res.ok) renderToolbox(res.status);
668
+ return res && res.status;
669
+ }
670
+
671
+ function fillToolboxDialog(status) {
672
+ if (!status) return;
673
+ const set = (id, text, cls = "") => {
674
+ const el = $(id);
675
+ if (!el) return;
676
+ el.textContent = text;
677
+ el.className = cls;
678
+ };
679
+ set("#tb-docker", status.docker_cli ? "installed" : "not found", status.docker_cli ? "ok" : "bad");
680
+ set("#tb-daemon", status.daemon ? "running" : "stopped", status.daemon ? "ok" : "bad");
681
+ set("#tb-image", status.image ? "built" : "not built", status.image ? "ok" : "bad");
682
+ set("#tb-workspace", status.workspace || "—");
683
+ $("#tb-edition").value = status.edition || "standard";
684
+ const hint = $("#tb-hint");
685
+ if (!status.docker_cli) {
686
+ hint.textContent =
687
+ "Docker was not found. Install Docker Desktop, then reopen this dialog to " +
688
+ "enable the isolated environment. Until then the agents run in local mode.";
689
+ } else if (!status.daemon) {
690
+ hint.textContent =
691
+ "Docker is installed but not running. SplitAgent will start it for you.";
692
+ } else {
693
+ hint.textContent =
694
+ "The image is built once. After that the toolbox starts silently in the " +
695
+ "background and you never have to touch Docker again.";
696
+ }
697
+ $("#tb-install").disabled = !status.docker_cli || !!state.toolboxBusy;
698
+ $("#tb-install").dataset.noDocker = status.docker_cli ? "0" : "1";
699
+ }
700
+
701
+ async function openToolboxDialog() {
702
+ closeModals();
703
+ openModal("#modal-toolbox");
704
+ $("#tb-progress").textContent = "";
705
+ $("#tb-progress").className = "test-result";
706
+ const status = await refreshToolbox();
707
+ fillToolboxDialog(status);
708
+ }
709
+
710
+ function setToolboxBusy(busy) {
711
+ state.toolboxBusy = busy;
712
+ const install = $("#tb-install");
713
+ const skip = $("#tb-skip");
714
+ if (install) install.disabled = busy || install.dataset.noDocker === "1";
715
+ if (skip) skip.disabled = busy;
716
+ const badge = $("#tb-badge");
717
+ if (badge && busy) {
718
+ badge.textContent = "setting up…";
719
+ badge.className = "ws-badge off";
720
+ }
721
+ }
722
+
723
+ async function installToolbox() {
724
+ const progress = $("#tb-progress");
725
+ progress.textContent = "starting…";
726
+ progress.className = "test-result";
727
+ setToolboxBusy(true);
728
+ const res = await api("toolbox_setup", {
729
+ edition: $("#tb-edition").value,
730
+ build: true,
731
+ });
732
+ // The work happens in a worker thread; progress arrives via events.
733
+ if (!res || !res.ok) {
734
+ setToolboxBusy(false);
735
+ progress.textContent = (res && res.error) || "setup failed";
736
+ progress.className = "test-result bad";
737
+ toast((res && res.error) || "toolbox setup failed", true);
738
+ }
739
+ }
740
+
741
+ async function toolboxAction(action) {
742
+ const res = await api("toolbox_action", action);
743
+ if (res && res.async) {
744
+ setToolboxBusy(true);
745
+ toast(`Toolbox ${action}…`);
746
+ return;
747
+ }
748
+ await refreshToolbox();
749
+ if (res && res.ok) toast(`Toolbox ${action} ok`);
750
+ else if (res && res.error) toast(res.error, true);
751
+ }
752
+
753
+ function onToolboxProgress(data) {
754
+ const progress = $("#tb-progress");
755
+ const text = data.text || data.stage;
756
+ if (progress) {
757
+ progress.textContent = text;
758
+ progress.className = "test-result";
759
+ }
760
+ const badge = $("#tb-badge");
761
+ if (badge) {
762
+ badge.textContent = data.stage === "build" ? "building…" : "starting…";
763
+ badge.className = "ws-badge off";
764
+ }
765
+ addActivity(`toolbox: ${text}`);
766
+ }
767
+
768
+ function onToolboxReady(status) {
769
+ setToolboxBusy(false);
770
+ if (status) {
771
+ renderToolbox(status);
772
+ fillToolboxDialog(status);
773
+ }
774
+ const progress = $("#tb-progress");
775
+ if (progress) {
776
+ progress.textContent = "Ready — the agents now run isolated.";
777
+ progress.className = "test-result ok";
778
+ }
779
+ addActivity(
780
+ `toolbox ready (${status?.edition || "?"}, ${status?.container || "?"})`
781
+ );
782
+ toast("Isolated environment ready");
783
+ setTimeout(closeModals, 1400);
784
+ }
785
+
786
+ function onToolboxFailed(data) {
787
+ setToolboxBusy(false);
788
+ const progress = $("#tb-progress");
789
+ const message = (data && data.error) || "setup failed";
790
+ if (progress) {
791
+ progress.textContent = message;
792
+ progress.className = "test-result bad";
793
+ }
794
+ addActivity(`toolbox error: ${message}`, true);
795
+ if (data && data.output) addActivity(data.output.slice(-1200), true);
796
+ toast(message, true);
797
+ refreshToolbox();
798
+ }
799
+
800
+ /* ── Todo dock ──────────────────────────────────────────── */
801
+ function renderTodos(todos) {
802
+ state.todos = todos || [];
803
+ const dock = $("#todo-dock");
804
+ if (!dock) return;
805
+ if (!state.todos.length) {
806
+ dock.classList.add("hidden");
807
+ return;
808
+ }
809
+ dock.classList.remove("hidden");
810
+ const done = state.todos.filter((t) => t.status === "completed").length;
811
+ const total = state.todos.length;
812
+ $("#td-done").textContent = done;
813
+ $("#td-total").textContent = total;
814
+ const active =
815
+ state.todos.find((t) => t.status === "in_progress") ||
816
+ state.todos.find((t) => t.status === "pending") ||
817
+ [...state.todos].reverse().find((t) => t.status === "completed") ||
818
+ state.todos[0];
819
+ $("#td-preview").textContent = dock.classList.contains("collapsed")
820
+ ? (active && active.content) || ""
821
+ : "";
822
+ $("#td-list").innerHTML = state.todos
823
+ .map(
824
+ (todo) => `
825
+ <div class="todo-item" data-state="${escapeHtml(todo.status)}">
826
+ <span class="todo-box">
827
+ ${
828
+ todo.status === "in_progress"
829
+ ? '<svg class="todo-spinner" viewBox="0 0 12 12" width="10" height="10" fill="currentColor"><circle cx="6" cy="6" r="3"/></svg>'
830
+ : '<svg class="todo-tick" viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 6.2 4.6 8.3 9.5 3.6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
831
+ }
832
+ </span>
833
+ <span class="todo-text">${escapeHtml(todo.content)}</span>
834
+ </div>`
835
+ )
836
+ .join("");
837
+ }
838
+
839
+ function toggleTodoDock() {
840
+ const dock = $("#todo-dock");
841
+ dock.classList.toggle("collapsed");
842
+ renderTodos(state.todos || []);
843
+ }
844
+
845
+ /* ── Dynamic working state ──────────────────────────────── */
846
+ function setWorking(agent, step, maxSteps) {
847
+ state.working = state.working || {};
848
+ state.working[agent] = { step, maxSteps };
849
+ // Reuse the single activity line; never stack a second one.
850
+ const running = (state.runningTools[agent] || 0) > 0;
851
+ setActivity(
852
+ agent,
853
+ running ? "Exploring" : "Thinking",
854
+ running ? "running tools…" : "considering next steps…"
855
+ );
856
+ }
857
+
858
+ function clearWorking(agent) {
859
+ // No separate working line exists any more; kept for call-site compatibility.
860
+ void agent;
861
+ }
862
+
863
+ /* ── response timer (Grok-style) ──────────────────────────
864
+ A small live counter under the header of the message that is currently
865
+ streaming: "Thinking… 3.4s" while it works, frozen to "+3.4s" when done. */
866
+ function startTimer(agent) {
867
+ const wrap = state.currentMsg[agent];
868
+ if (!wrap) return;
869
+ const head = $(".msg-head", wrap);
870
+ if (!head || $(".msg-timer", head)) return;
871
+ const el = document.createElement("span");
872
+ el.className = "msg-timer running";
873
+ el.textContent = "Thinking… 0.0s";
874
+ el.dataset.start = Date.now();
875
+ head.appendChild(el);
876
+ }
877
+
878
+ function stopTimer(agent) {
879
+ const wrap = state.currentMsg[agent];
880
+ if (!wrap) return;
881
+ const el = $(".msg-timer", wrap);
882
+ if (!el) return;
883
+ const seconds = Math.max(0, (Date.now() - Number(el.dataset.start || Date.now())) / 1000);
884
+ el.classList.remove("running");
885
+ el.textContent = `+${seconds.toFixed(1)}s`;
886
+ }
887
+
888
+ function updateContextMeter() {
889
+ const ctx = state.context;
890
+ const el = $("#st-context");
891
+ if (!el) return;
892
+ if (!ctx) {
893
+ el.textContent = "context —";
894
+ return;
895
+ }
896
+ el.textContent = `ctx ${ctx.percent}%`;
897
+ el.className = "st " + (ctx.percent >= 90 ? "warn" : ctx.percent >= 70 ? "mid" : "");
898
+ }
899
+
900
+ /* ── Dashboard: the at-a-glance result of the engagement ────
901
+ Rendered from live session state; no chart library, just CSS
902
+ bars and dots. It becomes the active tab when an audit ends. */
903
+
904
+ const SEVERITY_META = {
905
+ critical: { label: "Critical", color: "var(--red-strong)", order: 0 },
906
+ high: { label: "High", color: "var(--orange)", order: 1 },
907
+ medium: { label: "Medium", color: "var(--yellow)", order: 2 },
908
+ low: { label: "Low", color: "var(--green)", order: 3 },
909
+ info: { label: "Info", color: "var(--cyan)", order: 4 },
910
+ };
911
+
912
+ function severityMeta(severity) {
913
+ return SEVERITY_META[(severity || "info").toLowerCase()] || SEVERITY_META.info;
914
+ }
915
+
916
+ function worstSeverity(findings) {
917
+ for (const sev of ["critical", "high", "medium", "low", "info"]) {
918
+ if (findings.some((f) => (f.severity || "info").toLowerCase() === sev)) return sev;
919
+ }
920
+ return null;
921
+ }
922
+
923
+ function renderDashboard() {
924
+ const panel = $("#panel-dashboard");
925
+ if (!panel) return;
926
+
927
+ const findings = state.findings || [];
928
+ const mitigations = state.mitigations || [];
929
+
930
+ if (!findings.length && !(state.rounds || []).length && !state.running) {
931
+ panel.innerHTML =
932
+ '<div class="panel-empty">Run an audit to see the results here.</div>';
933
+ return;
934
+ }
935
+
936
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
937
+ findings.forEach((f) => {
938
+ const sev = (f.severity || "info").toLowerCase();
939
+ counts[counts[sev] === undefined ? "info" : sev] += 1;
940
+ });
941
+
942
+ const total = findings.length;
943
+ const worst = worstSeverity(findings);
944
+ const resilience = Math.round(state.resilience);
945
+ const riskClass = !worst
946
+ ? "ok"
947
+ : worst === "critical"
948
+ ? "bad"
949
+ : worst === "high" || worst === "medium"
950
+ ? "warn"
951
+ : "ok";
952
+ const riskLabel = !worst ? "No issues found" : `${severityMeta(worst).label} risk`;
953
+
954
+ const sevRows = Object.keys(SEVERITY_META)
955
+ .sort((a, b) => SEVERITY_META[a].order - SEVERITY_META[b].order)
956
+ .map((sev) => {
957
+ const count = counts[sev] || 0;
958
+ const pct = total ? Math.round((count / total) * 100) : 0;
959
+ return `
960
+ <div class="sev-row">
961
+ <span class="sev-name"><span class="sev-dot" style="background:${SEVERITY_META[sev].color}"></span>${SEVERITY_META[sev].label}</span>
962
+ <span class="sev-bar"><i style="width:${pct}%;background:${SEVERITY_META[sev].color}"></i></span>
963
+ <span class="sev-count">${count}</span>
964
+ </div>`;
965
+ })
966
+ .join("");
967
+
968
+ const top = [...findings]
969
+ .sort(
970
+ (a, b) =>
971
+ severityMeta(a.severity).order - severityMeta(b.severity).order ||
972
+ (b.cvss_score || 0) - (a.cvss_score || 0)
973
+ )
974
+ .slice(0, 6)
975
+ .map((f) => {
976
+ const sev = (f.severity || "info").toLowerCase();
977
+ return `
978
+ <div class="dash-finding">
979
+ <span class="sev-dot" style="background:${severityMeta(sev).color}"></span>
980
+ <span class="df-title">${escapeHtml(f.title || "Untitled")}</span>
981
+ <span class="df-where">${escapeHtml(f.endpoint || f.target || "")}</span>
982
+ <span class="df-cvss">${(f.cvss_score || 0).toFixed(1)}</span>
983
+ </div>`;
984
+ })
985
+ .join("") || '<div class="panel-empty">No findings yet.</div>';
986
+
987
+ const rounds = state.rounds || [];
988
+ const timeline = rounds
989
+ .map(
990
+ (r) => `
991
+ <div class="dash-round">
992
+ <div class="dr-head">Round ${r.index}<span>${r.findings || 0} findings · ${r.mitigations || 0} mitigations</span></div>
993
+ </div>`
994
+ )
995
+ .join("") || '<div class="panel-empty">No rounds recorded.</div>';
996
+
997
+ panel.innerHTML = `
998
+ <div class="dash-hero ${riskClass}">
999
+ <div class="dh-risk">${escapeHtml(riskLabel)}</div>
1000
+ <div class="dh-metrics">
1001
+ <div class="dh-metric"><b>${total}</b><span>findings</span></div>
1002
+ <div class="dh-metric"><b>${mitigations.length}</b><span>mitigations</span></div>
1003
+ <div class="dh-metric"><b>${state.round || 0}<small>/${state.totalRounds || 0}</small></b><span>rounds</span></div>
1004
+ <div class="dh-metric"><b>${resilience}<small>%</small></b><span>protected</span></div>
1005
+ </div>
1006
+ </div>
1007
+ <div class="dash-section">
1008
+ <div class="dash-title">Severity distribution</div>
1009
+ ${sevRows}
1010
+ </div>
1011
+ <div class="dash-section">
1012
+ <div class="dash-title">Top findings</div>
1013
+ ${top}
1014
+ </div>
1015
+ <div class="dash-section">
1016
+ <div class="dash-title">Rounds</div>
1017
+ ${timeline}
1018
+ </div>`;
1019
+ }
1020
+
1021
+ function focusTab(name) {
1022
+ const tab = $(`#review-tabs .rtab[data-tab="${name}"]`);
1023
+ if (!tab) return;
1024
+ $$(".rtab").forEach((t) => t.classList.remove("active"));
1025
+ $$(".rpanel").forEach((p) => p.classList.remove("active"));
1026
+ tab.classList.add("active");
1027
+ const panel = $("#panel-" + name);
1028
+ if (panel) panel.classList.add("active");
1029
+ }
1030
+
1031
+ /* ── panels ─────────────────────────────────────────────── */
1032
+ function addFinding(data) {
1033
+ state.findings.push(data);
1034
+ $("#count-findings").textContent = state.findings.length;
1035
+ const panel = $("#panel-findings");
1036
+ if ($(".panel-empty", panel)) $(".panel-empty", panel).remove();
1037
+ const el = document.createElement("div");
1038
+ el.className = `finding ${data.severity || "info"}`;
1039
+ el.innerHTML = `
1040
+ <div class="f-top">
1041
+ <span class="f-title">${escapeHtml(data.title || "")}</span>
1042
+ <span class="sev ${data.severity || "info"}">${(data.severity || "info").toUpperCase()}</span>
1043
+ </div>
1044
+ <div class="f-sub">${escapeHtml(data.id || "")} · open</div>`;
1045
+ panel.appendChild(el);
1046
+ addInlineFinding(data);
1047
+ state.findingIndex = state.findingIndex || {};
1048
+ state.findingIndex[data.id] = data;
1049
+ renderDashboard();
1050
+ }
1051
+
1052
+ function addMitigation(data) {
1053
+ state.mitigations.push(data);
1054
+ renderDashboard();
1055
+ $("#count-mitigations").textContent = state.mitigations.length;
1056
+ const panel = $("#panel-mitigations");
1057
+ if ($(".panel-empty", panel)) $(".panel-empty", panel).remove();
1058
+ const el = document.createElement("div");
1059
+ el.className = "mitigation";
1060
+ el.innerHTML = `
1061
+ <div class="m-top">
1062
+ <span class="badge">${escapeHtml(data.kind || "config")}</span>
1063
+ <span class="m-title">${escapeHtml(data.title || "")}</span>
1064
+ </div>
1065
+ <div class="m-sub">${escapeHtml(data.finding_id || "")}</div>`;
1066
+ panel.appendChild(el);
1067
+ }
1068
+
1069
+ function addActivity(text, isError = false) {
1070
+ const panel = $("#panel-activity");
1071
+ const el = document.createElement("div");
1072
+ el.className = "activity-line" + (isError ? " err" : "");
1073
+ el.textContent = text;
1074
+ panel.appendChild(el);
1075
+ const scroller = panel.closest(".review-body") || panel;
1076
+ scroller.scrollTop = scroller.scrollHeight;
1077
+ }
1078
+
1079
+ function setPhase(phase) {
1080
+ state.phase = phase;
1081
+ $("#st-phase").textContent = phase;
1082
+ }
1083
+
1084
+ function setRunning(running) {
1085
+ state.running = running;
1086
+ $("#btn-run").classList.toggle("hidden", running);
1087
+ $("#btn-stop").classList.toggle("hidden", !running);
1088
+ }
1089
+
1090
+ /* The objective is no longer typed in the audit bar: it comes from the plan.
1091
+ The button shows it and points the operator to the planner when empty. */
1092
+ function setObjective(text) {
1093
+ state.objective = (text || "").trim();
1094
+ const btn = $("#btn-objective");
1095
+ if (!btn) return;
1096
+ if (!state.objective) {
1097
+ btn.textContent = "Objective: —";
1098
+ btn.classList.add("muted");
1099
+ } else {
1100
+ const short = state.objective.length > 60 ? state.objective.slice(0, 60) + "…" : state.objective;
1101
+ btn.textContent = `Objective: ${short}`;
1102
+ btn.classList.remove("muted");
1103
+ }
1104
+ }
1105
+ function setChatting(chatting) {
1106
+ state.chatting = chatting;
1107
+ $("#chat-send").classList.toggle("hidden", chatting);
1108
+ $("#chat-stop").classList.toggle("hidden", !chatting);
1109
+ $("#chat-input").disabled = chatting;
1110
+ }
1111
+ function setResilience(value) {
1112
+ state.resilience = value;
1113
+ $("#st-resilience").textContent = Math.round(value);
1114
+ $("#res-bar").style.width = `${Math.max(0, Math.min(100, value))}%`;
1115
+ renderDashboard();
1116
+ }
1117
+
1118
+ /* ── event handling ─────────────────────────────────────── */
1119
+ function handleEvent(event) {
1120
+ state.events += 1;
1121
+ $("#st-events").textContent = `${state.events} events`;
1122
+ const { type, agent, data } = event;
1123
+
1124
+ switch (type) {
1125
+ case "audit.start":
1126
+ resetStream();
1127
+ setRunning(true);
1128
+ setPhase("starting");
1129
+ break;
1130
+ case "audit.started":
1131
+ break;
1132
+ addActivity(`audit start · target ${data.target || "?"} · ${data.provider}/${data.model}`);
1133
+ break;
1134
+ case "session.start":
1135
+ state.totalRounds = data.rounds || state.totalRounds;
1136
+ setPhase("running");
1137
+ break;
1138
+ case "round.start":
1139
+ state.round = data.round || state.round;
1140
+ $("#st-round").textContent = `round ${state.round}/${data.total || state.totalRounds}`;
1141
+ addDivider("red", `Round ${data.round} · offence`);
1142
+ break;
1143
+ case "phase.start":
1144
+ setPhase(data.phase || type);
1145
+ addDivider(agent, `${AGENTS[agent] ? AGENTS[agent].name : agent} · ${data.phase || ""}`);
1146
+ break;
1147
+ case "phase.end":
1148
+ finalize(agent);
1149
+ finalizeThinking(agent);
1150
+ addActivity(`${agent} phase end · ${data.tool_calls || 0} tool calls`);
1151
+ break;
1152
+ case "agent.text":
1153
+ appendText(agent, data.text || "");
1154
+ break;
1155
+ case "agent.thinking":
1156
+ addThinking(agent, data.text || "");
1157
+ break;
1158
+ case "agent.tool_call":
1159
+ addTool(agent, data);
1160
+ addActivity(`${agent} -> ${data.tool}`);
1161
+ break;
1162
+ case "agent.tool_result":
1163
+ finishTool(agent, data);
1164
+ break;
1165
+ case "context.usage":
1166
+ state.context = data;
1167
+ updateContextMeter();
1168
+ addActivity(
1169
+ `context ${data.estimated}/${data.usable} tokens (${data.percent}%)` +
1170
+ (data.overflow ? " · overflow" : "")
1171
+ );
1172
+ break;
1173
+ case "context.optimize":
1174
+ state.savedTokens = (state.savedTokens || 0) + Number(data.saved || 0);
1175
+ if (data.saved) {
1176
+ addActivity(
1177
+ `context optimised · saved ~${data.saved} tokens ` +
1178
+ `(reasoning ${data.reasoning}, snapshots ${data.stateful}, output ${data.pruned})`
1179
+ );
1180
+ }
1181
+ addTrace("optimize", data);
1182
+ break;
1183
+ case "agent.wrap_up":
1184
+ setPhase("wrapping up");
1185
+ addActivity(
1186
+ `agent wrap-up · ${data.remaining} steps left — persisting findings and summarising`
1187
+ );
1188
+ addTrace("wrap_up", data);
1189
+ break;
1190
+ case "context.compaction":
1191
+ addCompactionCard(data);
1192
+ addActivity(`compaction · removed ~${data.removed} tokens`);
1193
+ addTrace("compaction", data);
1194
+ break;
1195
+ case "checkpoint":
1196
+ addTrace("checkpoint", data);
1197
+ break;
1198
+ case "context.policy":
1199
+ state.policy = data;
1200
+ updateContextMeter();
1201
+ addActivity(
1202
+ `context window ${data.context_limit} · usable ${data.usable} · prune ${data.prune ? "on" : "off"}`
1203
+ );
1204
+ break;
1205
+ case "usage":
1206
+ addTrace("usage", data);
1207
+ break;
1208
+ case "todos":
1209
+ renderTodos(data.todos || []);
1210
+ addActivity(`task list updated · ${(data.todos || []).length} items`);
1211
+ break;
1212
+ case "workspace":
1213
+ state.workspace = data;
1214
+ renderWorkspace(data);
1215
+ addActivity(`workspace ${data.root}`);
1216
+ break;
1217
+ case "toolbox":
1218
+ if (data.status) renderToolbox({ ...data.status, effective_mode: data.active ? "toolbox" : data.mode });
1219
+ addActivity(
1220
+ `toolbox ${data.active ? "active" : "inactive"} · edition ${data.edition} · mode ${data.mode}` +
1221
+ (data.error ? ` · ${data.error}` : "")
1222
+ );
1223
+ break;
1224
+ case "toolbox.progress":
1225
+ onToolboxProgress(data);
1226
+ break;
1227
+ case "toolbox.ready":
1228
+ onToolboxReady(data);
1229
+ break;
1230
+ case "toolbox.failed":
1231
+ onToolboxFailed(data);
1232
+ break;
1233
+ case "agent.working":
1234
+ setWorking(agent, data.step, data.max_steps);
1235
+ break;
1236
+ case "finding":
1237
+ addFinding(data);
1238
+ addActivity(`finding ${data.id}: ${data.title} [${data.severity}]`);
1239
+ break;
1240
+ case "mitigation":
1241
+ addMitigation(data);
1242
+ addActivity(`mitigation ${data.id} (${data.kind}) for ${data.finding_id}`);
1243
+ break;
1244
+ case "round.end":
1245
+ setResilience(Number(data.resilience || state.resilience));
1246
+ state.rounds.push({
1247
+ index: data.round,
1248
+ findings: data.findings,
1249
+ mitigations: data.mitigations,
1250
+ });
1251
+ renderDashboard();
1252
+ addActivity(`round ${data.round} end · ${data.findings} findings · ${data.mitigations} mitigations`);
1253
+ break;
1254
+ case "log":
1255
+ addActivity(data.text || "");
1256
+ break;
1257
+ case "error":
1258
+ addActivity(`error: ${data.text || ""}`, true);
1259
+ toast(data.text || "error", true);
1260
+ break;
1261
+ case "session.end":
1262
+ setResilience(Number(data.resilience || state.resilience));
1263
+ finalize("red");
1264
+ finalize("blue");
1265
+ finalizeThinking("red");
1266
+ finalizeThinking("blue");
1267
+ break;
1268
+ case "audit.end":
1269
+ setRunning(false);
1270
+ setPhase(data.ok ? "done" : "failed");
1271
+ finalize("red");
1272
+ finalize("blue");
1273
+ finalizeThinking("red");
1274
+ finalizeThinking("blue");
1275
+ if (data.ok) {
1276
+ state.reportPaths = data.reports || [];
1277
+ $("#btn-open-reports").disabled = state.reportPaths.length === 0;
1278
+ addActivity(`audit complete · session ${data.session}`);
1279
+ toast("Audit complete");
1280
+ renderReport();
1281
+ focusTab("dashboard");
1282
+ renderDashboard();
1283
+ } else if (data.error !== "cancelled") {
1284
+ toast(data.error || "audit failed", true);
1285
+ }
1286
+ break;
1287
+ case "chat.start":
1288
+ setChatting(true);
1289
+ // Begin a fresh assistant bubble for this turn; it streams into the same
1290
+ // continuous conversation instead of spawning a separate chat.
1291
+ ensureMsg("assistant");
1292
+ break;
1293
+ case "chat.end": {
1294
+ setChatting(false);
1295
+ // Capture what actually streamed before finalize() wipes the buffer, so
1296
+ // the fallback only fires when the stream produced nothing.
1297
+ const streamed = state.rawText.assistant || "";
1298
+ finalize("assistant");
1299
+ finalizeThinking("assistant");
1300
+ state.chatTurns = (state.chatTurns || 0) + 1;
1301
+ if (data && data.ok && data.text && !streamed.trim()) {
1302
+ appendText("assistant", data.text);
1303
+ finalize("assistant");
1304
+ }
1305
+ if (data && data.error && data.error !== "cancelled") toast(data.error, true);
1306
+ break;
1307
+ }
1308
+ case "chat.plan":
1309
+ renderChatPlan(data && data.plan ? data.plan : {});
1310
+ break;
1311
+ case "chat.reset":
1312
+ break;
1313
+ }
1314
+ }
1315
+
1316
+ window.SplitAgent = {
1317
+ emit(events) {
1318
+ if (!Array.isArray(events)) events = [events];
1319
+ events.forEach(handleEvent);
1320
+ },
1321
+ };
1322
+
1323
+ function resetStream() {
1324
+ const s = $("#stream");
1325
+ s.innerHTML = "";
1326
+ state.currentMsg = {};
1327
+ state.rawText = {};
1328
+ state.tools = {};
1329
+ state.findings = [];
1330
+ state.mitigations = [];
1331
+ state.events = 0;
1332
+ state.round = 0;
1333
+ state.runningTools = { red: 0, blue: 0, assistant: 0 };
1334
+ state.toolLog = { red: [], blue: [], assistant: [] };
1335
+ state.context = null;
1336
+ state.trace = [];
1337
+ state.thinking = { red: "", blue: "", assistant: "" };
1338
+ state.todos = [];
1339
+ state.working = {};
1340
+ $("#todo-dock").classList.add("hidden");
1341
+ updateContextMeter();
1342
+ api("workspace_info").then((info) => {
1343
+ if (info && info.ok) renderWorkspace(info);
1344
+ });
1345
+ state.findingIndex = {};
1346
+ state.rounds = [];
1347
+ $("#panel-dashboard").innerHTML =
1348
+ '<div class="panel-empty">Run an audit to see the results here.</div>';
1349
+ $("#panel-findings").innerHTML = '<div class="panel-empty">No findings yet.</div>';
1350
+ $("#panel-mitigations").innerHTML = '<div class="panel-empty">No mitigations yet.</div>';
1351
+ $("#panel-activity").innerHTML = "";
1352
+ $("#panel-report").innerHTML = '<div class="panel-empty">The report is generated at the end of a run.</div>';
1353
+ // Reset the panels and status that survive across runs.
1354
+ const tracePanel = $("#panel-trace");
1355
+ const traceList = tracePanel.querySelector(".trace-list");
1356
+ if (traceList) traceList.innerHTML = "";
1357
+ if (!tracePanel.querySelector(".trace-empty")) {
1358
+ const empty = document.createElement("div");
1359
+ empty.className = "trace-empty";
1360
+ empty.textContent = "Nothing captured yet.";
1361
+ tracePanel.appendChild(empty);
1362
+ }
1363
+ state.reportPaths = [];
1364
+ $("#btn-open-reports").disabled = true;
1365
+ $("#st-round").textContent = "round 0/0";
1366
+ setPhase("idle");
1367
+ $("#count-findings").textContent = "0";
1368
+ $("#count-mitigations").textContent = "0";
1369
+ $("#st-events").textContent = "0 events";
1370
+ setResilience(100);
1371
+ }
1372
+
1373
+ function resetChat() {
1374
+ const s = $("#chat-stream");
1375
+ s.innerHTML = `
1376
+ <div class="empty-state" id="chat-empty">
1377
+ <div class="empty-mark">
1378
+ <svg viewBox="0 0 24 24"><path d="M3.5 5.5h17v10h-9l-4.5 4v-4H3.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
1379
+ </div>
1380
+ <h1>Pentest copilot</h1>
1381
+ <p>Let's talk about the engagement first. Tell me what you want to test and what matters, and I'll propose a plan. Nothing runs until you approve it.</p>
1382
+ <div class="suggestions">
1383
+ <button class="suggestion">I want to test my web app — where do we start?</button>
1384
+ <button class="suggestion">Help me scope a pentest for my target</button>
1385
+ <button class="suggestion">What should I consider before testing?</button>
1386
+ <button class="suggestion">Explain CVSS and how you score findings</button>
1387
+ </div>
1388
+ </div>`;
1389
+ delete state.currentMsg.assistant;
1390
+ delete state.rawText.assistant;
1391
+ state.tools = {};
1392
+ state.chatTurns = 0;
1393
+ }
1394
+
1395
+ function renderReport() {
1396
+ const panel = $("#panel-report");
1397
+ const lines = [`<div class="activity-line">session complete</div>`];
1398
+ state.reportPaths.forEach((p) => {
1399
+ lines.push(
1400
+ `<div class="activity-line">report: ${escapeHtml(p)} <a href="#" data-open="${escapeHtml(p)}">open</a></div>`
1401
+ );
1402
+ });
1403
+ panel.innerHTML = lines.join("") || '<div class="panel-empty">No report.</div>';
1404
+ }
1405
+
1406
+ /* ── bootstrap ──────────────────────────────────────────── */
1407
+ function renderBootstrap(data) {
1408
+ state.bootstrap = data;
1409
+ state.config = data.config || {};
1410
+ state.project = data.project || {};
1411
+ state.totalRounds = (data.project && data.project.run && data.project.run.rounds) || 3;
1412
+
1413
+ $("#brand-ver").textContent = "v" + (data.version || "");
1414
+ $("#settings-path").textContent = data.config_path || "";
1415
+ $("#project-path").textContent = data.project_path || "";
1416
+
1417
+ updateModelUI();
1418
+ renderTarget();
1419
+ renderSessions(data.sessions || []);
1420
+ fillSettingsForm();
1421
+ fillProjectForm();
1422
+ setRunning(!!data.running);
1423
+ setChatting(!!data.chatting);
1424
+ renderDashboard();
1425
+
1426
+ refreshToolbox().then((status) => {
1427
+ const exec = data.execution || {};
1428
+ if (status && status.docker_cli && !exec.installed && !status.image) {
1429
+ openToolboxDialog();
1430
+ }
1431
+ });
1432
+
1433
+ if (!data.configured) {
1434
+ toast("Connect a provider to start", true);
1435
+ openSettings();
1436
+ openConnectProvider("");
1437
+ }
1438
+ }
1439
+
1440
+ function updateModelUI() {
1441
+ const provider = state.config.provider || "";
1442
+ const model = state.config.model || "no model";
1443
+ const label = `${provider}/${model}`;
1444
+ const ok = !!state.config.api_key_set && !!state.config.model;
1445
+ ["#pill-model", "#chat-pill-model", "#side-model-name"].forEach((sel) => {
1446
+ const el = $(sel);
1447
+ if (el) el.textContent = label;
1448
+ });
1449
+ $("#st-provider").textContent = label;
1450
+ ["#pill-dot", "#chat-pill-dot", "#side-model-dot"].forEach((sel) => {
1451
+ const el = $(sel);
1452
+ if (el) el.className = "dot " + (ok ? "ok" : "bad");
1453
+ });
1454
+ }
1455
+
1456
+ function renderTarget() {
1457
+ const t = (state.project && state.project.target) || {};
1458
+ const run = (state.project && state.project.run) || {};
1459
+ $("#tgt-kind").textContent = t.kind || "web";
1460
+ $("#tgt-url").textContent = t.url || (t.hosts && t.hosts[0]) || "not configured";
1461
+ const scope = (t.scope || []).join(", ") || "—";
1462
+ const sb = run.sandbox && run.sandbox.enabled ? "sandbox on" : "sandbox off";
1463
+ $("#tgt-meta").innerHTML = `scope <code>${escapeHtml(scope)}</code> · ${run.rounds || 3} rounds · ${sb}`;
1464
+ $("#opt-rounds").value = run.rounds || 3;
1465
+ $("#opt-sandbox").checked = !!(run.sandbox && run.sandbox.enabled);
1466
+ }
1467
+
1468
+ function renderSessions(sessions) {
1469
+ const list = $("#session-list");
1470
+ if (!sessions.length) {
1471
+ list.innerHTML = '<div class="session-empty">No saved sessions.</div>';
1472
+ return;
1473
+ }
1474
+ list.innerHTML = sessions
1475
+ .map(
1476
+ (s) =>
1477
+ `<div class="session-item" data-session="${escapeHtml(s.id)}">
1478
+ <span class="sid">${escapeHtml(s.id)}</span>
1479
+ <span class="smeta">${escapeHtml((s.modified || "").replace("T", " ").replace("+00:00", ""))}</span>
1480
+ </div>`
1481
+ )
1482
+ .join("");
1483
+ $$(".session-item", list).forEach((el) =>
1484
+ el.addEventListener("click", () => loadSession(el.dataset.session))
1485
+ );
1486
+ }
1487
+
1488
+ async function loadSession(id) {
1489
+ const res = await api("load_session", id);
1490
+ if (!res || !res.ok) {
1491
+ toast(res && res.error ? res.error : "could not load session", true);
1492
+ return;
1493
+ }
1494
+ const s = res.state || {};
1495
+ setMode("audit");
1496
+ resetStream();
1497
+ state.rounds = (s.rounds || []).map((r) => ({
1498
+ index: r.index,
1499
+ findings: (r.finding_ids || []).length,
1500
+ mitigations: (r.mitigation_ids || []).length,
1501
+ }));
1502
+ addDivider("core", `Loaded session ${s.id}`);
1503
+ (s.rounds || []).forEach((r) => {
1504
+ addDivider("red", `Round ${r.index} · offence`);
1505
+ if (r.red_summary) { appendText("red", r.red_summary); finalize("red"); }
1506
+ addDivider("blue", `Round ${r.index} · defence`);
1507
+ if (r.blue_summary) { appendText("blue", r.blue_summary); finalize("blue"); }
1508
+ });
1509
+ (s.findings || []).forEach((f) =>
1510
+ addFinding({ id: f.id, title: f.title, severity: f.severity, cvss_score: f.cvss_score })
1511
+ );
1512
+ (s.mitigations || []).forEach((m) => addMitigation(m));
1513
+ setResilience(res.summary && typeof res.summary.resilience === "number" ? res.summary.resilience : 100);
1514
+ addActivity(`session ${s.id} loaded`);
1515
+ toast("Session loaded");
1516
+ }
1517
+
1518
+ /* ── model picker ───────────────────────────────────────── */
1519
+ let mpAnchor = null;
1520
+ async function openModelPicker(anchor) {
1521
+ mpAnchor = anchor;
1522
+ const pop = $("#model-popover");
1523
+ if (!state.modelsLoaded) {
1524
+ const res = await api("list_models");
1525
+ if (res && res.ok) {
1526
+ state.models = res.models || [];
1527
+ state.modelsLoaded = true;
1528
+ }
1529
+ }
1530
+ renderModelList("");
1531
+ $("#mp-input").value = "";
1532
+ $("#mp-clear").classList.add("hidden");
1533
+ positionPopover();
1534
+ pop.classList.remove("hidden");
1535
+ setTimeout(() => $("#mp-input").focus(), 20);
1536
+ }
1537
+
1538
+ function positionPopover() {
1539
+ const pop = $("#model-popover");
1540
+ const anchor = mpAnchor;
1541
+ if (!anchor) return;
1542
+ const rect = anchor.getBoundingClientRect();
1543
+ const width = 284;
1544
+ let left = rect.left;
1545
+ if (left + width > window.innerWidth - 12) left = window.innerWidth - width - 12;
1546
+ if (left < 12) left = 12;
1547
+ pop.style.left = `${left}px`;
1548
+ const height = Math.min(300, window.innerHeight - 80);
1549
+ let bottom = window.innerHeight - rect.top + 6;
1550
+ if (window.innerHeight - bottom < 60) bottom = window.innerHeight - 60;
1551
+ pop.style.bottom = `${bottom}px`;
1552
+ pop.style.maxHeight = `${height}px`;
1553
+ }
1554
+
1555
+ function renderModelList(query) {
1556
+ const q = (query || "").trim().toLowerCase();
1557
+ const current = `${state.config.provider}:${state.config.model}`;
1558
+ let models = state.models;
1559
+ if (q) {
1560
+ models = models.filter(
1561
+ (m) =>
1562
+ m.name.toLowerCase().includes(q) ||
1563
+ m.id.toLowerCase().includes(q) ||
1564
+ m.provider.toLowerCase().includes(q)
1565
+ );
1566
+ }
1567
+ models = [...models].sort((a, b) => a.name.localeCompare(b.name));
1568
+
1569
+ // group by provider, current provider first
1570
+ const groups = new Map();
1571
+ for (const m of models) {
1572
+ if (!groups.has(m.provider)) groups.set(m.provider, []);
1573
+ groups.get(m.provider).push(m);
1574
+ }
1575
+ const ordered = Array.from(groups.entries()).sort((a, b) => {
1576
+ if (a[0] === state.config.provider) return -1;
1577
+ if (b[0] === state.config.provider) return 1;
1578
+ return a[0].localeCompare(b[0]);
1579
+ });
1580
+
1581
+ const list = $("#mp-list");
1582
+ if (!ordered.length) {
1583
+ list.innerHTML = '<div class="mp-empty">No models found.</div>';
1584
+ state.mpFiltered = [];
1585
+ return;
1586
+ }
1587
+ let html = "";
1588
+ const flat = [];
1589
+ ordered.forEach(([provider, items]) => {
1590
+ html += `<div class="mp-group-label">${escapeHtml(provider)}</div>`;
1591
+ items.forEach((m) => {
1592
+ const key = `${m.provider}:${m.id}`;
1593
+ flat.push({ ...m, key });
1594
+ const selected = key === current;
1595
+ html += `<button class="mp-item${selected ? " selected" : ""}" data-key="${escapeHtml(key)}">
1596
+ <span class="mp-name">${escapeHtml(m.name)}</span>
1597
+ ${m.free ? '<span class="mp-tag free">FREE</span>' : ""}
1598
+ ${selected ? '<span class="check">✓</span>' : ""}
1599
+ </button>`;
1600
+ });
1601
+ });
1602
+ list.innerHTML = html;
1603
+ state.mpFiltered = flat;
1604
+ $$(".mp-item", list).forEach((el) =>
1605
+ el.addEventListener("click", () => pickModel(el.dataset.key))
1606
+ );
1607
+ }
1608
+
1609
+ async function pickModel(key) {
1610
+ const [provider, ...rest] = key.split(":");
1611
+ const model = rest.join(":");
1612
+ const res = await api("activate_model", { provider, model });
1613
+ closeModelPicker();
1614
+ if (!res || !res.ok) {
1615
+ toast((res && res.error) || "could not switch model", true);
1616
+ return;
1617
+ }
1618
+ const entry = state.models.find((m) => m.provider === provider && m.id === model);
1619
+ const conn = (state.connected || []).find((p) => p.id === provider);
1620
+ state.config = {
1621
+ ...state.config,
1622
+ provider,
1623
+ model,
1624
+ base_url: (conn && conn.base_url) || state.config.base_url,
1625
+ api_key_set: conn ? conn.has_key : true,
1626
+ };
1627
+ updateModelUI();
1628
+ if (!$("#modal-settings").classList.contains("hidden")) renderSettingsModels();
1629
+ toast(`Model: ${provider}/${model}`);
1630
+ }
1631
+
1632
+ async function pickModelFromSettings(key) {
1633
+ await pickModel(key);
1634
+ await refreshCatalog();
1635
+ }
1636
+
1637
+ function closeModelPicker() {
1638
+ $("#model-popover").classList.add("hidden");
1639
+ }
1640
+
1641
+ /* ── guided wizard ──────────────────────────────────────── */
1642
+ const WIZ_HINTS = [
1643
+ "What kind of target are you testing?",
1644
+ "Where is it? Scope keeps the agents safe.",
1645
+ "How should the agents run?",
1646
+ "Optional: credentials for authenticated testing.",
1647
+ "How deep should the audit go?",
1648
+ "Optional brief: help the planner adapt to this environment.",
1649
+ ];
1650
+
1651
+ function openWizard() {
1652
+ state.wizard.step = 0;
1653
+ state.wizard.kind = state.project.target?.kind || "web";
1654
+ state.wizard.depth = "standard";
1655
+ $$("#kind-grid .kind-card").forEach((c) =>
1656
+ c.classList.toggle("active", c.dataset.kind === state.wizard.kind)
1657
+ );
1658
+ $$("#depth-grid .depth-card").forEach((c) =>
1659
+ c.classList.toggle("active", c.dataset.depth === "standard")
1660
+ );
1661
+ // prefill from project
1662
+ const t = state.project.target || {};
1663
+ const run = state.project.run || {};
1664
+ const auth = state.project.auth || {};
1665
+ $("#wiz-url").value = t.url || "";
1666
+ $("#wiz-scope").value = (t.scope || []).join(", ");
1667
+ $("#wiz-out").value = (t.out_of_scope || []).join(", ");
1668
+ $("#wiz-ports").value = (t.ports || []).join(", ");
1669
+ $("#wiz-image").value = (run.sandbox && run.sandbox.image) || "bkimminich/juice-shop:latest";
1670
+ $("#wiz-network").checked = !!run.allow_network;
1671
+ const ws = state.project.workspace || {};
1672
+ $("#wiz-ws-path").value = ws.path || "";
1673
+ $("#wiz-ws-install").checked = ws.allow_install !== false;
1674
+ $("#wiz-ws-external").checked = ws.allow_external_tools !== false;
1675
+ $("#wiz-ws-instructions").value = ws.instructions || "";
1676
+ $("#wiz-user").value = auth.username || "";
1677
+ $("#wiz-pass").value = auth.password || "";
1678
+ $("#wiz-token").value = auth.token || "";
1679
+ $("#wiz-cookies").value = auth.cookies || "";
1680
+ $("#wiz-headers").value = Object.entries(auth.headers || {})
1681
+ .map(([k, v]) => `${k}: ${v}`)
1682
+ .join("\n");
1683
+ $$('input[name="env"]').forEach((r) => (r.checked = r.value === (run.sandbox && run.sandbox.enabled ? "sandbox" : "existing")));
1684
+ // Brief fields: reuse the last brief if we have one, else start empty.
1685
+ const brief = state.brief || {};
1686
+ $("#wiz-crown").value = brief.crown_jewels || "";
1687
+ if ($("#wiz-goal")) $("#wiz-goal").value = brief.goal || "";
1688
+ if ($("#wiz-noise")) $("#wiz-noise").value = brief.noise || "normal";
1689
+ $("#wiz-areas-exclude").value = brief.exclude_areas || "";
1690
+ $("#wiz-window").value = brief.window || "";
1691
+ $("#wiz-authorization").value = brief.authorization || "";
1692
+ $("#wiz-notes").value = brief.notes || "";
1693
+ setWizardStep(0);
1694
+ openModal("#modal-wizard");
1695
+ }
1696
+
1697
+ function setWizardStep(step) {
1698
+ state.wizard.step = Math.max(0, Math.min(5, step));
1699
+ $$(".wstep").forEach((s) => s.classList.toggle("active", Number(s.dataset.step) === state.wizard.step));
1700
+ $$("#wiz-progress .wdot").forEach((d, i) => {
1701
+ d.classList.toggle("active", i === state.wizard.step);
1702
+ d.classList.toggle("done", i < state.wizard.step);
1703
+ });
1704
+ $("#wiz-hint").textContent = WIZ_HINTS[state.wizard.step];
1705
+ $("#wiz-back").style.visibility = state.wizard.step === 0 ? "hidden" : "visible";
1706
+ $("#wiz-next").textContent = state.wizard.step === 5 ? "Build plan" : "Next";
1707
+ if (state.wizard.step === 4) renderWizardSummary();
1708
+ }
1709
+
1710
+ function renderWizardSummary() {
1711
+ const kind = state.wizard.kind;
1712
+ const url = $("#wiz-url").value.trim() || "(none)";
1713
+ const scope = $("#wiz-scope").value.trim() || "(auto)";
1714
+ const env = document.querySelector('input[name="env"]:checked')?.value || "sandbox";
1715
+ const depth = state.wizard.depth;
1716
+ const depthMap = { quick: "1 round · 6 steps", standard: "3 rounds · 12 steps", deep: "5 rounds · 20 steps" };
1717
+ $("#wiz-summary").textContent =
1718
+ `target ${kind} · ${url}\n` +
1719
+ `scope ${scope}\n` +
1720
+ `env ${env === "sandbox" ? "disposable Docker sandbox" : "existing target"}\n` +
1721
+ `workspace ${$("#wiz-ws-path").value.trim() || "splitagent-workspace"} ` +
1722
+ `(install ${$("#wiz-ws-install").checked ? "on" : "off"}, ` +
1723
+ `tools ${$("#wiz-ws-external").checked ? "on" : "off"})\n` +
1724
+ `depth ${depth} (${depthMap[depth]})`;
1725
+ }
1726
+
1727
+ async function wizardNext() {
1728
+ const step = state.wizard.step;
1729
+ if (step === 0) {
1730
+ setWizardStep(1);
1731
+ return;
1732
+ }
1733
+ if (step === 1) {
1734
+ const url = $("#wiz-url").value.trim();
1735
+ if (!url) {
1736
+ toast("Enter a target URL or host", true);
1737
+ return;
1738
+ }
1739
+ if (!$("#wiz-scope").value.trim()) {
1740
+ try {
1741
+ $("#wiz-scope").value = new URL(url).hostname;
1742
+ } catch {
1743
+ $("#wiz-scope").value = url.split(":")[0];
1744
+ }
1745
+ }
1746
+ setWizardStep(2);
1747
+ return;
1748
+ }
1749
+ if (step === 2) {
1750
+ setWizardStep(3);
1751
+ return;
1752
+ }
1753
+ if (step === 3) {
1754
+ setWizardStep(4);
1755
+ return;
1756
+ }
1757
+ if (step === 4) {
1758
+ setWizardStep(5);
1759
+ return;
1760
+ }
1761
+ await startWizardAudit();
1762
+ }
1763
+
1764
+ function collectBrief() {
1765
+ return {
1766
+ objective: $("#wiz-objective").value.trim(),
1767
+ crown_jewels: $("#wiz-crown") ? $("#wiz-crown").value.trim() : "",
1768
+ goal: $("#wiz-goal") ? $("#wiz-goal").value : "",
1769
+ noise: $("#wiz-noise") ? $("#wiz-noise").value : "normal",
1770
+ exclude_areas: $("#wiz-areas-exclude") ? $("#wiz-areas-exclude").value.trim() : "",
1771
+ window: $("#wiz-window") ? $("#wiz-window").value.trim() : "",
1772
+ authorization: $("#wiz-authorization") ? $("#wiz-authorization").value.trim() : "",
1773
+ notes: $("#wiz-notes") ? $("#wiz-notes").value.trim() : "",
1774
+ kind: state.wizard.kind,
1775
+ scope: $("#wiz-scope").value.split(",").map((s) => s.trim()).filter(Boolean),
1776
+ out_of_scope: $("#wiz-out").value.split(",").map((s) => s.trim()).filter(Boolean),
1777
+ scope_document: (state.brief && state.brief.scope_document) || "",
1778
+ };
1779
+ }
1780
+
1781
+ function parseHeaders(text) {
1782
+ const headers = {};
1783
+ (text || "").split("\n").forEach((line) => {
1784
+ const idx = line.indexOf(":");
1785
+ if (idx > 0) {
1786
+ const name = line.slice(0, idx).trim();
1787
+ const value = line.slice(idx + 1).trim();
1788
+ if (name) headers[name] = value;
1789
+ }
1790
+ });
1791
+ return headers;
1792
+ }
1793
+
1794
+ /* The wizard no longer starts the audit directly: it saves the project, then
1795
+ asks the planner (the AI) for a plan for this specific environment. The
1796
+ operator approves or refines it before anything runs. */
1797
+ async function startWizardAudit() {
1798
+ const env = document.querySelector('input[name="env"]:checked')?.value || "sandbox";
1799
+ const depth = state.wizard.depth;
1800
+ const depthMap = { quick: { rounds: 1, steps: 6 }, standard: { rounds: 3, steps: 12 }, deep: { rounds: 5, steps: 20 } };
1801
+ const { rounds, steps } = depthMap[depth];
1802
+ const brief = collectBrief();
1803
+ state.brief = brief;
1804
+
1805
+ const payload = {
1806
+ name: state.project.name,
1807
+ target: {
1808
+ kind: state.wizard.kind,
1809
+ url: $("#wiz-url").value.trim(),
1810
+ scope: $("#wiz-scope").value,
1811
+ out_of_scope: $("#wiz-out").value,
1812
+ ports: $("#wiz-ports").value,
1813
+ },
1814
+ run: {
1815
+ rounds,
1816
+ max_steps: steps,
1817
+ sandbox: { enabled: env === "sandbox", image: $("#wiz-image").value.trim() },
1818
+ allow_network: $("#wiz-network").checked,
1819
+ },
1820
+ auth: {
1821
+ username: $("#wiz-user").value.trim(),
1822
+ password: $("#wiz-pass").value,
1823
+ token: $("#wiz-token").value,
1824
+ cookies: $("#wiz-cookies").value.trim(),
1825
+ headers: parseHeaders($("#wiz-headers").value),
1826
+ },
1827
+ workspace: {
1828
+ path: $("#wiz-ws-path").value.trim(),
1829
+ allow_install: $("#wiz-ws-install").checked,
1830
+ allow_external_tools: $("#wiz-ws-external").checked,
1831
+ instructions: $("#wiz-ws-instructions").value.trim(),
1832
+ },
1833
+ };
1834
+ const saved = await api("save_project", payload);
1835
+ if (saved && saved.ok) {
1836
+ state.project = saved.project;
1837
+ renderTarget();
1838
+ fillProjectForm();
1839
+ }
1840
+ setObjective(brief.objective);
1841
+ closeModals();
1842
+ setMode("audit");
1843
+ await requestPlan(brief);
1844
+ }
1845
+
1846
+ /* Ask the planner for a plan, show "generating…", then render it. */
1847
+ async function requestPlan(brief) {
1848
+ const stream = $("#stream");
1849
+ const empty = $("#stream-empty");
1850
+ if (empty) empty.remove();
1851
+ const pending = document.createElement("div");
1852
+ pending.className = "plan-pending";
1853
+ pending.id = "plan-pending";
1854
+ pending.innerHTML = '<span class="spinner"></span> Generating a plan for this environment…';
1855
+ stream.appendChild(pending);
1856
+ stream.scrollTop = stream.scrollHeight;
1857
+
1858
+ const res = await api("build_audit_plan", brief);
1859
+ const el = $("#plan-pending");
1860
+ if (el) el.remove();
1861
+ if (!res || !res.ok) {
1862
+ toast((res && res.error) || "could not build a plan", true);
1863
+ renderAuditPlan({}, "fallback");
1864
+ return;
1865
+ }
1866
+ renderAuditPlan(res, res.source || "ai");
1867
+ // Reset the planner chat so it reflects this new plan.
1868
+ const log = $("#plan-chat-log");
1869
+ if (log) {
1870
+ log.innerHTML =
1871
+ '<div class="panel-empty">Talk to the planner about the plan here. It only plans — it never tests.</div>';
1872
+ }
1873
+ }
1874
+
1875
+ /* Chat with the planner in the right-hand "Plan chat" panel. It only plans;
1876
+ it never runs the audit. */
1877
+ function openPlanChat() {
1878
+ // Make sure the right-hand panel is actually visible: the operator may have
1879
+ // collapsed it, in which case focusing the tab alone shows nothing.
1880
+ document.body.classList.remove("review-collapsed");
1881
+ focusTab("plan");
1882
+ const input = $("#plan-chat-input");
1883
+ if (input) setTimeout(() => input.focus(), 40);
1884
+ }
1885
+
1886
+ function planChatSystem(text) {
1887
+ const log = $("#plan-chat-log");
1888
+ if (!log) return;
1889
+ const empty = $(".panel-empty", log);
1890
+ if (empty) empty.remove();
1891
+ log.insertAdjacentHTML(
1892
+ "beforeend",
1893
+ `<div class="ap-msg bot">${escapeHtml(text)}</div>`
1894
+ );
1895
+ log.scrollTop = log.scrollHeight;
1896
+ }
1897
+
1898
+ async function sendPlanChat() {
1899
+ const input = $("#plan-chat-input");
1900
+ if (!input) return;
1901
+ const message = input.value.trim();
1902
+ if (!message) return;
1903
+ input.value = "";
1904
+ const log = $("#plan-chat-log");
1905
+ log.insertAdjacentHTML("beforeend", `<div class="ap-msg user">${escapeHtml(message)}</div>`);
1906
+ log.scrollTop = log.scrollHeight;
1907
+ const thinking = document.createElement("div");
1908
+ thinking.className = "ap-msg bot planner-thinking";
1909
+ thinking.textContent = "Thinking…";
1910
+ log.appendChild(thinking);
1911
+ log.scrollTop = log.scrollHeight;
1912
+ const res = await api("brief_chat", { message, brief: state.brief || {} });
1913
+ thinking.remove();
1914
+ log.insertAdjacentHTML(
1915
+ "beforeend",
1916
+ `<div class="ap-msg bot">${res && res.ok ? md(res.text) : escapeHtml((res && res.error) || "error")}</div>`
1917
+ );
1918
+ log.scrollTop = log.scrollHeight;
1919
+ // Fold the clarification into the brief so the next plan reflects it.
1920
+ if (res && res.ok && state.brief) {
1921
+ state.brief.notes = [state.brief.notes, message].filter(Boolean).join(" | ");
1922
+ }
1923
+ }
1924
+
1925
+ function setScopeDoc(name, content) {
1926
+ state.brief = state.brief || {};
1927
+ state.brief.scope_document = content || "";
1928
+ const label = $("#wiz-scope-name");
1929
+ const clear = $("#wiz-scope-clear");
1930
+ if (label) label.textContent = content ? `Attached: ${name}` : "No document attached.";
1931
+ if (clear) clear.classList.toggle("hidden", !content);
1932
+ }
1933
+
1934
+ /* The plan card for the Audit view: Approve & start, or refine via the brief. */
1935
+ function renderAuditPlan(plan, source) {
1936
+ const stream = $("#stream");
1937
+ finalize("red");
1938
+ finalize("blue");
1939
+ const card = document.createElement("div");
1940
+ card.className = "chat-plan audit-plan";
1941
+ const scope = (plan.scope || []).join(", ") || "see engagement config";
1942
+ const out = (plan.out_of_scope || []).join(", ");
1943
+ const phases = (plan.phases || []).map((p) => `<li>${escapeHtml(p)}</li>`).join("");
1944
+ const techniques = (plan.techniques || []).map((p) => `<li>${escapeHtml(p)}</li>`).join("");
1945
+ const cautions = (plan.cautions || []).map((p) => `<li>${escapeHtml(p)}</li>`).join("");
1946
+ card.innerHTML = `
1947
+ <div class="cp-head">
1948
+ <span class="cp-badge">Proposed plan${source === "fallback" ? " · offline" : ""}</span>
1949
+ <span class="cp-title">${escapeHtml(plan.objective || "Engagement")}</span>
1950
+ </div>
1951
+ <div class="cp-row"><span>In scope</span><code>${escapeHtml(scope)}</code></div>
1952
+ ${out ? `<div class="cp-row"><span>Out of scope</span><code>${escapeHtml(out)}</code></div>` : ""}
1953
+ ${plan.noise ? `<div class="cp-row"><span>Noise</span><code>${escapeHtml(plan.noise)}</code></div>` : ""}
1954
+ ${phases ? `<div class="cp-phases"><div class="cp-sub">Phases</div><ol>${phases}</ol></div>` : ""}
1955
+ ${techniques ? `<div class="cp-phases"><div class="cp-sub">Techniques</div><ul>${techniques}</ul></div>` : ""}
1956
+ ${cautions ? `<div class="cp-phases"><div class="cp-sub">Cautions</div><ul>${cautions}</ul></div>` : ""}
1957
+ <div class="cp-foot">
1958
+ <span class="cp-note">Nothing is tested until you approve.</span>
1959
+ <button class="btn ghost" id="ap-refine">Refine with chat</button>
1960
+ <button class="btn ghost" id="ap-replan">Rebuild plan</button>
1961
+ <button class="btn primary" id="ap-approve">Approve &amp; start</button>
1962
+ </div>`;
1963
+ stream.appendChild(card);
1964
+ stream.scrollTop = stream.scrollHeight;
1965
+
1966
+ const approve = $("#ap-approve", card);
1967
+ approve.addEventListener("click", () => {
1968
+ approve.disabled = true;
1969
+ approve.textContent = "Starting…";
1970
+ if (plan.objective) setObjective(plan.objective);
1971
+ startAudit(plan);
1972
+ });
1973
+ // Refining opens the planner chat in the right-hand panel, so the operator
1974
+ // can talk about the plan without leaving the audit view.
1975
+ $("#ap-refine", card).addEventListener("click", () => {
1976
+ planChatSystem("Ask me anything about the plan or the environment. When you are happy, rebuild the plan or approve it.");
1977
+ openPlanChat();
1978
+ });
1979
+ $("#ap-replan", card).addEventListener("click", async () => {
1980
+ approve.disabled = true;
1981
+ await requestPlan(state.brief || {});
1982
+ });
1983
+ }
1984
+
1985
+ /* ── settings: models & providers ───────────────────────── */
1986
+ const collapsedProviders = {};
1987
+
1988
+ async function refreshCatalog() {
1989
+ const [models, providers] = await Promise.all([
1990
+ api("list_models"),
1991
+ api("list_providers"),
1992
+ ]);
1993
+ state.models = (models && models.models) || [];
1994
+ state.connected = (providers && providers.connected) || [];
1995
+ state.popular = (providers && providers.popular) || [];
1996
+ state.modelsLoaded = true;
1997
+ renderSettingsModels();
1998
+ renderProviders();
1999
+ }
2000
+
2001
+ function switchHtml(on, cls = "") {
2002
+ return `<span class="switch ${on ? "on" : ""} ${cls}"></span>`;
2003
+ }
2004
+
2005
+ function renderSettingsModels() {
2006
+ const q = ($("#s-model-search").value || "").trim().toLowerCase();
2007
+ const current = `${state.config.provider}:${state.config.model}`;
2008
+ let models = state.models;
2009
+ if (q) {
2010
+ models = models.filter(
2011
+ (m) =>
2012
+ m.name.toLowerCase().includes(q) ||
2013
+ m.id.toLowerCase().includes(q) ||
2014
+ m.provider.toLowerCase().includes(q)
2015
+ );
2016
+ }
2017
+ const groups = new Map();
2018
+ for (const m of models) {
2019
+ if (!groups.has(m.provider)) groups.set(m.provider, []);
2020
+ groups.get(m.provider).push(m);
2021
+ }
2022
+ const ordered = Array.from(groups.entries()).sort((a, b) => {
2023
+ if (a[0] === state.config.provider) return -1;
2024
+ if (b[0] === state.config.provider) return 1;
2025
+ return a[0].localeCompare(b[0]);
2026
+ });
2027
+
2028
+ const list = $("#s-models-list");
2029
+ if (!ordered.length) {
2030
+ list.innerHTML = `<div class="s-connected-empty">No models${q ? ` matching “${escapeHtml(q)}”` : ""}.</div>`;
2031
+ return;
2032
+ }
2033
+ let html = "";
2034
+ ordered.forEach(([provider, items]) => {
2035
+ const searching = q.length > 0;
2036
+ const expanded = searching || !collapsedProviders[provider];
2037
+ const allVisible = items.every((m) => m.visible);
2038
+ html += `<div class="s-group" data-provider="${escapeHtml(provider)}">
2039
+ <div class="s-group-header">
2040
+ <button class="s-group-trigger" data-toggle="${escapeHtml(provider)}">
2041
+ <span class="s-chev">${expanded ? "▾" : "▸"}</span>
2042
+ <span class="s-provider-dot">${escapeHtml(provider.slice(0, 2))}</span>
2043
+ <span>${escapeHtml(provider)}</span>
2044
+ <span class="s-group-count">${items.length}</span>
2045
+ </button>
2046
+ <span class="switch ${allVisible ? "on" : ""}" data-provider-toggle="${escapeHtml(provider)}" title="Toggle all models"></span>
2047
+ </div>`;
2048
+ if (expanded) {
2049
+ items
2050
+ .slice()
2051
+ .sort((a, b) => a.name.localeCompare(b.name))
2052
+ .forEach((m) => {
2053
+ const key = `${m.provider}:${m.id}`;
2054
+ const isActive = key === current;
2055
+ html += `<div class="s-row ${m.visible ? "" : "hidden-model"}" data-model-row="${escapeHtml(key)}">
2056
+ <span class="s-active-dot" style="${isActive ? "" : "visibility:hidden"}"></span>
2057
+ <span class="s-name">${escapeHtml(m.name)}</span>
2058
+ ${m.id !== m.name ? `<span class="s-sub">${escapeHtml(m.id)}</span>` : ""}
2059
+ <button class="s-remove" data-remove="${escapeHtml(key)}" title="Remove">✕</button>
2060
+ <span class="switch ${m.visible ? "on" : ""}" data-model-toggle="${escapeHtml(key)}"></span>
2061
+ </div>`;
2062
+ });
2063
+ }
2064
+ html += `</div>`;
2065
+ });
2066
+ list.innerHTML = html;
2067
+
2068
+ $$("#s-models-list [data-toggle]").forEach((el) =>
2069
+ el.addEventListener("click", (e) => {
2070
+ e.stopPropagation();
2071
+ const p = el.dataset.toggle;
2072
+ collapsedProviders[p] = !collapsedProviders[p];
2073
+ renderSettingsModels();
2074
+ })
2075
+ );
2076
+ $$("#s-models-list [data-provider-toggle]").forEach((el) =>
2077
+ el.addEventListener("click", async (e) => {
2078
+ e.stopPropagation();
2079
+ const p = el.dataset.providerToggle;
2080
+ const allVisible = state.models
2081
+ .filter((m) => m.provider === p)
2082
+ .every((m) => m.visible);
2083
+ await api("set_provider_visibility", { provider: p, visible: !allVisible });
2084
+ await refreshCatalog();
2085
+ })
2086
+ );
2087
+ $$("#s-models-list [data-model-toggle]").forEach((el) =>
2088
+ el.addEventListener("click", async (e) => {
2089
+ e.stopPropagation();
2090
+ const [provider, ...rest] = el.dataset.modelToggle.split(":");
2091
+ const model = rest.join(":");
2092
+ const entry = state.models.find((m) => m.provider === provider && m.id === model);
2093
+ await api("set_model_visibility", {
2094
+ provider,
2095
+ model,
2096
+ visible: !(entry && entry.visible),
2097
+ });
2098
+ await refreshCatalog();
2099
+ })
2100
+ );
2101
+ $$("#s-models-list [data-model-row]").forEach((el) =>
2102
+ el.addEventListener("dblclick", () => pickModelFromSettings(el.dataset.modelRow))
2103
+ );
2104
+ $$("#s-models-list [data-remove]").forEach((el) =>
2105
+ el.addEventListener("click", async (e) => {
2106
+ e.stopPropagation();
2107
+ const [provider, ...rest] = el.dataset.remove.split(":");
2108
+ await api("remove_model", { provider, model: rest.join(":") });
2109
+ await refreshCatalog();
2110
+ toast("Model removed");
2111
+ })
2112
+ );
2113
+ }
2114
+
2115
+ function renderProviders() {
2116
+ const connectedList = $("#s-connected");
2117
+ if (!(state.connected || []).length) {
2118
+ connectedList.innerHTML = '<div class="s-connected-empty">No providers connected yet.</div>';
2119
+ } else {
2120
+ connectedList.innerHTML = state.connected
2121
+ .map(
2122
+ (p) => `<div class="provider-row">
2123
+ <span class="s-provider-dot">${escapeHtml(p.id.slice(0, 2))}</span>
2124
+ <div class="provider-main">
2125
+ <span class="provider-name">${escapeHtml(p.name)}</span>
2126
+ <span class="tag-custom">${p.models} models</span>
2127
+ ${p.active ? '<span class="s-active-dot"></span>' : ""}
2128
+ </div>
2129
+ <span class="provider-sub">${escapeHtml(p.base_url.replace(/^https?:\/\//, ""))}</span>
2130
+ ${p.has_key ? '<span class="tag-rec">KEY</span>' : ""}
2131
+ <span class="switch ${p.enabled ? "on" : ""}" data-provider-enable="${escapeHtml(p.id)}" title="Enable"></span>
2132
+ <button class="btn ghost" data-provider-refresh="${escapeHtml(p.id)}">Refresh</button>
2133
+ <button class="btn ghost" data-provider-open="${escapeHtml(p.id)}">Edit</button>
2134
+ <button class="btn ghost" data-provider-disconnect="${escapeHtml(p.id)}">Disconnect</button>
2135
+ </div>`
2136
+ )
2137
+ .join("");
2138
+ }
2139
+
2140
+ $("#s-popular").innerHTML =
2141
+ state.popular
2142
+ .map(
2143
+ (p) => `<div class="provider-row">
2144
+ <span class="s-provider-dot">${escapeHtml(p.id.slice(0, 2))}</span>
2145
+ <div class="provider-main">
2146
+ <span class="provider-name">${escapeHtml(p.name)}</span>
2147
+ ${["opencode", "opencode-go"].includes(p.id) ? '<span class="tag-rec">RECOMMENDED</span>' : ""}
2148
+ </div>
2149
+ <span class="provider-sub">${escapeHtml(p.base_url.replace(/^https?:\/\//, ""))}</span>
2150
+ <button class="btn ghost" data-provider-connect="${escapeHtml(p.id)}">Connect</button>
2151
+ </div>`
2152
+ )
2153
+ .join("") || '<div class="s-connected-empty">All providers connected.</div>';
2154
+
2155
+ $$("#s-connected [data-provider-enable]").forEach((el) =>
2156
+ el.addEventListener("click", async () => {
2157
+ const id = el.dataset.providerEnable;
2158
+ const entry = state.connected.find((p) => p.id === id);
2159
+ await api("set_provider_visibility", { provider: id, visible: !(entry && entry.enabled) });
2160
+ await refreshCatalog();
2161
+ })
2162
+ );
2163
+ $$("#s-connected [data-provider-refresh]").forEach((el) =>
2164
+ el.addEventListener("click", async () => {
2165
+ toast("Refreshing catalogue…");
2166
+ const res = await api("refresh_provider", el.dataset.providerRefresh);
2167
+ if (res && res.ok) toast(`${res.models} models`);
2168
+ else toast((res && res.error) || "refresh failed", true);
2169
+ await refreshCatalog();
2170
+ })
2171
+ );
2172
+ $$("#s-connected [data-provider-open]").forEach((el) =>
2173
+ el.addEventListener("click", () => {
2174
+ const entry = state.connected.find((p) => p.id === el.dataset.providerOpen);
2175
+ openConnectProvider(entry ? entry.id : "");
2176
+ })
2177
+ );
2178
+ $$("#s-connected [data-provider-disconnect]").forEach((el) =>
2179
+ el.addEventListener("click", async () => {
2180
+ await api("disconnect_provider", el.dataset.providerDisconnect);
2181
+ await refreshCatalog();
2182
+ toast("Provider disconnected");
2183
+ })
2184
+ );
2185
+ $$("#s-popular [data-provider-connect]").forEach((el) =>
2186
+ el.addEventListener("click", () => openConnectProvider(el.dataset.providerConnect))
2187
+ );
2188
+ }
2189
+
2190
+ function openSettings() {
2191
+ openModal("#modal-settings");
2192
+ refreshCatalog();
2193
+ }
2194
+
2195
+ function openConnectProvider(providerId) {
2196
+ const presets = state.bootstrap.providers || {};
2197
+ const select = $("#pr-provider");
2198
+ select.innerHTML = Object.keys(presets)
2199
+ .map((name) => `<option value="${name}">${name}</option>`)
2200
+ .join("");
2201
+ const connectedIds = state.connected.map((p) => p.id);
2202
+ const known = state.connected.find((p) => p.id === providerId);
2203
+ select.value = providerId || known?.id || connectedIds[0] || "opencode-go";
2204
+ const isEdit = !!known;
2205
+ $("#provider-title").textContent = isEdit ? `Edit ${known.id}` : "Connect provider";
2206
+ const preset = presets[select.value] || {};
2207
+ $("#pr-base-url").value = known ? known.base_url : preset.base_url || "";
2208
+ $("#pr-model").value = "";
2209
+ $("#pr-api-key").value = "";
2210
+ $("#pr-api-key").placeholder = known && known.has_key ? "leave blank to keep current" : "sk-…";
2211
+ $("#pr-activate").checked = known ? known.active : true;
2212
+ openModal("#modal-provider");
2213
+ }
2214
+
2215
+ async function connectProvider() {
2216
+ const payload = {
2217
+ provider: $("#pr-provider").value,
2218
+ base_url: $("#pr-base-url").value.trim(),
2219
+ api_key: $("#pr-api-key").value.trim(),
2220
+ activate: $("#pr-activate").checked,
2221
+ };
2222
+ const model = $("#pr-model").value.trim();
2223
+ if (model) payload.model = model;
2224
+ const status = $("#settings-status");
2225
+ status.textContent = "connecting…";
2226
+ status.className = "settings-status";
2227
+ const res = await api("connect_provider", payload);
2228
+ if (!res || !res.ok) {
2229
+ toast((res && res.error) || "could not connect", true);
2230
+ status.textContent = (res && res.error) || "failed";
2231
+ status.className = "settings-status bad";
2232
+ return;
2233
+ }
2234
+ if (model) {
2235
+ await api("add_custom_model", { provider: payload.provider, model });
2236
+ }
2237
+ closeModals();
2238
+ await refreshCatalog();
2239
+ syncActiveConfig();
2240
+ status.textContent = `${res.models} models${res.warning ? " · " + res.warning : ""}`;
2241
+ status.className = "settings-status " + (res.warning ? "bad" : "ok");
2242
+ toast(`Connected ${payload.provider} (${res.models} models)`);
2243
+ }
2244
+
2245
+ function syncActiveConfig() {
2246
+ const active = state.connected.find((p) => p.active);
2247
+ if (!active) return;
2248
+ const models = state.models.filter((m) => m.provider === active.id);
2249
+ const current = state.config.model;
2250
+ const stillThere = models.some((m) => m.id === current);
2251
+ state.config = {
2252
+ ...state.config,
2253
+ provider: active.id,
2254
+ model: stillThere ? current : models[0]?.id || current,
2255
+ base_url: active.base_url,
2256
+ api_key_set: active.has_key,
2257
+ };
2258
+ updateModelUI();
2259
+ }
2260
+
2261
+ /* ── forms ──────────────────────────────────────────────── */
2262
+ function fillSettingsForm() {
2263
+ $("#f-temperature").value = state.config.temperature != null ? state.config.temperature : 0.2;
2264
+ $("#f-max-tokens").value = state.config.max_tokens || 4096;
2265
+ }
2266
+
2267
+ function fillProjectForm() {
2268
+ const t = (state.project && state.project.target) || {};
2269
+ const run = (state.project && state.project.run) || {};
2270
+ $("#p-kind").value = t.kind || "web";
2271
+ $("#p-url").value = t.url || "";
2272
+ $("#p-scope").value = (t.scope || []).join(", ");
2273
+ $("#p-out").value = (t.out_of_scope || []).join(", ");
2274
+ $("#p-rounds").value = run.rounds || 3;
2275
+ $("#p-steps").value = run.max_steps || 12;
2276
+ $("#p-image").value = (run.sandbox && run.sandbox.image) || "";
2277
+ $("#p-sandbox").checked = !!(run.sandbox && run.sandbox.enabled);
2278
+ }
2279
+
2280
+ async function saveSettings() {
2281
+ const payload = {
2282
+ temperature: $("#f-temperature").value,
2283
+ max_tokens: $("#f-max-tokens").value,
2284
+ };
2285
+ const res = await api("save_llm_config", payload);
2286
+ if (!res || !res.ok) {
2287
+ toast("could not save settings", true);
2288
+ return;
2289
+ }
2290
+ state.config = {
2291
+ ...state.config,
2292
+ temperature: Number(payload.temperature),
2293
+ max_tokens: Number(payload.max_tokens),
2294
+ };
2295
+ toast("Settings saved");
2296
+ }
2297
+
2298
+ async function addCustomModel() {
2299
+ const provider = $("#cm-provider").value;
2300
+ const model = $("#cm-id").value.trim();
2301
+ if (!model) {
2302
+ toast("Enter a model id", true);
2303
+ return;
2304
+ }
2305
+ const res = await api("add_custom_model", {
2306
+ provider,
2307
+ model,
2308
+ name: $("#cm-name").value.trim(),
2309
+ });
2310
+ closeModals();
2311
+ if (!res || !res.ok) {
2312
+ toast((res && res.error) || "could not add model", true);
2313
+ return;
2314
+ }
2315
+ await refreshCatalog();
2316
+ toast(`Added ${model}`);
2317
+ }
2318
+
2319
+ function openAddModel() {
2320
+ const select = $("#cm-provider");
2321
+ select.innerHTML = state.connected
2322
+ .map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.id)}</option>`)
2323
+ .join("");
2324
+ if (!select.options.length) {
2325
+ toast("Connect a provider first", true);
2326
+ return;
2327
+ }
2328
+ select.value = state.config.provider || select.options[0].value;
2329
+ $("#cm-id").value = "";
2330
+ $("#cm-name").value = "";
2331
+ openModal("#modal-model");
2332
+ }
2333
+
2334
+ async function saveProject() {
2335
+ const payload = {
2336
+ name: state.project.name,
2337
+ target: {
2338
+ kind: $("#p-kind").value,
2339
+ url: $("#p-url").value.trim(),
2340
+ scope: $("#p-scope").value,
2341
+ out_of_scope: $("#p-out").value,
2342
+ },
2343
+ run: {
2344
+ rounds: Number($("#p-rounds").value || 3),
2345
+ max_steps: Number($("#p-steps").value || 12),
2346
+ sandbox: { enabled: $("#p-sandbox").checked, image: $("#p-image").value.trim() },
2347
+ },
2348
+ };
2349
+ const res = await api("save_project", payload);
2350
+ if (!res || !res.ok) {
2351
+ toast("could not save project", true);
2352
+ return;
2353
+ }
2354
+ state.project = res.project;
2355
+ renderTarget();
2356
+ fillProjectForm();
2357
+ closeModals();
2358
+ toast("Project saved");
2359
+ }
2360
+
2361
+ async function startAudit(plan) {
2362
+ if (state.running) return;
2363
+ // Set synchronously: two handlers (composer + document) can otherwise both
2364
+ // pass the guard before the first await resolves and start two audits.
2365
+ setRunning(true);
2366
+ setPhase("starting");
2367
+ const payload = {
2368
+ objective: state.objective || (plan && plan.objective) || "",
2369
+ target: { url: state.project.target ? state.project.target.url : "" },
2370
+ run: {
2371
+ rounds: Number($("#opt-rounds").value || 3),
2372
+ sandbox: $("#opt-sandbox").checked,
2373
+ allow_network: !!(state.project.run && state.project.run.allow_network),
2374
+ },
2375
+ };
2376
+ // An approved plan carries its scope/out-of-scope and the brief, so the audit
2377
+ // runs exactly what was proposed.
2378
+ if (plan) {
2379
+ if (plan.scope && plan.scope.length) payload.target.scope = plan.scope.join(", ");
2380
+ payload.brief = { ...(state.brief || {}), ...plan };
2381
+ } else if (state.brief) {
2382
+ payload.brief = state.brief;
2383
+ }
2384
+ const res = await api("start_audit", payload);
2385
+ if (!res || !res.ok) {
2386
+ toast(res && res.error ? res.error : "could not start", true);
2387
+ setRunning(false);
2388
+ return;
2389
+ }
2390
+ setRunning(true);
2391
+ addActivity("audit requested");
2392
+ }
2393
+
2394
+ async function exportReport() {
2395
+ const res = await api("export_report", null);
2396
+ if (!res || !res.ok) {
2397
+ toast(res && res.error ? res.error : "nothing to export", true);
2398
+ return;
2399
+ }
2400
+ state.reportPaths = res.reports || [];
2401
+ $("#btn-open-reports").disabled = false;
2402
+ renderReport();
2403
+ toast("Report exported");
2404
+ }
2405
+
2406
+ /* ── chat ───────────────────────────────────────────────── */
2407
+ async function sendChat() {
2408
+ const input = $("#chat-input");
2409
+ const message = input.value.trim();
2410
+ if (!message || state.chatting) return;
2411
+ const empty = $("#chat-empty");
2412
+ if (empty) empty.remove();
2413
+ // Finish any assistant bubble left open, then append the user's message to the
2414
+ // same conversation. The assistant reply streams below it as the next turn.
2415
+ finalize("assistant");
2416
+ appendUserMessage(message);
2417
+ input.value = "";
2418
+ input.style.height = "auto";
2419
+ const res = await api("chat_send", message);
2420
+ if (!res || !res.ok) {
2421
+ toast(res && res.error ? res.error : "could not send", true);
2422
+ input.value = message; // give the text back so nothing is lost
2423
+ return;
2424
+ }
2425
+ // Optimistically enter the working state; chat.start confirms it. This
2426
+ // removes the window where the user could fire a second message.
2427
+ setChatting(true);
2428
+ await pollChat();
2429
+ }
2430
+
2431
+ /* The copilot proposes a plan and waits. We render it as a card with an
2432
+ explicit Approve button; only that starts the audit. */
2433
+ function renderChatPlan(plan) {
2434
+ const stream = $("#chat-stream");
2435
+ const empty = $("#chat-empty");
2436
+ if (empty) empty.remove();
2437
+ finalize("assistant");
2438
+ const card = document.createElement("div");
2439
+ card.className = "chat-plan";
2440
+ const scope = (plan.scope || []).join(", ") || "see engagement config";
2441
+ const out = (plan.out_of_scope || []).join(", ");
2442
+ const phases = (plan.phases || []).map((p) => `<li>${escapeHtml(p)}</li>`).join("");
2443
+ card.innerHTML = `
2444
+ <div class="cp-head">
2445
+ <span class="cp-badge">Proposed engagement</span>
2446
+ <span class="cp-title">${escapeHtml(plan.objective || "Engagement")}</span>
2447
+ </div>
2448
+ <div class="cp-row"><span>In scope</span><code>${escapeHtml(scope)}</code></div>
2449
+ ${out ? `<div class="cp-row"><span>Out of scope</span><code>${escapeHtml(out)}</code></div>` : ""}
2450
+ ${phases ? `<div class="cp-phases"><div class="cp-sub">Phases</div><ol>${phases}</ol></div>` : ""}
2451
+ <div class="cp-foot">
2452
+ <span class="cp-note">Nothing is tested until you approve.</span>
2453
+ <button class="btn primary" id="cp-approve">Approve &amp; start</button>
2454
+ </div>`;
2455
+ stream.appendChild(card);
2456
+ stream.scrollTop = stream.scrollHeight;
2457
+ const approve = $("#cp-approve", card);
2458
+ if (approve) {
2459
+ approve.addEventListener("click", () => {
2460
+ approve.disabled = true;
2461
+ approve.textContent = "Starting…";
2462
+ if (plan.objective) setObjective(plan.objective);
2463
+ startAudit(plan);
2464
+ setMode("audit");
2465
+ });
2466
+ }
2467
+ }
2468
+
2469
+ function appendUserMessage(message) {
2470
+ const stream = $("#chat-stream");
2471
+ const wrap = document.createElement("div");
2472
+ wrap.className = "msg user";
2473
+ wrap.innerHTML = `
2474
+ <div class="msg-head">
2475
+ <span class="msg-name core">You</span>
2476
+ <span class="msg-time">${nowTime()}</span>
2477
+ </div>
2478
+ <div class="msg-body">${md(message)}</div>`;
2479
+ stream.appendChild(wrap);
2480
+ stream.scrollTop = stream.scrollHeight;
2481
+ }
2482
+
2483
+ /* The chat runs on a background thread and pushes events through the bridge.
2484
+ Between pushes we poll so the UI stays live even if an event is dropped.
2485
+
2486
+ The bridge push and this poll would otherwise deliver the same event twice,
2487
+ which duplicated replies and scrambled the timeline. We track an index and
2488
+ only apply events we have not seen, advancing past everything the bridge
2489
+ already pushed. */
2490
+ async function pollChat() {
2491
+ let seen = 0;
2492
+ for (let i = 0; i < 2400; i += 1) {
2493
+ await new Promise((r) => setTimeout(r, 120));
2494
+ if (!state.chatting) break;
2495
+ const res = await api("chat_state", seen);
2496
+ if (res && res.ok && Array.isArray(res.events)) {
2497
+ if (res.events.length) {
2498
+ window.SplitAgent.emit(res.events);
2499
+ seen = typeof res.next === "number" ? res.next : seen + res.events.length;
2500
+ }
2501
+ }
2502
+ if (res && res.ok && !res.running && !res.pending) {
2503
+ setChatting(false);
2504
+ finalize("assistant");
2505
+ break;
2506
+ }
2507
+ }
2508
+ }
2509
+
2510
+ async function clearChat() {
2511
+ await api("chat_reset");
2512
+ resetChat();
2513
+ setChatting(false);
2514
+ toast("Conversation cleared");
2515
+ }
2516
+
2517
+ /* ── modals / palette ───────────────────────────────────── */
2518
+ function openModal(sel) {
2519
+ closeModals();
2520
+ closeModelPicker();
2521
+ $(sel).classList.remove("hidden");
2522
+ }
2523
+ function closeModals() {
2524
+ $$(".overlay").forEach((el) => el.classList.add("hidden"));
2525
+ }
2526
+
2527
+ function openPalette(title, items, onPick) {
2528
+ state.palette.items = items;
2529
+ state.palette.index = 0;
2530
+ state.palette.onPick = onPick;
2531
+ $("#command-input").value = "";
2532
+ $("#command-input").placeholder = title;
2533
+ renderPalette("");
2534
+ openModal("#modal-command");
2535
+ setTimeout(() => $("#command-input").focus(), 30);
2536
+ }
2537
+
2538
+ function renderPalette(filter) {
2539
+ const list = $("#command-list");
2540
+ const f = filter.toLowerCase();
2541
+ const items = state.palette.items.filter((i) => !f || i.label.toLowerCase().includes(f));
2542
+ list.innerHTML =
2543
+ items
2544
+ .map(
2545
+ (i, idx) =>
2546
+ `<div class="command-item${idx === state.palette.index ? " active" : ""}" data-idx="${idx}">
2547
+ <span>${escapeHtml(i.label)}</span>
2548
+ ${i.hint ? `<span class="kbd">${escapeHtml(i.hint)}</span>` : ""}
2549
+ </div>`
2550
+ )
2551
+ .join("") || '<div class="panel-empty">No matches.</div>';
2552
+ state.palette.filtered = items;
2553
+ $$(".command-item", list).forEach((el) =>
2554
+ el.addEventListener("click", () => pickPalette(Number(el.dataset.idx)))
2555
+ );
2556
+ }
2557
+
2558
+ function pickPalette(index) {
2559
+ const item = (state.palette.filtered || [])[index];
2560
+ if (!item) return;
2561
+ closeModals();
2562
+ state.palette.onPick(item);
2563
+ }
2564
+
2565
+ const COMMANDS = [
2566
+ { label: "New audit (guided setup)", run: openWizard },
2567
+ { label: "Run audit", hint: "Ctrl+Enter", run: startAudit },
2568
+ { label: "Stop audit", run: () => api("stop_audit") },
2569
+ { label: "Open copilot chat", run: () => setMode("chat") },
2570
+ { label: "Choose model", run: () => openModelPicker($("#side-model-btn")) },
2571
+ { label: "Manage models & providers", hint: "Ctrl+,", run: openSettings },
2572
+ { label: "Connect a provider…", run: () => { openSettings(); openConnectProvider(""); } },
2573
+ { label: "Engagement / advanced", run: () => openModal("#modal-project") },
2574
+ { label: "Export report", run: exportReport },
2575
+ {
2576
+ label: "Open reports folder",
2577
+ run: () => {
2578
+ if (state.reportPaths[0]) api("reveal", state.reportPaths[0]);
2579
+ else toast("No report yet", true);
2580
+ },
2581
+ },
2582
+ {
2583
+ label: "Open project file…",
2584
+ run: async () => {
2585
+ const res = await api("choose_project_file");
2586
+ if (res && res.ok) {
2587
+ state.project = res.project;
2588
+ renderTarget();
2589
+ fillProjectForm();
2590
+ toast("Project loaded");
2591
+ }
2592
+ },
2593
+ },
2594
+ { label: "Clear audit stream", run: () => resetStream() },
2595
+ { label: "Clear chat", run: clearChat },
2596
+ ];
2597
+
2598
+ /* ── wiring ─────────────────────────────────────────────── */
2599
+ function autoGrow(el) {
2600
+ el.style.height = "auto";
2601
+ el.style.height = Math.min(el.scrollHeight, 160) + "px";
2602
+ }
2603
+
2604
+ function wire() {
2605
+ $$("#mode-switch .mode-btn").forEach((b) =>
2606
+ b.addEventListener("click", () => setMode(b.dataset.mode))
2607
+ );
2608
+
2609
+ // audit
2610
+ $("#btn-run").addEventListener("click", startAudit);
2611
+ $("#btn-stop").addEventListener("click", () => api("stop_audit"));
2612
+ $("#btn-guided").addEventListener("click", openWizard);
2613
+ $("#btn-wizard").addEventListener("click", openWizard);
2614
+ $("#empty-start").addEventListener("click", openWizard);
2615
+ $("#btn-export").addEventListener("click", exportReport);
2616
+ $("#btn-dump-trace").addEventListener("click", dumpTrace);
2617
+
2618
+ $("#btn-workspace").addEventListener("click", openWorkspace);
2619
+ $("#btn-toolbox").addEventListener("click", openToolboxDialog);
2620
+ $("#tb-install").addEventListener("click", installToolbox);
2621
+ $("#tb-edition").addEventListener("change", () => {
2622
+ const edition = $("#tb-edition").value;
2623
+ const size = edition === "kali" ? "~2.5 GB" : "~450 MB";
2624
+ $("#tb-hint").textContent =
2625
+ edition === "kali"
2626
+ ? `Kali Linux edition — everything preinstalled, ${size} download.`
2627
+ : `Standard edition — Debian with a curated toolset, ${size} download.`;
2628
+ });
2629
+ $("#tb-skip").addEventListener("click", async () => {
2630
+ await api("toolbox_action", "set_edition", "standard");
2631
+ closeModals();
2632
+ toast("Continuing in local mode");
2633
+ });
2634
+ $("#tb-autostart").addEventListener("change", async () => {
2635
+ // Persisted through the project save path on the next wizard save.
2636
+ });
2637
+ $("#td-toggle").addEventListener("click", toggleTodoDock);
2638
+ $("#td-toggle").addEventListener("keydown", (e) => {
2639
+ if (e.key === "Enter" || e.key === " ") {
2640
+ e.preventDefault();
2641
+ toggleTodoDock();
2642
+ }
2643
+ });
2644
+ $("#btn-open-reports").addEventListener("click", () => {
2645
+ if (state.reportPaths[0]) api("reveal", state.reportPaths[0]);
2646
+ else toast("No report yet", true);
2647
+ });
2648
+ $("#btn-project").addEventListener("click", () => openModal("#modal-project"));
2649
+
2650
+ // model pickers
2651
+ ["#model-pill", "#chat-model-pill", "#side-model-btn"].forEach((sel) =>
2652
+ $(sel).addEventListener("click", (e) => {
2653
+ e.stopPropagation();
2654
+ if (!$("#model-popover").classList.contains("hidden") && mpAnchor === e.currentTarget) {
2655
+ closeModelPicker();
2656
+ } else {
2657
+ openModelPicker(e.currentTarget);
2658
+ }
2659
+ })
2660
+ );
2661
+ $("#mp-input").addEventListener("input", (e) => {
2662
+ renderModelList(e.target.value);
2663
+ $("#mp-clear").classList.toggle("hidden", !e.target.value.trim());
2664
+ });
2665
+ $("#mp-input").addEventListener("keydown", (e) => {
2666
+ const items = state.mpFiltered || [];
2667
+ if (e.key === "Escape") { closeModelPicker(); e.preventDefault(); }
2668
+ else if (e.key === "ArrowDown" && items.length) {
2669
+ state.mpIndex = Math.min((state.mpIndex || 0) + 1, items.length - 1);
2670
+ highlightMp();
2671
+ e.preventDefault();
2672
+ } else if (e.key === "ArrowUp" && items.length) {
2673
+ state.mpIndex = Math.max((state.mpIndex || 0) - 1, 0);
2674
+ highlightMp();
2675
+ e.preventDefault();
2676
+ } else if (e.key === "Enter" && items.length) {
2677
+ pickModel(items[state.mpIndex || 0].key);
2678
+ e.preventDefault();
2679
+ }
2680
+ });
2681
+ $("#mp-clear").addEventListener("click", () => {
2682
+ $("#mp-input").value = "";
2683
+ renderModelList("");
2684
+ $("#mp-clear").classList.add("hidden");
2685
+ $("#mp-input").focus();
2686
+ });
2687
+ $("#mp-manage").addEventListener("click", () => {
2688
+ closeModelPicker();
2689
+ openSettings();
2690
+ });
2691
+ document.addEventListener("click", (e) => {
2692
+ if (!$("#model-popover").classList.contains("hidden") &&
2693
+ !$("#model-popover").contains(e.target) &&
2694
+ !e.target.closest(".model-trigger") && !e.target.closest("#side-model-btn")) {
2695
+ closeModelPicker();
2696
+ }
2697
+ });
2698
+ window.addEventListener("resize", () => {
2699
+ if (!$("#model-popover").classList.contains("hidden")) positionPopover();
2700
+ });
2701
+
2702
+ // chat
2703
+ $("#chat-send").addEventListener("click", sendChat);
2704
+ $("#chat-stop").addEventListener("click", () => api("chat_stop"));
2705
+ $("#chat-clear").addEventListener("click", clearChat);
2706
+ const chatInput = $("#chat-input");
2707
+ chatInput.addEventListener("input", () => autoGrow(chatInput));
2708
+ chatInput.addEventListener("keydown", (e) => {
2709
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
2710
+ e.preventDefault();
2711
+ sendChat();
2712
+ }
2713
+ });
2714
+ document.addEventListener("click", (e) => {
2715
+ if (e.target.classList && e.target.classList.contains("suggestion")) {
2716
+ chatInput.value = e.target.textContent;
2717
+ autoGrow(chatInput);
2718
+ sendChat();
2719
+ }
2720
+ });
2721
+
2722
+ // wizard
2723
+ $("#wiz-next").addEventListener("click", wizardNext);
2724
+ $("#wiz-back").addEventListener("click", () => setWizardStep(state.wizard.step - 1));
2725
+ $$("#kind-grid .kind-card").forEach((c) =>
2726
+ c.addEventListener("click", () => {
2727
+ state.wizard.kind = c.dataset.kind;
2728
+ $$("#kind-grid .kind-card").forEach((x) => x.classList.toggle("active", x === c));
2729
+ })
2730
+ );
2731
+ $$("#depth-grid .depth-card").forEach((c) =>
2732
+ c.addEventListener("click", () => {
2733
+ state.wizard.depth = c.dataset.depth;
2734
+ $$("#depth-grid .depth-card").forEach((x) => x.classList.toggle("active", x === c));
2735
+ renderWizardSummary();
2736
+ })
2737
+ );
2738
+ $$('input[name="env"]').forEach((r) =>
2739
+ r.addEventListener("change", () => {
2740
+ const sandbox = document.querySelector('input[name="env"]:checked').value === "sandbox";
2741
+ $("#wiz-image-field").style.opacity = sandbox ? "1" : "0.4";
2742
+ $("#wiz-image").disabled = !sandbox;
2743
+ })
2744
+ );
2745
+ $$("#wiz-presets .mini-btn").forEach((b) =>
2746
+ b.addEventListener("click", async () => {
2747
+ const res = await api("apply_target_preset", b.dataset.preset);
2748
+ if (res && res.ok) {
2749
+ state.project = res.project;
2750
+ const t = res.project.target || {};
2751
+ const run = res.project.run || {};
2752
+ $("#wiz-url").value = t.url || "";
2753
+ $("#wiz-scope").value = (t.scope || []).join(", ");
2754
+ $("#wiz-ports").value = (t.ports || []).join(", ");
2755
+ $("#wiz-image").value = (run.sandbox && run.sandbox.image) || "";
2756
+ toast(`Preset ${b.dataset.preset} applied`);
2757
+ }
2758
+ })
2759
+ );
2760
+
2761
+ // "Open planner chat" from the brief step: stash the current brief, close the
2762
+ // wizard and open the right-hand planner chat panel.
2763
+ $("#wiz-open-planner").addEventListener("click", () => {
2764
+ state.brief = collectBrief();
2765
+ closeModals();
2766
+ openPlanChat();
2767
+ planChatSystem(
2768
+ "Planner chat ready. Tell me anything about the environment or the scope, and I'll fold it into the plan. I only plan — I never test."
2769
+ );
2770
+ });
2771
+
2772
+ // scope document upload
2773
+ $("#wiz-scope-upload").addEventListener("click", async () => {
2774
+ const res = await api("pick_scope_document");
2775
+ if (res && res.ok) {
2776
+ setScopeDoc(res.name, res.content);
2777
+ if (res.truncated) toast("Document truncated to 20000 characters", true);
2778
+ else toast(`Attached ${res.name}`);
2779
+ } else if (res && res.error) {
2780
+ toast(res.error, true);
2781
+ }
2782
+ });
2783
+ $("#wiz-scope-clear").addEventListener("click", () => setScopeDoc("", ""));
2784
+
2785
+ // planner chat panel (right side)
2786
+ $("#plan-chat-send").addEventListener("click", sendPlanChat);
2787
+ $("#plan-chat-input").addEventListener("keydown", (e) => {
2788
+ if (e.key === "Enter") {
2789
+ e.preventDefault();
2790
+ sendPlanChat();
2791
+ }
2792
+ });
2793
+
2794
+ // settings
2795
+ $$("#settings-tabs .stab").forEach((tab) =>
2796
+ tab.addEventListener("click", () => {
2797
+ $$("#settings-tabs .stab").forEach((t) => t.classList.remove("active"));
2798
+ $$(".spanel").forEach((p) => p.classList.remove("active"));
2799
+ tab.classList.add("active");
2800
+ $("#spanel-" + tab.dataset.stab).classList.add("active");
2801
+ })
2802
+ );
2803
+ $("#s-model-search").addEventListener("input", (e) => {
2804
+ renderSettingsModels();
2805
+ $("#s-model-search-clear").classList.toggle("hidden", !e.target.value.trim());
2806
+ });
2807
+ $("#s-model-search-clear").addEventListener("click", () => {
2808
+ $("#s-model-search").value = "";
2809
+ renderSettingsModels();
2810
+ $("#s-model-search-clear").classList.add("hidden");
2811
+ });
2812
+ $("#btn-add-model").addEventListener("click", openAddModel);
2813
+ $("#btn-model-add").addEventListener("click", addCustomModel);
2814
+ $("#btn-connect-provider").addEventListener("click", () => openConnectProvider(""));
2815
+ $("#btn-provider-connect").addEventListener("click", connectProvider);
2816
+ $("#pr-provider").addEventListener("change", () => {
2817
+ const preset = (state.bootstrap.providers || {})[$("#pr-provider").value];
2818
+ if (preset) {
2819
+ $("#pr-base-url").value = preset.base_url;
2820
+ $("#pr-model").value = "";
2821
+ }
2822
+ });
2823
+ $("#btn-open-config").addEventListener("click", () => {
2824
+ const path = state.bootstrap.config_path || "";
2825
+ if (path) api("reveal", path);
2826
+ });
2827
+ $("#btn-save-settings").addEventListener("click", saveSettings);
2828
+ $("#btn-test").addEventListener("click", async () => {
2829
+ const result = $("#test-result");
2830
+ result.textContent = "testing...";
2831
+ result.className = "test-result";
2832
+ const res = await api("test_connection", {});
2833
+ result.textContent = res && res.ok ? `OK · ${res.message}` : `Failed · ${(res && res.message) || "error"}`;
2834
+ result.className = "test-result " + (res && res.ok ? "ok" : "bad");
2835
+ });
2836
+ $("#btn-settings").addEventListener("click", openSettings);
2837
+ $("#btn-save-project").addEventListener("click", saveProject);
2838
+ $("#btn-load-project").addEventListener("click", async () => {
2839
+ const res = await api("choose_project_file");
2840
+ if (res && res.ok) {
2841
+ state.project = res.project;
2842
+ renderTarget();
2843
+ fillProjectForm();
2844
+ toast("Project loaded");
2845
+ }
2846
+ });
2847
+ $("#btn-command").addEventListener("click", () => openPalette("Command", COMMANDS, (i) => i.run()));
2848
+
2849
+ // panel collapse + drag resize
2850
+ const body = $("#body");
2851
+ const sideW = () =>
2852
+ parseFloat(getComputedStyle(body).getPropertyValue("--side-w")) || 272;
2853
+ const reviewW = () =>
2854
+ parseFloat(getComputedStyle(body).getPropertyValue("--review-w")) || 384;
2855
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2856
+
2857
+ const toggleSidebar = () =>
2858
+ document.body.classList.toggle("sidebar-collapsed");
2859
+ const toggleReview = () => document.body.classList.toggle("review-collapsed");
2860
+ $("#btn-toggle-sidebar").addEventListener("click", toggleSidebar);
2861
+ $("#btn-toggle-review").addEventListener("click", toggleReview);
2862
+
2863
+ function installResizer(el, varName, getter, min, max, invert) {
2864
+ if (!el) return;
2865
+ let startX = 0;
2866
+ let startW = 0;
2867
+ const move = (e) => {
2868
+ const delta = (e.clientX - startX) * (invert ? -1 : 1);
2869
+ body.style.setProperty(varName, clamp(startW + delta, min, max) + "px");
2870
+ };
2871
+ const up = () => {
2872
+ el.classList.remove("dragging");
2873
+ body.classList.remove("resizing");
2874
+ window.removeEventListener("mousemove", move);
2875
+ window.removeEventListener("mouseup", up);
2876
+ };
2877
+ el.addEventListener("mousedown", (e) => {
2878
+ e.preventDefault();
2879
+ startX = e.clientX;
2880
+ startW = getter();
2881
+ el.classList.add("dragging");
2882
+ body.classList.add("resizing");
2883
+ window.addEventListener("mousemove", move);
2884
+ window.addEventListener("mouseup", up);
2885
+ });
2886
+ }
2887
+ installResizer($("#resize-sidebar"), "--side-w", sideW, 180, 420, false);
2888
+ installResizer($("#resize-review"), "--review-w", reviewW, 260, 640, true);
2889
+
2890
+ // window controls
2891
+ $("#win-min").addEventListener("click", () => api("window_action", "minimize"));
2892
+ $("#win-max").addEventListener("click", () => api("window_action", "maximize"));
2893
+ $("#win-close").addEventListener("click", () => api("window_action", "close"));
2894
+
2895
+ $$("[data-close]").forEach((el) => el.addEventListener("click", closeModals));
2896
+ $$(".overlay").forEach((el) =>
2897
+ el.addEventListener("mousedown", (e) => {
2898
+ if (e.target === el) closeModals();
2899
+ })
2900
+ );
2901
+
2902
+ // review tabs
2903
+ $$(".rtab").forEach((tab) =>
2904
+ tab.addEventListener("click", () => {
2905
+ $$(".rtab").forEach((t) => t.classList.remove("active"));
2906
+ $$(".rpanel").forEach((p) => p.classList.remove("active"));
2907
+ tab.classList.add("active");
2908
+ $("#panel-" + tab.dataset.tab).classList.add("active");
2909
+ })
2910
+ );
2911
+
2912
+ // audit bar: objective is set from the plan; clicking it opens the planner.
2913
+ setObjective(state.objective);
2914
+ $("#btn-objective").addEventListener("click", () => openPlanChat());
2915
+ $("#btn-plan-chat").addEventListener("click", () => openPlanChat());
2916
+
2917
+ document.addEventListener("keydown", (e) => {
2918
+ if (e.key === "Escape") { closeModals(); closeModelPicker(); }
2919
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
2920
+ e.preventDefault();
2921
+ openPalette("Command", COMMANDS, (i) => i.run());
2922
+ }
2923
+ if ((e.ctrlKey || e.metaKey) && e.key === ",") {
2924
+ e.preventDefault();
2925
+ openSettings();
2926
+ }
2927
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
2928
+ e.preventDefault();
2929
+ document.body.classList.toggle("sidebar-collapsed");
2930
+ }
2931
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "j") {
2932
+ e.preventDefault();
2933
+ document.body.classList.toggle("review-collapsed");
2934
+ }
2935
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
2936
+ e.preventDefault();
2937
+ if (state.mode === "chat") sendChat();
2938
+ else startAudit();
2939
+ }
2940
+ if (!$("#modal-command").classList.contains("hidden")) {
2941
+ const filtered = state.palette.filtered || [];
2942
+ if (e.key === "ArrowDown") {
2943
+ state.palette.index = Math.min(state.palette.index + 1, filtered.length - 1);
2944
+ renderPalette($("#command-input").value);
2945
+ e.preventDefault();
2946
+ } else if (e.key === "ArrowUp") {
2947
+ state.palette.index = Math.max(state.palette.index - 1, 0);
2948
+ renderPalette($("#command-input").value);
2949
+ e.preventDefault();
2950
+ } else if (e.key === "Enter") {
2951
+ pickPalette(state.palette.index);
2952
+ e.preventDefault();
2953
+ }
2954
+ }
2955
+ });
2956
+ $("#command-input").addEventListener("input", (e) => {
2957
+ state.palette.index = 0;
2958
+ renderPalette(e.target.value);
2959
+ });
2960
+
2961
+ document.addEventListener("click", (e) => {
2962
+ const link = e.target.closest("[data-open]");
2963
+ if (link) {
2964
+ e.preventDefault();
2965
+ api("open_path", link.dataset.open);
2966
+ }
2967
+ });
2968
+ }
2969
+
2970
+ function highlightMp() {
2971
+ $$("#mp-list .mp-item").forEach((el, i) => el.classList.toggle("active", i === (state.mpIndex || 0)));
2972
+ }
2973
+
2974
+ /* ── Boot intro ─────────────────────────────────────────── */
2975
+ function playIntro() {
2976
+ const intro = $("#intro");
2977
+ if (!intro) return;
2978
+ // Respect reduced motion: skip straight to the app.
2979
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
2980
+ intro.remove();
2981
+ return;
2982
+ }
2983
+
2984
+ const lines = $("#intro-lines");
2985
+ const script = [
2986
+ "initialising purple-team core",
2987
+ "loading recon \u00b7 web \u00b7 exploit toolkits",
2988
+ "encrypted shared context ready",
2989
+ "red team \u00b7 blue team online",
2990
+ ];
2991
+ const canvas = $("#intro-canvas");
2992
+ const ctx = canvas.getContext("2d");
2993
+ const dpr = window.devicePixelRatio || 1;
2994
+ const resize = () => {
2995
+ canvas.width = intro.clientWidth * dpr;
2996
+ canvas.height = intro.clientHeight * dpr;
2997
+ };
2998
+ resize();
2999
+
3000
+ let raf = 0;
3001
+ const stars = Array.from({ length: 90 }, () => ({
3002
+ x: Math.random(),
3003
+ y: Math.random(),
3004
+ z: Math.random() * 0.8 + 0.2,
3005
+ r: Math.random() * 1.6 + 0.4,
3006
+ hue: Math.random() < 0.5 ? "91,118,255" : "90,210,230",
3007
+ }));
3008
+ const draw = () => {
3009
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3010
+ stars.forEach((s) => {
3011
+ s.y += 0.0004 * s.z;
3012
+ if (s.y > 1) s.y = 0;
3013
+ const x = s.x * canvas.width + Math.sin((Date.now() / 1400 + s.x * 20)) * 6 * dpr;
3014
+ const y = s.y * canvas.height;
3015
+ const r = s.r * s.z * dpr;
3016
+ ctx.beginPath();
3017
+ ctx.arc(x, y, r, 0, Math.PI * 2);
3018
+ ctx.fillStyle = `rgba(${s.hue},${0.25 + s.z * 0.55})`;
3019
+ ctx.fill();
3020
+ });
3021
+ raf = requestAnimationFrame(draw);
3022
+ };
3023
+ draw();
3024
+
3025
+ script.forEach((text, i) => {
3026
+ const line = document.createElement("div");
3027
+ line.innerHTML = `<b>\u2713</b> ${escapeHtml(text)}`;
3028
+ line.style.animationDelay = `${0.9 + i * 0.4}s`;
3029
+ lines.appendChild(line);
3030
+ });
3031
+
3032
+ const finish = () => {
3033
+ cancelAnimationFrame(raf);
3034
+ intro.classList.add("done");
3035
+ document.body.classList.add("ready");
3036
+ setTimeout(() => intro.remove(), 700);
3037
+ };
3038
+ // setTimeout takes milliseconds; the stagger above is in seconds.
3039
+ const totalMs = (0.9 + script.length * 0.4 + 0.7) * 1000;
3040
+ setTimeout(finish, totalMs);
3041
+ intro.addEventListener("click", finish);
3042
+ }
3043
+
3044
+ let booted = false;
3045
+ let wired = false;
3046
+ async function boot() {
3047
+ if (booted) return;
3048
+ if (!window.pywebview || !window.pywebview.api) {
3049
+ setTimeout(boot, 200);
3050
+ return;
3051
+ }
3052
+ if (!wired) {
3053
+ wire();
3054
+ wired = true;
3055
+ }
3056
+ const data = await api("bootstrap");
3057
+ if (data && data.version) {
3058
+ booted = true;
3059
+ renderBootstrap(data);
3060
+ playIntro();
3061
+ } else {
3062
+ setTimeout(boot, 300);
3063
+ }
3064
+ }
3065
+
3066
+ window.addEventListener("pywebviewready", boot);
3067
+ if (window.pywebview && window.pywebview.api) boot();