@twinsearth/w2m-dsh-plugin 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0.5", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.0.7", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "2.0.6",
3
+ "case": "RELEASE-2060",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T17:02:50.164Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2060-1793206970165",
11
+ "requirement": "v2.0.6:CI 跨平台修复(realpath 归一) + 搜索规则(纯数字≤100拒绝/视口最多6条/滚动刷新) + 一键到顶等待加载",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "preflight": "16 pass / 0 fail / 1 unverifiable (GREEN)",
16
+ "tests": "declared suite all pass; client-bundle 27/27",
17
+ "lint": "0 errors",
18
+ "pack": "byte-identical; manifest equals tree",
19
+ "scope": "local code + tests only; release follows via tag v2.0.6"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "2.0.7",
3
+ "case": "RELEASE-2070",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T17:37:31.887Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2070-1793209051887",
11
+ "requirement": "v2.0.7: chat-enc-v2 门控 + encrypted mirror + v2 发送/读取;Node 执行能力开启",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "preflight": "GREEN",
16
+ "tests": "all declared suites",
17
+ "lint": "0 errors",
18
+ "scope": "local; relay already advertises chat-enc-v2"
19
+ }
20
+ }
21
+ }
package/lib/client.js CHANGED
@@ -1100,12 +1100,30 @@ function clearSearchMarks() {
1100
1100
  if (parent.normalize) parent.normalize();
1101
1101
  }
1102
1102
  }
1103
+ /**
1104
+ * Search rules (v2.0.5 UI): a pure number up to 100 is not a useful query in a conversation full of
1105
+ * ids/ports/timestamps, so it is refused outright (no marks, no scroll, no count). Only the viewport
1106
+ * is highlighted, at most six matches per screen; scrolling re-runs the pass for the new viewport.
1107
+ */
1108
+ const SEARCH_MAX_MARKS = 6;
1109
+ function searchQueryAllowed(query) {
1110
+ const text = String(query ?? '').trim();
1111
+ if (text === '') return false;
1112
+ if (/^\d+$/.test(text)) return Number(text) > 100;
1113
+ return true;
1114
+ }
1115
+ function viewportRect() {
1116
+ const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
1117
+ const height = typeof window !== 'undefined' ? window.innerHeight : 768;
1118
+ return { left: 0, top: 0, right: width, bottom: height };
1119
+ }
1103
1120
  function paintSearchMarks(query) {
1104
1121
  clearSearchMarks();
1105
- if (typeof document === 'undefined' || query === '') return 0;
1122
+ if (typeof document === 'undefined' || query === '' || !searchQueryAllowed(query)) return 0;
1106
1123
  const needle = query.toLowerCase();
1124
+ const view = viewportRect();
1107
1125
  let total = 0;
1108
- for (const root of searchRoots()) {
1126
+ outer: for (const root of searchRoots()) {
1109
1127
  let walker;
1110
1128
  try {
1111
1129
  walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
@@ -1124,21 +1142,28 @@ function paintSearchMarks(query) {
1124
1142
  let node = walker.nextNode();
1125
1143
  while (node !== null) { nodes.push(node); node = walker.nextNode(); }
1126
1144
  for (const textNode of nodes) {
1145
+ // Cheap pre-filter: a text node whose element is entirely outside the viewport cannot paint.
1146
+ const host = textNode.parentElement;
1147
+ const hostRect = host !== null && typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null;
1148
+ if (hostRect !== null && (hostRect.bottom < view.top || hostRect.top > view.bottom)) continue;
1127
1149
  const text = textNode.nodeValue ?? '';
1128
1150
  const lower = text.toLowerCase();
1129
1151
  const hits = [];
1130
1152
  let index = lower.indexOf(needle);
1131
1153
  while (index >= 0) { hits.push(index); index = lower.indexOf(needle, index + query.length); }
1132
- for (let k = hits.length - 1; k >= 0; k -= 1) {
1154
+ for (const hit of hits) {
1133
1155
  try {
1134
1156
  const range = document.createRange();
1135
- range.setStart(textNode, hits[k]);
1136
- range.setEnd(textNode, hits[k] + query.length);
1157
+ range.setStart(textNode, hit);
1158
+ range.setEnd(textNode, hit + query.length);
1159
+ const rect = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : null;
1160
+ if (rect !== null && (rect.bottom < view.top || rect.top > view.bottom || rect.right < view.left || rect.left > view.right)) continue;
1137
1161
  const mark = document.createElement('mark');
1138
1162
  mark.className = 'w2mSearchMark';
1139
1163
  mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
1140
1164
  range.surroundContents(mark);
1141
1165
  total += 1;
1166
+ if (total >= SEARCH_MAX_MARKS) break outer;
1142
1167
  } catch { /* a range that crosses elements cannot be wrapped; skip it */ }
1143
1168
  }
1144
1169
  }
@@ -1381,24 +1406,54 @@ function ConversationSearch() {
1381
1406
  React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
1382
1407
  // v2.0.5: wait until 3s pass without new input before walking the conversation, so typing does
1383
1408
  // not trigger a full-tree highlight pass on every keystroke (the reported input jank).
1384
- const paintNow = () => {
1385
- const total = paintSearchMarks(query);
1409
+ // Rules: a pure number <= 100 is refused; only the viewport is highlighted (max 6) and scrolling
1410
+ // repaints the current viewport without yanking the scroll position.
1411
+ const appliedRef = React.useRef('');
1412
+ const paintRef = React.useRef(() => {});
1413
+ paintRef.current = (focus = false) => {
1414
+ const total = paintSearchMarks(appliedRef.current);
1386
1415
  setCount(total);
1387
1416
  setCursor(total > 0 ? 0 : -1);
1388
- const first = document.querySelector('mark.w2mSearchMark');
1389
- if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1417
+ if (focus === true) {
1418
+ const first = document.querySelector('mark.w2mSearchMark');
1419
+ if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1420
+ }
1390
1421
  };
1391
1422
  React.useEffect(() => {
1392
- if (!open) { clearSearchMarks(); setCount(0); setCursor(-1); }
1423
+ if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); }
1393
1424
  }, [open]);
1394
1425
  React.useEffect(() => {
1395
1426
  if (!open) return undefined;
1396
- const timer = setTimeout(paintNow, 3000);
1427
+ if (!searchQueryAllowed(query)) {
1428
+ // Refused query: no marks, no count, no scroll — the input itself stays usable.
1429
+ appliedRef.current = '';
1430
+ clearSearchMarks();
1431
+ setCount(0);
1432
+ setCursor(-1);
1433
+ return undefined;
1434
+ }
1435
+ const timer = setTimeout(() => { appliedRef.current = query; paintRef.current(true); }, 3000);
1397
1436
  return () => clearTimeout(timer);
1398
1437
  }, [open, query]);
1438
+ React.useEffect(() => {
1439
+ if (!open) return undefined;
1440
+ let timer = null;
1441
+ const onScroll = () => {
1442
+ if (appliedRef.current === '') return;
1443
+ if (timer !== null) clearTimeout(timer);
1444
+ timer = setTimeout(() => { paintRef.current(false); }, 150);
1445
+ };
1446
+ window.addEventListener('scroll', onScroll, true);
1447
+ return () => {
1448
+ if (timer !== null) clearTimeout(timer);
1449
+ window.removeEventListener('scroll', onScroll, true);
1450
+ };
1451
+ }, [open]);
1399
1452
  const step = (backwards) => {
1400
- const marks = [...document.querySelectorAll('mark.w2mSearchMark')];
1401
- if (marks.length === 0) { paintSearchMarks(query); return; }
1453
+ if (appliedRef.current === '') return;
1454
+ let marks = [...document.querySelectorAll('mark.w2mSearchMark')];
1455
+ if (marks.length === 0) { paintRef.current(false); marks = [...document.querySelectorAll('mark.w2mSearchMark')]; }
1456
+ if (marks.length === 0) return;
1402
1457
  const next = (cursor + (backwards ? -1 : 1) + marks.length) % marks.length;
1403
1458
  setCursor(next);
1404
1459
  if (marks[next] && marks[next].scrollIntoView) marks[next].scrollIntoView({ block: 'center', behavior: 'smooth' });
@@ -1424,11 +1479,14 @@ function ConversationSearch() {
1424
1479
  placeholder: '搜索本对话全部内容',
1425
1480
  onChange: (event) => setQuery(event.target.value),
1426
1481
  onKeyDown: (event) => {
1427
- if (event.key === 'Enter') { event.preventDefault(); paintNow(); step(event.shiftKey); }
1482
+ if (event.key === 'Enter') {
1483
+ event.preventDefault();
1484
+ if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true); step(event.shiftKey); }
1485
+ }
1428
1486
  if (event.key === 'Escape') { setOpen(false); setQuery(''); }
1429
1487
  },
1430
1488
  }),
1431
- h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query === '' ? '' : '0') : whenCount(count, cursor)),
1489
+ h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (searchQueryAllowed(query) ? '0' : '') : whenCount(count, cursor)),
1432
1490
  h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
1433
1491
  h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
1434
1492
  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.0.5",
3
+ "version": "2.0.7",
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/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 test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.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/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 test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.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": "2.0.5",
4
+ "version": "2.0.7",
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": [
@@ -0,0 +1,40 @@
1
+ /**
2
+ * chat-enc-v2 capability gate (A7 wiring).
3
+ *
4
+ * The relay advertises `chat-enc-v2` in `/healthz.features`; when it does not, the plugin must keep
5
+ * sending v1 envelopes (which every 1.x relay accepts) rather than inventing a v2 shape. The answer
6
+ * is cached briefly so a chatty panel does not probe healthz on every message.
7
+ */
8
+ const TTL_MS = 60_000;
9
+ const cache = new Map();
10
+
11
+ export function resetChatV2Cache() {
12
+ cache.clear();
13
+ }
14
+
15
+ /**
16
+ * @param {{rabbitUrl?: string|null, fetchImpl?: Function, now?: () => number, signal?: AbortSignal, ttlMs?: number}} input
17
+ * @returns {Promise<boolean>}
18
+ */
19
+ export async function chatEncV2Supported(input = {}) {
20
+ const base = typeof input.rabbitUrl === 'string' ? input.rabbitUrl.replace(/\/$/, '') : '';
21
+ if (base === '') return false;
22
+ const now = typeof input.now === 'function' ? input.now() : Date.now();
23
+ const ttl = Number.isInteger(input.ttlMs) ? input.ttlMs : TTL_MS;
24
+ const hit = cache.get(base);
25
+ if (hit !== undefined && now - hit.at < ttl) return hit.supported;
26
+ const fetchImpl = typeof input.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
27
+ let supported = false;
28
+ try {
29
+ const response = await fetchImpl(base + '/healthz', { headers: { accept: 'application/json' }, signal: input.signal });
30
+ if (response?.ok === true) {
31
+ const body = await response.json();
32
+ const features = Array.isArray(body?.features) ? body.features : [];
33
+ supported = features.includes('chat-enc-v2');
34
+ }
35
+ } catch {
36
+ supported = false;
37
+ }
38
+ cache.set(base, { at: now, supported });
39
+ return supported;
40
+ }
@@ -28,7 +28,12 @@ function readConfig(stateDir) {
28
28
  }
29
29
 
30
30
  function insideStateDir(stateDir, target) {
31
- const rel = relative(stateDir, target);
31
+ // Compare canonical paths: Windows temp dirs come back as RUNNER~1 here and runneradmin in the
32
+ // realpath, and macOS /var is a symlink to /private/var, so a raw prefix test lets an inside path
33
+ // through on CI even though it is inside the state dir.
34
+ let base = stateDir;
35
+ try { base = realpathSync.native ? realpathSync.native(stateDir) : realpathSync(stateDir); } catch { /* keep raw */ }
36
+ const rel = relative(base, target);
32
37
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
33
38
  }
34
39
 
@@ -22,6 +22,8 @@ import {
22
22
  decodeTranscript, extractUserMessages, sessionDirName,
23
23
  } from './session-watch.mjs';
24
24
  import { createSessionSync } from './session-sync.mjs';
25
+ import { deriveChatKey, encryptChatTextV2 } from '../util/chat-crypto.mjs';
26
+ import { chatEncV2Supported } from './chat-v2-gate.mjs';
25
27
 
26
28
  /** Session prompts are mirrored only when the operator opts in; the default is off. */
27
29
  export const DEFAULT_MIRROR_MODE = 'off';
@@ -174,9 +176,38 @@ export function registerChatRoutes(ctx, deps) {
174
176
  const mirrorOnce = async () => {
175
177
  const mode = mirrorModeOf(deps.cfg());
176
178
  if (mode === 'off') return;
177
- if (mode === 'encrypted') return; // encrypted mirroring lands with the AEAD v2 slice
178
179
  const identity = await identityOf();
179
180
  if (identity.conversationKey === null) return;
181
+ if (mode === 'encrypted') {
182
+ // Relay gate: without chat-enc-v2 the mirror stays off (never send a shape the relay cannot
183
+ // validate). The fleet key derives the same group key every machine uses; the AAD binds the
184
+ // message to this conversation, this machine and a deterministic message id, so a ciphertext
185
+ // copied into another session is refused at decryption time.
186
+ const supported = await chatEncV2Supported({ rabbitUrl: deps.cfg()?.rabbitUrl ?? null });
187
+ if (!supported) return;
188
+ const accountMaterial = typeof deps.accountMaterial === 'function' ? await deps.accountMaterial() : null;
189
+ const chatKey = deriveChatKey(accountMaterial ?? deps.cfg()?.fleetKey ?? null);
190
+ if (chatKey === null) return;
191
+ const file = newestTranscript(projectDirOf());
192
+ if (file === null) return;
193
+ let messages;
194
+ try { messages = extractUserMessages(decodeTranscript(readFileSync(file))); } catch { return; }
195
+ for (const payload of sync.pending(messages)) {
196
+ const plain = truncateUtf8(payload.text, MIRROR_MAX_BYTES);
197
+ const messageId = createHash('sha256').update(identity.conversationKey + '|' + identity.machineId + '|' + plain).digest('hex').slice(0, 32);
198
+ const sealed = encryptChatTextV2(chatKey, plain, { sessionId: identity.conversationKey, machineId: identity.machineId, messageId });
199
+ if (sealed.ok !== true) continue;
200
+ await postToRelay(identity, {
201
+ text: sealed.text,
202
+ enc: sealed.enc,
203
+ machine_id: identity.machineId,
204
+ machine_name: identity.machineName,
205
+ origin: 'session',
206
+ });
207
+ }
208
+ return;
209
+ }
210
+ if (identity.conversationKey === null) return;
180
211
  const file = newestTranscript(projectDirOf());
181
212
  if (file === null) return;
182
213
  let messages;
@@ -206,7 +206,8 @@ import { buildBoard } from './task-graph.mjs';
206
206
  import { verifyEnvelopeSignature } from '../util/envelope-signature.mjs';
207
207
  import { createAskStore } from './ask-store.mjs';
208
208
  import { deriveTurns } from './turn.mjs';
209
- import { decryptChatMessage, deriveChatKey, encryptChatText, isEncrypted } from '../util/chat-crypto.mjs';
209
+ import { decryptChatMessage, deriveChatKey, encryptChatText, encryptChatTextV2, isEncrypted } from '../util/chat-crypto.mjs';
210
+ import { chatEncV2Supported } from './chat-v2-gate.mjs';
210
211
 
211
212
  /**
212
213
  * v1.2.6 reliability: two small files beside the plugin's state.
@@ -289,7 +290,7 @@ const MAX_STAGES = 16;
289
290
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
290
291
  * overrides it at runtime for tests and packagers.
291
292
  */
292
- const PLUGIN_VERSION = '2.0.5';
293
+ const PLUGIN_VERSION = '2.0.7';
293
294
 
294
295
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
295
296
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -6989,7 +6990,18 @@ hint: 'the local fleet-memory.md is unchanged; action read/show still reads loca
6989
6990
  */
6990
6991
  const chatKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
6991
6992
  const plainText = typeof args?.text === 'string' ? args.text : '';
6992
- const sealed = chatKey === null ? null : encryptChatText(chatKey, plainText);
6993
+ const idempotencyKey = typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== ''
6994
+ ? args.idempotency_key.trim()
6995
+ : 'spool-' + Date.now() + '-' + Math.random().toString(36).slice(2);
6996
+ // A7: v2 only when the relay advertises chat-enc-v2 AND this machine has an id to bind. Otherwise
6997
+ // send v1 (every relay accepts it) rather than an unverifiable v2 envelope.
6998
+ const canV2 = chatKey !== null && typeof cfg.machineId === 'string' && cfg.machineId !== ''
6999
+ && await chatEncV2Supported({ rabbitUrl: cfg.rabbitUrl ?? null });
7000
+ const sealed = chatKey === null
7001
+ ? null
7002
+ : (canV2
7003
+ ? encryptChatTextV2(chatKey, plainText, { sessionId, machineId: cfg.machineId, messageId: idempotencyKey })
7004
+ : encryptChatText(chatKey, plainText));
6993
7005
  /**
6994
7006
  * v1.2.6 reliability: an unreachable Rabbit does not lose the message. The exact body that could not be sent is
6995
7007
  * spooled to disk and replayed with the SAME idempotency key next time, so a message the relay actually received
@@ -7001,7 +7013,7 @@ hint: 'the local fleet-memory.md is unchanged; action read/show still reads loca
7001
7013
  ...(sealed !== null && sealed.ok === true ? { enc: sealed.enc } : {}),
7002
7014
  ...addressing,
7003
7015
  ...(mentions.length === 0 ? {} : { mentions }),
7004
- ...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : { idempotency_key: 'spool-' + Date.now() + '-' + Math.random().toString(36).slice(2) }),
7016
+ ...{ idempotency_key: idempotencyKey },
7005
7017
  ...(typeof args?.reply_state === 'string' && (args.reply_state === 'answered' || args.reply_state === 'refused') ? { reply_state: args.reply_state } : {}),
7006
7018
  };
7007
7019
  let posted = null;
@@ -7058,7 +7070,7 @@ hint: 'the local fleet-memory.md is unchanged; action read/show still reads loca
7058
7070
  const listKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
7059
7071
  const messages = rawMessages.map((message) => {
7060
7072
  if (!isEncrypted(message)) return message;
7061
- const opened = decryptChatMessage(listKey, message);
7073
+ const opened = decryptChatMessage(listKey, message, { sessionId: String(query.session_id ?? message.session_id ?? ''), machineId: typeof message.machine_id === 'string' ? message.machine_id : undefined });
7062
7074
  if (opened.ok === true) return { ...message, text: opened.text, decrypted: true };
7063
7075
  return { ...message, text: '', decrypted: false, encrypted: true, decrypt_error: opened.code };
7064
7076
  });