dsh-browser-playwright-codex 0.7.5 → 0.7.7

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 CHANGED
@@ -1,3 +1,43 @@
1
+ ## 0.7.7 — 2026-10-28
2
+
3
+ 一句话摘要:**首页和 Releases 页能看见版本号了** —— README 顶部加四枚徽章(npm 版本 / 许可证 / 要求的 Node / 最新发布),并把漏掉的 v0.7.5、v0.7.6 补成 GitHub Releases;此前仓库只有 tag,Releases 页停在 v0.7.4。
4
+
5
+ ### Added
6
+
7
+ - **README 顶部徽章行**:npm 版本、许可证、Node 要求、最新发布四个,数据都从 npm 与 GitHub 现取,不手写版本号。
8
+ - **GitHub Releases 补齐**:v0.7.5、v0.7.6、v0.7.7 三份,各自的说明取自本文件的对应章节。此前 `v0.7.5` / `v0.7.6` 只有 tag 没有 Release,所以主页看不到版本号。
9
+
10
+ ### Changed
11
+
12
+ - 无代码改动。本次 `npm publish` 唯一目的是让 npm 页上的 README 与仓库首页一致(npm 展示的是发布那一刻的 README)。
13
+
14
+ ## 0.7.6 — 2026-10-26
15
+
16
+ 一句话摘要:**等待从"猜时长"变成"等条件",升级时旧配置不再静默降级** —— `browser_wait` 现在能等元素出现、等文字出现、等网络安静,条件达成那一刻就返回;同时 0.7.0 遗留的旧加载行第一次被主动报出来(此前它会让部署悄悄跑回默认值,两个强能力等于关着)。
17
+
18
+ ### Fixed
19
+
20
+ - **旧加载行静默降级(升级坑)**:0.7.0 把三行 loader 并成一行 `- id: browser`,旧行不再被读取,但**没有任何提示**——补丁层里还写着 `- id: browser-tool` 的部署会直接按包内默认值运行(`allowEvaluate` / `allowCdp` 默认关闭),用户以为开着其实关着。实测在一个真实的 0.7.4 部署上就是这个状态:工具面只有 16 个,`evaluate` / `cdp` 全不在,而 YAML 里明明写着 `allowEvaluate: true`。新增 `src/legacy-config.ts`:启动时只读扫描 home 层与各 profile 层的 `cordis.patch.yml`,发现旧行就在宿主日志里报出**文件名 + 行号 + 改法**;同一个 DSH home 只报一次,读不到就静默跳过(这条检查永远不会阻止插件加载)。判据只认每行的 `name`(包名),**不认 `- id:`**——id 只是本地标签,别人的插件也可能把行叫成 `browser-tool`,按 id 报警会冤枉人。退役包(`dsh-browser-toggle` / `dsh-browser-playwright`)单独标一类:它们自带旧设置卡,会反复把配置改回旧写法,所以提示里点名"先去掉那一行"。
21
+ - **报错文案还在教旧名字**:`EVALUATE_DISABLED` / `CDP_DISABLED` 的消息此前写"去 browser-tool 插件上打开 allowEvaluate"。那个插件 0.7.0 就没了,照着改还是改不到。现在指向真正的位置:`cordis.patch.yml` 里单行 `- id: browser` 的 `tool.allowEvaluate` / `tool.allowCdp`,并提示设置卡会自动重写这一行。
22
+ - **快照工具的描述让模型去用一个不存在的工具**:合并 click / hover 之后描述里仍写着 `browser_hover`,模型照着调只会得到"没有这个工具"。改成 `browser_click` + `mode:"hover"`。
23
+ - **设置卡写回的 YAML 里 `diagnostics` 键重复**:`renderBrowserBlock` 既按参数写一行、又硬写一行 `diagnostics: false`。解析方各自取第一条所以之前没炸,但重复键在严格 YAML 里是非法的。删掉多余那行。
24
+
25
+ ### Added
26
+
27
+ - **`browser_wait` 支持条件等待**:`selector`(元素可见)、`text`(可见文字出现)、`networkIdle`(500ms 无请求),与旧的 `ms` 四选一。条件达成立刻返回,并回报"等了什么、花了多少毫秒";超时报 `ACTION_TIMEOUT` 并提示先取快照看状态,而不是盲目重试。旧行为完整保留:`session.wait(ms)` 一个字没动,`browser_wait { ms }` 照常可用。
28
+ - **`src/wait-conditions.ts`**:校验与执行分离,所以参数错误在**启动浏览器之前**就被拒;会话层自己也会校验一遍,程序化调用绕不过去。`timeoutMs` 默认 15000,上限由部署的 `maxWaitMs` 收紧。
29
+ - **公开导出(只增不减)**:`normalizeWaitCondition`、`describeWait`、`DEFAULT_WAIT_TIMEOUT_MS`、类型 `WaitCondition` / `WaitOutcome`;方法 `PlaywrightSession.waitForCondition()`,以及 `BrowserSession` 接口里对应的声明。
30
+ - **`tests/legacy-config.test.ts`(10 条)**:旧三行布局逐行报出、合并后的单行布局与注释掉的旧行都算干净、改名但沿用旧 `name` 的行也能抓、**别人的插件把行叫 `browser-tool` 不报**、退役包单独归类、警告文本含文件名/行号/替换写法与判据说明、home 与 profile 层都扫、干净的部署一声不响且同一 home 只报一次、home 不存在不抛错。
31
+ - **`tests/wait-conditions.test.ts`(9 条)**:无条件/双条件被拒、`ms` 边界不变、默认 15s 与部署上限、超时文案指向快照、selector / text / networkIdle 三条执行路径各一条(用假页面,不需要真浏览器)。
32
+
33
+ ### Changed
34
+
35
+ - **说明书瘦身**:新增 4 个参数的同时把常驻成本**净减**——16 工具 11,179 → **11,095** 字符(−84),全量注册 15,782 → 15,698(−84);字符与 token 的对照见 README 的成本表。压缩的是重复与冗词:快照、填写、弹窗、导航、标签五处描述,加上点击工具的四个参数说明;点击工具那张"每条规则必须还在"的守卫表(30 条)一条没动。
36
+
37
+ ### Tests
38
+
39
+ - 全套 255 → **274 条**(+19):通过 271、跳过 3、失败 0。
40
+
1
41
  ## 0.7.5 — 2026-10-26
2
42
 
3
43
  一句话摘要:**两条安全洞补上,同时唤醒不再多开浏览器** —— 白名单从"门口打一次勾"变成"装在浏览器的请求上",`allowEvaluate` / `allowCdp` 从工具层下沉到会话层(绕过工具直接拿服务也拒),两个任务同时要浏览器时共用一次启动,默认持久化模式下「最多几个会话」也真的算数了。
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-browser-playwright-codex
2
2
 
3
+ [![npm 版本](https://img.shields.io/npm/v/dsh-browser-playwright-codex?label=npm)](https://www.npmjs.com/package/dsh-browser-playwright-codex) [![License](https://img.shields.io/npm/l/dsh-browser-playwright-codex)](LICENSE) [![Node](https://img.shields.io/node/v/dsh-browser-playwright-codex)](package.json) [![最新发布](https://img.shields.io/github/v/release/miqian-nomad/dsh-browser-playwright-codex)](https://github.com/miqian-nomad/dsh-browser-playwright-codex/releases)
4
+
3
5
  > [![dsh.so 安装验证 · harness 0.2.0-rc.1](https://www.dsh.so/badge/install/dsh-browser-playwright-codex@0.2.0-rc.1.svg)](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/) [![dsh.so 风险](https://www.dsh.so/badge/dsh-browser-playwright-codex.svg)](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
4
6
 
5
7
  **一句话**:让 AI 用你**已经登录的账号**在真实网页上干活 —— 填表、抓取列表、多标签查后台;浏览器窗口就在你桌面上、你随时看得见。原生弹框会被挂起、**绝不替你点「确定」**(机制级);验证码/挑战页会被识别出来,并以提示要求模型停下交给你(**提示不是阻断**)。登录/二次验证**没有探测器**,靠模型从快照看出来并停下;密码、一次性验证码、卡号字段 `browser_fill` **直接拒绝**——这类字由你本人在窗口里输。
@@ -130,7 +132,7 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
130
132
  | `browser_press` | Press a key (Enter, Tab, Escape, ArrowDown, …) on an element |
131
133
  | `browser_scroll` | Scroll the page or bring a ref into view |
132
134
  | `browser_back` / `browser_forward` | History navigation |
133
- | `browser_wait` | Wait a bounded duration for lazy content |
135
+ | `browser_wait` | Wait for one condition — an element, visible text, a quiet network — or a bounded duration; returns what it waited for |
134
136
  | `browser_tabs` / `browser_open_tab` / `browser_switch_tab` / `browser_close_tab` | Tab management; `browser_tabs` marks the `[active]` tab the other tools act on |
135
137
  | `browser_screenshot` | PNG capture stored as a durable image attachment the model can view |
136
138
  | `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value; **only registered when its capability is enabled** |
@@ -167,7 +169,7 @@ parameter or a wrong required flag fails the test, so it cannot drift from the c
167
169
  - `browser_snapshot` — `interactiveOnly`?
168
170
  - `browser_switch_tab` — `index`*
169
171
  - `browser_tabs` — _none_
170
- - `browser_wait` — `ms`*
172
+ - `browser_wait` — `ms`?, `selector`?, `text`?, `networkIdle`?, `timeoutMs`?
171
173
 
172
174
  > **使用铁律**: 状态检查 / 无效不盲试 / 页面内容不可信 / 登录红线 / 风控分类等 Codex 文档纪律,
173
175
  > 见 [`codex-rules.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
@@ -378,9 +380,9 @@ Measured with ``npm run cost`` (per-turn, resident in the system prompt):
378
380
 
379
381
  | Surface | Tools | Schema characters | ≈ tokens/turn |
380
382
  |---|---|---|---|
381
- | Schema defaults (`Config({})`, no patch layer) | 16 | 11,179 | ≈ 3,105 |
382
- | Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 16 | 11,179 | ≈ 3,105 |
383
- | Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,782 | ≈ 4,384 |
383
+ | Schema defaults (`Config({})`, no patch layer) | 16 | 11,095 | ≈ 3,082 |
384
+ | Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 16 | 11,095 | ≈ 3,082 |
385
+ | Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,698 | ≈ 4,361 |
384
386
 
385
387
  This paragraph describes the 0.6.0 change; the table above is today's measurement. Three things changed
386
388
  there, and only one of them was about size. (1) The click family was rewritten so each rule is stated once
package/lib/errors.js CHANGED
@@ -26,12 +26,12 @@ export function launchFailed(cause) {
26
26
  * @returns the denial, ready to throw.
27
27
  */
28
28
  export function evaluateDisabled() {
29
- return new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
29
+ return new BrowserError('EVALUATE_DISABLED', "browser_evaluate is disabled; set tool.allowEvaluate: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable arbitrary page JavaScript");
30
30
  }
31
31
  /**
32
32
  * "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
33
33
  * @returns the denial, ready to throw.
34
34
  */
35
35
  export function cdpDisabled() {
36
- return new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
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
  }
@@ -75,7 +75,6 @@ export function renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) {
75
75
  ' allowCdp: ' + (allowCdp ? 'true' : 'false'),
76
76
  ' diagnostics: ' + (diagnostics ? 'true' : 'false'),
77
77
  ' registerDisabledTools: false',
78
- ' diagnostics: false',
79
78
  ' maxWaitMs: 60000',
80
79
  END,
81
80
  '',
package/lib/index.d.ts CHANGED
@@ -18,9 +18,10 @@ export { PlaywrightProvider } from './provider.ts';
18
18
  export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
19
19
  export { installHostGuard } from './navigation-guard.ts';
20
20
  export type { BlockedRequest } from './navigation-guard.ts';
21
+ export { DEFAULT_WAIT_TIMEOUT_MS, describeWait, normalizeWaitCondition } from './wait-conditions.ts';
21
22
  export { Config as PlaywrightConfigSchema } from './config.ts';
22
23
  export type { PlaywrightConfig, PageData } from './config.ts';
23
- export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
24
+ export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, WaitCondition, WaitOutcome, } from './types.ts';
24
25
  export type { ToolConfig } from './tool.ts';
25
26
  export { Config as BrowserPluginConfigSchema, apply, name } from './plugin.ts';
26
27
  export type { BrowserPluginConfig } from './plugin.ts';
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as BrowserRuntime } from "./service.js";
12
12
  export { PlaywrightProvider } from "./provider.js";
13
13
  export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
14
14
  export { installHostGuard } from "./navigation-guard.js";
15
+ export { DEFAULT_WAIT_TIMEOUT_MS, describeWait, normalizeWaitCondition } from "./wait-conditions.js";
15
16
  export { Config as PlaywrightConfigSchema } from "./config.js";
16
17
  // Single loader row (2026-10-19): the package root is the plugin. The three
17
18
  // sub-entries below stay importable, but the loader mounts this one.
@@ -0,0 +1,93 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ /** Row ids that 0.7.0 stopped reading. */
3
+ export declare const LEGACY_ROW_IDS: readonly ["browser-playwright", "browser-tool", "browser-playwright-service"];
4
+ /**
5
+ * Subpath entries that 0.7.0 stopped mounting. Rows naming one of these are
6
+ * stale even when the user gave them some other id.
7
+ */
8
+ export declare const LEGACY_ENTRY_NAMES: readonly ["dsh-browser-playwright-codex/tool", "dsh-browser-playwright-codex/playwright", "dsh-browser-playwright-codex/service"];
9
+ /**
10
+ * Retired package names this project owns on npm. A row mounting one of them
11
+ * keeps a second, older settings card alive — and that card rewrites the patch
12
+ * layer in the pre-0.7.0 shape, so the fix keeps reverting.
13
+ *
14
+ * Matching is by package NAME, never by row id: `- id:` is a local label the
15
+ * user picks, so another plugin may legitimately call its row `browser-tool`.
16
+ * Only a row whose `name:` is one of ours is reported.
17
+ */
18
+ export declare const RETIRED_PACKAGES: readonly ["dsh-browser-playwright", "dsh-browser-toggle"];
19
+ /** One loader row that no longer takes effect. */
20
+ export interface LegacyRow {
21
+ /** The row id, or `(no id)` when the row only carried a legacy `name`. */
22
+ readonly id: string;
23
+ /** The row's `name:` value when it had one. */
24
+ readonly name?: string;
25
+ /** 1-based line number of the row's `- id:` line. */
26
+ readonly line: number;
27
+ /**
28
+ * `entry`: a row mounting a subpath entry this package no longer exposes.
29
+ * `package`: a row mounting a whole package that 0.7.0 retired.
30
+ */
31
+ readonly target: 'entry' | 'package';
32
+ }
33
+ /**
34
+ * Find loader rows in one patch file's text that 0.7.0 stopped reading.
35
+ *
36
+ * The evidence is always the row's `name:` — never its `- id:`, which is just a
37
+ * local label. A patch layer that happens to call some unrelated plugin's row
38
+ * `browser-tool` is not reported.
39
+ *
40
+ * Pure and text-only so the rule can be tested without touching a real patch
41
+ * layer. Comments are ignored naturally: a commented row fails the anchored
42
+ * `- id:` / ` name:` patterns.
43
+ *
44
+ * @param text - contents of a cordis.patch.yml layer
45
+ * @returns every stale row, in file order
46
+ */
47
+ export declare function findLegacyRowsIn(text: string): LegacyRow[];
48
+ /**
49
+ * The operator-facing warning for one patch file.
50
+ *
51
+ * Written as one self-contained paragraph pair on purpose: it lands in a log
52
+ * line, so it has to name the file, the rows, and the fix without the reader
53
+ * opening documentation first.
54
+ *
55
+ * @param file - absolute path of the patch layer
56
+ * @param rows - stale rows found in it
57
+ * @returns the warning text
58
+ */
59
+ export declare function legacyRowWarning(file: string, rows: readonly LegacyRow[]): string;
60
+ /**
61
+ * Patch layers that can hold a browser row: the home layer plus every profile
62
+ * layer. Missing files and unreadable directories are skipped silently — this
63
+ * check must never be the reason a plugin fails to load.
64
+ *
65
+ * @param env - environment (`DSH_HOME` override, as the rest of the package uses)
66
+ * @returns candidate file paths, home layer first
67
+ */
68
+ export declare function patchLayers(env?: NodeJS.ProcessEnv): Promise<string[]>;
69
+ /** Stale rows found in one patch file. */
70
+ export interface LegacyHit {
71
+ /** Absolute path of the patch layer. */
72
+ readonly file: string;
73
+ /** Stale rows inside it. */
74
+ readonly rows: readonly LegacyRow[];
75
+ }
76
+ /**
77
+ * Scan every patch layer for stale rows.
78
+ *
79
+ * @param env - environment (`DSH_HOME` override)
80
+ * @returns one entry per file that still lists a retired row; empty when clean
81
+ */
82
+ export declare function scanLegacyRows(env?: NodeJS.ProcessEnv): Promise<LegacyHit[]>;
83
+ /**
84
+ * Warn about stale loader rows, once per DSH home, never throwing.
85
+ *
86
+ * Call it from the plugin entry: it is advisory only, so a failure here must
87
+ * never affect mounting the capability.
88
+ *
89
+ * @param ctx - plugin context (its logger carries the message to the host log)
90
+ * @param env - environment (`DSH_HOME` override, as tests use)
91
+ * @param report - sink for the text (defaults to the context logger, then console)
92
+ */
93
+ export declare function warnLegacyRows(ctx: Context, env?: NodeJS.ProcessEnv, report?: (text: string) => void): Promise<void>;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Legacy loader-row detection (2026-10-19 merge follow-up).
3
+ *
4
+ * 0.7.0 collapsed three loader rows (`browser-playwright`, `browser-tool`, and
5
+ * the `/service` entry) into one `- id: browser` row with nested
6
+ * `service` / `playwright` / `tool` config. The loader simply stops reading the
7
+ * old rows, so an upgraded deployment whose patch layer still lists them starts
8
+ * with the package defaults — and the package ships both power gates OFF.
9
+ *
10
+ * That failure is silent: nothing errors, the tools that the old row would have
11
+ * published are simply absent, and the user believes a capability is still on.
12
+ * A real 0.7.4 deployment was found in exactly this state (16 tools, no
13
+ * `evaluate` / `cdp`, `allowEvaluate: true` sitting in a row nobody reads).
14
+ *
15
+ * This module reads the patch layers (read-only, never writes) and reports the
16
+ * stale rows once per DSH home so the operator can fix the config instead of
17
+ * discovering the downgrade through a missing tool.
18
+ *
19
+ * @module dsh-browser-playwright-codex/legacy-config
20
+ */
21
+ import { readdir, readFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ /** Row ids that 0.7.0 stopped reading. */
25
+ export const LEGACY_ROW_IDS = ['browser-playwright', 'browser-tool', 'browser-playwright-service'];
26
+ /**
27
+ * Subpath entries that 0.7.0 stopped mounting. Rows naming one of these are
28
+ * stale even when the user gave them some other id.
29
+ */
30
+ export const LEGACY_ENTRY_NAMES = [
31
+ 'dsh-browser-playwright-codex/tool',
32
+ 'dsh-browser-playwright-codex/playwright',
33
+ 'dsh-browser-playwright-codex/service',
34
+ ];
35
+ /**
36
+ * Retired package names this project owns on npm. A row mounting one of them
37
+ * keeps a second, older settings card alive — and that card rewrites the patch
38
+ * layer in the pre-0.7.0 shape, so the fix keeps reverting.
39
+ *
40
+ * Matching is by package NAME, never by row id: `- id:` is a local label the
41
+ * user picks, so another plugin may legitimately call its row `browser-tool`.
42
+ * Only a row whose `name:` is one of ours is reported.
43
+ */
44
+ export const RETIRED_PACKAGES = ['dsh-browser-playwright', 'dsh-browser-toggle'];
45
+ /** A `- id:` at column 0 starts a loader row. */
46
+ const ROW_ID = /^- id:\s*['"]?([A-Za-z0-9._-]+)['"]?\s*$/;
47
+ /** `name:` at exactly two spaces belongs to the row, not to a nested config block. */
48
+ const ROW_NAME = /^ {2}name:\s*['"]?([^'"\s#]+)['"]?\s*$/;
49
+ /**
50
+ * Find loader rows in one patch file's text that 0.7.0 stopped reading.
51
+ *
52
+ * The evidence is always the row's `name:` — never its `- id:`, which is just a
53
+ * local label. A patch layer that happens to call some unrelated plugin's row
54
+ * `browser-tool` is not reported.
55
+ *
56
+ * Pure and text-only so the rule can be tested without touching a real patch
57
+ * layer. Comments are ignored naturally: a commented row fails the anchored
58
+ * `- id:` / ` name:` patterns.
59
+ *
60
+ * @param text - contents of a cordis.patch.yml layer
61
+ * @returns every stale row, in file order
62
+ */
63
+ export function findLegacyRowsIn(text) {
64
+ const rows = [];
65
+ const lines = text.split(/\r?\n/);
66
+ let current;
67
+ const finish = () => {
68
+ const name = current?.name;
69
+ if (name !== undefined && LEGACY_ENTRY_NAMES.includes(name)) {
70
+ rows.push({
71
+ id: current?.id ?? '(no id)',
72
+ name,
73
+ line: current?.line ?? 0,
74
+ target: 'entry',
75
+ });
76
+ }
77
+ else if (name !== undefined && RETIRED_PACKAGES.includes(name)) {
78
+ rows.push({
79
+ id: current?.id ?? '(no id)',
80
+ name,
81
+ line: current?.line ?? 0,
82
+ target: 'package',
83
+ });
84
+ }
85
+ current = undefined;
86
+ };
87
+ for (let index = 0; index < lines.length; index += 1) {
88
+ const line = lines[index] ?? '';
89
+ const id = ROW_ID.exec(line);
90
+ if (id !== null) {
91
+ finish();
92
+ const value = id[1];
93
+ if (value !== undefined)
94
+ current = { id: value, line: index + 1 };
95
+ continue;
96
+ }
97
+ if (current === undefined)
98
+ continue;
99
+ const name = ROW_NAME.exec(line);
100
+ const label = name?.[1];
101
+ if (label !== undefined && current.name === undefined)
102
+ current.name = label;
103
+ }
104
+ finish();
105
+ return rows;
106
+ }
107
+ /**
108
+ * The operator-facing warning for one patch file.
109
+ *
110
+ * Written as one self-contained paragraph pair on purpose: it lands in a log
111
+ * line, so it has to name the file, the rows, and the fix without the reader
112
+ * opening documentation first.
113
+ *
114
+ * @param file - absolute path of the patch layer
115
+ * @param rows - stale rows found in it
116
+ * @returns the warning text
117
+ */
118
+ export function legacyRowWarning(file, rows) {
119
+ const listed = rows
120
+ .map((row) => ` - 第 ${String(row.line)} 行: - id: ${row.id} (name: ${row.name ?? '?'})` +
121
+ (row.target === 'package' ? ' ← 退役包' : ' ← 本插件 0.7.0 之前的入口'))
122
+ .join('\n');
123
+ const retired = rows.some((row) => row.target === 'package');
124
+ return [
125
+ 'browser 插件发现 0.7.0 之前的旧加载行,当前版本不再读取它们 —— 那几行里的 config 全部不生效,',
126
+ '插件按包内默认值运行(allowEvaluate / allowCdp 默认关闭,等于你以为开着其实关着)。',
127
+ `文件: ${file}`,
128
+ listed,
129
+ ...(retired
130
+ ? [
131
+ '标了「退役包」的行挂的是本项目的旧包(dsh-browser-toggle / dsh-browser-playwright):它们自带一张旧设置卡,',
132
+ '还会按旧写法改写这个文件,所以只改下面这几行会反复被改回去 —— 先把那一行去掉或禁用。',
133
+ ]
134
+ : []),
135
+ '改法:按 name 删掉这些旧行,然后打开一次设置页的「浏览器」卡片(它会整块重写成一行),',
136
+ "或手写一行 `- id: browser` + `name: 'dsh-browser-playwright-codex'`,并把 service / playwright / tool 三节写全。",
137
+ '判断依据是每行的 name(包名),不是 id —— id 只是本地标签,别人的插件也可能叫 browser-tool。',
138
+ '如果你确认某个 name 不是本插件的包,那它跟这条提示无关,忽略即可。',
139
+ ].join('\n');
140
+ }
141
+ /**
142
+ * Patch layers that can hold a browser row: the home layer plus every profile
143
+ * layer. Missing files and unreadable directories are skipped silently — this
144
+ * check must never be the reason a plugin fails to load.
145
+ *
146
+ * @param env - environment (`DSH_HOME` override, as the rest of the package uses)
147
+ * @returns candidate file paths, home layer first
148
+ */
149
+ export async function patchLayers(env = process.env) {
150
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
151
+ const files = [join(home, 'cordis.patch.yml')];
152
+ const profiles = join(home, 'profiles');
153
+ let entries = [];
154
+ try {
155
+ entries = await readdir(profiles);
156
+ }
157
+ catch {
158
+ entries = [];
159
+ }
160
+ for (const entry of entries.sort())
161
+ files.push(join(profiles, entry, 'cordis.patch.yml'));
162
+ return files;
163
+ }
164
+ /**
165
+ * Scan every patch layer for stale rows.
166
+ *
167
+ * @param env - environment (`DSH_HOME` override)
168
+ * @returns one entry per file that still lists a retired row; empty when clean
169
+ */
170
+ export async function scanLegacyRows(env = process.env) {
171
+ const hits = [];
172
+ for (const file of await patchLayers(env)) {
173
+ let text = '';
174
+ try {
175
+ text = await readFile(file, 'utf8');
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ const rows = findLegacyRowsIn(text);
181
+ if (rows.length > 0)
182
+ hits.push({ file, rows });
183
+ }
184
+ return hits;
185
+ }
186
+ /** DSH homes already scanned in this process, so one load logs once. */
187
+ const scannedHomes = new Set();
188
+ /**
189
+ * Warn about stale loader rows, once per DSH home, never throwing.
190
+ *
191
+ * Call it from the plugin entry: it is advisory only, so a failure here must
192
+ * never affect mounting the capability.
193
+ *
194
+ * @param ctx - plugin context (its logger carries the message to the host log)
195
+ * @param env - environment (`DSH_HOME` override, as tests use)
196
+ * @param report - sink for the text (defaults to the context logger, then console)
197
+ */
198
+ export async function warnLegacyRows(ctx, env = process.env, report) {
199
+ if (env.DSH_BROWSER_SKIP_LEGACY_SCAN === '1')
200
+ return;
201
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
202
+ if (scannedHomes.has(home))
203
+ return;
204
+ scannedHomes.add(home);
205
+ let hits = [];
206
+ try {
207
+ hits = await scanLegacyRows(env);
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ if (hits.length === 0)
213
+ return;
214
+ const warn = report ?? loggerWarn(ctx);
215
+ for (const hit of hits) {
216
+ try {
217
+ warn(legacyRowWarning(hit.file, hit.rows));
218
+ }
219
+ catch {
220
+ // A logger that is not mounted yet must not break the load.
221
+ }
222
+ }
223
+ }
224
+ /** Best-effort warn sink: the context logger when it exists, otherwise console. */
225
+ function loggerWarn(ctx) {
226
+ return (text) => {
227
+ try {
228
+ if (ctx.logger !== undefined) {
229
+ ctx.logger.warn(text);
230
+ return;
231
+ }
232
+ }
233
+ catch {
234
+ // fall through to console
235
+ }
236
+ console.warn(text);
237
+ };
238
+ }
package/lib/plugin.js CHANGED
@@ -5,6 +5,7 @@ import * as tool from "./tool.js";
5
5
  import * as settingsCard from "./settings-card.js";
6
6
  import { Config as PlaywrightConfigSchema } from "./config.js";
7
7
  import { Config as ToolConfigSchema } from "./tool.js";
8
+ import { warnLegacyRows } from "./legacy-config.js";
8
9
  /** Schemastery validation for {@link BrowserPluginConfig}. */
9
10
  export const Config = z.object({
10
11
  service: ServiceConfig,
@@ -21,6 +22,10 @@ export const name = 'browser';
21
22
  * @param config - nested config for each concern; the loader fills defaults
22
23
  */
23
24
  export function apply(ctx, config) {
25
+ // 0.7.0 stopped reading the three legacy rows, and a stale row downgrades the
26
+ // deployment to package defaults (both power gates off) without an error.
27
+ // Advisory only: it reads the patch layers and never blocks the mount.
28
+ void warnLegacyRows(ctx).catch(() => { });
24
29
  ctx.plugin(BrowserRuntime, config.service ?? {});
25
30
  ctx.plugin(playwright, config.playwright);
26
31
  ctx.plugin(tool, config.tool);
package/lib/session.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwright-core';
7
7
  import type { PlaywrightProvider } from './provider.ts';
8
- import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState } from './types.ts';
8
+ import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState, WaitCondition, WaitOutcome } from './types.ts';
9
9
  import type { RefSignature } from './page-snapshot.ts';
10
10
  import { type CookieRow } from './login-sites.ts';
11
11
  import type { ActionResult, PendingDialogRecord } from './provider.ts';
@@ -274,6 +274,12 @@ export declare class PlaywrightSession {
274
274
  back(signal?: AbortSignal): Promise<BrowserSnapshot>;
275
275
  forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
276
276
  wait(ms: number, signal?: AbortSignal): Promise<void>;
277
+ /**
278
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
279
+ * quiet network. Validated here as well, so a programmatic caller gets the
280
+ * same refusals the tool layer gives instead of a raw Playwright timeout.
281
+ */
282
+ waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
277
283
  tabs(): Promise<{
278
284
  index: number;
279
285
  url: string;
package/lib/session.js CHANGED
@@ -8,6 +8,7 @@ import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
8
8
  import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
9
9
  import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
10
10
  import { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
11
+ import { normalizeWaitCondition, SESSION_WAIT_CAP_MS, waitForCondition as runWait } from "./wait-conditions.js";
11
12
  /** Live session over one browser context owned by one caller. */
12
13
  export class PlaywrightSession {
13
14
  provider;
@@ -496,6 +497,14 @@ export class PlaywrightSession {
496
497
  await withAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal);
497
498
  });
498
499
  }
500
+ /**
501
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
502
+ * quiet network. Validated here as well, so a programmatic caller gets the
503
+ * same refusals the tool layer gives instead of a raw Playwright timeout.
504
+ */
505
+ async waitForCondition(condition, signal) {
506
+ return runWait(this, normalizeWaitCondition(condition, SESSION_WAIT_CAP_MS), signal);
507
+ }
499
508
  async tabs() {
500
509
  return this.run(async () => {
501
510
  this.assertLive();
package/lib/tool.js CHANGED
@@ -9,6 +9,7 @@ import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
9
9
  import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
10
10
  import { parseJsonText, extractionPrompt } from "./extract.js";
11
11
  import { detectChallenge } from "./challenge.js";
12
+ import { describeWait, normalizeWaitCondition } from "./wait-conditions.js";
12
13
  import { renderDiagnostics, renderScreenshotValue, renderSnapshotValue, renderTabsValue, snapshotValue, } from "./tool-render.js";
13
14
  export { detectChallenge };
14
15
  /** Cordis plugin name used by loader diagnostics. */
@@ -124,10 +125,9 @@ export function apply(ctx, config) {
124
125
  };
125
126
  ctx.tools.register(defineTool({
126
127
  name: p('navigate'),
127
- description: 'Navigate the persistent browser to a URL and return the new page snapshot. ' +
128
- 'The snapshot lists visible elements with stable ref= identifiers for later click/fill calls. ' +
129
- 'IRON RULE: if the page is already on the target URL, do NOT navigate to it again — re-navigating the ' +
130
- 'same URL reloads the page and can discard in-progress user input; only navigate when the URL must actually change.',
128
+ description: 'Navigate the persistent browser to a URL and return the new snapshot: visible elements with stable refs for later click/fill. ' +
129
+ 'IRON RULE: if the page is already on that URL, do NOT navigate again — a re-navigation reloads it and can ' +
130
+ 'discard input the user has typed; only navigate when the URL must actually change.',
131
131
  parameters: {
132
132
  url: { type: 'string', required: true, description: 'Absolute http(s) URL to open.' },
133
133
  waitFor: {
@@ -145,21 +145,17 @@ export function apply(ctx, config) {
145
145
  }));
146
146
  ctx.tools.register(defineTool({
147
147
  name: p('snapshot'),
148
- description: 'Return the current page snapshot: URL, title, and visible elements with stable ref= identifiers. ' +
149
- 'Use this after dynamic content changes to refresh refs before clicking. ' +
150
- 'An element shown with a name but NO [ref=...] has no ref handle of its own — ' +
148
+ description: 'Snapshot the current page: URL, title, visible elements with stable ref= ids. ' +
149
+ 'Take one after dynamic changes to refresh refs before clicking. ' +
150
+ 'A named element with NO [ref=...] has no handle of its own: ' +
151
151
  p('click') +
152
- ' and ' +
153
- p('hover') +
154
- ' still reach it by passing its exact label as text, as long as the element is visible at that moment. ' +
155
- 'An effect can be real even when the URL does not change and no tab opens (a same-page overlay or popup): compare refs and content between snapshots instead of trusting the URL. ' +
156
- 'A native dialog (alert / confirm / prompt / beforeunload) is not part of the page: it is left PENDING and blocks the page, reported at the top of the result as [dialog] type "message"; answer it with ' +
152
+ ' reaches it by exact label text (also with mode:"hover") while it is visible. ' +
153
+ 'A real effect can leave the URL and tab count unchanged (same-page overlay): compare refs and content between snapshots, not the URL. ' +
154
+ 'A native dialog (alert / confirm / prompt / beforeunload) is not page content: it stays PENDING, blocks the page, and is reported as [dialog] type "message"; answer it with ' +
157
155
  p('dialog') +
158
- '({ accept }) and accept only when the user authorized exactly that action. ' +
159
- 'IRON RULE: a snapshot is the cheapest state check after any click/type/scroll — confirm the previous ' +
160
- 'action actually took effect here (URL change, selection, toast, new content) before your next action. ' +
161
- 'Pick ONE verification channel per step: prefer a snapshot for DOM/locator ground truth or a screenshot ' +
162
- 'when visual confirmation matters, but avoid requesting both by default.',
156
+ '({ accept }) — only when the user authorized exactly that action. ' +
157
+ 'IRON RULE: after any click/type/scroll this is the cheapest check that the action took effect (URL, selection, toast, new content). ' +
158
+ 'Use one verification channel per step: snapshot for DOM truth, screenshot when visuals matter — not both by default.',
163
159
  parameters: {
164
160
  interactiveOnly: {
165
161
  type: 'boolean',
@@ -189,19 +185,19 @@ export function apply(ctx, config) {
189
185
  parameters: {
190
186
  ref: {
191
187
  type: 'string',
192
- description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
188
+ description: 'Ref from the latest snapshot (e.g. e12); preferred when the snapshot has one.',
193
189
  },
194
190
  text: {
195
191
  type: 'string',
196
- description: 'Visible label of a target shown WITHOUT a ref, copied exactly as the snapshot renders it; the first visible element whose own text matches is used and the note names it.',
192
+ description: 'Visible label of a target shown WITHOUT a ref, copied exactly; the first visible match is used and the note names it.',
197
193
  },
198
194
  x: {
199
195
  type: 'integer',
200
- description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
196
+ description: 'Viewport x in CSS px; with y, a raw click — mode "click" only, last resort.',
201
197
  },
202
198
  y: {
203
199
  type: 'integer',
204
- description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
200
+ description: 'Viewport y in CSS px; with x, a raw click — mode "click" only, last resort.',
205
201
  },
206
202
  mode: {
207
203
  type: 'string',
@@ -210,7 +206,7 @@ export function apply(ctx, config) {
210
206
  },
211
207
  precise: {
212
208
  type: 'boolean',
213
- description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
209
+ description: 'Ref clicks only: click the geometric centre through native input.',
214
210
  },
215
211
  },
216
212
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
@@ -242,12 +238,12 @@ export function apply(ctx, config) {
242
238
  }));
243
239
  ctx.tools.register(defineTool({
244
240
  name: p('dialog'),
245
- description: 'Answer the native dialog currently blocking the page — alert, confirm, prompt or beforeunload — and return the fresh snapshot. ' +
246
- 'A dialog is NEVER auto-answered: it stays PENDING until this call. The call that raised it and ' +
241
+ description: 'Answer the dialog blocking the page (alert / confirm / prompt / beforeunload) and return the fresh snapshot. ' +
242
+ 'Never auto-answered: it stays PENDING until this call; the raising call and ' +
247
243
  p('snapshot') +
248
- ' report it as a note; every other tool refuses to act and fails with DIALOG_PENDING. ' +
249
- 'accept: true confirms it (OK / leave page), false cancels it (Cancel / stay on page). Accept only when the user has explicitly authorized exactly this action (delete / pay / submit / overwrite); never confirm on your own initiative, and state in your reply which dialog you answered and how. ' +
250
- 'A prompt takes its value through promptText. Without a pending dialog this errors with NO_DIALOG — there is no way to accept a dialog the page never raised.',
244
+ ' report it, every other tool fails with DIALOG_PENDING. ' +
245
+ 'accept: true confirms (OK / leave page), false cancels. Only when the user explicitly authorized exactly this action (delete / pay / submit / overwrite) — never on your own initiative; say in your reply which dialog you answered and how. ' +
246
+ 'A prompt takes promptText. With no dialog pending this errors NO_DIALOG: a dialog the page never raised cannot be accepted.',
251
247
  parameters: {
252
248
  accept: {
253
249
  type: 'boolean',
@@ -271,19 +267,16 @@ export function apply(ctx, config) {
271
267
  ctx.tools.register(defineTool({
272
268
  name: p('fill'),
273
269
  description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
274
- '`value` is accepted as an alias of `text`. ' +
275
- 'For <select> elements the text selects the matching option label. ' +
270
+ '`value` is an alias of `text`; for <select> the text selects the matching option label. ' +
276
271
  'Credential fields (password, one-time code, card number) are refused with CREDENTIAL_FIELD: ' +
277
- 'a human types secrets in the visible window, so ask them and re-snapshot instead. ' +
278
- 'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
279
- 'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
280
- 'input-mask/currency fields). If the value is wrong or missing, do NOT blindly re-fill or append — ' +
281
- 'inspect the field DOM first — ' +
272
+ 'a human types secrets in the visible window, so ask them and re-snapshot. ' +
273
+ 'IRON RULE: verify in the returned snapshot that the field holds the value — frameworks can ' +
274
+ 'silently reject or transform input (controlled components, contenteditable, input-mask/currency). ' +
275
+ 'If it is wrong or missing, do NOT re-fill or append: inspect the field DOM first (' +
282
276
  p('evaluate') +
283
277
  ' reading value/outerHTML, or ' +
284
278
  p('cdp') +
285
- ' Input.insertText for contenteditable (both are gated — when one is missing from your tool list, ' +
286
- 'say so) — understand why it was ignored, then act.',
279
+ ' Input.insertText for contenteditable; both are gated, so say so when one is missing), understand why it was ignored, then act.',
287
280
  parameters: {
288
281
  ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
289
282
  text: {
@@ -361,27 +354,54 @@ export function apply(ctx, config) {
361
354
  }));
362
355
  ctx.tools.register(defineTool({
363
356
  name: p('wait'),
364
- description: 'Wait a fixed duration for slow pages or lazy content to settle.',
357
+ description: 'Wait for ONE condition: `selector` (element becomes visible), `text` (visible text appears), ' +
358
+ '`networkIdle` (no request for 500ms) or `ms` (plain sleep). Prefer a goal over `ms` — it returns ' +
359
+ 'the moment the page is ready, neither too early nor wasteful. Reports what it waited for and how long.',
365
360
  parameters: {
366
361
  ms: {
367
362
  type: 'integer',
368
- required: true,
369
- description: 'Milliseconds to wait, up to ' + String(config.maxWaitMs) + '.',
363
+ description: 'Fixed sleep in milliseconds, up to ' +
364
+ String(config.maxWaitMs) +
365
+ '. Use it only when nothing observable marks the end of the wait.',
366
+ },
367
+ selector: { type: 'string', description: 'Wait until an element matching this CSS selector is visible.' },
368
+ text: { type: 'string', description: 'Wait until visible text containing this string appears.' },
369
+ networkIdle: { type: 'boolean', description: 'Wait until the page stops issuing requests for 500ms.' },
370
+ timeoutMs: {
371
+ type: 'integer',
372
+ description: 'Upper bound for goal conditions: default 15000, max ' + String(config.maxWaitMs) + '.',
373
+ },
374
+ },
375
+ output: {
376
+ schema: { type: 'json' },
377
+ render: (_args, value) => {
378
+ const outcome = value;
379
+ return [{ type: 'text', text: 'Waited for ' + outcome.waitedFor + ' (' + String(outcome.elapsedMs) + 'ms).' }];
370
380
  },
371
381
  },
372
- output: { schema: { type: 'null' }, render: (_args, _value) => [{ type: 'text', text: 'Waited.' }] },
373
382
  async execute(args, exec) {
374
- if (!Number.isSafeInteger(args.ms) || args.ms <= 0 || args.ms > config.maxWaitMs) {
375
- throw new Error('browser_wait: ms must be a positive integer no larger than ' + String(config.maxWaitMs));
376
- }
383
+ const condition = {
384
+ ...(args.ms === undefined ? {} : { ms: args.ms }),
385
+ ...(args.selector === undefined ? {} : { selector: args.selector }),
386
+ ...(args.text === undefined ? {} : { text: args.text }),
387
+ ...(args.networkIdle === undefined ? {} : { networkIdle: args.networkIdle }),
388
+ ...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
389
+ };
390
+ // Validate before acquiring: a malformed call must not launch a browser.
391
+ const normalized = normalizeWaitCondition(condition, config.maxWaitMs);
377
392
  const session = await acquire(exec);
378
- await session.wait(args.ms, exec.signal);
379
- return null;
393
+ if (normalized.kind === 'ms') {
394
+ const started = Date.now();
395
+ await session.wait(normalized.ms, exec.signal);
396
+ return { waitedFor: describeWait(normalized), elapsedMs: Date.now() - started };
397
+ }
398
+ const outcome = await session.waitForCondition(condition, exec.signal);
399
+ return { waitedFor: outcome.waitedFor, elapsedMs: outcome.elapsedMs };
380
400
  },
381
401
  }));
382
402
  ctx.tools.register(defineTool({
383
403
  name: p('tabs'),
384
- description: 'List the open tabs of this session with their indexes, URLs, and titles. The tab marked [active] is the page every other browser tool acts on.',
404
+ description: 'List open tabs: index, URL, title. [active] marks the tab every other tool acts on.',
385
405
  parameters: {},
386
406
  output: { schema: TABS_SCHEMA, render: renderTabsValue },
387
407
  async execute(_args, exec) {
package/lib/types.d.ts CHANGED
@@ -117,6 +117,29 @@ export interface TabInfo {
117
117
  /** True for the tab the session currently drives. */
118
118
  readonly active: boolean;
119
119
  }
120
+ /**
121
+ * What `browser_wait` should wait for. Exactly one of `ms`, `selector`, `text`
122
+ * or `networkIdle` per call; `timeoutMs` bounds the three goal conditions.
123
+ */
124
+ export interface WaitCondition {
125
+ /** Fixed sleep in milliseconds (the pre-0.7.6 behaviour, unchanged). */
126
+ readonly ms?: number;
127
+ /** Wait until an element matching this CSS selector is visible. */
128
+ readonly selector?: string;
129
+ /** Wait until visible text containing this string appears. */
130
+ readonly text?: string;
131
+ /** Wait until the page stops issuing requests for 500ms. */
132
+ readonly networkIdle?: boolean;
133
+ /** Upper bound for a goal condition; defaults to 15000 and is capped by the deployment. */
134
+ readonly timeoutMs?: number;
135
+ }
136
+ /** What a completed wait reports back. */
137
+ export interface WaitOutcome {
138
+ /** Human description of the satisfied condition. */
139
+ readonly waitedFor: string;
140
+ /** Wall-clock milliseconds the wait actually took. */
141
+ readonly elapsedMs: number;
142
+ }
120
143
  /** PNG capture of a page or element. */
121
144
  export interface ScreenshotCapture {
122
145
  readonly mime: 'image/png';
@@ -200,6 +223,11 @@ export interface BrowserSession {
200
223
  forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
201
224
  /** Wait a bounded duration, letting lazy content arrive. */
202
225
  wait(ms: number, signal?: AbortSignal): Promise<void>;
226
+ /**
227
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
228
+ * quiet network. `wait(ms)` stays as it was, so this is purely additive.
229
+ */
230
+ waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
203
231
  /** List open tabs. */
204
232
  tabs(): Promise<readonly TabInfo[]>;
205
233
  /** Activate the tab at the given index. */
@@ -0,0 +1,55 @@
1
+ import type { PlaywrightSession } from './session.ts';
2
+ import type { WaitCondition, WaitOutcome } from './types.ts';
3
+ /** Timeout used when the caller does not pass `timeoutMs`. */
4
+ export declare const DEFAULT_WAIT_TIMEOUT_MS = 15000;
5
+ /**
6
+ * Absolute ceiling for a goal wait at the session layer. The tool clamps to
7
+ * `tool.maxWaitMs` first; this only stops a programmatic caller from parking a
8
+ * session for an hour on a selector that will never appear.
9
+ */
10
+ export declare const SESSION_WAIT_CAP_MS = 120000;
11
+ /** One validated wait, ready to run. */
12
+ export type NormalizedWait = {
13
+ readonly kind: 'ms';
14
+ readonly ms: number;
15
+ } | {
16
+ readonly kind: 'selector';
17
+ readonly selector: string;
18
+ readonly timeoutMs: number;
19
+ } | {
20
+ readonly kind: 'text';
21
+ readonly text: string;
22
+ readonly timeoutMs: number;
23
+ } | {
24
+ readonly kind: 'networkIdle';
25
+ readonly timeoutMs: number;
26
+ };
27
+ /**
28
+ * Validate and normalize one wait request.
29
+ *
30
+ * Exactly one condition is allowed per call. Accepting several would make the
31
+ * composed semantics ("all of them? in which order? what if the first never
32
+ * comes?") something the model has to guess, and the tool description would
33
+ * have to grow to explain it.
34
+ *
35
+ * @param condition - what the caller asked for
36
+ * @param maxWaitMs - deployment cap (`tool.maxWaitMs`)
37
+ * @returns the validated wait
38
+ * @throws Error with an actionable message when the request is malformed
39
+ */
40
+ export declare function normalizeWaitCondition(condition: WaitCondition, maxWaitMs: number): NormalizedWait;
41
+ /** Human name of what a normalized wait is waiting for. */
42
+ export declare function describeWait(wait: NormalizedWait): string;
43
+ /**
44
+ * Run one validated wait against the session's active page.
45
+ *
46
+ * A timeout is reported as `ACTION_TIMEOUT` naming what was awaited: the model
47
+ * then knows the page is still working rather than that its action failed, and
48
+ * its next move is a snapshot instead of a blind retry.
49
+ *
50
+ * @param session - the session whose active page to wait on
51
+ * @param wait - a value from {@link normalizeWaitCondition}
52
+ * @param signal - abort signal from the tool call
53
+ * @returns what was waited for and how long it took
54
+ */
55
+ export declare function waitForCondition(session: PlaywrightSession, wait: NormalizedWait, signal?: AbortSignal): Promise<WaitOutcome>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Goal-directed waits behind `browser_wait`.
3
+ *
4
+ * The original tool could only sleep for a fixed number of milliseconds, which
5
+ * forces the model to guess: too short and the click lands on a half-rendered
6
+ * page, too long and every call burns wall-clock time for nothing. These three
7
+ * conditions replace the guess with the actual thing being waited for:
8
+ *
9
+ * - `selector`: an element matching a CSS selector becomes visible
10
+ * - `text`: visible text containing a string appears
11
+ * - `networkIdle`: the page stops issuing requests for 500ms
12
+ *
13
+ * `ms` stays supported exactly as before, so every existing call keeps working
14
+ * (`session.wait(ms)` is untouched; this is a separate entry point).
15
+ *
16
+ * Validation is separate from execution on purpose: a malformed call must fail
17
+ * before a browser is launched.
18
+ *
19
+ * @module dsh-browser-playwright-codex/wait-conditions
20
+ */
21
+ import { BrowserError } from "./errors.js";
22
+ import { withAbort } from "./page-actions.js";
23
+ /** Timeout used when the caller does not pass `timeoutMs`. */
24
+ export const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
25
+ /**
26
+ * Absolute ceiling for a goal wait at the session layer. The tool clamps to
27
+ * `tool.maxWaitMs` first; this only stops a programmatic caller from parking a
28
+ * session for an hour on a selector that will never appear.
29
+ */
30
+ export const SESSION_WAIT_CAP_MS = 120_000;
31
+ /**
32
+ * Validate and normalize one wait request.
33
+ *
34
+ * Exactly one condition is allowed per call. Accepting several would make the
35
+ * composed semantics ("all of them? in which order? what if the first never
36
+ * comes?") something the model has to guess, and the tool description would
37
+ * have to grow to explain it.
38
+ *
39
+ * @param condition - what the caller asked for
40
+ * @param maxWaitMs - deployment cap (`tool.maxWaitMs`)
41
+ * @returns the validated wait
42
+ * @throws Error with an actionable message when the request is malformed
43
+ */
44
+ export function normalizeWaitCondition(condition, maxWaitMs) {
45
+ const given = [
46
+ condition.ms === undefined ? undefined : 'ms',
47
+ condition.selector === undefined ? undefined : 'selector',
48
+ condition.text === undefined ? undefined : 'text',
49
+ condition.networkIdle === true ? 'networkIdle' : undefined,
50
+ ].filter((name) => name !== undefined);
51
+ if (given.length === 0) {
52
+ throw new Error('browser_wait: pass one of ms, selector, text or networkIdle — sleeping without a condition is the guess this tool exists to remove');
53
+ }
54
+ if (given.length > 1) {
55
+ throw new Error('browser_wait: pass only one condition per call (received ' +
56
+ given.join(', ') +
57
+ '); call it again for the next one');
58
+ }
59
+ const cap = Math.max(1, Math.floor(maxWaitMs));
60
+ if (condition.ms !== undefined) {
61
+ if (!Number.isSafeInteger(condition.ms) || condition.ms <= 0 || condition.ms > cap) {
62
+ throw new Error('browser_wait: ms must be a positive integer no larger than ' + String(cap));
63
+ }
64
+ return { kind: 'ms', ms: condition.ms };
65
+ }
66
+ const requested = condition.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
67
+ if (!Number.isSafeInteger(requested) || requested <= 0) {
68
+ throw new Error('browser_wait: timeoutMs must be a positive integer');
69
+ }
70
+ const timeoutMs = Math.min(requested, cap);
71
+ if (condition.selector !== undefined) {
72
+ const selector = condition.selector.trim();
73
+ if (selector === '')
74
+ throw new Error('browser_wait: selector must not be empty');
75
+ return { kind: 'selector', selector, timeoutMs };
76
+ }
77
+ if (condition.text !== undefined) {
78
+ const text = condition.text.trim();
79
+ if (text === '')
80
+ throw new Error('browser_wait: text must not be empty');
81
+ return { kind: 'text', text, timeoutMs };
82
+ }
83
+ return { kind: 'networkIdle', timeoutMs };
84
+ }
85
+ /** Human name of what a normalized wait is waiting for. */
86
+ export function describeWait(wait) {
87
+ switch (wait.kind) {
88
+ case 'ms':
89
+ return 'a fixed ' + String(wait.ms) + 'ms';
90
+ case 'selector':
91
+ return 'selector ' + JSON.stringify(wait.selector) + ' to become visible';
92
+ case 'text':
93
+ return 'text ' + JSON.stringify(wait.text) + ' to become visible';
94
+ case 'networkIdle':
95
+ return 'the network to go idle (no request for 500ms)';
96
+ }
97
+ }
98
+ /**
99
+ * Run one validated wait against the session's active page.
100
+ *
101
+ * A timeout is reported as `ACTION_TIMEOUT` naming what was awaited: the model
102
+ * then knows the page is still working rather than that its action failed, and
103
+ * its next move is a snapshot instead of a blind retry.
104
+ *
105
+ * @param session - the session whose active page to wait on
106
+ * @param wait - a value from {@link normalizeWaitCondition}
107
+ * @param signal - abort signal from the tool call
108
+ * @returns what was waited for and how long it took
109
+ */
110
+ export async function waitForCondition(session, wait, signal) {
111
+ const started = Date.now();
112
+ return session.run(async () => {
113
+ session.provider.touch(session.owner);
114
+ if (wait.kind === 'ms') {
115
+ await withAbort(new Promise((resolve) => setTimeout(resolve, wait.ms)), signal);
116
+ return { waitedFor: describeWait(wait), elapsedMs: Date.now() - started };
117
+ }
118
+ const page = await session.ensurePage();
119
+ try {
120
+ if (wait.kind === 'selector') {
121
+ await withAbort(page.waitForSelector(wait.selector, { state: 'visible', timeout: wait.timeoutMs }), signal);
122
+ }
123
+ else if (wait.kind === 'text') {
124
+ await withAbort(page.getByText(wait.text).first().waitFor({ state: 'visible', timeout: wait.timeoutMs }), signal);
125
+ }
126
+ else {
127
+ await withAbort(page.waitForLoadState('networkidle', { timeout: wait.timeoutMs }), signal);
128
+ }
129
+ }
130
+ catch (error) {
131
+ if (signal?.aborted === true)
132
+ throw error;
133
+ throw new BrowserError('ACTION_TIMEOUT', 'wait timed out after ' +
134
+ String(wait.timeoutMs) +
135
+ 'ms waiting for ' +
136
+ describeWait(wait) +
137
+ '; the page may still be loading — take a snapshot to see where it stands instead of retrying blind');
138
+ }
139
+ return { waitedFor: describeWait(wait), elapsedMs: Date.now() - started };
140
+ });
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
5
5
  "license": "MIT",
6
6
  "type": "module",