dsh-browser-playwright-codex 0.6.5 → 0.7.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/lib/plugin.js ADDED
@@ -0,0 +1,31 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import BrowserRuntime, { Config as ServiceConfig } from "./service.js";
3
+ import * as playwright from "./playwright.js";
4
+ import * as tool from "./tool.js";
5
+ import * as settingsCard from "./settings-card.js";
6
+ import { Config as PlaywrightConfigSchema } from "./config.js";
7
+ import { Config as ToolConfigSchema } from "./tool.js";
8
+ /** Schemastery validation for {@link BrowserPluginConfig}. */
9
+ export const Config = z.object({
10
+ service: ServiceConfig,
11
+ playwright: PlaywrightConfigSchema,
12
+ tool: ToolConfigSchema,
13
+ });
14
+ /** Cordis plugin name used by loader diagnostics (this is the row id in patches). */
15
+ export const name = 'browser';
16
+ /**
17
+ * Mount the whole capability: the seam, the Playwright provider, the tool family,
18
+ * and — when the deployment has a web server — the Settings card.
19
+ *
20
+ * @param ctx - plugin context
21
+ * @param config - nested config for each concern; the loader fills defaults
22
+ */
23
+ export function apply(ctx, config) {
24
+ ctx.plugin(BrowserRuntime, config.service ?? {});
25
+ ctx.plugin(playwright, config.playwright);
26
+ ctx.plugin(tool, config.tool);
27
+ // The card's host half injects `webServer`, so in a CLI deployment this child
28
+ // simply stays pending instead of failing the whole plugin.
29
+ ctx.plugin(settingsCard);
30
+ }
31
+ export default { name, Config, apply };
@@ -0,0 +1,9 @@
1
+ export declare const name = "browser-toggle";
2
+ export declare const inject: string[];
3
+ export declare function apply(ctx: any): void;
4
+ declare const _default: {
5
+ name: string;
6
+ inject: string[];
7
+ apply: typeof apply;
8
+ };
9
+ export default _default;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Settings card host half, absorbed from dsh-browser-toggle 0.3.0 (2026-10-19).
3
+ * Same four /api/browser-playwright routes and CSRF rules; the only change is that the
4
+ * runtime state is now a sibling module instead of a package specifier.
5
+ *
6
+ * @module dsh-browser-playwright-codex/settings-card
7
+ */
8
+ // @ts-nocheck — absorbed verbatim from the standalone package (plain JS), typed later.
9
+ /**
10
+ * dsh-browser-toggle - Host half.
11
+ *
12
+ * HTTP API for the Settings → Plugins UI card that flips the runtime enable
13
+ * flag of dsh-browser-playwright-codex without restarting DSH.
14
+ *
15
+ * GET /api/browser-playwright/state -> { enabled, engine, engineUserChosen, allowEvaluate, allowCdp }
16
+ * POST /api/browser-playwright/toggle {enabled} -> { enabled, changed }
17
+ * POST /api/browser-playwright/engine {engine} -> { engine, changed }
18
+ * POST /api/browser-playwright/gates {allowEvaluate, allowCdp} -> { allowEvaluate, allowCdp, changed }
19
+ * GET /api/browser-playwright/logins -> { logins: { source, capturedAt, cookies, total, sites } | null }
20
+ *
21
+ * `allowEvaluate` / `allowCdp` are the two power gates of the browser tool
22
+ * family: arbitrary page JavaScript and raw CDP. They are registration-time
23
+ * settings (an off gate means the tool is not registered at all, which is how
24
+ * the plugin keeps them out of the resident prompt), so this route cannot flip
25
+ * them in memory — it writes them into the home-level cordis.patch.yml, the
26
+ * layer with the highest priority among patch files, and lets the profile's
27
+ * `patchReload: live` re-apply the tree. The Settings page therefore reads the
28
+ * *effective* values published by the tool family (runtime-state.getGates),
29
+ * never a guess at the package default.
30
+ *
31
+ * The package itself ships both gates off: cordis.patch.yml travels inside the
32
+ * package, so anything it enabled would be enabled for every installer. This
33
+ * route is how a user opts in, in their own layer.
34
+ *
35
+ * `engine` is the page-reading mode: 'legacy' (the plugin's own DOM walk) or
36
+ * 'aria' (the browser's official accessibility tree). It is a user setting too,
37
+ * so it lives next to the enable flag and applies from the next operation on —
38
+ * no restart, no config edit. `engineUserChosen` says whether the current mode
39
+ * is the user's own pick or still the configured default.
40
+ *
41
+ * The enable flag lives in dsh-browser-playwright-codex's shared module
42
+ * (lib/runtime-state.js, persisted to ~/.dsh/dsh-browser-playwright.state.json —
43
+ * the file name is intentionally stable across package renames so the user's
44
+ * saved switch survives). Importing it through the package export
45
+ * gives this plugin the SAME module instance as the playwright provider, so
46
+ * flipping the flag here triggers the provider's disable-subscription (tears
47
+ * down any open window) in-process, and every future browser acquire is
48
+ * rejected while disabled.
49
+ *
50
+ * The specifier below must match the provider package's package.json#name.
51
+ * 2026-09-30: the provider was renamed to *-codex; the old specifier stopped
52
+ * resolving (MODULE_NOT_FOUND) and the toggle failed to load, so the import and
53
+ * the node_modules link have to move together with the rename.
54
+ *
55
+ * CSRF: POST requires the custom `x-dsh-plugin: browser-toggle` header, the
56
+ * same pattern as dsh-local-switch.
57
+ */
58
+ // Namespace import on purpose: this host half and the provider package are
59
+ // separate plugins, and a profile that is still running an older provider build
60
+ // would otherwise fail at link time with "does not provide an export named".
61
+ // Missing helpers degrade to "unknown" instead of breaking the Settings page.
62
+ import * as runtimeState from "./runtime-state.js";
63
+ // The patch writer lives in its own dependency-free module so it can be tested
64
+ // without a DSH host (a half-written patch layer would break every plugin load).
65
+ import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
66
+ import { readFileSync } from 'node:fs';
67
+ const { getEnabled, setEnabled, getEffectiveEngine, setSnapshotEngine, isEngineUserChosen } = runtimeState;
68
+ export const name = 'browser-toggle';
69
+ export const inject = ['webServer'];
70
+ const PREFIX = '/api/browser-playwright';
71
+ const HEADER = 'x-dsh-plugin';
72
+ /** Read the gates the tool family published, or undefined while unknown. */
73
+ function readGates() {
74
+ if (typeof runtimeState.getGates !== 'function')
75
+ return undefined;
76
+ try {
77
+ const gates = runtimeState.getGates();
78
+ if (gates && typeof gates.allowEvaluate === 'boolean' && typeof gates.allowCdp === 'boolean')
79
+ return gates;
80
+ }
81
+ catch {
82
+ /* fall through to unknown */
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Report the current managed block (what the user's own layer says). The switch
88
+ * shows the *effective* gates from readGates(); this is the file-level view used
89
+ * for diagnostics when the two disagree (i.e. the reload has not landed yet).
90
+ */
91
+ function managedBlockGates() {
92
+ try {
93
+ const text = readFileSync(homePatchPath(), 'utf8');
94
+ return parseGatesBlock(text);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ function sendJson(res, status, payload) {
101
+ res.statusCode = status;
102
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
103
+ res.setHeader('Cache-Control', 'no-store');
104
+ res.end(JSON.stringify(payload));
105
+ }
106
+ async function readBody(req) {
107
+ const chunks = [];
108
+ try {
109
+ for await (const chunk of req)
110
+ chunks.push(chunk);
111
+ }
112
+ catch (error) {
113
+ return { ok: false, error: String(error?.message ?? error) };
114
+ }
115
+ return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
116
+ }
117
+ export function apply(ctx) {
118
+ ctx.effect(() => ctx.webServer.register({
119
+ kind: 'exact',
120
+ path: `${PREFIX}/state`,
121
+ handler: async (req, res) => {
122
+ if (req.method !== 'GET')
123
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'GET only' } });
124
+ // Required even for a read: this exposes the deployment's switch state. A custom
125
+ // header forces a CORS preflight, which is what stops any other page in the
126
+ // browser from reading it off 127.0.0.1 (2026-10-16: only the POST routes had it).
127
+ if (req.headers[HEADER] !== 'browser-toggle')
128
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
129
+ const gates = readGates();
130
+ sendJson(res, 200, {
131
+ enabled: getEnabled(),
132
+ engine: getEffectiveEngine(),
133
+ engineUserChosen: isEngineUserChosen(),
134
+ // Effective values, published by the tool family itself. null = the
135
+ // provider build in this profile is too old to report them.
136
+ allowEvaluate: gates ? gates.allowEvaluate : null,
137
+ allowCdp: gates ? gates.allowCdp : null,
138
+ // What the user's own layer says right now. Differs from the above
139
+ // between a write and the live reload that applies it.
140
+ patchGates: managedBlockGates() ?? null,
141
+ });
142
+ },
143
+ }));
144
+ ctx.effect(() => ctx.webServer.register({
145
+ kind: 'exact',
146
+ path: `${PREFIX}/toggle`,
147
+ handler: async (req, res) => {
148
+ if (req.method !== 'POST')
149
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
150
+ if (req.headers[HEADER] !== 'browser-toggle')
151
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
152
+ const body = await readBody(req);
153
+ if (!body.ok)
154
+ return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
155
+ let payload;
156
+ try {
157
+ payload = JSON.parse(body.text || '{}');
158
+ }
159
+ catch {
160
+ return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
161
+ }
162
+ if (typeof payload.enabled !== 'boolean')
163
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`enabled` must be a boolean' } });
164
+ const changed = setEnabled(payload.enabled);
165
+ sendJson(res, 200, { enabled: getEnabled(), changed });
166
+ },
167
+ }));
168
+ ctx.effect(() => ctx.webServer.register({
169
+ kind: 'exact',
170
+ path: `${PREFIX}/engine`,
171
+ handler: async (req, res) => {
172
+ if (req.method !== 'POST')
173
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
174
+ if (req.headers[HEADER] !== 'browser-toggle')
175
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
176
+ const body = await readBody(req);
177
+ if (!body.ok)
178
+ return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
179
+ let payload;
180
+ try {
181
+ payload = JSON.parse(body.text || '{}');
182
+ }
183
+ catch {
184
+ return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
185
+ }
186
+ if (payload.engine !== 'legacy' && payload.engine !== 'aria')
187
+ return sendJson(res, 400, {
188
+ error: { code: 'INVALID_BODY', message: '`engine` must be "legacy" or "aria"' },
189
+ });
190
+ const changed = setSnapshotEngine(payload.engine);
191
+ sendJson(res, 200, { engine: getEffectiveEngine(), changed });
192
+ },
193
+ }));
194
+ ctx.effect(() => ctx.webServer.register({
195
+ kind: 'exact',
196
+ path: `${PREFIX}/gates`,
197
+ handler: async (req, res) => {
198
+ if (req.method !== 'POST')
199
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
200
+ if (req.headers[HEADER] !== 'browser-toggle')
201
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
202
+ const body = await readBody(req);
203
+ if (!body.ok)
204
+ return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
205
+ let payload;
206
+ try {
207
+ payload = JSON.parse(body.text || '{}');
208
+ }
209
+ catch {
210
+ return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
211
+ }
212
+ if (typeof payload.allowEvaluate !== 'boolean' || typeof payload.allowCdp !== 'boolean')
213
+ return sendJson(res, 400, {
214
+ error: {
215
+ code: 'INVALID_BODY',
216
+ message: '`allowEvaluate` and `allowCdp` must both be booleans',
217
+ },
218
+ });
219
+ const before = readGates();
220
+ if (before && before.allowEvaluate === payload.allowEvaluate && before.allowCdp === payload.allowCdp)
221
+ return sendJson(res, 200, { ...before, changed: false, patch: null });
222
+ try {
223
+ const written = await writeGates(payload.allowEvaluate, payload.allowCdp);
224
+ // The effective values only move once the loader re-applies the tree
225
+ // (profile patchReload: live); report what was written, and let the
226
+ // panel's next /state poll show whether it took effect.
227
+ sendJson(res, 200, {
228
+ allowEvaluate: written.allowEvaluate,
229
+ allowCdp: written.allowCdp,
230
+ changed: true,
231
+ patch: written.patch,
232
+ });
233
+ }
234
+ catch (error) {
235
+ sendJson(res, 500, {
236
+ error: { code: 'PATCH_WRITE', message: String(error?.message ?? error) },
237
+ });
238
+ }
239
+ },
240
+ }));
241
+ ctx.effect(() => ctx.webServer.register({
242
+ kind: 'exact',
243
+ path: `${PREFIX}/logins`,
244
+ handler: async (req, res) => {
245
+ if (req.method !== 'GET')
246
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'GET only' } });
247
+ // The CSRF header is mandatory here, and this route is the reason the rule exists:
248
+ // the payload is a list of the sites this browser is signed in to. A custom header
249
+ // forces a preflight, so no other page in the browser can read it off 127.0.0.1.
250
+ if (req.headers[HEADER] !== 'browser-toggle')
251
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
252
+ // Read-only, display-only: the provider publishes this summary (counts and domain
253
+ // names, never cookie values or names). null = nothing published yet, or the
254
+ // provider build in this profile is too old to publish it.
255
+ let logins = null;
256
+ if (typeof runtimeState.getLoginSites === 'function') {
257
+ try {
258
+ logins = runtimeState.getLoginSites() ?? null;
259
+ }
260
+ catch {
261
+ logins = null;
262
+ }
263
+ }
264
+ sendJson(res, 200, { logins });
265
+ },
266
+ }));
267
+ }
268
+ export default { name, inject, apply };
@@ -4,6 +4,18 @@
4
4
  * @module dsh-browser-playwright-codex/snapshot-render
5
5
  */
6
6
  import type { BrowserNode, BrowserSnapshot, SnapshotDiff } from './types.ts';
7
+ /** Default cap for a rendered href, in characters. */
8
+ export declare const MAX_HREF_LENGTH = 160;
9
+ /**
10
+ * Trim a href for display: drop analytics parameters and clip the whole thing.
11
+ * The snapshot's refs remain the interaction handle, so this only affects what
12
+ * the model reads, never what a click does.
13
+ *
14
+ * @param href - the raw href from the accessibility tree
15
+ * @param maxLength - display budget in characters
16
+ * @returns the shortened href
17
+ */
18
+ export declare function trimUrlForDisplay(href: string, maxLength?: number): string;
7
19
  /**
8
20
  * Render a node tree as indented lines, one element per line.
9
21
  * @param nodes - the snapshot's root nodes.
@@ -3,8 +3,106 @@
3
3
  * functions run on live calls and on session replay alike.
4
4
  * @module dsh-browser-playwright-codex/snapshot-render
5
5
  */
6
+ /**
7
+ * Query parameters that only feed analytics or the site's own ranking pipeline.
8
+ * They make a rendered href several times longer than the link it names and the
9
+ * model never needs them: the ref (not the URL) is what clicks.
10
+ */
11
+ const TRACKING_PARAMS = new Set([
12
+ 'gclid',
13
+ 'dclid',
14
+ 'fbclid',
15
+ 'msclkid',
16
+ 'yclid',
17
+ 'spm',
18
+ 'scm',
19
+ 'share_token',
20
+ 'rsv_spt',
21
+ 'rsv_iqid',
22
+ 'rsv_bp',
23
+ 'rsv_idx',
24
+ 'rsv_enter',
25
+ 'rsv_pq',
26
+ 'rsv_t',
27
+ 'rsv_dl',
28
+ 'rsv_sug1',
29
+ 'rsv_sug2',
30
+ 'rsv_sug3',
31
+ 'rsv_sug4',
32
+ 'rsv_sug5',
33
+ 'rsv_sug6',
34
+ 'rsv_sug7',
35
+ 'rsv_sug8',
36
+ 'rsv_sug9',
37
+ 'rsv_sug10',
38
+ 'sa',
39
+ 'usm',
40
+ 'rqid',
41
+ 'rsf',
42
+ ]);
43
+ /** Default cap for a rendered href, in characters. */
44
+ export const MAX_HREF_LENGTH = 160;
45
+ /** Clip a string to a display budget, marking the cut. */
46
+ function clip(value, maxLength) {
47
+ return value.length <= maxLength ? value : value.slice(0, maxLength - 1) + '…';
48
+ }
49
+ /**
50
+ * Trim a href for display: drop analytics parameters and clip the whole thing.
51
+ * The snapshot's refs remain the interaction handle, so this only affects what
52
+ * the model reads, never what a click does.
53
+ *
54
+ * @param href - the raw href from the accessibility tree
55
+ * @param maxLength - display budget in characters
56
+ * @returns the shortened href
57
+ */
58
+ export function trimUrlForDisplay(href, maxLength = MAX_HREF_LENGTH) {
59
+ const hashAt = href.indexOf('#');
60
+ const hash = hashAt >= 0 ? href.slice(hashAt) : '';
61
+ const head = hashAt >= 0 ? href.slice(0, hashAt) : href;
62
+ const queryAt = head.indexOf('?');
63
+ if (queryAt < 0)
64
+ return clip(head + hash, maxLength);
65
+ const base = head.slice(0, queryAt);
66
+ const kept = head
67
+ .slice(queryAt + 1)
68
+ .split('&')
69
+ .filter((pair) => pair !== '')
70
+ .filter((pair) => {
71
+ let key = pair.split('=')[0] ?? '';
72
+ try {
73
+ key = decodeURIComponent(key);
74
+ }
75
+ catch {
76
+ /* keep the raw key: an undecodable one is not a known tracker */
77
+ }
78
+ key = key.toLowerCase();
79
+ return !key.startsWith('utm_') && !TRACKING_PARAMS.has(key);
80
+ });
81
+ return clip(base + (kept.length > 0 ? '?' + kept.join('&') : '') + hash, maxLength);
82
+ }
83
+ /**
84
+ * A wrapper element with no name, ref, state or link of its own. Keeping it
85
+ * would push every real element one level deeper and repeat the word "generic"
86
+ * for every <div> on the page; dropping it keeps the structure the model needs
87
+ * (nesting of things that have names) without the noise.
88
+ */
89
+ function isTransparentWrapper(node) {
90
+ return (node.role === 'generic' &&
91
+ node.name === '' &&
92
+ node.ref === undefined &&
93
+ node.href === undefined &&
94
+ node.level === undefined &&
95
+ node.checked === undefined &&
96
+ node.selected === undefined &&
97
+ node.disabled === undefined);
98
+ }
6
99
  /** Render one node line into the accumulating line list. */
7
100
  function renderNode(node, depth, lines) {
101
+ if (isTransparentWrapper(node)) {
102
+ for (const child of node.children)
103
+ renderNode(child, depth, lines);
104
+ return;
105
+ }
8
106
  const indent = ' '.repeat(depth);
9
107
  let line = indent + '- ' + node.role;
10
108
  if (node.name !== '')
@@ -23,7 +121,7 @@ function renderNode(node, depth, lines) {
23
121
  if (flags.length > 0)
24
122
  line += ' [' + flags.join(', ') + ']';
25
123
  if (node.href !== undefined)
26
- line += ' -> ' + node.href;
124
+ line += ' -> ' + trimUrlForDisplay(node.href);
27
125
  lines.push(line);
28
126
  for (const child of node.children)
29
127
  renderNode(child, depth + 1, lines);
@@ -1,4 +1,4 @@
1
- import { CHALLENGE_NOTE, detectChallenge } from "./challenge.js";
1
+ import { CHALLENGE_NOTE, detectChallenge, detectSilentPage } from "./challenge.js";
2
2
  import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
3
3
  /**
4
4
  * Render one diagnostics payload as one line per entry. Diagnostics are read when
@@ -57,9 +57,32 @@ export function renderDiagnostics(kind, value) {
57
57
  }
58
58
  /** Project a provider snapshot into its canonical tool value. */
59
59
  export function snapshotValue(snapshot) {
60
- const challenge = detectChallenge(snapshot);
60
+ // The ref count travels with the snapshot on purpose: the wording tier needs it
61
+ // to tell a real wall (nothing to click) from a results page whose title merely
62
+ // contains the phrase. Measured on 百度: 0 refs on the challenge, 57/81 on the
63
+ // search pages titled "安全验证_百度搜索" / "滑块验证_百度搜索".
64
+ const challenge = detectChallenge({
65
+ url: snapshot.url,
66
+ title: snapshot.title,
67
+ nodes: snapshot.nodes,
68
+ refs: snapshot.totalRefs,
69
+ ...(snapshot.response !== undefined ? { response: snapshot.response } : {}),
70
+ });
61
71
  const tree = renderSnapshot(snapshot);
62
72
  const notes = [];
73
+ // The HTTP status was captured for challenge detection but never shown, so a
74
+ // 404/500 error page reached the model looking like an ordinary page — measured
75
+ // 2026-10-17 on a real MDN 404: status 404, 35 refs, and nothing in the result
76
+ // said so. Suppressed when a challenge was identified, because that note
77
+ // already names the reason.
78
+ const status = snapshot.response?.status;
79
+ if (challenge === null && status !== undefined && (status < 200 || status >= 300)) {
80
+ notes.push('[http ' +
81
+ String(status) +
82
+ '] the server answered ' +
83
+ String(status) +
84
+ ' for this document, so what follows may be an error page or an interception page rather than the content you asked for.');
85
+ }
63
86
  const dialog = snapshot.dialogNote;
64
87
  if (dialog !== undefined && dialog !== '')
65
88
  notes.push(dialog);
@@ -80,6 +103,18 @@ export function snapshotValue(snapshot) {
80
103
  const stall = snapshot.stallNote;
81
104
  if (challenge === null && stall !== undefined && stall !== '')
82
105
  notes.push(stall);
106
+ // The backstop above needs an action to compare before and after; a page with
107
+ // nothing to click never gets one, so the emptiest wall-like pages used to say
108
+ // nothing at all. Only on a successful status: a 4xx/5xx already has its own line.
109
+ const healthyStatus = status === undefined || (status >= 200 && status < 300);
110
+ // A parked dialog means the page is fine, it is the dialog that blocks us: the
111
+ // dialog note already says what to do, and adding "nothing here" contradicts it.
112
+ const dialogPending = snapshot.dialogNote !== undefined && snapshot.dialogNote !== '';
113
+ if (challenge === null && healthyStatus && !dialogPending) {
114
+ const silent = detectSilentPage({ refs: snapshot.totalRefs, nodes: snapshot.nodes, title: snapshot.title });
115
+ if (silent !== null)
116
+ notes.push(silent);
117
+ }
83
118
  const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
84
119
  // Incremental diff rides along when the provider computed one (snapshot.diff).
85
120
  const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
@@ -53,6 +53,12 @@ export interface ChallengeSnapshot {
53
53
  readonly title?: string;
54
54
  readonly url?: string;
55
55
  readonly nodes?: readonly ChallengeNode[];
56
+ /**
57
+ * Actionable-element count. The wording tier uses it to tell a challenge page
58
+ * (nothing to click) from an ordinary page whose *title* merely contains the
59
+ * phrase — searching Baidu for "安全验证" produces exactly that false positive.
60
+ */
61
+ readonly refs?: number;
56
62
  /** Main document response metadata; absent when no navigation response was seen. */
57
63
  readonly response?: DocumentResponse;
58
64
  }
package/lib/tool.d.ts CHANGED
@@ -31,6 +31,12 @@ export interface ToolConfig {
31
31
  * its schema would sit in the system prompt on every turn for nothing.
32
32
  */
33
33
  registerDisabledTools: boolean;
34
+ /**
35
+ * Register the two diagnostics tools (console messages, network requests).
36
+ * Defaults to false: they exist to debug a failed run, and their schemas
37
+ * would otherwise sit in the system prompt on every turn.
38
+ */
39
+ diagnostics: boolean;
34
40
  /** Upper bound for browser_wait in milliseconds. */
35
41
  maxWaitMs: number;
36
42
  /** Default value of the snapshot tool's interactiveOnly flag. */