pi-lxmf 0.1.2 β†’ 0.1.3

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
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3] - 2026-09-27
11
+
12
+ ### Changed
13
+
14
+ - The successful `/cd` reply is now a visually distinct banner (divider
15
+ line, πŸ“‚/πŸ”/✨ emoji) so project change boundaries are easy to spot when
16
+ scrolling back through the message history.
17
+
18
+ ### Fixed
19
+
20
+ - Startup notification lost to the announce race: reticulum-js keeps the
21
+ destination→identity mapping in memory, so right after a daemon restart
22
+ the owner's `lxmf.delivery` hash is unknown and the router fails the
23
+ "🟒 ready" send instantly (its path request only happens once the
24
+ identity is known). `sendWithRetry` now recognises that failure, sends a
25
+ path request (which solicits an announce from the peer or a node holding
26
+ its path) and waits up to 30s for the announce before retrying β€” instead
27
+ of burning two hopeless immediate retries and parking the text in the
28
+ next reply's delivery-failure note.
29
+ - Configured propagation node was never used for outbound: reticulum-js's
30
+ `lxmf.send` never consults the outbound propagation node on its own
31
+ (unlike Python's `LXMRouter`), so despite `setOutboundPropagationNode`
32
+ being called, replies to an off-mesh owner were simply lost. The retry
33
+ chain now escalates to `submitToPropagationNode` (store-and-forward,
34
+ delivered on the owner's next sync) after direct and opportunistic
35
+ delivery both fail β€” including waiting for the node's own announce on a
36
+ fresh start. The chain lives in the exported `createRetrySender`
37
+ (unit-tested against a fake router) instead of a closure inside
38
+ `startLxmf`.
39
+
10
40
  ## [0.1.2] - 2026-09-27
11
41
 
12
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lxmf",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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>",
package/src/bridge.js CHANGED
@@ -612,9 +612,16 @@ export class Bridge {
612
612
  /* switch reply still goes out; the next prompt re-observes */
613
613
  }
614
614
  const rel = relative(this.config.workdir, absPath) || ".";
615
- return pointer
616
- ? `Switched to ${rel}. Resuming session ${basename(pointer.sessionFile)}.`
617
- : `Switched to ${rel}. Fresh session.`;
615
+ // A visually loud banner: project changes are the main boundaries
616
+ // in the message history, so they must be easy to spot while
617
+ // scrolling back.
618
+ const lines = ["πŸ“‚ ───────────────────", `πŸ“‚ Switched to ${rel}`];
619
+ lines.push(
620
+ pointer
621
+ ? `πŸ” Resuming session ${basename(pointer.sessionFile)}`
622
+ : "✨ Fresh session",
623
+ );
624
+ return lines.join("\n");
618
625
  }
619
626
 
620
627
  /**
package/src/lxmf.js CHANGED
@@ -22,6 +22,244 @@ import {
22
22
  import { createBz2 } from "./bz2.js";
23
23
  import { chunkText } from "./text.js";
24
24
 
25
+ /**
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.
41
+ */
42
+ const PEER_DISCOVERY_WAIT_MS = 30_000;
43
+
44
+ /**
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.
48
+ *
49
+ * @param {unknown} e
50
+ * @returns {e is Error}
51
+ */
52
+ export function isUnknownIdentityError(e) {
53
+ return e instanceof Error && UNKNOWN_IDENTITY_MESSAGE.test(e.message);
54
+ }
55
+
56
+ /**
57
+ * Waits until `transport` can recall the identity for `destinationHash`,
58
+ * soliciting it first: a path request makes the destination itself (or any
59
+ * transport node holding its path) announce, and the ingested announce
60
+ * populates the destination→identity mapping. Resolves early once an
61
+ * announce for the exact destination arrives, `false` on timeout.
62
+ *
63
+ * Closes the restart gap the router leaves open: `_establishDirectLink`
64
+ * only requests-and-awaits a path once the identity is *known*, so an
65
+ * unknown identity fails the whole `send` without any mesh solicitation.
66
+ * reticulum-js keeps `knownDestinations` in memory, so every daemon restart
67
+ * re-enters that state until the owner's next announce.
68
+ *
69
+ * @param {any} transport - `rns.transport` (EventTarget with
70
+ * `recallIdentity`, `requestPath`; tolerates missing methods for test
71
+ * doubles).
72
+ * @param {Uint8Array} destinationHash
73
+ * @param {number} timeoutMs
74
+ * @returns {Promise<boolean>} `true` when the identity is recallable on return.
75
+ */
76
+ export async function waitForPeerIdentity(
77
+ transport,
78
+ destinationHash,
79
+ timeoutMs,
80
+ ) {
81
+ const destHex = toHex(destinationHash);
82
+ const recall = () =>
83
+ Promise.resolve()
84
+ .then(() => transport?.recallIdentity(destinationHash))
85
+ .catch(() => null);
86
+ if (await recall()) return true;
87
+ try {
88
+ await transport?.requestPath?.(destinationHash);
89
+ } catch {
90
+ /* best effort β€” a late announce still has the timeout window */
91
+ }
92
+ if (await recall()) return true;
93
+ return new Promise((resolve) => {
94
+ let settled = false;
95
+ /** @type {NodeJS.Timeout|null} */
96
+ let timer = null;
97
+ const finish = (/** @type {boolean} */ ok) => {
98
+ if (settled) return;
99
+ settled = true;
100
+ if (timer) clearTimeout(timer);
101
+ transport.removeEventListener("announce", onAnnounce);
102
+ resolve(ok);
103
+ };
104
+ // The transport dispatches "announce" only after `rememberIdentity`
105
+ // completed, so a matching event implies a recallable identity; the
106
+ // re-check is belt-and-braces against half-fakes in tests.
107
+ const onAnnounce = (/** @type {any} */ ev) => {
108
+ const announced = ev?.detail?.destinationHash;
109
+ if (!announced || toHex(announced) !== destHex) return;
110
+ void recall().then((identity) => finish(Boolean(identity)));
111
+ };
112
+ timer = setTimeout(() => finish(false), timeoutMs);
113
+ transport.addEventListener("announce", onAnnounce);
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Builds the outbound retry chain behind `sendText`/`sendReaction`:
119
+ *
120
+ * 1. `lxmf.send` over the given link (DIRECT; the router falls back to an
121
+ * opportunistic packet internally when no link can be established),
122
+ * 2. on the router's unknown-identity failure: solicit the destination
123
+ * (path request β†’ announce) and wait for its announce β€” the restart
124
+ * race, since reticulum-js keeps the destination→identity map in
125
+ * memory and an immediate retry cannot succeed,
126
+ * 3. retry over the same link, then once more without it (the arrival
127
+ * link is usually gone by reply time on battery-conscious clients),
128
+ * 4. store-and-forward via the configured propagation node β€” the owner is
129
+ * likely off-mesh entirely; their next sync picks the message up.
130
+ *
131
+ * The same `LXMessage` object flows through every attempt so all wire
132
+ * copies share one message id and a deduplicating client renders the
133
+ * reply once. Factored out of `startLxmf` with injected dependencies so
134
+ * the chain is testable against a fake router.
135
+ *
136
+ * @param {object} deps
137
+ * @param {LXMRouter} deps.lxmf - Initialised router.
138
+ * @param {Identity} deps.identity - The node's LXMF identity (signs sends).
139
+ * @param {string|null} [deps.propagationNodeHex] - Configured propagation
140
+ * node's `lxmf.propagation` hash; enables the store-and-forward fallback
141
+ * (reticulum-js's `send` never consults the outbound node on its own).
142
+ * @param {(msg: string) => void} [deps.log] - Diagnostic sink.
143
+ * @param {number} [deps.peerWaitMs] - Per-peer announce wait (overridable in tests).
144
+ * @returns {{sendWithRetry: (message: LXMessage, link?: any) => Promise<void>}}
145
+ */
146
+ export function createRetrySender({
147
+ lxmf,
148
+ identity,
149
+ propagationNodeHex = null,
150
+ log = () => {},
151
+ peerWaitMs = PEER_DISCOVERY_WAIT_MS,
152
+ }) {
153
+ const propagationNodeHash = propagationNodeHex
154
+ ? fromHex(propagationNodeHex)
155
+ : null;
156
+
157
+ /**
158
+ * Last-resort store-and-forward through the configured propagation
159
+ * node, reached from `sendWithRetry` after direct and opportunistic
160
+ * delivery both failed β€” typically the owner being off-mesh entirely
161
+ * (the mobile case). The propagated form is encrypted to the *recipient's*
162
+ * public key (`dest_hash β€– E(srcβ€–sigβ€–payload)`), so it needs their
163
+ * identity (by then known β€” the earlier sends failed on reachability,
164
+ * not identity) but **no live path**: the node holds the message until
165
+ * the owner's next sync. A node whose announce hasn't been heard yet
166
+ * (fresh start) is solicited and waited for like unknown recipients are.
167
+ *
168
+ * @param {LXMessage} message
169
+ * @param {Uint8Array} nodeHash - The configured node's `lxmf.propagation`
170
+ * hash (callers guarantee it is set).
171
+ */
172
+ async function submitViaPropagationNode(message, nodeHash) {
173
+ const describe = (/** @type {unknown} */ e) =>
174
+ e instanceof Error ? e.message : String(e);
175
+ const nodeHex = toHex(nodeHash);
176
+ try {
177
+ try {
178
+ await lxmf.submitToPropagationNode(message, identity);
179
+ } catch (e) {
180
+ if (!/Propagation node identity unknown/.test(describe(e))) throw e;
181
+ log(
182
+ `pi-lxmf: propagation node ${nodeHex} unknown β€” requesting path, ` +
183
+ `waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
184
+ );
185
+ const learned = await waitForPeerIdentity(
186
+ lxmf.rns.transport,
187
+ nodeHash,
188
+ peerWaitMs,
189
+ );
190
+ if (!learned) throw e;
191
+ await lxmf.submitToPropagationNode(message, identity);
192
+ }
193
+ log(
194
+ "pi-lxmf: owner unreachable directly β€” submitted via propagation " +
195
+ "node (delivered on their next sync)",
196
+ );
197
+ } catch (e) {
198
+ log(`pi-lxmf: propagation submit failed (${describe(e)})`);
199
+ throw e;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {LXMessage} message
205
+ * @param {any} [link]
206
+ */
207
+ async function sendWithRetry(message, link) {
208
+ try {
209
+ await lxmf.send(message, identity, link);
210
+ } catch (e) {
211
+ log(
212
+ `pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
213
+ );
214
+ // The destination's identity is unknown (typically: the startup
215
+ // notification racing the owner's first announce after a restart β€”
216
+ // `knownDestinations` is in-memory in reticulum-js, so every restart
217
+ // forgets it). An immediate retry cannot succeed; solicit the peer
218
+ // and give its announce time to land first.
219
+ if (isUnknownIdentityError(e)) {
220
+ const destHex = toHex(message.destinationHash);
221
+ log(
222
+ `pi-lxmf: identity for ${destHex} unknown β€” requesting path, waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
223
+ );
224
+ const learned = await waitForPeerIdentity(
225
+ lxmf.rns.transport,
226
+ message.destinationHash,
227
+ peerWaitMs,
228
+ );
229
+ log(
230
+ learned
231
+ ? `pi-lxmf: learned ${destHex} β€” retrying delivery`
232
+ : `pi-lxmf: no announce from ${destHex} in ${Math.round(peerWaitMs / 1000)}s β€” retrying anyway`,
233
+ );
234
+ }
235
+ try {
236
+ await lxmf.send(message, identity, link);
237
+ } catch (e2) {
238
+ // The arrival link is likely gone (the peer closed it after its
239
+ // message was acknowledged). Retry without it: `LXMRouter.send`
240
+ // then establishes a fresh DIRECT link, falling back to an
241
+ // opportunistic packet. Same message object β†’ same message id, so
242
+ // a deduplicating client renders the reply once.
243
+ log(
244
+ `pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
245
+ );
246
+ try {
247
+ await lxmf.send(message, identity, null);
248
+ } catch (e3) {
249
+ // Direct and opportunistic both failed: the owner is likely
250
+ // off-mesh. Store-and-forward via the configured propagation
251
+ // node instead of losing the reply (their next sync picks it
252
+ // up); without a configured node the failure stands.
253
+ if (!propagationNodeHash) throw e3;
254
+ await submitViaPropagationNode(message, propagationNodeHash);
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ return { sendWithRetry };
261
+ }
262
+
25
263
  /**
26
264
  * Attaches diagnostic logging to the inbound LXMF choke points that the
27
265
  * bridge itself can't see: packets that decrypt but never dispatch.
@@ -216,10 +454,17 @@ export async function startLxmf(config, options = {}) {
216
454
  log(`pi-lxmf: announcing as "${config.name}"`);
217
455
 
218
456
  // Optional propagation-node integration: outbound submits go through the
219
- // node when a direct link cannot be established, and a periodic sync
220
- // pulls messages that arrived while this daemon was down.
221
- if (config.propagationNode) {
222
- lxmf.setOutboundPropagationNode(fromHex(config.propagationNode));
457
+ // node when neither a direct link nor opportunistic delivery can be
458
+ // established, and a periodic sync pulls messages that arrived while
459
+ // this daemon was down. (reticulum-js's `send` never consults the
460
+ // outbound node on its own β€” `submitToPropagationNode` is an explicit
461
+ // call β€” so the store-and-forward fallback in `sendWithRetry` below is
462
+ // what makes the config effective.)
463
+ const propagationNodeHash = config.propagationNode
464
+ ? fromHex(config.propagationNode)
465
+ : null;
466
+ if (propagationNodeHash) {
467
+ lxmf.setOutboundPropagationNode(propagationNodeHash);
223
468
  log(`pi-lxmf: outbound propagation node ${config.propagationNode}`);
224
469
  }
225
470
  /** @type {NodeJS.Timeout|null} */
@@ -243,15 +488,26 @@ export async function startLxmf(config, options = {}) {
243
488
  log(`pi-lxmf: propagation sync every ${config.syncIntervalSec}s`);
244
489
  }
245
490
 
491
+ // The outbound retry chain shared by sendText/sendReaction (see
492
+ // createRetrySender for the escalation order).
493
+ const { sendWithRetry } = createRetrySender({
494
+ lxmf,
495
+ identity,
496
+ propagationNodeHex: config.propagationNode ?? null,
497
+ log,
498
+ });
499
+
246
500
  /**
247
501
  * Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
248
502
  * chunked to `chunkChars`, titled on the first chunk. A failed send is
249
503
  * retried once over the same path, then once more opportunistically
250
- * (without the link) β€” battery-conscious mobile clients tear their link
251
- * down right after their message is acknowledged, so the arrival link can
252
- * be gone by reply time; the same `LXMessage` object is re-sent so both
253
- * wire copies share one message id and a deduplicating client shows the
254
- * reply once (learned in signalk-reticulum's deliverer).
504
+ * (without the link), and finally submitted to the configured
505
+ * propagation node for store-and-forward β€” see {@link createRetrySender}
506
+ * for the full escalation order. Battery-conscious mobile clients tear
507
+ * their link down right after their message is acknowledged, so the
508
+ * arrival link can be gone by reply time; the same `LXMessage` object is
509
+ * re-sent so all wire copies share one message id and a deduplicating
510
+ * client shows the reply once (learned in signalk-reticulum's deliverer).
255
511
  *
256
512
  * @param {string} destinationHex
257
513
  * @param {string} text
@@ -312,33 +568,6 @@ export async function startLxmf(config, options = {}) {
312
568
  await sendWithRetry(message, sendOptions.link);
313
569
  }
314
570
 
315
- /**
316
- * @param {LXMessage} message
317
- * @param {any} [link]
318
- */
319
- async function sendWithRetry(message, link) {
320
- try {
321
- await lxmf.send(message, identity, link);
322
- } catch (e) {
323
- log(
324
- `pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
325
- );
326
- try {
327
- await lxmf.send(message, identity, link);
328
- } catch (e2) {
329
- // The arrival link is likely gone (the peer closed it after its
330
- // message was acknowledged). Retry without it: `LXMRouter.send`
331
- // then establishes a fresh DIRECT link, falling back to an
332
- // opportunistic packet. Same message object β†’ same message id, so
333
- // a deduplicating client renders the reply once.
334
- log(
335
- `pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
336
- );
337
- await lxmf.send(message, identity, null);
338
- }
339
- }
340
- }
341
-
342
571
  /**
343
572
  * Verifies the signature of an inbound `message` against the sender's
344
573
  * recalled identity. The router verifies signatures on the direct-delivery