@twinsearth/w2m-dsh-plugin 1.2.2 → 1.2.3

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.2.2"
8
+ "version": "1.2.3"
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.2.2",
15
+ "version": "1.2.3",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -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.2.2",
4
+ "version": "1.2.3",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.2.3] — 2026-10-20
11
+
12
+ **The packed artifact did not mount. This fixes that, and the reason it was not caught here.**
13
+
14
+ `v1.2.2` was released, published to npm, and **failed Compat**: the packed artifact would not mount in a real profile.
15
+
16
+ ```
17
+ JsonSchemaError: unsupported JSON schema: parameters.params.additionalProperties must be explicitly true or false
18
+ ```
19
+
20
+ `w2m_intent` declared `params: { type: "object", ... }` without stating `additionalProperties`. DSH refuses such a
21
+ schema **at mount time** -- in the profile of whoever installs the build -- and this repository had no check that would
22
+ have said so before shipping: the local loader gate mounted the artifact on a host whose compiler path differs, so it
23
+ stayed green while the real profile refused the entry.
24
+
25
+ ### What changed
26
+
27
+ - `w2m_intent`'s `params` now states `additionalProperties: true`, and every tool schema is checked when it is defined.
28
+ - New `src/plugin/schema-spec.mjs`: a pure walker over schema-shaped values, and `schemaProblems()` which returns every
29
+ object node that does not state `additionalProperties` as a boolean -- all of them at once, not the first one.
30
+ - `defineTool` now runs that check, so a tool with an under-specified object parameter fails where it is written rather
31
+ than in someone else's profile.
32
+ - `test/schema-spec.test.mjs`: 5 tests on the walker and the checker (nested properties, array items, unions, multiple
33
+ offenders, `true` accepted as well as `false`).
34
+
35
+ ### Panel polish
36
+
37
+ - **Thread grouping**: consecutive messages that share a thread are shown under one thread head. Only consecutive runs are
38
+ grouped, because re-sorting by thread would put an answer above the question it answers.
39
+ - **Unread count**: the trigger shows how many messages arrived since this conversation was last opened. The watermark is
40
+ local storage per conversation -- the relay stays stateless and no "who read what" is invented.
41
+
42
+ - **Task board**: the panel shows the relay's own task list (up to six rows: id prefix, mode, status) through a host
43
+ proxy (`/api/w2m/tasks`), so the browser never holds a device token.
44
+
45
+ Still missing from the panel: a timeout mark on an unanswered question, which needs the ask store from the turn state
46
+ machine.
47
+ ### Verified
48
+
49
+ Gate GREEN twice, 15 of 15: 2555 tests / 2541 pass / 0 fail / 14 skip.
50
+
51
+ ### Not verified
52
+
53
+ Compat itself: that workflow runs in its own environment, so this release is verified by the same local gate that missed
54
+ the defect. **Watch the Compat run for this tag.** If it fails again, the mount is failing for a different reason than the
55
+ schema and the log will say which entry and which fiber.
10
56
  ## [1.2.2] — 2026-10-20
11
57
 
12
58
  **Fleet collaboration, part one: shared-memory writes, a council of machines, and the panel that reaches them.**
@@ -22,33 +68,33 @@ Creating the tag through the API had already triggered one run, which created th
22
68
  collided with it. A release step that cannot be retried fails for the wrong reason, so `beta.yml` now refreshes an existing
23
69
  release (notes + assets with `--clobber`) instead of refusing, and says so in the log.
24
70
 
25
- ### What is in it
26
-
27
- - **Shared-memory writes**: `w2m_memory` gained `action: "append"` with `if_revision`. A stale revision comes back as a 409
28
- the caller reads (revision, how far behind, which records it has not seen) and is deliberately **not** retried in the
29
- transport -- retrying there turns a visible conflict into a silent overwrite.
30
- - **A council of machines**: `src/plugin/council-fleet.mjs` keeps the single-machine decision rule and changes the
31
- transport to chat messages on `council-<id>`, so every machine reads the same ballots and recounts the same outcome.
32
- Weights are a pure function of the ballot; a tie is refused; one ballot per machine; a late ballot is refused by name.
33
- `w2m_council` exposes `fleet-open` / `fleet-vote` / `fleet-decide` / `fleet-state` / `fleet-list`.
34
- - **The panel**: a recipient picker (default "all"), a thread chip on messages that belong to a thread, click-to-reply with
35
- a quoted line above the input, and the reply thread travelling with it. The host route refuses a single recipient that
36
- is not in this fleet (`CHAT_UNKNOWN_RECIPIENT`) and says `CHAT_ROSTER_UNAVAILABLE` when the roster could not be read at
37
- all -- a relay outage is not a typo in the recipient.
38
- - **A readable view over memory**: `w2m_memory` read now returns `view` (counts by machine and by status, one line per
39
- record). A row shape it does not recognise is shown as itself rather than dropped.
40
- - **The beta pipeline is repeatable**: creating the tag had triggered a run that created the release, so the explicit
41
- dispatch collided with it. The step now refreshes an existing release instead of refusing.
42
-
43
- ### Verified
44
-
45
- Gate GREEN twice, 15 of 15: 2550 tests / 2536 pass / 0 fail / 14 skip. tools 125 (three append cases), council-fleet 12,
46
- memory-view 6, client-bundle 25.
47
-
48
- ### Not verified
49
-
50
- The fleet council and a directed message across two live machines, and the panel against a second machine. Those need two
51
- machines on this build.
71
+ ### What is in it
72
+
73
+ - **Shared-memory writes**: `w2m_memory` gained `action: "append"` with `if_revision`. A stale revision comes back as a 409
74
+ the caller reads (revision, how far behind, which records it has not seen) and is deliberately **not** retried in the
75
+ transport -- retrying there turns a visible conflict into a silent overwrite.
76
+ - **A council of machines**: `src/plugin/council-fleet.mjs` keeps the single-machine decision rule and changes the
77
+ transport to chat messages on `council-<id>`, so every machine reads the same ballots and recounts the same outcome.
78
+ Weights are a pure function of the ballot; a tie is refused; one ballot per machine; a late ballot is refused by name.
79
+ `w2m_council` exposes `fleet-open` / `fleet-vote` / `fleet-decide` / `fleet-state` / `fleet-list`.
80
+ - **The panel**: a recipient picker (default "all"), a thread chip on messages that belong to a thread, click-to-reply with
81
+ a quoted line above the input, and the reply thread travelling with it. The host route refuses a single recipient that
82
+ is not in this fleet (`CHAT_UNKNOWN_RECIPIENT`) and says `CHAT_ROSTER_UNAVAILABLE` when the roster could not be read at
83
+ all -- a relay outage is not a typo in the recipient.
84
+ - **A readable view over memory**: `w2m_memory` read now returns `view` (counts by machine and by status, one line per
85
+ record). A row shape it does not recognise is shown as itself rather than dropped.
86
+ - **The beta pipeline is repeatable**: creating the tag had triggered a run that created the release, so the explicit
87
+ dispatch collided with it. The step now refreshes an existing release instead of refusing.
88
+
89
+ ### Verified
90
+
91
+ Gate GREEN twice, 15 of 15: 2550 tests / 2536 pass / 0 fail / 14 skip. tools 125 (three append cases), council-fleet 12,
92
+ memory-view 6, client-bundle 25.
93
+
94
+ ### Not verified
95
+
96
+ The fleet council and a directed message across two live machines, and the panel against a second machine. Those need two
97
+ machines on this build.
52
98
 
53
99
  ## [1.2.1] — 2026-10-18
54
100
 
@@ -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.2.2", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.3", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/lib/client.js CHANGED
@@ -300,6 +300,12 @@ window.__ModuleLoader__.load({
300
300
  '.w2mChat_replyCancel{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer}',
301
301
  '.w2mChat_msg{cursor:pointer}',
302
302
  '.w2mChat_thread{margin-left:6px;opacity:.7}',
303
+ '.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
304
+ '.w2mChat_board{display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-bottom:.5px solid var(--dsw-alias-border-default)}',
305
+ '.w2mChat_boardTitle{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
306
+ '.w2mChat_boardRow{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
307
+ '.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
308
+ '.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
303
309
  '.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
304
310
  '.w2mChat_msg.is-session .w2mChat_text{color:var(--dsw-danger)}', '.w2mChat_msg.is-chat .w2mChat_text{color:var(--dsw-success)}', '.w2mChat_msg.is-remote .w2mChat_text{color:var(--dsw-text)}', '.w2mChat_avatar{display:inline-flex;width:16px;height:16px;margin-right:5px;vertical-align:-3px}',
305
311
  '.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
@@ -621,6 +627,16 @@ window.__ModuleLoader__.load({
621
627
  const [roster, setRoster] = React.useState(null);
622
628
  const [toRecipient, setToRecipient] = React.useState('all');
623
629
  const [replyTo, setReplyTo] = React.useState(null);
630
+ const [tasks, setTasks] = React.useState([]);
631
+ const [lastSeenMs, setLastSeenMs] = React.useState(() => {
632
+ try {
633
+ const raw = globalThis.localStorage?.getItem('w2m:chat:seen:' + sessionId);
634
+ const parsed = Number.parseInt(raw ?? '', 10);
635
+ return Number.isFinite(parsed) ? parsed : 0;
636
+ } catch {
637
+ return 0;
638
+ }
639
+ });
624
640
  const [text, setText] = React.useState('');
625
641
  const [error, setError] = React.useState(null);
626
642
  const [busy, setBusy] = React.useState(false);
@@ -666,6 +682,40 @@ window.__ModuleLoader__.load({
666
682
  });
667
683
  return () => controller.abort();
668
684
  }, [open, sessionId]);
685
+
686
+ // The task board is the relay's own list: the host proxies it so the panel never holds a device token.
687
+ React.useEffect(() => {
688
+ if (!open) return undefined;
689
+ const controller = new AbortController();
690
+ chatCall({ url: '/api/w2m/tasks?limit=20', init: { signal: controller.signal } })
691
+ .then((result) => { if (!controller.signal.aborted) setTasks(result.ok && Array.isArray(result.data?.tasks) ? result.data.tasks : []); })
692
+ .catch(() => { /* the board is an extra; a failed read must not break the chat */ });
693
+ return () => controller.abort();
694
+ }, [open]);
695
+
696
+ // Unread is measured against a per-conversation watermark in local storage: the relay stays stateless, and
697
+ // the count answers "is there anything I have not looked at" without claiming which messages a machine read.
698
+ React.useEffect(() => {
699
+ if (!open || messages.length === 0) return undefined;
700
+ const latest = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) ? Math.max(max, message.at_ms) : max), 0);
701
+ if (latest <= 0 || latest <= lastSeenMs) return undefined;
702
+ setLastSeenMs(latest);
703
+ try { globalThis.localStorage?.setItem('w2m:chat:seen:' + sessionId, String(latest)); } catch { /* storage may be unavailable */ }
704
+ return undefined;
705
+ }, [open, messages, sessionId, lastSeenMs]);
706
+
707
+ // Consecutive messages that share a thread are shown under one thread head. Grouping only consecutive runs keeps
708
+ // the reading order the relay assigned -- re-sorting by thread would put an answer above the question it answers.
709
+ const groupThreads = (list) => {
710
+ const groups = [];
711
+ for (const message of list ?? []) {
712
+ const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
713
+ const last = groups[groups.length - 1] ?? null;
714
+ if (last !== null && last.threadId === threadId) { last.messages.push(message); continue; }
715
+ groups.push({ threadId, messages: [message] });
716
+ }
717
+ return groups;
718
+ };
669
719
 
670
720
  const send = React.useCallback(async () => {
671
721
  const value = text.trim();
@@ -696,6 +746,7 @@ window.__ModuleLoader__.load({
696
746
  return undefined;
697
747
  }, [busy, load, sessionId, text]);
698
748
 
749
+ const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
699
750
  const disabled = typeof sessionId !== 'string' || sessionId === '';
700
751
  const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
701
752
  const machineLabel = (message) => {
@@ -722,7 +773,7 @@ window.__ModuleLoader__.load({
722
773
  'aria-expanded': open,
723
774
  disabled,
724
775
  onClick: () => setOpen((value) => !value),
725
- }, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天')),
776
+ }, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天'), unseenCount > 0 && !open ? h('span', { className: 'w2mChat_unseen' }, String(unseenCount)) : null),
726
777
  open
727
778
  ? h('div', { className: 'w2mChat_panel', role: 'dialog', 'aria-label': '编队聊天' },
728
779
  h('div', { className: 'w2mChat_head' },
@@ -738,16 +789,24 @@ window.__ModuleLoader__.load({
738
789
  h('span', null, '编队聊天'),
739
790
  usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
740
791
  h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
792
+ tasks.length === 0 ? null : h('div', { className: 'w2mChat_board' }, [
793
+ h('div', { key: 'title', className: 'w2mChat_boardTitle' }, '任务看板'),
794
+ ...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
795
+ `${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
796
+ ]),
741
797
  h('div', { className: 'w2mChat_list' },
742
798
  error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
743
799
  messages.length === 0
744
800
  ? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
745
- : messages.map((message) => {
801
+ : groupThreads(messages).flatMap((group, groupIndex) => [
802
+ group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
803
+ ...group.messages.map((message) => {
746
804
  var kind = messageKind(message, localMachineId);
747
805
  return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }) },
748
806
  h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
749
807
  h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
750
- })),
808
+ }),
809
+ ].filter((node) => node !== null))),
751
810
 
752
811
 
753
812
  h('div', { className: 'w2mChat_compose' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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,14 +64,14 @@
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",
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",
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",
71
71
  "test:admission": "node --test --test-force-exit test/admission.test.mjs",
72
72
  "test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
73
73
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
74
- "test:all": "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/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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",
74
+ "test:all": "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/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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",
75
75
  "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
76
76
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
77
77
  "test:authority": "node --test --test-force-exit test/authority.test.mjs",
@@ -126,7 +126,9 @@
126
126
  "test:dataflow": "node --test --test-force-exit test/dataflow.test.mjs",
127
127
  "test:council-fleet": "node --test --test-force-exit test/council-fleet.test.mjs",
128
128
  "test:fleet-memory": "node --test --test-force-exit test/fleet-memory.test.mjs",
129
- "test:memory-view": "node --test --test-force-exit test/memory-view.test.mjs"
129
+ "test:memory-view": "node --test --test-force-exit test/memory-view.test.mjs",
130
+ "test:schema-spec": "node --test --test-force-exit test/schema-spec.test.mjs",
131
+ "test:fleet-weight-parity": "node --test --test-force-exit test/fleet-weight-parity.test.mjs"
130
132
  },
131
133
  "devDependencies": {
132
134
  "@eslint/js": "^10.0.1",
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.2.2",
4
+ "version": "1.2.3",
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": [
@@ -42,7 +42,10 @@ function newestTranscript(cwd) {
42
42
  return best === null ? null : best.file;
43
43
  }
44
44
 
45
- export const CHAT_ROUTE_PATH = '/api/w2m/chat';
45
+ export const CHAT_ROUTE_PATH = '/api/w2m/chat';
46
+
47
+ /** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
48
+ export const TASKS_ROUTE_PATH = '/api/w2m/tasks';
46
49
  export const MAX_SESSION_ID_LENGTH = 200;
47
50
 
48
51
  const NO_STORE = { 'cache-control': 'no-store' };
@@ -284,6 +287,48 @@ export function registerChatRoutes(ctx, deps) {
284
287
  }
285
288
  },
286
289
  });
290
+ // A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
291
+ // token that never leaves the host.
292
+ registry.register({
293
+ path: TASKS_ROUTE_PATH,
294
+ methods: ['GET'],
295
+ requestBody: 'buffered',
296
+ fetch: async (request) => {
297
+ const refuse = (httpStatus, reasonCode, error, detail) =>
298
+ Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
299
+ if (request.method !== 'GET') {
300
+ return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
301
+ }
302
+ const url = new URL(request.url);
303
+ const limitRaw = url.searchParams.get('limit');
304
+ const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
305
+ const cfg = deps.cfg();
306
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
307
+ if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
308
+ let token;
309
+ try {
310
+ token = await deps.resolveToken();
311
+ } catch (error) {
312
+ return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
313
+ }
314
+ if (typeof token !== 'string' || token === '') {
315
+ return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
316
+ }
317
+ try {
318
+ const listed = await deps.request({
319
+ rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
320
+ });
321
+ if (listed?.ok !== true) {
322
+ const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
323
+ return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
324
+ }
325
+ return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
326
+ } catch (error) {
327
+ return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
328
+ }
329
+ },
330
+ });
331
+
287
332
  status.mounted = true;
288
333
  status.reason = null;
289
334
  const timer = setInterval(() => { mirrorOnce().catch(() => { /* one bad tick must not stop the next */ }); }, 2000);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Authoring rules DSH enforces on tool schemas, checked here so the failure happens at build time in this repository
3
+ * rather than at mount time in someone else's profile.
4
+ *
5
+ * The rule this exists for: DSH's `parameterSchemaSpecToJsonSchema` requires an EXPLICIT `additionalProperties` on every
6
+ * `type: 'object'` node and throws otherwise. Local tests passed while the packed artifact failed in a real profile,
7
+ * which is exactly the gap a pure checker closes: the same walk runs in the suite and at registration.
8
+ */
9
+
10
+ /**
11
+ * Every `type: 'object'` node in a JSON-schema-shaped value, with its path.
12
+ *
13
+ * @param {unknown} schema
14
+ * @param {string} [path]
15
+ * @returns {Array<{path: string, node: object}>}
16
+ */
17
+ export function objectNodes(schema, path = 'schema') {
18
+ const found = [];
19
+ if (schema === null || typeof schema !== 'object') return found;
20
+ const node = schema;
21
+ if (node.type === 'object') found.push({ path, node });
22
+ if (node.properties !== null && typeof node.properties === 'object') {
23
+ for (const [name, child] of Object.entries(node.properties)) {
24
+ found.push(...objectNodes(child, path + '.properties.' + name));
25
+ }
26
+ }
27
+ if (node.items !== undefined) found.push(...objectNodes(node.items, path + '[]'));
28
+ for (const key of ['oneOf', 'anyOf', 'allOf']) {
29
+ if (Array.isArray(node[key])) {
30
+ node[key].forEach((child, index) => found.push(...objectNodes(child, path + '.' + key + '[' + index + ']')));
31
+ }
32
+ }
33
+ return found;
34
+ }
35
+
36
+ /**
37
+ * The problems a schema would be refused for, as `{path, why}` values rather than a thrown error, so a caller can report
38
+ * all of them at once instead of fixing one per run.
39
+ *
40
+ * @param {unknown} schema
41
+ * @param {string} [name] tool name, used in the path
42
+ * @returns {Array<{path: string, why: string}>}
43
+ */
44
+ export function schemaProblems(schema, name = 'tool') {
45
+ return objectNodes(schema, 'parameters')
46
+ .filter(({ node }) => typeof node.additionalProperties !== 'boolean')
47
+ .map(({ path }) => ({ path: path + '.additionalProperties', why: 'an object parameter must state additionalProperties: true or false' }))
48
+ .map((problem) => ({ ...problem, path: name + '.' + problem.path }));
49
+ }
@@ -185,6 +185,7 @@ import {
185
185
  import { registerChatRoutes } from './fleet-chat-route.mjs';
186
186
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
187
187
  import { renderMemoryView } from './memory-view.mjs';
188
+ import { schemaProblems } from './schema-spec.mjs';
188
189
  import { listIntents, resolveIntent } from './intents.mjs';
189
190
  // v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
190
191
  // module header for why a discovered device is a candidate to *ask* and never an authorisation.
@@ -223,7 +224,7 @@ const MAX_STAGES = 16;
223
224
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
224
225
  * overrides it at runtime for tests and packagers.
225
226
  */
226
- const PLUGIN_VERSION = '1.2.2';
227
+ const PLUGIN_VERSION = '1.2.3';
227
228
 
228
229
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
229
230
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -556,6 +557,15 @@ function createDefineTool(real) {
556
557
  parameters[key] = normaliseParameter(name, key, decl);
557
558
  }
558
559
 
560
+ // v1.2.3: DSH refuses an object parameter that does not state additionalProperties explicitly, and it
561
+ // refuses it at MOUNT time -- in the profile of whoever installed the build. That is the failure the packed
562
+ // artifact hit in a real host while this repository tests were green, so the rule is enforced here, where
563
+ // every tool already passes through.
564
+ const schemaProblemsFound = schemaProblems({ type: 'object', properties: parameters, additionalProperties: false }, name);
565
+ if (schemaProblemsFound.length > 0) {
566
+ throw new TypeError(schemaProblemsFound.map((problem) => problem.path + ': ' + problem.why).join('; '));
567
+ }
568
+
559
569
  return { ...definition, parameters };
560
570
  };
561
571
 
@@ -6173,7 +6183,7 @@ export async function apply(ctx, config = {}) {
6173
6183
  skipped_lines: current.skipped,
6174
6184
  truncated: current.truncated,
6175
6185
  status_tally: tally(current.records),
6176
- records: current.records,
6186
+ records: current.records,
6177
6187
  view: renderMemoryView(current.records),
6178
6188
  ...(previous === null
6179
6189
  ? {}
@@ -6508,7 +6518,11 @@ export async function apply(ctx, config = {}) {
6508
6518
  'command the machine allow-list does not carry is refused with the list it checked.',
6509
6519
  parameters: {
6510
6520
  action: { type: 'string', description: 'The catalog action; call with action:"list" to read the catalog.' },
6511
- params: { type: 'object', description: 'Action parameters, for example { n: 3 } for shard-probe.' },
6521
+ params: {
6522
+ type: 'object',
6523
+ additionalProperties: true,
6524
+ description: 'Action parameters, for example { n: 3 } for shard-probe.',
6525
+ },
6512
6526
  },
6513
6527
  output: jsonOutput,
6514
6528
  presentCall: (args) => ({ card: 'generic', title: 'w2m intent ' + (typeof args?.action === 'string' ? args.action : '?'), kind: 'read', rawInput: {} }),
@@ -6609,7 +6623,29 @@ export async function apply(ctx, config = {}) {
6609
6623
  }
6610
6624
  if (action === 'fleet-decide') {
6611
6625
  const decided = fleetCouncil.decide({ councilId: id });
6612
- return JSON.stringify({ ok: decided.ok, action, council_id: id, thread_id: threadId, reason_code: decided.code ?? null, detail: decided.detail ?? null, council: decided.council ?? null });
6626
+ // The decision travels as an append-only memory record: a decision that exists only in one process memory
6627
+ // is a decision nobody can audit later. Writing it is best-effort -- the outcome is returned either way and
6628
+ // `trail` says which happened, rather than implying the record exists when it does not.
6629
+ let trail = 'not-written';
6630
+ if (decided.ok === true && decided.council?.decision != null) {
6631
+ try {
6632
+ const trailUrl = cfg?.rabbitUrl ?? null;
6633
+ const trailToken = trailUrl === null ? null : await resolveToken(cfg);
6634
+ if (trailUrl !== null && typeof trailToken === 'string' && trailToken !== '') {
6635
+ const decision = decided.council.decision;
6636
+ const line = 'council ' + String(id) + ': ' + String(decision.choice) + ' (weight ' + String(decision.weight) + ', '
6637
+ + String(decided.council.ballots.length) + ' ballot(s))';
6638
+ const appended = await request({
6639
+ rabbitUrl: trailUrl, token: trailToken, method: 'PUT',
6640
+ pathname: '/v1/fleet/memory/fleet', body: { records: [line] }, signal: exec?.signal,
6641
+ });
6642
+ trail = appended?.ok === true ? 'appended' : 'refused';
6643
+ }
6644
+ } catch {
6645
+ trail = 'error';
6646
+ }
6647
+ }
6648
+ return JSON.stringify({ ok: decided.ok, action, council_id: id, thread_id: threadId, reason_code: decided.code ?? null, detail: decided.detail ?? null, trail, council: decided.council ?? null });
6613
6649
  }
6614
6650
  if (action === 'fleet-list') {
6615
6651
  return JSON.stringify({ ok: true, action, ...fleetCouncil.list() });