pi-lxmf 0.2.2 → 0.3.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +4 -4
  3. package/src/lxmf.js +93 -205
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-03
11
+
12
+ ### Changed
13
+
14
+ - Upgraded to reticulum-js 0.9.3 (`@reticulum/core`, `@reticulum/lxmf`, `@reticulum/node`) and adopted its API ergonomics: outbound delivery now uses the router's own `send()` escalation (`{ linkId, fallback, solicit, timeoutMs }` — DIRECT link → opportunistic packet with recipient-identity solicitation → propagation store-and-forward), replacing the hand-rolled `waitForPeerIdentity` announce-wait and multi-attempt retry chain in `src/lxmf.js`. The unknown-identity failure is now the typed `UnknownIdentityError` from `@reticulum/core`. `startLxmf` awaits the new `Reticulum.ready()` before loading the node identity.
15
+
16
+ ### Fixed
17
+
18
+ - The smoke script spawned its fake `pi` wrapper with a `#!/usr/bin/sh` shebang, which does not exist on macOS (ENOENT on every spawn); it now uses `/bin/sh`. The `/help` assertion also still expected the pre-Markdown `Bridge commands:` heading and never matched since the response was reformatted; it now greps `Bridge commands`.
19
+
10
20
  ## [0.2.2] - 2026-09-28
11
21
 
12
22
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lxmf",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Drive the Pi coding agent over LXMF messaging (Reticulum mesh) from a headless server.",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Henri Bergius <henri.bergius@iki.fi>",
@@ -33,9 +33,9 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@digitaldefiance/bzip2-wasm": "^1.1.1",
36
- "@reticulum/core": "^0.9.0",
37
- "@reticulum/lxmf": "^0.9.0",
38
- "@reticulum/node": "^0.9.0"
36
+ "@reticulum/core": "^0.9.3",
37
+ "@reticulum/lxmf": "^0.9.3",
38
+ "@reticulum/node": "^0.9.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.6.2",
package/src/lxmf.js CHANGED
@@ -11,7 +11,13 @@
11
11
  */
12
12
 
13
13
  import { join } from "node:path";
14
- import { fromHex, Identity, Reticulum, toHex } from "@reticulum/core";
14
+ import {
15
+ fromHex,
16
+ Identity,
17
+ Reticulum,
18
+ toHex,
19
+ UnknownIdentityError,
20
+ } from "@reticulum/core";
15
21
  import { LXMessage, LXMFConstants, LXMRouter } from "@reticulum/lxmf";
16
22
  import {
17
23
  AutoInterface,
@@ -23,34 +29,27 @@ import { createBz2 } from "./bz2.js";
23
29
  import { chunkText } from "./text.js";
24
30
 
25
31
  /**
26
- * The LXMRouter's failure when the destination's identity has not been
27
- * learned yet (no announce heard): `send` declines instantly — no link can
28
- * be established and opportunistic encryption is impossible without the
29
- * recipient's public key.
30
- */
31
- const UNKNOWN_IDENTITY_MESSAGE =
32
- /^Cannot deliver: identity for [0-9a-f]+ is unknown$/;
33
-
34
- /**
35
- * How long {@link waitForPeerIdentity} waits for a solicited announce
36
- * before giving up (and `sendWithRetry` falling back to its plain retries).
37
- * Generous on purpose: the peer may be several slow mesh hops away, and the
38
- * common trigger (the startup notification racing the owner's first
39
- * announce after a daemon restart) is worth waiting for — the alternative
40
- * parks the message in the bridge's `failedNote` until the *next* reply.
32
+ * How long the router may solicit a peer for its announce (path request →
33
+ * awaited announce via `transport.recallOrSolicitIdentity`) before giving
34
+ * up on a send. Generous on purpose: the peer may be several slow mesh
35
+ * hops away, and the common trigger (the startup notification racing the
36
+ * owner's first announce after a daemon restart) is worth waiting for —
37
+ * the alternative parks the message in the bridge's `failedNote` until
38
+ * the *next* reply.
41
39
  */
42
40
  const PEER_DISCOVERY_WAIT_MS = 30_000;
43
41
 
44
42
  /**
45
- * Whether `e` is the router's unknown-destination failure — the caller
46
- * should solicit the peer (path request + announce) instead of retrying
47
- * blind, since the retry cannot succeed until the announce lands.
43
+ * Whether `e` is the typed unknown-identity failure the router throws when
44
+ * a destination's identity is neither recallable nor solicitable within the
45
+ * send's time budget — no link can be established and opportunistic
46
+ * encryption is impossible without the recipient's public key.
48
47
  *
49
48
  * @param {unknown} e
50
- * @returns {e is Error}
49
+ * @returns {e is UnknownIdentityError}
51
50
  */
52
51
  export function isUnknownIdentityError(e) {
53
- return e instanceof Error && UNKNOWN_IDENTITY_MESSAGE.test(e.message);
52
+ return e instanceof UnknownIdentityError;
54
53
  }
55
54
 
56
55
  /**
@@ -68,91 +67,30 @@ export function contentFields() {
68
67
  }
69
68
 
70
69
  /**
71
- * Waits until `transport` can recall the identity for `destinationHash`,
72
- * soliciting it first: a path request makes the destination itself (or any
73
- * transport node holding its path) announce, and the ingested announce
74
- * populates the destination→identity mapping. Resolves early once an
75
- * announce for the exact destination arrives, `false` on timeout.
76
- *
77
- * Closes the restart gap the router leaves open: `_establishDirectLink`
78
- * only requests-and-awaits a path once the identity is *known*, so an
79
- * unknown identity fails the whole `send` without any mesh solicitation.
80
- * reticulum-js keeps `knownDestinations` in memory, so every daemon restart
81
- * re-enters that state until the owner's next announce.
82
- *
83
- * @param {any} transport - `rns.transport` (EventTarget with
84
- * `recallIdentity`, `requestPath`; tolerates missing methods for test
85
- * doubles).
86
- * @param {Uint8Array} destinationHash
87
- * @param {number} timeoutMs
88
- * @returns {Promise<boolean>} `true` when the identity is recallable on return.
89
- */
90
- export async function waitForPeerIdentity(
91
- transport,
92
- destinationHash,
93
- timeoutMs,
94
- ) {
95
- const destHex = toHex(destinationHash);
96
- const recall = () =>
97
- Promise.resolve()
98
- .then(() => transport?.recallIdentity(destinationHash))
99
- .catch(() => null);
100
- if (await recall()) return true;
101
- try {
102
- await transport?.requestPath?.(destinationHash);
103
- } catch {
104
- /* best effort — a late announce still has the timeout window */
105
- }
106
- if (await recall()) return true;
107
- return new Promise((resolve) => {
108
- let settled = false;
109
- /** @type {NodeJS.Timeout|null} */
110
- let timer = null;
111
- const finish = (/** @type {boolean} */ ok) => {
112
- if (settled) return;
113
- settled = true;
114
- if (timer) clearTimeout(timer);
115
- transport.removeEventListener("announce", onAnnounce);
116
- resolve(ok);
117
- };
118
- // The transport dispatches "announce" only after `rememberIdentity`
119
- // completed, so a matching event implies a recallable identity; the
120
- // re-check is belt-and-braces against half-fakes in tests.
121
- const onAnnounce = (/** @type {any} */ ev) => {
122
- const announced = ev?.detail?.destinationHash;
123
- if (!announced || toHex(announced) !== destHex) return;
124
- void recall().then((identity) => finish(Boolean(identity)));
125
- };
126
- timer = setTimeout(() => finish(false), timeoutMs);
127
- transport.addEventListener("announce", onAnnounce);
128
- });
129
- }
130
-
131
- /**
132
- * Builds the outbound retry chain behind `sendText`/`sendReaction`:
133
- *
134
- * 1. `lxmf.send` over the given link (DIRECT; the router falls back to an
135
- * opportunistic packet internally when no link can be established),
136
- * 2. on the router's unknown-identity failure: solicit the destination
137
- * (path request → announce) and wait for its announce — the restart
138
- * race, since reticulum-js keeps the destination→identity map in
139
- * memory and an immediate retry cannot succeed,
140
- * 3. retry over the same link, then once more without it (the arrival
141
- * link is usually gone by reply time on battery-conscious clients),
142
- * 4. store-and-forward via the configured propagation node — the owner is
143
- * likely off-mesh entirely; their next sync picks the message up.
70
+ * Builds the outbound delivery call behind `sendText`/`sendReaction`. Since
71
+ * reticulum-js 0.9.3 the router escalates on its own
72
+ * (`send(message, identity, { linkId, fallback, solicit, timeoutMs })`):
73
+ * DIRECT link → opportunistic packet (soliciting the recipient's identity
74
+ * via `transport.recallOrSolicitIdentity` when no announce has been heard —
75
+ * the restart race, since reticulum-js keeps the destination→identity map
76
+ * in memory) → propagation store-and-forward when an outbound node is set.
77
+ * All of that happens inside one `send` call against one serialized message,
78
+ * so every wire copy shares one message id and a deduplicating client
79
+ * renders the reply once.
144
80
  *
145
- * The same `LXMessage` object flows through every attempt so all wire
146
- * copies share one message id and a deduplicating client renders the
147
- * reply once. Factored out of `startLxmf` with injected dependencies so
148
- * the chain is testable against a fake router.
81
+ * The one gap left: the router's propagation handoff
82
+ * (`submitToPropagationNode`) still declines when the *node's* announce has
83
+ * not been heard yet (fresh start), while a recipient identity is solicited
84
+ * internally. `sendWithRetry` catches that failure, solicits the node and
85
+ * resends. Factored out of `startLxmf` with injected dependencies so the
86
+ * behavior is testable against a fake router.
149
87
  *
150
88
  * @param {object} deps
151
89
  * @param {LXMRouter} deps.lxmf - Initialised router.
152
90
  * @param {Identity} deps.identity - The node's LXMF identity (signs sends).
153
91
  * @param {string|null} [deps.propagationNodeHex] - Configured propagation
154
92
  * node's `lxmf.propagation` hash; enables the store-and-forward fallback
155
- * (reticulum-js's `send` never consults the outbound node on its own).
93
+ * (`fallback: "propagation"` on the router's send).
156
94
  * @param {(msg: string) => void} [deps.log] - Diagnostic sink.
157
95
  * @param {number} [deps.peerWaitMs] - Per-peer announce wait (overridable in tests).
158
96
  * @returns {{sendWithRetry: (message: LXMessage, link?: any) => Promise<void>}}
@@ -168,106 +106,57 @@ export function createRetrySender({
168
106
  ? fromHex(propagationNodeHex)
169
107
  : null;
170
108
 
171
- /**
172
- * Last-resort store-and-forward through the configured propagation
173
- * node, reached from `sendWithRetry` after direct and opportunistic
174
- * delivery both failed — typically the owner being off-mesh entirely
175
- * (the mobile case). The propagated form is encrypted to the *recipient's*
176
- * public key (`dest_hash ‖ E(src‖sig‖payload)`), so it needs their
177
- * identity (by then known — the earlier sends failed on reachability,
178
- * not identity) but **no live path**: the node holds the message until
179
- * the owner's next sync. A node whose announce hasn't been heard yet
180
- * (fresh start) is solicited and waited for like unknown recipients are.
181
- *
182
- * @param {LXMessage} message
183
- * @param {Uint8Array} nodeHash - The configured node's `lxmf.propagation`
184
- * hash (callers guarantee it is set).
185
- */
186
- async function submitViaPropagationNode(message, nodeHash) {
187
- const describe = (/** @type {unknown} */ e) =>
188
- e instanceof Error ? e.message : String(e);
189
- const nodeHex = toHex(nodeHash);
190
- try {
191
- try {
192
- await lxmf.submitToPropagationNode(message, identity);
193
- } catch (e) {
194
- if (!/Propagation node identity unknown/.test(describe(e))) throw e;
195
- log(
196
- `pi-lxmf: propagation node ${nodeHex} unknown — requesting path, ` +
197
- `waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
198
- );
199
- const learned = await waitForPeerIdentity(
200
- lxmf.rns.transport,
201
- nodeHash,
202
- peerWaitMs,
203
- );
204
- if (!learned) throw e;
205
- await lxmf.submitToPropagationNode(message, identity);
206
- }
207
- log(
208
- "pi-lxmf: owner unreachable directly — submitted via propagation " +
209
- "node (delivered on their next sync)",
210
- );
211
- } catch (e) {
212
- log(`pi-lxmf: propagation submit failed (${describe(e)})`);
213
- throw e;
214
- }
215
- }
216
-
217
109
  /**
218
110
  * @param {LXMessage} message
219
111
  * @param {any} [link]
220
112
  */
221
113
  async function sendWithRetry(message, link) {
114
+ const options =
115
+ /** @type {{linkId?: Uint8Array, fallback?: "propagation"|"opportunistic", timeoutMs?: number}} */ ({
116
+ fallback: propagationNodeHash ? "propagation" : "opportunistic",
117
+ timeoutMs: peerWaitMs,
118
+ ...(link ? { linkId: link } : {}),
119
+ });
222
120
  try {
223
- await lxmf.send(message, identity, link);
121
+ await lxmf.send(message, identity, options);
224
122
  } catch (e) {
225
- log(
226
- `pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
227
- );
228
- // The destination's identity is unknown (typically: the startup
229
- // notification racing the owner's first announce after a restart —
230
- // `knownDestinations` is in-memory in reticulum-js, so every restart
231
- // forgets it). An immediate retry cannot succeed; solicit the peer
232
- // and give its announce time to land first.
233
- if (isUnknownIdentityError(e)) {
234
- const destHex = toHex(message.destinationHash);
123
+ // Direct and opportunistic both failed and the router fell through
124
+ // to its propagation handoff — but the node's announce has not
125
+ // landed yet (fresh start), so the submit declined instead of
126
+ // queueing. Solicit the node and resend; the message keeps its
127
+ // messageId across serialize() calls, so wire-level dedup still
128
+ // holds.
129
+ if (
130
+ propagationNodeHash &&
131
+ e instanceof Error &&
132
+ /Propagation node identity unknown/.test(e.message)
133
+ ) {
235
134
  log(
236
- `pi-lxmf: identity for ${destHex} unknown — requesting path, waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
237
- );
238
- const learned = await waitForPeerIdentity(
239
- lxmf.rns.transport,
240
- message.destinationHash,
241
- peerWaitMs,
242
- );
243
- log(
244
- learned
245
- ? `pi-lxmf: learned ${destHex} — retrying delivery`
246
- : `pi-lxmf: no announce from ${destHex} in ${Math.round(peerWaitMs / 1000)}s — retrying anyway`,
247
- );
248
- }
249
- try {
250
- await lxmf.send(message, identity, link);
251
- } catch (e2) {
252
- // The arrival link is likely gone (the peer closed it after its
253
- // message was acknowledged). Retry without it: `LXMRouter.send`
254
- // then establishes a fresh DIRECT link, falling back to an
255
- // opportunistic packet. Same message object → same message id, so
256
- // a deduplicating client renders the reply once.
257
- log(
258
- `pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
135
+ `pi-lxmf: propagation node ${propagationNodeHex} unknown — requesting path, ` +
136
+ `waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
259
137
  );
260
138
  try {
261
- await lxmf.send(message, identity, null);
262
- } catch (e3) {
263
- // Direct and opportunistic both failed: the owner is likely
264
- // off-mesh. Store-and-forward via the configured propagation
265
- // node instead of losing the reply (their next sync picks it
266
- // up); without a configured node the failure stands.
267
- if (!propagationNodeHash) throw e3;
268
- await submitViaPropagationNode(message, propagationNodeHash);
139
+ await lxmf.rns.transport.recallOrSolicitIdentity(
140
+ propagationNodeHash,
141
+ peerWaitMs,
142
+ );
143
+ } catch (solicitError) {
144
+ if (solicitError instanceof UnknownIdentityError) {
145
+ log(
146
+ `pi-lxmf: no announce from propagation node ${propagationNodeHex} in ${Math.round(peerWaitMs / 1000)}s — giving up`,
147
+ );
148
+ throw e;
149
+ }
150
+ throw solicitError;
269
151
  }
152
+ await lxmf.send(message, identity, options);
153
+ log(
154
+ "pi-lxmf: owner unreachable directly — submitted via propagation " +
155
+ "node (delivered on their next sync)",
156
+ );
157
+ return;
270
158
  }
159
+ throw e;
271
160
  }
272
161
  }
273
162
 
@@ -400,6 +289,9 @@ export async function startLxmf(config, options = {}) {
400
289
  storageAdapter: new FileStorageAdapter(storageDir),
401
290
  compressionProvider: bz2,
402
291
  });
292
+ // Persistor hydration and background services (interface discovery) must
293
+ // be settled before the identity is loaded and announces go out.
294
+ await rns.ready();
403
295
 
404
296
  /** @type {string[]} */
405
297
  const interfaceNames = [];
@@ -467,13 +359,13 @@ export async function startLxmf(config, options = {}) {
467
359
  });
468
360
  log(`pi-lxmf: announcing as "${config.name}"`);
469
361
 
470
- // Optional propagation-node integration: outbound submits go through the
471
- // node when neither a direct link nor opportunistic delivery can be
472
- // established, and a periodic sync pulls messages that arrived while
473
- // this daemon was down. (reticulum-js's `send` never consults the
474
- // outbound node on its own — `submitToPropagationNode` is an explicit
475
- // call — so the store-and-forward fallback in `sendWithRetry` below is
476
- // what makes the config effective.)
362
+ // Optional propagation-node integration: a periodic sync pulls messages
363
+ // that arrived while this daemon was down, and (since reticulum-js 0.9.3)
364
+ // the router's own send escalation submits outbound messages to the node
365
+ // as the last fallback when neither a direct link nor opportunistic
366
+ // delivery can be established — the `fallback: "propagation"` option set
367
+ // by `createRetrySender` below is what makes the config effective on the
368
+ // outbound side.
477
369
  const propagationNodeHash = config.propagationNode
478
370
  ? fromHex(config.propagationNode)
479
371
  : null;
@@ -513,15 +405,12 @@ export async function startLxmf(config, options = {}) {
513
405
 
514
406
  /**
515
407
  * Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
516
- * chunked to `chunkChars`, titled on the first chunk. A failed send is
517
- * retried once over the same path, then once more opportunistically
518
- * (without the link), and finally submitted to the configured
519
- * propagation node for store-and-forward — see {@link createRetrySender}
520
- * for the full escalation order. Battery-conscious mobile clients tear
521
- * their link down right after their message is acknowledged, so the
522
- * arrival link can be gone by reply time; the same `LXMessage` object is
523
- * re-sent so all wire copies share one message id and a deduplicating
524
- * client shows the reply once (learned in signalk-reticulum's deliverer).
408
+ * chunked to `chunkChars`, titled on the first chunk. Delivery escalates
409
+ * inside the router's `send`: DIRECT link → opportunistic packet (with
410
+ * recipient-identity solicitation) → propagation store-and-forward — see
411
+ * {@link createRetrySender} for how the options are set. All copies on
412
+ * the wire share one message id, so a deduplicating client shows the
413
+ * reply once.
525
414
  *
526
415
  * @param {string} destinationHex
527
416
  * @param {string} text
@@ -553,8 +442,7 @@ export async function startLxmf(config, options = {}) {
553
442
  * reaction field is rendered natively by Sideband/NomadNet (confirmed in
554
443
  * live testing); no `content` is set so no separate chat bubble is
555
444
  * produced alongside the reaction. Reuses `sendWithRetry` for the same
556
- * retry-once path as `sendText` (same message object across retries → one
557
- * message id).
445
+ * router-escalated delivery as `sendText` (one message id on the wire).
558
446
  *
559
447
  * @param {string} destinationHex
560
448
  * @param {Uint8Array} targetMessageId - The `message_id` of the message being reacted to.