@twinsearth/w2m-dsh-plugin 1.2.0 → 1.2.1

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.0"
8
+ "version": "1.2.1"
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.0",
15
+ "version": "1.2.1",
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.0",
4
+ "version": "1.2.1",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.2.1] — 2026-10-18
6
+
7
+ **P1 of fleet collaboration: addressing, correlation, and a turn that can time out.**
8
+
9
+ ### 1. Addressing — a message can name its recipient
10
+
11
+ `POST /v1/fleet/messages` accepts `to`: `"all"` (the fleet group, the default) or one `machine_id`. The relay stores ONE row
12
+ either way and filters at read time (`for_me=1` is answered from the authenticated device identity, never from a caller
13
+ supplied id), because N copies for N recipients would multiply the byte budget by the size of the fleet for no extra
14
+ meaning. A malformed address is refused `BAD_ADDRESS` by the store and `CHAT_BAD_ADDRESS` by the host route.
15
+
16
+ ### 2. Correlation — threads and replies
17
+
18
+ Messages carry `thread_id`, `reply_to` and `turn`. `reply_to` must name a message that exists in the SAME session
19
+ (`REPLY_TARGET_MISSING` otherwise): a dangling reply makes a thread unreadable. `turn` is assigned by the relay and counts
20
+ the exchange inside a thread, so a reader can order answers without trusting client clocks.
21
+
22
+ ### 6. Turn semantics — waiting is a state, timeouts are values
23
+
24
+ `w2m_chat` gained `action: "ask"` (send, return the message id) and `action: "await"` (wait for a reply to that id).
25
+ `await` returns `{state: "answered" | "timeout"}` — a timeout is a result the caller reads, not an exception that loses the
26
+ question, and the question stays in the flow so it can be answered later.
27
+
28
+ ### What this needs from the relay
29
+
30
+ The fields are new, so the relay on the fleet host must be redeployed with this build. An older relay ignores the fields
31
+ (the message still stores and reads, without addressing/threading) because they are plain body fields rather than a
32
+ protocol version bump.
33
+
34
+ ### Verified
35
+
36
+ Relay store: `BAD_ADDRESS`, `REPLY_TARGET_MISSING`, turn counting, and the read filters. HTTP: the three existing
37
+ fleet-message tests still pass. Tools: 122 pass. relay suite: 163 pass. Gate GREEN twice, 15 of 15.
38
+
39
+ ### Not verified
40
+
41
+ Two-machine end-to-end (A asks, B answers, A's await reports answered) — that needs both machines on this build, and the
42
+ relay redeployed.
5
43
  ## [1.2.0] — 2026-10-18
6
44
 
7
45
  **Fleet chat is one group per relay.**
@@ -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.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.2.1", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
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.2.0",
4
+ "version": "1.2.1",
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": [
@@ -165,6 +165,7 @@ export function registerChatRoutes(ctx, deps) {
165
165
  // Its key is the relay alone -- deliberately NOT the project name -- so every machine in the fleet
166
166
  // lands in one stream. It is not a history fetch: the caller sends a floor timestamp (see the panel).
167
167
  let sessionId = '';
168
+ const addressing = {};
168
169
  let text = null;
169
170
  if (request.method === 'GET') {
170
171
  sessionId = sessionIdOf(url.searchParams.get('session_id'));
@@ -177,6 +178,17 @@ export function registerChatRoutes(ctx, deps) {
177
178
  }
178
179
  sessionId = sessionIdOf(body?.session_id);
179
180
  text = typeof body?.text === 'string' ? body.text : null;
181
+ // Addressing + correlation travel as optional fields; each is validated here so a bad one is refused with a
182
+ // name instead of being stored and never understood.
183
+ if (typeof body?.to === 'string' && body.to.trim() !== '') {
184
+ const to = body.to.trim();
185
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) {
186
+ return refuse(400, 'CHAT_BAD_ADDRESS', 'to must be "all" or a machine id', 'a recipient is either the whole fleet or one machine');
187
+ }
188
+ addressing.to = to;
189
+ }
190
+ if (typeof body?.thread_id === 'string' && body.thread_id.trim() !== '') addressing.thread_id = body.thread_id.trim().slice(0, 64);
191
+ if (typeof body?.reply_to === 'string' && body.reply_to.trim() !== '') addressing.reply_to = body.reply_to.trim();
180
192
  }
181
193
  const identity = await identityOf();
182
194
  if (identity.error !== null) {
@@ -203,7 +215,11 @@ export function registerChatRoutes(ctx, deps) {
203
215
 
204
216
  try {
205
217
  if (request.method === 'GET') {
206
- const query = { session_id: sessionId };
218
+ const query = { session_id: sessionId, for_me: '1' };
219
+ const threadOf = url.searchParams.get('thread_id');
220
+ const toOf = url.searchParams.get('to');
221
+ if (threadOf !== null && threadOf !== '') query.thread_id = threadOf;
222
+ if (toOf !== null && toOf !== '') query.to = toOf;
207
223
  const sinceMs = url.searchParams.get('since_ms');
208
224
  const limit = url.searchParams.get('limit');
209
225
  if (sinceMs !== null && /^\d+$/.test(sinceMs)) query.since_ms = sinceMs;
@@ -216,7 +232,7 @@ export function registerChatRoutes(ctx, deps) {
216
232
  }
217
233
  return Response.json({ ...listed.json, ok: true, local_machine_id: identity.machineId, local_machine_name: identity.machineName, conversation_key: sessionId, conversation_source: identity.source }, { headers: NO_STORE });
218
234
  }
219
- const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat' }, signal: request.signal });
235
+ const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat', ...addressing }, signal: request.signal });
220
236
  if (posted?.ok !== true) {
221
237
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
222
238
  const message = posted?.json?.error?.message ?? posted?.error?.message ?? `the relay answered HTTP ${posted?.status ?? 0}`;
@@ -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.2.0';
223
+ const PLUGIN_VERSION = '1.2.1';
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;
@@ -6335,11 +6335,20 @@ export async function apply(ctx, config = {}) {
6335
6335
  'Fleet chat for one session: send plain text to the machines in this conversation, or read what was said. ' +
6336
6336
  'A refused write comes back with its named reason (quota, size, or non-text) rather than as a bare failure.',
6337
6337
  parameters: {
6338
- action: { type: 'string', enum: ['send', 'list'], description: 'send posts text; list reads the flow (default).', default: 'list' },
6338
+ action: {
6339
+ type: 'string',
6340
+ enum: ['send', 'list', 'ask', 'await'],
6341
+ description: 'send posts text; ask posts a question and returns its message id; await waits for a reply to it; list reads the flow (default).',
6342
+ default: 'list',
6343
+ },
6339
6344
  session_id: { type: 'string', description: 'Which conversation. Chat is scoped to a session, not a machine.' },
6340
6345
  text: { type: 'string', description: 'The message for send. Plain text, UTF-8, at most 4 KiB.' },
6341
6346
  since_ms: { type: 'number', description: 'For list: only messages newer than this relay timestamp.' },
6342
6347
  limit: { type: 'number', description: 'For list: how many of the newest messages (default 50).' },
6348
+ to: { type: 'string', description: 'For send/ask: "all" (the fleet group, the default) or one machine_id.' },
6349
+ thread_id: { type: 'string', description: 'For send/ask: group this message into a named thread (at most 64 characters).' },
6350
+ reply_to: { type: 'string', description: 'For send/ask: the message_id this message answers. For await: the message_id to wait for a reply to.' },
6351
+ timeout_ms: { type: 'number', description: 'For await: how long to wait before reporting a timeout (default 60000, capped at 300000). A timeout is a value, not an error.' },
6343
6352
  },
6344
6353
  output: jsonOutput,
6345
6354
  presentCall: (args) => ({ card: 'generic', title: 'w2m chat ' + (typeof args?.action === 'string' ? args.action : 'list'), kind: args?.action === 'send' ? 'write' : 'read', rawInput: {} }),
@@ -6356,14 +6365,46 @@ export async function apply(ctx, config = {}) {
6356
6365
  // one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
6357
6366
  const token = await resolveToken(cfg);
6358
6367
  if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
6359
- if (action === 'send') {
6360
- const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '' } });
6368
+ // Addressing + correlation travel only when the caller supplied them, so a plain broadcast stays byte-identical.
6369
+ const addressing = {};
6370
+ if (typeof args?.to === 'string' && args.to.trim() !== '') {
6371
+ const to = args.to.trim();
6372
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) {
6373
+ throw new W2MError('W2M_CHAT_ADDRESS', 'W2M_CHAT_ADDRESS: to must be "all" or a machine id', { hint: 'a recipient is either the whole fleet or one machine listed by w2m_devices' });
6374
+ }
6375
+ addressing.to = to;
6376
+ }
6377
+ if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') addressing.thread_id = args.thread_id.trim().slice(0, 64);
6378
+ if (typeof args?.reply_to === 'string' && args.reply_to.trim() !== '') addressing.reply_to = args.reply_to.trim();
6379
+
6380
+ if (action === 'await') {
6381
+ // Waiting for an ANSWER is a state machine, and a timeout is a value the caller reads rather than an exception
6382
+ // that loses the question. The question stays in the flow, so it can still be answered later.
6383
+ const target = typeof args?.reply_to === 'string' ? args.reply_to.trim() : '';
6384
+ if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass the message_id returned by ask' });
6385
+ const timeoutMs = Number.isFinite(args?.timeout_ms) ? Math.max(1000, Math.min(args.timeout_ms, 300000)) : 60000;
6386
+ const startedAt = Date.now();
6387
+ for (;;) {
6388
+ throwIfAborted(exec?.signal);
6389
+ const reply = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, reply_to: target, limit: '10' } });
6390
+ const found = Array.isArray(reply?.json?.messages) ? reply.json.messages : [];
6391
+ if (found.length > 0) return JSON.stringify({ ok: true, action, state: 'answered', reply_to: target, replies: found });
6392
+ if (Date.now() - startedAt >= timeoutMs) {
6393
+ return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, hint: 'the question is still in the flow and can be answered later' });
6394
+ }
6395
+ await new Promise((resolve) => { setTimeout(resolve, 1500); });
6396
+ }
6397
+ }
6398
+ if (action === 'send' || action === 'ask') {
6399
+ const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '', ...addressing } });
6361
6400
  if (posted?.ok !== true) {
6362
6401
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
6363
6402
  throw new W2MError('W2M_CHAT_SEND', code + ': the message was refused', { reason_code: code, hint: 'quota, size and non-text each need a different answer' });
6364
6403
  }
6365
- return JSON.stringify({ ok: true, action, message: posted.json?.message ?? null });
6404
+ return JSON.stringify({ ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null });
6366
6405
  }
6406
+ if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') query.thread_id = args.thread_id.trim();
6407
+ if (typeof args?.to === 'string' && args.to.trim() !== '') query.to = args.to.trim();
6367
6408
  const query = { session_id: sessionId };
6368
6409
  if (Number.isFinite(args?.since_ms)) query.since_ms = String(args.since_ms);
6369
6410
  if (Number.isInteger(args?.limit)) query.limit = String(args.limit);
@@ -88,25 +88,46 @@ export function createFleetMessages(options = {}) {
88
88
  const text = input.text;
89
89
  if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
90
90
  // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
91
- // Control characters are the thing being rejected here, so the rule that forbids them in a pattern
92
- // does not apply -- the escape IS the contract.
93
- // eslint-disable-next-line no-control-regex
91
+ // eslint-disable-next-line no-control-regex
94
92
  if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
95
93
  const bytes = Buffer.byteLength(text, 'utf8');
96
94
  if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
97
95
 
96
+ // Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
97
+ // would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
98
+ const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
99
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
100
+ // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
101
+ // exchange inside one thread. Plain fields, validated rather than trusted.
102
+ const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
103
+ ? input.thread_id.trim().slice(0, 64)
104
+ : null;
105
+ const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
106
+
98
107
  const before = usage();
99
108
  const limit = limitBytes();
100
109
  if (before.used_24h_bytes + bytes > limit) {
101
110
  return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
102
111
  }
103
112
 
113
+ // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
114
+ const existing = sessions.get(sessionId) ?? [];
115
+ if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
116
+ return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
117
+ }
118
+ const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
119
+ const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
120
+
104
121
  seq += 1;
105
122
  const message = {
106
123
  message_id: 'msg-' + String(seq).padStart(6, '0'),
107
124
  session_id: sessionId,
108
125
  fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
109
126
  machine_id: machineId,
127
+ to,
128
+ thread_id: threadOf,
129
+ reply_to: replyTo,
130
+ turn,
110
131
  text,
111
132
  at_ms: now(),
112
133
  bytes,
@@ -122,9 +143,16 @@ export function createFleetMessages(options = {}) {
122
143
  }
123
144
 
124
145
  /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
125
- function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50 } = {}) {
146
+ function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
147
+ thread_id: threadId = null, to: toFilter = null } = {}) {
126
148
  const rows = sessions.get(sessionId) ?? [];
127
- const filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
149
+ let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
150
+ // A reader sees the group and what was addressed to it, never another machine's private message.
151
+ if (typeof forMachine === 'string' && forMachine !== '') {
152
+ filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
153
+ }
154
+ if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
155
+ if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
128
156
  const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
129
157
  return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
130
158
  }
@@ -1052,6 +1052,9 @@ export class RelayServer {
1052
1052
  session_id: body.session_id,
1053
1053
  fleet_id: typeof body.fleet_id === 'string' ? body.fleet_id : null,
1054
1054
  machine_id: device.machine_id,
1055
+ to: body.to,
1056
+ thread_id: body.thread_id,
1057
+ reply_to: body.reply_to,
1055
1058
  text: body.text,
1056
1059
  });
1057
1060
  if (posted.ok !== true) {
@@ -1098,6 +1101,11 @@ export class RelayServer {
1098
1101
  session_id: url.searchParams.get('session_id') ?? '',
1099
1102
  since_ms: sinceRaw === null ? null : Number(sinceRaw),
1100
1103
  limit: limitRaw === null ? 50 : Number(limitRaw),
1104
+ // Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
1105
+ // caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
1106
+ for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
1107
+ thread_id: url.searchParams.get('thread_id'),
1108
+ to: url.searchParams.get('to'),
1101
1109
  });
1102
1110
  return sendJson(res, 200, {
1103
1111
  protocol_version: PROTOCOL_VERSION,