@twinsearth/w2m-dsh-plugin 0.58.0 → 0.60.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,134 @@ 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.60.0] — 2026-10-16
8
+
9
+ **Two things learned from a client that would not start, neither of which was a crash in this plugin.**
10
+
11
+ ### §3.1 in `TROUBLESHOOTING.md`: check the lock before you check the plugin
12
+
13
+ A launch that leaves **no process at all** is a different failure from one that starts and reports something, and
14
+ the existing §3 procedure will mislead anyone who skips that distinction — it assumes the application *ran*.
15
+
16
+ **The mechanism is one function in `@deepseek-ai/dsh-desktop`'s `lib/main.js`:**
17
+
18
+ ```js
19
+ function claimDesktopSingleInstance(application, focusOwner) {
20
+ if (!application.requestSingleInstanceLock()) {
21
+ application.quit(); // no dialog, no error, no window
22
+ return false;
23
+ }
24
+ ```
25
+
26
+ **A second launch is silently discarded while the first instance holds the lock.** Electron registers it against
27
+ the *application*, not a directory — which is why the two natural remedies both fail, and why the section now says
28
+ so before offering any plugin diagnosis:
29
+
30
+ | What was tried | Why it did nothing |
31
+ |---|---|
32
+ | disable third-party plugins before restarting | the new process quits at the lock, before plugin loading |
33
+ | reinstall the client (new folder, new exe) | a new directory does not release an application-scoped lock |
34
+
35
+ **Deleting `~/.dsh` fixed it — not because anything in it was corrupt, but because the deletion took down the
36
+ process still holding the lock.** The section now leads with the measurement that costs nothing:
37
+
38
+ ```powershell
39
+ Get-Process -Name 'DeepSeek Harness' | Select-Object Id, StartTime
40
+ ```
41
+
42
+ **Processes listed → it is the lock, end them and retry. Nothing listed → the failure really is pre-lock, and §3's
43
+ procedure applies.** The deleted directory carried session history and profile configuration; **`Stop-Process`
44
+ would have cost nothing.**
45
+
46
+ ### `scripts/session-retention.mjs`: the store is bounded, out-of-band, as DSH says it must be
47
+
48
+ `@deepseek-ai/dsh-session-persistence`'s README, under *Known Limitations*:
49
+
50
+ > **No deletion or retention API** — pruning stored sessions is **out-of-band backend maintenance**.
51
+
52
+ **So this is not a workaround for a missing feature: the host states retention is the operator's job.** The
53
+ measurement that motivated it is that **one session reached 18.03 MiB of an 18.07 MiB store**, nothing bounds it,
54
+ and every start reads the store.
55
+
56
+ Three refusals are built in, and each is a deliberate limit rather than an omission:
57
+
58
+ * **The newest session of every workspace is never removed.** DSH writes to it, and *which* process is writing
59
+ cannot be checked reliably from outside — so the rule is the one that needs no knowledge of the running
60
+ process.
61
+ * **`--apply` is required.** The default prints the plan and exits 0. **A tool that removes conversation history
62
+ shows what it will remove before it removes it.**
63
+ * **A directory that is not a session is reported, not touched**, because the layout is read from the filesystem
64
+ rather than assumed.
65
+
66
+ **Verified against the real store:** at a 256 MiB cap it reports *"under the cap — nothing to remove"*; at a
67
+ 1 MiB cap it plans two removals and leaves the 18 MiB live session alone.
68
+
69
+ **No test count change** — both additions are a document and a script, and the script is exercised by hand against
70
+ the store it bounds rather than by a fixture that would not resemble it.
71
+
72
+ ## [0.59.0] — 2026-10-16
73
+
74
+ **Every published bin can now be imported without killing the importing process — measured, not reasoned about.**
75
+
76
+ ### The defect
77
+
78
+ `package.json` publishes three CLIs as importable `exports` subpaths:
79
+
80
+ ```json
81
+ "./rabbit": "./bin/w2m-rabbit.mjs"
82
+ "./localside": "./bin/w2m-localside.mjs"
83
+ "./stun": "./bin/w2m-stun.mjs"
84
+ ```
85
+
86
+ **And those three had no import guard, so their top-level body ran on import.** Measured:
87
+
88
+ ```
89
+ import .../bin/w2m-localside.mjs -> node exited with code 2
90
+ a statement placed after the import never ran
91
+ ```
92
+
93
+ **A published entry point that terminates whichever process imports it.** The plugin's own client-bundle
94
+ documentation records that `exports['./client']` *"is what the host scans for"*, so the host reads that map —
95
+ **and `w2m-mcp.mjs` already carried this guard, which is why it was the one bin that behaved.**
96
+
97
+ The fix gives the other three the same guard rather than withdrawing the exports: **a subpath that is importable
98
+ and inert is strictly better than one that is neither.** All four are now inert on import (`58ms / 74ms / 5ms /
99
+ 2ms`) and all four still run as programs (`--help` → exit 0 with the right banner).
100
+
101
+ ### What this does **not** claim, and why that matters here
102
+
103
+ **This is not offered as the explanation for the client that would not start.** That was reported as *no process
104
+ at all on a double-click*, and that symptom has a different mechanism, read out of `lib/main.js`:
105
+
106
+ ```js
107
+ function claimDesktopSingleInstance(application, focusOwner) {
108
+ if (!application.requestSingleInstanceLock()) {
109
+ application.quit();
110
+ return false;
111
+ }
112
+ ```
113
+
114
+ **`application.quit()` — no dialog, no error, no window.** A second launch is silently discarded while the first
115
+ instance holds the lock, which explains all three observations: disabling third-party plugins did nothing (the
116
+ new process never reached plugin loading), **reinstalling the client did nothing (a new directory does not release
117
+ a lock registered against the application)**, and deleting `~/.dsh` worked — **because it took down the lock
118
+ holder.**
119
+
120
+ **A real defect, fixed; a different defect, responsible.** The two were adjacent in time, and conflating them
121
+ would have produced a changelog entry claiming a fix it cannot demonstrate.
122
+
123
+ ### Three of my edits failed on the same mistake before the fourth worked
124
+
125
+ The guard had to be inserted where each file's executable body begins. **My first two markers were text I
126
+ predicted rather than read** — `await main()` does not exist in `w2m-rabbit.mjs`, and `const bound = await bind`
127
+ does not exist in `w2m-stun.mjs`. Then a `Select-Object -Skip` offset was **1-based** while the array index is
128
+ **0-based**, so the script refused both files by one line. **The fourth attempt anchored on the statements' own
129
+ text and succeeded on the first run.** That is the same lesson as the fabricated relay route and the malformed
130
+ PNG, and it is now the fifth time this session.
131
+
132
+ **No test count change** — the guards alter when the bodies run, not what they do, and all four entry points are
133
+ exercised by `scripts/check-entrypoints.mjs`.
134
+
7
135
  ## [0.58.0] — 2026-10-15
8
136
 
9
137
  **The bridge now reports what it received instead of guessing what an image looks like.**
@@ -258,6 +258,59 @@ after removal, remove the `w2m` row from `cordis.patch.yml` by hand (§2) before
258
258
  **Not us.** If the failure is in a _different_ plugin's entry and this one is merely in the same
259
259
  profile, the loader reports the package name in the same line — read it before assuming it is ours.
260
260
 
261
+ ### 3.1 First rule: if a double-click produces NO process, it is not a plugin
262
+
263
+ **This section's decision procedure below will mislead you if you skip this step.** It assumes the
264
+ application *ran and reported something*. A launch that leaves **no process at all** — nothing in Task
265
+ Manager, no window, no dialog — is a different failure, and the plugin is not the cause.
266
+
267
+ **The mechanism**, read out of `@deepseek-ai/dsh-desktop`'s `lib/main.js`:
268
+
269
+ ```js
270
+ function claimDesktopSingleInstance(application, focusOwner) {
271
+ if (!application.requestSingleInstanceLock()) {
272
+ application.quit(); // no dialog, no error, no window
273
+ return false;
274
+ }
275
+ application.on("second-instance", focusOwner);
276
+ return true;
277
+ }
278
+ ```
279
+
280
+ **A second launch is silently discarded while the first instance holds the lock.** Electron registers that
281
+ lock against the *application*, not against a directory, which is why the two most natural remedies both
282
+ fail:
283
+
284
+ | What you try | Why it does nothing |
285
+ |---|---|
286
+ | **Disable third-party plugins before restarting** | The new process never reaches plugin loading — it quits at the lock |
287
+ | **Reinstall the client (new folder, new exe)** | A new directory does not release a lock registered against the application |
288
+
289
+ **Check the lock before touching anything else:**
290
+
291
+ ```powershell
292
+ # Windows
293
+ Get-Process -Name 'DeepSeek Harness' | Select-Object Id, StartTime
294
+ ```
295
+
296
+ ```bash
297
+ # macOS / Linux
298
+ pgrep -fl "DeepSeek Harness"
299
+ ```
300
+
301
+ * **Processes listed** → it is the lock, and the app is not broken. **End them and start again.** On
302
+ Windows: `Stop-Process -Name 'DeepSeek Harness' -Force`. Do **not** delete `~/.dsh`.
303
+ * **Nothing listed** → the failure really is before the lock, and *then* §3's procedure below applies.
304
+
305
+ **A measured case, so the cost is on record.** A restart after upgrading to **v0.58.0** produced no
306
+ process. Disabling third-party plugins and reinstalling the client both changed nothing; **deleting the
307
+ entire `~/.dsh` directory fixed it** — not because anything in that directory was corrupt, but because
308
+ **the deletion took down the process still holding the lock**, and the next launch then became the first
309
+ instance. **Killing that process is the smaller, safer step, and it is the one to try first.**
310
+
311
+ **What this cost, stated plainly:** the deleted directory carried session history and profile
312
+ configuration that no longer exists. **`Stop-Process` would have cost nothing.**
313
+
261
314
  ---
262
315
 
263
316
  ## 4. A machine is listed but never answers
@@ -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.58.0",
3
+ "version": "0.60.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",
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.58.0",
4
+ "version": "0.60.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": [
@@ -135,7 +135,7 @@ export function textOf(message) {
135
135
  * @param {{maxKeys?: number, maxDepth?: number}} [options]
136
136
  * @returns {string} a compact, content-free description
137
137
  */
138
- export function describeItems(message, { maxKeys = 24, maxDepth = 3 } = {}) {
138
+ export function describeItems(message, { maxKeys = 24, maxDepth = 5 } = {}) {
139
139
  const items = Array.isArray(message?.item_list) ? message.item_list : [];
140
140
  if (items.length === 0) return 'item_list is empty or absent';
141
141
 
@@ -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.58.0';
211
+ const PLUGIN_VERSION = '0.60.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;