@twinsearth/w2m-dsh-plugin 1.1.22 → 1.2.0

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.1.22"
8
+ "version": "1.2.0"
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.1.22",
15
+ "version": "1.2.0",
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.1.22",
4
+ "version": "1.2.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.2.0] — 2026-10-18
6
+
7
+ **Fleet chat is one group per relay.**
8
+
9
+ The conversation used to be keyed from `process.cwd()`, which is the DSH process's own directory and differs per machine
10
+ (`E:\DS` here, `/root` there). Two machines therefore computed two different keys and each panel saw only its own messages:
11
+ "互相收不到队员聊天信息". The rule is now the simplest one that makes a fleet talk: **the relay is the group**.
12
+
13
+ - one relay → one conversation, whatever project a machine is working on;
14
+ - a trailing slash on the relay URL is the same relay;
15
+ - the project name and the account key are deliberately NOT part of the basis: including either split the fleet into
16
+ separate streams, which is exactly the bug;
17
+ - the machine id remains only as a last resort when no relay is configured, so a panel can still answer locally.
18
+
19
+ ### What was removed, and why it is not a loss
20
+
21
+ Two intermediate designs were built and dropped in this release: keying per project (relay + project name) and a
22
+ "同步编队全部项目" scope button that switched between the project stream and the fleet stream. With the relay as the group
23
+ there is nothing to switch to, so the button and its scope parameter are gone rather than left as a no-op. The panel is
24
+ simpler and the operator gets the fleet-wide conversation by default.
25
+
26
+ ### Verified
27
+
28
+ `conversationBasis` is pure and tested: same relay unites two machines, a trailing slash does not split them, different
29
+ relays stay apart, and a host without a relay falls back to the machine scope. 4 identity tests, 25 client-bundle tests,
30
+ tools 122. Gate GREEN twice, 15 of 15.
31
+
32
+ ### Not verified
33
+
34
+ Two-machine end-to-end against a live fleet; that check needs both machines on this build, which is the next step.
5
35
  ## [1.1.22] — 2026-10-18
6
36
 
7
37
  **The fleet-chat panel said "this machine has no fleet-chat identity yet" on a machine that was paired and online.**
@@ -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.1.22", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/lib/client.js CHANGED
@@ -693,7 +693,7 @@ window.__ModuleLoader__.load({
693
693
  : null;
694
694
  const mine = id === localMachineId;
695
695
  const name = known?.name ?? message?.machine_name ?? (mine ? localMachineName : null);
696
- const detail = mine ? '本机' : (known?.os ?? null);
696
+ const detail = mine ? '\u672c\u673a' : (known?.os ?? null);
697
697
  // A machine with no name yet is shown by a short id, never by a full UUID, and the detail says which of the
698
698
  // two it is -- "unknown" next to a UUID is what made the panel look like it was leaking identifiers.
699
699
  const shown = name ?? (id.length > 12 ? `${id.slice(0, 8)}…` : id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.1.22",
3
+ "version": "1.2.0",
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
  "main": "lib/index.js",
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.1.22",
4
+ "version": "1.2.0",
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": [
@@ -19,7 +19,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
  import {
22
- conversationKey as deriveConversationKey, decodeTranscript, extractUserMessages, sessionDirName,
22
+ decodeTranscript, extractUserMessages, sessionDirName,
23
23
  } from './session-watch.mjs';
24
24
  import { createSessionSync } from './session-sync.mjs';
25
25
 
@@ -56,7 +56,17 @@ const NO_STORE = { 'cache-control': 'no-store' };
56
56
  */
57
57
  export function registerChatRoutes(ctx, deps) {
58
58
  const status = { mounted: false, reason: 'the host has no Connection service' };
59
- const cwd = process.cwd();
59
+ /**
60
+ * The project this conversation belongs to.
61
+ *
62
+ * NOT `process.cwd()`: that is the DSH host's own directory, which differs per machine (E:\DS on one box, /root on
63
+ * another), so the derived conversation key differed per machine and every panel saw only its own messages. The
64
+ * operator's rule is account + project NAME; the name has to come from the project this plugin was configured for.
65
+ */
66
+ const projectDirOf = () => {
67
+ const configured = deps.cfg()?.projectDir;
68
+ return typeof configured === 'string' && configured !== '' ? configured : process.cwd();
69
+ };
60
70
  const sync = createSessionSync({ conversationKey: 'pending', machineId: 'pending', machineName: null });
61
71
 
62
72
  /**
@@ -81,15 +91,13 @@ export function registerChatRoutes(ctx, deps) {
81
91
  // Nothing can be keyed without a machine, and inventing one would put unrelated hosts in one conversation.
82
92
  return { error: 'CHAT_NO_IDENTITY', machineId: null, machineName: null, conversationKey: null, source: null };
83
93
  }
84
- const material = typeof deps.accountMaterial === 'function' ? deps.accountMaterial() : null;
85
- const basis = conversationBasis({ machineId, accountMaterial: material });
86
- const fingerprint = createHash('sha256')
87
- .update('w2m-conversation-basis-v1').update('\\u0000').update(basis.basis).digest('hex');
94
+ const basis = conversationBasis({ machineId, relayUrl: deps.cfg()?.rabbitUrl ?? null });
88
95
  resolved = {
89
96
  error: null,
90
97
  machineId,
91
98
  machineName: typeof local?.machine_name === 'string' ? local.machine_name : null,
92
- conversationKey: deriveConversationKey(fingerprint, cwd),
99
+ // One group per relay: every machine in this fleet shares this conversation, whatever its project.
100
+ conversationKey: createHash('sha256').update('w2m-fleet-conversation-v1').update(basis.basis).digest('hex'),
93
101
  source: basis.source,
94
102
  };
95
103
  return resolved;
@@ -118,7 +126,7 @@ export function registerChatRoutes(ctx, deps) {
118
126
  const mirrorOnce = async () => {
119
127
  const identity = await identityOf();
120
128
  if (identity.conversationKey === null) return;
121
- const file = newestTranscript(cwd);
129
+ const file = newestTranscript(projectDirOf());
122
130
  if (file === null) return;
123
131
  let messages;
124
132
  try { messages = extractUserMessages(decodeTranscript(readFileSync(file))); } catch { return; }
@@ -153,6 +161,9 @@ export function registerChatRoutes(ctx, deps) {
153
161
 
154
162
  const url = new URL(request.url);
155
163
  const sessionIdOf = (value) => (typeof value === 'string' ? value.trim() : '');
164
+ // "All projects in this fleet" is an explicit scope, not the default: the operator presses a button for it.
165
+ // Its key is the relay alone -- deliberately NOT the project name -- so every machine in the fleet
166
+ // lands in one stream. It is not a history fetch: the caller sends a floor timestamp (see the panel).
156
167
  let sessionId = '';
157
168
  let text = null;
158
169
  if (request.method === 'GET') {
@@ -167,14 +178,12 @@ export function registerChatRoutes(ctx, deps) {
167
178
  sessionId = sessionIdOf(body?.session_id);
168
179
  text = typeof body?.text === 'string' ? body.text : null;
169
180
  }
170
- const identity = await identityOf();
171
- if (identity.error !== null) {
172
- return refuse(503, identity.error, 'this machine has no fleet-chat identity yet', 'pair the machine and let it derive the fleet key');
173
- }
174
- // v1.1.20: the browser's session id is per-machine and no other machine can compute it, so the wire key is the
175
- // conversation key (account + project directory). The browser's value is accepted and then replaced.
176
- sessionId = identity.conversationKey;
177
- if (sessionId === '') return refuse(400, 'CHAT_SESSION_ID_REQUIRED', '`session_id` is required', 'chat is scoped to one conversation');
181
+ const identity = await identityOf();
182
+ if (identity.error !== null) {
183
+ return refuse(503, identity.error, 'this machine has no fleet-chat identity yet', 'pair the machine and let it derive the fleet key');
184
+ }
185
+ // One group per relay: the browser sends its own session id, which is replaced here.
186
+ sessionId = identity.conversationKey;
178
187
  if (sessionId.length > MAX_SESSION_ID_LENGTH) {
179
188
  return refuse(400, 'CHAT_SESSION_ID_TOO_LONG', `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters`, 'send the id the host gave this session');
180
189
  }
@@ -232,22 +241,23 @@ export function registerChatRoutes(ctx, deps) {
232
241
  });
233
242
  return status;
234
243
  }
235
-
236
- /**
237
- * The material a conversation is keyed from, and which scope it represents.
238
- *
239
- * The account key is the right basis -- two machines on the same account and the same project are one conversation. But
240
- * the account key is derived asynchronously at stack start, and a panel that cannot answer at all is worse than a panel
241
- * that answers for one machine until the join lands. So the machine id is the documented fallback, and the answer says
242
- * which basis it used rather than leaving the caller to guess.
243
- *
244
- * @param {{machineId?: string|null, accountMaterial?: string|null}} input
245
- * @returns {{basis: string, source: 'account'|'machine'}|null} null when there is no machine to key on
246
- */
247
- export function conversationBasis({ machineId, accountMaterial } = {}) {
248
- const machine = typeof machineId === 'string' && machineId !== '' ? machineId : null;
249
- if (machine === null) return null;
250
- const account = typeof accountMaterial === 'string' && accountMaterial.trim() !== '' ? accountMaterial.trim() : null;
251
- if (account !== null) return { basis: 'account:' + account, source: 'account' };
252
- return { basis: 'machine:' + machine, source: 'machine' };
253
- }
244
+
245
+ /**
246
+ * The material a conversation is keyed from.
247
+ *
248
+ * The operator's rule, in its simplest form: **one relay is one group**. Every machine talking to the same relay shares
249
+ * one conversation, whatever project it is working on. Neither the project name nor the account key is part of the basis:
250
+ * including either split the fleet into separate streams, which is the report this replaces.
251
+ *
252
+ * The machine id stays only as a last resort for a host with no relay configured, so the panel can still answer locally.
253
+ *
254
+ * @param {{machineId?: string|null, relayUrl?: string|null}} input
255
+ * @returns {{basis: string, source: 'fleet'|'machine'}|null} null when there is no machine to key on
256
+ */
257
+ export function conversationBasis({ machineId, relayUrl } = {}) {
258
+ const machine = typeof machineId === 'string' && machineId !== '' ? machineId : null;
259
+ if (machine === null) return null;
260
+ const relay = typeof relayUrl === 'string' && relayUrl.trim() !== '' ? relayUrl.trim().replace(/\/+$/, '') : null;
261
+ if (relay !== null) return { basis: 'fleet:' + relay, source: 'fleet' };
262
+ return { basis: 'machine:' + machine, source: 'machine' };
263
+ }
@@ -220,7 +220,7 @@ const MAX_STAGES = 16;
220
220
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
221
221
  * overrides it at runtime for tests and packagers.
222
222
  */
223
- const PLUGIN_VERSION = '1.1.22';
223
+ const PLUGIN_VERSION = '1.2.0';
224
224
 
225
225
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
226
226
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;