@twinsearth/w2m-dsh-plugin 0.57.1 → 0.59.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,114 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.59.0] — 2026-10-16
8
+
9
+ **Every published bin can now be imported without killing the importing process — measured, not reasoned about.**
10
+
11
+ ### The defect
12
+
13
+ `package.json` publishes three CLIs as importable `exports` subpaths:
14
+
15
+ ```json
16
+ "./rabbit": "./bin/w2m-rabbit.mjs"
17
+ "./localside": "./bin/w2m-localside.mjs"
18
+ "./stun": "./bin/w2m-stun.mjs"
19
+ ```
20
+
21
+ **And those three had no import guard, so their top-level body ran on import.** Measured:
22
+
23
+ ```
24
+ import .../bin/w2m-localside.mjs -> node exited with code 2
25
+ a statement placed after the import never ran
26
+ ```
27
+
28
+ **A published entry point that terminates whichever process imports it.** The plugin's own client-bundle
29
+ documentation records that `exports['./client']` *"is what the host scans for"*, so the host reads that map —
30
+ **and `w2m-mcp.mjs` already carried this guard, which is why it was the one bin that behaved.**
31
+
32
+ The fix gives the other three the same guard rather than withdrawing the exports: **a subpath that is importable
33
+ and inert is strictly better than one that is neither.** All four are now inert on import (`58ms / 74ms / 5ms /
34
+ 2ms`) and all four still run as programs (`--help` → exit 0 with the right banner).
35
+
36
+ ### What this does **not** claim, and why that matters here
37
+
38
+ **This is not offered as the explanation for the client that would not start.** That was reported as *no process
39
+ at all on a double-click*, and that symptom has a different mechanism, read out of `lib/main.js`:
40
+
41
+ ```js
42
+ function claimDesktopSingleInstance(application, focusOwner) {
43
+ if (!application.requestSingleInstanceLock()) {
44
+ application.quit();
45
+ return false;
46
+ }
47
+ ```
48
+
49
+ **`application.quit()` — no dialog, no error, no window.** A second launch is silently discarded while the first
50
+ instance holds the lock, which explains all three observations: disabling third-party plugins did nothing (the
51
+ new process never reached plugin loading), **reinstalling the client did nothing (a new directory does not release
52
+ a lock registered against the application)**, and deleting `~/.dsh` worked — **because it took down the lock
53
+ holder.**
54
+
55
+ **A real defect, fixed; a different defect, responsible.** The two were adjacent in time, and conflating them
56
+ would have produced a changelog entry claiming a fix it cannot demonstrate.
57
+
58
+ ### Three of my edits failed on the same mistake before the fourth worked
59
+
60
+ The guard had to be inserted where each file's executable body begins. **My first two markers were text I
61
+ predicted rather than read** — `await main()` does not exist in `w2m-rabbit.mjs`, and `const bound = await bind`
62
+ does not exist in `w2m-stun.mjs`. Then a `Select-Object -Skip` offset was **1-based** while the array index is
63
+ **0-based**, so the script refused both files by one line. **The fourth attempt anchored on the statements' own
64
+ text and succeeded on the first run.** That is the same lesson as the fabricated relay route and the malformed
65
+ PNG, and it is now the fifth time this session.
66
+
67
+ **No test count change** — the guards alter when the bodies run, not what they do, and all four entry points are
68
+ exercised by `scripts/check-entrypoints.mjs`.
69
+
70
+ ## [0.58.0] — 2026-10-15
71
+
72
+ **The bridge now reports what it received instead of guessing what an image looks like.**
73
+
74
+ ### Why this is a probe and not an image branch
75
+
76
+ `ITEM_TYPE` declares exactly one member:
77
+
78
+ ```js
79
+ export const ITEM_TYPE = Object.freeze({ TEXT: 1 });
80
+ ```
81
+
82
+ **The shape of every other item type is unrecorded in this repository** — no `image_item`, no sample payload, no
83
+ fixture. `ilink-protocol.mjs` documents only the text form. **So a download path written today would be a guess
84
+ about field names, and it would fail at the moment a real photograph arrived** — which is the worst possible time
85
+ to discover a wrong guess, because the person is waiting for their picture to be read.
86
+
87
+ **This is the discovery step.** `describeItems()` reports **key paths and value types, never values**, so one
88
+ photograph sent by a person answers the question the source cannot.
89
+
90
+ ### Values are never recorded, and that is the point of the whole function
91
+
92
+ A message body is the user's content, and **a discovery aid that quietly wrote photographs into a log file would
93
+ be a worse defect than the one it exists to diagnose.** One assertion is dedicated to that: a distinctive
94
+ passphrase planted in a URL and a caption, and the check that neither appears in the output.
95
+
96
+ The walk is bounded — `maxKeys` and `maxDepth`, arrays described by length and first element, cycles reported
97
+ rather than followed — **so a surprising payload cannot bury the log it is being written to.**
98
+
99
+ ### Every wrong guess this session was corrected by printing the thing
100
+
101
+ A fabricated relay route (`/v1/stats`, `/v1/status`), a malformed PNG reported as a credential failure, a count
102
+ typed from memory onto a CHANGELOG entry — **each was fixed by reading rather than predicting.** This applies that
103
+ method *before* the guess instead of after it, which is the only difference between this and the four previous
104
+ occasions.
105
+
106
+ ### What is still not done, stated rather than implied
107
+
108
+ **The bridge still cannot read an image.** The `/v1`-style branch that downloads it does not exist, because the
109
+ field names are unknown. What exists now is the instrument that finds them: **send one photograph, and the log
110
+ says what the far end actually sends.**
111
+
112
+ **13 new tests, 0 failures**, and the existing bridge and tool-card suites still pass — the added `shape` field is
113
+ carried only on a non-text reply, so it means one thing. Full suite 2354 in 89 files. **Pre-flight GREEN.**
114
+
7
115
  ## [0.57.1] — 2026-10-15
8
116
 
9
117
  **The vision status line now appears only on a bridge that actually starts — which it already did, for a reason
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { parseArgs as parseNodeArgs } from 'node:util';
23
23
  import process from 'node:process';
24
+ import { pathToFileURL } from 'node:url';
24
25
 
25
26
  import { formatError, resolveStateDir } from '../src/util/cli.mjs';
26
27
  import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
@@ -314,12 +315,34 @@ async function main() {
314
315
  }
315
316
  }
316
317
 
317
- main().then(
318
- (code) => {
319
- process.exitCode = code;
320
- },
321
- (error) => {
322
- process.stderr.write(`fatal: ${formatError(error)}\n`);
323
- process.exitCode = 1;
324
- },
325
- );
318
+ /**
319
+ * Run only when executed, never when imported.
320
+ *
321
+ * ## The defect this closes, which was measured rather than reasoned about
322
+ *
323
+ * `package.json` publishes `"./localside": "./bin/w2m-localside.mjs"` in its `exports` map, and this file used to
324
+ * call `main()` unconditionally. **So `import '@twinsearth/w2m-dsh-plugin/localside'` executed a CLI, printed
325
+ * usage, and terminated the process** -- measured, not inferred:
326
+ *
327
+ * import ... /bin/w2m-localside.mjs -> node exited with code 2
328
+ * a line after the import never ran
329
+ *
330
+ * **That is fatal for any process unlucky enough to import the subpath**, and the host is the only plausible
331
+ * candidate: a plugin whose published entry points kill the client is a plugin that makes the client "unable to
332
+ * start", which is exactly what was reported.
333
+ *
334
+ * `w2m-mcp.mjs` already carried this guard, and it was the one file that behaved. **The other three bins had no
335
+ * guard and were published as importable paths** -- so the fix is to give them the same one, not to remove the
336
+ * exports: a subpath that is importable *and* inert is strictly more useful than one that is neither.
337
+ */
338
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
339
+ main().then(
340
+ (code) => {
341
+ process.exitCode = code;
342
+ },
343
+ (error) => {
344
+ process.stderr.write(`fatal: ${formatError(error)}\n`);
345
+ process.exitCode = 1;
346
+ },
347
+ );
348
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { parseArgs } from 'node:util';
21
+ import { pathToFileURL } from 'node:url';
21
22
  import { createRelayServer } from '../src/relay/server.mjs';
22
23
  import { resolveStateDir, formatError } from '../src/util/cli.mjs';
23
24
 
@@ -248,182 +249,196 @@ const stateDir = resolveStateDir(values.state, 'rabbit');
248
249
  const log = (line) => process.stdout.write(`${line}\n`);
249
250
  const diag = (line) => process.stderr.write(`${line}\n`);
250
251
 
251
- let relay;
252
- let listenInfo;
253
- let pairingCode;
254
- try {
255
- relay = createRelayServer({
256
- stateDir,
257
- pairingCode: values['pairing-code'],
258
- heartbeatIntervalMs: heartbeatMs,
259
- leaseGraceMs: graceMs,
260
- // v0.1.2 deployment options
261
- basePath,
262
- trustProxy: Boolean(values['trust-proxy']),
263
- tlsCert: tlsCert ?? null,
264
- tlsKey: tlsKey ?? null,
265
- operatorToken,
266
- operatorTokenRequired,
267
- // MEDIUM-6: the token is printed by the start that generated it, or when the operator asks for it
268
- // here (--print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1). The relay resolves both, so the
269
- // banner below and the relay's own startup lines can never disagree about it.
270
- printOperatorToken: values['print-operator-token'] === true,
271
- pairRateLimitPerMinute: pairRateLimit,
272
- persist: !values['no-persist'],
273
- // v0.3.0 request signing. Undefined means "not configured", which the relay treats as
274
- // "do not verify" -- byte-for-byte the v0.2.3 behaviour.
275
- signingSecret,
276
- signingSecretPrevious,
277
- requireSignature,
278
- metrics: values.metrics === true,
279
- signatureSkewSeconds,
280
- });
281
- // The pairing code rotates on every successful pairing, so the CLI has to
282
- // follow it rather than print it once: an operator pairing a second machine
283
- // needs the *current* code, not the one that was already consumed.
284
- //
285
- // Listeners receive the buffered *entry* (`{seq, event, target}`), not the
286
- // event itself -- `entry.event` is the frame that goes on the wire.
287
- relay.state.onEvent((entry) => {
288
- const event = entry?.event ?? entry;
289
- if (event?.type === 'notice' && event.code === 'PAIRING_CODE_ROTATED' && event.pairing_code) {
290
- pairingCode = event.pairing_code;
291
- // In --json mode this must not touch stdout: a rotation would otherwise
292
- // corrupt the very output the flag exists to produce.
293
- const out = values.json ? diag : log;
294
- out('');
295
- out(' Pairing code rotated after a successful pairing.');
296
- out(` 新的配对码 / NEW PAIRING CODE: ${pairingCode}`);
297
- out('');
298
- }
299
- });
300
- listenInfo = await relay.listen({
301
- host: values.host ?? '127.0.0.1',
302
- port,
303
- });
304
- pairingCode = listenInfo.pairingCode;
305
- } catch (error) {
306
- fail(formatError(error), 3);
307
- }
308
-
309
- const boundPort = listenInfo.port;
310
- const boundHost = listenInfo.host;
311
- const scheme = listenInfo.scheme ?? (tlsCert ? 'https' : 'http');
312
- // Prefer what the relay reports (it knows whether TLS is active); fall back to
313
- // the bound address so a 0.0.0.0 bind is not advertised as a usable URL.
314
- const publicUrl = listenInfo.publicUrl ?? `${scheme}://${boundHost === '0.0.0.0' ? '<this-host>' : boundHost}:${boundPort}${basePath === '/' ? '' : basePath}`;
315
- const resolvedOperatorToken = listenInfo.operatorToken ?? operatorToken ?? null;
316
-
317
- if (values.json) {
318
- log(JSON.stringify({
319
- event: 'listening',
320
- url: listenInfo.url,
321
- publicUrl,
322
- host: boundHost,
323
- port: boundPort,
324
- scheme,
325
- basePath,
326
- stateDir,
327
- persist: !values['no-persist'],
328
- trustProxy: Boolean(values['trust-proxy']),
329
- pairRateLimit: pairRateLimit ?? 5,
330
- operatorTokenRequired,
331
- // The token itself is deliberately absent. This line is the one people pipe
332
- // into a log or a file, and a credential written there is a credential
333
- // leaked; it is printed to stderr and stored at
334
- // <stateDir>/operator-token.txt (0600), which is where a secret belongs.
335
- operatorTokenPresent: Boolean(operatorTokenRequired && resolvedOperatorToken),
336
- pairingCode,
337
- relayId: listenInfo.relayId ?? null,
338
- protocolVersion: 1,
339
- }));
340
- } else {
341
- log('');
342
- log(' W2M relay (Rabbit) is listening.');
343
- log('');
344
- log(` URL ${publicUrl}`);
345
- log(` Bind ${boundHost}:${boundPort}${scheme === 'https' ? ' (TLS)' : ''}`);
346
- if (basePath !== '/') log(` Base path ${basePath}`);
347
- log(` State ${stateDir}${values['no-persist'] ? ' (persistence DISABLED)' : ''}`);
348
- log(` Protocol v1 relay id ${listenInfo.relayId ?? '(none)'}`);
349
-
350
- if (values['trust-proxy']) {
351
- log('');
352
- log(' NOTE: --trust-proxy is ON. The relay believes X-Forwarded-For and');
353
- log(' X-Forwarded-Proto. Only safe behind a proxy you control.');
354
- }
355
- if (boundHost === '0.0.0.0' && scheme === 'http') {
356
- log('');
357
- log(' NOTE: bound to 0.0.0.0 over plain HTTP. Fine on a Tailscale/WireGuard');
358
- log(' address (already encrypted); NOT fine on a public interface --');
359
- log(' use --tls-cert/--tls-key or a TLS-terminating proxy there.');
360
- }
361
-
362
- log('');
363
- if (operatorTokenRequired && resolvedOperatorToken) {
364
- // MEDIUM-6: this banner is the *machine's* stdout, and stdout is what journald, a container log or
365
- // a CI transcript keeps. The token authorises dispatching to every machine in the group, so it is
366
- // printed only on the start that generated it — the one moment it exists nowhere else — or when
367
- // --print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1 asked for it explicitly. Every other start
368
- // says where to read it and how to change that answer.
252
+ /**
253
+ * Run only when executed, never when imported.
254
+ *
255
+ * `package.json` publishes this file as an `exports` subpath, and an unguarded top-level body means
256
+ * **importing that subpath executes the program**. Measured, not inferred: on the sibling `w2m-localside.mjs`, a
257
+ * statement placed after the import never ran and node exited with code 2.
258
+ *
259
+ * **A published entry point that terminates whichever process imports it is a plugin that stops the client from
260
+ * starting** -- the defect this closes. `w2m-mcp.mjs` already carried the guard and was the only bin that
261
+ * behaved, so the fix gives the others the same one rather than withdrawing the exports: **a subpath that is
262
+ * importable and inert is strictly better than one that is neither.**
263
+ */
264
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
265
+ let relay;
266
+ let listenInfo;
267
+ let pairingCode;
268
+ try {
269
+ relay = createRelayServer({
270
+ stateDir,
271
+ pairingCode: values['pairing-code'],
272
+ heartbeatIntervalMs: heartbeatMs,
273
+ leaseGraceMs: graceMs,
274
+ // v0.1.2 deployment options
275
+ basePath,
276
+ trustProxy: Boolean(values['trust-proxy']),
277
+ tlsCert: tlsCert ?? null,
278
+ tlsKey: tlsKey ?? null,
279
+ operatorToken,
280
+ operatorTokenRequired,
281
+ // MEDIUM-6: the token is printed by the start that generated it, or when the operator asks for it
282
+ // here (--print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1). The relay resolves both, so the
283
+ // banner below and the relay's own startup lines can never disagree about it.
284
+ printOperatorToken: values['print-operator-token'] === true,
285
+ pairRateLimitPerMinute: pairRateLimit,
286
+ persist: !values['no-persist'],
287
+ // v0.3.0 request signing. Undefined means "not configured", which the relay treats as
288
+ // "do not verify" -- byte-for-byte the v0.2.3 behaviour.
289
+ signingSecret,
290
+ signingSecretPrevious,
291
+ requireSignature,
292
+ metrics: values.metrics === true,
293
+ signatureSkewSeconds,
294
+ });
295
+ // The pairing code rotates on every successful pairing, so the CLI has to
296
+ // follow it rather than print it once: an operator pairing a second machine
297
+ // needs the *current* code, not the one that was already consumed.
369
298
  //
370
- // DELIBERATE, do not "fix": on that generating start the relay's own startup line prints the value
371
- // too (on stderr). The pair is the intended shape — this box is the operator-facing handoff, that
372
- // line is the log copy — and every other start prints it in neither place.
373
- if (listenInfo.operatorTokenPrintable === true) {
374
- log(' ┌────────────────────────────────────────────────────────────┐');
375
- log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
376
- log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
377
- log(' └────────────────────────────────────────────────────────────┘');
378
- log(` Printed once because ${listenInfo.operatorTokenPrintReason === 'generated'
379
- ? 'this start generated it'
380
- : `${listenInfo.operatorTokenPrintReason ?? 'an option'} asked for it`}.`);
381
- log(` Also written to ${stateDir}/operator-token.txt (0600).`);
382
- } else {
383
- log(' OPERATOR TOKEN — not printed (this start did not generate it).');
384
- log(` Read it from ${stateDir}/operator-token.txt (0600).`);
385
- log(' To print it here as well: --print-operator-token, or W2M_PRINT_OPERATOR_TOKEN=1.');
386
- }
387
- log(' Device tokens cannot send work; this is what authorises it.');
388
- } else if (!operatorTokenRequired) {
389
- log(' ⚠ OPERATOR TOKEN REQUIREMENT IS OFF (--operator-token "").');
390
- log(' Any paired device can send work to any other. Trusted LAN only.');
299
+ // Listeners receive the buffered *entry* (`{seq, event, target}`), not the
300
+ // event itself -- `entry.event` is the frame that goes on the wire.
301
+ relay.state.onEvent((entry) => {
302
+ const event = entry?.event ?? entry;
303
+ if (event?.type === 'notice' && event.code === 'PAIRING_CODE_ROTATED' && event.pairing_code) {
304
+ pairingCode = event.pairing_code;
305
+ // In --json mode this must not touch stdout: a rotation would otherwise
306
+ // corrupt the very output the flag exists to produce.
307
+ const out = values.json ? diag : log;
308
+ out('');
309
+ out(' Pairing code rotated after a successful pairing.');
310
+ out(` 新的配对码 / NEW PAIRING CODE: ${pairingCode}`);
311
+ out('');
312
+ }
313
+ });
314
+ listenInfo = await relay.listen({
315
+ host: values.host ?? '127.0.0.1',
316
+ port,
317
+ });
318
+ pairingCode = listenInfo.pairingCode;
319
+ } catch (error) {
320
+ fail(formatError(error), 3);
391
321
  }
392
322
 
393
- log('');
394
- if (pairingCode) {
395
- log(' ┌──────────────────────────────────────────────┐');
396
- log(` │ 配对码 / PAIRING CODE: ${String(pairingCode).padEnd(19)}│`);
397
- log(' └──────────────────────────────────────────────┘');
323
+ const boundPort = listenInfo.port;
324
+ const boundHost = listenInfo.host;
325
+ const scheme = listenInfo.scheme ?? (tlsCert ? 'https' : 'http');
326
+ // Prefer what the relay reports (it knows whether TLS is active); fall back to
327
+ // the bound address so a 0.0.0.0 bind is not advertised as a usable URL.
328
+ const publicUrl = listenInfo.publicUrl ?? `${scheme}://${boundHost === '0.0.0.0' ? '<this-host>' : boundHost}:${boundPort}${basePath === '/' ? '' : basePath}`;
329
+ const resolvedOperatorToken = listenInfo.operatorToken ?? operatorToken ?? null;
330
+
331
+ if (values.json) {
332
+ log(JSON.stringify({
333
+ event: 'listening',
334
+ url: listenInfo.url,
335
+ publicUrl,
336
+ host: boundHost,
337
+ port: boundPort,
338
+ scheme,
339
+ basePath,
340
+ stateDir,
341
+ persist: !values['no-persist'],
342
+ trustProxy: Boolean(values['trust-proxy']),
343
+ pairRateLimit: pairRateLimit ?? 5,
344
+ operatorTokenRequired,
345
+ // The token itself is deliberately absent. This line is the one people pipe
346
+ // into a log or a file, and a credential written there is a credential
347
+ // leaked; it is printed to stderr and stored at
348
+ // <stateDir>/operator-token.txt (0600), which is where a secret belongs.
349
+ operatorTokenPresent: Boolean(operatorTokenRequired && resolvedOperatorToken),
350
+ pairingCode,
351
+ relayId: listenInfo.relayId ?? null,
352
+ protocolVersion: 1,
353
+ }));
354
+ } else {
398
355
  log('');
399
- log(' Run this on every machine you want in the group:');
356
+ log(' W2M relay (Rabbit) is listening.');
400
357
  log('');
401
- log(` w2m-localside --rabbit ${listenInfo.url} --pair ${pairingCode} \\`);
402
- log(' --project <path-to-project> --name <machine-name>');
358
+ log(` URL ${publicUrl}`);
359
+ log(` Bind ${boundHost}:${boundPort}${scheme === 'https' ? ' (TLS)' : ''}`);
360
+ if (basePath !== '/') log(` Base path ${basePath}`);
361
+ log(` State ${stateDir}${values['no-persist'] ? ' (persistence DISABLED)' : ''}`);
362
+ log(` Protocol v1 relay id ${listenInfo.relayId ?? '(none)'}`);
363
+
364
+ if (values['trust-proxy']) {
365
+ log('');
366
+ log(' NOTE: --trust-proxy is ON. The relay believes X-Forwarded-For and');
367
+ log(' X-Forwarded-Proto. Only safe behind a proxy you control.');
368
+ }
369
+ if (boundHost === '0.0.0.0' && scheme === 'http') {
370
+ log('');
371
+ log(' NOTE: bound to 0.0.0.0 over plain HTTP. Fine on a Tailscale/WireGuard');
372
+ log(' address (already encrypted); NOT fine on a public interface --');
373
+ log(' use --tls-cert/--tls-key or a TLS-terminating proxy there.');
374
+ }
375
+
403
376
  log('');
404
- log(' The code rotates after each successful pairing; the new one is');
405
- log(' printed here, so you can pair the next machine without restarting.');
406
- }
407
- if (boundHost === '0.0.0.0') {
408
- log(' NOTE: bound to 0.0.0.0 -- other machines on your network can reach this');
409
- log(' relay. It carries no TLS and no authentication beyond device tokens.');
377
+ if (operatorTokenRequired && resolvedOperatorToken) {
378
+ // MEDIUM-6: this banner is the *machine's* stdout, and stdout is what journald, a container log or
379
+ // a CI transcript keeps. The token authorises dispatching to every machine in the group, so it is
380
+ // printed only on the start that generated it — the one moment it exists nowhere else — or when
381
+ // --print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1 asked for it explicitly. Every other start
382
+ // says where to read it and how to change that answer.
383
+ //
384
+ // DELIBERATE, do not "fix": on that generating start the relay's own startup line prints the value
385
+ // too (on stderr). The pair is the intended shape — this box is the operator-facing handoff, that
386
+ // line is the log copy — and every other start prints it in neither place.
387
+ if (listenInfo.operatorTokenPrintable === true) {
388
+ log(' ┌────────────────────────────────────────────────────────────┐');
389
+ log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
390
+ log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
391
+ log(' └────────────────────────────────────────────────────────────┘');
392
+ log(` Printed once because ${listenInfo.operatorTokenPrintReason === 'generated'
393
+ ? 'this start generated it'
394
+ : `${listenInfo.operatorTokenPrintReason ?? 'an option'} asked for it`}.`);
395
+ log(` Also written to ${stateDir}/operator-token.txt (0600).`);
396
+ } else {
397
+ log(' OPERATOR TOKEN — not printed (this start did not generate it).');
398
+ log(` Read it from ${stateDir}/operator-token.txt (0600).`);
399
+ log(' To print it here as well: --print-operator-token, or W2M_PRINT_OPERATOR_TOKEN=1.');
400
+ }
401
+ log(' Device tokens cannot send work; this is what authorises it.');
402
+ } else if (!operatorTokenRequired) {
403
+ log(' ⚠ OPERATOR TOKEN REQUIREMENT IS OFF (--operator-token "").');
404
+ log(' Any paired device can send work to any other. Trusted LAN only.');
405
+ }
406
+
410
407
  log('');
408
+ if (pairingCode) {
409
+ log(' ┌──────────────────────────────────────────────┐');
410
+ log(` │ 配对码 / PAIRING CODE: ${String(pairingCode).padEnd(19)}│`);
411
+ log(' └──────────────────────────────────────────────┘');
412
+ log('');
413
+ log(' Run this on every machine you want in the group:');
414
+ log('');
415
+ log(` w2m-localside --rabbit ${listenInfo.url} --pair ${pairingCode} \\`);
416
+ log(' --project <path-to-project> --name <machine-name>');
417
+ log('');
418
+ log(' The code rotates after each successful pairing; the new one is');
419
+ log(' printed here, so you can pair the next machine without restarting.');
420
+ }
421
+ if (boundHost === '0.0.0.0') {
422
+ log(' NOTE: bound to 0.0.0.0 -- other machines on your network can reach this');
423
+ log(' relay. It carries no TLS and no authentication beyond device tokens.');
424
+ log('');
425
+ }
411
426
  }
412
- }
413
427
 
414
- let stopping = false;
415
- async function shutdown(signal) {
416
- if (stopping) return;
417
- stopping = true;
418
- if (!values.json) log(`\n ${signal} received, shutting down...`);
419
- try {
420
- await relay.close();
421
- } catch (error) {
422
- process.stderr.write(`w2m-rabbit: error while closing: ${formatError(error)}\n`);
423
- process.exit(1);
428
+ let stopping = false;
429
+ async function shutdown(signal) {
430
+ if (stopping) return;
431
+ stopping = true;
432
+ if (!values.json) log(`\n ${signal} received, shutting down...`);
433
+ try {
434
+ await relay.close();
435
+ } catch (error) {
436
+ process.stderr.write(`w2m-rabbit: error while closing: ${formatError(error)}\n`);
437
+ process.exit(1);
438
+ }
439
+ process.exit(0);
424
440
  }
425
- process.exit(0);
426
- }
427
441
 
428
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
429
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
442
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
443
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
444
+ }
package/bin/w2m-stun.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { parseArgs } from 'node:util';
30
+ import { pathToFileURL } from 'node:url';
30
31
  import { createStunServer, DEFAULT_SOFTWARE } from '../src/relay/stun-server.mjs';
31
32
  import { formatError } from '../src/util/cli.mjs';
32
33
 
@@ -154,78 +155,92 @@ function logRequest(entry) {
154
155
  );
155
156
  }
156
157
 
157
- let server;
158
- let listen;
159
- const startedAt = Date.now();
160
- try {
161
- server = createStunServer({ host, port, software, log: logRequest });
162
- listen = await server.start();
163
- } catch (error) {
164
- fail(formatError(error), 3);
165
- }
158
+ /**
159
+ * Run only when executed, never when imported.
160
+ *
161
+ * `package.json` publishes this file as an `exports` subpath, and an unguarded top-level body means
162
+ * **importing that subpath executes the program**. Measured, not inferred: on the sibling `w2m-localside.mjs`, a
163
+ * statement placed after the import never ran and node exited with code 2.
164
+ *
165
+ * **A published entry point that terminates whichever process imports it is a plugin that stops the client from
166
+ * starting** -- the defect this closes. `w2m-mcp.mjs` already carried the guard and was the only bin that
167
+ * behaved, so the fix gives the others the same one rather than withdrawing the exports: **a subpath that is
168
+ * importable and inert is strictly better than one that is neither.**
169
+ */
170
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
+ let server;
172
+ let listen;
173
+ const startedAt = Date.now();
174
+ try {
175
+ server = createStunServer({ host, port, software, log: logRequest });
176
+ listen = await server.start();
177
+ } catch (error) {
178
+ fail(formatError(error), 3);
179
+ }
166
180
 
167
- if (!listen.ok) {
168
- // EADDRINUSE deserves its own sentence: it is the one failure an operator will
169
- // actually hit, and the fix (find the other listener, or pick another port) is
170
- // not obvious from "EADDRINUSE".
171
- if (listen.error === 'EADDRINUSE') {
172
- fail(
173
- `UDP port ${port} is already in use on ${host} — another STUN server or another ` +
174
- `w2m-stun is holding it. Stop it, or choose a different --port.`,
175
- 3,
176
- );
181
+ if (!listen.ok) {
182
+ // EADDRINUSE deserves its own sentence: it is the one failure an operator will
183
+ // actually hit, and the fix (find the other listener, or pick another port) is
184
+ // not obvious from "EADDRINUSE".
185
+ if (listen.error === 'EADDRINUSE') {
186
+ fail(
187
+ `UDP port ${port} is already in use on ${host} — another STUN server or another ` +
188
+ `w2m-stun is holding it. Stop it, or choose a different --port.`,
189
+ 3,
190
+ );
191
+ }
192
+ fail(`failed to bind ${host}:${port} — ${listen.error}`, 3);
177
193
  }
178
- fail(`failed to bind ${host}:${port} — ${listen.error}`, 3);
179
- }
180
194
 
181
- const bound = server.address() ?? { address: listen.host, port: listen.port };
182
- log('');
183
- log(' W2M STUN responder is listening.');
184
- log('');
185
- log(` Bind udp4 ${bound.address}:${bound.port}`);
186
- log(` Software ${software}`);
187
- log(' Answers Binding requests (0x0001) -> 0x0101 with XOR-MAPPED-ADDRESS,');
188
- log(' MAPPED-ADDRESS and SOFTWARE. Anything else is dropped.');
189
- log('');
190
- log(' NAT translation:');
191
- log(' A machine behind NAT cannot see the public address and port its packets');
192
- log(
193
- ` leave from. Point clients at ${bound.address === '0.0.0.0' ? '<this-host>' : bound.address}:${bound.port} (UDP) and`,
194
- );
195
- log(' this server reports the translated address back, which is what lets two');
196
- log(' machines punch a direct path instead of relaying every task.');
197
- if (bound.address === '0.0.0.0') {
195
+ const bound = server.address() ?? { address: listen.host, port: listen.port };
198
196
  log('');
199
- log(' NOTE: bound to 0.0.0.0 — reachable from anywhere that can route UDP to this');
200
- log(' host. There is no authentication and no state: every request is');
201
- log(' answered on the address it came from, and nothing else is possible.');
202
- }
203
- log('');
204
- log(` Log level ${logLevel}${debug ? '' : ' (--log-level debug prints one line per request)'}`);
205
- log('');
206
-
207
- let stopping = false;
208
- async function shutdown(signal) {
209
- if (stopping) return;
210
- stopping = true;
211
- const stats = server.stats();
197
+ log(' W2M STUN responder is listening.');
198
+ log('');
199
+ log(` Bind udp4 ${bound.address}:${bound.port}`);
200
+ log(` Software ${software}`);
201
+ log(' Answers Binding requests (0x0001) -> 0x0101 with XOR-MAPPED-ADDRESS,');
202
+ log(' MAPPED-ADDRESS and SOFTWARE. Anything else is dropped.');
203
+ log('');
204
+ log(' NAT translation:');
205
+ log(' A machine behind NAT cannot see the public address and port its packets');
212
206
  log(
213
- `\n ${signal} received, shutting down. ` +
214
- `requests=${stats.requests} responses=${stats.responses} errors=${stats.errors} ` +
215
- `uptime=${Math.round((Date.now() - startedAt) / 100) / 10}s`,
207
+ ` leave from. Point clients at ${bound.address === '0.0.0.0' ? '<this-host>' : bound.address}:${bound.port} (UDP) and`,
216
208
  );
217
- try {
218
- await server.stop();
219
- } catch (error) {
220
- process.stderr.write(`w2m-stun: error while closing: ${formatError(error)}\n`);
221
- process.exit(1);
209
+ log(' this server reports the translated address back, which is what lets two');
210
+ log(' machines punch a direct path instead of relaying every task.');
211
+ if (bound.address === '0.0.0.0') {
212
+ log('');
213
+ log(' NOTE: bound to 0.0.0.0 — reachable from anywhere that can route UDP to this');
214
+ log(' host. There is no authentication and no state: every request is');
215
+ log(' answered on the address it came from, and nothing else is possible.');
222
216
  }
223
- process.exit(0);
224
- }
217
+ log('');
218
+ log(` Log level ${logLevel}${debug ? '' : ' (--log-level debug prints one line per request)'}`);
219
+ log('');
225
220
 
226
- process.on('SIGINT', () => {
227
- void shutdown('SIGINT');
228
- });
229
- process.on('SIGTERM', () => {
230
- void shutdown('SIGTERM');
231
- });
221
+ let stopping = false;
222
+ async function shutdown(signal) {
223
+ if (stopping) return;
224
+ stopping = true;
225
+ const stats = server.stats();
226
+ log(
227
+ `\n ${signal} received, shutting down. ` +
228
+ `requests=${stats.requests} responses=${stats.responses} errors=${stats.errors} ` +
229
+ `uptime=${Math.round((Date.now() - startedAt) / 100) / 10}s`,
230
+ );
231
+ try {
232
+ await server.stop();
233
+ } catch (error) {
234
+ process.stderr.write(`w2m-stun: error while closing: ${formatError(error)}\n`);
235
+ process.exit(1);
236
+ }
237
+ process.exit(0);
238
+ }
239
+
240
+ process.on('SIGINT', () => {
241
+ void shutdown('SIGINT');
242
+ });
243
+ process.on('SIGTERM', () => {
244
+ void shutdown('SIGTERM');
245
+ });
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -116,13 +116,13 @@
116
116
  }
117
117
  },
118
118
  "scripts": {
119
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.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/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/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/chatops-fleet-join.test.mjs",
119
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.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/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/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",
120
120
  "test:e2e": "node --test --test-force-exit 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/p2p-agent.test.mjs",
121
121
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
122
122
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
123
123
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
124
124
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
125
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.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/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/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/chatops-fleet-join.test.mjs",
125
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.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/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/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",
126
126
  "pack": "node scripts/pack.mjs",
127
127
  "verify:join": "node scripts/verify-join.mjs",
128
128
  "lint": "eslint .",
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": "0.57.1",
4
+ "version": "0.59.0",
5
5
  "description": "Cross-platform fleet execution between Windows and macOS: one DeepSeek Harness account, a Rabbit relay, and signed device identity. Ships four Agent Skills describing fleet dispatch, field-level memory acceptance, session references, and resource scheduling.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -113,6 +113,57 @@ export function textOf(message) {
113
113
  return null;
114
114
  }
115
115
 
116
+ /**
117
+ * Describe the SHAPE of a message's items without recording any of their content.
118
+ *
119
+ * ## Why this exists rather than an image branch
120
+ *
121
+ * `ITEM_TYPE` declares exactly one member, `TEXT: 1`. **The shape of every other item type is unrecorded in this
122
+ * repository** -- no `image_item`, no sample payload, no fixture. So a download path written now would be a guess
123
+ * about field names, and **it would fail at the moment a real person's photograph arrived**, which is the worst
124
+ * possible time to discover it.
125
+ *
126
+ * This is the discovery step instead. It reports **key paths and value types, never values**, so an operator can
127
+ * send one photograph and the log then says what the far end actually sends. **Every wrong guess this session
128
+ * was corrected by printing the thing instead of predicting it** -- a fabricated relay route, a malformed PNG, a
129
+ * count on a CHANGELOG entry -- and this is that method applied before the guess rather than after.
130
+ *
131
+ * Values are never included because a message body is the user's content, and **a discovery aid that quietly
132
+ * logs photographs to a file would be a worse defect than the one it is diagnosing.**
133
+ *
134
+ * @param {object} message one `msgs[]` entry
135
+ * @param {{maxKeys?: number, maxDepth?: number}} [options]
136
+ * @returns {string} a compact, content-free description
137
+ */
138
+ export function describeItems(message, { maxKeys = 24, maxDepth = 5 } = {}) {
139
+ const items = Array.isArray(message?.item_list) ? message.item_list : [];
140
+ if (items.length === 0) return 'item_list is empty or absent';
141
+
142
+ /**
143
+ * A bounded walk: enough to answer "which fields exist and what kind are they", and short enough that a
144
+ * surprising payload cannot bury the log. Arrays are described by their first element, because a caption list
145
+ * and a sticker list need the same answer.
146
+ */
147
+ const seen = new Set();
148
+ const walk = (value, depth) => {
149
+ if (value === null) return 'null';
150
+ if (Array.isArray(value)) return value.length === 0 ? 'array[0]' : `array[${value.length}] of ${walk(value[0], depth)}`;
151
+ const kind = typeof value;
152
+ if (kind !== 'object') return kind;
153
+ if (depth >= maxDepth) return 'object{…}';
154
+ if (seen.has(value)) return 'object{cyclic}';
155
+ seen.add(value);
156
+ const keys = Object.keys(value).slice(0, maxKeys);
157
+ const parts = keys.map((k) => `${k}:${walk(value[k], depth + 1)}`);
158
+ const more = Object.keys(value).length > maxKeys ? `,+${Object.keys(value).length - maxKeys}` : '';
159
+ return `{${parts.join(',')}${more}}`;
160
+ };
161
+
162
+ return items
163
+ .map((item, i) => `item[${i}].type=${JSON.stringify(item?.type)} ${walk(item, 1)}`)
164
+ .join(' | ');
165
+ }
166
+
116
167
  /**
117
168
  * The reply envelope.
118
169
  *
@@ -230,7 +281,22 @@ export function planMessage({ message, authorize }) {
230
281
  // A non-text message is answered rather than dropped: "every sentence must get a reply" includes the
231
282
  // sentences the bridge cannot read.
232
283
  if (text === null) {
233
- return { act: 'reply', to: from, contextToken, text: helpText({ reason: '这条消息不是文字(语音/图片/文件暂不支持)' }) };
284
+ const shape = describeItems(message);
285
+ return {
286
+ act: 'reply',
287
+ to: from,
288
+ contextToken,
289
+ text: helpText({ reason: '这条消息不是文字(语音/图片/文件暂不支持)' }),
290
+ /**
291
+ * Carried out of the plan so the caller can log it.
292
+ *
293
+ * **The shape, never the content.** This field exists so that one photograph sent by a person answers the
294
+ * question this file cannot answer from its own source -- which fields iLink actually puts on an image --
295
+ * and it is reported rather than acted on, because acting on a guessed shape is how a download path ends up
296
+ * failing on the first real photograph.
297
+ */
298
+ shape,
299
+ };
234
300
  }
235
301
 
236
302
  const trimmed = text.trim();
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.57.1';
211
+ const PLUGIN_VERSION = '0.59.0';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;