scenescout 3.16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b0709f0: `scenescout check` accepts `--ignore-path`: a path drops every rule filed on that route, and `rule:/path` drops that one rule there. A page meant to answer HTTP 500 can stay off the gate while the same status on another path still fails `--fail-on high`. The GitHub Action takes the same input.
8
+ - 547d006: Add the `live` and `login` MCP prompts. `live` returns the current session's loopback live-view URL. `login` takes a role and tells the agent to call `scout_login`, with no password argument.
9
+ - 7ba8712: Add `scout_status`, a run-status pane built as an MCP App (`io.modelcontextprotocol/ui`, specification 2026-01-26). In a client that renders MCP Apps it shows each session's objective and task, open findings by severity, coverage and a button for the live view, and refreshes itself every 2.5 seconds through the app-only `scout_status_poll` tool. Every other client gets the same as text, starting with the live view's loopback address. The pane's page loads nothing from outside and shows only what the live view already shows.
10
+
11
+ ### Patch Changes
12
+
13
+ - 745a38e: On Windows, `scenescout install --client` starts an npm-installed client (a `.cmd` or `.bat` shim) through `cmd.exe`, so registration runs the client's own command instead of stopping and printing it to run by hand.
14
+
3
15
  ## 3.16.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -229,7 +229,7 @@ Before a parallel run, `scout_lane_brief` checks that the planner's saved login
229
229
 
230
230
  ## πŸ“Ί Watching a run live
231
231
 
232
- When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. On a desktop the engine also opens it in your default browser, and opens the report when it is written; nothing opens in CI or over SSH, and `SCENESCOUT_OPEN=none` (or `live`, `report`, `both`) chooses otherwise. From a terminal, `scenescout watch` opens the same page. There is one card per session:
232
+ When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. On a local desktop it also opens that page in your default browser as the session attaches, and opens `report.html` when `scout_report` writes it, whether or not the browser window is shown. Nothing opens in CI, over SSH, or on Linux with no display. `SCENESCOUT_OPEN` (`live`, `report`, `both` or `none`) in the server's environment chooses otherwise, and `scout_attach {open}` wins over it. `scenescout ci` opens nothing unless `SCENESCOUT_OPEN` is set. From a terminal, `scenescout watch` opens the same page. There is one card per session:
233
233
 
234
234
  <p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of three parallel agents against the demo app: one card per session, each with its role and objective, the task it is on, the tool it is running, the page it is on, a live thumbnail, and a feed of the actions it just took, tinted one colour per task" width="880" /></p>
235
235
 
@@ -378,7 +378,7 @@ That refusal *is* the guarantee: an extensive report can only exist when nothing
378
378
  - 🟒 **`read-only` by default.** Destructive-labeled elements (delete/revoke/archive/…) **and** all `PUT/PATCH/DELETE` + destructive `POST`s are blocked at the network layer β€” see [`src/engine/policy.ts`](src/engine/policy.ts). Non-destructive `POST`s are allowed, because submitting forms is how a tester finds validation bugs β€” so read-only means *nothing existing is changed or removed*, not *nothing is ever created*.
379
379
  - 🟑 **`safe-write`** (`--safe-write`) lets the agent create data and edit/delete **only what it created** this run β€” never pre-existing records.
380
380
  - πŸ”΄ **`destructive`** (`--allow-destructive`) allows everything, and only ever when *you* confirm the environment is disposable. The skill will never choose this itself.
381
- - πŸ“‚ Findings, memory, and reports live in a `.scenescout/` folder where you ran it. It ignores itself in git, so a stray `git add -A` never commits test data.
381
+ - πŸ“‚ Findings, memory, and reports live in a `.scenescout/` folder in the project. A client with no project folder, such as a desktop chat, gets one folder per tested site under `Documents/SceneScout/<host>/` by default, and the attach says where; `SCENESCOUT_PROJECTS_DIR` moves it. The folder ignores itself in git, so a stray `git add -A` never commits test data.
382
382
 
383
383
  A `πŸ›‘ WRITE-POLICY blocked` notice is the safety net doing its job, not an app bug. The server never sees a blocked request, but a page's own `fetch` or XHR is answered with a `403` in its place rather than dropped, so the page's handling of a refusal really runs: a page that then claims success is reported as a `false_success` ([ADR 9](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)).
384
384
 
@@ -388,6 +388,8 @@ A control is judged by its own label: a dropdown by the option picked, a row by
388
388
 
389
389
  ## πŸ“‹ What you get
390
390
 
391
+ `.scenescout/report.md` and `report.html` open **In plain words**: a short summary, then each problem this run found, worst first, with its impact, the steps that led to it, what was expected, what happened and a picture when there is one. The technical detail (id, category, evidence, route) stays one click away.
392
+
391
393
  `.scenescout/report.md` β€” a deduplicated, worst-first report with:
392
394
 
393
395
  - πŸ› **Findings** with repro traces and generated Playwright regression-test skeletons.
@@ -398,9 +400,9 @@ A control is judged by its own label: a dropdown by the option picked, a row by
398
400
  - ⏱️ **How the run was paced** β€” how closely each session kept working, and apart from that, how long finished lanes held their browsers waiting to be collected, so neither hides the other.
399
401
  - 🎯 **How well the lanes judged** β€” on a parallel run, whether the confidence each lane stated matched what the project went on to file, beside what later re-tests found ([ADR 10](docs/adr/0010-a-confidence-is-checked-not-trusted.md)).
400
402
 
401
- `.scenescout/report.html` β€” the same report as one self-contained page, with every session's trail beside it, and on a [recorded run](#-recording-a-run-and-reading-it-back) the screenshots under each finding.
403
+ `.scenescout/report.html` β€” the same report as one self-contained page, with every session's trail beside it. Each finding shows a picture of the element it is about, or of the page as it was. A [recorded run](#-recording-a-run-and-reading-it-back) also shows the screenshots around each finding.
402
404
 
403
- πŸ‘€ Watch a run live: `node dist/cli.js status <project-path>`.
405
+ πŸ‘€ Watch a run live: `npx scenescout watch`, or `npx scenescout status <project-path>` for the same information as text.
404
406
 
405
407
  ---
406
408
 
@@ -787,6 +789,7 @@ The load-bearing choices are recorded as ADRs β€” read the relevant one before c
787
789
  - [11 Β· A gate is deterministic, and fails only on what it can prove](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md)
788
790
  - [12 Β· A check replays saved flows and re-tests open findings, within settings whose defaults do the least harm](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md)
789
791
  - [13 Β· What depends on a project's convention is the project's to decide](docs/adr/0013-a-convention-is-the-projects-to-decide.md)
792
+ - [21 Β· Update the docs in the same pull request, unless the change has no user-facing surface](docs/adr/0021-update-the-docs-in-the-pull-request.md)
790
793
 
791
794
  ---
792
795
 
package/dist/check-run.js CHANGED
@@ -175,7 +175,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
175
175
  const measured = redactRoutes(routes.map(withoutOwnResponse));
176
176
  const flows = redactFlowRuns(flowRuns);
177
177
  const pictured = baselines ? redactBaselineRun(baselines) : null;
178
- const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows, pictured);
178
+ const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows, pictured, options.ignorePaths);
179
179
  return {
180
180
  url: redactRoute(options.url),
181
181
  generatedAt: new Date().toISOString(),
@@ -188,6 +188,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
188
188
  unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
189
189
  ...(options.timeBudgetMs !== undefined ? { timeBudget: { ms: options.timeBudgetMs, reached: timeLimitReached } } : {}),
190
190
  ignored: options.ignore,
191
+ ignoredPaths: options.ignorePaths,
191
192
  flows,
192
193
  skippedFlows: inputs.skippedFlows ?? [],
193
194
  retest,
package/dist/cli.js CHANGED
@@ -78,7 +78,8 @@ Usage:
78
78
  so it can gate a pull request. Writes report.md, check.sarif and check.json.
79
79
  (--fail-on high|medium|low|never (default high); --mode observe|read-only;
80
80
  --max-routes N (default 50); --paths /a,/b to check only those;
81
- --ignore rule,rule; --storage-state file to check signed in;
81
+ --ignore rule,rule; --ignore-path /a,rule:/b to exempt a path, or one rule on it;
82
+ --storage-state file to check signed in;
82
83
  --project dir (default: here); --out dir (default: .scenescout/check);
83
84
  --browser chromium|firefox|webkit;
84
85
  --action-timeout-ms N (default 5000), --nav-timeout-ms N (default 20000;
@@ -482,10 +483,8 @@ async function install(flags) {
482
483
  }
483
484
  else {
484
485
  failed = true;
485
- // On Windows a client installed through npm is a .cmd shim, which node cannot start directly.
486
- const windowsNote = process.platform === "win32" ? " (or it is installed as a .cmd shim, which cannot be started from here)" : "";
487
486
  console.log(reg.status === "client-missing"
488
- ? `Β· ${label} was not found on this machine${windowsNote}, so nothing was registered with it.`
487
+ ? `Β· ${label} was not found on this machine, so nothing was registered with it.`
489
488
  : `βœ— Registering with ${label} failed: ${reg.detail}`);
490
489
  console.log(` To do it by hand, ${reg.manual}\n`);
491
490
  }
@@ -210,14 +210,20 @@ export function geometryRule(line) {
210
210
  * dedup, and their fingerprint comes from the target, not the evidence: the
211
211
  * evidence carries this run's percentage, and the same target changing by a
212
212
  * different amount is still the same alert.
213
+ *
214
+ * `--ignore-path` drops a fact on the route it names, or one rule there,
215
+ * before the fact is filed. A fact also seen on a route that is not exempted
216
+ * stays, on that route alone.
213
217
  */
214
- export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null) {
218
+ export function checkFindings(routes, origin, ignore = [], flows = [], baselines = null, ignorePaths = []) {
215
219
  const byKey = new Map();
216
220
  const looks = new Map();
221
+ /** A route (and, when named, one rule on it) that --ignore-path exempts. */
222
+ const exempt = (rule, route) => ignorePaths.some((e) => e.path === route && (e.rule === undefined || e.rule === rule));
217
223
  /** Evidence as it is written: no origin, no secret, and bounded. */
218
224
  const cleanEvidence = (evidence) => redactSecrets(withoutOrigin(evidence, origin)).slice(0, 300);
219
225
  const add = (rule, evidence, route, opts = {}) => {
220
- if (ignore.includes(rule))
226
+ if (ignore.includes(rule) || exempt(rule, route))
221
227
  return;
222
228
  const clean = cleanEvidence(evidence);
223
229
  const key = `${rule}\u0000${clean}`;
@@ -287,6 +293,8 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
287
293
  const visual = [];
288
294
  if (baselines && !ignore.includes(VISUAL_RULE)) {
289
295
  for (const r of baselines.results) {
296
+ if (exempt(VISUAL_RULE, r.path))
297
+ continue;
290
298
  const evidence = baselineEvidence(r, baselines);
291
299
  if (evidence === null)
292
300
  continue;
@@ -305,8 +313,8 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
305
313
  };
306
314
  }
307
315
  /** The defect tier of `checkFindings`: what counts, and what the gate reads. */
308
- export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
309
- return checkFindings(routes, origin, ignore, flows).issues;
316
+ export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignorePaths = []) {
317
+ return checkFindings(routes, origin, ignore, flows, null, ignorePaths).issues;
310
318
  }
311
319
  /**
312
320
  * Why a check has nothing to give a verdict on, or null when it measured the
@@ -413,6 +421,7 @@ export const CHECK_OPTION_NAMES = [
413
421
  "max-routes",
414
422
  "paths",
415
423
  "ignore",
424
+ "ignore-path",
416
425
  "flows",
417
426
  "retest",
418
427
  "flow-writes",
@@ -507,6 +516,50 @@ export function parseCheckArgs(args, cwd) {
507
516
  const unknownRules = ignore.filter((r) => !CHECK_RULE_IDS.includes(r));
508
517
  if (unknownRules.length > 0)
509
518
  return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
519
+ const ignorePathList = list(flags.get("ignore-path"));
520
+ if (ignorePathList && ignorePathList.length === 0)
521
+ return { ok: false, error: "--ignore-path is empty" };
522
+ const ignorePaths = [];
523
+ const unknownPathRules = [];
524
+ const badPathEntries = [];
525
+ const ruleAfterPath = [];
526
+ for (const entry of ignorePathList ?? []) {
527
+ if (entry.startsWith("/")) {
528
+ // `/path:rule` would otherwise be a path that never matches. A colon is
529
+ // still allowed in a path when what follows it is not a rule id.
530
+ const suffix = entry.slice(entry.lastIndexOf(":") + 1);
531
+ if (entry.includes(":") && CHECK_RULE_IDS.includes(suffix)) {
532
+ ruleAfterPath.push(entry);
533
+ continue;
534
+ }
535
+ ignorePaths.push({ path: entry });
536
+ continue;
537
+ }
538
+ const colon = entry.indexOf(":");
539
+ const rule = colon > 0 ? entry.slice(0, colon) : "";
540
+ const path = colon > 0 ? entry.slice(colon + 1) : "";
541
+ if (path.startsWith("/") && CHECK_RULE_IDS.includes(rule)) {
542
+ ignorePaths.push({ path, rule: rule });
543
+ continue;
544
+ }
545
+ if (path.startsWith("/") && rule) {
546
+ unknownPathRules.push(rule);
547
+ continue;
548
+ }
549
+ badPathEntries.push(entry);
550
+ }
551
+ if (unknownPathRules.length > 0) {
552
+ return { ok: false, error: `unknown rule(s) in --ignore-path: ${unknownPathRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
553
+ }
554
+ if (ruleAfterPath.length > 0) {
555
+ return { ok: false, error: `--ignore-path names a rule as rule:/path (got ${ruleAfterPath.join(", ")})` };
556
+ }
557
+ if (badPathEntries.length > 0) {
558
+ return {
559
+ ok: false,
560
+ error: `--ignore-path entries are paths starting with /, or one rule on a path as rule:/path (got ${badPathEntries.join(", ")})`,
561
+ };
562
+ }
510
563
  const retest = flags.get("retest") ?? "on";
511
564
  if (retest !== "on" && retest !== "off")
512
565
  return { ok: false, error: "--retest must be on or off" };
@@ -558,6 +611,7 @@ export function parseCheckArgs(args, cwd) {
558
611
  maxRoutes,
559
612
  ...(paths ? { paths } : {}),
560
613
  ignore: ignore,
614
+ ignorePaths,
561
615
  ...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
562
616
  retest: retest === "on",
563
617
  flowWrites,
@@ -940,6 +994,10 @@ export function formatCheck(result) {
940
994
  }
941
995
  if (result.ignored.length > 0)
942
996
  lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
997
+ if (result.ignoredPaths.length > 0) {
998
+ const shown = result.ignoredPaths.map((e) => (e.rule ? `${e.rule}:${e.path}` : e.path));
999
+ lines.push("", `Paths exempted by --ignore-path: ${shown.join(", ")}`);
1000
+ }
943
1001
  lines.push("", result.flows.length > 0
944
1002
  ? "_A check visits pages, measures what loads and replays the flows saved for it. It does not explore, fill forms of its own accord or compare roles; an exploratory run does that._"
945
1003
  : "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that. Flows saved in .scenescout/flows are replayed._");
@@ -968,6 +1026,7 @@ export function toSummaryJson(result, toolVersion) {
968
1026
  unvisited: result.unvisited,
969
1027
  ...(result.timeBudget ? { timeBudget: result.timeBudget } : {}),
970
1028
  ignored: result.ignored,
1029
+ ignoredPaths: result.ignoredPaths,
971
1030
  flows: result.flows.map((f) => ({
972
1031
  name: f.name,
973
1032
  file: f.file,
@@ -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
+ }
package/dist/first-run.js CHANGED
@@ -147,6 +147,7 @@ export function firstRunCheckOptions(o, projectDir) {
147
147
  maxRoutes: o.maxRoutes,
148
148
  timeBudgetMs: o.maxMinutes * 60_000,
149
149
  ignore: [],
150
+ ignorePaths: [],
150
151
  flows: "off",
151
152
  retest: false,
152
153
  flowWrites: "never",
package/dist/installer.js CHANGED
@@ -16,13 +16,118 @@ export const MCP_NAME = "scenescout";
16
16
  /** Names this tool's skill and MCP server had before renames; cleaned up on install so the old slash command and a duplicate tool set do not linger. */
17
17
  const LEGACY_SKILL_NAMES = ["frontend-tester", "scenecraft"];
18
18
  const LEGACY_MCP_NAMES = ["scenecraft"];
19
+ /** Windows' own PATHEXT when the variable is unset. Each entry includes its dot. */
20
+ const WINDOWS_PATH_EXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC";
21
+ /** cmd.exe meta characters. A caret in front of one of these keeps it literal. */
22
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
23
+ /** A `.cmd` or `.bat` file. Node refuses to spawn one directly (EINVAL) unless a shell starts it. */
24
+ export function isBatchShim(file) {
25
+ return /\.(?:cmd|bat)$/i.test(file);
26
+ }
27
+ function escapeCmdMeta(value) {
28
+ return value.replace(CMD_META, "^$1");
29
+ }
30
+ /**
31
+ * The file Windows would run for `command`, or null when nothing matches.
32
+ *
33
+ * The current directory is tried before PATH. A name that already contains a
34
+ * dot is tried as written, then with each PATHEXT suffix; a name without a
35
+ * dot only gains those suffixes. Suffixes stay in PATHEXT order, so an
36
+ * `.exe` listed ahead of `.cmd` wins when both exist.
37
+ */
38
+ export function resolveWindowsCommand(command, opts) {
39
+ const listed = (opts.pathExt?.trim() || WINDOWS_PATH_EXT).split(";").filter((ext) => ext !== "");
40
+ const exts = path.win32.basename(command).includes(".") ? ["", ...listed] : listed;
41
+ const rooted = path.win32.isAbsolute(command) || /[\\/]/.test(command);
42
+ const dirs = rooted ? [""] : [opts.cwd, ...windowsPathDirs(opts.pathEnv)];
43
+ for (const dir of dirs) {
44
+ const base = rooted ? (path.win32.isAbsolute(command) ? command : path.win32.join(opts.cwd, command)) : path.win32.join(dir, command);
45
+ for (const ext of exts) {
46
+ const candidate = base + ext;
47
+ if (opts.exists(candidate))
48
+ return candidate;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ function windowsPathDirs(pathEnv) {
54
+ const dirs = [];
55
+ for (const raw of pathEnv.split(";")) {
56
+ const dir = raw.trim();
57
+ if (!dir)
58
+ continue;
59
+ dirs.push(dir.length >= 2 && dir.startsWith('"') && dir.endsWith('"') ? dir.slice(1, -1) : dir);
60
+ }
61
+ return dirs;
62
+ }
63
+ /**
64
+ * One argument on a `cmd /c` line that a batch shim forwards with `%*`.
65
+ *
66
+ * Quoted for the program that finally runs (backslashes before a quote follow
67
+ * CommandLineToArgvW). Every cmd meta character is then caret-escaped twice:
68
+ * once for `cmd.exe /c`, and once for the shim line that expands `%*`. A JSON
69
+ * value and a path containing spaces each stay a single argument.
70
+ */
71
+ function escapeCmdArgument(arg) {
72
+ let value = `${arg}`;
73
+ // Backslashes that escape a following quote, then a trailing run that the
74
+ // wrapping quote would otherwise swallow. The match does not backtrack.
75
+ value = value.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
76
+ value = value.replace(/(?=(\\+?)?)\1$/, "$1$1");
77
+ value = escapeCmdMeta(escapeCmdMeta(`"${value}"`));
78
+ return value;
79
+ }
80
+ /** The string after `cmd.exe /d /s /c`. The outer quotes exist for `/s` to strip. */
81
+ function windowsBatchCommandLine(command, args) {
82
+ const program = escapeCmdMeta(path.win32.normalize(command));
83
+ return `"${[program, ...args.map(escapeCmdArgument)].join(" ")}"`;
84
+ }
85
+ /**
86
+ * How to start `command`. On Windows a resolved `.cmd` or `.bat` goes through
87
+ * `cmd.exe`, because node will not spawn those itself. Everywhere else, and
88
+ * for an executable, the command and its arguments pass through unchanged.
89
+ */
90
+ export function planSpawn(opts) {
91
+ if (opts.platform === "win32" && opts.resolved && isBatchShim(opts.resolved)) {
92
+ return {
93
+ command: opts.comSpec?.trim() || "cmd.exe",
94
+ args: ["/d", "/s", "/c", windowsBatchCommandLine(opts.resolved, opts.args)],
95
+ windowsVerbatimArguments: true,
96
+ };
97
+ }
98
+ return { command: opts.command, args: opts.args };
99
+ }
100
+ /** `planSpawn` for this platform, resolving a Windows batch shim before choosing cmd.exe. */
101
+ export function commandSpawnPlan(command, args, opts) {
102
+ if (opts.platform !== "win32")
103
+ return { command, args };
104
+ return planSpawn({
105
+ command,
106
+ args,
107
+ platform: "win32",
108
+ resolved: resolveWindowsCommand(command, { cwd: opts.cwd, pathEnv: opts.pathEnv ?? "", pathExt: opts.pathExt, exists: opts.exists }),
109
+ comSpec: opts.comSpec,
110
+ });
111
+ }
19
112
  /** Real runner. `missing` separates "the binary is not installed" from "it ran and failed". */
20
113
  export const spawnRunner = (command, args, opts) => {
21
- const r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000, cwd: opts?.cwd });
114
+ const planned = commandSpawnPlan(command, args, {
115
+ platform: process.platform,
116
+ cwd: opts?.cwd ?? process.cwd(),
117
+ pathEnv: process.env.PATH,
118
+ pathExt: process.env.PATHEXT,
119
+ comSpec: process.env.ComSpec,
120
+ exists: (file) => fs.existsSync(file),
121
+ });
122
+ const r = spawnSync(planned.command, planned.args, {
123
+ encoding: "utf8",
124
+ timeout: 60_000,
125
+ cwd: opts?.cwd,
126
+ windowsVerbatimArguments: planned.windowsVerbatimArguments,
127
+ });
22
128
  const code = r.error?.code;
23
- // On Windows node refuses to start a .cmd or .bat file directly (EINVAL). For
24
- // the caller that is the same situation as a missing binary: nothing ran, and
25
- // the command has to be handed to the person instead.
129
+ // A batch shim is started through cmd.exe above. EINVAL still means a shim
130
+ // was spawned directly and nothing ran: the same outcome as a missing binary.
26
131
  const missing = code === "ENOENT" || (process.platform === "win32" && code === "EINVAL");
27
132
  // With `encoding` set, a command that never ran still yields "" for stderr: the error is the only account of it.
28
133
  const stderr = r.stderr || (code === "ETIMEDOUT" ? "npm did not finish within 60 s" : r.error ? String(r.error.message) : "");
@@ -295,7 +400,8 @@ export function planCommand(opts) {
295
400
  if (mine || !checkout)
296
401
  return { action: "present", at: opts.resolved };
297
402
  }
298
- // npm on Windows is a .cmd shim, which node cannot start directly.
403
+ // Putting `scenescout` on PATH stays a manual npm command on Windows. Client
404
+ // registration is separate: spawnRunner starts a `.cmd` shim itself.
299
405
  if (opts.platform === "win32")
300
406
  return { action: "manual", manual, why: "this step cannot start npm on Windows" };
301
407
  const beside = path.join(path.dirname(opts.nodePath), "npm");
@@ -53,6 +53,8 @@ import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
53
53
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
54
54
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
55
55
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
56
+ import { liveViewUrl, MCP_APP_MIME, paneData, paneText, STATUS_PANE_URI, STATUS_POLL_TOOL, STATUS_TOOL, } from "./engine/status-pane.js";
57
+ import { statusPanePage } from "./engine/status-pane-page.js";
56
58
  import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
57
59
  import { decideOpen, OPEN_CHOICES, OPEN_ENV, openChoiceFromEnv, openInBrowser } from "./engine/open.js";
58
60
  import { DEFAULT_EXPIRY_MARGIN_MINUTES, DEFAULT_RUN_MINUTES, judgeProfileFile } from "./engine/expiry.js";
@@ -70,6 +72,7 @@ import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
70
72
  import { describePace, normalizePace } from "./engine/settle.js";
71
73
  import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
72
74
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
75
+ import { livePrompt, loginPrompt, LOGIN_PROMPT, LIVE_PROMPT, LOGIN_PROMPT_ARGUMENTS, LIVE_PROMPT_ARGUMENTS } from "./prompts.js";
73
76
  import { formatScan, scanProject } from "./scan.js";
74
77
  import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, describePicture, EVIDENCE_ENV, EVIDENCE_LIMITS, EVIDENCE_MARGIN, EVIDENCE_MODES, evidenceFrame, evidenceSettings, findingPicturePath, MAX_CAPTURE_MARGIN, RECORD_ENV, recordChoice, returnsInline, } from "./engine/capture.js";
75
78
  import { decodePng, fitPicture } from "./engine/png.js";
@@ -346,7 +349,7 @@ function startLiveServer() {
346
349
  function liveLine() {
347
350
  if (!liveAddress)
348
351
  return liveError ? `\nLive view unavailable: ${liveError}` : "";
349
- return (`\nLive view: http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/ β€” give this address to the user so they can watch every session ` +
352
+ return (`\nLive view: ${liveViewUrl(liveAddress.port, liveAddress.token)} β€” give this address to the user so they can watch every session ` +
350
353
  `(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
351
354
  }
352
355
  /** What each session's attach decided to open (engine/open.ts). scout_report reads it for the report. */
@@ -540,10 +543,11 @@ server.registerTool(PLAYBOOK_TOOL, {
540
543
  return errorText(err);
541
544
  }
542
545
  });
543
- // The same method as a prompt, for clients that list server prompts as commands.
544
- // Registered on the protocol server directly: the SDK's prompt helper rejects a
545
- // request that carries no `arguments` object, which is exactly what a client
546
- // sends when the person typed none, and every argument here is optional.
546
+ // Prompts, for clients that list server prompts as commands. Registered on the
547
+ // protocol server directly: the SDK's prompt helper rejects a request that
548
+ // carries no `arguments` object, which is exactly what a client sends when the
549
+ // person typed none. `explore` and `live` accept that; `login` then says the
550
+ // role is missing. None of them takes a password.
547
551
  server.server.registerCapabilities({ prompts: {} });
548
552
  server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
549
553
  prompts: [
@@ -553,16 +557,36 @@ server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
553
557
  description: "Start an exploratory test session: loads the SceneScout method and states the target.",
554
558
  arguments: EXPLORE_PROMPT_ARGUMENTS,
555
559
  },
560
+ {
561
+ name: LIVE_PROMPT,
562
+ title: "Watch the live view",
563
+ description: "Return the loopback live-view URL for the current session. Takes no arguments and no password.",
564
+ arguments: LIVE_PROMPT_ARGUMENTS,
565
+ },
566
+ {
567
+ name: LOGIN_PROMPT,
568
+ title: "Sign in as a role",
569
+ description: "Call scout_login for a role and wait the way that tool waits. The person signs in in the window it opens. Takes the role, and the app URL when you have it. Never a password.",
570
+ arguments: LOGIN_PROMPT_ARGUMENTS,
571
+ },
556
572
  ],
557
573
  }));
558
574
  server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
559
- if (request.params.name !== PLAYBOOK_PROMPT)
560
- throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
575
+ const name = request.params.name;
561
576
  let message;
562
577
  try {
563
- message = explorePrompt(loadPlaybook(PACKAGE_ROOT), request.params.arguments);
578
+ if (name === PLAYBOOK_PROMPT)
579
+ message = explorePrompt(loadPlaybook(PACKAGE_ROOT), request.params.arguments);
580
+ else if (name === LIVE_PROMPT)
581
+ message = livePrompt(request.params.arguments);
582
+ else if (name === LOGIN_PROMPT)
583
+ message = loginPrompt(request.params.arguments);
584
+ else
585
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
564
586
  }
565
587
  catch (err) {
588
+ if (err instanceof McpError)
589
+ throw err;
566
590
  throw new McpError(ErrorCode.InvalidParams, err instanceof Error ? err.message : String(err));
567
591
  }
568
592
  return { messages: [{ role: "user", content: { type: "text", text: message } }] };
@@ -950,7 +974,7 @@ server.registerTool("scout_attach", {
950
974
  let openNote = "";
951
975
  if (opening.live && liveAddress && !liveOpened) {
952
976
  liveOpened = true;
953
- openNote = openForUser("the live view", `http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/`);
977
+ openNote = openForUser("the live view", liveViewUrl(liveAddress.port, liveAddress.token));
954
978
  }
955
979
  else if (!opening.live && liveAddress && !openWhySaid) {
956
980
  // Once per server, so a person wondering why no window appeared is told how to change it.
@@ -1760,6 +1784,67 @@ server.registerTool("scout_finding", {
1760
1784
  return errorText(err);
1761
1785
  }
1762
1786
  }));
1787
+ // ---- The run-status pane: an MCP App (SEP-1865, `io.modelcontextprotocol/ui`, spec 2026-01-26). ----
1788
+ // scout_status links the pane through _meta.ui.resourceUri; the pane polls the
1789
+ // app-only tool. Neither goes through a session queue: like the live view, a
1790
+ // watcher must never wait behind the agent's calls, and both only read.
1791
+ function statusPaneData() {
1792
+ const eng = [lastWriter ? engines.get(lastWriter.session) : undefined, ...engines.values()].find((e) => !!e?.memory);
1793
+ const memory = eng?.memory ?? null;
1794
+ let coverage = null;
1795
+ if (eng && memory) {
1796
+ const all = eng.allKnownRoutes();
1797
+ const cov = memory.coverage();
1798
+ coverage = {
1799
+ routesVisited: all.length - eng.unvisitedKnownRoutes().length,
1800
+ routesTotal: all.length,
1801
+ states: cov.states,
1802
+ elementsExercised: cov.elementsExercised,
1803
+ elementsTotal: cov.elementsTotal,
1804
+ };
1805
+ }
1806
+ return paneData({
1807
+ nowMs: Date.now(),
1808
+ version: PKG_VERSION,
1809
+ live: liveAddress,
1810
+ liveError,
1811
+ liveOff: process.env[LIVE_ENV] === "off",
1812
+ sessions: board.list(),
1813
+ findings: memory ? memory.findings : null,
1814
+ runStart: memory?.sessionStart,
1815
+ coverage,
1816
+ });
1817
+ }
1818
+ function statusResult() {
1819
+ try {
1820
+ const data = statusPaneData();
1821
+ return { content: [{ type: "text", text: paneText(data) }], structuredContent: { ...data } };
1822
+ }
1823
+ catch (err) {
1824
+ return errorText(err);
1825
+ }
1826
+ }
1827
+ server.registerResource("scenescout-status", STATUS_PANE_URI, {
1828
+ title: "SceneScout run status",
1829
+ description: "The run's sessions, open findings by severity, coverage and the live view's address, refreshed every few seconds.",
1830
+ mimeType: MCP_APP_MIME,
1831
+ // No outside origin: the page is one self-contained document, so the host's restrictive default CSP applies.
1832
+ _meta: { ui: { csp: {}, prefersBorder: true } },
1833
+ }, () => ({
1834
+ contents: [{ uri: STATUS_PANE_URI, mimeType: MCP_APP_MIME, text: statusPanePage(PKG_VERSION), _meta: { ui: { csp: {}, prefersBorder: true } } }],
1835
+ }));
1836
+ server.registerTool(STATUS_TOOL, {
1837
+ title: "Run status",
1838
+ description: "Show the person running you how the run stands: each session's objective and current task, open findings by severity, coverage, and the live view's address. " +
1839
+ "Call it when the user wants to watch or asks how the run is going. A host that renders MCP Apps shows a pane that keeps itself up to date; every other host gets the same as text, and you pass the `Live view:` address on. Takes no input and touches no browser.",
1840
+ // The legacy flat key alongside _meta.ui.resourceUri, as the ext-apps SDK's registerAppTool sets both for hosts that read only the old one.
1841
+ _meta: { ui: { resourceUri: STATUS_PANE_URI }, "ui/resourceUri": STATUS_PANE_URI },
1842
+ }, async () => statusResult());
1843
+ server.registerTool(STATUS_POLL_TOOL, {
1844
+ title: "Run status (for the pane)",
1845
+ description: `Called by the ${STATUS_TOOL} pane every few seconds to refresh itself. Not for the agent: call ${STATUS_TOOL} instead.`,
1846
+ _meta: { ui: { visibility: ["app"] } },
1847
+ }, async () => statusResult());
1763
1848
  server.registerTool("scout_coverage", {
1764
1849
  description: "Show exploration coverage: states visited, which elements remain unexercised, which options of a dropdown used this run no session has chosen yet, and which forms seen this run no session has submitted with every text field blank. Use to decide where to explore next and when the level's budget is satisfied. In a parallel run it shows this session's own work by default β€” the routes it reached this run and the forms it saw β€” so one lane is not handed another's gaps; scope:\"project\" shows every session's, each form and route tagged with the sessions that saw it.",
1765
1850
  inputSchema: {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * MCP prompts other than the testing method.
3
+ *
4
+ * `explore` lives in playbook.ts because its text is the skill. `live` and
5
+ * `login` are short instructions: hand back the loopback live-view address,
6
+ * or call scout_login for a role. Neither accepts a password. The protocol
7
+ * server in mcp-server.ts lists all three; the SDK prompt helper is not used,
8
+ * because it rejects a request that omits `arguments`.
9
+ */
10
+ import { validateRoleName } from "./engine/profiles.js";
11
+ export const LIVE_PROMPT = "live";
12
+ export const LOGIN_PROMPT = "login";
13
+ /** `live` takes nothing. A password field here would ask the person for a credential the live view does not use. */
14
+ export const LIVE_PROMPT_ARGUMENTS = [];
15
+ /** `login` names a role, and the app's address when the caller has one. There is no password argument. */
16
+ export const LOGIN_PROMPT_ARGUMENTS = [
17
+ {
18
+ name: "role",
19
+ description: "The name to save the sign-in under, e.g. admin. scout_attach { role } uses it afterwards. Not a password.",
20
+ required: true,
21
+ },
22
+ {
23
+ name: "url",
24
+ description: "The app's address or its sign-in page, when you have it, e.g. http://localhost:3000. Omit when the conversation already has it.",
25
+ required: false,
26
+ },
27
+ ];
28
+ /**
29
+ * Argument names that would carry a credential. Matched on the name only, so
30
+ * a refusal can name the argument without repeating the value the client sent.
31
+ */
32
+ const CREDENTIAL_ARGUMENT = /^(?:password|passwd|pass|secret|token|credential|credentials|otp|totp|mfa|apikey|api_key)$/i;
33
+ function refuseCredentialArguments(args) {
34
+ if (!args)
35
+ return;
36
+ for (const key of Object.keys(args)) {
37
+ if (CREDENTIAL_ARGUMENT.test(key)) {
38
+ throw new Error(`this prompt does not take ${key}: never pass a password or any other credential`);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * The opening message of the `live` prompt. The address itself is printed by
44
+ * the engine on tool results (`Live view: http://127.0.0.1:…`); this only
45
+ * tells the model to hand that line back. Any argument is refused.
46
+ */
47
+ export function livePrompt(args) {
48
+ refuseCredentialArguments(args);
49
+ const given = Object.keys(args ?? {}).filter((key) => args?.[key] !== undefined);
50
+ if (given.length > 0)
51
+ throw new Error("the live prompt takes no arguments");
52
+ return ("Return the loopback live-view URL for the current session.\n\n" +
53
+ "The live view is one address for every session of this run. It is served on 127.0.0.1 only, and a tool result prints it as a line starting " +
54
+ '"Live view: http://127.0.0.1:". If this conversation does not already contain that line, call scout_session with no arguments and read it from the result. ' +
55
+ "Calling scout_session with a name would change the default session; do not pass one.\n\n" +
56
+ "Reply with that address. Where you can open a URL for the user, you may open it as well. " +
57
+ "If the result says the live view is unavailable, say so and do not invent an address.\n\n" +
58
+ "Do not ask for a password or any other credential. This prompt takes none.");
59
+ }
60
+ /** An http(s) address with no userinfo. The raw string is returned only after that check, so a refusal never quotes a password embedded in it. */
61
+ function publicHttpUrl(raw) {
62
+ let url;
63
+ try {
64
+ url = new URL(raw);
65
+ }
66
+ catch {
67
+ throw new Error("url must be an http or https address");
68
+ }
69
+ if (url.protocol !== "http:" && url.protocol !== "https:")
70
+ throw new Error(`only http and https URLs can be opened (got ${url.protocol})`);
71
+ if (url.username || url.password)
72
+ throw new Error("put no credentials in the URL: sign in in the window instead");
73
+ return raw;
74
+ }
75
+ /**
76
+ * The opening message of the `login` prompt: call scout_login for the role and
77
+ * wait the way that tool already waits. A missing or illegal role is refused.
78
+ * A password argument is refused without the value being repeated.
79
+ */
80
+ export function loginPrompt(args) {
81
+ refuseCredentialArguments(args);
82
+ const given = args ?? {};
83
+ const unknown = Object.keys(given).filter((key) => key !== "role" && key !== "url");
84
+ if (unknown.length > 0)
85
+ throw new Error("the login prompt takes role and url only");
86
+ const checked = validateRoleName(typeof given.role === "string" ? given.role.trim() : given.role);
87
+ if (!checked.ok)
88
+ throw new Error(checked.error);
89
+ const url = publicHttpUrlOrNone(typeof given.url === "string" ? given.url.trim() : "");
90
+ // JSON so a quote in the address cannot rewrite the instruction. The address has already been refused when it carries credentials.
91
+ const where = url
92
+ ? `Call scout_login with ${JSON.stringify({ role: checked.role, url })}.`
93
+ : `Call scout_login with role ${JSON.stringify(checked.role)} and the app's address as url. ` +
94
+ "Use the address this conversation already has; if it has none, ask for the app's address or its sign-in page, then pass that as url.";
95
+ return (`Sign in as the role "${checked.role}" by calling scout_login. ` +
96
+ "Do not ask for a password, and do not type credentials yourself: the person signs in in the browser window the tool opens.\n\n" +
97
+ 'Tell them first, in plain words: "A browser window is opening. Sign in there as you normally would; it closes by itself once you are in."\n\n' +
98
+ `${where}\n\n` +
99
+ "The call returns once they are signed in and the login is saved, or after its wait with the window still open. " +
100
+ `When it is still waiting, call scout_login again with the same role ("${checked.role}") to keep waiting, once they say they are done or to check. ` +
101
+ "Never pass a password.");
102
+ }
103
+ /** `publicHttpUrl("")` would throw. An omitted url is the optional argument left out, not an illegal address. */
104
+ function publicHttpUrlOrNone(raw) {
105
+ return raw ? publicHttpUrl(raw) : "";
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "SceneScout β€” exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",
@@ -111,14 +111,14 @@
111
111
  "dedup-bench": "tsx scripts/dedup-bench.ts"
112
112
  },
113
113
  "dependencies": {
114
- "@modelcontextprotocol/sdk": "^1.12.0",
114
+ "@modelcontextprotocol/sdk": "^1.31.0",
115
115
  "playwright": "^1.63.0",
116
116
  "zod": "^3.25.0"
117
117
  },
118
118
  "devDependencies": {
119
119
  "@changesets/cli": "3.0.3",
120
120
  "@types/node": "^20.19.0",
121
- "prettier": "3.9.8",
121
+ "prettier": "3.9.9",
122
122
  "tsx": "^4.23.15",
123
123
  "typescript": "^5.9.3",
124
124
  "yaml": "^2.9.1"
@@ -116,7 +116,7 @@ Polite, precise testing misses how real users behave. Once per module's key flow
116
116
  - **Wrong-order behaviour:** press Enter mid-form before required fields are filled; go `scout_back` mid-wizard and return; submit, then immediately back-button. State should survive all three without data loss or duplicate records.
117
117
  - Keep attribution honest: these are deliberate probes β€” say so in findings ("under rapid double-click…"), so a developer can reproduce exactly.
118
118
 
119
- The engine is self-healing (orphaned browsers reaped, wedged calls time out with guidance instead of hanging) and observable: `.scenescout/status.json` + `scenescout status <project>` show what every session is doing right now, and there is a live view for the person running you: one card per session with its current tool, how long it has been there, a thumbnail of its page, a feed of the actions it just took, and a stream they can switch on. It works for headless runs too. **`scout_attach` returns its address on a `Live view:` line: pass that address to the user in your next message, once, so they can watch.** On a local desktop session the engine also opens it in the user's browser (an `Opened the live view` line says so), and opens report.html when `scout_report` writes it; pass `open` (`live`, `report`, `both`, `none`) only when the user asks for something else. `scout_session` with no arguments repeats it if they ask again, and `scenescout watch <project>` opens it from a terminal. It is for the person watching: you do not need to open it, and a session shown as stuck there is one to re-attach.
119
+ The engine is self-healing (orphaned browsers reaped, wedged calls time out with guidance instead of hanging) and observable: `.scenescout/status.json` + `scenescout status <project>` show what every session is doing right now, and there is a live view for the person running you: one card per session with its current tool, how long it has been there, a thumbnail of its page, a feed of the actions it just took, and a stream they can switch on. It works for headless runs too. **`scout_attach` returns its address on a `Live view:` line: pass that address to the user in your next message, once, so they can watch.** On a local desktop session the engine also opens it in the user's browser (an `Opened the live view` line says so), and opens report.html when `scout_report` writes it; pass `open` (`live`, `report`, `both`, `none`) only when the user asks for something else. `scout_session` with no arguments repeats it if they ask again, and `scenescout watch <project>` opens it from a terminal. **When the user wants to watch the run, or asks how it is going, call `scout_status`** (no input): a host that renders MCP Apps shows a pane that keeps itself up to date with each session's objective and task, open findings by severity, coverage and a button for the live view; every other host gets the same as text, starting with the `Live view:` address, which you pass on. It is for the person watching: you do not need to open it, and a session shown as stuck there is one to re-attach.
120
120
 
121
121
  ## Judgment (what the engine can't do)
122
122