dsh-browser-playwright-codex 0.7.8 → 0.8.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/lib/config.d.ts CHANGED
@@ -41,6 +41,32 @@ export interface PlaywrightConfig {
41
41
  /** Incremental ref-diff mode; false = always full snapshots (zero behavioral change). */
42
42
  diff: boolean;
43
43
  };
44
+ /**
45
+ * 进场特效:把一个页面抹白,再用一群光标把它重新画出来。
46
+ *
47
+ * 可选:老配置对象里没有这一节时,由 Schemastery 的 Config 补齐默认值
48
+ * (见下面的 `swarm: z.object(...).default(...)`)。做成可选是为了让手写的
49
+ * 测试配置、以及旧部署的 patch 层都能直接通过类型检查。
50
+ */
51
+ swarm?: {
52
+ /** 出厂默认关:动效资产随包分发(0.8.1 起已获作者授权),但默认不播;想要的人在设置卡里打开。 */
53
+ enabled: boolean;
54
+ /** 只在某个网站第一次打开时播;false = 每次导航都播(不建议)。 */
55
+ oncePerSite: boolean;
56
+ /** 播放速度档位 1–3(3 最快)。 */
57
+ speed: number;
58
+ /** 最多播多久(毫秒)。0 = 不限制,完整播完。 */
59
+ maxMs: number;
60
+ /** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
61
+ playOnVeiled: boolean;
62
+ /** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
63
+ persistPlayed: boolean;
64
+ /**
65
+ * 播放范围:`viewport` = 只画当前这一屏(默认,1–10 秒结束);
66
+ * `page` = 由插件按节奏往下滚,一屏一屏把整页画完(会接管滚动,时间长得多)。
67
+ */
68
+ range: 'viewport' | 'page';
69
+ };
44
70
  }
45
71
  /** Bounded page data the extraction consumer feeds to a model. */
46
72
  export interface PageData {
package/lib/config.js CHANGED
@@ -45,6 +45,25 @@ export const Config = z.object({
45
45
  diff: z.boolean().default(false),
46
46
  })
47
47
  .default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
48
+ swarm: z
49
+ .object({
50
+ // 出厂关:资产在包里(0.8.1 起已获作者授权),但默认不播,避免每次开新站先把页面盖住。
51
+ enabled: z.boolean().default(false),
52
+ // 只在每个网站第一次打开时播,避免每次导航都把页面盖住。
53
+ oncePerSite: z.boolean().default(true),
54
+ // 1–3 档,脚本里对应点几次速度按钮。
55
+ speed: z.number().default(3),
56
+ // 硬上限:到点无条件退回真实页面。原版脚本在长页面上要约 12 秒,留 15 秒。
57
+ maxMs: z.number().default(15000),
58
+ // 默认关:遮罩后面的内容没解锁就不该被画出来。自己的面板想看可以打开。
59
+ playOnVeiled: z.boolean().default(false),
60
+ // 默认关:整个浏览器窗口一关就忘掉这趟播过哪些站,下次重开全部重播。
61
+ persistPlayed: z.boolean().default(false),
62
+ // 只画首屏:长页面不动,短页面也不会卡在"下半页永远等不到"。想看整页的人
63
+ // 到设置卡选「整页」——那会把滚动接过去几秒,所以不做默认。
64
+ range: z.union([z.const('viewport'), z.const('page')]).default('viewport'),
65
+ })
66
+ .default({ enabled: false, oncePerSite: true, speed: 3, maxMs: 15000, playOnVeiled: false, persistPlayed: false }),
48
67
  });
49
68
  /** Channels probed in order when none is configured. */
50
69
  export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
package/lib/errors.d.ts CHANGED
@@ -35,3 +35,20 @@ export declare function evaluateDisabled(): BrowserError;
35
35
  * @returns the denial, ready to throw.
36
36
  */
37
37
  export declare function cdpDisabled(): BrowserError;
38
+ /**
39
+ * Normalize a `browser_evaluate` result so it survives the harness's
40
+ * lossless-JSON output boundary.
41
+ *
42
+ * The harness (dsh-tools) rejects a tool result that is not losslessly
43
+ * JSON-serializable, and it is strictest about *top-level* shape: a bare array
44
+ * or `undefined` at the root fails its check with an opaque
45
+ * "value is not lossless JSON" that does not tell the model how to fix it. This
46
+ * wraps only those shapes — arrays, `undefined` and functions — into a JSON
47
+ * string plus a marker note, and leaves every other value (objects, strings,
48
+ * numbers, booleans, null) untouched so callers get back what their expression
49
+ * actually returned.
50
+ *
51
+ * @param value - the raw value the page expression evaluated to.
52
+ * @returns a lossless-JSON-safe value, with a note when it had to be stringified.
53
+ */
54
+ export declare function normalizeEvaluateResult(value: unknown): unknown;
package/lib/errors.js CHANGED
@@ -35,3 +35,31 @@ export function evaluateDisabled() {
35
35
  export function cdpDisabled() {
36
36
  return new BrowserError('CDP_DISABLED', "browser_cdp is disabled; set tool.allowCdp: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable raw CDP inspection commands");
37
37
  }
38
+ /**
39
+ * Normalize a `browser_evaluate` result so it survives the harness's
40
+ * lossless-JSON output boundary.
41
+ *
42
+ * The harness (dsh-tools) rejects a tool result that is not losslessly
43
+ * JSON-serializable, and it is strictest about *top-level* shape: a bare array
44
+ * or `undefined` at the root fails its check with an opaque
45
+ * "value is not lossless JSON" that does not tell the model how to fix it. This
46
+ * wraps only those shapes — arrays, `undefined` and functions — into a JSON
47
+ * string plus a marker note, and leaves every other value (objects, strings,
48
+ * numbers, booleans, null) untouched so callers get back what their expression
49
+ * actually returned.
50
+ *
51
+ * @param value - the raw value the page expression evaluated to.
52
+ * @returns a lossless-JSON-safe value, with a note when it had to be stringified.
53
+ */
54
+ export function normalizeEvaluateResult(value) {
55
+ if (Array.isArray(value)) {
56
+ return { note: 'array converted to JSON string', value: JSON.stringify(value) };
57
+ }
58
+ if (value === undefined) {
59
+ return { note: 'expression returned undefined' };
60
+ }
61
+ if (typeof value === 'function') {
62
+ return { note: 'function converted to string', value: String(value) };
63
+ }
64
+ return value;
65
+ }
@@ -20,7 +20,46 @@ export declare function homePatchPath(env?: NodeJS.ProcessEnv): string;
20
20
  * @returns the YAML block, markers included
21
21
  */
22
22
  export declare function renderBrowserBlock(allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
23
- /** Merge the managed block into `original`, replacing an existing one in place. */
23
+ /** The three gate keys, in the order a freshly created block writes them. */
24
+ declare const GATE_KEYS: readonly ["allowEvaluate", "allowCdp", "diagnostics"];
25
+ /** One of the three gate keys this writer owns. */
26
+ type GateKey = (typeof GATE_KEYS)[number];
27
+ /** The gate values as a record, for line-level rewriting. */
28
+ type GateValues = Record<GateKey, boolean>;
29
+ /**
30
+ * Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
31
+ * other line byte for byte.
32
+ *
33
+ * Why not re-render the whole block: the loader replaces an entry's `config`
34
+ * wholesale, so the block has to carry every key the user cares about — and it
35
+ * cannot know all of them. Re-rendering the template silently reset whatever
36
+ * the template did not list: `profileDir` (which wiped the saved login), the
37
+ * launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
38
+ * `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
39
+ * 2026-11-10: toggling one switch in the settings card logged the user out of
40
+ * every site, because their block said profileDir: 'D:/dsh-profile' and the
41
+ * template does not.
42
+ *
43
+ * Documented consequence, deliberately kept: keys the user never wrote stay
44
+ * unwritten (the package default applies), which is exactly what the user chose
45
+ * by not writing them.
46
+ *
47
+ * @param body - the full block text, markers included
48
+ * @param gates - the three gate values to write
49
+ * @returns the block with only those lines changed or added
50
+ */
51
+ export declare function patchBlockGates(body: string, gates: GateValues): string;
52
+ /**
53
+ * Merge the gate values into `original`.
54
+ *
55
+ * - No managed block yet → append the full template (nothing to preserve).
56
+ * - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
57
+ * - Block present but its structure defeats line editing (a flow-style
58
+ * `tool: { ... }`, a truncated block) → fall back to the whole template, which
59
+ * is the old behaviour: at least the switch the user just flipped takes
60
+ * effect. Guarded by a read-back check so a half-edited block can never be
61
+ * written out.
62
+ */
24
63
  export declare function mergeBrowserBlock(original: string, allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
25
64
  /** Read the current managed block, or undefined when there is none. */
26
65
  export declare function parseBrowserBlock(text: string): {
@@ -47,3 +86,4 @@ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, di
47
86
  allowCdp: boolean;
48
87
  diagnostics: boolean;
49
88
  }>;
89
+ export {};
@@ -80,6 +80,82 @@ export function renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) {
80
80
  '',
81
81
  ].join('\n');
82
82
  }
83
+ /** The three gate keys, in the order a freshly created block writes them. */
84
+ const GATE_KEYS = ['allowEvaluate', 'allowCdp', 'diagnostics'];
85
+ /** One `key: value # note` line of the managed block. */
86
+ const GATE_LINE = /^(\s*)(allowEvaluate|allowCdp|diagnostics)(\s*:\s*)(?:true|false)?[ \t]*(#.*)?$/;
87
+ /**
88
+ * Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
89
+ * other line byte for byte.
90
+ *
91
+ * Why not re-render the whole block: the loader replaces an entry's `config`
92
+ * wholesale, so the block has to carry every key the user cares about — and it
93
+ * cannot know all of them. Re-rendering the template silently reset whatever
94
+ * the template did not list: `profileDir` (which wiped the saved login), the
95
+ * launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
96
+ * `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
97
+ * 2026-11-10: toggling one switch in the settings card logged the user out of
98
+ * every site, because their block said profileDir: 'D:/dsh-profile' and the
99
+ * template does not.
100
+ *
101
+ * Documented consequence, deliberately kept: keys the user never wrote stay
102
+ * unwritten (the package default applies), which is exactly what the user chose
103
+ * by not writing them.
104
+ *
105
+ * @param body - the full block text, markers included
106
+ * @param gates - the three gate values to write
107
+ * @returns the block with only those lines changed or added
108
+ */
109
+ export function patchBlockGates(body, gates) {
110
+ const nl = body.includes('\r\n') ? '\r\n' : '\n';
111
+ const lines = body.split(/\r?\n/);
112
+ const seen = new Set();
113
+ let lastGateAt = -1;
114
+ let gateIndent = '';
115
+ let toolAt = -1;
116
+ let configIndent;
117
+ for (let index = 0; index < lines.length; index += 1) {
118
+ const line = lines[index] ?? '';
119
+ const gate = GATE_LINE.exec(line);
120
+ if (gate !== null) {
121
+ const key = gate[2];
122
+ seen.add(key);
123
+ const indent = gate[1] ?? '';
124
+ const comment = gate[4] === undefined ? '' : ' ' + gate[4];
125
+ lines[index] = indent + key + ': ' + (gates[key] ? 'true' : 'false') + comment;
126
+ lastGateAt = index;
127
+ gateIndent = indent;
128
+ continue;
129
+ }
130
+ if (toolAt === -1 && /^\s*tool\s*:\s*(#.*)?$/.test(line))
131
+ toolAt = index;
132
+ const config = /^(\s*)config\s*:/.exec(line);
133
+ if (configIndent === undefined && config !== null)
134
+ configIndent = config[1] ?? '';
135
+ }
136
+ const missing = GATE_KEYS.filter((key) => !seen.has(key));
137
+ if (missing.length === 0)
138
+ return lines.join(nl);
139
+ const rendered = missing.map((key) => key + ': ' + (gates[key] ? 'true' : 'false'));
140
+ if (lastGateAt >= 0) {
141
+ // Beside the gates already present: same indentation, same section.
142
+ lines.splice(lastGateAt + 1, 0, ...rendered.map((line) => gateIndent + line));
143
+ return lines.join(nl);
144
+ }
145
+ if (toolAt >= 0) {
146
+ const indent = (/^\s*/.exec(lines[toolAt] ?? '')?.[0] ?? '') + ' ';
147
+ lines.splice(toolAt + 1, 0, ...rendered.map((line) => indent + line));
148
+ return lines.join(nl);
149
+ }
150
+ // No tool section at all: add one just above the closing marker, inside config.
151
+ const endAt = lines.findIndex((line) => line.trim() === END || line.trim() === LEGACY_END);
152
+ const configPad = (configIndent ?? '') + ' ';
153
+ const insert = [configPad + 'tool:', ...rendered.map((line) => configPad + ' ' + line)];
154
+ if (endAt === -1)
155
+ return lines.concat(insert).join(nl);
156
+ lines.splice(endAt, 0, ...insert);
157
+ return lines.join(nl);
158
+ }
83
159
  /** Locate the managed block, preferring the current markers over the legacy ones. */
84
160
  function findBlock(original) {
85
161
  const markers = [
@@ -98,13 +174,35 @@ function findBlock(original) {
98
174
  }
99
175
  return undefined;
100
176
  }
101
- /** Merge the managed block into `original`, replacing an existing one in place. */
177
+ /**
178
+ * Merge the gate values into `original`.
179
+ *
180
+ * - No managed block yet → append the full template (nothing to preserve).
181
+ * - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
182
+ * - Block present but its structure defeats line editing (a flow-style
183
+ * `tool: { ... }`, a truncated block) → fall back to the whole template, which
184
+ * is the old behaviour: at least the switch the user just flipped takes
185
+ * effect. Guarded by a read-back check so a half-edited block can never be
186
+ * written out.
187
+ */
102
188
  export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics) {
103
- const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
189
+ const gates = { allowEvaluate, allowCdp, diagnostics };
104
190
  const found = findBlock(original);
105
- if (found !== undefined)
106
- return original.slice(0, found.start) + block + original.slice(found.after);
107
- return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
191
+ if (found === undefined) {
192
+ const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
193
+ return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
194
+ }
195
+ const current = original.slice(found.start, found.after);
196
+ const edited = patchBlockGates(current, gates);
197
+ const check = parseBrowserBlock(edited);
198
+ if (check !== undefined &&
199
+ check.allowEvaluate === allowEvaluate &&
200
+ check.allowCdp === allowCdp &&
201
+ check.diagnostics === diagnostics) {
202
+ return original.slice(0, found.start) + edited + original.slice(found.after);
203
+ }
204
+ const rebuilt = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
205
+ return original.slice(0, found.start) + rebuilt + original.slice(found.after);
108
206
  }
109
207
  /** Read the current managed block, or undefined when there is none. */
110
208
  export function parseBrowserBlock(text) {
package/lib/injected.js CHANGED
@@ -66,6 +66,22 @@ export const SNAPSHOT_SCRIPT = String.raw `
66
66
  return text.length > max ? text.slice(0, max) + '…' : text
67
67
  }
68
68
 
69
+ // Autocomplete tokens that mark a credential field. Mirrors CREDENTIAL_AUTOCOMPLETE
70
+ // in page-actions.ts so the snapshot never reads back what the user typed into one.
71
+ const CREDENTIAL_AUTOCOMPLETE = ['current-password', 'new-password', 'one-time-code', 'cc-number', 'cc-csc', 'cc-exp', 'cc-exp-month', 'cc-exp-year']
72
+
73
+ // Whether an element holds a secret (password, one-time code, card number).
74
+ // Same judgement as isCredentialField in page-actions.ts: type=password, or an
75
+ // autocomplete token list containing a credential token (not just the last one).
76
+ function isCredentialEl(el) {
77
+ const tag = el.tagName.toLowerCase()
78
+ if (tag !== 'input' && tag !== 'textarea') return false
79
+ const type = tag === 'input' ? (el.getAttribute('type') || '').toLowerCase() : ''
80
+ if (type === 'password') return true
81
+ const tokens = (el.getAttribute('autocomplete') || '').toLowerCase().split(/\s+/)
82
+ return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.indexOf(token) !== -1)
83
+ }
84
+
69
85
  function ownText(el, maxText) {
70
86
  let out = ''
71
87
  for (const node of el.childNodes) {
@@ -77,6 +93,13 @@ export const SNAPSHOT_SCRIPT = String.raw `
77
93
  }
78
94
 
79
95
  function nameOf(el, maxName, maxText, actionable) {
96
+ // An <option> names itself with its own text — never the enclosing <label> or
97
+ // <select>, whose textContent contains every option. Without this every option
98
+ // in one <select> shares the same long name and the model cannot tell them
99
+ // apart (measured 2026-11-11 on selenium.dev's web-form).
100
+ if (el.tagName.toLowerCase() === 'option') {
101
+ return clip((el.textContent || '').replace(/\s+/g, ' ').trim(), maxName)
102
+ }
80
103
  const aria = el.getAttribute('aria-label')
81
104
  if (aria && aria.trim()) return clip(aria.trim(), maxName)
82
105
  if (el.id) {
@@ -256,6 +279,28 @@ export const SNAPSHOT_SCRIPT = String.raw `
256
279
  if (el.tagName.toLowerCase() === 'input' && (role === 'checkbox' || role === 'radio')) node.checked = !!el.checked
257
280
  if (role === 'option') node.selected = !!el.selected
258
281
  if (el.disabled) node.disabled = true
282
+ // Expose the current value of a text entry field so the model can see what a
283
+ // fill actually left behind, and so a value change registers as page change.
284
+ // Credential fields (password, one-time code, card number) are excluded: the
285
+ // user typed those in the visible window, and reading them back into the
286
+ // snapshot would send the secret to the model API. Mirrors isCredentialField
287
+ // in page-actions.ts.
288
+ //
289
+ // A real <select> is left out on purpose: its NAME already shows the chosen
290
+ // option (the branch above), so a value flag would print the same fact twice.
291
+ // Excluding by TAG, not by role, matters: autocomplete search boxes routinely
292
+ // carry an explicit role="combobox" and their value must still be visible.
293
+ // An <option> carries its value attribute only when it differs from its text.
294
+ const isSelectEl = el.tagName.toLowerCase() === 'select'
295
+ const textEntry =
296
+ role === 'textbox' || role === 'textarea' || role === 'searchbox' || role === 'combobox'
297
+ if (textEntry && !isSelectEl && !isCredentialEl(el)) {
298
+ const v = (el.value !== undefined ? String(el.value) : '').replace(/\s+/g, ' ').trim()
299
+ if (v !== '') node.value = clip(v, opts.maxTextLength)
300
+ } else if (role === 'option') {
301
+ const ov = (el.getAttribute('value') || '').trim()
302
+ if (ov !== '' && ov !== nodeName) node.value = clip(ov, opts.maxNameLength)
303
+ }
259
304
  if (role === 'link') {
260
305
  const href = el.getAttribute('href')
261
306
  if (href && href.trim() && href.indexOf('javascript:') !== 0) node.href = href
@@ -84,6 +84,30 @@ export declare function isCredentialField(spec: {
84
84
  type?: string;
85
85
  autocomplete?: string;
86
86
  }): boolean;
87
+ /** What the live element says about itself, as far as the credential check needs. */
88
+ export interface FieldSpec {
89
+ tag: string;
90
+ type: string;
91
+ autocomplete: string;
92
+ }
93
+ /**
94
+ * Read tag/type/autocomplete from the live element — and refuse to guess.
95
+ *
96
+ * `browser_fill` promises it never types a secret. That promise used to depend
97
+ * on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
98
+ * autocomplete:''}))`): an unreadable field produced a spec that looks like a
99
+ * plain text box, the credential check said "not a credential", and the fill
100
+ * went ahead. Fail-open on a page whose attributes we cannot read is exactly
101
+ * the case where the answer is least certain, so it now fails closed: no spec,
102
+ * nothing typed, CREDENTIAL_FIELD with a clear next step.
103
+ *
104
+ * Cancel and deadline are NOT credential failures: they pass through untouched
105
+ * so the caller reports what actually happened.
106
+ *
107
+ * @param locator - the resolved target
108
+ * @returns the field spec read from the page
109
+ */
110
+ export declare function readFieldSpec(locator: Locator): Promise<FieldSpec>;
87
111
  export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
88
112
  /**
89
113
  * Normalize a field value before comparing. Two levels of forgiveness, both of them
@@ -7,8 +7,11 @@ import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
7
7
  import { BrowserError } from "./errors.js";
8
8
  import { assertAllowedUrl } from "./url-policy.js";
9
9
  import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
10
+ import { isReadTimeout } from "./read-deadline.js";
10
11
  import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
11
12
  import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
13
+ import { maybePlaySwarm, stopSwarm, willPlaySwarm } from "./swarm-intro.js";
14
+ import { armCurtain, clearCurtainToken } from "./swarm-curtain.js";
12
15
  /** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
13
16
  export async function withAbort(promise, signal) {
14
17
  // Attach handlers FIRST: the caller's promise may still be draining (a
@@ -99,6 +102,10 @@ export async function runAction(session, page, action) {
99
102
  const already = session.provider.pendingDialogFor(page);
100
103
  if (already !== undefined)
101
104
  return { blocked: true, pending: already };
105
+ // 进场特效运行时页面最上层盖着覆盖层。它不接管鼠标(pointer-events:none),
106
+ // 拦不住点击,但 AI 动手时它该让开。放在对话框检查之后:页面被原生对话框
107
+ // 卡住时 evaluate 会一直挂着,那会把整个动作拖死,所以还加了个 300ms 上限。
108
+ await Promise.race([stopSwarm(page), new Promise((resolve) => setTimeout(resolve, 300))]);
102
109
  const wait = session.provider.armDialogWait(page);
103
110
  const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
104
111
  const winner = await Promise.race([outcome, wait.promise]);
@@ -113,12 +120,20 @@ export async function navigate(session, url, waitUntil, signal) {
113
120
  return session.run(async () => {
114
121
  const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
115
122
  const page = await session.ensurePage();
123
+ // 提前启动要在导航**之前**打标记:addInitScript 读到它,就在页面脚本之前把
124
+ // 原版引擎拉起来(白底 + 18 个光标从第一帧就在),原页面不会闪出来。
125
+ if (willPlaySwarm(target.toString(), session.provider.config, session.provider.desiredHeadless())) {
126
+ await armCurtain(page);
127
+ }
116
128
  try {
117
129
  const racedNav = await session.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal));
118
130
  if (racedNav.blocked)
119
131
  return session.blockedSnapshot(page, racedNav.pending);
120
132
  }
121
133
  catch (error) {
134
+ // 导航没走成:标记还留在 window.name 里,而它跨导航保留。不清掉的话,
135
+ // 下一次普通跳转会意外提前启动引擎(2026-11-09 复核 find B)。
136
+ await clearCurtainToken(page);
122
137
  if (isAbortError(error) || signal?.aborted === true)
123
138
  throw error;
124
139
  // A redirect chain can leave the allow-list: the guard refuses the hop and
@@ -126,7 +141,24 @@ export async function navigate(session, url, waitUntil, signal) {
126
141
  session.throwIfNavigationBlocked();
127
142
  throw asTimeoutError('navigation', error);
128
143
  }
129
- return session.settleAndSnapshot(page, true);
144
+ // **先放特效、再拍快照**:原版开场本身就是"页面被抹白 + 光标从两边飞进来打转",
145
+ // 让它立刻登场,用户就不会先看见网页、过一会儿才播动画(2026-11-09 用户的判断:
146
+ // 与其自己做一套过场,不如用原版那一套)。资产里"扫描页面"那一步被改成等放行信号,
147
+ // 所以拍快照期间页面不会被它滚走;快照拍完再放行。
148
+ maybePlaySwarm(page, session.provider.config, session.provider.desiredHeadless());
149
+ // 放行必须放 finally:快照阶段抛错、被原生弹窗拦住、导航被拒,都不能把页面
150
+ // 一直扣在白幕下等满 5 秒兜底(2026-11-09 复核建议 1)。
151
+ try {
152
+ return await session.settleAndSnapshot(page, true);
153
+ }
154
+ finally {
155
+ await page
156
+ .evaluate(() => {
157
+ ;
158
+ window.__dshSwarmGo = true;
159
+ })
160
+ .catch(() => { });
161
+ }
130
162
  });
131
163
  }
132
164
  /**
@@ -511,9 +543,45 @@ export function isCredentialField(spec) {
511
543
  return false;
512
544
  if ((spec.type ?? '').toLowerCase() === 'password')
513
545
  return true;
514
- const tokens = (spec.autocomplete ?? '').toLowerCase().trim().split(/\s+/);
515
- const token = tokens[tokens.length - 1] ?? '';
516
- return CREDENTIAL_AUTOCOMPLETE.has(token);
546
+ // Every token, not just the last one. `autocomplete` is a space-separated
547
+ // token list ("section-a shipping new-password webauthn") and only its LAST
548
+ // token used to be checked, so any credential token that was not written last
549
+ // read as an ordinary field (measured 2026-11-10).
550
+ const tokens = (spec.autocomplete ?? '').toLowerCase().split(/\s+/);
551
+ return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.has(token));
552
+ }
553
+ /**
554
+ * Read tag/type/autocomplete from the live element — and refuse to guess.
555
+ *
556
+ * `browser_fill` promises it never types a secret. That promise used to depend
557
+ * on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
558
+ * autocomplete:''}))`): an unreadable field produced a spec that looks like a
559
+ * plain text box, the credential check said "not a credential", and the fill
560
+ * went ahead. Fail-open on a page whose attributes we cannot read is exactly
561
+ * the case where the answer is least certain, so it now fails closed: no spec,
562
+ * nothing typed, CREDENTIAL_FIELD with a clear next step.
563
+ *
564
+ * Cancel and deadline are NOT credential failures: they pass through untouched
565
+ * so the caller reports what actually happened.
566
+ *
567
+ * @param locator - the resolved target
568
+ * @returns the field spec read from the page
569
+ */
570
+ export async function readFieldSpec(locator) {
571
+ try {
572
+ return await locator.evaluate((el) => {
573
+ const tag = el.tagName.toLowerCase();
574
+ const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
575
+ return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
576
+ });
577
+ }
578
+ catch (error) {
579
+ if (isAbortError(error) || isReadTimeout(error))
580
+ throw error;
581
+ const detail = error instanceof Error ? error.message : String(error);
582
+ throw new BrowserError('CREDENTIAL_FIELD', "browser_fill could not read this field's attributes, so it cannot rule out a password / one-time code / card field and typed nothing. Take a fresh snapshot (the element may have been replaced) and retry, or ask the user to fill it in the visible window. Underlying failure: " +
583
+ detail.slice(0, 200));
584
+ }
517
585
  }
518
586
  export async function fill(session, ref, text, signal) {
519
587
  return session.run(async () => {
@@ -521,13 +589,7 @@ export async function fill(session, ref, text, signal) {
521
589
  const page = session.currentPageSync();
522
590
  await session.waitForElementStable(locator);
523
591
  await session.assertActionable(locator, { editable: true });
524
- const spec = await locator
525
- .evaluate((el) => {
526
- const tag = el.tagName.toLowerCase();
527
- const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
528
- return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
529
- })
530
- .catch(() => ({ tag: 'input', type: '', autocomplete: '' }));
592
+ const spec = await readFieldSpec(locator);
531
593
  if (isCredentialField(spec)) {
532
594
  throw new BrowserError('CREDENTIAL_FIELD', 'browser_fill refuses credential fields (password, one-time code, card number): a human types those in the visible window. Ask the user to type it there, then take a fresh snapshot and continue with the rest.');
533
595
  }
package/lib/provider.js CHANGED
@@ -13,6 +13,8 @@ import { PlaywrightSession } from "./session.js";
13
13
  import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
14
14
  import { BrowserError } from "./errors.js";
15
15
  import { SNAPSHOT_SCRIPT } from "./injected.js";
16
+ import { swarmBootScript } from "./swarm-curtain.js";
17
+ import { forgetOnBrowserClose } from "./swarm-intro.js";
16
18
  import { isWindowMinimized } from "./window-focus.js";
17
19
  import { installHostGuard } from "./navigation-guard.js";
18
20
  export class PlaywrightProvider {
@@ -267,11 +269,17 @@ export class PlaywrightProvider {
267
269
  // Allow-list on the wire, not just at the entry points (navigation-guard.ts).
268
270
  await this.installHostGuard(context);
269
271
  await context.addInitScript(SNAPSHOT_SCRIPT);
272
+ // 提前启动「同一个引擎」:只有导航前打了标记的那次才在页面脚本之前启动原版特效
273
+ // (白底 + 18 个光标从第一帧就在),没标记的普通导航不会白闪。见 swarm-curtain.ts。
274
+ await context.addInitScript({ content: swarmBootScript() });
270
275
  const session = new PlaywrightSession(this, owner, context);
271
276
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
272
277
  this.entries.set(owner, entry);
273
278
  this.armIdle(entry);
274
279
  void context.on('close', () => {
280
+ // 这个浏览器上下文关掉:默认只忘掉它自己那趟播过哪些站。
281
+ // 别的窗口还开着时不清它们的记录(2026-11-09 复核 find B)。
282
+ forgetOnBrowserClose(context);
275
283
  if (this.entries.get(owner) === entry) {
276
284
  if (entry.timer !== undefined)
277
285
  clearTimeout(entry.timer);
@@ -10,8 +10,12 @@ import { isAbortError } from './page-diagnostics.ts';
10
10
  * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
11
11
  * @param label - 超时消息里说明卡在哪一步
12
12
  * @param signal - 可选的取消信号
13
+ * @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
14
+ * @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
15
+ * 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
16
+ * 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
13
17
  */
14
- export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined'): Promise<T | undefined>;
18
+ export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined', consequence?: string): Promise<T | undefined>;
15
19
  /** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
16
20
  export declare function isReadTimeout(error: unknown): boolean;
17
21
  /** 取消错误保持原样传递(不多包一层)。 */
@@ -26,8 +26,12 @@ import { isAbortError } from "./page-diagnostics.js";
26
26
  * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
27
27
  * @param label - 超时消息里说明卡在哪一步
28
28
  * @param signal - 可选的取消信号
29
+ * @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
30
+ * @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
31
+ * 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
32
+ * 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
29
33
  */
30
- export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject') {
34
+ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject', consequence = 'The call was abandoned; the page may still be running its script.') {
31
35
  const guarded = Promise.resolve(promise);
32
36
  guarded.catch(() => { }); // 超时后原调用仍在跑:别让它的 rejection 冒到宿主
33
37
  return new Promise((resolve, reject) => {
@@ -50,9 +54,12 @@ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout
50
54
  label +
51
55
  ' did not finish within ' +
52
56
  String(timeoutMs) +
53
- 'ms; the page may be stuck running script. The next browser call reopens the session.')));
57
+ 'ms; the page may be stuck running script. ' +
58
+ consequence)));
54
59
  }, timeoutMs);
55
- timer.unref?.();
60
+ // 不要 unref:期限是「到点必须断」的承诺,unref 之后若这是进程里最后一个句柄,
61
+ // 事件循环会判定无事可做直接退出,调用方永远等不到这条 ACTION_TIMEOUT
62
+ // (tests/read-deadline.test.ts 就是这么被打成 cancelledByParent 的)。
56
63
  if (signal !== undefined && typeof signal.addEventListener === 'function') {
57
64
  if (signal.aborted)
58
65
  onAbort();
@@ -27,6 +27,20 @@
27
27
  import type { LoginSites } from './login-sites.ts';
28
28
  /** How a page is read into a snapshot tree. */
29
29
  export type SnapshotEngine = 'legacy' | 'aria';
30
+ /** 进场特效的播放参数(速度/时长/是否只播一次),只存用户改过的项。 */
31
+ export interface SwarmTuning {
32
+ speed?: number;
33
+ maxMs?: number;
34
+ oncePerSite?: boolean;
35
+ /** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
36
+ playOnVeiled?: boolean;
37
+ /** AI 要动手时怎么办:'auto-close' = 立刻关掉动画(默认);'keep-alive' = 动画继续但不挡点击。 */
38
+ whileAiWorks?: 'auto-close' | 'keep-alive';
39
+ /** 播放范围:只画首屏 / 整页(由插件接管滚动,一屏一屏画完)。 */
40
+ range?: 'viewport' | 'page';
41
+ /** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
42
+ persistPlayed?: boolean;
43
+ }
30
44
  /** Current enable state (loads from disk on first call). */
31
45
  export declare function getEnabled(): boolean;
32
46
  /** Set the enable state. Returns true if the value actually changed. */
@@ -40,6 +54,18 @@ export declare function subscribe(fn: (enabled: boolean) => void): () => void;
40
54
  export declare function getHeadless(): boolean | undefined;
41
55
  /** Persist the window-mode choice. Returns true when the value changed. */
42
56
  export declare function setHeadless(next: boolean): boolean;
57
+ /**
58
+ * 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
59
+ */
60
+ export declare function getSwarm(): boolean | undefined;
61
+ /** 记下用户对进场特效的选择。Returns true when the value changed. */
62
+ export declare function setSwarm(next: boolean): boolean;
63
+ /** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
64
+ export declare function isSwarmUserChosen(): boolean;
65
+ /** 播放参数:只含用户改过的项,其余跟配置默认值。 */
66
+ export declare function getSwarmTuning(): SwarmTuning;
67
+ /** 改播放参数(只传要改的项)。Returns true when something changed. */
68
+ export declare function setSwarmTuning(patch: SwarmTuning): boolean;
43
69
  /** Publish the configured default (launch.headless) for display. */
44
70
  export declare function publishConfiguredHeadless(value: boolean): void;
45
71
  /** The configured default, when the provider has published it. */