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.
- package/CHANGELOG.md +10 -0
- package/package.json +4 -4
- 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.
|
|
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.
|
|
37
|
-
"@reticulum/lxmf": "^0.9.
|
|
38
|
-
"@reticulum/node": "^0.9.
|
|
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 {
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
49
|
+
* @returns {e is UnknownIdentityError}
|
|
51
50
|
*/
|
|
52
51
|
export function isUnknownIdentityError(e) {
|
|
53
|
-
return e instanceof
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
* (
|
|
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,
|
|
121
|
+
await lxmf.send(message, identity, options);
|
|
224
122
|
} catch (e) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
)
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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:
|
|
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.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
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.
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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
|
-
*
|
|
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.
|