scenescout 1.2.0 → 1.4.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 +26 -0
- package/README.md +41 -9
- package/dist/browsers.js +11 -0
- package/dist/cli.js +127 -37
- package/dist/engine/browser.js +207 -10
- package/dist/engine/injection.js +162 -0
- package/dist/engine/live-page.js +650 -0
- package/dist/engine/live.js +579 -0
- package/dist/engine/memory.js +10 -2
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/report.js +8 -2
- package/dist/installer.js +82 -4
- package/dist/mcp-server.js +216 -26
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +3 -2
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
|
-
|
|
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 =
|
|
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}`);
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
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):
|
|
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
|
|
97
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.4.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 demo-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,8 @@
|
|
|
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",
|
|
85
|
+
"demo-test": "tsx --test scripts/demo-test.ts",
|
|
84
86
|
"hygiene-test": "tsx --test scripts/hygiene-test.ts"
|
|
85
87
|
},
|
|
86
88
|
"dependencies": {
|
|
@@ -48,7 +48,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `minimal` | Every known route visited (crawl does this) + ≥1 design audit + 1–2 primary journeys as plans + crawl-flagged problems triaged. The report DISCLOSES remaining gaps in its Gap Ledger — minimal is honest-but-fast, not silent. | 1 crawl + 2–4 plans + a few snapshots |
|
|
50
50
|
| `medium` (default) | minimal + design audits on several distinct routes (gate: ≥ min(3, visited/10)) + every unexercised interactable class exercised once + every form submitted valid AND invalid | + interactive passes per module |
|
|
51
|
-
| `extensive` | medium + fuzzing (empty, 1000-char, unicode, `<script
|
|
51
|
+
| `extensive` | medium + fuzzing (empty, 1000-char, unicode, and markup such as `<script>` or `<img src=x onerror=…>` — the engine remembers every markup-shaped value you type and reports a `dom_injection` violation when it later renders as an element on any page, which is a stored or reflected XSS: file it as `security`, high, naming the field and the page; for uploads, `scout_upload {name}` with a wrong extension against `accept`, a 255-char name, unicode), back/refresh/deep-link resilience, keyboard-only pass, **an `scout_journey` per module's primary task**, **the impatient-user pass** (below), **≥2 roles compared**, **the anonymous auth-surface pass** (signup / forgot-password walked to the mailbox wall, step 8 above), and source-grounding for every absence-claim. **`scout_report {level:'extensive'}` REFUSES while the Gap Ledger is non-empty** — that refusal IS the completeness guarantee: an extensive report can only exist when every known route is visited, exercised, audited, journey-measured, and role-compared. | budget-capped by user |
|
|
52
52
|
|
|
53
53
|
**Levels in `observe` mode.** No form submission reaches the server, so "every form submitted valid AND invalid" means submitted and observed client-side; the unsubmitted-forms gap stays in the ledger, worded as the mode's doing. `extensive` refuses while any gap remains, so an `observe` run tops out at `medium` — say so rather than forcing the report.
|
|
54
54
|
|
|
@@ -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
|
|
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
|
|