@twinsearth/w2m-dsh-plugin 1.9.17 → 1.9.20

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": "1.9.17"
8
+ "version": "1.9.20"
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": "1.9.17",
15
+ "version": "1.9.20",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.9.17"
29
+ "version": "1.9.20"
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": "1.9.17",
4
+ "version": "1.9.20",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.9.20] - 2026-10-28
11
+
12
+ - 新增浮动滚动浮标(固定在主工作区右上角,不依赖 slot 布局):
13
+ · 「⇧3」:单击向上滚动约 3 轮对话;长按 3 秒直接回到第一轮;
14
+ · 「⤒」:一键到顶。
15
+ - 浮标挂载在插件生命周期内(ctx.effect 卸载时移除),失败只记录、不影响页面启动。
16
+
17
+ ## [1.9.19] - 2026-10-28
18
+
19
+ - 对话右上角新增「⇧3」按钮:单击向上滚动约 3 轮对话,长按 3 秒直接回到第一轮。
20
+ - 全文搜索高亮修复:改为直接插入本地 mark 元素(黄色背景 rgba(255,235,59,.85) + 绿色文字 rgba(11,122,51,1)),并递归遍历 shadow DOM;不再依赖 CSS Custom Highlight API,命中项一定可见。
21
+ - 同版带上 v1.9.18 的聊天收紧:relay 只接收文字与 /微笑 /火箭 /烟花 指令(图片字段、data:image、Markdown 图片、base64 图片魔数一律 400 拒绝;加密消息按设计豁免);表情按钮改为指令按钮;长提示词镜像按 3500 B 截断,避免被 4096 B 上限静默丢弃。
22
+
10
23
  ## [1.9.17] - 2026-10-28
11
24
 
12
25
  - 修复左侧栏 GPW 入口只剩小图标:恢复 🌐 GPW 文字(侧栏 label 仍为空,不会重复)。
package/README.md CHANGED
@@ -14,7 +14,7 @@ English | [中文](#中文说明)
14
14
 
15
15
  ## Install
16
16
 
17
- Five routes in. They all end with **16 tools registered** in your agent. Pick one.
17
+ Five routes in. They all end with **18 tools registered** in your agent. Pick one.
18
18
 
19
19
  ### 1. DSH plugin (recommended — zero configuration)
20
20
 
@@ -136,7 +136,7 @@ hand-maintained host list.
136
136
  ### Tools
137
137
 
138
138
  `w2m_status` · `w2m_devices` · `w2m_run` · `w2m_wait` · `w2m_history` · `w2m_stats` · `w2m_report` ·
139
- `w2m_memory` · `w2m_approvals` · `w2m_cancel` · `w2m_council` · `w2m_chat` · `w2m_update` `w2m_tasks` · `w2m_reduce`
139
+ `w2m_memory` · `w2m_approvals` · `w2m_cancel` · `w2m_council` · `w2m_chat` · `w2m_update` `w2m_tasks` · `w2m_reduce` · `w2m_joint_plan` · `w2m_joint_status`
140
140
  - `w2m_intent` — map a structured intent (run-tests, node-version, git-status, git-head, fleet-facts, shard-probe) onto an allow-listed command; unknown intents are refused by name, never guessed.
141
141
 
142
142
  ### Environment a dispatched command receives (1.1.15+)
@@ -261,7 +261,7 @@ MIT.
261
261
 
262
262
  ### 安装
263
263
 
264
- 五种方式,最终都会在你的 agent 里注册 **16 tools registered**。任选其一。
264
+ 五种方式,最终都会在你的 agent 里注册 **18 tools registered**。任选其一。
265
265
 
266
266
  #### 1. DSH 插件(推荐,零配置)
267
267
 
package/bin/w2m-joint.mjs CHANGED
@@ -163,7 +163,7 @@ if (command === 'dispatch') {
163
163
  // decision 2: one approval authorises exactly one dispatch.
164
164
  const consumed = consumeApproval({ stateDir, planSha: args.plan, approvalSha: gate.approval.approval.approval_sha256 });
165
165
  if (consumed.ok !== true) fail(consumed.code ?? 'APPROVAL_CONSUME_FAILED', consumed.detail ?? null);
166
- const sent = await dispatchPlanToRelay({ rabbitUrl: relayUrl, token, plan: gate.plan, approval: gate.approval.approval });
166
+ const sent = await dispatchPlanToRelay({ rabbitUrl: relayUrl, token, plan: gate.plan, approval: gate.approval.approval, auditFile: stateDir + '/joint-dev/audit.jsonl' });
167
167
  print({ ...sent, plan_sha256: gate.plan.plan_sha256, relay: relayUrl });
168
168
  process.exit(sent.ok === true ? 0 : 1);
169
169
  }
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.17", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.20", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.9.19",
3
+ "case": "RELEASE-1919",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T05:08:27.700Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1919-1793164107702",
11
+ "requirement": "V1.9.19:对话向上滚动按钮(点击 3 轮/长按 3 秒到首轮)+ 搜索命中绿字黄底(DOM mark,含 shadow DOM)",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "scroll": "header ⇧3: click scrollBy 3 turns, hold 3s scrollTo top",
16
+ "search": "mark.w2mSearchMark with rgba yellow background + rgba green text, shadow-root aware",
17
+ "chat": "text-only guard retained from v1.9.18 work",
18
+ "tests": "client-bundle 26/26"
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.9.20",
3
+ "case": "RELEASE-1920",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T07:38:58.684Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1920-1793173138685",
11
+ "requirement": "V1.9.20:主工作区右上角浮动滚动浮标(⇧3 单击 3 轮/长按 3 秒到顶 + ⤒ 一键到顶)",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "float": "fixed top-right DOM controls installed from mount(), removed by ctx.effect",
16
+ "click": "scrollBy ~3 turns on the largest scrollable conversation surface",
17
+ "longpress": "pointerdown 3s -> scrollTo top",
18
+ "oneclick": "top button scrolls to first turn",
19
+ "tests": "client-bundle 26/26"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "kind": "ops-smoke",
3
+ "case": "AG-SMOKE",
4
+ "executed_at": "2026-10-28T05:02:36.913Z",
5
+ "executor": "lead",
6
+ "task_id": "01M60E2W38FFKXKRZS95JSVWFM",
7
+ "setup": "relay 43.142.74.71:8787 upgraded to 1.9.16; operator token used from ~/.w2m/operator-token; read via local device token",
8
+ "attempt": "broadcast executor_machine_id=ToFuture command_argv=[\"node\",\"-v\"] at 2.0.0-tree relay",
9
+ "observed": {
10
+ "dispatch": "POST /v1/task 200; executor lease on ToFuture=offered; DSH/macmini and 15 other devices=observing",
11
+ "final": "aggregate=refused; ToFuture lease=refused (agent allow-list), outcome=refused; observers did not execute"
12
+ },
13
+ "conclusions": {
14
+ "selection_routing": "PASS at relay level: only the named executor gets an executing lease; every other device is observing",
15
+ "execution": "NOT PROVEN: the target agent refused the command (allow-list)",
16
+ "ag_matrix": "PARTIAL: A/B/C/D routing smoke only; E/F/G and real execution blocked"
17
+ },
18
+ "blockers": [
19
+ "agent allow-list does not include node -v on ToFuture",
20
+ "running DSH process still predates the operator-token change (restart required)",
21
+ "macmini/DSH plugin versions unknown/unverified; same-version requirement not yet met",
22
+ "S4 data plane (agent putBlobBytes reroute + no-sink degradation) not implemented"
23
+ ]
24
+ }
package/lib/client.js CHANGED
@@ -495,6 +495,8 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
495
495
  // v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
496
496
  '.w2mChatTab{padding:4px 10px;border-radius:6px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font:inherit;cursor:pointer}',
497
497
  '.w2mChat_panelFull{position:fixed;inset:5vh 8vw;width:auto;max-height:90vh;z-index:70}',
498
+ '.w2mChat_sticker{font-size:16px;line-height:1}',
499
+ '.w2mChat_stickerBig{font-size:28px;line-height:1.1}',
498
500
  '.w2mChat_emojiRow{display:flex;gap:4px;padding:4px 8px;flex-wrap:wrap}',
499
501
  '.w2mChat_emoji{padding:2px 4px;border:0;background:transparent;font-size:16px;line-height:1;cursor:pointer}',
500
502
  '.w2mFleetStatus{display:flex;gap:16px;align-items:center;padding:2px 12px;font-size:11px;opacity:.8;white-space:nowrap}',
@@ -504,6 +506,11 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
504
506
  '.w2mSearchBox{display:flex;align-items:center;gap:4px;padding:2px 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-background-secondary)}',
505
507
  '.w2mSearchInput{height:24px;min-width:220px;border:0;background:transparent;color:inherit;font:inherit;outline:none}',
506
508
  '.w2mSearchCount{font-size:11px;opacity:.75;min-width:34px;text-align:center}',
509
+ '.w2mSearchControls{display:inline-flex;align-items:center;gap:4px}',
510
+ '.w2mScrollFloatWrap{position:fixed;right:18px;top:72px;z-index:2147483000;display:flex;flex-direction:column;gap:6px}',
511
+ '.w2mScrollFloatBtn{width:34px;height:34px;border-radius:50%;border:1px solid currentColor;background:transparent;color:inherit;opacity:.72;font-size:13px;line-height:1;cursor:pointer}',
512
+ '.w2mScrollFloatBtn:hover{opacity:1}',
513
+ '.w2mScrollUp{font-size:12px;padding:2px 6px;border:1px solid rgba(128,128,128,.4);border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
507
514
  '::highlight(w2m-conversation-search){background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)}',
508
515
  '.w2mSearchMark{background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1);border-radius:2px}',
509
516
  '.w2mSearchBtn{height:24px;min-width:24px;border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}',
@@ -964,112 +971,216 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
964
971
  * Custom Highlight API (no DOM mutation, so React's tree stays intact), then scrolls to the
965
972
  * focused match. Matches stay yellow until the query changes or the box closes.
966
973
  */
967
- const SEARCH_HIGHLIGHT = 'w2m-conversation-search';
968
- function clearSearchHighlight() {
969
- try { if (typeof CSS !== 'undefined' && CSS.highlights) CSS.highlights.delete(SEARCH_HIGHLIGHT); } catch { /* unsupported */ }
974
+ function searchRoots() {
975
+ const roots = [];
976
+ const seen = new Set();
977
+ const visit = (node) => {
978
+ if (!node || seen.has(node)) return;
979
+ seen.add(node);
980
+ if (node.nodeType === 1 || node.nodeType === 11) {
981
+ if (node.shadowRoot) { roots.push(node.shadowRoot); visit(node.shadowRoot); }
982
+ const kids = node.children ?? [];
983
+ for (let i = 0; i < kids.length; i += 1) visit(kids[i]);
970
984
  }
971
- function ConversationSearch() {
972
- const [open, setOpen] = React.useState(false);
973
- const [query, setQuery] = React.useState('');
974
- const [count, setCount] = React.useState(0);
975
- const [cursor, setCursor] = React.useState(-1);
976
- const rangesRef = React.useRef([]);
977
- const inputRef = React.useRef(null);
978
- React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
979
- const collect = React.useCallback(() => {
980
- if (query === '' || typeof document === 'undefined') return [];
981
- const needle = query.toLowerCase();
982
- const ranges = [];
983
- try {
984
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
985
- acceptNode(node) {
986
- const parent = node.parentElement;
987
- if (parent === null) return NodeFilter.FILTER_REJECT;
988
- const tag = parent.tagName;
989
- if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
990
- if (parent.closest && parent.closest('[data-w2m-search]') !== null) return NodeFilter.FILTER_REJECT;
991
- return node.nodeValue && node.nodeValue.trim() !== '' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
992
- },
993
- });
994
- let node = walker.nextNode();
995
- while (node !== null && ranges.length < 500) {
996
- const text = node.nodeValue.toLowerCase();
997
- let index = text.indexOf(needle);
998
- while (index >= 0) {
999
- const range = document.createRange();
1000
- range.setStart(node, index);
1001
- range.setEnd(node, index + query.length);
1002
- ranges.push(range);
1003
- index = text.indexOf(needle, index + query.length);
1004
- if (ranges.length >= 500) break;
1005
- }
1006
- node = walker.nextNode();
1007
- }
1008
- } catch { /* finding is best effort */ }
1009
- return ranges;
1010
- }, [query]);
1011
- const paint = React.useCallback(() => {
1012
- const ranges = collect();
1013
- rangesRef.current = ranges;
1014
- setCount(ranges.length);
1015
- try {
1016
- if (typeof CSS !== 'undefined' && CSS.highlights && typeof Highlight === 'function') {
1017
- CSS.highlights.set(SEARCH_HIGHLIGHT, new Highlight(...ranges));
1018
- }
1019
- } catch { /* older engine: the count still helps */ }
1020
- if (ranges.length > 0) {
1021
- setCursor(0);
1022
- try {
1023
- const parent = ranges[0].startContainer.parentElement;
1024
- if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
1025
- } catch { /* scroll is best effort */ }
1026
- } else setCursor(-1);
1027
- return ranges.length;
1028
- }, [collect]);
1029
- React.useEffect(() => {
1030
- if (!open) { clearSearchHighlight(); setCount(0); setCursor(-1); return undefined; }
1031
- paint();
1032
- return () => clearSearchHighlight();
1033
- }, [open, query, paint]);
1034
- const step = (backwards) => {
1035
- const ranges = rangesRef.current;
1036
- if (ranges.length === 0) { paint(); return; }
1037
- const next = (cursor + (backwards ? -1 : 1) + ranges.length) % ranges.length;
1038
- setCursor(next);
985
+ };
986
+ if (typeof document !== 'undefined' && document.body) { roots.push(document.body); visit(document.body); }
987
+ return roots;
988
+ }
989
+ function clearSearchMarks() {
990
+ if (typeof document === 'undefined') return;
991
+ const marks = document.querySelectorAll('mark.w2mSearchMark');
992
+ for (const mark of marks) {
993
+ const parent = mark.parentNode;
994
+ if (parent === null) continue;
995
+ parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
996
+ if (parent.normalize) parent.normalize();
997
+ }
998
+ }
999
+ function paintSearchMarks(query) {
1000
+ clearSearchMarks();
1001
+ if (typeof document === 'undefined' || query === '') return 0;
1002
+ const needle = query.toLowerCase();
1003
+ let total = 0;
1004
+ for (const root of searchRoots()) {
1005
+ let walker;
1006
+ try {
1007
+ walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1008
+ acceptNode(node) {
1009
+ const parent = node.parentElement;
1010
+ if (parent === null) return NodeFilter.FILTER_REJECT;
1011
+ const tag = parent.tagName;
1012
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
1013
+ if (parent.closest && parent.closest('[data-w2m-search]') !== null) return NodeFilter.FILTER_REJECT;
1014
+ if (parent.closest && parent.closest('mark.w2mSearchMark') !== null) return NodeFilter.FILTER_REJECT;
1015
+ return node.nodeValue && node.nodeValue.trim() !== '' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
1016
+ },
1017
+ });
1018
+ } catch { continue; }
1019
+ const nodes = [];
1020
+ let node = walker.nextNode();
1021
+ while (node !== null) { nodes.push(node); node = walker.nextNode(); }
1022
+ for (const textNode of nodes) {
1023
+ const text = textNode.nodeValue ?? '';
1024
+ const lower = text.toLowerCase();
1025
+ const hits = [];
1026
+ let index = lower.indexOf(needle);
1027
+ while (index >= 0) { hits.push(index); index = lower.indexOf(needle, index + query.length); }
1028
+ for (let k = hits.length - 1; k >= 0; k -= 1) {
1039
1029
  try {
1040
- const parent = ranges[next].startContainer.parentElement;
1041
- if (parent && parent.scrollIntoView) parent.scrollIntoView({ block: 'center', behavior: 'smooth' });
1042
- } catch { /* scroll is best effort */ }
1043
- };
1044
- if (!open) {
1045
- return h('button', {
1046
- type: 'button',
1047
- className: 'w2mSearchToggle',
1048
- title: '搜索本对话',
1049
- 'aria-label': '搜索本对话',
1050
- onClick: () => setOpen(true),
1051
- }, '🔍');
1030
+ const range = document.createRange();
1031
+ range.setStart(textNode, hits[k]);
1032
+ range.setEnd(textNode, hits[k] + query.length);
1033
+ const mark = document.createElement('mark');
1034
+ mark.className = 'w2mSearchMark';
1035
+ mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
1036
+ range.surroundContents(mark);
1037
+ total += 1;
1038
+ } catch { /* a range that crosses elements cannot be wrapped; skip it */ }
1052
1039
  }
1053
- return h('div', { className: 'w2mSearchBox', 'data-w2m-search': 'box' }, [
1054
- h('input', {
1055
- key: 'q',
1056
- ref: inputRef,
1057
- className: 'w2mSearchInput',
1058
- value: query,
1059
- placeholder: '搜索本对话全部内容',
1060
- onChange: (event) => setQuery(event.target.value),
1061
- onKeyDown: (event) => {
1062
- if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey); }
1063
- if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1064
- },
1065
- }),
1066
- h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1067
- h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1068
- h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1069
- h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchHighlight(); } }, '×'),
1070
- ]);
1071
1040
  }
1072
- function whenCount(total, at) {
1041
+ }
1042
+ return total;
1043
+ }
1044
+ function findScrollSurface() {
1045
+ if (typeof document === 'undefined') return null;
1046
+ let best = null;
1047
+ for (const root of searchRoots()) {
1048
+ let all = [];
1049
+ try { all = root.querySelectorAll ? [...root.querySelectorAll('*')] : []; } catch { continue; }
1050
+ for (const el of all) {
1051
+ if (el.scrollHeight <= el.clientHeight + 40) continue;
1052
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
1053
+ const overflow = style === null ? '' : style.overflowY;
1054
+ if (overflow !== 'auto' && overflow !== 'scroll') continue;
1055
+ if (best === null || el.scrollHeight > best.scrollHeight) best = el;
1056
+ }
1057
+ }
1058
+ return best;
1059
+ }
1060
+ function scrollChatTurns() {
1061
+ const surface = findScrollSurface();
1062
+ if (surface !== null) surface.scrollBy({ top: -Math.round(Math.max(240, surface.clientHeight * 1.8)), behavior: 'smooth' });
1063
+ else if (typeof window !== 'undefined') window.scrollBy({ top: -Math.round(window.innerHeight * 1.8), behavior: 'smooth' });
1064
+ }
1065
+ function scrollChatTop() {
1066
+ const surface = findScrollSurface();
1067
+ if (surface !== null) surface.scrollTo({ top: 0, behavior: 'smooth' });
1068
+ else if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
1069
+ }
1070
+ /** Floating scroll controls: fixed top-right of the workspace, independent of any slot layout. */
1071
+ function installFloatingScrollButtons() {
1072
+ if (typeof document === 'undefined' || document.body === null) return null;
1073
+ const wrap = document.createElement('div');
1074
+ wrap.className = 'w2mScrollFloatWrap';
1075
+ wrap.setAttribute('data-w2m-scroll-float', 'true');
1076
+ const makeButton = (label, title) => {
1077
+ const button = document.createElement('button');
1078
+ button.type = 'button';
1079
+ button.className = 'w2mScrollFloatBtn';
1080
+ button.textContent = label;
1081
+ button.title = title;
1082
+ button.setAttribute('aria-label', title);
1083
+ wrap.appendChild(button);
1084
+ return button;
1085
+ };
1086
+ const up = makeButton('\u21E73', '向上滚动 3 轮(长按 3 秒到第一轮)');
1087
+ const top = makeButton('\u2912', '一键到顶');
1088
+ let holdTimer = null;
1089
+ let longFired = false;
1090
+ const clearHold = () => { if (holdTimer !== null) { clearTimeout(holdTimer); holdTimer = null; } };
1091
+ up.addEventListener('pointerdown', (event) => {
1092
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
1093
+ longFired = false;
1094
+ clearHold();
1095
+ holdTimer = setTimeout(() => { longFired = true; scrollChatTop(); }, 3000);
1096
+ });
1097
+ for (const type of ['pointerup', 'pointerleave', 'pointercancel']) up.addEventListener(type, clearHold);
1098
+ up.addEventListener('click', () => { if (longFired) { longFired = false; return; } scrollChatTurns(); });
1099
+ top.addEventListener('click', () => scrollChatTop());
1100
+ document.body.appendChild(wrap);
1101
+ return () => { clearHold(); wrap.remove(); };
1102
+ }
1103
+ function ConversationSearch() {
1104
+ const [open, setOpen] = React.useState(false);
1105
+ const [query, setQuery] = React.useState('');
1106
+ const [count, setCount] = React.useState(0);
1107
+ const [cursor, setCursor] = React.useState(-1);
1108
+ const inputRef = React.useRef(null);
1109
+ const holdRef = React.useRef(null);
1110
+ const longFiredRef = React.useRef(false);
1111
+ React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
1112
+ React.useEffect(() => {
1113
+ if (!open) { clearSearchMarks(); setCount(0); setCursor(-1); return undefined; }
1114
+ const total = paintSearchMarks(query);
1115
+ setCount(total);
1116
+ setCursor(total > 0 ? 0 : -1);
1117
+ const first = document.querySelector('mark.w2mSearchMark');
1118
+ if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1119
+ return () => clearSearchMarks();
1120
+ }, [open, query]);
1121
+ const step = (backwards) => {
1122
+ const marks = [...document.querySelectorAll('mark.w2mSearchMark')];
1123
+ if (marks.length === 0) { paintSearchMarks(query); return; }
1124
+ const next = (cursor + (backwards ? -1 : 1) + marks.length) % marks.length;
1125
+ setCursor(next);
1126
+ if (marks[next] && marks[next].scrollIntoView) marks[next].scrollIntoView({ block: 'center', behavior: 'smooth' });
1127
+ };
1128
+ const scrollTurns = () => {
1129
+ const surface = findScrollSurface();
1130
+ if (surface !== null) surface.scrollBy({ top: -Math.round(Math.max(240, surface.clientHeight * 1.8)), behavior: 'smooth' });
1131
+ else if (typeof window !== 'undefined') window.scrollBy({ top: -Math.round(window.innerHeight * 1.8), behavior: 'smooth' });
1132
+ };
1133
+ const scrollFirstTurn = () => {
1134
+ const surface = findScrollSurface();
1135
+ if (surface !== null) surface.scrollTo({ top: 0, behavior: 'smooth' });
1136
+ else if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
1137
+ };
1138
+ if (!open) {
1139
+ return h('span', { className: 'w2mSearchControls' }, [
1140
+ h('button', {
1141
+ key: 'up',
1142
+ type: 'button',
1143
+ className: 'w2mScrollUp',
1144
+ title: '向上滚动 3 轮(长按 3 秒到第一轮)',
1145
+ 'aria-label': '向上滚动 3 轮,长按 3 秒到第一轮',
1146
+ onMouseDown: () => {
1147
+ longFiredRef.current = false;
1148
+ holdRef.current = setTimeout(() => { longFiredRef.current = true; scrollFirstTurn(); }, 3000);
1149
+ },
1150
+ onMouseUp: () => { if (holdRef.current) { clearTimeout(holdRef.current); holdRef.current = null; } },
1151
+ onMouseLeave: () => { if (holdRef.current) { clearTimeout(holdRef.current); holdRef.current = null; } },
1152
+ onClick: () => { if (longFiredRef.current) { longFiredRef.current = false; return; } scrollTurns(); },
1153
+ }, '⇧3'),
1154
+ h('button', {
1155
+ key: 'find',
1156
+ type: 'button',
1157
+ className: 'w2mSearchToggle',
1158
+ title: '搜索本对话',
1159
+ 'aria-label': '搜索本对话',
1160
+ onClick: () => setOpen(true),
1161
+ }, '🔍'),
1162
+ ]);
1163
+ }
1164
+ return h('div', { className: 'w2mSearchBox', 'data-w2m-search': 'box' }, [
1165
+ h('input', {
1166
+ key: 'q',
1167
+ ref: inputRef,
1168
+ className: 'w2mSearchInput',
1169
+ value: query,
1170
+ placeholder: '搜索本对话全部内容',
1171
+ onChange: (event) => setQuery(event.target.value),
1172
+ onKeyDown: (event) => {
1173
+ if (event.key === 'Enter') { event.preventDefault(); step(event.shiftKey); }
1174
+ if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1175
+ },
1176
+ }),
1177
+ h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1178
+ h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1179
+ h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1180
+ h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchMarks(); } }, '×'),
1181
+ ]);
1182
+ }
1183
+ function whenCount(total, at) {
1073
1184
  return (at >= 0 ? at + 1 : 0) + '/' + total;
1074
1185
  }
1075
1186
 
@@ -1667,7 +1778,22 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1667
1778
  }
1668
1779
 
1669
1780
  function FleetChat({ sessionId, viewMode }) {
1670
- const CHAT_EMOJIS = ['👍', '✅', '🚀', '😄', '❤️', '🎉', '🔧', '📌'];
1781
+ // v1.9.18: chat emotes are local slash commands, never image bytes. The relay only sees text.
1782
+ const CHAT_COMMANDS = ['/微笑', '/火箭', '/烟花'];
1783
+ // v1.9.18: the sticker pack is local to the plugin. Commands travel as text; rendering never requests bytes.
1784
+ const CHAT_STICKER_GLYPHS = { '/微笑': '\u{1F60A}', '/火箭': '\u{1F680}', '/烟花': '\u{1F386}' };
1785
+ const CHAT_STICKER_RE = /(\/微笑|\/火箭|\/烟花)/g;
1786
+ function renderChatText(text) {
1787
+ const raw = String(text ?? '');
1788
+ const whole = CHAT_STICKER_GLYPHS[raw.trim()];
1789
+ if (whole !== undefined) return h('span', { className: 'w2mChat_stickerBig', 'data-sticker': raw.trim(), title: raw.trim() }, whole);
1790
+ const parts = raw.split(CHAT_STICKER_RE);
1791
+ if (parts.length === 1) return raw;
1792
+ return parts.map((part, index) => (CHAT_STICKER_GLYPHS[part] !== undefined
1793
+ ? h('span', { key: 'st' + index, className: 'w2mChat_sticker', 'data-sticker': part, title: part }, CHAT_STICKER_GLYPHS[part])
1794
+ : part));
1795
+ }
1796
+ const CHAT_EMOJIS = CHAT_COMMANDS;
1671
1797
  const [open, setOpen] = React.useState(false);
1672
1798
  const [messages, setMessages] = React.useState([]);
1673
1799
  const [usage, setUsage] = React.useState(null);
@@ -1973,7 +2099,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1973
2099
  var kind = messageKind(message, localMachineId);
1974
2100
  return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => { const selection = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''; if (selection !== '') return; setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }); } },
1975
2101
  h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', title: '复制这条消息', onClick: (event) => { event.stopPropagation(); copyText(String(message?.text ?? '')); } }, '⧉'), message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
1976
- h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
2102
+ h('div', { className: 'w2mChat_text' }, renderChatText(message?.text ?? '')));
1977
2103
  }),
1978
2104
  ].filter((node) => node !== null))),
1979
2105
 
@@ -2288,6 +2414,16 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2288
2414
  } catch (error) {
2289
2415
  console.error('@twinsearth/w2m-dsh-plugin: the conversation search control was not mounted', error);
2290
2416
  }
2417
+ // v1.9.20: floating scroll controls, fixed top-right of the workspace. DOM nodes, not slot
2418
+ // entries, so they appear whichever conversation view is active; ctx.effect disposes them.
2419
+ try {
2420
+ const disposeFloat = installFloatingScrollButtons();
2421
+ if (typeof disposeFloat === 'function' && typeof ctx.effect === 'function') {
2422
+ ctx.effect(() => disposeFloat, 'w2m: floating scroll buttons');
2423
+ }
2424
+ } catch (error) {
2425
+ console.error('@twinsearth/w2m-dsh-plugin: the floating scroll controls were not mounted', error);
2426
+ }
2291
2427
  // v1.9.6: fleet chat as a real conversation View tab (after Chat and Rules), rendering the
2292
2428
  // full chat page in the main workspace.
2293
2429
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.17",
3
+ "version": "1.9.20",
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": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
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": "1.9.17",
4
+ "version": "1.9.20",
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": [