dsh-browser-playwright-codex 0.5.5 → 0.6.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/tool.js CHANGED
@@ -3,7 +3,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
4
4
  import { BrowserError } from "./errors.js";
5
5
  import { registeredSuffixes } from "./contract.js";
6
- import { getEnabled, publishGates } from "./runtime-state.js";
6
+ import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
7
+ import { summarizeLoginSites } from "./login-sites.js";
7
8
  import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
8
9
  import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
9
10
  import { parseJsonText, extractionPrompt } from "./extract.js";
@@ -68,11 +69,37 @@ export function apply(ctx, config) {
68
69
  return id === undefined ? 'anonymous' : String(id);
69
70
  };
70
71
  /** Acquire the calling session's live browser session. */
72
+ /**
73
+ * Publish the "which sites look logged in" summary for the settings card. Throttled, and
74
+ * every failure is swallowed: this is a display nicety and must never slow down or break an
75
+ * action. Nothing here is model-visible — it travels only to the settings route.
76
+ */
77
+ let lastLoginPublish = 0;
78
+ const publishLogins = (session) => {
79
+ const now = Date.now();
80
+ if (now - lastLoginPublish < 5000)
81
+ return;
82
+ lastLoginPublish = now;
83
+ void session
84
+ .cookieJar()
85
+ .then(async (rows) => {
86
+ publishLoginSites(summarizeLoginSites(rows, 'live', now, {
87
+ visited: session.visitedHosts(),
88
+ titles: await session.siteTitles(),
89
+ }));
90
+ })
91
+ .catch(() => {
92
+ /* display-only: a failed cookie read must not surface as a tool failure */
93
+ });
94
+ };
71
95
  const acquire = (exec) => {
72
96
  if (!getEnabled()) {
73
97
  throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
74
98
  }
75
- return ctx.browser.acquire(ownerFor(exec), exec.signal);
99
+ return ctx.browser.acquire(ownerFor(exec), exec.signal).then((session) => {
100
+ publishLogins(session);
101
+ return session;
102
+ });
76
103
  };
77
104
  const services = () => {
78
105
  const out = {};
@@ -137,46 +164,69 @@ export function apply(ctx, config) {
137
164
  }));
138
165
  ctx.tools.register(defineTool({
139
166
  name: p('click'),
140
- description: 'Click the element with the given ref from the latest snapshot and return the new snapshot. ' +
141
- 'Refs go stale after navigation or DOM changes — take a fresh ' +
142
- p('snapshot') +
143
- ' if a ref fails. ' +
144
- 'Prefer ref when the snapshot has one; for a target shown WITHOUT a ref (plain text, e.g. generic "设置", or a menu item ' +
145
- p('hover') +
146
- ' just revealed), pass text instead, copied exactly as rendered — a revealed item may need ' +
147
- p('hover') +
148
- ' first to stay on screen. ' +
149
- 'IRON RULE: after the click, verify in the returned snapshot that the intended effect actually happened (URL, selection, toast, dialog, new content). ' +
150
- 'A click can also succeed without changing this page: when the snapshot shows no change, check ' +
167
+ description: 'Click the target from the latest snapshot — or reveal it without clicking (mode:"hover") — and return the new snapshot. ' +
168
+ 'Best target first: `ref` when the snapshot has one; `text`, a visible label copied exactly as rendered, for a target shown WITHOUT a ref; `x`+`y` viewport CSS pixels only as a last resort (canvas-drawn hit areas). Refs go stale after navigation or DOM changes: take a fresh snapshot if a ref fails. ' +
169
+ 'mode:"hover" only REVEALS content and never activates: use it when a control hides content until the pointer rests on it, especially when the snapshot lacks an element the page obviously should have. A hover returning only means the pointer moved: read the note comparing the actionable ref count before and after; new refs mean pre-hover refs are stale, so act from the NEW refs before an unrelated click closes the menu. If that note reports no new refs, do NOT repeat the hover and do NOT fall back to raw coordinates — hover the parent/container or the sibling that owns the menu, otherwise use a visible alternative or tell the user. ' +
170
+ 'precise:true takes a ref click down the geometric path instead: the element is scrolled into view and clicked at its exact centre through native input, with no screenshot measuring, no devicePixelRatio guesswork. It is the FALLBACK, not a routine alternative — use it only after a ref click reports the element is not actionable, or that the click could not be delivered. ' +
171
+ 'IRON RULE: verify the effect in the returned snapshot (URL, selection, toast, dialog, new content). No change may still be success: check ' +
151
172
  p('tabs') +
152
- ' for a new tab (a target="_blank" link) before concluding failure, and treat a same-page overlay or popup, URL unchanged and no new tab, as success — the returned snapshot is the evidence: compare its refs and content with the pre-click snapshot. ' +
153
- 'The snapshot may open with a landing note naming what received the click: if that is not your target, the click was intercepted — treat it as failed and retarget. ' +
154
- 'If there was no visible effect, do NOT blindly repeat the click and do NOT fall back to ' +
155
- p('click_at') +
156
- ' raw coordinates — inspect the visible state for the blocker or interception, resolve it, then retry the most direct semantic action. ' +
157
- 'If the click raises a native dialog, it stays PENDING and this call returns immediately with the dialog text instead of the page tree — answer it with ' +
173
+ ' for a new tab (a target="_blank" link) before concluding, and treat a same-page overlay as success. ' +
174
+ 'The landing note names what received the click: if that is not your target the click was intercepted — treat it as failed and retarget; do NOT blindly repeat it and do NOT throw raw coordinates at it, but inspect the visible state for the blocker or interception first, resolve that, then retry the most direct semantic action. ' +
175
+ 'A native dialog stays PENDING and this returns immediately with the dialog text; this tool cannot accept it — ' +
158
176
  p('dialog') +
159
- '. This tool has no way to accept a dialog: accepting is a separate, deliberate call.',
177
+ ' is a separate, deliberate call.',
160
178
  parameters: {
161
179
  ref: {
162
180
  type: 'string',
163
- description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the target.',
181
+ description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
164
182
  },
165
183
  text: {
166
184
  type: 'string',
167
- description: 'Visible label of a target shown WITHOUT a ref (plain text, e.g. generic "搜索设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is clicked and the landing note names it.',
185
+ 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.',
186
+ },
187
+ x: {
188
+ type: 'integer',
189
+ description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
190
+ },
191
+ y: {
192
+ type: 'integer',
193
+ description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
194
+ },
195
+ mode: {
196
+ type: 'string',
197
+ enum: ['click', 'hover'],
198
+ description: 'click (default) activates the target; hover only reveals it.',
199
+ },
200
+ precise: {
201
+ type: 'boolean',
202
+ description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
168
203
  },
169
204
  },
170
205
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
171
206
  async execute(args, exec) {
172
207
  const session = await acquire(exec);
173
- if (args.ref !== undefined && args.ref !== '') {
174
- return snapshotValue(await session.click(args.ref, exec.signal));
208
+ const mode = args.mode ?? 'click';
209
+ if (mode !== 'click' && mode !== 'hover') {
210
+ throw new BrowserError('PARAM_MISSING', 'browser_click takes mode "click" or "hover", got: ' + String(args.mode));
211
+ }
212
+ const ref = args.ref !== undefined && args.ref !== '' ? args.ref : undefined;
213
+ const text = args.text !== undefined && args.text !== '' ? args.text : undefined;
214
+ const point = typeof args.x === 'number' && typeof args.y === 'number' ? { x: args.x, y: args.y } : undefined;
215
+ if (mode === 'hover') {
216
+ if (ref !== undefined)
217
+ return snapshotValue(await session.hover(ref, exec.signal));
218
+ if (text !== undefined)
219
+ return snapshotValue(await session.hoverText(text, exec.signal));
220
+ throw new BrowserError('PARAM_MISSING', 'browser_click with mode "hover" needs a ref from the latest snapshot, or the visible text label of a trigger that has no ref');
175
221
  }
176
- if (args.text !== undefined && args.text !== '') {
177
- return snapshotValue(await session.clickText(args.text, exec.signal));
222
+ if (ref !== undefined) {
223
+ return snapshotValue(args.precise === true ? await session.clickAtRef(ref, exec.signal) : await session.click(ref, exec.signal));
178
224
  }
179
- throw new BrowserError('PARAM_MISSING', 'browser_click needs either a ref from the latest snapshot or the visible text label of a target that has no ref');
225
+ if (text !== undefined)
226
+ return snapshotValue(await session.clickText(text, exec.signal));
227
+ if (point !== undefined)
228
+ return snapshotValue(await session.clickAt(point.x, point.y, exec.signal));
229
+ throw new BrowserError('PARAM_MISSING', 'browser_click needs a target: a ref from the latest snapshot, the visible text label of a target that has no ref, or both x and y viewport coordinates');
180
230
  },
181
231
  }));
182
232
  ctx.tools.register(defineTool({
@@ -207,71 +257,10 @@ export function apply(ctx, config) {
207
257
  return snapshotValue(await session.resolveDialog(args.accept, args.promptText, exec.signal));
208
258
  },
209
259
  }));
210
- ctx.tools.register(defineTool({
211
- name: p('click_at'),
212
- description: 'Precise native click, two modes. Ref mode (prefer it): pass ref — the element is scrolled into view, awaited for geometry stability, and clicked at its exact geometric centre through native input: no screenshot measuring, no devicePixelRatio guesswork. ' +
213
- 'Raw mode: pass x/y viewport CSS pixels to click a raw point with no element involvement. ' +
214
- 'This is the FALLBACK, not a routine alternative: try ' +
215
- p('click') +
216
- ' (including its text mode) first, and use ref mode only after a click reports the element is not actionable or the click could not be delivered. Raw x/y is a last resort for targets no ref resolves (canvas-drawn hit areas): raw clicks still return a landing note saying what they hit, so re-snapshot and confirm. ' +
217
- 'IRON RULE: verify the effect in the returned snapshot; if nothing changed, do NOT repeat blindly and do NOT throw more raw coordinates at it — find the blocker or interception first, then retry the most direct semantic action. ' +
218
- 'The snapshot may open with a landing note naming what received the click, or — when an intercepted ref-mode target had to fall back to a bare click at its centre — a CAUTION note: if the click was intercepted, treat it as failed and retarget.',
219
- parameters: {
220
- ref: {
221
- type: 'string',
222
- description: 'Element ref (e.g. e12) from the latest snapshot. If given, its geometric centre is clicked; x/y are ignored.',
223
- },
224
- x: { type: 'integer', description: 'Raw-mode viewport x in CSS pixels (required only when ref is omitted).' },
225
- y: { type: 'integer', description: 'Raw-mode viewport y in CSS pixels (required only when ref is omitted).' },
226
- },
227
- output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
228
- async execute(args, exec) {
229
- const session = await acquire(exec);
230
- if (args.ref !== undefined && args.ref !== '') {
231
- return snapshotValue(await session.clickAtRef(args.ref, exec.signal));
232
- }
233
- if (typeof args.x !== 'number' || typeof args.y !== 'number') {
234
- throw new BrowserError('PARAM_MISSING', 'browser_click_at needs a ref, or both x and y viewport coordinates');
235
- }
236
- return snapshotValue(await session.clickAt(args.x, args.y, exec.signal));
237
- },
238
- }));
239
- ctx.tools.register(defineTool({
240
- name: p('hover'),
241
- description: 'Hover the element with the given ref — or, for a trigger the snapshot gave no ref, the visible element whose own label equals text — and return the new snapshot. ' +
242
- 'Hover only REVEALS content and never activates anything: use ' +
243
- p('click') +
244
- ' to open, select or submit. Use it when a control hides content until the pointer rests on it (submenus, dropdowns, account menus, tooltips, previews, chart values, player controls) — and especially when the snapshot lacks an element the page obviously should have. ' +
245
- 'Prefer ref when the snapshot has one; use text only for a label shown WITHOUT a ref, copied exactly as rendered. Do not hover plain links/buttons you mean to activate, and never hover "just in case". ' +
246
- 'A hover returning only means the pointer moved: the snapshot opens with a note comparing the actionable ref count before and after — read it. New refs mean the revealed content is in this snapshot and pre-hover refs are stale, so pick from the NEW refs and act before any unrelated click closes the menu. ' +
247
- 'IRON RULE: if the note reports no new refs and what you expected is still missing, do NOT repeat the hover and do NOT fall back to ' +
248
- p('click_at') +
249
- ' raw coordinates — hover the parent/container or the sibling that owns the menu; if that also reveals nothing, hover is not implemented here, so use a visible alternative or tell the user.',
250
- parameters: {
251
- ref: {
252
- type: 'string',
253
- description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the trigger.',
254
- },
255
- text: {
256
- type: 'string',
257
- description: 'Visible label of a trigger shown WITHOUT a ref (plain text, e.g. generic "设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is hovered and the note names it.',
258
- },
259
- },
260
- output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
261
- async execute(args, exec) {
262
- const session = await acquire(exec);
263
- if (args.ref !== undefined && args.ref !== '') {
264
- return snapshotValue(await session.hover(args.ref, exec.signal));
265
- }
266
- if (args.text !== undefined && args.text !== '') {
267
- return snapshotValue(await session.hoverText(args.text, exec.signal));
268
- }
269
- throw new BrowserError('PARAM_MISSING', 'browser_hover needs either a ref from the latest snapshot or the visible text label of a trigger that has no ref');
270
- },
271
- }));
272
260
  ctx.tools.register(defineTool({
273
261
  name: p('fill'),
274
262
  description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
263
+ '`value` is accepted as an alias of `text`. ' +
275
264
  'For <select> elements the text selects the matching option label. ' +
276
265
  'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
277
266
  'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
@@ -284,12 +273,27 @@ export function apply(ctx, config) {
284
273
  'say so) — understand why it was ignored, then act.',
285
274
  parameters: {
286
275
  ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
287
- text: { type: 'string', required: true, description: 'Exact value to fill, or option label for selects.' },
276
+ text: {
277
+ type: 'string',
278
+ description: 'Exact value to fill, or option label for selects. (In browser_click, `text` is a label to click instead.)',
279
+ },
280
+ value: { type: 'string', description: 'Alias of `text`; if both are given, `text` wins.' },
288
281
  },
289
282
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
290
283
  async execute(args, exec) {
284
+ // `text` deliberately is NOT `required: true` here, even though in practice you must give it
285
+ // (or `value`). The parameter DSL is a flat property map whose only requiredness is
286
+ // per-property, so it cannot express "exactly one of text/value" — the `oneOf` the compiled
287
+ // JSON Schema supports is value-level, not object-level. The requirement is therefore
288
+ // enforced below, before a session is acquired (a malformed call must not launch a browser),
289
+ // and the message names both spellings. 2026-10-16: a reviewing model passed `value` first
290
+ // and only got `invalid arguments: missing required property "text"` — no way forward.
291
+ const value = args.text ?? args.value;
292
+ if (value === undefined) {
293
+ throw new BrowserError('PARAM_MISSING', 'browser_fill needs the value to type: pass `text` (alias `value`). The `text` here is the value you type — for a visible label to click, use browser_click instead.');
294
+ }
291
295
  const session = await acquire(exec);
292
- return snapshotValue(await session.fill(args.ref, args.text, exec.signal));
296
+ return snapshotValue(await session.fill(args.ref, value, exec.signal));
293
297
  },
294
298
  }));
295
299
  ctx.tools.register(defineTool({
package/lib/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Domain types for the browser capability: snapshots, sessions, providers.
3
3
  * @module dsh-browser-playwright-codex/types
4
4
  */
5
+ import type { CookieRow } from './login-sites.ts';
5
6
  /** Page load condition a navigation waits for. */
6
7
  export type LoadState = 'load' | 'domcontentloaded' | 'networkidle';
7
8
  /** One snapshot element. Refs identify actionable elements for later calls. */
@@ -191,6 +192,18 @@ export interface BrowserSession {
191
192
  } | undefined, signal?: AbortSignal): Promise<ScreenshotCapture>;
192
193
  /** Extract the page's bounded data: text, links, forms, frames. */
193
194
  pageData(): Promise<unknown>;
195
+ /**
196
+ * Read this session's cookies for display-only login inference. Implementations must drop
197
+ * `value` before returning: the inference only needs names, domains and lifetime.
198
+ */
199
+ cookieJar(): Promise<readonly CookieRow[]>;
200
+ /**
201
+ * Registrable domains of the pages this browser has open, used to attribute cookies to the site
202
+ * that was visited rather than to whoever set them.
203
+ */
204
+ visitedHosts(): string[];
205
+ /** Page titles by registrable domain, for display only; empty when nothing is open. */
206
+ siteTitles(): Promise<Map<string, string>>;
194
207
  /** Evaluate one JavaScript expression and return its JSON result. */
195
208
  evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
196
209
  /** Click the element whose visible text matches, by semantic locator. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Browser automation for DeepSeek Harness: 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, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,6 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
45
+ "typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json",
45
46
  "test": "node --test \"tests/*.test.ts\"",
46
47
  "test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
47
48
  "test:live": "node --test tests/live-smoke.test.ts",
@@ -51,7 +52,7 @@
51
52
  "cost": "node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs",
52
53
  "format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
53
54
  "format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
54
- "prepublishOnly": "npm run build && npm test && npm run verify && npm run preflight",
55
+ "prepublishOnly": "npm run build && npm run typecheck && npm test && npm run verify && npm run preflight",
55
56
  "preflight": "node scripts/preflight.mjs ."
56
57
  },
57
58
  "dependencies": {