scenescout 1.4.0 → 1.5.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,11 @@
1
1
  # scenescout
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 791b4d0: The live view now hands over the report when the run ends. Closing the last session used to take the report with it — the view served it from the live engine, so the moment the browsers went the page said there was nothing to report. The last rendering is now kept, and when the board empties the page says the run has finished, opens the report by itself, and names the file it belongs in: `saved at <path>` once `scout_report` has written it, or plainly that it is not on disk and this page holds the only copy. A **Save a copy** button downloads that copy through the viewer's own browser (nothing is asked of the engine, which still answers `GET` and nothing else), and closing the tab on a finished run whose report was never written asks for confirmation first.
8
+
3
9
  ## 1.4.0
4
10
 
5
11
  ### Minor Changes
@@ -48,6 +48,13 @@ export const LIVE_PAGE = `<!doctype html>
48
48
  button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
49
49
  #banner { display: none; margin: 12px 16px 0; padding: 8px 12px; border-radius: 6px; background: var(--stuck-bg); color: var(--stuck); }
50
50
  #empty { display: none; padding: 48px 16px; text-align: center; color: var(--muted); }
51
+ #finished { display: none; max-width: 640px; margin: 48px auto; padding: 24px; text-align: center;
52
+ background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
53
+ #finished.open { display: block; }
54
+ #finished h2 { margin: 0 0 8px; font-size: 18px; }
55
+ #finished p { margin: 0 0 8px; color: var(--muted); }
56
+ #finished .where { font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
57
+ #finished button { margin-top: 8px; padding: 7px 14px; font-weight: 600; }
51
58
  main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
52
59
  .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
53
60
  .card.stuck { border-color: var(--stuck); }
@@ -143,12 +150,19 @@ export const LIVE_PAGE = `<!doctype html>
143
150
  </header>
144
151
  <div id="banner" role="alert" data-testid="live-unreachable-banner">The engine is not answering. It may have exited; this page will pick up again if it comes back.</div>
145
152
  <div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
153
+ <div id="finished" data-testid="live-finished-state">
154
+ <h2>The run has finished</h2>
155
+ <p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
156
+ <p class="where" id="finished-where" data-testid="live-finished-where"></p>
157
+ <button type="button" id="finished-report" data-testid="live-finished-report">Read the report</button>
158
+ </div>
146
159
  <main id="grid"></main>
147
160
  <div id="report" role="dialog" aria-modal="true" aria-label="The run's report" data-testid="live-report-dialog">
148
161
  <div class="bar">
149
162
  <strong>Report</strong>
150
163
  <span class="meta" id="report-meta" data-testid="live-report-meta"></span>
151
164
  <span class="spacer"></span>
165
+ <button type="button" id="report-save" data-testid="live-report-save">Save a copy</button>
152
166
  <button type="button" id="report-close" data-testid="live-report-close">Close</button>
153
167
  </div>
154
168
  <div class="doc" id="report-doc" data-testid="live-report-doc"></div>
@@ -187,6 +201,14 @@ export const LIVE_PAGE = `<!doctype html>
187
201
  var reportOpen = false;
188
202
  var reportTimer = null;
189
203
  var reportProblem = null;
204
+ // The run had sessions and has none now: its browsers are gone, and this
205
+ // page holds the only rendering of the report unless it was written to disk.
206
+ var sawRun = false;
207
+ var finished = false;
208
+ var reportShown = false;
209
+ var reportFile = null;
210
+ var reportMarkdown = null;
211
+ var savedACopy = false;
190
212
  // A result that reads as a failure is shown in red.
191
213
  var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
192
214
 
@@ -435,7 +457,8 @@ export const LIVE_PAGE = `<!doctype html>
435
457
  meta.textContent = '';
436
458
  return;
437
459
  }
438
- meta.textContent = 'as the run stands at ' + clock(d.at) + ' · scout_report writes this document to .scenescout/report.md at the end';
460
+ reportMarkdown = d.markdown;
461
+ meta.textContent = (finished ? 'as the run left it at ' : 'as the run stands at ') + clock(d.at) + ' · ' + whereItIs();
439
462
  renderMarkdown(doc, d.markdown);
440
463
  })
441
464
  .catch(function (err) {
@@ -448,6 +471,28 @@ export const LIVE_PAGE = `<!doctype html>
448
471
  }
449
472
  });
450
473
  }
474
+ /** Where the report's file is, and whether the agent has written it there. */
475
+ function whereItIs() {
476
+ if (!reportFile) return 'scout_report writes this document to .scenescout/report.md at the end';
477
+ if (reportFile.written) return 'saved at ' + reportFile.path;
478
+ return 'NOT saved: ' + reportFile.path + ' does not exist — the agent has not run scout_report, so this page holds the only copy';
479
+ }
480
+
481
+ // Saving is the viewer's own browser writing a file the page already has;
482
+ // nothing is sent to the engine, which only ever answers GET (ADR 7).
483
+ function saveACopy() {
484
+ if (!reportMarkdown) return;
485
+ var url = URL.createObjectURL(new Blob([reportMarkdown], { type: 'text/markdown' }));
486
+ var a = document.createElement('a');
487
+ a.href = url;
488
+ a.download = 'scenescout-report.md';
489
+ document.body.appendChild(a);
490
+ a.click();
491
+ a.remove();
492
+ setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
493
+ savedACopy = true;
494
+ }
495
+
451
496
  function openReport() {
452
497
  reportOpen = true;
453
498
  document.getElementById('report').classList.add('open');
@@ -608,7 +653,21 @@ export const LIVE_PAGE = `<!doctype html>
608
653
  delete frames[name];
609
654
  });
610
655
  syncEvents();
611
- document.getElementById('empty').style.display = snap.sessions.length ? 'none' : 'block';
656
+ reportFile = snap.report || reportFile;
657
+ if (snap.sessions.length > 0) sawRun = true;
658
+ finished = sawRun && snap.sessions.length === 0;
659
+ document.getElementById('empty').style.display = snap.sessions.length || finished ? 'none' : 'block';
660
+ document.getElementById('finished').classList.toggle('open', finished);
661
+ if (finished) {
662
+ var where = document.getElementById('finished-where');
663
+ where.textContent = whereItIs();
664
+ where.className = 'where' + (reportFile && reportFile.written ? '' : ' unset');
665
+ // The moment somebody wants the report is the moment the run ends: show it.
666
+ if (!reportShown) {
667
+ reportShown = true;
668
+ if (!reportOpen) openReport();
669
+ }
670
+ }
612
671
  document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
613
672
  document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
614
673
  ' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
@@ -633,6 +692,16 @@ export const LIVE_PAGE = `<!doctype html>
633
692
  document.getElementById('focus-feed').addEventListener('mouseleave', function () { showObjective(null); });
634
693
  document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
635
694
  document.getElementById('report-open').addEventListener('click', openReport);
695
+ document.getElementById('finished-report').addEventListener('click', openReport);
696
+ document.getElementById('report-save').addEventListener('click', saveACopy);
697
+ // Closing the tab on a finished run whose report was never written to disk
698
+ // throws the only copy away. The browser shows its own confirm/dismiss, and
699
+ // only when the person has interacted with the page at least once.
700
+ window.addEventListener('beforeunload', function (e) {
701
+ if (!finished || savedACopy || (reportFile && reportFile.written)) return;
702
+ e.preventDefault();
703
+ e.returnValue = '';
704
+ });
636
705
  document.getElementById('report-close').addEventListener('click', closeReport);
637
706
  document.addEventListener('keydown', function (e) {
638
707
  if (e.key !== 'Escape') return;
@@ -37,7 +37,7 @@ import { reapOrphanBrowsers } from "./engine/reaper.js";
37
37
  import { MemoryStore, redactSecrets } from "./engine/memory.js";
38
38
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
39
39
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
40
- import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard } from "./engine/live.js";
40
+ import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
41
41
  import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
42
42
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
43
43
  import { formatScan, scanProject } from "./scan.js";
@@ -105,8 +105,44 @@ const liveTokenWrites = new Map();
105
105
  let liveTokenWarned = false;
106
106
  /** Why the live view could not start, when it could not: told to the agent and written to status.json. */
107
107
  let liveError = null;
108
+ /**
109
+ * The last run's report, kept after its sessions close. The engines are gone
110
+ * by then, so this is the only way the live view can still show what the run
111
+ * found — which is the moment somebody most wants to read it.
112
+ */
113
+ let lastRun = null;
114
+ /** Render the report for a session that is about to close, so the live view keeps it. */
115
+ function keepReport(eng) {
116
+ if (!eng.memory)
117
+ return;
118
+ try {
119
+ const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
120
+ lastRun = { markdown: redactSecrets(markdown), at: new Date().toISOString(), dir: eng.memory.dir };
121
+ }
122
+ catch {
123
+ // Best-effort: a report that cannot be rendered must not fail a close.
124
+ }
125
+ }
126
+ /** Where this run's report belongs, and whether the agent has written it there yet. */
127
+ function reportFile(dir) {
128
+ if (!dir)
129
+ return undefined;
130
+ const file = path.join(dir, "report.md");
131
+ try {
132
+ return { path: file, written: fs.existsSync(file) };
133
+ }
134
+ catch {
135
+ return { path: file, written: false };
136
+ }
137
+ }
108
138
  const liveProvider = {
109
- snapshot: () => ({ pid: process.pid, version: PKG_VERSION, at: new Date().toISOString(), sessions: board.list() }),
139
+ snapshot: () => ({
140
+ pid: process.pid,
141
+ version: PKG_VERSION,
142
+ at: new Date().toISOString(),
143
+ sessions: board.list(),
144
+ report: reportFile(engines.values().next().value?.memory?.dir ?? lastRun?.dir),
145
+ }),
110
146
  // The engine holds no reasoning — it never sees one — so the feed is what the
111
147
  // session DID: the action log, which is the same trail a finding's repro uses.
112
148
  activity: (session, limit) => feedForSession(engines.get(session)?.memory?.actionLog ?? [], session, limit, redactSecrets),
@@ -118,7 +154,7 @@ const liveProvider = {
118
154
  report: () => {
119
155
  const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
120
156
  if (!eng?.memory)
121
- return null;
157
+ return lastRun ? { markdown: lastRun.markdown, at: lastRun.at } : null;
122
158
  const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
123
159
  return { markdown: redactSecrets(markdown), at: new Date().toISOString() };
124
160
  },
@@ -976,6 +1012,8 @@ server.registerTool("scout_close", {
976
1012
  for (const e of engines.values())
977
1013
  if (e.memory?.dir)
978
1014
  dirs.add(e.memory.dir);
1015
+ for (const e of engines.values())
1016
+ keepReport(e);
979
1017
  for (const name of engines.keys())
980
1018
  live?.dropSession(name);
981
1019
  await Promise.allSettled([...engines.values()].map((e) => e.close()));
@@ -991,6 +1029,7 @@ server.registerTool("scout_close", {
991
1029
  const eng = engines.get(name);
992
1030
  if (!eng)
993
1031
  return text(`No live session "${name}".`, name);
1032
+ keepReport(eng);
994
1033
  live?.dropSession(name);
995
1034
  await eng.close();
996
1035
  const saveError = eng.memory?.lastSaveError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",