@twinsearth/w2m-dsh-plugin 0.8.3 → 0.8.5

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,332 @@ 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
+
7
333
  ## [0.8.3] — 2026-10-14
8
334
 
9
335
  **The last wire, and the reason it was never there.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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",
@@ -121,7 +121,9 @@ import { mintPunchId, punchSession } from '../agent/p2p.mjs';
121
121
  // rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
122
122
  // implementations of a handshake are two chances to disagree about it.
123
123
  import { DeviceKey } from '../agent/crypto.mjs';
124
- import { loadOrCreateIdentity } from '../agent/identity.mjs';
124
+ import { loadOrCreateIdentity, saveDeviceToken } from '../agent/identity.mjs';
125
+ import { probeCapsDetailed, detectPlatform } from '../agent/caps.mjs';
126
+ import { resolveStateDir } from '../util/cli.mjs';
125
127
  import { parseServer } from '../agent/stun.mjs';
126
128
  // The result-deduplication identity the relay mints. Imported rather than re-implemented: the value
127
129
  // is compared byte for byte by the executor, so a second copy of the formula that drifted by one
@@ -4230,6 +4232,24 @@ export async function apply(ctx, config = {}) {
4230
4232
  // -------------------------------------------------------------------------------------------
4231
4233
  if (cfg.autoStartAgent) {
4232
4234
  ctx.effect?.(() => startLocalStack(ctx, cfg));
4235
+ } else {
4236
+ /**
4237
+ * v0.8.5: **say it out loud.**
4238
+ *
4239
+ * This branch is the whole reason a machine can run v0.8.4 with the account-derived fleet key,
4240
+ * hold a valid signed-in account, reach the relay over HTTP 200, and still be in no fleet —
4241
+ * with nothing anywhere reporting a problem. The eight tools register, the plugin looks
4242
+ * healthy, and the join sequence simply never runs.
4243
+ *
4244
+ * A silent no-op that looks like success is the worst failure mode this plugin has had. One
4245
+ * line at warn level turns *"why is this machine not in my fleet"* from an investigation into a
4246
+ * log search.
4247
+ */
4248
+ ctx.logger?.warn?.(
4249
+ 'w2m: no agent is running on this machine, so it is in no fleet and will receive no work. '
4250
+ + 'Set autoStartAgent: true to join (the fleet key is derived from the signed-in account), '
4251
+ + 'or run w2m-localside yourself and point it at the relay.',
4252
+ );
4233
4253
  }
4234
4254
 
4235
4255
  // ---------------------------------------------------------------------------------------------
@@ -4620,6 +4640,25 @@ async function startLocalStack(ctx, cfg) {
4620
4640
  * So: pair by the **account fleet key** when one is available, fall back to a pairing code, and
4621
4641
  * only stand up a local relay when the configured URL actually is local.
4622
4642
  */
4643
+ /**
4644
+ * v0.8.5: a usable state directory, because an empty one THROWS.
4645
+ *
4646
+ * `createAgent` rejects a falsy `stateDir` (`createAgent: stateDir is required`), and the
4647
+ * plugin's resolved default is the empty string. So `autoStartAgent: true` with `stateDir`
4648
+ * unset threw as well -- a SECOND gate behind the first, and the two together meant the
4649
+ * join sequence could not run even when someone flipped the switch.
4650
+ *
4651
+ * Resolved here rather than left to the caller because `w2m_status` reports `cfg.stateDir`
4652
+ * verbatim: showing the resolved path is what makes "where did it write device.json"
4653
+ * answerable instead of a guess.
4654
+ */
4655
+ const resolvedStateDir =
4656
+ typeof cfg.stateDir === 'string' && cfg.stateDir.trim() !== ''
4657
+ ? cfg.stateDir.trim()
4658
+ : resolveStateDir(null, 'localside');
4659
+ if (cfg.stateDir !== resolvedStateDir) {
4660
+ ctx.logger?.info?.(`w2m: stateDir was unset; using ${resolvedStateDir}`);
4661
+
4623
4662
  const target = cfg.rabbitUrl ? new URL(cfg.rabbitUrl) : null;
4624
4663
  const isLocalRelay =
4625
4664
  target === null || ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(target.hostname);
@@ -4629,7 +4668,7 @@ async function startLocalStack(ctx, cfg) {
4629
4668
  relay = await createRelay({
4630
4669
  host: target?.hostname ?? '127.0.0.1',
4631
4670
  port: target?.port ? Number(target.port) : 8787,
4632
- stateDir: cfg.stateDir,
4671
+ stateDir: resolvedStateDir,
4633
4672
  pairingCode: cfg.pairingCode ?? undefined,
4634
4673
  });
4635
4674
  ctx.logger?.info?.(`w2m: relay listening at ${relay.url ?? '(unknown url)'}`);
@@ -4639,30 +4678,178 @@ async function startLocalStack(ctx, cfg) {
4639
4678
  ctx.logger?.info?.(`w2m: joining the relay at ${cfg.rabbitUrl}`);
4640
4679
  }
4641
4680
 
4681
+ }
4682
+
4683
+ /**
4684
+ * v0.8.6: the identity, and the options needed to WRITE IT BACK.
4685
+ *
4686
+ * loadOrCreateIdentity is read-only. The token it gains at pairing lives only in memory
4687
+ * unless something calls saveDeviceToken -- and nothing in this path did, so a machine could
4688
+ * pair successfully, appear in the relay's device table, and then answer 401 to every
4689
+ * authenticated request after the next restart, because device.json still said device_token:
4690
+ * null.
4691
+ *
4692
+ * The CLI has always done this (in/w2m-localside.mjs: saveDeviceToken(paired.device_token)).
4693
+ * The in-process path was written without it, which is why the symptom looked like a permission
4694
+ * fault rather than a missing line.
4695
+ */
4696
+ const identityOptions = { name: cfg.machineName, rabbitUrl: cfg.rabbitUrl ?? undefined };
4697
+ const loaded = loadOrCreateIdentity(identityOptions);
4698
+
4699
+ /**
4700
+ * v0.8.8: report what this machine IS, not just that it exists.
4701
+ *
4702
+ * `createAgent` accepts `platform` and `caps`, and the CLI has always supplied them. This path
4703
+ * did not, so a machine that joined through the plugin appeared in the relay complete and
4704
+ * correct except for `os: null` and `caps: {}` -- and the capability gate then had nothing to
4705
+ * filter on, which decides what work the machine is offered.
4706
+ *
4707
+ * The same shape as the other defects in this series: the record was created, and the part
4708
+ * that makes it USEFUL was not filled in.
4709
+ */
4710
+ const [{ caps, detail: capDetail }, platform] = await Promise.all([
4711
+ probeCapsDetailed({}),
4712
+ detectPlatform({}),
4713
+ ]);
4714
+ if (capDetail?.notes?.length) {
4715
+ ctx.logger?.info?.(`w2m: capability probe notes: ${capDetail.notes.join('; ')}`);
4716
+ }
4717
+
4642
4718
  const agent = await createAgent({
4643
4719
  rabbitUrl: relay?.url ?? cfg.rabbitUrl,
4644
4720
  // `project`, not `projectDir`: the option `createAgent` documents, and the one it requires.
4645
4721
  project: cfg.projectDir,
4646
- stateDir: cfg.stateDir,
4722
+ stateDir: resolvedStateDir,
4647
4723
  // The identity the agent announces itself under. Loaded from the same file the CLI uses, so
4648
4724
  // a machine has ONE identity whether the agent runs in-process or as its own process --
4649
4725
  // otherwise each would mint its own keypair and overwrite the other's announcement under a
4650
4726
  // shared machine_id (the v0.6.2 defect, in a new place).
4651
- identity: loadOrCreateIdentity({ name: cfg.machineName, rabbitUrl: cfg.rabbitUrl ?? undefined }).identity,
4727
+ // The identity options are kept in a variable because the SAME options must be used to write
4728
+ // the device token back. Reading with one set and writing with another is how the plugin and
4729
+ // the CLI ended up each minting a keypair under one machine_id (the v0.6.2 defect).
4730
+ identity: identityOptions.identity,
4731
+ identityPath: loaded.path,
4732
+ caps,
4733
+ platform,
4652
4734
  allowedCommands: cfg.allowedCommands,
4653
4735
  });
4736
+ /**
4737
+ * v0.8.9: derive the fleet key, RETRYING until the account actually answers.
4738
+ *
4739
+ * The account profile is LAZILY loaded: `getPlatformSession().userId` is null until a
4740
+ * successful `getProfile` has happened, and this runs at mount -- before any model call has
4741
+ * touched the profile. So a single read at startup returns "signed out" on a machine that is
4742
+ * signed in, and the fleet key is never derived.
4743
+ *
4744
+ * That also explains an earlier confusing result: v0.8.4 DID pair once, because the profile
4745
+ * happened to be cached by then. A timing-dependent success is the worst kind to debug,
4746
+ * because the code is right and the outcome still varies.
4747
+ *
4748
+ * v0.8.7 made it worse without meaning to: "already paired -> do not pair again" turned an
4749
+ * intermittent miss into a PERMANENT one, since a machine that missed at startup never
4750
+ * retried. Re-pairing was the only thing that had been rescuing it.
4751
+ *
4752
+ * So: back off and retry until a real key appears or the budget runs out, and REPORT EVERY
4753
+ * OUTCOME -- including the one that used to say nothing at all. `resolve()` on the stored-SN
4754
+ * path returns ok:true with a null fleetKey, which the old code checked for neither truthy
4755
+ * nor falsy, so that shape produced no log line anywhere. A silent branch in the exact place
4756
+ * that was failing is how this took several rounds to find.
4757
+ */
4758
+ let fleetKey = cfg.fleetKey ?? null;
4759
+ let fleetKeySource = fleetKey === null ? null : 'config';
4760
+ if (fleetKey === null) {
4761
+ const ATTEMPTS = 6;
4762
+ let last = null;
4763
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
4764
+ try {
4765
+ const { createAccountIdentity } = await import('../chatops/account.mjs');
4766
+ last = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
4767
+ } catch (error) {
4768
+ last = { ok: false, reason: 'threw', detail: error instanceof Error ? error.message : String(error) };
4769
+ }
4770
+ const key = last && last.ok === true ? last.fleetKey : null;
4771
+ if (typeof key === 'string' && key !== '') {
4772
+ fleetKey = key;
4773
+ fleetKeySource = 'account';
4774
+ ctx.logger?.info?.(`w2m: fleet key derived from the signed-in account on attempt ${attempt} (fleet ${last.accountCode})`);
4775
+ break;
4776
+ }
4777
+ if (attempt < ATTEMPTS) {
4778
+ const waitMs = Math.min(2000 * 2 ** (attempt - 1), 30000);
4779
+ ctx.logger?.info?.(`w2m: no fleet key yet (${describeIdentityFailure(last)}); retrying in ${waitMs}ms`);
4780
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
4781
+ }
4782
+ }
4783
+ if (fleetKey === null) {
4784
+ // Loud, and it names which failure it was: a machine in no fleet must never be quiet.
4785
+ ctx.logger?.warn?.(
4786
+ `w2m: could not derive a fleet key after ${ATTEMPTS} attempts (${describeIdentityFailure(last)}) -- `
4787
+ + 'this machine is in NO fleet and will receive no work',
4788
+ );
4789
+ }
4790
+ }
4791
+
4792
+ /**
4793
+ * Name what actually went wrong, including the shape that used to say nothing.
4794
+ *
4795
+ * `ok: true` with a null key is the stored-SN path, and it was invisible before: the old code
4796
+ * only logged when `ok === false`, so the one branch that was firing had no output.
4797
+ */
4798
+ function describeIdentityFailure(identity) {
4799
+ if (!identity) return 'no result';
4800
+ if (identity.ok === true) return 'signed in, but the account returned no fleet key (the profile may not be loaded yet)';
4801
+ return `${identity.reason}${identity.detail ? `: ${identity.detail}` : ''}`;
4802
+ }
4654
4803
 
4655
4804
  /**
4656
- * Pair before starting, because an unpaired agent can fetch no offers.
4805
+ * v0.8.7: **pair only when there is no token**, because the relay rate-limits pairing.
4657
4806
  *
4658
- * The fleet key is tried first: it needs no operator step and no code to hand over, which is
4659
- * the whole point of v0.8.1. A pairing code is the fallback for a relay that has no fleet of
4660
- * its own.
4807
+ * The previous version paired on EVERY start: the fleet key is derived unconditionally, so
4808
+ * `pairBy` was never null and the branch always ran. The relay allows
4809
+ * `pair_rate_limit: 5` per minute, so five restarts inside a minute would hit the limit --
4810
+ * and a rate-limit refusal reads as a pairing failure, which reads as a fleet problem.
4811
+ * A machine that already holds a token has nothing to gain from pairing again: the relay
4812
+ * would re-issue one and invalidate the old, for no reason.
4813
+ *
4814
+ * So the condition is the token: absent -> pair once and persist; present -> leave it alone.
4815
+ * That is also what makes the persisted token load-bearing rather than an optimisation --
4816
+ * without it every start is a fresh pairing, which is why the missing write in v0.8.5 and
4817
+ * the unconditional pairing here were the same defect seen from two sides.
4661
4818
  */
4662
- const pairBy = cfg.fleetKey ? 'fleet key' : cfg.pairingCode ? 'pairing code' : null;
4663
- if (pairBy !== null) {
4819
+ const existingToken = loaded.identity.device_token;
4820
+ const alreadyPaired = typeof existingToken === 'string' && existingToken !== '';
4821
+
4822
+ const pairBy = fleetKey !== null ? `fleet key (${fleetKeySource})` : cfg.pairingCode ? 'pairing code' : null;
4823
+ if (alreadyPaired) {
4824
+ ctx.logger?.info?.(`w2m: already paired (token in ${loaded.path}); not pairing again`);
4825
+ } else if (pairBy !== null) {
4664
4826
  try {
4665
- await agent.pair(cfg.pairingCode ?? null, undefined, cfg.fleetKey ?? null);
4827
+ const paired = await agent.pair(cfg.pairingCode ?? null, undefined, fleetKey);
4828
+ /**
4829
+ * v0.8.6: **persist the token.** Without this the pairing is thrown away.
4830
+ *
4831
+ * `agent.pair` sets `identity.device_token` on its in-memory object only. Nothing in this
4832
+ * path wrote it back, so the observable behaviour was: the relay registers the device, the
4833
+ * machine appears in `w2m_devices`, and then every authenticated call answers
4834
+ * `401 UNAUTHORIZED` -- `w2m_devices`, the P2P announce, all of it -- because `device.json`
4835
+ * still said `device_token: null`. It reads as a permission failure and is a missing write.
4836
+ *
4837
+ * The CLI has always done this; the in-process path was written without it.
4838
+ * `saveDeviceToken` throws on an empty token rather than storing one, which is the right
4839
+ * shape: a silent null here is exactly the state this line removes.
4840
+ */
4841
+ try {
4842
+ saveDeviceToken(paired?.device_token, identityOptions);
4843
+ ctx.logger?.info?.(`w2m: device token persisted to ${loaded.path}`);
4844
+ } catch (error) {
4845
+ // Named rather than silent: a machine that paired but cannot authenticate is the exact
4846
+ // failure this release exists to remove, and it must not be quiet a second time.
4847
+ ctx.logger?.warn?.(
4848
+ 'w2m: paired but the device token could NOT be persisted ('
4849
+ + `${error instanceof Error ? error.message : String(error)}) -- this machine will answer `
4850
+ + '401 to every authenticated call until that is fixed',
4851
+ );
4852
+ }
4666
4853
  ctx.logger?.info?.(`w2m: paired by ${pairBy}`);
4667
4854
  } catch (error) {
4668
4855
  // Named rather than swallowed: an agent that failed to pair runs and does nothing, which