scenescout 1.2.0 → 1.3.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/dist/installer.js CHANGED
@@ -16,14 +16,16 @@ export const MCP_NAME = "scenescout";
16
16
  const LEGACY_SKILL_NAMES = ["frontend-tester", "scenecraft"];
17
17
  const LEGACY_MCP_NAMES = ["scenecraft"];
18
18
  /** Real runner. `missing` separates "the binary is not installed" from "it ran and failed". */
19
- export const spawnRunner = (command, args) => {
20
- const r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000 });
19
+ export const spawnRunner = (command, args, opts) => {
20
+ const r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000, cwd: opts?.cwd });
21
21
  const code = r.error?.code;
22
22
  // On Windows node refuses to start a .cmd or .bat file directly (EINVAL). For
23
23
  // the caller that is the same situation as a missing binary: nothing ran, and
24
24
  // the command has to be handed to the person instead.
25
25
  const missing = code === "ENOENT" || (process.platform === "win32" && code === "EINVAL");
26
- return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error.message) : ""), missing };
26
+ // With `encoding` set, a command that never ran still yields "" for stderr: the error is the only account of it.
27
+ const stderr = r.stderr || (code === "ETIMEDOUT" ? "npm did not finish within 60 s" : r.error ? String(r.error.message) : "");
28
+ return { status: r.status, stdout: r.stdout ?? "", stderr, missing };
27
29
  };
28
30
  /** Written into a copy-mode install so a later install can tell its own copy from a user's directory. */
29
31
  const OWNERSHIP_MARKER = ".installed-by-scenescout";
@@ -248,13 +250,89 @@ export function parseRegistration(listing) {
248
250
  const field = (name) => new RegExp(`^[ \\t]*${name}:[ \\t]*(\\S.*?)[ \\t]*$`, "m").exec(listing)?.[1] ?? null;
249
251
  return { command: field("Command"), serverPath: field("Args") };
250
252
  }
253
+ /** The command the package's `bin` entry provides. */
254
+ export const CLI_NAME = "scenescout";
255
+ const isCheckoutRoot = (packageRoot) => fs.existsSync(path.join(packageRoot, "tsconfig.json")) && fs.existsSync(path.join(packageRoot, "src"));
256
+ /**
257
+ * Where a command resolves in the user's own shell, or null.
258
+ *
259
+ * npm and npx put `node_modules/.bin` directories on PATH for the length of a
260
+ * run. Under `npx scenescout install` that makes the command appear installed
261
+ * when it will be gone the moment the run ends, so those entries are skipped.
262
+ */
263
+ export function findOnUserPath(opts) {
264
+ const exists = opts.exists ?? fs.existsSync;
265
+ for (const dir of opts.pathValue.split(opts.delimiter ?? path.delimiter).filter(Boolean)) {
266
+ if (/[\\/]node_modules[\\/]\.bin[\\/]?$/.test(dir))
267
+ continue;
268
+ for (const name of opts.names) {
269
+ const candidate = path.join(dir, name);
270
+ if (exists(candidate))
271
+ return candidate;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+ /**
277
+ * What it takes for `scenescout` to work as a command in a terminal.
278
+ *
279
+ * The MCP registration never needed that: it stores an absolute launcher. But
280
+ * `scenescout status` and `scenescout watch` are typed by a person, and both a
281
+ * source checkout and an npx run leave nothing on PATH, so the commands the
282
+ * tool itself recommends answered "command not found".
283
+ *
284
+ * A checkout is linked, so the command always runs what was last built. Any
285
+ * other install gets the same version installed globally. A checkout takes the
286
+ * name over from another copy, the way it takes over the MCP registration; a
287
+ * packaged install leaves an existing command alone.
288
+ */
289
+ export function planCommand(opts) {
290
+ const checkout = isCheckoutRoot(opts.packageRoot);
291
+ const manual = checkout ? `npm link (run in ${opts.packageRoot})` : `npm install -g ${CLI_NAME}@${opts.version}`;
292
+ if (opts.resolved !== null) {
293
+ const mine = samePath(opts.resolved, path.join(opts.packageRoot, "dist", "cli.js"));
294
+ if (mine || !checkout)
295
+ return { action: "present", at: opts.resolved };
296
+ }
297
+ // npm on Windows is a .cmd shim, which node cannot start directly.
298
+ if (opts.platform === "win32")
299
+ return { action: "manual", manual, why: "this step cannot start npm on Windows" };
300
+ const beside = path.join(path.dirname(opts.nodePath), "npm");
301
+ // The npm beside the running node installs into that node's prefix, which is
302
+ // the one whose bin directory the shell that started us already has on PATH.
303
+ const npm = fs.existsSync(beside) ? beside : "npm";
304
+ return checkout
305
+ ? { action: "run", how: "link", command: npm, args: ["link"], cwd: opts.packageRoot, manual, replaces: opts.resolved }
306
+ : { action: "run", how: "global", command: npm, args: ["install", "-g", `${CLI_NAME}@${opts.version}`], manual, replaces: null };
307
+ }
308
+ export function ensureCommand(plan, run) {
309
+ if (plan.action === "present")
310
+ return { status: "present", at: plan.at };
311
+ if (plan.action === "manual")
312
+ return { status: "failed", manual: plan.manual, detail: plan.why };
313
+ const r = run(plan.command, plan.args, { cwd: plan.cwd });
314
+ if (r.missing)
315
+ return { status: "failed", manual: plan.manual, detail: "npm was not found" };
316
+ if (r.status !== 0) {
317
+ // A system-wide node owns its prefix as root; that is the usual reason, and
318
+ // the last line of npm's output names it. With no output at all (a timeout,
319
+ // a kill) the exit is all there is to say.
320
+ const lines = (r.stderr || r.stdout).trim().split("\n");
321
+ return {
322
+ status: "failed",
323
+ manual: plan.manual,
324
+ detail: lines.find((l) => /EACCES|EPERM|ERR!/.test(l))?.trim() || lines[lines.length - 1] || `npm exited ${r.status ?? "without finishing"}`,
325
+ };
326
+ }
327
+ return { status: "installed", how: plan.how, replaced: plan.replaces };
328
+ }
251
329
  /**
252
330
  * The command that repairs a setup, for THIS kind of install. A source checkout
253
331
  * has `npm run setup`; someone who installed from npm has no such script, and
254
332
  * telling them to run it sends them looking for a package.json they never had.
255
333
  */
256
334
  export function repairCommands(packageRoot) {
257
- const isCheckout = fs.existsSync(path.join(packageRoot, "tsconfig.json")) && fs.existsSync(path.join(packageRoot, "src"));
335
+ const isCheckout = isCheckoutRoot(packageRoot);
258
336
  // Installing "chromium" brings the headless shell with it, so the plain
259
337
  // setup command already repairs either Chromium build.
260
338
  const browserFlags = (target) => (engineOf(target) === "chromium" ? "" : ` --browser-only --browsers ${target}`);
@@ -21,7 +21,9 @@
21
21
  * - Self-healing: orphaned browser processes from crashed runs are reaped at
22
22
  * startup and on launch failure; attach retries once after reaping.
23
23
  * - Observable: .scenescout/status.json in the tested project always shows
24
- * what each session is doing right now (`scenescout status <project>`).
24
+ * what EVERY session is doing right now (`scenescout status <project>`), and
25
+ * a loopback-only live view shows what each one is looking at
26
+ * (`scenescout watch <project>`, engine/live.ts, ADR 7).
25
27
  */
26
28
  import fs from "node:fs";
27
29
  import path from "node:path";
@@ -35,6 +37,7 @@ import { reapOrphanBrowsers } from "./engine/reaper.js";
35
37
  import { MemoryStore, redactSecrets } from "./engine/memory.js";
36
38
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
37
39
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
40
+ import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard } from "./engine/live.js";
38
41
  import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
39
42
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
40
43
  import { formatScan, scanProject } from "./scan.js";
@@ -87,32 +90,170 @@ function errorText(err) {
87
90
  isError: true,
88
91
  };
89
92
  }
93
+ /** One entry per live session: what status.json and the live view both read. */
94
+ const board = new StatusBoard();
95
+ /** The session whose call wrote status last. The top-level fields of status.json describe it, as they always have. */
96
+ let lastWriter = null;
97
+ let live = null;
98
+ let liveAddress = null;
99
+ /** Set while the server is starting and kept afterwards, so every caller awaits the same start. */
100
+ let liveStart = null;
101
+ /** Project directories holding this process's token file, so shutdown can take it back. */
102
+ const liveDirs = new Set();
103
+ /** Token writes in flight, so shutdown waits for them instead of racing a file that appears after the rm. */
104
+ const liveTokenWrites = new Map();
105
+ let liveTokenWarned = false;
106
+ /** Why the live view could not start, when it could not: told to the agent and written to status.json. */
107
+ let liveError = null;
108
+ const liveProvider = {
109
+ snapshot: () => ({ pid: process.pid, version: PKG_VERSION, at: new Date().toISOString(), sessions: board.list() }),
110
+ // The engine holds no reasoning — it never sees one — so the feed is what the
111
+ // session DID: the action log, which is the same trail a finding's repro uses.
112
+ activity: (session, limit) => feedForSession(engines.get(session)?.memory?.actionLog ?? [], session, limit, redactSecrets),
113
+ // The same document scout_report writes at the end, rendered now and not
114
+ // written: what the run has found so far, its scores and its gap ledger.
115
+ // Findings and coverage are project-wide; the route, audit and mode figures
116
+ // are the session's that wrote status last, so in a multi-session run they
117
+ // can shift between polls.
118
+ report: () => {
119
+ const eng = (lastWriter && engines.get(lastWriter.session)) ?? engines.values().next().value;
120
+ if (!eng?.memory)
121
+ return null;
122
+ const { markdown } = generateReport(eng.memory, eng.oracleLog.all, reportExtras(eng), { write: false });
123
+ return { markdown: redactSecrets(markdown), at: new Date().toISOString() };
124
+ },
125
+ // Both go around the session queue on purpose: a viewer must never wait
126
+ // behind the agent's calls, and a session that is stuck is the one most
127
+ // worth looking at.
128
+ screenshot: async (session) => (await engines.get(session)?.liveShot()) ?? null,
129
+ startStream: async (session, onFrame, onEnd) => (await engines.get(session)?.startScreencast(onFrame, onEnd)) ?? null,
130
+ };
131
+ /** What the report needs to know beyond memory: the one place it is built, so the live view and scout_report cannot drift apart. */
132
+ function reportExtras(eng) {
133
+ const unvisited = eng.unvisitedKnownRoutes();
134
+ const all = eng.allKnownRoutes();
135
+ return {
136
+ routesVisited: all.length - unvisited.length,
137
+ routesTotal: all.length,
138
+ designAudits: eng.designAuditCount,
139
+ createdResources: eng.createdResources,
140
+ unvisitedRoutes: unvisited,
141
+ mode: eng.mode,
142
+ policyAttributed: eng.oracleLog.policyAttributed,
143
+ };
144
+ }
145
+ /** Hand the live view's token to `scenescout watch` through a file only the owner can read. */
146
+ function publishLiveToken(dir) {
147
+ if (!liveAddress || liveDirs.has(dir) || liveTokenWrites.has(dir))
148
+ return;
149
+ const file = path.join(dir, LIVE_TOKEN_FILE);
150
+ const write = fs.promises
151
+ .writeFile(file, liveAddress.token, { mode: 0o600 })
152
+ // `mode` applies only when the file is created; a leftover one keeps its old bits.
153
+ .then(() => fs.promises.chmod(file, 0o600))
154
+ .then(() => {
155
+ liveDirs.add(dir);
156
+ })
157
+ .catch((err) => {
158
+ // A file with the wrong bits, or none: either way nothing to take back later.
159
+ void fs.promises.rm(file, { force: true }).catch(() => { });
160
+ if (!liveTokenWarned)
161
+ console.error(`[scenescout] could not write the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
162
+ liveTokenWarned = true;
163
+ })
164
+ .finally(() => liveTokenWrites.delete(dir));
165
+ liveTokenWrites.set(dir, write);
166
+ }
167
+ /**
168
+ * Started with the first attach rather than at boot: a server nobody attaches
169
+ * to should not open a port. Never rejects — observability is best-effort, and
170
+ * a port that will not open must not cost the run anything.
171
+ */
172
+ async function ensureLive(dir) {
173
+ if (process.env[LIVE_ENV] === "off")
174
+ return;
175
+ if (liveAddress)
176
+ return publishLiveToken(dir);
177
+ liveStart ??= startLiveServer();
178
+ await liveStart;
179
+ if (!liveAddress)
180
+ return;
181
+ publishLiveToken(dir);
182
+ // The port is new, so a reader polling status.json needs it rewritten.
183
+ flushStatus(dir);
184
+ }
185
+ function startLiveServer() {
186
+ const starting = new LiveServer(liveProvider);
187
+ return starting
188
+ .start()
189
+ .then((address) => {
190
+ live = starting;
191
+ liveAddress = address;
192
+ liveError = null;
193
+ })
194
+ .catch((err) => {
195
+ // Say why, once, and let a later attach try again.
196
+ const reason = err instanceof Error ? err.message : String(err);
197
+ if (liveError !== reason)
198
+ console.error(`[scenescout] the live view could not start: ${reason}`);
199
+ liveError = reason;
200
+ liveStart = null;
201
+ });
202
+ }
203
+ /**
204
+ * The line that hands the live view to the person running the agent. The
205
+ * address holds the token, and a tool result lands in the client's transcript;
206
+ * that is accepted (ADR 7) because the address answers on this machine only.
207
+ */
208
+ function liveLine() {
209
+ if (!liveAddress)
210
+ return liveError ? `\nLive view unavailable: ${liveError}` : "";
211
+ return (`\nLive view: http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/ — give this address to the user so they can watch every session ` +
212
+ `(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
213
+ }
214
+ function flushStatus(dir) {
215
+ // Fire-and-forget: status is best-effort observability on every tool call's
216
+ // hot path and must never add blocking filesystem latency. The writer
217
+ // queues writes per directory and lands each by rename, so a reader never
218
+ // sees a torn file.
219
+ void writeStatusFile(dir, JSON.stringify({
220
+ pid: process.pid,
221
+ phase: lastWriter?.phase ?? "idle",
222
+ tool: lastWriter?.tool ?? "",
223
+ session: lastWriter?.session ?? "",
224
+ role: lastWriter?.role ?? "anonymous",
225
+ sessions: [...engines.keys()],
226
+ url: lastWriter?.url ?? "",
227
+ at: new Date().toISOString(),
228
+ // Everything above describes one session. This is all of them.
229
+ detail: board.list(),
230
+ ...(liveAddress ? { live: { port: liveAddress.port } } : liveError ? { live: { error: liveError } } : {}),
231
+ }, null, 2));
232
+ }
90
233
  /**
91
234
  * Live status for the tested project (`scenescout status <project>` or any
92
- * supervising layer reads this): which session/tool is running right now.
235
+ * supervising layer reads this): what every session is doing right now.
93
236
  * Best-effort — observability must never break the tool call itself.
94
237
  */
95
- function writeStatus(session, phase, tool) {
96
- const dir = engines.get(session)?.memory?.dir;
97
- if (!dir)
238
+ function writeStatus(session, phase, tool, budgetMs) {
239
+ const eng = engines.get(session);
240
+ const dir = eng?.memory?.dir;
241
+ if (!eng || !dir)
98
242
  return;
99
- // Fire-and-forget async write: status is best-effort observability and runs
100
- // on every tool call's hot path — it must never add blocking filesystem
101
- // latency. Two sessions writing concurrently is a benign last-write-wins on
102
- // this one project-level file; each session's OWN status still reaches disk.
103
- void fs.promises
104
- .writeFile(path.join(dir, "status.json"), JSON.stringify({
105
- pid: process.pid,
243
+ // status.json is a poll target that gets pasted into bug reports.
244
+ const { task, objective, ...described } = eng.liveDescription;
245
+ lastWriter = board.update(session, {
246
+ role: eng.role,
106
247
  phase,
107
248
  tool,
108
- session,
109
- role: engines.get(session)?.role ?? "anonymous",
110
- sessions: [...engines.keys()],
111
- // status.json is a poll target that gets pasted into bug reports.
112
- url: redactSecrets(engines.get(session)?.currentUrl ?? ""),
113
- at: new Date().toISOString(),
114
- }, null, 2))
115
- .catch(() => { });
249
+ url: redactSecrets(eng.currentUrl),
250
+ ...(budgetMs ? { budgetMs } : {}),
251
+ ...described,
252
+ ...(task ? { task: redactSecrets(task) } : {}),
253
+ ...(objective ? { objective: redactSecrets(objective) } : {}),
254
+ });
255
+ void ensureLive(dir);
256
+ flushStatus(dir);
116
257
  }
117
258
  /** The watchdog's timeout answer — a diagnosable result, not a hang. */
118
259
  function watchdogTimeout(label, ms) {
@@ -133,7 +274,7 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
133
274
  return (args) => {
134
275
  const session = args.session ?? activeName;
135
276
  const exec = async () => {
136
- writeStatus(session, "running", label);
277
+ writeStatus(session, "running", label, timeoutMs);
137
278
  try {
138
279
  const out = await withWatchdog(label, fn(args, session), timeoutMs, watchdogTimeout);
139
280
  // `activeName` is process-global and every scout_attach moves it. With
@@ -241,13 +382,18 @@ server.registerTool("scout_attach", {
241
382
  .describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium. firefox and webkit must be downloaded first (scenescout install --browser-only --browsers firefox). Use them for a cross-browser pass; stay on chromium otherwise."),
242
383
  viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
243
384
  viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
385
+ task: z
386
+ .string()
387
+ .max(300)
388
+ .optional()
389
+ .describe("What this session is for, in one sentence (e.g. 'Approve and reject orders as a manager'). Shown to the person watching the live view, next to the goal of whatever scout_journey is active. Worth setting whenever more than one session is running."),
244
390
  session: z
245
391
  .string()
246
392
  .max(40)
247
393
  .optional()
248
394
  .describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
249
395
  },
250
- }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, session, }) => {
396
+ }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, task, session, }) => {
251
397
  try {
252
398
  const target = session ?? activeName;
253
399
  if (session) {
@@ -308,9 +454,16 @@ server.registerTool("scout_attach", {
308
454
  /* conflict detection is best-effort */
309
455
  }
310
456
  const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
311
- const out = await eng.attach({ url, projectDir: projectPath, storageStatePath, mode, headed, browser, viewport, memoryStore: store });
457
+ const out = await eng.attach({ url, projectDir: projectPath, storageStatePath, mode, headed, browser, viewport, task, memoryStore: store });
312
458
  eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
313
- return text(out + conflictNote + (engines.size > 1 ? `\n${sessionLines()}` : ""), target);
459
+ // Put the session on the board now, so the live view shows it before its
460
+ // first tool call. liveLine() needs the port, so the server is awaited
461
+ // here rather than started in the background by writeStatus.
462
+ if (eng.memory?.dir) {
463
+ await ensureLive(eng.memory.dir);
464
+ writeStatus(target, "idle", "scout_attach");
465
+ }
466
+ return text(out + conflictNote + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
314
467
  }
315
468
  catch (err) {
316
469
  return errorText(err);
@@ -336,7 +489,7 @@ server.registerTool("scout_session", {
336
489
  try {
337
490
  name = name ?? session;
338
491
  if (!name)
339
- return text(sessionLines(), activeName);
492
+ return text(sessionLines() + liveLine(), activeName);
340
493
  if (!engines.has(name)) {
341
494
  return text(`No session named "${name}" yet — create it with scout_attach { session: "${name}", … }.\n${sessionLines()}`, activeName);
342
495
  }
@@ -819,19 +972,35 @@ server.registerTool("scout_close", {
819
972
  const names = [...engines.keys()];
820
973
  // Closes are independent per-browser — run them in parallel so N wedged
821
974
  // sessions cost one 8s teardown cap total, not N of them.
975
+ const dirs = new Set();
976
+ for (const e of engines.values())
977
+ if (e.memory?.dir)
978
+ dirs.add(e.memory.dir);
979
+ for (const name of engines.keys())
980
+ live?.dropSession(name);
822
981
  await Promise.allSettled([...engines.values()].map((e) => e.close()));
823
982
  engines.clear();
824
983
  sessionQueue.clear();
984
+ board.clear();
985
+ lastWriter = null;
986
+ for (const dir of dirs)
987
+ flushStatus(dir);
825
988
  return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.`, activeName);
826
989
  }
827
990
  const name = session ?? activeName;
828
991
  const eng = engines.get(name);
829
992
  if (!eng)
830
993
  return text(`No live session "${name}".`, name);
994
+ live?.dropSession(name);
831
995
  await eng.close();
832
996
  const saveError = eng.memory?.lastSaveError;
833
997
  engines.delete(name);
834
998
  sessionQueue.forget(name);
999
+ board.remove(name);
1000
+ if (lastWriter?.session === name)
1001
+ lastWriter = null;
1002
+ if (eng.memory?.dir)
1003
+ flushStatus(eng.memory.dir);
835
1004
  if (activeName === name)
836
1005
  activeName = engines.keys().next().value ?? "default";
837
1006
  return text(`Session "${name}" closed. Memory and report remain in .scenescout/.` +
@@ -845,13 +1014,34 @@ server.registerTool("scout_close", {
845
1014
  async function main() {
846
1015
  const transport = new StdioServerTransport();
847
1016
  await server.connect(transport);
1017
+ // A client that exits by closing the pipe sends no signal. Without this the
1018
+ // process, its port, its browsers and its token file all outlived the run.
1019
+ const clientGone = () => {
1020
+ void shutdown().finally(() => process.exit(0));
1021
+ };
1022
+ transport.onclose = clientGone;
1023
+ // The transport reports a closed pipe on some platforms and not others, and
1024
+ // on Windows the SIGTERM a client sends next is a plain kill that runs no
1025
+ // handler. stdin ending is the one signal every platform gives.
1026
+ process.stdin.once("end", clientGone);
1027
+ process.stdin.once("close", clientGone);
848
1028
  // Self-heal across restarts: browsers whose parent crashed/was killed can
849
1029
  // linger and have been observed to wedge fresh launches. After connect —
850
1030
  // the stdio handshake must not wait on a full process-table scan.
851
1031
  setImmediate(() => reapOrphanBrowsers());
852
1032
  }
853
1033
  async function shutdown() {
854
- await Promise.allSettled([...engines.values()].map((e) => e.close()));
1034
+ // The token outlives nothing: a file left behind would name a port some other process may get next.
1035
+ await Promise.allSettled(liveTokenWrites.values());
1036
+ for (const dir of liveDirs) {
1037
+ try {
1038
+ fs.rmSync(path.join(dir, LIVE_TOKEN_FILE), { force: true });
1039
+ }
1040
+ catch (err) {
1041
+ console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
1042
+ }
1043
+ }
1044
+ await Promise.allSettled([live?.stop(), ...[...engines.values()].map((e) => e.close())]);
855
1045
  }
856
1046
  process.on("SIGINT", () => {
857
1047
  void shutdown().finally(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -71,7 +71,7 @@
71
71
  "mcp-check": "npm run build && npm run mcp-check:run",
72
72
  "mcp-check:run": "tsx scripts/mcp-check.ts",
73
73
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
74
- "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run hygiene-test",
74
+ "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run live-test && npm run hygiene-test",
75
75
  "scan-test": "tsx scripts/scan-test.ts",
76
76
  "oracle-test": "tsx --test scripts/oracle-test.ts",
77
77
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -81,6 +81,7 @@
81
81
  "contract-test": "tsx --test scripts/contract-test.ts",
82
82
  "memory-test": "tsx --test scripts/memory-test.ts",
83
83
  "install-test": "tsx --test scripts/install-test.ts",
84
+ "live-test": "tsx --test scripts/live-test.ts",
84
85
  "hygiene-test": "tsx --test scripts/hygiene-test.ts"
85
86
  },
86
87
  "dependencies": {
@@ -68,6 +68,7 @@ Some flows need a TEAM — a document one role submits and another approves, a r
68
68
  - While roles are NOT collaborating, use each one productively where its permissions matter (admin in /admin surfaces, low-privilege probing for permission leaks) — same coverage contract, different vantage points.
69
69
  - **Infer the PERSONA behind each role, and write it down.** From what a role can see and do (its nav, its dashboard, the capability matrix in the report), state what this person is FOR: "qa = reviewer — approves orders, assigns reviewers, no admin" / "user = front-line user — reads documents, completes reviews, raises orders". Record it with `scout_note {section:'roles'}`. Then test the persona's WORLD, not just the permissions: does the operator's landing page serve an operator? Is anything they need N clicks deep? The capability matrix's divergent rows are questions, not verdicts — each is either a correct boundary or a gap ("should this role be able to do this?"); say which you believe it is and why.
70
70
  - `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
71
+ - **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS and closes it by name when it FINISHES. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session a `task` when you attach it (`scout_attach {session, task:"Approve and reject orders as a manager"}`) and wrap each goal in `scout_journey`: the live view shows the task and the active journey's goal beside the session's feed, which is how the person watching knows what every agent is for. Keep that goal TRUE: one journey per goal, one goal per thing you are checking ("Save a settings change as the auditor", not "Check every page"), ended the moment it is decided and the next one started before you move on. A journey that outlives its goal shows the viewer an objective the session left behind minutes ago. Run roughly as many agents at once as the machine has cores, less two, since each drives a real browser; beyond that they only queue. Exploring one area is well within a mid-tier model, so run these agents on one (Sonnet or its equivalent in your client) unless the user names a model; keep the larger model for the agent that plans the split and writes the report. No agent may call `scout_close {all: true}` while others run — only the last step, once every agent has finished.
71
72
 
72
73
  ## The impatient-user pass (extensive)
73
74
 
@@ -78,7 +79,7 @@ Polite, precise testing misses how real users behave. Once per module's key flow
78
79
  - **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.
79
80
  - Keep attribution honest: these are deliberate probes — say so in findings ("under rapid double-click…"), so a developer can reproduce exactly.
80
81
 
81
- 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 it's doing right now point the user there if they ask for live progress.
82
+ 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.** `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.
82
83
 
83
84
  ## Judgment (what the engine can't do)
84
85