scenescout 3.23.0 → 3.23.2

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.
@@ -285,7 +285,7 @@ ol.steps { list-style:none; margin:10px 0 0; padding:0; }
285
285
  .why { margin:4px 0; font-size:13px; overflow-wrap:anywhere; }
286
286
  a.frame { display:grid; margin:6px 0 2px; max-width:min(100%,720px); color:var(--muted); font-size:12px; }
287
287
  a.frame > * { grid-area:1 / 1; }
288
- a.frame img { position:relative; max-width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
288
+ a.frame img { position:relative; width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
289
289
  .gone-note { align-self:end; padding:2.6em 12px 12px; border:1px dashed var(--line); border-radius:6px; }
290
290
  .noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
291
291
  .note { color:var(--muted); font-size:13px; }
@@ -431,7 +431,7 @@ header.title h1 { font-size:22px; margin:0 0 4px; }
431
431
  header.title .subtitle { margin:0 0 8px; color:var(--muted); }
432
432
  h2 { font-size:17px; margin:28px 0 8px; border-bottom:1px solid var(--line); padding-bottom:4px; }
433
433
  table { border-collapse:collapse; width:100%; margin:8px 0; }
434
- th, td { border:1px solid var(--line); padding:5px 8px; text-align:left; vertical-align:top; overflow-wrap:anywhere; }
434
+ th, td { border:1px solid var(--line); padding:5px 8px; text-align:left; vertical-align:top; overflow-wrap:break-word; }
435
435
  thead th, table.facts th { background:var(--head); }
436
436
  table.facts { width:auto; min-width:50%; }
437
437
  tr.test-head th { background:var(--head); font-weight:400; }
@@ -444,7 +444,8 @@ td.r-passed { color:var(--pass); font-weight:700; } td.r-failed, td.r-refused {
444
444
  tr.failed td, tr.refused td { background:var(--fail-bg); }
445
445
  a.frame { display:block; color:var(--muted); font-size:11px; }
446
446
  a.frame img { display:block; max-width:220px; max-height:150px; object-fit:cover; object-position:top; border:1px solid var(--line); }
447
- td.hash, td.path { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
447
+ td.hash, td.path { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
448
+ a.frame { overflow-wrap:anywhere; }
448
449
  table.sign td.blank { height:2.6em; min-width:8em; }
449
450
  .none { color:var(--muted); font-style:italic; }
450
451
  @media print { main { max-width:none; padding:0; } a { color:inherit; text-decoration:none; } tbody, tr { break-inside:avoid; } }
@@ -98,3 +98,73 @@ export class SessionQueue {
98
98
  this.forget(key);
99
99
  }
100
100
  }
101
+ /**
102
+ * The work of every tool call still running, per session.
103
+ *
104
+ * The watchdog answers a slow call, but it cannot stop the call: the work goes
105
+ * on in the background and can still write into the project (its action log,
106
+ * a frame, a debounced memory save). Left alone, those writes land after
107
+ * `scout_close` has answered, and after another session has attached to the
108
+ * same folder. So close waits for them: it closes the browser first, which
109
+ * makes every page-bound step of the abandoned work fail at once, then waits
110
+ * here for the work to finish unwinding. The wait is bounded, so work that is
111
+ * stuck on something other than the browser cannot hold a close forever; the
112
+ * caller is told how many calls were still running when it gave up.
113
+ */
114
+ export class CallWork {
115
+ running = new Map();
116
+ /** Record `work` as running for `key` until it settles, whether it resolves or rejects. */
117
+ track(key, work) {
118
+ let set = this.running.get(key);
119
+ if (!set) {
120
+ set = new Set();
121
+ this.running.set(key, set);
122
+ }
123
+ set.add(work);
124
+ const done = () => {
125
+ const left = this.running.get(key);
126
+ if (!left)
127
+ return;
128
+ left.delete(work);
129
+ if (left.size === 0)
130
+ this.running.delete(key);
131
+ };
132
+ // The rejection is the call's own caller's to handle; here it only means "finished".
133
+ work.then(done, done);
134
+ }
135
+ /** Calls still running for `key`. */
136
+ count(key) {
137
+ return this.running.get(key)?.size ?? 0;
138
+ }
139
+ /** Wait up to `ms` for `key`'s running calls to finish; resolves with how many are still running. */
140
+ async settle(key, ms) {
141
+ return this.settleKeys([key], ms);
142
+ }
143
+ /** The same for every session (close-all). */
144
+ async settleAll(ms) {
145
+ return this.settleKeys([...this.running.keys()], ms);
146
+ }
147
+ async settleKeys(keys, ms) {
148
+ const work = keys.flatMap((k) => [...(this.running.get(k) ?? [])]);
149
+ if (work.length > 0) {
150
+ let timer;
151
+ await Promise.race([Promise.allSettled(work), new Promise((resolve) => (timer = setTimeout(resolve, ms)))]);
152
+ clearTimeout(timer);
153
+ }
154
+ return keys.reduce((n, k) => n + this.count(k), 0);
155
+ }
156
+ }
157
+ /**
158
+ * The cap on every tool call's watchdog, from SCENESCOUT_WATCHDOG_MS. Unset
159
+ * means each tool keeps its own limit. Anything else must be a whole number
160
+ * of milliseconds of at least 100, or the server refuses to start: a typo
161
+ * here would otherwise run with limits nobody chose.
162
+ */
163
+ export function watchdogCap(raw) {
164
+ if (raw === undefined || raw.trim() === "")
165
+ return undefined;
166
+ const ms = Number(raw);
167
+ if (!Number.isInteger(ms) || ms < 100)
168
+ throw new Error(`SCENESCOUT_WATCHDOG_MS must be a whole number of milliseconds, at least 100; got "${raw}"`);
169
+ return ms;
170
+ }
@@ -1815,6 +1815,18 @@ export class MemoryStore {
1815
1815
  // Deliberately NOT unref'd: a pending coverage write briefly holds the
1816
1816
  // process open so an exit without scout_close still lands the last save.
1817
1817
  }
1818
+ /**
1819
+ * Write now if a debounced save is waiting. A close calls it after the work
1820
+ * it waited for has finished, so a save that work scheduled lands before the
1821
+ * close answers rather than half a second after. Throws as flush() does.
1822
+ */
1823
+ flushPending() {
1824
+ if (!this.saveTimer)
1825
+ return;
1826
+ clearTimeout(this.saveTimer);
1827
+ this.saveTimer = null;
1828
+ this.flush();
1829
+ }
1818
1830
  /** How many states the last open pruned. Reported once, so a shrinking history is never silent. */
1819
1831
  prunedStates = 0;
1820
1832
  /** Set when a debounced background write failed — cleared on the next successful write. Surfaced by scout_coverage/scout_close so a broken persistence path is never silently invisible. */
@@ -50,7 +50,7 @@ import { DedupJudge, planDedup, samplingAsk } from "./engine/dedup.js";
50
50
  import { httpJudgeAsk } from "./ci-run.js";
51
51
  import { decodedEntitiesNote, ignoredConventionsNote, LANE_CONVENTION_MAX, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport, } from "./engine/lane.js";
52
52
  import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
53
- import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
53
+ import { CallWork, SessionQueue, watchdogCap, 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
56
  import { liveViewUrl, MCP_APP_MIME, paneData, paneText, STATUS_PANE_URI, STATUS_POLL_TOOL, STATUS_TOOL, } from "./engine/status-pane.js";
@@ -451,6 +451,31 @@ function watchdogTimeout(label, ms) {
451
451
  * each other. The queue itself lives in engine/dispatch.ts, where it is tested.
452
452
  */
453
453
  const sessionQueue = new SessionQueue();
454
+ /** Every call's work until it settles, so a close can wait for what a watchdog let run on (engine/dispatch.ts). */
455
+ const callWork = new CallWork();
456
+ /** How long a close waits for that work once the browser is gone; anything page-bound has failed well before this. */
457
+ const CLOSE_WORK_WAIT_MS = 10_000;
458
+ /**
459
+ * Write the save that work finishing during a close's wait scheduled. The engine's own close has already flushed;
460
+ * this catches what came after it. A failure is recorded where the close reports it, as the engine's flush does.
461
+ */
462
+ function flushAfterClose(eng) {
463
+ try {
464
+ eng.memory?.flushPending();
465
+ }
466
+ catch (err) {
467
+ if (eng.memory)
468
+ eng.memory.lastSaveError = err instanceof Error ? err.message : String(err);
469
+ }
470
+ }
471
+ /** What a close says when work it waited for was still running at the end of the wait. */
472
+ function stillRunningNote(count) {
473
+ if (count === 0)
474
+ return "";
475
+ return `\n⚠ ${count} tool call${count === 1 ? " was" : "s were"} still running ${CLOSE_WORK_WAIT_MS / 1000} s after the browser closed, so ${count === 1 ? "it" : "they"} may still write into .scenescout/.`;
476
+ }
477
+ /** SCENESCOUT_WATCHDOG_MS caps every tool's watchdog. Read once, at start, and a bad value stops the server here. */
478
+ const WATCHDOG_CAP_MS = watchdogCap(process.env.SCENESCOUT_WATCHDOG_MS);
454
479
  function serializedPerSession(label, fn, timeoutMs = 60_000) {
455
480
  return (args) => {
456
481
  const session = args.session ?? activeName;
@@ -469,10 +494,13 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
469
494
  const exec = async () => {
470
495
  // A session that raised its time limits gets its watchdog raised by as much (limits.ts).
471
496
  const current = engines.get(session);
472
- const watchdogMs = current ? watchdogFor(timeoutMs, current.timeLimits) : timeoutMs;
497
+ const raised = current ? watchdogFor(timeoutMs, current.timeLimits) : timeoutMs;
498
+ const watchdogMs = Math.min(raised, WATCHDOG_CAP_MS ?? raised);
473
499
  writeStatus(session, "running", label, watchdogMs);
474
500
  try {
475
- const out = await withWatchdog(label, fn(args, session), watchdogMs, watchdogTimeout);
501
+ const work = fn(args, session);
502
+ callWork.track(session, work);
503
+ const out = await withWatchdog(label, work, watchdogMs, watchdogTimeout);
476
504
  // `activeName` is process-global and every scout_attach moves it. With
477
505
  // several sessions live — the multi-role runs this tool encourages —
478
506
  // an omitted `session` silently binds to whichever browser attached
@@ -2258,8 +2286,15 @@ server.registerTool("scout_close", {
2258
2286
  for (const name of engines.keys())
2259
2287
  live?.dropSession(name);
2260
2288
  const stores = new Set([...engines.values()].map((e) => e.memory).filter((m) => m !== null && m !== undefined));
2261
- await Promise.allSettled([...engines.values()].map((e) => e.close()));
2289
+ const closing = [...engines.values()];
2290
+ await Promise.allSettled(closing.map((e) => e.close()));
2291
+ // Out of the live list before the wait, so a call queued behind abandoned work finds no session instead of
2292
+ // reaching a closed one. Then wait for work a watchdog let run on to unwind, and write what it left pending,
2293
+ // so nothing it writes lands after this answer.
2262
2294
  engines.clear();
2295
+ const stillRunning = await callWork.settleAll(CLOSE_WORK_WAIT_MS);
2296
+ for (const e of closing)
2297
+ flushAfterClose(e);
2263
2298
  for (const store of stores)
2264
2299
  store.endRun();
2265
2300
  sessionQueue.clear();
@@ -2267,7 +2302,7 @@ server.registerTool("scout_close", {
2267
2302
  laneLedger.clear();
2268
2303
  lastWriter = null;
2269
2304
  await Promise.all([...dirs].map((dir) => settleProjectWrites(dir)));
2270
- return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.`, activeName);
2305
+ return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.` + stillRunningNote(stillRunning), activeName);
2271
2306
  }
2272
2307
  const name = session ?? activeName;
2273
2308
  const eng = engines.get(name);
@@ -2279,8 +2314,11 @@ server.registerTool("scout_close", {
2279
2314
  keepReport(eng);
2280
2315
  live?.dropSession(name);
2281
2316
  await eng.close();
2282
- const saveError = eng.memory?.lastSaveError;
2317
+ // As above: out of the live list, then wait for work a watchdog let run on and write what it left pending.
2283
2318
  engines.delete(name);
2319
+ const stillRunning = await callWork.settle(name, CLOSE_WORK_WAIT_MS);
2320
+ flushAfterClose(eng);
2321
+ const saveError = eng.memory?.lastSaveError;
2284
2322
  openDecisions.delete(name);
2285
2323
  // The last session on this project ends its run.
2286
2324
  if (eng.memory && ![...engines.values()].some((e) => e.memory === eng.memory))
@@ -2300,6 +2338,7 @@ server.registerTool("scout_close", {
2300
2338
  activeName = engines.keys().next().value ?? "default";
2301
2339
  return text(`Session "${name}" closed. Memory and report remain in .scenescout/.` +
2302
2340
  (engines.size > 0 ? ` Default session → ${activeName}.` : "") +
2341
+ stillRunningNote(stillRunning) +
2303
2342
  (saveError ? `\n⚠ The final memory write failed (${saveError}) — some coverage/findings from this session may not have been persisted to disk.` : ""), activeName);
2304
2343
  }
2305
2344
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.23.0",
3
+ "version": "3.23.2",
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",
@@ -112,7 +112,7 @@
112
112
  "dedup-bench": "tsx scripts/dedup-bench.ts"
113
113
  },
114
114
  "dependencies": {
115
- "@modelcontextprotocol/sdk": "^1.31.0",
115
+ "@modelcontextprotocol/sdk": "^1.32.1",
116
116
  "playwright": "^1.63.0",
117
117
  "zod": "^3.25.0"
118
118
  },