@twinsearth/w2m-dsh-plugin 0.8.2 → 0.8.4

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,393 @@ 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.8.9] — 2026-10-14
8
+
9
+ **The account answers lazily, and we asked once.**
10
+
11
+ ### The defect
12
+
13
+ `getPlatformSession().userId` repeats the stable account id **from the most recent successful
14
+ `getProfile`**, and is `null` until one has happened. The account profile is a **lazy cache**, and
15
+ `startLocalStack` reads it **at mount** — before any model call has touched the profile.
16
+
17
+ So on a signed-in machine the first read returns *signed out*, the fleet key is never derived, no
18
+ pairing happens, and **nothing is written**. The evidence that pins it is `device.json`'s **mtime**:
19
+ unchanged since the first boot, so `saveDeviceToken` was not merely ineffective, it was **never
20
+ reached**.
21
+
22
+ **That also explains an earlier result that made no sense at the time:** v0.8.4 *did* pair once,
23
+ because by then the profile happened to be cached. **A timing-dependent success is the worst kind to
24
+ debug — the code is right and the outcome still varies.**
25
+
26
+ ### v0.8.7 made it worse, without meaning to
27
+
28
+ *"Already paired → do not pair again"* turned an **intermittent miss into a permanent one**. A
29
+ machine that missed at startup never retried, and re-pairing had been the only thing rescuing it.
30
+ The rate-limit fix was correct and exposed a defect it did not create.
31
+
32
+ ### Fixed
33
+
34
+ Derivation now **backs off and retries** (6 attempts, 2s doubling to a 30s cap) until a real key
35
+ appears or the budget runs out.
36
+
37
+ ### And the silent branch that hid it
38
+
39
+ `resolve()` on the **stored-SN path** returns `ok: true` with `fleetKey: null`. The old code logged
40
+ only when `ok === false` — so the shape that was actually firing **produced no output anywhere**.
41
+
42
+ That is the same *silent no-op* class fixed in 0.8.5, reappearing **in the exact branch that was
43
+ failing**. Every outcome is now named:
44
+
45
+ | shape | reported as |
46
+ |---|---|
47
+ | `ok: true`, key present | `fleet key derived … on attempt N (fleet …)` |
48
+ | **`ok: true`, key null** | **`signed in, but the account returned no fleet key (the profile may not be loaded yet)`** |
49
+ | `ok: false` | the reason and detail, verbatim |
50
+ | budget exhausted | **`this machine is in NO fleet and will receive no work`** |
51
+
52
+ A machine in no fleet must never be quiet about it.
53
+
54
+ ### Verified
55
+
56
+ **174 cases**. `tools.mjs` loads; the describer was exercised against all five shapes including the
57
+ one that previously said nothing.
58
+
59
+ ## [0.8.8] — 2026-10-14
60
+
61
+ **A machine that joined without saying what it is — and a fix that was computed and never used.**
62
+
63
+ ### `platform` and `caps` were never sent
64
+
65
+ `createAgent` accepts `platform` and `caps`, and `bin/w2m-localside.mjs` has always supplied them.
66
+ `startLocalStack` did not, so a machine that joined **through the plugin** appeared in the relay
67
+ complete and correct except for:
68
+
69
+ ```
70
+ "os": null, "arch": null, "caps": {}
71
+ ```
72
+
73
+ **The capability gate then had nothing to filter on — and that gate decides what work a machine is
74
+ offered.** So the device was registered, reachable, and unusable for anything the gate would have
75
+ matched it to.
76
+
77
+ Same shape as the rest of this series: the record was created and **the part that makes it useful was
78
+ not filled in**.
79
+
80
+ ### The v0.8.5 fix was computed and never used
81
+
82
+ Worse, and found while making the change above:
83
+
84
+ ```js
85
+ const resolvedStateDir = cfg.stateDir.trim() !== '' ? cfg.stateDir.trim() : resolveStateDir(...);
86
+ if (cfg.stateDir !== resolvedStateDir) { ctx.logger?.info?.(`w2m: stateDir was unset; using ...`); }
87
+ ...
88
+ const agent = await createAgent({ stateDir: cfg.stateDir, ... }); // ← the EMPTY string
89
+ ```
90
+
91
+ **v0.8.5 computed the resolved directory, logged it, and then passed the original empty string to
92
+ `createAgent`** — the exact call that throws `createAgent: stateDir is required`. So the second gate
93
+ v0.8.5 claimed to open was **still shut**, and the only evidence was a log line saying the resolved
94
+ path was being used.
95
+
96
+ *The log said the right thing while the code did the wrong thing* — which is worse than silence,
97
+ because silence sends you looking and a confident wrong log sends you away.
98
+
99
+ Both `createRelay` and `createAgent` now receive `resolvedStateDir`, and its definition was moved
100
+ **above** its first use: as a `const` used earlier in the same block it would have thrown
101
+ `ReferenceError: Cannot access 'resolvedStateDir' before initialization` at runtime, which
102
+ `node --check` cannot see because it is not a syntax error. Caught by checking the **order**, not the
103
+ syntax.
104
+
105
+ ### Also sent now
106
+
107
+ `identityPath: loaded.path` — the device key belongs **beside the `device.json` this process actually
108
+ loaded**, not in a directory the agent guesses. Without it the in-process and standalone paths can
109
+ mint separate keypairs under one `machine_id` and overwrite each other's announcement (the v0.6.2
110
+ defect, which this path would have reintroduced).
111
+
112
+ ### Verified
113
+
114
+ **174 cases**. `tools.mjs` loads; the ordering was confirmed by comparing line numbers rather than by
115
+ reading.
116
+
117
+ ## [0.8.7] — 2026-10-14
118
+
119
+ **Pair only when there is no token — because the relay rate-limits pairing.**
120
+
121
+ ### The defect
122
+
123
+ v0.8.6 made the token persist; it still **paired on every start**. The fleet key is derived
124
+ unconditionally, so `pairBy` was never null and the pairing branch always ran.
125
+
126
+ The relay allows `pair_rate_limit: 5` per minute. **Five restarts inside a minute hit the limit — and
127
+ a rate-limit refusal reads as a pairing failure, which reads as a fleet problem.** The symptom would
128
+ have pointed at the network, the relay, or the account, none of which would be at fault.
129
+
130
+ A machine that already holds a token has nothing to gain from pairing again: the relay would re-issue
131
+ one and invalidate the old, for no reason.
132
+
133
+ ### Fixed
134
+
135
+ ```js
136
+ const existingToken = loaded.identity.device_token;
137
+ const alreadyPaired = typeof existingToken === 'string' && existingToken !== '';
138
+
139
+ if (alreadyPaired) {
140
+ ctx.logger?.info?.(`w2m: already paired (token in ${loaded.path}); not pairing again`);
141
+ } else if (pairBy !== null) {
142
+ // pair once, then persist
143
+ }
144
+ ```
145
+
146
+ **This also makes the persisted token load-bearing rather than an optimisation.** Without it every
147
+ start is a fresh pairing — which is why *the missing write in v0.8.5* and *the unconditional pairing
148
+ here* are **the same defect seen from two sides**: one threw the token away, the other had to
149
+ re-create it every time.
150
+
151
+ ### A note on how this was found
152
+
153
+ Reported, with reasoning and a rate-limit number from the relay's own configuration — not from a
154
+ failing test. The distinction matters for what it implies: **nothing in the suite would have caught
155
+ it**, because every case starts from a fresh state and none of them restart the plugin in a loop.
156
+
157
+ ### Verified
158
+
159
+ **174 cases** across the tools, apply, account and fleet-join suites.
160
+
161
+ ## [0.8.6] — 2026-10-14
162
+
163
+ **The pairing worked and the token was thrown away.**
164
+
165
+ ### The defect
166
+
167
+ `startLocalStack` called `agent.pair(...)` and **never persisted the result**. `agent.pair` sets
168
+ `identity.device_token` on its **in-memory object only**, so the observable behaviour was:
169
+
170
+ 1. the account-derived key is computed ✅
171
+ 2. the relay **accepts** the pair and registers the device ✅
172
+ 3. the machine appears in `w2m_devices` ✅
173
+ 4. **every authenticated call then answers `401 UNAUTHORIZED`** — `w2m_devices`, the P2P announce,
174
+ all of it — because `device.json` still said `device_token: null` ❌
175
+
176
+ It reads as a permission failure and is **a missing write**.
177
+
178
+ The evidence that settles it is the shape of the value: not an empty string, not a truncated token —
179
+ **`null`**. A write that failed would leave the previous value; a write that never happened leaves
180
+ what the identity was created with.
181
+
182
+ ### Why the CLI never had this bug
183
+
184
+ `bin/w2m-localside.mjs` has always done it:
185
+
186
+ ```js
187
+ const paired = await agent.pair(flags.pair ?? null, undefined, fleetKey);
188
+ saveDeviceToken(paired.device_token, {}); // ← the CLI persists it
189
+ ```
190
+
191
+ **The in-process path was written without that line.** So the two paths diverged in exactly the way
192
+ that produces a symptom in one and not the other — which is why it looked like a relay or permission
193
+ problem rather than a missing statement.
194
+
195
+ ### Fixed
196
+
197
+ `saveDeviceToken(paired.device_token, identityOptions)` now runs after a successful pair, using the
198
+ **same options object** that loaded the identity. Reading with one set of options and writing with
199
+ another is how the plugin and the CLI once ended up each minting a keypair under one `machine_id`
200
+ (the v0.6.2 defect), so the options are captured once and used for both.
201
+
202
+ A failure to persist is **named at warn level**, including what it means: *"this machine will answer
203
+ 401 to every authenticated call until that is fixed."* That exact condition was silent once already.
204
+
205
+ ### The pattern this is the third instance of
206
+
207
+ * v0.8.4 wired the **derivation** but the gate in front of it was closed.
208
+ * v0.8.5 fixed the **second gate** (`stateDir`) but not the write behind it.
209
+ * v0.8.6 fixes the **write**.
210
+
211
+ Each time, the action was connected and its **persistence or precondition** was not. Worth stating
212
+ plainly because it is the shape of every bug in this series: **the thing that makes the result
213
+ survive is the thing that gets forgotten.**
214
+
215
+ ### Verified
216
+
217
+ **174 cases** across the tools, apply, account and fleet-join suites.
218
+
219
+ ## [0.8.5] — 2026-10-14
220
+
221
+ **Two gates, not one — and the silence that hid both.**
222
+
223
+ ### The second gate
224
+
225
+ v0.8.4's analysis said the join sequence was blocked by `autoStartAgent` defaulting to false. That
226
+ was right and incomplete: **an unset `stateDir` threw as well**, so flipping the switch would have
227
+ failed too.
228
+
229
+ ```
230
+ stateDir=empty string -> THREW: createAgent: stateDir is required
231
+ ```
232
+
233
+ `createAgent` rejects a falsy `stateDir`; the plugin's resolved default is the empty string; and
234
+ `startLocalStack` passed it straight through. **Two gates in series, and the analysis found the one
235
+ in front.**
236
+
237
+ `stateDir` now resolves to a real path (`$DSH_HOME/xclient/localside`) when unset, resolved in
238
+ `startLocalStack` so `w2m_status` reports the path actually used — which is what makes *"where did it
239
+ write `device.json`"* answerable rather than a guess.
240
+
241
+ ### The silence, which is the real defect
242
+
243
+ A machine could run v0.8.4, hold a valid signed-in account, reach the relay over **HTTP 200**, and
244
+ be in **no fleet** — with nothing anywhere reporting a problem. The eight tools registered, the
245
+ plugin looked healthy, and the join sequence simply never ran.
246
+
247
+ **A silent no-op that looks like success is the worst failure mode this plugin has had**, because
248
+ every diagnostic signal a person would reach for says the system is fine. The evidence that finally
249
+ settled it was an *absence* — no `device.json`, no `xclient`, no listening ports — and an absence
250
+ is only readable once you know what should have been there.
251
+
252
+ So the disabled branch now says so, at warn level, naming what it means and how to change it:
253
+
254
+ > `w2m: no agent is running on this machine, so it is in no fleet and will receive no work. Set autoStartAgent: true to join …`
255
+
256
+ One line turns *"why is this machine not in my fleet"* from an investigation into a log search.
257
+
258
+ ### A test that measured a count instead of a message
259
+
260
+ `tools.test.mjs` asserted `warnings.length === 1` for the `dsh-tools` shim warning — a claim about
261
+ **every** warning the plugin will ever emit, so the new one broke a test about something else
262
+ entirely. It now matches the message it is about. **The count was never the property.**
263
+
264
+ ### What is still required, stated plainly
265
+
266
+ The switch does not default to on, deliberately: a control machine that only *dispatches* to others
267
+ has no reason to be in the fleet itself. So joining requires `autoStartAgent: true` **and** a
268
+ `w2m` row in the profile — and the profile having no such row is the state every fresh install
269
+ starts in.
270
+
271
+ ### Verified
272
+
273
+ **174 cases** across the tools, apply, account and fleet-join suites.
274
+
275
+ ## [0.8.4] — 2026-10-14
276
+
277
+ **The last connection — the one that makes the feature user-facing.**
278
+
279
+ ### The gap
280
+
281
+ The `chatops` half derived the fleet key from `userId` and exposed it. The `plugin/tools.mjs` half
282
+ accepted a key and handed it to the agent. **Neither knew about the other**, so the plugin read
283
+ `fleetKey` from config or `W2M_FLEET_KEY` — and a machine with a signed-in account still joined
284
+ nothing without a human setting a variable.
285
+
286
+ Two halves that each work, with no wire between them, is the same failure as v0.8.1's: the capability
287
+ existed and nothing exercised it.
288
+
289
+ ### Now
290
+
291
+ `startLocalStack` derives the key itself, from the account, via the same
292
+ `createAccountIdentity({ ctx }).resolve()` the rest of the plugin uses. The derivation belongs there
293
+ because **the host is the only layer that can read the account** — a standalone agent cannot — so the
294
+ key must be produced where `ctx` is available rather than where it is used.
295
+
296
+ Verified with DSH's own context shape:
297
+
298
+ ```
299
+ ctx.get('deepseekAccount') -> createAccountIdentity({ ctx }).resolve()
300
+ -> fleetKey (43 chars) -> fleetId d5bb1b9ddf3faad4 (matches the existing derivation)
301
+ signed-out -> named reason, no key invented
302
+ no account service -> named reason
303
+ ```
304
+
305
+ ### Two failure modes, handled differently on purpose
306
+
307
+ * A **derivation failure is caught and logged, not thrown** — an unpaired agent still runs and can be
308
+ paired later, whereas a thrown start is a machine that never appears at all.
309
+ * **`signed-out` is logged at info, not warn** — being signed out is a normal state, and the design
310
+ says a machine with no account must not be given a random fleet. Warning about it would train an
311
+ operator to ignore the line that matters.
312
+
313
+ An explicitly configured `fleetKey` still wins, and the log names which source was used — `paired by
314
+ fleet key (account)` or `(config)` — so nobody has to guess where the key came from.
315
+
316
+ ### A defect in this patch, caught by the compiler
317
+
318
+ The first edit consumed the `} catch (error) {` line and left the catch body inside the `try`, which
319
+ `node --check` reported immediately. Worth recording because the same edit pattern — replacing a
320
+ block by line range — corrupted a function in v0.8.1 as well.
321
+
322
+ ### Verified
323
+
324
+ **174 cases** across the tools, apply, account and fleet-join suites.
325
+
326
+ ### This is what closes the reported bug
327
+
328
+ A new machine, installed and signed into the same account, derives its fleet key, presents it at
329
+ pairing, and lands in the same fleet — **with no key to copy and no operator step**. That is the
330
+ behaviour the original report asked for, and it is now reachable by configuration rather than by
331
+ convention.
332
+
333
+ ## [0.8.3] — 2026-10-14
334
+
335
+ **The last wire, and the reason it was never there.**
336
+
337
+ ### `autoStartAgent` was dead code
338
+
339
+ The option existed, was documented, and **threw on its first call**. `startLocalStack` built its
340
+ `createAgent` arguments as `projectDir` (the option is `project`), omitted `identity` (which is
341
+ required), and passed a `pairingCode` that `createAgent` does not accept.
342
+
343
+ Verified rather than inferred — calling `createAgent` with that exact shape:
344
+
345
+ ```
346
+ createAgent THREW: createAgent: project is required
347
+ ```
348
+
349
+ **So the code path that was supposed to bring an agent up had never run.** That is why the
350
+ fleet-join wire appeared to be missing: the place it was meant to live could not be reached at all.
351
+ An option that exists, is documented, and cannot execute is worse than one that is absent — it
352
+ reads as a feature.
353
+
354
+ ### Two more things wrong with the shape
355
+
356
+ * **It created a local relay unconditionally.** For a machine joining a shared fleet that is the
357
+ opposite of what is wanted: the machine must connect to the relay that already exists, not mint
358
+ another one — and with `rabbitUrl` pointing at a remote host it would have tried to bind a relay
359
+ on that hostname. It now stands up a local relay **only when the configured URL actually is
360
+ local**, and otherwise logs that it is joining the relay at the given address.
361
+ * **Nothing paired the agent.** Even a started agent could fetch no offers, which looks identical to
362
+ a fleet with no work.
363
+
364
+ ### Now
365
+
366
+ `startLocalStack` passes the option names `createAgent` actually documents, loads the identity from
367
+ the **same file the CLI uses** — so one machine has one identity whether the agent runs in-process
368
+ or as its own process, rather than each minting a keypair and overwriting the other's announcement
369
+ under a shared `machine_id` (the v0.6.2 defect, in a new place) — and **pairs by the account fleet
370
+ key**, falling back to a pairing code.
371
+
372
+ Pairing is attempted **before** `agent.start()`, and a failure is **named at warn level** rather than
373
+ swallowed: an agent that failed to pair runs and does nothing, which is indistinguishable from a
374
+ quiet fleet.
375
+
376
+ ### Config
377
+
378
+ `fleetKey` is read from the config layer or `W2M_FLEET_KEY`. The key must be **handed down** rather
379
+ than computed where it is used, because a standalone agent cannot read the signed-in account — which
380
+ is why it comes from the host.
381
+
382
+ ### Verified
383
+
384
+ **132 cases** across the tools, apply and fleet-join suites. `tools.mjs` loads. The 12 agent-suite
385
+ failures remain pre-existing and environmental, measured against an untouched clone of the same
386
+ commit.
387
+
388
+ ### ⚠️ Not yet proven on real hardware
389
+
390
+ The wiring is fixed and the mechanism was verified end to end in v0.8.2, but **no machine has yet
391
+ joined a fleet through this path in a live run**. That is the next check, and it needs a machine
392
+ that is not already paired.
393
+
7
394
  ## [0.8.2] — 2026-10-14
8
395
 
9
396
  **The client half of the auto-join.** v0.8.1 landed the credential and the relay's acceptance of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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",