@twinsearth/w2m-dsh-plugin 2.2.5 → 2.2.6

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "2.2.5"
8
+ "version": "2.2.6"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "2.2.5",
15
+ "version": "2.2.6",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.2.5"
29
+ "version": "2.2.6"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "2.2.5",
4
+ "version": "2.2.6",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to this project are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.2.6] - 2026-11-14
8
+
9
+ - Fixes 搜索本对话: the walk covered only the viewport and stopped counting at six matches, so a
10
+ word that was merely scrolled out of sight reported `0/0` and the feature looked broken. It now
11
+ searches the whole rendered conversation, counts every match, and wraps up to 120 of them (the cap
12
+ bounds paint cost, not the count). The count shows `…` while a query is still being applied.
13
+ - The search settles 1s after the last keystroke instead of 3s (the 3s delay was the second half of
14
+ the same complaint: typing appeared to do nothing). Enter searches immediately.
15
+ - Marks persist after the query settles, so the scroll-triggered repaint is gone; it was re-walking
16
+ the tree on every scroll for a result that no longer needed refreshing.
17
+ - Tests: the client-bundle suite now asserts the 1s settle, the whole-conversation walk (no
18
+ viewport rect filter) and the count-every-match cap, instead of the old 3s/six-mark contract.
19
+
7
20
  ## [2.2.5] - 2026-11-12
8
21
 
9
22
  - DSH 0.2.2-rc.2 compatibility, measured rather than assumed: the packed artifact mounts and boots in
package/README.md CHANGED
@@ -93,7 +93,7 @@ w2m-mcp --help
93
93
  # 复制进你自己的 codex/mcp-servers.toml
94
94
  [mcp_servers.w2m]
95
95
  command = "npx"
96
- args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.5", "w2m-mcp"]
96
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.6", "w2m-mcp"]
97
97
  ```
98
98
 
99
99
  ### 5. 验证过的 release tarball(离线)
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.2.5", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.2.6", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,30 @@
1
+ {
2
+ "version": "2.2.6",
3
+ "case": "RELEASE-2260",
4
+ "kind": "positive",
5
+ "machines": ["b53a37f4-1464-4512-9259-33a7df091e3e"],
6
+ "executed_at": "2026-11-13T19:13:00Z",
7
+ "executor": "lead",
8
+ "task_id": "pre-release-2260",
9
+ "requirement": "v2.2.6: the in-conversation search (search conversation, the header magnifier) counts matches across the whole rendered conversation instead of only the viewport, and starts searching 1s after the last keystroke instead of 3s",
10
+ "commands": [
11
+ ["node", "--check", "lib/client.js"],
12
+ ["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
13
+ ["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"],
14
+ ["node", "scripts/pack.mjs"]
15
+ ],
16
+ "result": {
17
+ "outcome": "ok",
18
+ "assertions": {
19
+ "defect": "the walk was restricted to the current viewport and stopped counting at six matches, so a word that existed only above the fold reported 0/0 and the feature looked broken; the 3s settle made every query look ignored for its first three seconds",
20
+ "scope": "paintSearchMarks walks every text node under the conversation region (falling back to document.body), counts every occurrence, and wraps at most 120 of them -- the cap bounds paint cost, not the count; counting stops at 2000 matches so a one-letter query cannot stall the UI",
21
+ "settle": "the query settles 1000ms after the last keystroke (was 3000ms); Enter searches immediately; the count shows an ellipsis while the query is still being applied",
22
+ "no_viewport_filter": "the viewport rectangle filter and its six-match ceiling are gone, and the scroll-triggered repaint is removed because marks persist once painted",
23
+ "syntax": "node --check lib/client.js: exit 0",
24
+ "client_bundle": "test/client-bundle.test.mjs 36/36, including the rewritten contract assertions: 1s settle string, no viewportRect, count is ranges.length, no scroll repaint",
25
+ "loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.6 artifact in the real DSH 0.2.2-rc.3 desktop runtime (entry active, tools registered, client half composed and activated, page booted in a browser)",
26
+ "installed": "the packed 2.2.6 tarball is installed in this machine's desktop profile from ~/.dsh/w2m/w2m-dsh-plugin-2.2.6.tgz, so the running app serves the fixed client bundle on its next page load",
27
+ "artifact": "scripts/pack.mjs packs dist/twinsearth-w2m-dsh-plugin-2.2.6.tgz deterministically from this tree; the published sha256 is the one release.yml writes into dist/SHA256SUMS beside the tarball, so the record does not restate the hash of the archive that contains it"
28
+ }
29
+ }
30
+ }
package/lib/client.js CHANGED
@@ -1109,12 +1109,22 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
1109
1109
  * v1.9.11: in-conversation full-text search with visible yellow highlights.
1110
1110
  *
1111
1111
  * The previous version only called window.find, which the desktop shell does not reliably
1112
- * surface. This walks the rendered conversation text and registers the matches through the CSS
1113
- * Custom Highlight API (no DOM mutation, so React's tree stays intact), then scrolls to the
1114
- * focused match. Matches stay yellow until the query changes or the box closes.
1112
+ * surface. This walks the rendered conversation text and registers the matches through local
1113
+ * `mark` elements (no CSS Custom Highlight dependency, so a match is always visible), then
1114
+ * scrolls to the focused match. Matches stay yellow until the query changes or the box closes.
1115
+ *
1116
+ * v2.2.6: the walk covers the whole rendered conversation, not just the viewport. The old
1117
+ * viewport-only rule reported `0` for text that was merely scrolled out of sight, and capped the
1118
+ * count at six, which made a matching conversation look unsearchable. The cap now only bounds how
1119
+ * many nodes are wrapped (paint cost), never how many matches are counted.
1115
1120
  */
1116
1121
  function searchRoots() {
1117
1122
  const roots = [];
1123
+ if (typeof document === 'undefined' || !document.body) return roots;
1124
+ const region = typeof document.querySelector === 'function'
1125
+ ? (document.querySelector('[data-conversation-region]') ?? document.querySelector('[data-conversation-session]'))
1126
+ : null;
1127
+ const start = region ?? document.body;
1118
1128
  const seen = new Set();
1119
1129
  const visit = (node) => {
1120
1130
  if (!node || seen.has(node)) return;
@@ -1125,7 +1135,8 @@ function searchRoots() {
1125
1135
  for (let i = 0; i < kids.length; i += 1) visit(kids[i]);
1126
1136
  }
1127
1137
  };
1128
- if (typeof document !== 'undefined' && document.body) { roots.push(document.body); visit(document.body); }
1138
+ roots.push(start);
1139
+ visit(start);
1129
1140
  return roots;
1130
1141
  }
1131
1142
  function clearSearchMarks() {
@@ -1139,28 +1150,25 @@ function clearSearchMarks() {
1139
1150
  }
1140
1151
  }
1141
1152
  /**
1142
- * Search rules (v2.0.5 UI): a pure number up to 100 is not a useful query in a conversation full of
1143
- * ids/ports/timestamps, so it is refused outright (no marks, no scroll, no count). Only the viewport
1144
- * is highlighted, at most six matches per screen; scrolling re-runs the pass for the new viewport.
1153
+ * Search rules (v2.0.5 UI, revised in v2.2.6): a pure number up to 100 is not a useful query in a
1154
+ * conversation full of ids/ports/timestamps, so it is refused outright (no marks, no scroll, no
1155
+ * count). Every rendered match is counted; only the first {@link SEARCH_MAX_MARKS} are wrapped, so a
1156
+ * long conversation stays responsive without making a match outside the viewport invisible.
1145
1157
  */
1146
- const SEARCH_MAX_MARKS = 6;
1158
+ const SEARCH_MAX_MARKS = 120;
1159
+ /** Hard ceiling on counted matches, so a one-letter query in a huge conversation cannot stall the UI. */
1160
+ const SEARCH_MAX_COUNT = 2000;
1147
1161
  function searchQueryAllowed(query) {
1148
1162
  const text = String(query ?? '').trim();
1149
1163
  if (text === '') return false;
1150
1164
  if (/^\d+$/.test(text)) return Number(text) > 100;
1151
1165
  return true;
1152
1166
  }
1153
- function viewportRect() {
1154
- const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
1155
- const height = typeof window !== 'undefined' ? window.innerHeight : 768;
1156
- return { left: 0, top: 0, right: width, bottom: height };
1157
- }
1158
1167
  function paintSearchMarks(query) {
1159
1168
  clearSearchMarks();
1160
1169
  if (typeof document === 'undefined' || query === '' || !searchQueryAllowed(query)) return 0;
1161
1170
  const needle = query.toLowerCase();
1162
- const view = viewportRect();
1163
- let total = 0;
1171
+ const ranges = [];
1164
1172
  outer: for (const root of searchRoots()) {
1165
1173
  let walker;
1166
1174
  try {
@@ -1176,37 +1184,37 @@ function paintSearchMarks(query) {
1176
1184
  },
1177
1185
  });
1178
1186
  } catch { continue; }
1179
- const nodes = [];
1180
1187
  let node = walker.nextNode();
1181
- while (node !== null) { nodes.push(node); node = walker.nextNode(); }
1182
- for (const textNode of nodes) {
1183
- // Cheap pre-filter: a text node whose element is entirely outside the viewport cannot paint.
1184
- const host = textNode.parentElement;
1185
- const hostRect = host !== null && typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null;
1186
- if (hostRect !== null && (hostRect.bottom < view.top || hostRect.top > view.bottom)) continue;
1187
- const text = textNode.nodeValue ?? '';
1188
+ while (node !== null) {
1189
+ const text = node.nodeValue ?? '';
1188
1190
  const lower = text.toLowerCase();
1189
- const hits = [];
1190
1191
  let index = lower.indexOf(needle);
1191
- while (index >= 0) { hits.push(index); index = lower.indexOf(needle, index + query.length); }
1192
- for (const hit of hits) {
1192
+ while (index >= 0) {
1193
1193
  try {
1194
1194
  const range = document.createRange();
1195
- range.setStart(textNode, hit);
1196
- range.setEnd(textNode, hit + query.length);
1197
- const rect = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : null;
1198
- if (rect !== null && (rect.bottom < view.top || rect.top > view.bottom || rect.right < view.left || rect.left > view.right)) continue;
1199
- const mark = document.createElement('mark');
1200
- mark.className = 'w2mSearchMark';
1201
- mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
1202
- range.surroundContents(mark);
1203
- total += 1;
1204
- if (total >= SEARCH_MAX_MARKS) break outer;
1205
- } catch { /* a range that crosses elements cannot be wrapped; skip it */ }
1195
+ range.setStart(node, index);
1196
+ range.setEnd(node, index + query.length);
1197
+ ranges.push(range);
1198
+ } catch { /* an unselectable node cannot carry a match */ }
1199
+ if (ranges.length >= SEARCH_MAX_COUNT) break outer;
1200
+ index = lower.indexOf(needle, index + query.length);
1206
1201
  }
1202
+ node = walker.nextNode();
1207
1203
  }
1208
1204
  }
1209
- return total;
1205
+ // Wrap from the end so the offsets of the matches still ahead of us stay valid: wrapping one range
1206
+ // splits its text node, and a split above an earlier range cannot move that range.
1207
+ const wrapCount = Math.min(ranges.length, SEARCH_MAX_MARKS);
1208
+ for (let i = wrapCount - 1; i >= 0; i -= 1) {
1209
+ const range = ranges[i];
1210
+ try {
1211
+ const mark = document.createElement('mark');
1212
+ mark.className = 'w2mSearchMark';
1213
+ mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
1214
+ range.surroundContents(mark);
1215
+ } catch { /* a range that crosses elements cannot be wrapped; it stays counted */ }
1216
+ }
1217
+ return ranges.length;
1210
1218
  }
1211
1219
  /**
1212
1220
  * Probe-scroll: assign a tiny scrollTop and see whether the browser keeps it.
@@ -1440,25 +1448,27 @@ function ConversationSearch() {
1440
1448
  const [query, setQuery] = React.useState('');
1441
1449
  const [count, setCount] = React.useState(0);
1442
1450
  const [cursor, setCursor] = React.useState(-1);
1451
+ const [pending, setPending] = React.useState(false);
1443
1452
  const inputRef = React.useRef(null);
1444
1453
  React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
1445
- // v2.0.5: wait until 3s pass without new input before walking the conversation, so typing does
1446
- // not trigger a full-tree highlight pass on every keystroke (the reported input jank).
1447
- // Rules: a pure number <= 100 is refused; only the viewport is highlighted (max 6) and scrolling
1448
- // repaints the current viewport without yanking the scroll position.
1454
+ // v2.2.6: wait 1s after the last keystroke before walking the conversation (the v2.0.5 delay was
1455
+ // 3s, which read as "search does nothing" and was the reported complaint). One pass per settled
1456
+ // query is still what keeps typing cheap; the walk now covers the whole conversation and counts
1457
+ // every match, so a hit outside the current viewport is no longer reported as zero.
1449
1458
  const appliedRef = React.useRef('');
1450
1459
  const paintRef = React.useRef(() => {});
1451
1460
  paintRef.current = (focus = false) => {
1452
1461
  const total = paintSearchMarks(appliedRef.current);
1453
1462
  setCount(total);
1454
1463
  setCursor(total > 0 ? 0 : -1);
1464
+ setPending(false);
1455
1465
  if (focus === true) {
1456
1466
  const first = document.querySelector('mark.w2mSearchMark');
1457
1467
  if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1458
1468
  }
1459
1469
  };
1460
1470
  React.useEffect(() => {
1461
- if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); }
1471
+ if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); setPending(false); }
1462
1472
  }, [open]);
1463
1473
  React.useEffect(() => {
1464
1474
  if (!open) return undefined;
@@ -1468,25 +1478,13 @@ function ConversationSearch() {
1468
1478
  clearSearchMarks();
1469
1479
  setCount(0);
1470
1480
  setCursor(-1);
1481
+ setPending(false);
1471
1482
  return undefined;
1472
1483
  }
1473
- const timer = setTimeout(() => { appliedRef.current = query; paintRef.current(true); }, 3000);
1484
+ setPending(true);
1485
+ const timer = setTimeout(() => { appliedRef.current = query; paintRef.current(true); }, 1000);
1474
1486
  return () => clearTimeout(timer);
1475
1487
  }, [open, query]);
1476
- React.useEffect(() => {
1477
- if (!open) return undefined;
1478
- let timer = null;
1479
- const onScroll = () => {
1480
- if (appliedRef.current === '') return;
1481
- if (timer !== null) clearTimeout(timer);
1482
- timer = setTimeout(() => { paintRef.current(false); }, 150);
1483
- };
1484
- window.addEventListener('scroll', onScroll, true);
1485
- return () => {
1486
- if (timer !== null) clearTimeout(timer);
1487
- window.removeEventListener('scroll', onScroll, true);
1488
- };
1489
- }, [open]);
1490
1488
  const step = (backwards) => {
1491
1489
  if (appliedRef.current === '') return;
1492
1490
  let marks = [...document.querySelectorAll('mark.w2mSearchMark')];
@@ -1519,12 +1517,14 @@ function ConversationSearch() {
1519
1517
  onKeyDown: (event) => {
1520
1518
  if (event.key === 'Enter') {
1521
1519
  event.preventDefault();
1522
- if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true); step(event.shiftKey); }
1520
+ if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true); }
1523
1521
  }
1524
1522
  if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1525
1523
  },
1526
1524
  }),
1527
- h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (searchQueryAllowed(query) ? '0' : '') : whenCount(count, cursor)),
1525
+ h('span', { key: 'count', className: 'w2mSearchCount' }, pending
1526
+ ? '…'
1527
+ : (count === 0 ? (searchQueryAllowed(query) ? '0' : '') : whenCount(count, cursor))),
1528
1528
  h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1529
1529
  h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1530
1530
  h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchMarks(); } }, '×'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "2.2.5",
4
+ "version": "2.2.6",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -296,7 +296,7 @@ const MAX_STAGES = 16;
296
296
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
297
297
  * overrides it at runtime for tests and packagers.
298
298
  */
299
- const PLUGIN_VERSION = '2.2.5';
299
+ const PLUGIN_VERSION = '2.2.6';
300
300
 
301
301
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
302
302
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;