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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 01fe452: The engine now notices when a value it typed comes back as markup. Any markup-shaped value the agent types — `<script>…</script>`, `<img src=x onerror=…>`, a `<b>` — is remembered by shape, and every page seen afterwards is checked for an element of that shape. When one is found, a `dom_injection` violation (severity high) names the field it was typed into, the page it was typed on, the page it rendered on and the element it became: whoever opens that page runs the input, which is a stored or reflected XSS. The oracle never chooses what to type; the method asks for markup in the fuzzing pass, and the rest is the agent's judgment.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 2deca2a: Two findings that name the same endpoint no longer merge unless both name a failure status for it. A double submit and an accepted bad value can both mention `POST /api/orders` and are two bugs; the second one filed used to be absorbed into the first without a trace.
|
|
12
|
+
- 4e10197: The live view's header keeps its two buttons together when it wraps on a narrow screen, numbers that tick every second (badges, feed times, the session counts) use tabular numerals so they no longer jitter, the feed's journey groups are marked by their tint with a hairline rather than a stripe, and the scrolling panels and text selection take the page's own palette.
|
|
13
|
+
- 978022a: Every record a session creates is now named in the result of the action that created it (`created: /api/things id=44`), including the second and later ones on an endpoint. The state-changing-request notice reports each endpoint once per session, which in safe-write mode hid every creation after the first, so the agent could not tell from the result that it had just made one.
|
|
14
|
+
- dd8927a: `scout_run_plan` now prints a type step's note about the field (such as "replaced existing content") on that step's own line. It used to appear under the previous step, so a reader concluded the wrong field was prefilled.
|
|
15
|
+
|
|
16
|
+
## 1.3.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- a7c978a: `scenescout install` now puts the `scenescout` command on your PATH. Until now neither an `npx` run nor a source checkout left it there, so `scenescout status`, `scenescout doctor` and the other commands the tool itself tells you to run answered "command not found".
|
|
21
|
+
|
|
22
|
+
Run through `npx`, install does `npm install -g` of the version you ran. From a checkout it does `npm link`, so the command always runs what you last built, and From a checkout it runs `npm link`, taking the name over from any other copy the way install already takes over the MCP registration. Run through `npx`, a command that is already there is left alone. On Windows the step prints the command to run by hand. If npm refuses, the step prints the command to run by hand and does not fail the setup. `--no-command` skips it.
|
|
23
|
+
- 195cb59: Watch a run live. `scout_attach` now returns a `Live view:` address, which the agent passes on to you, and `scenescout watch <project>` opens the same page from a terminal. It shows one card per session: the tool it is running, how long it has been there, the page it is on, a thumbnail of that page, a rolling feed of what it just did (each action, its target and how it turned out, read from the same action log a finding's repro trace uses), and a live stream you can switch on per session or for all of them. Opening a card's close-up shows a longer stretch of that feed beside the session's brief: the task the agent gave it at `scout_attach {task}`, and the goal of the journey it is on right now. Actions of one journey share a tint in the feed, and pointing at a group shows the goal those actions served. The Report button shows the run's report as it stands, rendered from the current state without writing it, so it can be read while the run is still going. It works for headless runs, and a session whose call is still running past its own tool's watchdog budget is marked as stuck.
|
|
24
|
+
|
|
25
|
+
`status.json` now describes every session instead of the last one to write, and `scenescout status` prints a line for each.
|
|
26
|
+
|
|
27
|
+
The live view is served on `127.0.0.1` only, behind a per-process token, answers GET and nothing else, and writes no frame to disk ([ADR 7](docs/adr/0007-the-live-view-is-local-read-only-and-leaves-nothing-behind.md)). A stream runs only while someone is watching it. Set `SCENESCOUT_LIVE=off` to keep the engine from opening the port. The engine now also shuts down, closing its browsers and removing the token file, when its client closes the connection instead of sending a signal.
|
|
28
|
+
|
|
3
29
|
## 1.2.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ This is a real run against the small demo app bundled in this repository. The ap
|
|
|
35
35
|
|
|
36
36
|
<p align="center"><img src="examples/screenshots/dashboard-annotated.png" alt="The demo app's dashboard with two defects outlined in red: 1, a yellow badge covering the All orders button; 2, the weekly chart image failing to load" width="760" /></p>
|
|
37
37
|
|
|
38
|
-
**The broken chart is the demo app's bug, not this page's** — it is one of the
|
|
38
|
+
**The broken chart is the demo app's bug, not this page's** — it is one of the twelve findings SceneScout filed, next to the badge sitting on a button. The red callouts were added for this README; the [unmarked screenshots](examples/screenshots/) are the ones the engine took.
|
|
39
39
|
|
|
40
40
|
An excerpt of the report it wrote — [read the whole thing](examples/report.md):
|
|
41
41
|
|
|
@@ -46,13 +46,16 @@ An excerpt of the report it wrote — [read the whole thing](examples/report.md)
|
|
|
46
46
|
> **🔴 [HIGH] Filtering orders by Archived fails, and the page shows an empty table instead of an error**
|
|
47
47
|
> Evidence: `GET /api/orders?status=archived → HTTP 500`
|
|
48
48
|
>
|
|
49
|
+
> **🔴 [HIGH] A clerk can approve an order by calling the endpoint the page hides from them**
|
|
50
|
+
> Evidence: `POST /api/orders/1037/approve 200 as clerk; POST /api/orders/1038/reject 403 as clerk` — the button was hidden, the server did not agree.
|
|
51
|
+
>
|
|
49
52
|
> **🟠 [MEDIUM] The "New: bulk import" badge sits on top of the All orders button** *(callout 1)*
|
|
50
53
|
> Evidence: `"All orders" overlaps "New: bulk import" (81%)` — measured from layout boxes, no screenshot needed.
|
|
51
54
|
>
|
|
52
55
|
> **🟡 [LOW] The dashboard chart image is missing** *(callout 2)*
|
|
53
56
|
> Evidence: `GET /img/weekly-chart.png → HTTP 404`
|
|
54
57
|
>
|
|
55
|
-
> **Gap ledger — what was NOT tested:**
|
|
58
|
+
> **Gap ledger — what was NOT tested:** 9/12 visited routes never design-audited · single-role run, so permission boundaries are untested
|
|
56
59
|
|
|
57
60
|
Every finding comes with a repro trace and a Playwright regression-test skeleton. To try it yourself, clone this repository, run `npm run demo:serve`, then `/scenescout --url http://127.0.0.1:4173` — see [demo-app/](demo-app/). Its README lists every seeded defect and which oracle catches it.
|
|
58
61
|
|
|
@@ -136,9 +139,10 @@ Then download the browser once with `npx -y scenescout install --browser-only`.
|
|
|
136
139
|
|
|
137
140
|
1. puts the `/scenescout` skill into `~/.claude/skills/` (or `$CLAUDE_CONFIG_DIR/skills/`) — a `scenescout` folder it didn't create is moved aside to a `.backup-…` copy, never deleted,
|
|
138
141
|
2. downloads the browser SceneScout drives (skipped if you already have it). By default that is Chromium, as two builds: the full browser for headed runs and the headless shell every other run uses. [Choose something else](#-choosing-browsers) with `--browsers`,
|
|
139
|
-
3. registers the MCP server with Claude Code at user scope. Run through `npx`, the launcher is `npx -y scenescout serve`, with the absolute path of `npx` where one sits beside node, so it works under nvm/fnm. From a clone or a global install it is the absolute node path plus that install's `dist/mcp-server.js
|
|
142
|
+
3. registers the MCP server with Claude Code at user scope. Run through `npx`, the launcher is `npx -y scenescout serve`, with the absolute path of `npx` where one sits beside node, so it works under nvm/fnm. From a clone or a global install it is the absolute node path plus that install's `dist/mcp-server.js`,
|
|
143
|
+
4. puts the `scenescout` command on your PATH, so `scenescout status`, `scenescout watch` and `scenescout doctor` work from any terminal. Run through `npx`, that is `npm install -g` of the version you just ran; from a clone it is `npm link`, so the command always runs what you last built. If npm refuses (a system-wide node usually needs `sudo` for this), the step prints the command to run by hand and the rest of the setup still counts as done: `npx -y scenescout <command>` works without it.
|
|
140
144
|
|
|
141
|
-
Re-run it any time: after moving the folder or switching node versions it refreshes the stored paths. It exits non-zero if
|
|
145
|
+
Re-run it any time: after moving the folder or switching node versions it refreshes the stored paths. It exits non-zero if a step the tool depends on failed, so it is safe to chain. Opt out of a step with `--no-register`, `--skip-browser` or `--no-command`.
|
|
142
146
|
|
|
143
147
|
If `claude` isn't on the PATH of the shell you ran it from, it prints the registration command instead of running it:
|
|
144
148
|
|
|
@@ -179,6 +183,32 @@ The agent scans the project (if there is one), attaches read-only, explores, and
|
|
|
179
183
|
|
|
180
184
|
---
|
|
181
185
|
|
|
186
|
+
## 📺 Watching a run live
|
|
187
|
+
|
|
188
|
+
When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. From a terminal, `scenescout watch` opens the same page. There is one card per session:
|
|
189
|
+
|
|
190
|
+
<p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of six parallel agents against the demo app: one card per session, each with its role, the tool it is running and for how long, the page it is on, a thumbnail, and a feed of the actions it just took" width="880" /></p>
|
|
191
|
+
|
|
192
|
+
- **What it is doing:** the tool it is running and for how long, the page it is on, and a thumbnail of that page. This works for headless runs too, which have no window to look at.
|
|
193
|
+
- **What it just did:** a rolling feed of its actions, each with its target and how it turned out, with failures in red. It is the same trail a finding's repro trace uses. The engine never sees the agent's reasoning, so this is what the session *did*, not what it thought.
|
|
194
|
+
- **Stuck, not slow:** a call still running past its own tool's watchdog budget turns the card red, so a wedged session is visible without asking. A crawl legitimately runs for minutes; it is judged against the crawl's budget, not a click's.
|
|
195
|
+
- **Live stream:** switch it on for one card, or for all of them. Click a thumbnail for a close-up.
|
|
196
|
+
- **The report, as it stands:** the Report button in the top bar shows the same document `scout_report` writes at the end, rendered from the run's current state, so findings can be read while the agents are still working.
|
|
197
|
+
- **What it is for:** the close-up puts the feed beside the session's brief: the task the agent gave it when it attached (`scout_attach {task}`), and the goal of the journey it is on right now (`scout_journey`). Actions of one journey share a tint in the feed; point at a group and the brief shows the goal those actions served.
|
|
198
|
+
|
|
199
|
+
<p align="center"><img src="examples/screenshots/live-view-closeup.png" alt="A close-up of one session: its page streaming live, the feed of its recent actions grouped by journey, and beside it the task and current objective" width="880" /></p>
|
|
200
|
+
|
|
201
|
+
<p align="center"><img src="examples/screenshots/live-view-report.png" alt="The report opened from the live view's top bar while the run is still going: summary table, gap ledger and the findings filed so far" width="880" /></p>
|
|
202
|
+
|
|
203
|
+
The view is served on `127.0.0.1` only, behind a token that changes every time the engine starts. It answers `GET` and nothing else, so a viewer can watch a run but not act in it, and no frame is ever written to disk ([ADR 7](docs/adr/0007-the-live-view-is-local-read-only-and-leaves-nothing-behind.md)). A stream runs only while someone is watching it. `SCENESCOUT_LIVE=off` keeps the port closed.
|
|
204
|
+
|
|
205
|
+
**Try it with parallel agents.** The demo app has three roles and several separate areas, so a run can be split between agents. Start it with `npm run demo:serve`, then ask your agent to explore it with several agents in parallel, one role and one area each. The pictures above come from a run of six. Two things keep a parallel run efficient:
|
|
206
|
+
|
|
207
|
+
- **Each agent opens its own session when it starts and closes it when it is done.** An agent waiting for its turn then holds no browser. Opening every session up front leaves browsers idling while the machine runs out of memory for the agents that are working.
|
|
208
|
+
- **Run about as many agents at once as your machine has cores, less two.** Each one drives a real browser.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
182
212
|
## 🔄 How a run works
|
|
183
213
|
|
|
184
214
|
One curiosity loop, repeated — breadth first, then judgment where it matters:
|
|
@@ -457,7 +487,8 @@ The CLI is also useful on its own:
|
|
|
457
487
|
|
|
458
488
|
```bash
|
|
459
489
|
npx -y scenescout scan <path> # project discovery: framework, routes, saved logins
|
|
460
|
-
npx -y scenescout status <path> # what a running engine is doing right now
|
|
490
|
+
npx -y scenescout status <path> # what every session of a running engine is doing right now
|
|
491
|
+
npx -y scenescout watch <path> # the same, live in your browser, with each session's page
|
|
461
492
|
```
|
|
462
493
|
|
|
463
494
|
---
|
|
@@ -475,6 +506,7 @@ src/
|
|
|
475
506
|
probes.ts in-page scroll + overlay + focus probes (needs a browser too)
|
|
476
507
|
fingerprint.ts route + element-set identity (state hashing)
|
|
477
508
|
oracles.ts console/page/network/HTTP error detection
|
|
509
|
+
injection.ts the DOM-injection oracle's rules (what to watch for, how to find it)
|
|
478
510
|
policy.ts the write-policy safety net
|
|
479
511
|
ownership.ts safe-write: which records did this run create?
|
|
480
512
|
uploads.ts disk uploads, fenced to the project by real path
|
|
@@ -514,12 +546,12 @@ Working on SceneScout itself is the only reason to clone it:
|
|
|
514
546
|
git clone https://github.com/brunoboto96/SceneScout.git scenescout && cd scenescout
|
|
515
547
|
npm install # installs dependencies and builds
|
|
516
548
|
npm run setup # same as `scenescout install`, but registers THIS checkout (the skill is linked, so edits are live)
|
|
517
|
-
npm test # build +
|
|
549
|
+
npm test # build + 14 suites: scan, oracle, policy, fixture, dispatch, design,
|
|
518
550
|
# contract, memory, install, hygiene, smoke, mcp-check
|
|
519
551
|
npm run demo # regenerate examples/ from the demo app
|
|
520
552
|
```
|
|
521
553
|
|
|
522
|
-
Contributing? Start with [VISION.md](VISION.md) (what is in scope) and [CONTRIBUTING.md](CONTRIBUTING.md) (how changes land), then see [
|
|
554
|
+
Contributing? Start with [VISION.md](VISION.md) (what is in scope) and [CONTRIBUTING.md](CONTRIBUTING.md) (how changes land), then see [AGENTS.md](AGENTS.md) for the house rules — chiefly: bug fixes need a regression test at the cheapest layer that can fail, keep the repo project-agnostic (ADR 6), and `npm test` must pass.
|
|
523
555
|
|
|
524
556
|
## 🔐 Security
|
|
525
557
|
|
|
@@ -535,7 +567,7 @@ Found a way past the write policy, or another security problem? Please report it
|
|
|
535
567
|
- **Structured render-state, not pixels.** Element lists with geometry; screenshots reserved for pixel-native residue (canvas, rendering glitches). Images that failed to load are reported from the DOM, including ones whose URL answered 200 with something that is not an image.
|
|
536
568
|
- **Diff snapshots with stable refs.** Re-snapshots return only what changed (10.7 kB → 0.7 kB on a 130-element page); old refs stay valid.
|
|
537
569
|
- **Geometry oracles.** Overlap and off-screen defects computed from layout boxes.
|
|
538
|
-
- **Oracles after every action.** Console errors, page errors, failed requests, HTTP 4xx/5xx drained into every tool result.
|
|
570
|
+
- **Oracles after every action.** Console errors, page errors, failed requests, HTTP 4xx/5xx drained into every tool result — and DOM injection: a markup-shaped value the agent typed that later renders as an element on any page (stored or reflected XSS).
|
|
539
571
|
- **Multi-role, genuinely concurrent.** Commands to *different* sessions run in parallel; safe-write ownership is shared, so role A can create what role B approves. The report renders a role capability matrix.
|
|
540
572
|
- **Task ease, not just correctness.** `scout_journey` measures interaction cost, distinct screens, path, and backtracks.
|
|
541
573
|
- **Design audit with page scores.** Two tiers (⚠ measurable defects / → craft suggestions incl. AI-slop tells), per-page 0–100 score persisted per route, plus an automatic overlay/modal probe on every snapshot. Shared shell scored once, separately.
|
|
@@ -545,6 +577,6 @@ Found a way past the write policy, or another security problem? Please report it
|
|
|
545
577
|
- **A trustworthy gap ledger.** Entries must be actionable (a search box or wizard sub-step isn't "form filled but never submitted"); API/download URLs never enter the route contract.
|
|
546
578
|
- **Honest reporting.** Shared chrome counted once, stale scores marked, role matrix compares only roles that actually attempted a route.
|
|
547
579
|
- **Cross-run written knowledge.** `scout_note` curates `.scenescout/ASSUMPTIONS.md` — app model, personas, constraints, risks — in prose.
|
|
548
|
-
- **Daemon-grade robustness.** Per-tool watchdogs, orphaned-browser reaping, bounded teardown, live status via `scenescout status <project
|
|
580
|
+
- **Daemon-grade robustness.** Per-tool watchdogs, orphaned-browser reaping, bounded teardown, live status via `scenescout status <project>`, and a live view of every session's page: the agent gives you its address when it attaches, or run `scenescout watch <project>` (loopback only, read-only, nothing written to disk: [ADR 7](docs/adr/0007-the-live-view-is-local-read-only-and-leaves-nothing-behind.md)).
|
|
549
581
|
|
|
550
582
|
</details>
|
package/dist/browsers.js
CHANGED
|
@@ -140,6 +140,17 @@ export function installCommandFor(target, fromCheckout) {
|
|
|
140
140
|
export function serviceWorkerPolicy(engine) {
|
|
141
141
|
return engine === "chromium" ? "allow" : "block";
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* How the live view gets frames of a page.
|
|
145
|
+
*
|
|
146
|
+
* Chromium can push a frame on every repaint through the DevTools protocol,
|
|
147
|
+
* which costs nothing while the page sits still. Firefox and WebKit have no
|
|
148
|
+
* such channel in the driver, so a stream there is a screenshot taken on a
|
|
149
|
+
* timer for as long as somebody is watching.
|
|
150
|
+
*/
|
|
151
|
+
export function screencastSupport(engine) {
|
|
152
|
+
return engine === "chromium" ? "cdp" : "poll";
|
|
153
|
+
}
|
|
143
154
|
/**
|
|
144
155
|
* The key that moves keyboard focus to the next control, links and buttons
|
|
145
156
|
* included. WebKit on macOS follows Safari: plain Tab stops only at text
|
package/dist/cli.js
CHANGED
|
@@ -15,8 +15,9 @@ import path from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
|
|
17
17
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
18
|
-
import { diagnose, installSkill, launchCommand, manualRegisterCommand, registerMcp, resolveClaudeDir, spawnRunner } from "./installer.js";
|
|
18
|
+
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
19
19
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
20
|
+
import { formatStatus, localClock, LIVE_TOKEN_FILE, watchTarget } from "./engine/live.js";
|
|
20
21
|
import { formatScan, scanProject } from "./scan.js";
|
|
21
22
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const packageRoot = path.resolve(here, "..");
|
|
@@ -27,7 +28,8 @@ Usage:
|
|
|
27
28
|
scenescout scan <projectPath> Discover framework, routes, auth states
|
|
28
29
|
scenescout serve Run the MCP server (stdio)
|
|
29
30
|
scenescout install One-step setup: skill + Chromium + MCP registration
|
|
30
|
-
|
|
31
|
+
It also puts the \`scenescout\` command on your PATH.
|
|
32
|
+
(--skip-browser, --no-register, --no-command to opt out of a step;
|
|
31
33
|
--browser-only when the skill and server came from a plugin;
|
|
32
34
|
--browsers <list> to choose what to download: chromium (default),
|
|
33
35
|
chromium-headless-shell, firefox, webkit, all — comma-separated)
|
|
@@ -37,52 +39,67 @@ Usage:
|
|
|
37
39
|
scenescout doctor Check the setup and print the fix for anything missing
|
|
38
40
|
(--engine: only node, the build and the browser — for plugin
|
|
39
41
|
installs and other MCP clients)
|
|
40
|
-
scenescout status [projectPath] What is the engine doing right now? (
|
|
42
|
+
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
43
|
+
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
44
|
+
of its page, and a live stream you can switch on per session
|
|
45
|
+
(--no-open to print the address only)
|
|
41
46
|
`);
|
|
42
47
|
process.exit(exitCode);
|
|
43
48
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A project last touched before the rename (or one a pre-rename engine is using
|
|
51
|
+
* right now) still keeps its status under the legacy directory.
|
|
52
|
+
*/
|
|
53
|
+
function statusDir(projectPath) {
|
|
54
|
+
return ([MEMORY_DIRNAME, LEGACY_MEMORY_DIRNAME]
|
|
49
55
|
.map((name) => path.join(projectPath, name))
|
|
50
|
-
.find((candidate) => fs.existsSync(path.join(candidate, "status.json"))) ?? path.join(projectPath, MEMORY_DIRNAME);
|
|
56
|
+
.find((candidate) => fs.existsSync(path.join(candidate, "status.json"))) ?? path.join(projectPath, MEMORY_DIRNAME));
|
|
57
|
+
}
|
|
58
|
+
/** null when there is no file; "unreadable" when there is one and it does not parse. */
|
|
59
|
+
function readStatusFile(dir) {
|
|
51
60
|
const statusPath = path.join(dir, "status.json");
|
|
52
|
-
if (!fs.existsSync(statusPath))
|
|
53
|
-
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
let st;
|
|
61
|
+
if (!fs.existsSync(statusPath))
|
|
62
|
+
return null;
|
|
57
63
|
try {
|
|
58
|
-
|
|
64
|
+
return JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
59
65
|
}
|
|
60
66
|
catch {
|
|
61
67
|
// status.json is written fire-and-forget on every tool call, so a process
|
|
62
68
|
// killed mid-write leaves a truncated file. That is a diagnosable state,
|
|
63
69
|
// not a reason for the diagnostic tool itself to crash.
|
|
64
|
-
|
|
70
|
+
return "unreadable";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function pidAlive(pid) {
|
|
74
|
+
if (!pid)
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// EPERM means the process EXISTS but belongs to another user — only
|
|
82
|
+
// ESRCH actually means "no such process". Treating both as dead reported
|
|
83
|
+
// a live engine as stale.
|
|
84
|
+
return err?.code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Realtime observability: read the status file + recent action log the running engine maintains. */
|
|
88
|
+
function status(projectPath) {
|
|
89
|
+
const dir = statusDir(projectPath);
|
|
90
|
+
const statusPath = path.join(dir, "status.json");
|
|
91
|
+
const st = readStatusFile(dir);
|
|
92
|
+
if (st === null) {
|
|
93
|
+
console.log(`No status file at ${statusPath} — no SceneScout engine has attached to this project (or it predates v0.8).`);
|
|
65
94
|
return;
|
|
66
95
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
process.kill(st.pid, 0);
|
|
71
|
-
alive = true;
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
// EPERM means the process EXISTS but belongs to another user — only
|
|
75
|
-
// ESRCH actually means "no such process". Treating both as dead reported
|
|
76
|
-
// a live engine as stale.
|
|
77
|
-
alive = err?.code === "EPERM";
|
|
78
|
-
}
|
|
96
|
+
if (st === "unreadable") {
|
|
97
|
+
console.log(`Status file at ${statusPath} is unreadable or truncated — the engine was probably killed mid-write. Re-attach to refresh it.`);
|
|
98
|
+
return;
|
|
79
99
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
console.log(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
|
|
84
|
-
if (st.url)
|
|
85
|
-
console.log(`URL: ${st.url}`);
|
|
100
|
+
const alive = pidAlive(st.pid);
|
|
101
|
+
for (const line of formatStatus(st, alive, Date.now()))
|
|
102
|
+
console.log(line);
|
|
86
103
|
// Recent actions from the newest session log — the "what has it been doing" trail.
|
|
87
104
|
const logs = fs.existsSync(dir)
|
|
88
105
|
? fs
|
|
@@ -108,7 +125,7 @@ function status(projectPath) {
|
|
|
108
125
|
for (const line of lines) {
|
|
109
126
|
try {
|
|
110
127
|
const e = JSON.parse(line);
|
|
111
|
-
console.log(` ${e.at
|
|
128
|
+
console.log(` ${localClock(e.at)} ${e.action}${e.target ? ` ${e.target}` : ""} @ ${e.url}`);
|
|
112
129
|
}
|
|
113
130
|
catch {
|
|
114
131
|
/* skip malformed line */
|
|
@@ -116,6 +133,43 @@ function status(projectPath) {
|
|
|
116
133
|
}
|
|
117
134
|
}
|
|
118
135
|
}
|
|
136
|
+
/** Open the engine's live view. The engine serves it; this only finds the address and hands it to a browser. */
|
|
137
|
+
function watch(projectPath, open) {
|
|
138
|
+
const dir = statusDir(projectPath);
|
|
139
|
+
const st = readStatusFile(dir);
|
|
140
|
+
let token = null;
|
|
141
|
+
try {
|
|
142
|
+
token = fs.readFileSync(path.join(dir, LIVE_TOKEN_FILE), "utf8");
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// watchTarget explains a missing token in context.
|
|
146
|
+
}
|
|
147
|
+
const target = watchTarget({ status: st, alive: st !== null && st !== "unreadable" && pidAlive(st.pid), token });
|
|
148
|
+
if ("problem" in target) {
|
|
149
|
+
console.log(target.problem);
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(`Live view: ${target.url}`);
|
|
154
|
+
console.log("It is served on this machine only, and the address holds its access token: treat it like a password.");
|
|
155
|
+
if (!open)
|
|
156
|
+
return;
|
|
157
|
+
const { command, args } = browserOpener(target.url);
|
|
158
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
159
|
+
if (result.error || result.status !== 0)
|
|
160
|
+
console.log("Could not open a browser from here. Open the address above yourself.");
|
|
161
|
+
}
|
|
162
|
+
/** The platform's own "open this URL" command. */
|
|
163
|
+
function browserOpener(url) {
|
|
164
|
+
switch (process.platform) {
|
|
165
|
+
case "darwin":
|
|
166
|
+
return { command: "open", args: [url] };
|
|
167
|
+
case "win32":
|
|
168
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
169
|
+
default:
|
|
170
|
+
return { command: "xdg-open", args: [url] };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
119
173
|
/** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
|
|
120
174
|
async function presentBrowsers() {
|
|
121
175
|
const executables = { chromium: null, firefox: null, webkit: null };
|
|
@@ -303,8 +357,39 @@ async function install(flags) {
|
|
|
303
357
|
}
|
|
304
358
|
}
|
|
305
359
|
}
|
|
360
|
+
// `scenescout status`, `watch` and `doctor` are typed by a person, and neither
|
|
361
|
+
// a checkout nor an npx run leaves the command on PATH. Not having it costs
|
|
362
|
+
// convenience, never a working setup, so this step reports and does not fail.
|
|
363
|
+
let cli = isEphemeralRoot(packageRoot) ? `npx -y ${CLI_NAME}` : `node ${path.join(packageRoot, "dist", "cli.js")}`;
|
|
364
|
+
if (browserOnly) {
|
|
365
|
+
// a plugin install has no package of its own to put on PATH
|
|
366
|
+
}
|
|
367
|
+
else if (flags.includes("--no-command")) {
|
|
368
|
+
console.log(`· Putting \`${CLI_NAME}\` on PATH skipped (--no-command). Until then the command is: ${cli}`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const onPath = () => findOnUserPath({ names: process.platform === "win32" ? [`${CLI_NAME}.cmd`] : [CLI_NAME], pathValue: process.env.PATH ?? "" });
|
|
372
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
373
|
+
const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: pkg.version, resolved: onPath(), platform: process.platform }), spawnRunner);
|
|
374
|
+
if (done.status === "present") {
|
|
375
|
+
cli = CLI_NAME;
|
|
376
|
+
console.log(`✓ \`${CLI_NAME}\` command already on PATH: ${done.at}`);
|
|
377
|
+
}
|
|
378
|
+
else if (done.status === "installed") {
|
|
379
|
+
const at = onPath();
|
|
380
|
+
if (at)
|
|
381
|
+
cli = CLI_NAME;
|
|
382
|
+
const what = done.how === "link" ? "linked to this checkout, so it runs whatever was last built" : "installed globally";
|
|
383
|
+
console.log(at
|
|
384
|
+
? `✓ \`${CLI_NAME}\` command ${what}: ${at}${done.replaced ? ` (it replaces ${done.replaced})` : ""}`
|
|
385
|
+
: `· \`${CLI_NAME}\` was ${what}, but npm's global bin directory is not on this shell's PATH. Add it (\`npm prefix -g\` names it; the commands are in its bin folder), or use: ${cli}`);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
console.log(`· \`${CLI_NAME}\` was not put on PATH (${done.detail}). To do it by hand: ${done.manual}\n Until then the command is: ${cli}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
306
391
|
if (failed) {
|
|
307
|
-
console.log(`\nSetup is incomplete — fix the lines marked ✗ or · above, then run:
|
|
392
|
+
console.log(`\nSetup is incomplete — fix the lines marked ✗ or · above, then run: ${cli} doctor${forClaude ? "" : " --engine"}`);
|
|
308
393
|
process.exitCode = 1;
|
|
309
394
|
return;
|
|
310
395
|
}
|
|
@@ -317,7 +402,7 @@ async function install(flags) {
|
|
|
317
402
|
// Telling someone to restart a client nothing was registered with sends them looking for a server that is not there.
|
|
318
403
|
if (others.length > 0 && !flags.includes("--no-register"))
|
|
319
404
|
console.log(`\n${firstMessageHint(others)}`);
|
|
320
|
-
console.log(`Something off? Run:
|
|
405
|
+
console.log(`Something off? Run: ${cli} doctor${forClaude ? "" : " --engine"}`);
|
|
321
406
|
}
|
|
322
407
|
async function doctor(flags) {
|
|
323
408
|
const checks = diagnose({
|
|
@@ -387,6 +472,11 @@ try {
|
|
|
387
472
|
status(path.resolve(args[0] ?? process.cwd()));
|
|
388
473
|
break;
|
|
389
474
|
}
|
|
475
|
+
case "watch": {
|
|
476
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
477
|
+
watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
390
480
|
default:
|
|
391
481
|
usage();
|
|
392
482
|
}
|