dsh-browser-playwright-codex 0.8.0 → 0.8.2

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/session.js CHANGED
@@ -240,7 +240,12 @@ export class PlaywrightSession {
240
240
  }
241
241
  /** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
242
242
  async waitForElementStable(locator) {
243
- const ok = await locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false);
243
+ // locator.evaluate has no timeout of its own either: the probe runs page
244
+ // script, so a wedged main thread makes it hang before the action it is
245
+ // supposed to protect. Degrade to "not stable" and let the action's own
246
+ // Playwright timeout report the real problem (same contract as before, just
247
+ // no longer unbounded).
248
+ const ok = await withDeadline(locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false), Math.max(3000, this.timeoutMs()), 'waiting for the element to hold still', this.currentSignal, 'resolve-undefined').catch(() => false);
244
249
  return ok === true;
245
250
  }
246
251
  /** Post-action settle: wait out a real navigation, then render-stabilize before snapshotting. */
@@ -579,7 +584,11 @@ export class PlaywrightSession {
579
584
  if (page === undefined)
580
585
  continue;
581
586
  this.guardBlockedDialog(page);
582
- out.push({ index, url: page.url(), title: await page.title(), active: index === activeIndex });
587
+ // page.title() is a page round trip with no timeout: one tab running a
588
+ // stuck script used to hang the whole tab list, and every tab after it.
589
+ // An unreadable title degrades to "" — the URL still identifies the tab.
590
+ const title = await withDeadline(page.title().catch(() => ''), Math.max(2000, Math.min(this.timeoutMs(), 5000)), 'reading the title of tab ' + String(index), this.currentSignal, 'resolve-undefined').catch(() => undefined);
591
+ out.push({ index, url: page.url(), title: title ?? '', active: index === activeIndex });
583
592
  }
584
593
  return out;
585
594
  });
@@ -623,7 +632,29 @@ export class PlaywrightSession {
623
632
  });
624
633
  }
625
634
  async pageData() {
626
- return pageData(this);
635
+ // page.evaluate again: the extract walker is page script, so a stuck tab used
636
+ // to hang browser_extract exactly like a snapshot read. Same deadline, and on
637
+ // timeout the same cleanup — drop the handle and the page so the next call
638
+ // does not inherit a page that will never answer.
639
+ try {
640
+ return await withDeadline(pageData(this), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject', 'That page has been closed; anything typed into it but not submitted is lost.');
641
+ }
642
+ catch (error) {
643
+ if (isReadTimeout(error)) {
644
+ this.closed = true;
645
+ this.provider.invalidateOwner(this.owner);
646
+ let page;
647
+ try {
648
+ page = this.currentPageSync();
649
+ }
650
+ catch {
651
+ page = undefined;
652
+ }
653
+ if (page !== undefined)
654
+ await this.discardStuckPage(page);
655
+ }
656
+ throw error;
657
+ }
627
658
  }
628
659
  async evaluate(expression, signal) {
629
660
  // The switch is enforced HERE, not only in the tool layer: browser_evaluate
@@ -639,14 +670,26 @@ export class PlaywrightSession {
639
670
  this.provider.touch(this.owner);
640
671
  const page = await this.ensurePage();
641
672
  const fn = new Function('return (' + expression + ')');
673
+ // page.evaluate takes no timeout: an expression with an infinite loop (a
674
+ // shader compile, a spin on a mutex) never returns and the tool call hangs
675
+ // forever. Bound it with the same budget the snapshot read uses, and on
676
+ // timeout drop this handle + the stuck page so the next call starts clean
677
+ // instead of inheriting a page that will never answer.
678
+ const once = async () => (await withDeadline(withAbort(page.evaluate(fn), signal), this.timeoutMs(), 'evaluating page JavaScript', undefined, 'reject', 'That page has been closed; anything typed into it but not submitted is lost, and the expression may have left the page unresponsive.'));
642
679
  try {
643
- return await withAbort(page.evaluate(fn), signal);
680
+ return await once();
644
681
  }
645
682
  catch (error) {
683
+ if (isReadTimeout(error)) {
684
+ this.closed = true;
685
+ this.provider.invalidateOwner(this.owner);
686
+ await this.discardStuckPage(page);
687
+ throw error;
688
+ }
646
689
  if (!isContextDestroyed(error) || signal?.aborted === true)
647
690
  throw error;
648
691
  await this.settleNavigation(page);
649
- return withAbort(page.evaluate(fn), signal);
692
+ return once();
650
693
  }
651
694
  });
652
695
  }
@@ -688,13 +731,16 @@ export class PlaywrightSession {
688
731
  // inside the driver, and the failure gets reported as a protocol error.
689
732
  const session = this.cdpSession;
690
733
  const send = session.send.bind(session);
691
- return await withAbort(send(method, params ?? {}), signal);
734
+ // A CDP command has no timeout of its own either, and some of them
735
+ // (screenshots, layout dumps) run page script internally. Same budget as
736
+ // a page read; the CDP session itself stays usable, only this call dies.
737
+ return await withDeadline(withAbort(send(method, params ?? {}), signal), this.timeoutMs(), 'running CDP command ' + method, undefined, 'reject', 'The command was abandoned; the page may still be working on it. Take a fresh snapshot to see where it landed.');
692
738
  }
693
739
  catch (error) {
694
- if (isAbortError(error))
740
+ // A deadline and a cancel both mean what they say: never relabel them as
741
+ // a protocol failure (the wrapping below only owns real CDP errors).
742
+ if (isAbortError(error) || isReadTimeout(error))
695
743
  throw error;
696
- // CDP protocol errors are already structured ("Protocol error
697
- // (DOM.foo): ..."); wrap non-abort failures in a stable code.
698
744
  const detail = error instanceof Error ? error.message : String(error);
699
745
  if (/Protocol error/.test(detail)) {
700
746
  const m = /\(([^)]+)\): ([\s\S]+)/.exec(detail);
@@ -48,26 +48,6 @@ export interface AriaParseResult {
48
48
  /** Content lines that could not be parsed — non-empty means format drift. */
49
49
  readonly unparsed: string[];
50
50
  }
51
- /**
52
- * Parse Playwright's official ariaSnapshot YAML into a semantic node tree.
53
- *
54
- * The real format (verified against playwright-core 1.62, 2026-09-30) is:
55
- *
56
- * - generic [active] [ref=e1]: ← trailing ':' = the node has children
57
- * - link "One" [ref=e6] [cursor=pointer]:
58
- * - /url: /one ← href rides a deeper child line
59
- * - text: Name ← text content uses 'text: <value>'
60
- *
61
- * The first version of this parser assumed a hand-rolled dialect
62
- * (`- role "name" [flags] -> href`, nothing after the flags) that real output
63
- * never produces. Every container line ends in ':', so that regex matched none
64
- * of them: the whole hierarchy was dropped, links and their hrefs vanished, and
65
- * `captureAriaSnapshot` still reported success. Unreadable lines are now
66
- * collected in `unparsed` so the capture layer can refuse the tree and let the
67
- * provider fall back to the legacy engine.
68
- * @param yaml - the ariaSnapshot text.
69
- * @returns parsed roots plus any unreadable content lines.
70
- */
71
51
  export declare function parseAriaSnapshotWithStats(yaml: string): AriaParseResult;
72
52
  /**
73
53
  * Parse Playwright's ariaSnapshot YAML into a semantic node tree.
@@ -54,12 +54,19 @@ export const ARIA_REF_SCRIPT = String.raw `
54
54
  window.__dshAriaRefsV = 2
55
55
  const INTERACTIVE = new Set(['button','link','checkbox','radio','menuitem','menuitemcheckbox','menuitemradio','tab','switch','combobox','listbox','option','textbox','searchbox','slider','spinbutton','row','gridcell'])
56
56
  const TAGS = new Set(['a','button','select','textarea','option','summary'])
57
- const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', search:'searchbox', hidden:'null' }
57
+ const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', number:'spinbutton', search:'searchbox', hidden:'null' }
58
58
  const DOC_NONCE = String(Math.floor(Math.random() * 1e9)).padStart(9, '0')
59
59
  let refSeq = 0
60
60
  const roleOf = (el) => {
61
61
  const explicit = el.getAttribute && el.getAttribute('role')
62
- if (explicit && explicit.trim()) return explicit.trim().toLowerCase()
62
+ const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
63
+ // aria: an explicit role="presentation"/"none" is IGNORED on a focusable
64
+ // element — the tree still exposes its native role. jQuery-UI tabs do exactly
65
+ // this: <a href="#tabs-1" role="presentation"> shows up as a link in the aria
66
+ // tree (measured 2026-11-12 on MiniWoB click-tab).
67
+ const presentational = explicitRole === 'presentation' || explicitRole === 'none'
68
+ const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
69
+ if (explicitRole !== '' && !(presentational && focusableLink)) return explicitRole
63
70
  const tag = el.tagName.toLowerCase()
64
71
  if (tag === 'input') return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox'
65
72
  if (tag === 'a') return 'link'
@@ -68,10 +75,34 @@ export const ARIA_REF_SCRIPT = String.raw `
68
75
  if (tag === 'textarea') return 'textarea'
69
76
  if (tag === 'option') return 'option'
70
77
  if (tag === 'summary') return 'summary'
78
+ // Implicit roles for the elements the accessibility tree annotates anyway.
79
+ // Without these the DOM side said "generic" for a <tr> while the tree said
80
+ // "row" (an actionable role), so the minted ref list and the tree disagreed
81
+ // and the whole page fell back (measured 2026-11-12: 6 of the 18 fallback
82
+ // MiniWoB pages were table/row tasks).
83
+ if (tag === 'tr') return 'row'
84
+ if (tag === 'td') return 'cell'
85
+ if (tag === 'th') return 'columnheader'
86
+ if (tag === 'li') return 'listitem'
87
+ if (tag === 'table') return 'table'
88
+ if (tag === 'ul' || tag === 'ol') return 'list'
89
+ if (tag === 'p') return 'paragraph'
90
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading'
91
+ if (tag === 'img') return 'img'
71
92
  return 'generic'
72
93
  }
73
94
  const isActionable = (el) => {
74
95
  if (!(el instanceof Element)) return false
96
+ // An explicit role of presentation / none tells the accessibility tree to drop
97
+ // the element's own semantics — EXCEPT on a focusable link, where the spec says
98
+ // the role is ignored (see roleOf). Skipping those here would mint one ref too
99
+ // few and put the lists out of step (measured 2026-11-12: the MiniWoB tab strips
100
+ // wrap each tab in <a href role="presentation">; 4 pages fell back).
101
+ const explicit = el.getAttribute('role')
102
+ const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
103
+ const presentational = explicitRole === 'presentation' || explicitRole === 'none'
104
+ const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
105
+ if (presentational && !focusableLink) return false
75
106
  const tag = el.tagName.toLowerCase()
76
107
  if (TAGS.has(tag)) return true
77
108
  const role = roleOf(el)
@@ -89,6 +120,17 @@ export const ARIA_REF_SCRIPT = String.raw `
89
120
  if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true') return false
90
121
  const style = getComputedStyle(el)
91
122
  if (style.display === 'none' || style.visibility === 'hidden') return false
123
+ // <option> has no layout box of its own inside a closed <select>, yet the
124
+ // accessibility tree lists it — so it must keep its slot here, or the two
125
+ // lists drift (measured 2026-11-12: choose-list minted 2 refs while the tree
126
+ // had 7 actionable nodes). Its <select> is the visibility signal: options of
127
+ // a display:none select are absent from the tree too.
128
+ if (el.tagName.toLowerCase() === 'option') {
129
+ const select = el.closest('select')
130
+ if (select === null) return false
131
+ const selectStyle = getComputedStyle(select)
132
+ return selectStyle.display !== 'none' && selectStyle.visibility !== 'hidden'
133
+ }
92
134
  // A 'display: contents' box has no rect of its own but lays its children
93
135
  // out normally — it belongs to the tree, so it keeps its slot.
94
136
  if (style.display === 'contents') return true
@@ -177,6 +219,29 @@ function readQuoted(text) {
177
219
  * @param yaml - the ariaSnapshot text.
178
220
  * @returns parsed roots plus any unreadable content lines.
179
221
  */
222
+ /**
223
+ * Playwright quotes a whole node line when the name itself contains a double quote:
224
+ * - 'tab "Section #1" [ref=e5]'
225
+ * - 'link "Tab #1" [ref=e7] [cursor=pointer]':
226
+ * Strip the outer single quotes (and the optional trailing ':' that marks children),
227
+ * then parse the inner text like any other line. Measured 2026-11-12: 8 of 130
228
+ * MiniWoB pages fell back to the legacy engine purely over this shape (tab strips,
229
+ * collapsible sections), while the pages themselves read perfectly.
230
+ */
231
+ function unwrapQuotedNodeBody(body) {
232
+ if (body.length < 3 || body[0] !== "'")
233
+ return body;
234
+ const close = body.lastIndexOf("'");
235
+ if (close <= 0)
236
+ return body;
237
+ const inside = body.slice(1, close);
238
+ const after = body.slice(close + 1).trim();
239
+ // Anything else after the closing quote is not the shape we know; leave it to
240
+ // the normal path so an unknown line is reported rather than silently accepted.
241
+ if (after !== '' && after !== ':')
242
+ return body;
243
+ return inside;
244
+ }
180
245
  export function parseAriaSnapshotWithStats(yaml) {
181
246
  const roots = [];
182
247
  const stack = [];
@@ -206,7 +271,7 @@ export function parseAriaSnapshotWithStats(yaml) {
206
271
  continue;
207
272
  }
208
273
  const indent = Math.floor((m[1] ?? '').length / 2);
209
- const body = m[2] ?? '';
274
+ const body = unwrapQuotedNodeBody(m[2] ?? '');
210
275
  // Attribute child lines describe the node declared above: '- /url: /one',
211
276
  // '- /placeholder: Search'. Playwright adds facts over time, and an attribute
212
277
  // line carries no tree structure — so an unknown one is safe to ignore.
@@ -42,6 +42,21 @@ const TRACKING_PARAMS = new Set([
42
42
  ]);
43
43
  /** Default cap for a rendered href, in characters. */
44
44
  export const MAX_HREF_LENGTH = 160;
45
+ /**
46
+ * Path shapes that mark a redirect-wrapping URL — a link that does not point at
47
+ * its destination but at the site's own click-tracking endpoint, which forwards
48
+ * there. Search engines wrap every organic result this way (Bing `/ck/a?...`,
49
+ * Google `/url?q=...`, Facebook `l.php?u=...`). The real destination is encoded
50
+ * in a long query parameter, so the rendered href is 160 characters of base64
51
+ * that says nothing the link's own name does not — and it repeats for every
52
+ * result on the page.
53
+ *
54
+ * This is a generic category (a tracking endpoint on the same origin as the
55
+ * page), not a per-site special case. When one matches we render a short marker
56
+ * instead of the wrapped URL: the ref is what clicks, and the link's name plus
57
+ * any destination node already carry the meaning.
58
+ */
59
+ const REDIRECT_WRAPPERS = [/\/ck\/a\?/i, /\/url\?q=/i, /l\.php\?u=/i];
45
60
  /** Clip a string to a display budget, marking the cut. */
46
61
  function clip(value, maxLength) {
47
62
  return value.length <= maxLength ? value : value.slice(0, maxLength - 1) + '…';
@@ -56,6 +71,13 @@ function clip(value, maxLength) {
56
71
  * @returns the shortened href
57
72
  */
58
73
  export function trimUrlForDisplay(href, maxLength = MAX_HREF_LENGTH) {
74
+ // A redirect-wrapping href (search-engine click tracker) is 160 characters of
75
+ // encoded destination that says nothing the link's name does not. Render a
76
+ // short marker instead; the ref is what clicks, so this never changes where a
77
+ // click lands — only what the model reads.
78
+ if (REDIRECT_WRAPPERS.some((re) => re.test(href))) {
79
+ return '→ (redirect wrapper)';
80
+ }
59
81
  const hashAt = href.indexOf('#');
60
82
  const hash = hashAt >= 0 ? href.slice(hashAt) : '';
61
83
  const head = hashAt >= 0 ? href.slice(0, hashAt) : href;
@@ -94,13 +116,14 @@ function isTransparentWrapper(node) {
94
116
  node.level === undefined &&
95
117
  node.checked === undefined &&
96
118
  node.selected === undefined &&
97
- node.disabled === undefined);
119
+ node.disabled === undefined &&
120
+ node.value === undefined);
98
121
  }
99
122
  /** Render one node line into the accumulating line list. */
100
- function renderNode(node, depth, lines) {
123
+ function renderNode(node, depth, lines, seenHrefs) {
101
124
  if (isTransparentWrapper(node)) {
102
125
  for (const child of node.children)
103
- renderNode(child, depth, lines);
126
+ renderNode(child, depth, lines, seenHrefs);
104
127
  return;
105
128
  }
106
129
  const indent = ' '.repeat(depth);
@@ -116,15 +139,33 @@ function renderNode(node, depth, lines) {
116
139
  flags.push('selected');
117
140
  if (node.disabled === true)
118
141
  flags.push('disabled');
142
+ // The current value of a text entry field: without it the model cannot see
143
+ // what a fill actually left behind, which read as "no visible change".
144
+ if (node.value !== undefined && node.value !== '')
145
+ flags.push('value=' + node.value);
119
146
  if (node.ref !== undefined)
120
147
  flags.push('ref=' + node.ref);
121
148
  if (flags.length > 0)
122
149
  line += ' [' + flags.join(', ') + ']';
123
- if (node.href !== undefined)
124
- line += ' -> ' + trimUrlForDisplay(node.href);
150
+ if (node.href !== undefined) {
151
+ // Same href, second and later occurrence within ONE snapshot: the URL is
152
+ // already printed at its first occurrence, so repeat only a pointer to it.
153
+ // Every ref and element stays — this trims bytes, never click targets. Only
154
+ // absolute URLs are deduped (a bare "/docs" can name different pages).
155
+ const abs = /^https?:\/\//i.test(node.href);
156
+ const firstRef = abs ? seenHrefs.get(node.href) : undefined;
157
+ if (firstRef !== undefined && firstRef !== node.ref) {
158
+ line += ' -> (same as ' + firstRef + ')';
159
+ }
160
+ else {
161
+ line += ' -> ' + trimUrlForDisplay(node.href);
162
+ if (abs && node.ref !== undefined)
163
+ seenHrefs.set(node.href, node.ref);
164
+ }
165
+ }
125
166
  lines.push(line);
126
167
  for (const child of node.children)
127
- renderNode(child, depth + 1, lines);
168
+ renderNode(child, depth + 1, lines, seenHrefs);
128
169
  }
129
170
  /**
130
171
  * Render a node tree as indented lines, one element per line.
@@ -133,8 +174,12 @@ function renderNode(node, depth, lines) {
133
174
  */
134
175
  export function renderSnapshotTree(nodes) {
135
176
  const lines = [];
177
+ // href -> ref of its first occurrence in THIS snapshot. Same href second and
178
+ // later renders as "(same as <ref>)" instead of repeating the URL. Scoped to
179
+ // one snapshot on purpose: a fresh snapshot after navigation re-prints URLs.
180
+ const seenHrefs = new Map();
136
181
  for (const node of nodes)
137
- renderNode(node, 0, lines);
182
+ renderNode(node, 0, lines, seenHrefs);
138
183
  return lines.join('\n');
139
184
  }
140
185
  /**
@@ -27,9 +27,11 @@
27
27
  * `assets/swarm-rebuild.upstream.js` 里逐条核对下面的每个名字是否真的存在 —— 上游换人、
28
28
  * 改名、删节点,这里第一个报错,而不是等某天浏览器里莫名其妙不播了。
29
29
  *
30
- * 法律面(别绕过它):上游仓库根目录**没有 LICENSE**(默认保留所有权利),
31
- * issue #12 至今零回复。所以这段资产**不能 fork 成"我们自己的版本"** —— fork 不改变授权,
32
- * 只多出一份照样没有许可的副本。它只能留在本机自用、默认关闭、不随包分发。
30
+ * 法律面(0.8.1 起):上游仓库根目录**仍然没有 LICENSE**(默认保留所有权利),
31
+ * 但 Dhvanil Patel 于 2026-11-11 邮件书面许可本项目**打包与修改**,Joel Califa 于
32
+ * 2026-11-08 书面许可并仅要求署名。所以资产可以随包分发,**但授权不是 MIT**:
33
+ * 它是邮件许可,只覆盖本项目,不给第三方通用许可。别把这份资产 fork 成
34
+ * "我们自己的版本" —— fork 不改变任何人拿到的权利,只会多出一份没有许可的副本。
33
35
  */
34
36
  /** 上游出处与入仓指纹。这三项之外的任何东西,都不该成为插件的依赖。 */
35
37
  export declare const SWARM_UPSTREAM: {
@@ -45,8 +47,12 @@ export declare const SWARM_UPSTREAM: {
45
47
  readonly sha256: "6a30f19daaa182d52673035feaeae775c398fa426655cc71a87a451fe00c7bfe";
46
48
  /** 补丁后的产出(**随包分发的那份**)。 */
47
49
  readonly patchedFile: "assets/swarm-rebuild.js";
48
- /** 授权状态:`none` = 无许可证,默认保留所有权利。不是"宽松许可"。 */
49
- readonly license: "none";
50
+ /**
51
+ * 授权状态:`none` = 仓库没有许可证;`permission` = 无许可证文本,但有作者的书面
52
+ * 邮件许可(本项目:Dhvanil 2026-11-11 打包 + 修改;Joel 2026-11-08 效果 + 署名)。
53
+ * 它不是"宽松许可",第三方不能据此复用。
54
+ */
55
+ readonly license: "permission";
50
56
  };
51
57
  /**
52
58
  * Tier A —— 硬接口。上游官方句柄树,补丁完全没碰,插件主路径全走这里。
@@ -27,9 +27,11 @@
27
27
  * `assets/swarm-rebuild.upstream.js` 里逐条核对下面的每个名字是否真的存在 —— 上游换人、
28
28
  * 改名、删节点,这里第一个报错,而不是等某天浏览器里莫名其妙不播了。
29
29
  *
30
- * 法律面(别绕过它):上游仓库根目录**没有 LICENSE**(默认保留所有权利),
31
- * issue #12 至今零回复。所以这段资产**不能 fork 成"我们自己的版本"** —— fork 不改变授权,
32
- * 只多出一份照样没有许可的副本。它只能留在本机自用、默认关闭、不随包分发。
30
+ * 法律面(0.8.1 起):上游仓库根目录**仍然没有 LICENSE**(默认保留所有权利),
31
+ * 但 Dhvanil Patel 于 2026-11-11 邮件书面许可本项目**打包与修改**,Joel Califa 于
32
+ * 2026-11-08 书面许可并仅要求署名。所以资产可以随包分发,**但授权不是 MIT**:
33
+ * 它是邮件许可,只覆盖本项目,不给第三方通用许可。别把这份资产 fork 成
34
+ * "我们自己的版本" —— fork 不改变任何人拿到的权利,只会多出一份没有许可的副本。
33
35
  */
34
36
  /** 上游出处与入仓指纹。这三项之外的任何东西,都不该成为插件的依赖。 */
35
37
  export const SWARM_UPSTREAM = {
@@ -45,8 +47,12 @@ export const SWARM_UPSTREAM = {
45
47
  sha256: '6a30f19daaa182d52673035feaeae775c398fa426655cc71a87a451fe00c7bfe',
46
48
  /** 补丁后的产出(**随包分发的那份**)。 */
47
49
  patchedFile: 'assets/swarm-rebuild.js',
48
- /** 授权状态:`none` = 无许可证,默认保留所有权利。不是"宽松许可"。 */
49
- license: 'none',
50
+ /**
51
+ * 授权状态:`none` = 仓库没有许可证;`permission` = 无许可证文本,但有作者的书面
52
+ * 邮件许可(本项目:Dhvanil 2026-11-11 打包 + 修改;Joel 2026-11-08 效果 + 署名)。
53
+ * 它不是"宽松许可",第三方不能据此复用。
54
+ */
55
+ license: 'permission',
50
56
  };
51
57
  /**
52
58
  * Tier A —— 硬接口。上游官方句柄树,补丁完全没碰,插件主路径全走这里。
@@ -36,11 +36,11 @@ export function swarmBootScript() {
36
36
  asset = fs.readFileSync(fileURLToPath(new URL('../assets/swarm-rebuild.js', import.meta.url)), 'utf8');
37
37
  }
38
38
  catch {
39
- // 资产不在(0.8.0 起 assets/ 不再随 npm 包分发)。这里**绝不能往外抛**:
39
+ // 资产读不到(0.8.1 起随包分发,理论上不会发生;手工删包时会)。这里**绝不能往外抛**:
40
40
  // 两个调用点都是 `await ctx.addInitScript({ content: swarmBootScript() })`,
41
41
  // 抛出去就是上下文创建失败、浏览器直接打不开 —— 把"动效没了"放大成"插件挂了"。
42
42
  // 返回一段空注释脚本,等于这次启动不注入任何东西,行为与"没开这个功能"一致。
43
- return '/* dsh: swarm asset not shipped (0.8.0+) — nothing injected */\n';
43
+ return '/* dsh: swarm asset missing — nothing injected */\n';
44
44
  }
45
45
  return ('if (window.top === window && typeof window.name === "string" &&\n' +
46
46
  ' window.name.indexOf("' +
@@ -4,11 +4,13 @@
4
4
  * 这不是扩展。效果本体是 assets/swarm-rebuild.js,由 Playwright 直接注入当前
5
5
  * 页面执行,所以它跑在插件自己那个窗口里 —— 也就是用户已经登录的那个浏览器。
6
6
  *
7
- * **授权状态(发布前必须处理)**:那份脚本来自 Dhvanil Patel 的
8
- * github.com/DhvanilPatel/swarm-cursors,仓库当前**没有许可证**,README 明确
9
- * 写着「尚未授权,重用代码前请先问 Joel Califa」。底层的动效设计与编排又来自
10
- * Joel Califa 的 califa/swarm-site(同样没有许可证)。两人都点头之前,这个
11
- * 功能只能自己用,不能随公开版本分发。
7
+ * **授权状态(0.8.1 起已解决)**:那份脚本来自 Dhvanil Patel 的
8
+ * github.com/DhvanilPatel/swarm-cursors。2026-11-11 他邮件书面许可本项目
9
+ * **打包与修改**("you are welcome to bundle and modify it as needed"),
10
+ * 所以资产重新随 npm 包分发;许可没有附 MIT 许可文本,也只覆盖本项目,
11
+ * 不给第三方通用许可。底层的动效设计与编排来自 Joel Califa 的
12
+ * califa/swarm-site,他 2026-11-08 已书面许可、唯一条件是署名。
13
+ * 功能仍然**默认关闭**(config.swarm.enabled),打开才播。
12
14
  *
13
15
  * 三个约束决定了这里的实现方式:
14
16
  *
@@ -727,7 +729,7 @@ export async function showWelcome(page, config) {
727
729
  return; // 页面还没准备好:留一张空白页也不影响任何事
728
730
  }
729
731
  const userChoice = runtimeState.getSwarm();
730
- const enabled = userChoice ?? config?.swarm?.enabled ?? true;
732
+ const enabled = userChoice ?? config?.swarm?.enabled ?? false;
731
733
  if (!enabled)
732
734
  return;
733
735
  const tuning = runtimeState.getSwarmTuning();
@@ -754,7 +756,7 @@ export function willPlaySwarm(targetUrl, config, headless) {
754
756
  if (host === '')
755
757
  return false;
756
758
  const userChoice = runtimeState.getSwarm();
757
- const enabled = userChoice ?? config?.swarm?.enabled ?? true;
759
+ const enabled = userChoice ?? config?.swarm?.enabled ?? false;
758
760
  if (!enabled)
759
761
  return false;
760
762
  const tuning = runtimeState.getSwarmTuning();
@@ -781,9 +783,9 @@ export function maybePlaySwarm(page, config, headless) {
781
783
  const host = new URL(url).hostname;
782
784
  if (host === '')
783
785
  return;
784
- // 用户的开关优先;没表态就用配置里的默认值(出厂是开)。
786
+ // 用户的开关优先;没表态就用配置里的默认值(出厂是关,与 config.ts 的 schema 一致)。
785
787
  const userChoice = runtimeState.getSwarm();
786
- const enabled = userChoice ?? config?.swarm?.enabled ?? true;
788
+ const enabled = userChoice ?? config?.swarm?.enabled ?? false;
787
789
  if (!enabled) {
788
790
  // 关着也要把可能已提前启动的引擎撤掉:标记可能是上一次导航残留的,
789
791
  // 不清就会白屏卡住(2026-11-09 复核 find B)。
@@ -46,6 +46,16 @@ export interface ChallengeNode {
46
46
  readonly name?: string;
47
47
  /** Accessible role. Both built-in engines provide one; it gates the wording tier. */
48
48
  readonly role?: string;
49
+ /** Whether a checkbox/radio is checked; present on toggles. */
50
+ readonly checked?: boolean;
51
+ /** Whether an option is selected; present on options. */
52
+ readonly selected?: boolean;
53
+ /**
54
+ * Current value of a textbox/textarea/select. The no-progress fingerprint
55
+ * includes it so that filling a field registers as change — otherwise a
56
+ * normal fill looks like "no visible change" and trips the anti-bot guess.
57
+ */
58
+ readonly value?: string;
49
59
  readonly children?: readonly ChallengeNode[];
50
60
  }
51
61
  /** The slice of a snapshot the challenge detector reads. */
package/lib/tool.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
4
- import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
4
+ import { BrowserError, cdpDisabled, evaluateDisabled, normalizeEvaluateResult } from "./errors.js";
5
5
  import { registeredSuffixes } from "./contract.js";
6
6
  import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
7
7
  import { summarizeLoginSites } from "./login-sites.js";
@@ -563,6 +563,9 @@ export function apply(ctx, config) {
563
563
  registerMaybe('evaluate', defineTool({
564
564
  name: p('evaluate'),
565
565
  description: 'Evaluate one JavaScript expression in the page and return its JSON-serializable result. ' +
566
+ 'Return value rules: an object, string, number, boolean or null comes back as-is; ' +
567
+ 'a top-level ARRAY, undefined or a function is returned as a JSON string with a note saying it was converted ' +
568
+ '(wrap it yourself, e.g. {items: [...]} , if you need the array shape). ' +
566
569
  'Disabled by default; the deployment must enable allowEvaluate. ' +
567
570
  'IRON RULE: prefer read-only expressions to INSPECT state (value, outerHTML, checked, textContent) before ' +
568
571
  'concluding an interaction failed. If you write to the DOM directly (setting value/textContent), ' +
@@ -590,7 +593,8 @@ export function apply(ctx, config) {
590
593
  throw evaluateDisabled();
591
594
  }
592
595
  const session = await acquire(exec);
593
- return (await session.evaluate(args.expression, exec.signal));
596
+ const raw = await session.evaluate(args.expression, exec.signal);
597
+ return normalizeEvaluateResult(raw);
594
598
  },
595
599
  }));
596
600
  registerMaybe('cdp', defineTool({
package/lib/types.d.ts CHANGED
@@ -21,6 +21,12 @@ export interface BrowserNode {
21
21
  readonly selected?: boolean;
22
22
  /** Whether the element is disabled. */
23
23
  readonly disabled?: boolean;
24
+ /**
25
+ * Current value of a textbox, textarea or select, clipped to the snapshot's
26
+ * text budget. Present so the model can see what a fill actually left behind
27
+ * and so the no-progress fingerprint registers a value change as change.
28
+ */
29
+ readonly value?: string;
24
30
  /** Href for links; present when the page is same-origin. */
25
31
  readonly href?: string;
26
32
  readonly children: readonly BrowserNode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -44,6 +44,8 @@
44
44
  "CHANGELOG.md",
45
45
  "SECURITY.md",
46
46
  "NOTICE.md",
47
+ "assets",
48
+ "!assets/swarm-rebuild.upstream.js",
47
49
  "locale",
48
50
  "scripts/doctor.mjs",
49
51
  "scripts/dsh-browser-verify.mjs"
@@ -63,26 +65,29 @@
63
65
  "format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
64
66
  "format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
65
67
  "prepublishOnly": "npm run build && npm run typecheck && npm test && npm run swarm:check && npm run verify && npm run preflight",
66
- "preflight": "node scripts/preflight.mjs ."
68
+ "preflight": "node scripts/preflight.mjs .",
69
+ "test:toggle": "node --test tests/cross-repo/*.test.ts",
70
+ "coverage": "node scripts/coverage.mjs"
67
71
  },
68
72
  "dependencies": {
69
73
  "@deepseek-ai/schemastery": "^3.18.4",
70
74
  "playwright-core": "~1.62.1"
71
75
  },
72
76
  "peerDependencies": {
73
- "@deepseek-ai/cordis": ">=4.0.4",
74
- "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1",
75
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1"
77
+ "@deepseek-ai/cordis": ">=4.1.0",
78
+ "@deepseek-ai/dsh-llm": ">=0.2.2-rc.1",
79
+ "@deepseek-ai/dsh-tools": ">=0.2.2-rc.1"
76
80
  },
77
81
  "devDependencies": {
78
- "@deepseek-ai/cordis": "4.0.4",
79
- "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
80
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
81
- "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
82
- "@deepseek-ai/dsh-session": "0.2.0-rc.1",
83
- "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
84
- "@deepseek-ai/dsh-tools": "0.2.0-rc.1",
85
- "@deepseek-ai/schemastery": "3.18.4",
82
+ "@deepseek-ai/cordis": "4.1.0",
83
+ "@deepseek-ai/dsh-agent": "0.2.2-rc.1",
84
+ "@deepseek-ai/dsh-attachment": "0.2.2-rc.1",
85
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
86
+ "@deepseek-ai/dsh-session": "0.2.2-rc.1",
87
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-rc.1",
88
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.1",
89
+ "@deepseek-ai/dsh-tool-executor-native": "0.2.2-rc.1",
90
+ "@deepseek-ai/schemastery": "3.19.0",
86
91
  "@types/node": "^24.13.3",
87
92
  "prettier": "^3.9.9",
88
93
  "tsx": "^4.20.0",