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.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * The live view: what every session is doing right now, and what it is
3
+ * looking at.
4
+ *
5
+ * status.json used to hold ONE line for the whole project, last write wins. With
6
+ * several sessions attached that line describes whichever session happened to
7
+ * finish a call most recently, which is the opposite of what someone watching a
8
+ * multi-role run needs. The StatusBoard keeps one entry per session instead.
9
+ *
10
+ * The LiveServer puts that board, a thumbnail and an optional live stream of
11
+ * each session's page behind a small HTTP server. It exists for a person
12
+ * watching a run, and it holds to the rules in ADR 7:
13
+ *
14
+ * - it binds 127.0.0.1 only and refuses a Host header that is not loopback,
15
+ * so neither the network nor a DNS-rebinding page can reach it;
16
+ * - every path starts with a random token, handed over in the scout_attach
17
+ * result and, for `scenescout watch`, through a file only the owner can read;
18
+ * - it answers GET and nothing else: a viewer can look, never act;
19
+ * - a frame is held in memory and sent to the viewer. None is written to disk,
20
+ * because the page may be showing somebody's real data.
21
+ *
22
+ * Nothing here imports Playwright. The engine is reached through LiveProvider,
23
+ * so the whole module is tested over real HTTP with a fake provider.
24
+ */
25
+ import crypto from "node:crypto";
26
+ import fs from "node:fs";
27
+ import http from "node:http";
28
+ import path from "node:path";
29
+ import { LIVE_PAGE } from "./live-page.js";
30
+ import { JOURNEY_END, JOURNEY_START } from "./memory.js";
31
+ /** Holds the live view's token, next to status.json. Written owner-only; removed when the engine shuts down. */
32
+ export const LIVE_TOKEN_FILE = "live-token";
33
+ /** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
34
+ export const LIVE_ENV = "SCENESCOUT_LIVE";
35
+ /**
36
+ * One session's most recent actions, oldest first, each tagged with the
37
+ * journey it belonged to. The log is project-wide and interleaves every
38
+ * session, and a journey is delimited in it by its own `journey:start` and
39
+ * `journey:end` lines, so the walk goes back past the window until it finds
40
+ * the marker that says whether the window's first lines were inside one.
41
+ */
42
+ export function feedForSession(log, session, limit, redact = (s) => s) {
43
+ const mine = [];
44
+ let i = log.length - 1;
45
+ for (; i >= 0 && mine.length < limit; i -= 1) {
46
+ const e = log[i];
47
+ if (e && (e.session ?? session) === session)
48
+ mine.push(e);
49
+ }
50
+ let objective;
51
+ for (; i >= 0; i -= 1) {
52
+ const e = log[i];
53
+ if (!e || (e.session ?? session) !== session)
54
+ continue;
55
+ if (e.action === JOURNEY_END)
56
+ break;
57
+ if (e.action === JOURNEY_START) {
58
+ objective = e.target;
59
+ break;
60
+ }
61
+ }
62
+ const lines = [];
63
+ for (const e of mine.reverse()) {
64
+ if (e.action === JOURNEY_START)
65
+ objective = e.target;
66
+ const line = { at: e.at, action: e.action, url: redact(e.url) };
67
+ if (e.target !== undefined)
68
+ line.target = e.target;
69
+ if (e.result !== undefined)
70
+ line.result = e.result;
71
+ if (objective !== undefined)
72
+ line.objective = objective;
73
+ lines.push(line);
74
+ if (e.action === JOURNEY_END)
75
+ objective = undefined;
76
+ }
77
+ return lines;
78
+ }
79
+ /** How many feed lines a status poll carries per session. Enough to read the last move at a glance; the close-up asks for more. */
80
+ export const FEED_LINES = 6;
81
+ /** The cap the close-up gets. A long run's log is thousands of lines and none of it needs to reach the page. */
82
+ export const FEED_LINES_MAX = 60;
83
+ /**
84
+ * How long a call may run before the session is judged wedged rather than
85
+ * busy, when the entry does not carry the tool's own watchdog budget (an
86
+ * engine from before budgets were recorded). Each tool's real budget is what
87
+ * `classify` uses when it is there: a healthy crawl runs for minutes.
88
+ */
89
+ export const STUCK_AFTER_MS = 120_000;
90
+ export class StatusBoard {
91
+ now;
92
+ entries = new Map();
93
+ constructor(now = Date.now) {
94
+ this.now = now;
95
+ }
96
+ update(session, fields) {
97
+ const prev = this.entries.get(session);
98
+ const at = new Date(this.now()).toISOString();
99
+ // `since` survives repeated writes of the same phase of the same tool:
100
+ // that is what lets a reader tell a long-running call from a fresh one.
101
+ const since = prev && prev.phase === fields.phase && prev.tool === fields.tool ? prev.since : at;
102
+ const next = { session, ...fields, since, at };
103
+ this.entries.set(session, next);
104
+ return next;
105
+ }
106
+ remove(session) {
107
+ this.entries.delete(session);
108
+ }
109
+ clear() {
110
+ this.entries.clear();
111
+ }
112
+ has(session) {
113
+ return this.entries.has(session);
114
+ }
115
+ /** Sorted by name, so a poller sees a stable order rather than insertion order. */
116
+ list() {
117
+ return [...this.entries.values()].sort((a, b) => a.session.localeCompare(b.session));
118
+ }
119
+ }
120
+ export function classify(status, nowMs, stuckAfterMs = STUCK_AFTER_MS) {
121
+ if (status.phase !== "running")
122
+ return "idle";
123
+ // Past its own budget the watchdog should have ended the call; a call still
124
+ // running there has outlived the thing that was meant to stop it.
125
+ return nowMs - new Date(status.since).getTime() > (status.budgetMs ?? stuckAfterMs) ? "stuck" : "running";
126
+ }
127
+ export function formatDuration(ms) {
128
+ const total = Math.max(0, Math.floor(ms / 1000));
129
+ if (total < 60)
130
+ return `${total}s`;
131
+ const minutes = Math.floor(total / 60);
132
+ if (minutes < 60)
133
+ return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
134
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
135
+ }
136
+ /**
137
+ * A log timestamp on the reader's own clock, 24-hour. The log stores UTC, and
138
+ * a time sliced straight out of it reads an hour or more off the person's
139
+ * watch, which makes a live feed look stale.
140
+ */
141
+ export function localClock(iso) {
142
+ const d = new Date(iso);
143
+ if (Number.isNaN(d.getTime()))
144
+ return "";
145
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
146
+ }
147
+ /** One line per session for the terminal (`scenescout status`). */
148
+ export function formatSessionLine(status, nowMs) {
149
+ const state = classify(status, nowMs);
150
+ const held = formatDuration(nowMs - new Date(status.since).getTime());
151
+ const what = state === "idle" ? `· idle ${held} after ${status.tool}` : `${state === "stuck" ? "⚠ STUCK" : "⏳"} ${status.tool} for ${held}`;
152
+ return `${status.session} (${status.role}) ${what}${status.url ? ` — ${status.url}` : ""}`;
153
+ }
154
+ const statusWrites = new Map();
155
+ /**
156
+ * Write status.json so that a reader never sees a torn file. Writes to one
157
+ * directory are queued behind each other, and each lands by rename, so two
158
+ * calls in the same tick cannot leave a short document with the tail of a
159
+ * longer one after it (which is what overlapping `writeFile`s produced).
160
+ * Best-effort: a failed write is dropped and the next one lands.
161
+ */
162
+ export function writeStatusFile(dir, body) {
163
+ const file = path.join(dir, "status.json");
164
+ const tmp = `${file}.${process.pid}.tmp`;
165
+ const next = (statusWrites.get(dir) ?? Promise.resolve())
166
+ .then(() => fs.promises.writeFile(tmp, body))
167
+ .then(() => fs.promises.rename(tmp, file))
168
+ .catch(() => fs.promises.rm(tmp, { force: true }).catch(() => { }));
169
+ statusWrites.set(dir, next);
170
+ return next;
171
+ }
172
+ /** The entries of a status file that are whole enough to describe. A truncated write or an older engine can leave others. */
173
+ export function wholeSessions(detail) {
174
+ return (detail ?? []).filter((e) => typeof e.session === "string" &&
175
+ typeof e.tool === "string" &&
176
+ typeof e.role === "string" &&
177
+ typeof e.url === "string" &&
178
+ (e.phase === "running" || e.phase === "idle") &&
179
+ typeof e.since === "string" &&
180
+ Number.isFinite(Date.parse(e.since)));
181
+ }
182
+ /**
183
+ * Where `scenescout watch` should send the browser, or why it cannot.
184
+ *
185
+ * status.json and the token file sit inside the project under test, so a
186
+ * repository can ship its own. Both values are therefore checked for shape
187
+ * before they are put into a URL, and the host is never read from the file.
188
+ */
189
+ export function watchTarget(input) {
190
+ const { status, alive, token } = input;
191
+ if (!status)
192
+ return { problem: "No SceneScout engine has attached to this project yet. Start a run, then run this again." };
193
+ if (status === "unreadable")
194
+ return { problem: "The status file is unreadable or truncated: the engine was probably killed mid-write. Attach again to rewrite it." };
195
+ if (!alive)
196
+ return { problem: `The engine that last ran here (pid ${status.pid ?? "?"}) is not running. The live view exists only while a run is attached.` };
197
+ if (status.live?.error)
198
+ return { problem: `The engine could not open the live view: ${status.live.error}` };
199
+ const port = status.live?.port;
200
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
201
+ return {
202
+ problem: `This engine is not serving a live view: it was started with ${LIVE_ENV}=off, or it is a version from before the live view existed. Restart the MCP server to pick it up.`,
203
+ };
204
+ }
205
+ const clean = token?.trim() ?? "";
206
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(clean))
207
+ return {
208
+ problem: `The live view's token file (${LIVE_TOKEN_FILE}) is missing or unreadable. It is written when a session attaches; if a run is attached and it is still missing, the engine could not write into the project directory.`,
209
+ };
210
+ return { url: `http://127.0.0.1:${port}/${clean}/` };
211
+ }
212
+ const LIVE_CSP = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
213
+ const BOUNDARY = "scenescoutframe";
214
+ /** A thumbnail younger than this is served again instead of taking a new one. */
215
+ export const SHOT_MAX_AGE_MS = 1500;
216
+ /** A viewer this far behind is skipped for a frame rather than buffered without bound. */
217
+ const MAX_VIEWER_BACKLOG_BYTES = 4_000_000;
218
+ export class LiveServer {
219
+ provider;
220
+ now;
221
+ server = null;
222
+ port = 0;
223
+ token = "";
224
+ streams = new Map();
225
+ shots = new Map();
226
+ shotsInFlight = new Map();
227
+ constructor(provider, now = Date.now) {
228
+ this.provider = provider;
229
+ this.now = now;
230
+ }
231
+ async start() {
232
+ if (this.server)
233
+ return { port: this.port, token: this.token };
234
+ this.token = crypto.randomBytes(24).toString("base64url");
235
+ const server = http.createServer((req, res) => {
236
+ this.handle(req, res).catch((err) => {
237
+ console.error(`[scenescout] live view ${(req.url ?? "").split("/").slice(2).join("/")} failed: ${err instanceof Error ? err.message : String(err)}`);
238
+ if (!res.headersSent)
239
+ res.writeHead(500);
240
+ res.end();
241
+ });
242
+ });
243
+ await new Promise((resolve, reject) => {
244
+ // The listener stays: an 'error' with nobody listening (an accept that
245
+ // fails under EMFILE, say) would throw and take the whole engine down.
246
+ server.on("error", (err) => {
247
+ if (this.server === server)
248
+ console.error(`[scenescout] live view server error: ${err.message}`);
249
+ else
250
+ reject(err);
251
+ });
252
+ // Loopback, never 0.0.0.0: the page being shown may be signed in to a real account.
253
+ server.listen(0, "127.0.0.1", () => resolve());
254
+ });
255
+ this.server = server;
256
+ this.port = server.address().port;
257
+ return { port: this.port, token: this.token };
258
+ }
259
+ async stop() {
260
+ const server = this.server;
261
+ this.server = null;
262
+ const stops = [];
263
+ for (const entry of this.streams.values()) {
264
+ for (const viewer of entry.viewers.keys())
265
+ viewer.end();
266
+ if (entry.screencast.state === "live")
267
+ stops.push(entry.screencast.stop().catch(() => { }));
268
+ }
269
+ this.streams.clear();
270
+ this.shots.clear();
271
+ await Promise.all(stops);
272
+ if (server) {
273
+ server.closeAllConnections();
274
+ await new Promise((resolve) => server.close(() => resolve()));
275
+ }
276
+ }
277
+ get address() {
278
+ const addr = this.server?.address();
279
+ return addr ? { host: addr.address, port: addr.port } : null;
280
+ }
281
+ send(res, code, body = "", type = "text/plain; charset=utf-8", extra = {}) {
282
+ res.writeHead(code, {
283
+ "Content-Type": type,
284
+ "Cache-Control": "no-store",
285
+ "X-Content-Type-Options": "nosniff",
286
+ "Referrer-Policy": "no-referrer",
287
+ ...extra,
288
+ });
289
+ res.end(body);
290
+ }
291
+ tokenMatches(candidate) {
292
+ const a = Buffer.from(candidate);
293
+ const b = Buffer.from(this.token);
294
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
295
+ }
296
+ async handle(req, res) {
297
+ // A page on another origin can point a hostname at 127.0.0.1 (DNS
298
+ // rebinding); it cannot make the browser send a loopback Host header.
299
+ const host = req.headers.host ?? "";
300
+ if (host !== `127.0.0.1:${this.port}` && host !== `localhost:${this.port}`)
301
+ return this.send(res, 403, "forbidden");
302
+ if (req.method !== "GET")
303
+ return this.send(res, 405, "the live view is read-only");
304
+ const parts = (req.url ?? "/").split("?")[0].split("/").slice(1);
305
+ // A wrong token gets the same answer as a wrong path.
306
+ if (!this.tokenMatches(parts[0] ?? ""))
307
+ return this.send(res, 404, "not found");
308
+ const [, route, name] = parts;
309
+ // The page's own requests are relative, so it has to be served from a
310
+ // directory. The target is this server's own token, which the request has
311
+ // just matched, never the request's text.
312
+ if (parts.length === 1) {
313
+ res.writeHead(302, { Location: `/${this.token}/`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
314
+ res.end();
315
+ return;
316
+ }
317
+ if (!route)
318
+ return this.send(res, 200, LIVE_PAGE, "text/html; charset=utf-8", { "Content-Security-Policy": LIVE_CSP });
319
+ if (route === "api" && name === "status") {
320
+ const snap = this.provider.snapshot();
321
+ const nowMs = this.now();
322
+ const body = {
323
+ ...snap,
324
+ sessions: snap.sessions.map((s) => ({ ...s, state: classify(s, nowMs), feed: this.provider.activity(s.session, FEED_LINES) })),
325
+ };
326
+ return this.send(res, 200, JSON.stringify(body), "application/json; charset=utf-8");
327
+ }
328
+ if (route === "api" && name === "activity") {
329
+ const session = this.param(req, "session");
330
+ if (session === null)
331
+ return this.send(res, 404, "not found");
332
+ if (!this.hasSession(session))
333
+ return this.send(res, 404, "no such session");
334
+ return this.send(res, 200, JSON.stringify({ session, feed: this.provider.activity(session, FEED_LINES_MAX) }), "application/json; charset=utf-8");
335
+ }
336
+ if (route === "api" && name === "report") {
337
+ const report = this.provider.report();
338
+ if (!report)
339
+ return this.send(res, 404, "no run attached");
340
+ return this.send(res, 200, JSON.stringify(report), "application/json; charset=utf-8");
341
+ }
342
+ if (route === "events" && !name) {
343
+ const wanted = this.param(req, "sessions");
344
+ if (wanted === null)
345
+ return this.send(res, 404, "not found");
346
+ const sessions = [...new Set(wanted.split(","))].filter((s) => s.length > 0 && this.hasSession(s));
347
+ if (sessions.length === 0)
348
+ return this.send(res, 404, "no such session");
349
+ return this.serveEvents(sessions, req, res);
350
+ }
351
+ if ((route === "shot" || route === "stream") && name) {
352
+ const ext = route === "shot" ? ".jpg" : ".mjpg";
353
+ if (!name.endsWith(ext))
354
+ return this.send(res, 404, "not found");
355
+ let session;
356
+ try {
357
+ session = decodeURIComponent(name.slice(0, -ext.length));
358
+ }
359
+ catch {
360
+ return this.send(res, 404, "not found");
361
+ }
362
+ if (!this.hasSession(session))
363
+ return this.send(res, 404, "no such session");
364
+ return route === "shot" ? this.serveShot(session, res) : this.serveStream(session, req, res);
365
+ }
366
+ return this.send(res, 404, "not found");
367
+ }
368
+ /** The value of this server's single query parameter, or null when its escaping is malformed. */
369
+ param(req, key) {
370
+ const raw = (req.url ?? "").split("?")[1] ?? "";
371
+ try {
372
+ return decodeURIComponent(raw.startsWith(`${key}=`) ? raw.slice(key.length + 1) : raw);
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
378
+ /** A viewer may only address a session the board knows. */
379
+ hasSession(name) {
380
+ return this.provider.snapshot().sessions.some((s) => s.session === name);
381
+ }
382
+ /** One screenshot serves every poller for SHOT_MAX_AGE_MS, and concurrent requests share one capture. */
383
+ async takeShot(session) {
384
+ const cached = this.shots.get(session);
385
+ if (cached && this.now() - cached.at < SHOT_MAX_AGE_MS)
386
+ return cached.jpeg;
387
+ const pending = this.shotsInFlight.get(session);
388
+ if (pending)
389
+ return pending;
390
+ const capture = this.provider
391
+ .screenshot(session)
392
+ .catch(() => null)
393
+ .then((jpeg) => {
394
+ if (jpeg)
395
+ this.shots.set(session, { at: this.now(), jpeg });
396
+ return jpeg;
397
+ })
398
+ .finally(() => this.shotsInFlight.delete(session));
399
+ this.shotsInFlight.set(session, capture);
400
+ return capture;
401
+ }
402
+ async serveShot(session, res) {
403
+ const jpeg = await this.takeShot(session);
404
+ if (!jpeg)
405
+ return this.send(res, 503, "no frame available");
406
+ return this.send(res, 200, jpeg, "image/jpeg", { "Content-Length": jpeg.length });
407
+ }
408
+ writeFrame(res, jpeg) {
409
+ if (res.destroyed || res.writableLength > MAX_VIEWER_BACKLOG_BYTES)
410
+ return;
411
+ // One write per part: a part split across writes can reach the viewer half-drawn.
412
+ res.write(Buffer.concat([Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`), jpeg, Buffer.from("\r\n")]));
413
+ }
414
+ writeEvent(res, event, data) {
415
+ if (res.destroyed || res.writableLength > MAX_VIEWER_BACKLOG_BYTES)
416
+ return;
417
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
418
+ }
419
+ /**
420
+ * The session is gone (closed by the agent, or its page died): every viewer
421
+ * is told, and the screencast is stopped. A stream nobody ends would show
422
+ * the last frame under a LIVE badge for as long as the page stayed open.
423
+ */
424
+ dropSession(session) {
425
+ const stream = this.streams.get(session);
426
+ this.shots.delete(session);
427
+ if (!stream)
428
+ return;
429
+ this.streams.delete(session);
430
+ for (const viewer of stream.viewers.values())
431
+ viewer.gone();
432
+ stream.viewers.clear();
433
+ if (stream.screencast.state === "live")
434
+ void stream.screencast.stop().catch(() => { });
435
+ }
436
+ /**
437
+ * Add a viewer to a session's screencast, starting it for the first viewer.
438
+ * Resolves to the function that removes the viewer again, and to null when
439
+ * the session cannot stream. The last viewer to leave stops the screencast,
440
+ * so an unwatched session pays nothing.
441
+ */
442
+ async join(session, res, viewer) {
443
+ let stream = this.streams.get(session);
444
+ if (!stream) {
445
+ const entry = { viewers: new Map(), lastFrame: null, screencast: { state: "starting", ready: Promise.resolve(false) } };
446
+ entry.screencast = {
447
+ state: "starting",
448
+ ready: this.provider
449
+ .startStream(session, (jpeg) => {
450
+ entry.lastFrame = jpeg;
451
+ for (const v of entry.viewers.values())
452
+ v.frame(jpeg);
453
+ }, () => this.dropSession(session))
454
+ .catch(() => null)
455
+ .then((stop) => {
456
+ if (!stop)
457
+ return false;
458
+ // Stopped or dropped while it was starting: nobody is left to watch, so end it now.
459
+ if (this.streams.get(session) !== entry) {
460
+ void stop().catch(() => { });
461
+ return false;
462
+ }
463
+ entry.screencast = { state: "live", stop };
464
+ return true;
465
+ }),
466
+ };
467
+ this.streams.set(session, entry);
468
+ stream = entry;
469
+ }
470
+ const started = stream.screencast.state === "live" ? true : await stream.screencast.ready;
471
+ if (!started) {
472
+ if (this.streams.get(session) === stream && stream.viewers.size === 0)
473
+ this.streams.delete(session);
474
+ return null;
475
+ }
476
+ const entry = stream;
477
+ entry.viewers.set(res, viewer);
478
+ return () => {
479
+ entry.viewers.delete(res);
480
+ if (entry.viewers.size > 0 || this.streams.get(session) !== entry)
481
+ return;
482
+ this.streams.delete(session);
483
+ if (entry.screencast.state === "live")
484
+ void entry.screencast.stop().catch(() => { });
485
+ };
486
+ }
487
+ /**
488
+ * MJPEG: an <img> renders it natively, so anything that shows an image can
489
+ * show one session live. The page itself does not use this: see serveEvents.
490
+ */
491
+ async serveStream(session, req, res) {
492
+ const leave = await this.join(session, res, { frame: (jpeg) => this.writeFrame(res, jpeg), gone: () => res.end() });
493
+ if (!leave)
494
+ return this.send(res, 503, "this session cannot stream");
495
+ if (res.destroyed || req.destroyed)
496
+ return leave();
497
+ res.writeHead(200, {
498
+ "Content-Type": `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
499
+ "Cache-Control": "no-store",
500
+ "X-Content-Type-Options": "nosniff",
501
+ Connection: "close",
502
+ });
503
+ res.on("close", leave);
504
+ // A screencast emits on repaint, so a page that is sitting still would
505
+ // show a late viewer nothing at all. Give them the current picture first.
506
+ const first = this.streams.get(session)?.lastFrame ?? (await this.takeShot(session));
507
+ if (first)
508
+ this.writeFrame(res, first);
509
+ }
510
+ /**
511
+ * Every session the page is watching, over ONE connection, as server-sent
512
+ * events: `frame` carries a session's name and a base64 JPEG, `unavailable`
513
+ * names a session that cannot stream, or that has gone away since. A browser
514
+ * allows about six open connections to one host; with a stream per <img>,
515
+ * "Stream all" on six sessions used them all up and the status poll queued
516
+ * behind them forever, so the page froze at the moment it had the most to show.
517
+ */
518
+ async serveEvents(sessions, req, res) {
519
+ if (res.destroyed || req.destroyed)
520
+ return;
521
+ res.writeHead(200, {
522
+ "Content-Type": "text/event-stream",
523
+ "Cache-Control": "no-store",
524
+ "X-Content-Type-Options": "nosniff",
525
+ });
526
+ res.write("retry: 1000\n\n");
527
+ const leaves = [];
528
+ res.on("close", () => {
529
+ for (const leave of leaves)
530
+ leave();
531
+ });
532
+ for (const session of sessions) {
533
+ const leave = await this.join(session, res, {
534
+ frame: (jpeg) => this.writeEvent(res, "frame", { session, jpeg: jpeg.toString("base64") }),
535
+ gone: () => this.writeEvent(res, "unavailable", { session }),
536
+ });
537
+ if (!leave) {
538
+ this.writeEvent(res, "unavailable", { session });
539
+ continue;
540
+ }
541
+ if (res.destroyed)
542
+ return leave();
543
+ leaves.push(leave);
544
+ const first = this.streams.get(session)?.lastFrame ?? (await this.takeShot(session));
545
+ if (first)
546
+ this.writeEvent(res, "frame", { session, jpeg: first.toString("base64") });
547
+ }
548
+ }
549
+ }
@@ -89,6 +89,9 @@ export function redactSecrets(text) {
89
89
  }
90
90
  return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
91
91
  }
92
+ /** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
93
+ export const JOURNEY_START = "journey:start";
94
+ export const JOURNEY_END = "journey:end";
92
95
  const EMPTY = { version: 1, states: {}, findings: [] };
93
96
  /**
94
97
  * Fold another process's memory into ours, losing nothing from either side.
@@ -305,7 +305,12 @@ export function computeGaps(memory, extras) {
305
305
  gaps.push(`single-role run (${roles.join(", ") || "no role recorded"}) — permission boundaries and role capability gaps are untested`);
306
306
  return gaps;
307
307
  }
308
- export function generateReport(memory, oracleLog, extras) {
308
+ /**
309
+ * The report as the run stands now. `write` is what scout_report does at the
310
+ * end; the live view renders the same document on request without touching
311
+ * the disk, so someone can read it while the run is still going.
312
+ */
313
+ export function generateReport(memory, oracleLog, extras, opts = {}) {
309
314
  const cov = memory.coverage();
310
315
  const findings = [...memory.findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
311
316
  const lines = [];
@@ -487,7 +492,8 @@ export function generateReport(memory, oracleLog, extras) {
487
492
  }
488
493
  const markdown = lines.join("\n");
489
494
  const outPath = path.join(memory.dir, "report.md");
490
- fs.writeFileSync(outPath, markdown);
495
+ if (opts.write !== false)
496
+ fs.writeFileSync(outPath, markdown);
491
497
  // Bounded summary for the tool result: full reports have exceeded client
492
498
  // token limits in real runs (66–72KB observed) — the wire gets the digest,
493
499
  // the disk gets the document.