@twinsearth/w2m-dsh-plugin 1.1.22 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/fleet-chat-route.mjs +63 -37
- package/src/plugin/tools.mjs +46 -5
- package/src/relay/fleet-messages.mjs +33 -5
- package/src/relay/server.mjs +8 -0
|
@@ -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
|
|
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.1
|
|
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.1
|
|
4
|
+
"version": "1.2.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,74 @@
|
|
|
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.
|
|
43
|
+
## [1.2.0] — 2026-10-18
|
|
44
|
+
|
|
45
|
+
**Fleet chat is one group per relay.**
|
|
46
|
+
|
|
47
|
+
The conversation used to be keyed from `process.cwd()`, which is the DSH process's own directory and differs per machine
|
|
48
|
+
(`E:\DS` here, `/root` there). Two machines therefore computed two different keys and each panel saw only its own messages:
|
|
49
|
+
"互相收不到队员聊天信息". The rule is now the simplest one that makes a fleet talk: **the relay is the group**.
|
|
50
|
+
|
|
51
|
+
- one relay → one conversation, whatever project a machine is working on;
|
|
52
|
+
- a trailing slash on the relay URL is the same relay;
|
|
53
|
+
- the project name and the account key are deliberately NOT part of the basis: including either split the fleet into
|
|
54
|
+
separate streams, which is exactly the bug;
|
|
55
|
+
- the machine id remains only as a last resort when no relay is configured, so a panel can still answer locally.
|
|
56
|
+
|
|
57
|
+
### What was removed, and why it is not a loss
|
|
58
|
+
|
|
59
|
+
Two intermediate designs were built and dropped in this release: keying per project (relay + project name) and a
|
|
60
|
+
"同步编队全部项目" scope button that switched between the project stream and the fleet stream. With the relay as the group
|
|
61
|
+
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
|
|
62
|
+
simpler and the operator gets the fleet-wide conversation by default.
|
|
63
|
+
|
|
64
|
+
### Verified
|
|
65
|
+
|
|
66
|
+
`conversationBasis` is pure and tested: same relay unites two machines, a trailing slash does not split them, different
|
|
67
|
+
relays stay apart, and a host without a relay falls back to the machine scope. 4 identity tests, 25 client-bundle tests,
|
|
68
|
+
tools 122. Gate GREEN twice, 15 of 15.
|
|
69
|
+
|
|
70
|
+
### Not verified
|
|
71
|
+
|
|
72
|
+
Two-machine end-to-end against a live fleet; that check needs both machines on this build, which is the next step.
|
|
5
73
|
## [1.1.22] — 2026-10-18
|
|
6
74
|
|
|
7
75
|
**The fleet-chat panel said "this machine has no fleet-chat identity yet" on a machine that was paired and online.**
|
package/codex/mcp-servers.toml
CHANGED
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 ? '
|
|
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
|
|
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.1
|
|
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": [
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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,7 +161,11 @@ 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 = '';
|
|
168
|
+
const addressing = {};
|
|
157
169
|
let text = null;
|
|
158
170
|
if (request.method === 'GET') {
|
|
159
171
|
sessionId = sessionIdOf(url.searchParams.get('session_id'));
|
|
@@ -166,15 +178,24 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
166
178
|
}
|
|
167
179
|
sessionId = sessionIdOf(body?.session_id);
|
|
168
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();
|
|
192
|
+
}
|
|
193
|
+
const identity = await identityOf();
|
|
194
|
+
if (identity.error !== null) {
|
|
195
|
+
return refuse(503, identity.error, 'this machine has no fleet-chat identity yet', 'pair the machine and let it derive the fleet key');
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
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');
|
|
197
|
+
// One group per relay: the browser sends its own session id, which is replaced here.
|
|
198
|
+
sessionId = identity.conversationKey;
|
|
178
199
|
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
179
200
|
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
201
|
}
|
|
@@ -194,7 +215,11 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
194
215
|
|
|
195
216
|
try {
|
|
196
217
|
if (request.method === 'GET') {
|
|
197
|
-
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;
|
|
198
223
|
const sinceMs = url.searchParams.get('since_ms');
|
|
199
224
|
const limit = url.searchParams.get('limit');
|
|
200
225
|
if (sinceMs !== null && /^\d+$/.test(sinceMs)) query.since_ms = sinceMs;
|
|
@@ -207,7 +232,7 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
207
232
|
}
|
|
208
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 });
|
|
209
234
|
}
|
|
210
|
-
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 });
|
|
211
236
|
if (posted?.ok !== true) {
|
|
212
237
|
const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
|
|
213
238
|
const message = posted?.json?.error?.message ?? posted?.error?.message ?? `the relay answered HTTP ${posted?.status ?? 0}`;
|
|
@@ -232,22 +257,23 @@ export function registerChatRoutes(ctx, deps) {
|
|
|
232
257
|
});
|
|
233
258
|
return status;
|
|
234
259
|
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* The material a conversation is keyed from
|
|
238
|
-
*
|
|
239
|
-
* The
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* @
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return { basis: '
|
|
253
|
-
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The material a conversation is keyed from.
|
|
263
|
+
*
|
|
264
|
+
* The operator's rule, in its simplest form: **one relay is one group**. Every machine talking to the same relay shares
|
|
265
|
+
* one conversation, whatever project it is working on. Neither the project name nor the account key is part of the basis:
|
|
266
|
+
* including either split the fleet into separate streams, which is the report this replaces.
|
|
267
|
+
*
|
|
268
|
+
* The machine id stays only as a last resort for a host with no relay configured, so the panel can still answer locally.
|
|
269
|
+
*
|
|
270
|
+
* @param {{machineId?: string|null, relayUrl?: string|null}} input
|
|
271
|
+
* @returns {{basis: string, source: 'fleet'|'machine'}|null} null when there is no machine to key on
|
|
272
|
+
*/
|
|
273
|
+
export function conversationBasis({ machineId, relayUrl } = {}) {
|
|
274
|
+
const machine = typeof machineId === 'string' && machineId !== '' ? machineId : null;
|
|
275
|
+
if (machine === null) return null;
|
|
276
|
+
const relay = typeof relayUrl === 'string' && relayUrl.trim() !== '' ? relayUrl.trim().replace(/\/+$/, '') : null;
|
|
277
|
+
if (relay !== null) return { basis: 'fleet:' + relay, source: 'fleet' };
|
|
278
|
+
return { basis: 'machine:' + machine, source: 'machine' };
|
|
279
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|
-
|
|
6360
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -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,
|