scenescout 3.15.0 → 3.17.0

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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -0,0 +1,441 @@
1
+ /**
2
+ * The run-status pane's page: the `ui://` resource an MCP Apps host renders
3
+ * in a sandboxed iframe (SEP-1865, specification 2026-01-26).
4
+ *
5
+ * It speaks the extension's JSON-RPC over postMessage itself rather than
6
+ * bundling the ext-apps SDK, so it is one string with no external asset and
7
+ * the resource declares no outside origin: the host's restrictive default CSP
8
+ * applies. The handshake is the one the SDK's App.connect sends:
9
+ * `ui/initialize` with appInfo, appCapabilities and protocolVersion, then
10
+ * `ui/notifications/initialized`. Data comes from `tools/call` on the app-only
11
+ * poll tool every STATUS_POLL_MS, as the system-monitor example polls its own.
12
+ *
13
+ * Everything shown is untrusted (session names, tasks and objectives come from
14
+ * the agent, URLs from the app under test), so the script sets text with
15
+ * textContent and never builds markup from data. It accepts messages only from
16
+ * its parent window. Like live-page.ts, the client script avoids template
17
+ * literals because the page is one; status-pane tests check the script parses.
18
+ */
19
+ import { MCP_APPS_PROTOCOL, STATUS_POLL_MS, STATUS_POLL_TOOL } from "./status-pane.js";
20
+ export function statusPanePage(version) {
21
+ return `<!doctype html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26
+ <meta name="color-scheme" content="light dark">
27
+ <title>SceneScout run</title>
28
+ <style>
29
+ /* The host may set any of these (the spec's standard variables); each has a default for one that does not. */
30
+ :root {
31
+ color-scheme: light dark;
32
+ --color-background-primary: light-dark(#ffffff, #171a1f);
33
+ --color-background-secondary: light-dark(#f5f6f8, #1f232a);
34
+ --color-text-primary: light-dark(#15181d, #e8ebef);
35
+ --color-text-secondary: light-dark(#5b6472, #9aa3b1);
36
+ --color-text-danger: light-dark(#b42318, #ff8a80);
37
+ --color-text-warning: light-dark(#9a5b00, #f5c065);
38
+ --color-text-info: light-dark(#1f5fbf, #8ab4ff);
39
+ --color-text-success: light-dark(#18794e, #6fd3a2);
40
+ --color-border-primary: light-dark(#d8dce2, #343a44);
41
+ --color-ring-primary: light-dark(#2563eb, #7aa2ff);
42
+ --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
43
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
44
+ --font-text-sm-size: 13px;
45
+ --font-text-md-size: 14px;
46
+ --border-radius-sm: 6px;
47
+ --border-radius-md: 8px;
48
+ }
49
+ * { box-sizing: border-box; }
50
+ html, body { margin: 0; }
51
+ body {
52
+ background: var(--color-background-primary); color: var(--color-text-primary);
53
+ font: var(--font-text-md-size)/1.45 var(--font-sans);
54
+ overflow-wrap: anywhere;
55
+ }
56
+ main { padding: 12px; display: grid; gap: 12px; max-width: 760px; }
57
+ header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
58
+ h1 { margin: 0; font-size: 15px; font-weight: 650; flex: 1 1 auto; }
59
+ h2 { margin: 0 0 6px; font-size: var(--font-text-sm-size); font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: .04em; }
60
+ .status { display: inline-flex; align-items: center; gap: 6px; color: var(--color-text-secondary); font-size: var(--font-text-sm-size); font-variant-numeric: tabular-nums; }
61
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-text-secondary); flex: none; }
62
+ .dot.on { background: var(--color-text-success); }
63
+ .dot.err { background: var(--color-text-danger); }
64
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; }
65
+ button {
66
+ font: inherit; font-size: var(--font-text-sm-size); color: var(--color-text-primary);
67
+ background: var(--color-background-secondary); border: 1px solid var(--color-border-primary);
68
+ border-radius: var(--border-radius-sm); padding: 4px 10px; cursor: pointer;
69
+ }
70
+ button:hover { border-color: var(--color-ring-primary); }
71
+ button:focus-visible { outline: 2px solid var(--color-ring-primary); outline-offset: 1px; }
72
+ button[disabled] { opacity: .55; cursor: default; }
73
+ .live { display: grid; gap: 4px; }
74
+ .url { font: 12px/1.4 var(--font-mono); color: var(--color-text-secondary); user-select: all; }
75
+ .note { margin: 0; color: var(--color-text-secondary); font-size: var(--font-text-sm-size); }
76
+ section { background: var(--color-background-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--border-radius-md); padding: 10px 12px; }
77
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); gap: 8px; }
78
+ .tile { display: grid; gap: 2px; }
79
+ .tile b { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.1; }
80
+ .tile span { font-size: var(--font-text-sm-size); color: var(--color-text-secondary); }
81
+ .tile.high b { color: var(--color-text-danger); }
82
+ .tile.medium b { color: var(--color-text-warning); }
83
+ .tile.low b { color: var(--color-text-info); }
84
+ .sub { margin: 8px 0 0; font-size: var(--font-text-sm-size); color: var(--color-text-secondary); }
85
+ .bar { height: 6px; border-radius: 3px; background: var(--color-border-primary); overflow: hidden; margin: 4px 0 6px; }
86
+ .bar i { display: block; height: 100%; background: var(--color-ring-primary); width: 0; }
87
+ ul.sessions { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
88
+ ul.sessions li { display: grid; gap: 2px; padding-top: 8px; border-top: 1px solid var(--color-border-primary); }
89
+ ul.sessions li:first-child { border-top: 0; padding-top: 0; }
90
+ .who { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
91
+ .who strong { font-weight: 600; }
92
+ .state { font-size: 12px; padding: 0 6px; border-radius: 999px; border: 1px solid currentColor; }
93
+ .state.running { color: var(--color-text-warning); }
94
+ .state.idle { color: var(--color-text-secondary); }
95
+ .state.stuck { color: var(--color-text-danger); }
96
+ .line { font-size: var(--font-text-sm-size); color: var(--color-text-secondary); }
97
+ .line em { font-style: normal; color: var(--color-text-primary); }
98
+ .page { font: 12px/1.4 var(--font-mono); color: var(--color-text-secondary); }
99
+ [hidden] { display: none !important; }
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <main id="main" data-testid="status-pane">
104
+ <header>
105
+ <h1>SceneScout run</h1>
106
+ <span class="status" role="status" aria-live="polite"><span id="dot" class="dot"></span><span id="status-text" data-testid="status-pane-updated">Connecting…</span></span>
107
+ <div class="actions">
108
+ <button type="button" id="open-live" data-testid="status-pane-live-open" disabled>Open live view</button>
109
+ <button type="button" id="toggle" data-testid="status-pane-poll-toggle" aria-pressed="false">Pause</button>
110
+ </div>
111
+ </header>
112
+ <div class="live">
113
+ <code id="live-url" class="url" data-testid="status-pane-live-url" hidden></code>
114
+ <p id="live-note" class="note" data-testid="status-pane-live-note" hidden></p>
115
+ </div>
116
+ <section aria-labelledby="findings-h" data-testid="status-pane-findings">
117
+ <h2 id="findings-h">Open findings</h2>
118
+ <div class="tiles">
119
+ <div class="tile high"><b id="f-high">–</b><span>high</span></div>
120
+ <div class="tile medium"><b id="f-medium">–</b><span>medium</span></div>
121
+ <div class="tile low"><b id="f-low">–</b><span>low</span></div>
122
+ <div class="tile"><b id="f-open">–</b><span>open</span></div>
123
+ </div>
124
+ <p id="f-sub" class="sub">No session has attached yet.</p>
125
+ </section>
126
+ <section aria-labelledby="coverage-h" data-testid="status-pane-coverage">
127
+ <h2 id="coverage-h">Coverage</h2>
128
+ <div id="routes" hidden>
129
+ <div class="line">Routes <em id="c-routes"></em></div>
130
+ <div class="bar" role="img" id="c-bar-wrap"><i id="c-bar"></i></div>
131
+ </div>
132
+ <div id="c-line" class="line">Nothing measured yet.</div>
133
+ </section>
134
+ <section aria-labelledby="sessions-h" data-testid="status-pane-sessions">
135
+ <h2 id="sessions-h">Sessions</h2>
136
+ <ul id="sessions" class="sessions"></ul>
137
+ <p id="no-sessions" class="note">No session is attached.</p>
138
+ </section>
139
+ </main>
140
+ <script>
141
+ (function () {
142
+ "use strict";
143
+ var POLL_TOOL = ${JSON.stringify(STATUS_POLL_TOOL)};
144
+ var POLL_MS = ${STATUS_POLL_MS};
145
+ var PROTOCOL = ${JSON.stringify(MCP_APPS_PROTOCOL)};
146
+ var VERSION = ${JSON.stringify(version)};
147
+ var REQUEST_MS = 10000;
148
+
149
+ var nextId = 1;
150
+ var pending = {};
151
+ var connected = false;
152
+ var paused = false;
153
+ var timer = null;
154
+ var inFlight = false;
155
+ var liveUrl = null;
156
+ var canOpenLinks = false;
157
+ var openHint = "";
158
+ var observer = null;
159
+
160
+ function el(id) { return document.getElementById(id); }
161
+
162
+ // The view cannot know its host's origin, so it posts to its parent with "*",
163
+ // as the spec's own example and the SDK's transport do. Only the parent can
164
+ // receive it, and every message the view sends carries data the host gave it.
165
+ function post(message) { window.parent.postMessage(message, "*"); }
166
+
167
+ function request(method, params) {
168
+ var id = nextId++;
169
+ return new Promise(function (resolve, reject) {
170
+ var timeout = setTimeout(function () {
171
+ delete pending[id];
172
+ reject(new Error(method + " got no answer"));
173
+ }, REQUEST_MS);
174
+ pending[id] = { resolve: resolve, reject: reject, timeout: timeout };
175
+ post({ jsonrpc: "2.0", id: id, method: method, params: params });
176
+ });
177
+ }
178
+
179
+ function notify(method, params) {
180
+ var message = { jsonrpc: "2.0", method: method };
181
+ if (params !== undefined) message.params = params;
182
+ post(message);
183
+ }
184
+
185
+ function reply(id, result) { post({ jsonrpc: "2.0", id: id, result: result }); }
186
+
187
+ window.addEventListener("message", function (event) {
188
+ if (event.source !== window.parent || window.parent === window) return;
189
+ var m = event.data;
190
+ if (!m || m.jsonrpc !== "2.0") return;
191
+ if (m.method === undefined && m.id !== undefined && m.id !== null) {
192
+ var waiting = pending[m.id];
193
+ if (!waiting) return;
194
+ delete pending[m.id];
195
+ clearTimeout(waiting.timeout);
196
+ if (m.error) waiting.reject(new Error(m.error.message || "request failed"));
197
+ else waiting.resolve(m.result);
198
+ return;
199
+ }
200
+ if (typeof m.method !== "string") return;
201
+ if (m.id !== undefined && m.id !== null) {
202
+ if (m.method === "ui/resource-teardown") { tearDown(); reply(m.id, {}); return; }
203
+ if (m.method === "ping") { reply(m.id, {}); return; }
204
+ post({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "Method not found" } });
205
+ return;
206
+ }
207
+ if (m.method === "ui/notifications/tool-result") {
208
+ var data = m.params && m.params.structuredContent;
209
+ if (data && typeof data === "object") render(data);
210
+ } else if (m.method === "ui/notifications/host-context-changed") {
211
+ applyContext(m.params || {});
212
+ }
213
+ });
214
+
215
+ function applyContext(ctx) {
216
+ var root = document.documentElement;
217
+ if (ctx.theme === "light" || ctx.theme === "dark") {
218
+ root.setAttribute("data-theme", ctx.theme);
219
+ root.style.colorScheme = ctx.theme;
220
+ }
221
+ var vars = ctx.styles && ctx.styles.variables;
222
+ if (vars && typeof vars === "object") {
223
+ Object.keys(vars).forEach(function (key) {
224
+ if (key.indexOf("--") === 0 && typeof vars[key] === "string") root.style.setProperty(key, vars[key]);
225
+ });
226
+ }
227
+ var inset = ctx.safeAreaInsets;
228
+ if (inset) {
229
+ var main = el("main");
230
+ main.style.paddingTop = (12 + (inset.top || 0)) + "px";
231
+ main.style.paddingRight = (12 + (inset.right || 0)) + "px";
232
+ main.style.paddingBottom = (12 + (inset.bottom || 0)) + "px";
233
+ main.style.paddingLeft = (12 + (inset.left || 0)) + "px";
234
+ }
235
+ }
236
+
237
+ // Report the content's size so a host with a flexible container can fit it, as the SDK's autoResize does.
238
+ function watchSize() {
239
+ var lastW = 0, lastH = 0, scheduled = false;
240
+ function send() {
241
+ if (scheduled) return;
242
+ scheduled = true;
243
+ requestAnimationFrame(function () {
244
+ scheduled = false;
245
+ var html = document.documentElement;
246
+ var before = html.style.height;
247
+ html.style.height = "max-content";
248
+ var h = Math.ceil(html.getBoundingClientRect().height);
249
+ html.style.height = before;
250
+ var w = Math.ceil(window.innerWidth);
251
+ if (w !== lastW || h !== lastH) {
252
+ lastW = w; lastH = h;
253
+ notify("ui/notifications/size-changed", { width: w, height: h });
254
+ }
255
+ });
256
+ }
257
+ send();
258
+ observer = new ResizeObserver(send);
259
+ observer.observe(document.documentElement);
260
+ observer.observe(document.body);
261
+ }
262
+
263
+ function setStatus(text, state) {
264
+ el("status-text").textContent = text;
265
+ el("dot").className = "dot" + (state ? " " + state : "");
266
+ }
267
+
268
+ function clock(iso) {
269
+ var d = new Date(iso);
270
+ if (isNaN(d.getTime())) return "";
271
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map(function (n) { return (n < 10 ? "0" : "") + n; }).join(":");
272
+ }
273
+
274
+ function duration(ms) {
275
+ var total = Math.max(0, Math.floor(ms / 1000));
276
+ if (total < 60) return total + "s";
277
+ var minutes = Math.floor(total / 60);
278
+ if (minutes < 60) return minutes + "m" + (total % 60 < 10 ? "0" : "") + (total % 60) + "s";
279
+ return Math.floor(minutes / 60) + "h" + (minutes % 60 < 10 ? "0" : "") + (minutes % 60) + "m";
280
+ }
281
+
282
+ function plural(n, word) { return n + " " + word + (n === 1 ? "" : "s"); }
283
+
284
+ function render(data) {
285
+ liveUrl = typeof data.liveUrl === "string" ? data.liveUrl : null;
286
+ el("live-url").textContent = liveUrl || "";
287
+ el("live-url").hidden = !liveUrl;
288
+ el("live-note").textContent = data.liveNote || (liveUrl ? openHint : "");
289
+ el("live-note").hidden = !el("live-note").textContent;
290
+ el("open-live").disabled = !liveUrl;
291
+
292
+ var f = data.findings;
293
+ ["high", "medium", "low", "open"].forEach(function (k) { el("f-" + k).textContent = f ? String(f[k]) : "–"; });
294
+ if (f) {
295
+ var parts = [f.thisRun + " filed this run"];
296
+ if (f.worthALook) parts.push(f.worthALook + " worth a look");
297
+ if (f.resolved) parts.push(f.resolved + " resolved");
298
+ el("f-sub").textContent = parts.join(" · ");
299
+ } else {
300
+ el("f-sub").textContent = "No session has attached yet.";
301
+ }
302
+
303
+ var c = data.coverage;
304
+ el("routes").hidden = !(c && c.routesTotal > 0);
305
+ if (c && c.routesTotal > 0) {
306
+ el("c-routes").textContent = c.routesVisited + " of " + c.routesTotal;
307
+ var pct = Math.round((100 * c.routesVisited) / c.routesTotal);
308
+ el("c-bar").style.width = Math.min(100, Math.max(0, pct)) + "%";
309
+ el("c-bar-wrap").setAttribute("aria-label", pct + "% of known routes visited");
310
+ }
311
+ el("c-line").textContent = c
312
+ ? plural(c.states, "state") + " explored · " + c.elementsExercised + " of " + c.elementsTotal + " elements exercised"
313
+ : "Nothing measured yet.";
314
+
315
+ var list = el("sessions");
316
+ while (list.firstChild) list.removeChild(list.firstChild);
317
+ var sessions = Array.isArray(data.sessions) ? data.sessions : [];
318
+ el("no-sessions").hidden = sessions.length > 0;
319
+ sessions.forEach(function (s) {
320
+ var li = document.createElement("li");
321
+ li.setAttribute("data-testid", "status-pane-session");
322
+ var who = document.createElement("div");
323
+ who.className = "who";
324
+ var name = document.createElement("strong");
325
+ name.textContent = s.session;
326
+ var role = document.createElement("span");
327
+ role.className = "line";
328
+ role.textContent = s.role;
329
+ var state = document.createElement("span");
330
+ state.className = "state " + (s.state === "running" || s.state === "stuck" ? s.state : "idle");
331
+ state.textContent = s.state === "idle" ? "idle " + duration(s.forMs) : s.state + " " + duration(s.forMs);
332
+ who.appendChild(name); who.appendChild(role); who.appendChild(state);
333
+ li.appendChild(who);
334
+ li.appendChild(labelled(s.state === "idle" ? "Last tool" : "Tool", s.tool));
335
+ if (s.task) li.appendChild(labelled("Task", s.task));
336
+ if (s.objective) li.appendChild(labelled("Objective", s.objective));
337
+ if (s.url) {
338
+ var page = document.createElement("div");
339
+ page.className = "page";
340
+ page.textContent = s.url;
341
+ li.appendChild(page);
342
+ }
343
+ list.appendChild(li);
344
+ });
345
+ if (data.at) setStatus(paused ? "Paused" : "Updated " + clock(data.at), paused ? "" : "on");
346
+ }
347
+
348
+ function labelled(label, value) {
349
+ var line = document.createElement("div");
350
+ line.className = "line";
351
+ line.appendChild(document.createTextNode(label + ": "));
352
+ var v = document.createElement("em");
353
+ v.textContent = value;
354
+ line.appendChild(v);
355
+ return line;
356
+ }
357
+
358
+ function poll() {
359
+ if (!connected || paused || inFlight || document.visibilityState === "hidden") return;
360
+ inFlight = true;
361
+ request("tools/call", { name: POLL_TOOL, arguments: {} })
362
+ .then(function (result) {
363
+ if (result && result.isError) throw new Error("the server could not report the run");
364
+ if (result && result.structuredContent) render(result.structuredContent);
365
+ })
366
+ .catch(function (err) {
367
+ if (paused) return;
368
+ setStatus("Could not update: " + (err && err.message ? err.message : String(err)), "err");
369
+ })
370
+ .then(function () { inFlight = false; });
371
+ }
372
+
373
+ function start() {
374
+ if (timer !== null) return;
375
+ poll();
376
+ timer = setInterval(poll, POLL_MS);
377
+ }
378
+
379
+ function stop() {
380
+ if (timer !== null) clearInterval(timer);
381
+ timer = null;
382
+ }
383
+
384
+ // The host is about to remove the view: stop everything, and never start again.
385
+ function tearDown() {
386
+ connected = false;
387
+ stop();
388
+ if (observer) observer.disconnect();
389
+ Object.keys(pending).forEach(function (id) { clearTimeout(pending[id].timeout); delete pending[id]; });
390
+ }
391
+
392
+ el("toggle").addEventListener("click", function () {
393
+ paused = !paused;
394
+ el("toggle").textContent = paused ? "Resume" : "Pause";
395
+ el("toggle").setAttribute("aria-pressed", paused ? "true" : "false");
396
+ if (paused) { stop(); setStatus("Paused", ""); } else if (connected) start();
397
+ });
398
+
399
+ el("open-live").addEventListener("click", function () {
400
+ if (!liveUrl) return;
401
+ var url = liveUrl;
402
+ var fallback = function () {
403
+ // A sandbox may block new windows; window.open with noopener returns null either way, so say how to open it by hand.
404
+ window.open(url, "_blank", "noopener");
405
+ openHint = "If no tab opened, copy the address above into your browser.";
406
+ el("live-note").textContent = openHint;
407
+ el("live-note").hidden = false;
408
+ };
409
+ if (canOpenLinks) request("ui/open-link", { url: url }).then(function (r) { if (r && r.isError) fallback(); }, fallback);
410
+ else fallback();
411
+ });
412
+
413
+ if (window.parent === window) {
414
+ setStatus("Open this pane through an MCP host: it reads the run through scout_status.", "err");
415
+ return;
416
+ }
417
+
418
+ request("ui/initialize", {
419
+ appInfo: { name: "SceneScout status", version: VERSION },
420
+ appCapabilities: { availableDisplayModes: ["inline"] },
421
+ protocolVersion: PROTOCOL,
422
+ })
423
+ .then(function (result) {
424
+ result = result || {};
425
+ applyContext(result.hostContext || {});
426
+ canOpenLinks = !!(result.hostCapabilities && result.hostCapabilities.openLinks);
427
+ notify("ui/notifications/initialized");
428
+ connected = true;
429
+ watchSize();
430
+ setStatus("Connected", "on");
431
+ start();
432
+ })
433
+ .catch(function (err) {
434
+ setStatus("Not connected: " + (err && err.message ? err.message : String(err)), "err");
435
+ });
436
+ })();
437
+ </script>
438
+ </body>
439
+ </html>
440
+ `;
441
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The run-status pane: an MCP App (SEP-1865, extension `io.modelcontextprotocol/ui`)
3
+ * that a host able to render one shows inline, and the text every other host
4
+ * shows instead.
5
+ *
6
+ * `scout_status` links the pane's `ui://` resource through `_meta.ui.resourceUri`.
7
+ * The pane polls an app-only tool (`_meta.ui.visibility: ["app"]`) for what is
8
+ * built here, because the spec delivers no server notification to a view after
9
+ * it renders. A host that does not render MCP Apps treats `scout_status` as a
10
+ * plain tool, so its text has to stand alone: it always carries the live view's
11
+ * address.
12
+ *
13
+ * What the pane gets is what the live view already shows (ADR 7): each
14
+ * session's board entry, the open findings counted by severity, and the
15
+ * coverage figures in the report's summary. The board's text is redacted when
16
+ * it is written, and no token is returned except the one in the live view's
17
+ * address, which answers on this machine only.
18
+ *
19
+ * Nothing here imports Playwright or the MCP SDK, so every rule is table-tested.
20
+ */
21
+ import { classify, formatDuration } from "./live.js";
22
+ /** The pane's resource. `ui://` is the scheme the extension reserves for views. */
23
+ export const STATUS_PANE_URI = "ui://scenescout/status.html";
24
+ /** The only content type the 2026-01-26 specification defines for a view. */
25
+ export const MCP_APP_MIME = "text/html;profile=mcp-app";
26
+ /** The protocol version the pane names in `ui/initialize`. */
27
+ export const MCP_APPS_PROTOCOL = "2026-01-26";
28
+ /** The model-facing tool: returns the text and links the pane. */
29
+ export const STATUS_TOOL = "scout_status";
30
+ /** The app-only tool the pane polls. A host that implements visibility keeps it out of the model's list. */
31
+ export const STATUS_POLL_TOOL = "scout_status_poll";
32
+ /** How often the pane polls, in milliseconds: inside the 2–3 s the system-monitor example uses. */
33
+ export const STATUS_POLL_MS = 2500;
34
+ /** The live view's address. One place builds it, so the pane, the text and the attach result agree. */
35
+ export function liveViewUrl(port, token) {
36
+ return `http://127.0.0.1:${port}/${token}/`;
37
+ }
38
+ export function countFindings(findings, runStart) {
39
+ const counts = { open: 0, high: 0, medium: 0, low: 0, thisRun: 0, worthALook: 0, resolved: 0 };
40
+ for (const f of findings) {
41
+ if (f.status === "resolved")
42
+ counts.resolved += 1;
43
+ else if (f.tier === "worth_a_look")
44
+ counts.worthALook += 1;
45
+ else {
46
+ counts.open += 1;
47
+ counts[f.severity] += 1;
48
+ if (runStart !== undefined && f.foundAt >= runStart)
49
+ counts.thisRun += 1;
50
+ }
51
+ }
52
+ return counts;
53
+ }
54
+ /** What the pane renders and the app-only tool returns. Pure: the server gathers the input. */
55
+ export function paneData(input) {
56
+ const sessions = input.sessions.map((s) => {
57
+ const out = {
58
+ session: s.session,
59
+ role: s.role,
60
+ state: classify(s, input.nowMs),
61
+ tool: s.tool,
62
+ forMs: Math.max(0, input.nowMs - new Date(s.since).getTime()),
63
+ url: s.url,
64
+ };
65
+ if (s.objective)
66
+ out.objective = s.objective;
67
+ if (s.task)
68
+ out.task = s.task;
69
+ return out;
70
+ });
71
+ const data = {
72
+ at: new Date(input.nowMs).toISOString(),
73
+ version: input.version,
74
+ liveUrl: input.live ? liveViewUrl(input.live.port, input.live.token) : null,
75
+ sessions,
76
+ findings: input.findings ? countFindings(input.findings, input.runStart) : null,
77
+ coverage: input.coverage,
78
+ };
79
+ if (!data.liveUrl) {
80
+ data.liveNote = input.liveOff
81
+ ? "The live view is off: SCENESCOUT_LIVE=off is set in the server's environment."
82
+ : input.liveError
83
+ ? `The live view could not start: ${input.liveError}`
84
+ : "The live view starts with the first scout_attach.";
85
+ }
86
+ return data;
87
+ }
88
+ function sessionLine(s) {
89
+ const what = s.state === "idle"
90
+ ? `idle ${formatDuration(s.forMs)} after ${s.tool}`
91
+ : `${s.state === "stuck" ? "STUCK in" : "running"} ${s.tool} for ${formatDuration(s.forMs)}`;
92
+ const lines = [`- ${s.session} (${s.role}): ${what}${s.url ? ` on ${s.url}` : ""}`];
93
+ if (s.task)
94
+ lines.push(` task: ${s.task}`);
95
+ if (s.objective)
96
+ lines.push(` objective: ${s.objective}`);
97
+ return lines.join("\n");
98
+ }
99
+ /**
100
+ * The tool's text: everything the pane shows, for a host that shows no pane.
101
+ * The first line is the live view's address, in the `Live view:` form the
102
+ * attach result uses, so an agent that relays one relays the other.
103
+ */
104
+ export function paneText(data) {
105
+ const lines = [
106
+ data.liveUrl
107
+ ? `Live view: ${data.liveUrl} — open it to watch every session. It answers on this machine only and cannot act on the run.`
108
+ : `Live view: not available. ${data.liveNote ?? ""}`.trimEnd(),
109
+ ];
110
+ if (data.sessions.length === 0)
111
+ lines.push("No session is attached.");
112
+ else {
113
+ lines.push(`${data.sessions.length} session${data.sessions.length === 1 ? "" : "s"}:`);
114
+ for (const s of data.sessions)
115
+ lines.push(sessionLine(s));
116
+ }
117
+ if (data.findings) {
118
+ const f = data.findings;
119
+ const extra = [f.worthALook ? `${f.worthALook} worth a look` : "", f.resolved ? `${f.resolved} resolved` : ""].filter(Boolean).join(", ");
120
+ lines.push(`Open findings: ${f.open} (${f.high} high, ${f.medium} medium, ${f.low} low), ${f.thisRun} this run${extra ? `; ${extra}` : ""}`);
121
+ }
122
+ if (data.coverage) {
123
+ const c = data.coverage;
124
+ const routes = c.routesTotal > 0 ? `routes ${c.routesVisited}/${c.routesTotal} · ` : "";
125
+ lines.push(`Coverage: ${routes}${c.states} states · ${c.elementsExercised}/${c.elementsTotal} elements exercised`);
126
+ }
127
+ return lines.join("\n");
128
+ }