scenescout 3.13.1 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * scenescout doctor Check every piece of the setup and say how to fix what is missing
9
9
  * scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
10
10
  * scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
11
+ * scenescout login <url> --role r Sign in once in a visible browser and save it as a named role
11
12
  */
12
13
  import { spawnSync } from "node:child_process";
13
14
  import fs from "node:fs";
@@ -20,11 +21,15 @@ import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithC
20
21
  import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
21
22
  import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
22
23
  import { httpClient, runCi } from "./ci-run.js";
24
+ import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
25
+ import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
26
+ import { parseLoginArgs } from "./engine/profiles.js";
23
27
  import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
24
28
  import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
25
29
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
26
30
  import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
27
31
  import { formatScan, scanProject } from "./scan.js";
32
+ import { dispatch } from "./commands.js";
28
33
  const here = path.dirname(fileURLToPath(import.meta.url));
29
34
  const packageRoot = path.resolve(here, "..");
30
35
  /** This package's version, as published. */
@@ -57,6 +62,9 @@ Usage:
57
62
  --ignore rule,rule; --storage-state file to check signed in;
58
63
  --project dir (default: here); --out dir (default: .scenescout/check);
59
64
  --browser chromium|firefox|webkit;
65
+ --action-timeout-ms N (default 5000), --nav-timeout-ms N (default 20000;
66
+ 15000 per crawled route): raise on a loaded runner, or set
67
+ SCENESCOUT_ACTION_TIMEOUT_MS / SCENESCOUT_NAV_TIMEOUT_MS;
60
68
  --flows dir|off: replay the flows saved there (default: .scenescout/flows);
61
69
  --retest on|off: re-test open findings a page load reproduces (default on);
62
70
  --flow-writes never|allow: never (default) replays flows under observe's
@@ -84,8 +92,28 @@ Usage:
84
92
  destructive only with --allow-destructive as well); --level minimal|medium|
85
93
  extensive (default medium); --focus "an area or flow";
86
94
  --storage-state file; --browser chromium|firefox|webkit;
87
- --project dir (default: here); --out dir (default: .scenescout/ci))
95
+ --action-timeout-ms N, --nav-timeout-ms N: as for check;
96
+ --project dir (default: here); --out dir (default: .scenescout/ci);
97
+ --show "the Save button": instead of exploring, capture that element as a PNG
98
+ under shots/; --compare-url https://…: with --show, capture it there too and
99
+ write a diff picture)
88
100
  Exit code: 0 the run ran (findings never change it), 2 could not run.
101
+ scenescout login <url> --role <name>
102
+ Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
103
+ press Enter in this terminal to save the session as that role's profile, in
104
+ .scenescout/auth/<name>.json (owner-only; never printed, never committed).
105
+ Closing the window or Ctrl+C saves nothing. Agents then attach with
106
+ scout_attach { role: "<name>" }, as many sessions as they like from one login.
107
+ (--project dir (default: here); --browser chromium|firefox|webkit)
108
+ scenescout login <url> --role <name> --script
109
+ For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
110
+ and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
111
+ otpauth:// URI), then save the profile as above. A test user only. No value
112
+ is ever printed. Exit 0 signed in and saved, 1 not.
113
+ (--success-url text|url; --success-selector css; --username-selector,
114
+ --password-selector, --otp-selector, --submit-selector css; each of these also
115
+ from SCENESCOUT_LOGIN_<FLAG>, e.g. SCENESCOUT_LOGIN_SUCCESS_URL;
116
+ --timeout seconds (default 60))
89
117
  scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
90
118
  scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
91
119
  of its page, and a live stream you can switch on per session
@@ -268,13 +296,6 @@ function flagValue(flags, name) {
268
296
  const next = flags[at + 1];
269
297
  return next === undefined || next.startsWith("--") ? "" : next;
270
298
  }
271
- function browsersFlag(flags) {
272
- // `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
273
- const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
274
- if (slip)
275
- throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
276
- return flagValue(flags, "--browsers");
277
- }
278
299
  /** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
279
300
  function codeOnPath() {
280
301
  const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
@@ -322,7 +343,7 @@ async function install(flags) {
322
343
  // only thing it cannot bring is the browser download.
323
344
  const browserOnly = flags.includes("--browser-only");
324
345
  // Read the choice before doing anything, so a typo costs nothing.
325
- const selection = parseBrowserSelection(browsersFlag(flags));
346
+ const selection = parseBrowserSelection(flagValue(flags, "--browsers"));
326
347
  if ("error" in selection)
327
348
  throw new Error(selection.error);
328
349
  const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
@@ -493,8 +514,6 @@ async function doctor(flags) {
493
514
  }
494
515
  /** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
495
516
  async function check(args) {
496
- if (args.includes("--help") || args.includes("-h"))
497
- usage(0);
498
517
  const parsed = parseCheckArgs(args, process.cwd());
499
518
  if (!parsed.ok) {
500
519
  console.error(`scenescout check: ${parsed.error}`);
@@ -557,8 +576,6 @@ async function check(args) {
557
576
  }
558
577
  /** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
559
578
  async function ci(args) {
560
- if (args.includes("--help") || args.includes("-h"))
561
- usage(0);
562
579
  const secrets = secretValues(process.env);
563
580
  const say = (line) => console.log(redactKeys(line, secrets));
564
581
  const fail = (message) => {
@@ -598,6 +615,44 @@ async function ci(args) {
598
615
  }
599
616
  process.exit(exitCode);
600
617
  }
618
+ /** `scenescout login`: exit 0 saved, 1 nothing saved. */
619
+ async function login(args) {
620
+ const parsed = parseLoginArgs(args, process.cwd());
621
+ if (!parsed.ok) {
622
+ console.error(`scenescout login: ${parsed.error}`);
623
+ process.exit(1);
624
+ }
625
+ const options = parsed.options;
626
+ if (options.script) {
627
+ // Everything is checked before a browser launches; each problem names a variable, never a value.
628
+ const read = readScriptedLogin(options.script, process.env);
629
+ if (!read.ok) {
630
+ for (const e of read.errors)
631
+ console.error(`scenescout login: ${e}`);
632
+ process.exit(1);
633
+ }
634
+ const redactor = credentialRedactor(read.config, process.env[LOGIN_ENV.totpSecret]);
635
+ try {
636
+ const saved = await runScriptedLogin(options, read.config, redactor, (line) => console.log(line));
637
+ console.log(redactor.redact(savedLine(options, saved)));
638
+ }
639
+ catch (err) {
640
+ // Redacted again here: an error thrown by the browser itself has not been through the run's redaction.
641
+ console.error(redactor.redact(`scenescout login: ${err instanceof Error ? err.message : String(err)}`));
642
+ process.exit(1);
643
+ }
644
+ process.exit(0);
645
+ }
646
+ try {
647
+ const saved = await runLogin(options, (line) => console.log(line));
648
+ console.log(savedLine(options, saved));
649
+ }
650
+ catch (err) {
651
+ console.error(`scenescout login: ${err instanceof Error ? err.message : String(err)}`);
652
+ process.exit(1);
653
+ }
654
+ process.exit(0);
655
+ }
601
656
  const [, , command, ...args] = process.argv;
602
657
  // A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
603
658
  // path that does not exist and `status` on a half-written status.json both
@@ -605,57 +660,35 @@ const [, , command, ...args] = process.argv;
605
660
  // one line the user needs. `serve` is deliberately outside this: it hands off
606
661
  // to the MCP server, whose own transport owns error reporting from then on.
607
662
  try {
608
- switch (command) {
609
- // Asking for help is not an error; scripts and shells treat a non-zero
610
- // exit as one.
611
- case "--help":
612
- case "-h":
613
- case "help":
614
- usage(0);
615
- case "--version":
616
- case "-v": {
617
- console.log(packageVersion());
618
- break;
619
- }
620
- case "scan": {
621
- const target = args[0];
622
- if (!target)
623
- usage();
624
- console.log(formatScan(scanProject(target)));
625
- break;
626
- }
627
- case "serve": {
628
- await import("./mcp-server.js");
629
- break;
630
- }
631
- case "install": {
632
- await install(args);
633
- break;
634
- }
635
- case "doctor": {
636
- await doctor(args);
637
- break;
638
- }
639
- case "check": {
640
- await check(args);
641
- break;
642
- }
643
- case "ci": {
644
- await ci(args);
645
- break;
646
- }
647
- case "status": {
648
- status(path.resolve(args[0] ?? process.cwd()));
649
- break;
650
- }
651
- case "watch": {
652
- const positional = args.filter((a) => !a.startsWith("--"));
653
- watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
654
- break;
655
- }
656
- default:
657
- usage();
658
- }
663
+ await dispatch(command, args, {
664
+ usage,
665
+ version: () => console.log(packageVersion()),
666
+ refuse: (message) => {
667
+ console.error(`scenescout ${command}: ${message}`);
668
+ console.error("Run `scenescout --help` for the options.");
669
+ process.exit(1);
670
+ },
671
+ commands: {
672
+ scan: (a) => {
673
+ if (!a[0])
674
+ usage();
675
+ console.log(formatScan(scanProject(a[0])));
676
+ },
677
+ serve: async () => {
678
+ await import("./mcp-server.js");
679
+ },
680
+ install,
681
+ doctor,
682
+ check,
683
+ ci,
684
+ login,
685
+ status: (a) => status(path.resolve(a[0] ?? process.cwd())),
686
+ watch: (a) => {
687
+ const positional = a.filter((x) => !x.startsWith("--"));
688
+ watch(path.resolve(positional[0] ?? process.cwd()), !a.includes("--no-open"));
689
+ },
690
+ },
691
+ });
659
692
  }
660
693
  catch (err) {
661
694
  console.error(`scenescout ${command ?? ""}: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * How the CLI decides what a command line asks for, before any command runs.
3
+ *
4
+ * It lives apart from cli.ts so it can be table-tested: `scenescout install
5
+ * --help` once ran a real install, because install read only the flags it knew
6
+ * and ignored the rest. Every subcommand now answers `--help` / `-h` with the
7
+ * usage text and exit 0 before it does anything, and a command that parses its
8
+ * own flags by hand refuses one it does not know.
9
+ */
10
+ const HANDLERS_OF = {
11
+ scan: true,
12
+ serve: true,
13
+ install: true,
14
+ doctor: true,
15
+ check: true,
16
+ ci: true,
17
+ login: true,
18
+ status: true,
19
+ watch: true,
20
+ };
21
+ export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
22
+ /**
23
+ * Commands that read their arguments by hand. `check`, `ci` and `login` are
24
+ * absent: their own parsers refuse unknown options. `serve` is absent on
25
+ * purpose: it is the line an MCP client launches, and a stray argument there
26
+ * should not stop the server from starting.
27
+ */
28
+ export const HAND_PARSED = {
29
+ install: {
30
+ switches: ["--skip-browser", "--no-register", "--no-command", "--browser-only"],
31
+ valued: ["--browsers", "--client", "--clients"],
32
+ // `--browser` is what `check` and `login` call it; for install it would otherwise download Chromium regardless.
33
+ hints: { "--browser": "--browsers" },
34
+ positional: 0,
35
+ },
36
+ doctor: { switches: ["--engine"], valued: [], positional: 0 },
37
+ scan: { switches: [], valued: [], positional: 1 },
38
+ status: { switches: [], valued: [], positional: 1 },
39
+ watch: { switches: ["--no-open"], valued: [], positional: 1 },
40
+ };
41
+ /** True when the arguments ask for help. */
42
+ export function wantsHelp(args) {
43
+ return args.includes("--help") || args.includes("-h");
44
+ }
45
+ /** The first argument `spec` does not accept, as an error sentence; null when every one is accepted. */
46
+ export function unknownArgument(args, spec) {
47
+ let positional = 0;
48
+ for (let i = 0; i < args.length; i++) {
49
+ const arg = args[i];
50
+ if (!arg.startsWith("-") || arg === "-") {
51
+ if (++positional > spec.positional)
52
+ return `unexpected argument ${arg}`;
53
+ continue;
54
+ }
55
+ const name = arg.split("=")[0];
56
+ if (spec.valued.includes(name)) {
57
+ // The value after a valued flag is not itself a flag, even when it starts with a dash.
58
+ if (!arg.includes("="))
59
+ i++;
60
+ continue;
61
+ }
62
+ if (spec.switches.includes(name) && !arg.includes("="))
63
+ continue;
64
+ const hint = spec.hints?.[name];
65
+ return `unknown option ${name}${hint ? ` — did you mean ${hint}?` : ""}`;
66
+ }
67
+ return null;
68
+ }
69
+ /** Whether `command` names a subcommand. */
70
+ export function isSubcommand(command) {
71
+ return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
72
+ }
73
+ /** Settle help and flag errors for a subcommand before its handler is reached. */
74
+ export function preflight(command, args) {
75
+ if (wantsHelp(args))
76
+ return { kind: "help" };
77
+ const spec = Object.hasOwn(HAND_PARSED, command) ? HAND_PARSED[command] : undefined;
78
+ const problem = spec ? unknownArgument(args, spec) : null;
79
+ return problem ? { kind: "error", message: problem } : { kind: "run" };
80
+ }
81
+ /**
82
+ * Run the command line: help and flag errors are settled here, so a subcommand's
83
+ * handler is reached only when it is actually meant to run.
84
+ */
85
+ export async function dispatch(command, args, h) {
86
+ switch (command) {
87
+ // Asking for help is not an error; scripts and shells treat a non-zero exit as one.
88
+ case "--help":
89
+ case "-h":
90
+ case "help":
91
+ return h.usage(0);
92
+ case "--version":
93
+ case "-v":
94
+ return h.version();
95
+ }
96
+ if (!isSubcommand(command))
97
+ return h.usage(1);
98
+ const verdict = preflight(command, args);
99
+ if (verdict.kind === "help")
100
+ return h.usage(0);
101
+ if (verdict.kind === "error")
102
+ return h.refuse(verdict.message);
103
+ await h.commands[command](args);
104
+ }
@@ -14,6 +14,18 @@
14
14
  export const LOGIN_ROUTE_RE = /\/(login|signin|sign-in|auth)(\/|$)/;
15
15
  /** Consecutive login bounces before we stop assuming it is a permission wall. */
16
16
  export const AUTH_LOSS_STREAK = 3;
17
+ /**
18
+ * Should this loss be answered by re-attaching from the role's saved profile?
19
+ *
20
+ * Only a session attached by role has a profile that `scenescout login` (or
21
+ * another process) may have refreshed since, so only it has something to
22
+ * re-read. A storage-state file or an anonymous session is reported as
23
+ * before. Once per session: a second loss means the refresh did not hold,
24
+ * and retrying on a loop would hide a dead profile behind endless recoveries.
25
+ */
26
+ export function shouldReattach(opts) {
27
+ return opts.streak >= AUTH_LOSS_STREAK && opts.signIn === "role" && !opts.alreadyReattached;
28
+ }
17
29
  export class AuthLossTracker {
18
30
  /** Consecutive navigations that ended on a login page. */
19
31
  streak = 0;
@@ -21,6 +33,37 @@ export class AuthLossTracker {
21
33
  reported = false;
22
34
  /** Notice for the in-flight call, consumed exactly once by take(). */
23
35
  pending = "";
36
+ /** How the session signed in; set by beginSession. */
37
+ signIn = "none";
38
+ /** The role label of the session, for the notices. */
39
+ role = "";
40
+ /** What each navigation of the current streak asked for, in order. */
41
+ streakTargets = [];
42
+ /** A re-attach this navigation earned and the engine has not taken yet. */
43
+ due = null;
44
+ /** Taken by the engine: the next recorded navigation says whether it worked. */
45
+ awaiting = null;
46
+ /** What happened to this session's one re-attach. */
47
+ outcome = null;
48
+ /** Notices a batch (crawl) must still say at its end, since it discards the per-route ones. */
49
+ batchNotes = [];
50
+ /**
51
+ * Start tracking a newly attached session. An explicit attach is new
52
+ * credentials: the streak, the verdict and the one re-attach start afresh,
53
+ * so a new session neither inherits a dead one's bounces nor names its routes.
54
+ */
55
+ beginSession(signIn, role) {
56
+ this.signIn = signIn;
57
+ this.role = role;
58
+ this.streak = 0;
59
+ this.streakTargets = [];
60
+ this.reported = false;
61
+ this.pending = "";
62
+ this.due = null;
63
+ this.awaiting = null;
64
+ this.outcome = null;
65
+ this.batchNotes = [];
66
+ }
24
67
  /**
25
68
  * Did a navigation to `requested` end up on a login screen?
26
69
  *
@@ -46,14 +89,14 @@ export class AuthLossTracker {
46
89
  *
47
90
  * `bounced` decides both the streak and whether the route counts as covered;
48
91
  * the caller owns the memory writes, because ownership of the store belongs
49
- * to the engine, not to this tracker.
92
+ * to the engine, not to this tracker. `target` is what the caller navigated
93
+ * to (a path with its real ids, where `requestedRoute` is the normalized
94
+ * route), which a re-attach goes back to; it defaults to the route.
50
95
  */
51
96
  record(opts) {
52
97
  const { requestedRoute, landedRoute, bounced, role } = opts;
53
- if (bounced)
54
- this.streak += 1;
55
- else
56
- this.streak = 0;
98
+ const target = opts.target ?? requestedRoute;
99
+ this.due = null;
57
100
  const divergence = landedRoute === requestedRoute
58
101
  ? ""
59
102
  : `⚠ REDIRECTED: asked for ${requestedRoute}, landed on ${landedRoute}` +
@@ -61,8 +104,101 @@ export class AuthLossTracker {
61
104
  ? ` — this is a login page, so the route is NOT counted as covered.`
62
105
  : ` — the app redirected; the route counts as covered for role '${role}'.`) +
63
106
  `\n`;
107
+ if (this.awaiting) {
108
+ this.settleReattach(requestedRoute, target, bounced, divergence);
109
+ return;
110
+ }
111
+ if (bounced) {
112
+ this.streak += 1;
113
+ this.streakTargets.push(target);
114
+ }
115
+ else {
116
+ this.streak = 0;
117
+ this.streakTargets = [];
118
+ }
119
+ if (shouldReattach({ streak: this.streak, signIn: this.signIn, alreadyReattached: this.outcome !== null })) {
120
+ // The verdict is replaced by the recovery's own notice once the engine
121
+ // has re-attached; if it never does (the call threw), the verdict stands.
122
+ this.due = { returnTo: target, retry: [...new Set(this.streakTargets)].filter((t) => t !== target) };
123
+ }
64
124
  this.pending = this.banner() + divergence;
65
125
  }
126
+ /**
127
+ * The re-attach this navigation earned, if any: consumed once, and from then
128
+ * on this session has used its one re-attach. `revisits` says which of the
129
+ * streak's other targets the caller will visit again itself (a crawl, its own
130
+ * paths); the plan it gets back holds only those, and the notice asks for the rest.
131
+ */
132
+ takeReattach(opts = {}) {
133
+ const plan = this.due;
134
+ this.due = null;
135
+ if (!plan)
136
+ return null;
137
+ const revisits = opts.revisits;
138
+ this.awaiting = revisits
139
+ ? { returnTo: plan.returnTo, retry: plan.retry.filter(revisits), left: plan.retry.filter((t) => !revisits(t)) }
140
+ : { returnTo: plan.returnTo, retry: [], left: plan.retry };
141
+ this.outcome = { kind: "failed", why: "it had not finished" };
142
+ return { returnTo: this.awaiting.returnTo, retry: this.awaiting.retry };
143
+ }
144
+ /** Whether a re-attach was taken and the navigation that decides it has not been recorded. */
145
+ get reattaching() {
146
+ return this.awaiting !== null;
147
+ }
148
+ /** The profile could not be applied, or the page to go back to never loaded: the loss is reported, with why. */
149
+ abortReattach(why) {
150
+ this.awaiting = null;
151
+ this.outcome = { kind: "failed", why };
152
+ const note = `The session tried to re-attach once from role '${this.role}''s saved profile, but ${why}.`;
153
+ this.pending = this.banner(this.lossTail(note));
154
+ this.batchNotes.push(`⚠ ${note}\n`);
155
+ }
156
+ /** The navigation after a re-attach decides it: signed in again, or not. */
157
+ settleReattach(requestedRoute, target, bounced, divergence) {
158
+ const plan = this.awaiting;
159
+ this.awaiting = null;
160
+ if (bounced) {
161
+ // Still on a login page with the latest profile: that profile is dead too.
162
+ this.streak = Math.max(this.streak, AUTH_LOSS_STREAK);
163
+ this.streakTargets.push(target);
164
+ const why = `its latest saved profile landed on a login page as well, so that profile has expired too`;
165
+ this.outcome = { kind: "failed", why };
166
+ const note = `The session re-attached once from role '${this.role}''s saved profile, but ${why}.`;
167
+ this.pending = this.banner(this.lossTail(note)) + divergence;
168
+ this.batchNotes.push(`⚠ ${note}\n`);
169
+ return;
170
+ }
171
+ this.streak = 0;
172
+ this.streakTargets = [];
173
+ // The loss was answered: a later batch verdict must not claim this session is dead.
174
+ this.reported = false;
175
+ this.outcome = { kind: "recovered", route: requestedRoute };
176
+ const others = (plan.retry.length === 0 ? "" : ` The other routes that bounced (${plan.retry.join(", ")}) are visited again in this sweep.`) +
177
+ (plan.left.length === 0 ? "" : ` The other routes that bounced before it (${plan.left.join(", ")}) are still NOT covered: visit them again.`);
178
+ const note = `↻ SESSION RE-ATTACHED — its sign-in was lost, so it re-attached once from role '${this.role}''s latest saved profile and ` +
179
+ `is signed in again at ${requestedRoute}.${others} This happens once per session: a second loss is reported, not retried.\n`;
180
+ this.pending = note + divergence;
181
+ this.batchNotes.push(note);
182
+ }
183
+ /** The end of a loss once the one re-attach is spent: what happened, and that it is not retried. */
184
+ lossTail(note) {
185
+ return ` ${note} It is not retried: record the profile again with \`scenescout login\` for role '${this.role}' and re-attach.`;
186
+ }
187
+ /**
188
+ * One line on what this session's re-attach did, for whoever hands its
189
+ * results on (a lane's report). Empty when it never re-attached.
190
+ */
191
+ reattachSummary() {
192
+ if (!this.outcome)
193
+ return "";
194
+ if (this.outcome.kind === "recovered")
195
+ return `its sign-in was lost and it re-attached once from role '${this.role}''s saved profile, continuing from ${this.outcome.route}`;
196
+ return `its sign-in was lost and re-attaching from role '${this.role}''s saved profile did not recover it: ${this.outcome.why}`;
197
+ }
198
+ /** Start a batch (crawl): its end-of-batch notes cover only what happened during it. */
199
+ beginBatch() {
200
+ this.batchNotes = [];
201
+ }
66
202
  /**
67
203
  * The verdict, once the streak says the session is dead.
68
204
  *
@@ -74,15 +210,18 @@ export class AuthLossTracker {
74
210
  * collapses repeats to "nothing new" — the signal decayed exactly as the
75
211
  * problem got worse.
76
212
  */
77
- banner() {
213
+ banner(tail) {
78
214
  if (this.streak < AUTH_LOSS_STREAK)
79
215
  return "";
80
216
  const first = !this.reported;
81
217
  this.reported = true;
218
+ // After the session's one re-attach, a later loss says so rather than implying a retry is still to come.
219
+ const spent = tail ?? (this.outcome !== null ? this.lossTail(`This session already re-attached once from role '${this.role}''s saved profile.`) : "");
82
220
  return (`⚠ SESSION AUTH LOST — ${this.streak} consecutive navigations were redirected to a login page. ` +
83
221
  `The credentials this session attached with have almost certainly expired. ` +
84
222
  `Nothing tested past this point is meaningful: re-attach with a fresh storage state before continuing.` +
85
223
  (first ? ` Routes bounced this way are recorded as NOT covered, so the completion contract still sees them as gaps.` : "") +
224
+ spent +
86
225
  `\n`);
87
226
  }
88
227
  /**
@@ -115,9 +254,11 @@ export class AuthLossTracker {
115
254
  * whatever the final route did.
116
255
  */
117
256
  batchVerdict() {
257
+ const notes = this.batchNotes.join("");
118
258
  if (!this.reported)
119
- return "";
120
- return (`⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
259
+ return notes;
260
+ return (notes +
261
+ `⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
121
262
  `The credentials this session attached with have almost certainly expired, so routes crawled after that ` +
122
263
  `point tested a logged-out app. They are recorded as NOT covered; re-attach with a fresh storage state ` +
123
264
  `and crawl again.\n`);
@@ -85,6 +85,12 @@ export function laneName(modules, index) {
85
85
  const name = modules.length > 1 ? `${base}+${modules.length - 1}` : base || `lane-${index + 1}`;
86
86
  return name.slice(0, LANE_NAME_MAX);
87
87
  }
88
+ /** How a lane signs in, as scout_attach arguments: by saved role, by a storage-state file, or not at all. */
89
+ function signInArgument(opts) {
90
+ if (!opts.role || opts.role === "anonymous")
91
+ return "";
92
+ return opts.roleProfile ? `, role: "${opts.role}"` : `, storageStatePath: "<${opts.role}>"`;
93
+ }
88
94
  /** The lanes to run, each with the objective to attach with. */
89
95
  export function planLanes(routes, laneCount, opts = {}) {
90
96
  return splitRoutes(routes, laneCount).map((lane, i) => ({
@@ -124,7 +130,7 @@ export function formatBriefs(briefs, opts = {}) {
124
130
  `LANE PLAN — ${briefs.length} lane(s) over ${briefs.reduce((n, b) => n + b.routes.length, 0)} route(s).`,
125
131
  ``,
126
132
  `Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel, and lands on its own first route rather than the home page:`,
127
- ` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${opts.role ? `, storageStatePath: "<${opts.role}>"` : ""}, objective: "<objective>" }`,
133
+ ` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
128
134
  ``,
129
135
  `Rules to pass on, which a hand-written brief tends to drop:`,
130
136
  ` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,