pi-lxmf 0.1.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/SPEC.md ADDED
@@ -0,0 +1,469 @@
1
+ # pi-lxmf — Driving Pi over LXMF: Implementation Specification
2
+
3
+ ## 1. Overview & objectives
4
+
5
+ `pi-lxmf` is a bridge that lets a human drive a [Pi](https://pi.dev) coding
6
+ agent running on a headless server entirely from an LXMF messaging client
7
+ (Sideband, Nomad Network, or any `@reticulum/lxmf` peer) over the Reticulum
8
+ Network Stack.
9
+
10
+ The design goal is the experience of
11
+ [pi-msg](https://github.com/zachpmanson/pi-msg) (which bridges Pi to XMPP):
12
+ chat messages become prompts, finished assistant replies are delivered back
13
+ as chat messages, and the usual Pi controls (`/new`, `/compact`, `/abort`,
14
+ model/thinking switches, session stats) work over chat without anyone at a
15
+ terminal. The LXMF side is built on [reticulum-js](https://reticulum.js.org/)
16
+ (`@reticulum/lxmf`), wire-compatible with the Python LXMF reference, so any
17
+ existing LXMF client can drive the agent with no server-side changes beyond
18
+ running one daemon.
19
+
20
+ Objectives, in priority order:
21
+
22
+ 1. **Single owner, full control.** One configured owner — identified by
23
+ their Reticulum identity hash — drives the agent; everyone else is
24
+ ignored. Plain text is a prompt,
25
+ slash commands map to Pi controls, and replies always reach the owner.
26
+ 2. **Robust asynchronous operation.** LXMF is store-and-forward and mesh
27
+ transport is slow and lossy; the bridge never assumes a fast round trip.
28
+ Long replies are chunked, runs may take many minutes, and a restart
29
+ resumes the previous Pi session.
30
+ 3. **No terminal required.** Nothing in the operation of the bridge assumes
31
+ a TUI. Extension dialogs raised inside Pi are auto-declined and reported
32
+ over LXMF.
33
+ 4. **Plain Node.js, small surface.** ESM JavaScript with JSDoc types,
34
+ `node --test` tests, no build step — matching the conventions of
35
+ `pi-rngit-work-document-skill` and the reticulum-js packages.
36
+
37
+ Non-goals for the initial version: group conversations (LXMF has no MUC),
38
+ file attachments in either direction, streaming/preview of in-flight turns,
39
+ and acting as an LXMF propagation node. These are covered in §13.
40
+
41
+ ## 2. Prior art and reference material
42
+
43
+ | Reference | What is taken from it |
44
+ |---|---|
45
+ | [pi-msg](https://github.com/zachpmanson/pi-msg) | Overall architecture: a bridge daemon spawns `pi --mode rpc` and translates between chat and Pi's JSONL RPC protocol. Command surface (`/new`, `/abort` vs `!`, `/model`, `/session`, …), session persistence across restarts, empty-tail reply recovery, auto-dismissing extension dialogs. |
46
+ | [@llblab/pi-telegram](https://pi.dev/packages/@llblab/pi-telegram) | The JS/Pi-package conventions studied for this spec: config under the agent/home directory, queue-instead-of-interrupt handling of messages that arrive mid-run. (Its first-contact pairing was deliberately *not* adopted — see §6.1.) |
47
+ | `reticulum-js` (`@reticulum/core`, `@reticulum/lxmf`, `@reticulum/node`) | The entire LXMF transport: `LXMRouter`, `LXMessage`, interface setup (shared instance → AutoInterface → TCP fallback), announce app-data conventions, identity persistence. See `../reticulum-js/examples/lxmf_echobot.js` and `lxmf_sender.js` for the canonical wiring. |
48
+ | `pi-rngit-work-document-skill` | Project conventions: ESM + JSDoc, Biome with `--use-editorconfig=true`, `node --test`, EUPL-1.2, `@reticulum/*` dependency set, `createBz2()` adapter for Resource compression. |
49
+ | Pi RPC protocol (`docs/rpc.md` of `@earendil-works/pi-coding-agent`) | The exact command/event vocabulary used below. |
50
+
51
+ ## 3. Architecture
52
+
53
+ ```text
54
+ ┌──────────────┐ LXMF (signed, encrypted, ┌─────────────────────────────┐
55
+ │ Owner's │ store-and-forward over │ pi-lxmf bridge daemon │
56
+ │ LXMF client │ the Reticulum mesh) │ (Node.js, this package) │
57
+ │ (Sideband / │ ◄─────────────────────────► │ │
58
+ │ NomadNet) │ │ LXMRouter ◄──┐ │
59
+ └──────────────┘ │ │ spawn/JSONL│
60
+ │ PiRpcClient ──┴── pi --mode rpc
61
+ └─────────────────────────────┘
62
+ ```
63
+
64
+ Two processes, deliberately:
65
+
66
+ - **The bridge daemon** (`pi-lxmf` bin) owns everything long-lived and
67
+ precious: the Reticulum identity (the node's LXMF address), the mesh
68
+ interfaces, the announce loop, and the session pointer.
69
+ It must survive Pi crashes and Pi upgrades without changing identity.
70
+ - **`pi --mode rpc`** is a child process, disposable and restartable. All
71
+ agent control flows through its documented RPC protocol (JSON commands on
72
+ stdin, JSONL events on stdout), so the bridge depends only on Pi's stable
73
+ RPC surface — not on extension-internal APIs.
74
+
75
+ ### 3.1 Why not an in-process Pi extension
76
+
77
+ An earlier pi-msg revision ran the bridge as an in-process extension and was
78
+ rebuilt on RPC mode because `sendUserMessage` cannot reach Pi's command
79
+ layer; the same trade-off applies here. Additionally:
80
+
81
+ - RPC mode exits when its stdin closes, so a long-running headless
82
+ deployment needs a supervisor holding the pipes anyway — the daemon *is*
83
+ that supervisor, and putting the bridge logic there removes the
84
+ indirection.
85
+ - Session lifecycle commands (`new_session`, `compact`, `set_model`,
86
+ `export_html`, …) are first-class RPC commands, keeping the bridge out of
87
+ the extension API churn.
88
+ - A Pi crash must not take the mesh identity down with it; the daemon
89
+ restarts Pi and resumes the session file.
90
+
91
+ A small companion Pi extension remains a future option for agent-side
92
+ *tools* (e.g. proactive file sends, §13); it is not needed to drive the
93
+ agent.
94
+
95
+ ## 4. Process management (PiRpcClient)
96
+
97
+ `src/rpc.js` implements `PiRpcClient`, a spawn-and-talk client for
98
+ `pi --mode rpc`:
99
+
100
+ - **Spawn:** `pi --mode rpc` with `cwd` = configured `workdir`, plus
101
+ `--model <pattern>` when configured and `--session <file>` when a saved
102
+ session pointer exists and the file is non-empty. The daemon's
103
+ environment is inherited.
104
+ - **Framing:** Pi's RPC protocol is strict JSONL with `\n` as the only
105
+ record delimiter. Node's `readline` is **not** protocol-compliant (it
106
+ also splits on U+2028/U+2029, which are valid inside JSON strings), so
107
+ `rpc.js` implements its own line reader: a `StringDecoder`-backed buffer
108
+ split on `\n`, tolerating a trailing `\r`. The same reader is reused for
109
+ stderr logging.
110
+ - **Correlation:** every command is sent with a generated `id`; `response`
111
+ events are matched by `id` and resolve a pending promise (with timeout).
112
+ Non-response events are forwarded to the bridge through an `onEvent`
113
+ callback. Lines are bounded (8 MiB) since assistant/tool payloads can be
114
+ large.
115
+ - **Readiness:** RPC mode accepts commands once its stdin reader is
116
+ attached; the daemon probes with `get_state` (retries with backoff,
117
+ ~10 s budget) before considering Pi ready.
118
+ - **Streaming state:** the daemon tracks `agent_start` / `agent_settled`
119
+ (plus `compaction_*`, `auto_retry_*`) to maintain a *busy* hint, but
120
+ treats `get_state` → `isStreaming` as authoritative before every prompt
121
+ (see §6.2).
122
+ - **Restart:** if Pi exits unintentionally (not a `/quit`), the daemon
123
+ respawns it after a short backoff, re-applies `--session` from the last
124
+ persisted pointer, and notifies the owner over LXMF. Repeated crashes
125
+ (e.g. 3 within a minute) stop the respawn loop and report to the owner.
126
+ - **Shutdown:** SIGINT/SIGTERM or `/quit` → best-effort `get_state` to
127
+ persist the session pointer, SIGINT to Pi (hard kill after 3 s), stop
128
+ announcing, exit.
129
+
130
+ ## 5. LXMF subsystem
131
+
132
+ `src/lxmf.js` owns the mesh side, wired like the reticulum-js `lxmf_echobot`
133
+ example:
134
+
135
+ 1. **Storage & identity.** A `FileStorageAdapter` rooted at
136
+ `<dataDir>/storage` persists the Ed25519 identity (and known
137
+ destinations/ratchets) across restarts. `Identity.loadOrGenerate` loads
138
+ or creates it. This identity *is* the node's stable LXMF address.
139
+ 2. **Compression.** A `createBz2()` adapter (bzip2-wasm, as in
140
+ `pi-rngit-work-document-skill`) is installed as the Reticulum
141
+ `compressionProvider` so oversized replies transferred as §10 Resources
142
+ are compressed.
143
+ 3. **Interfaces.** Prefer `LocalClientInterface.connectToSharedInstance()`
144
+ (attach to a running `rnsd`, which owns the real mesh interfaces). When
145
+ no shared instance is reachable, fall back to `AutoInterface`, plus a
146
+ `TCPClientInterface` when `rnsHost`/`rnsPort` are configured.
147
+ 4. **Router.** `new LXMRouter(identity, rns)` + `await lxmf.init()`
148
+ registers the `lxmf.delivery` destination (with forward-secrecy
149
+ ratchets, per the router's own init).
150
+ 5. **Announcing.** `startAnnouncing(name)` with `name` from config
151
+ (default `pi-lxmf <version>`) fires an immediate announce and re-announces
152
+ on the default cadence so cached mesh paths stay fresh; the §4.3
153
+ msgpack app-data makes Sideband display the name.
154
+ 6. **Receiving.** `message` events carry a signature-verified `LXMessage`
155
+ (`detail.message`) and the link id (`detail.link`). The router already
156
+ deduplicates by LXMF message id within a process lifetime. Note: the
157
+ router only verifies signatures on the direct-delivery path — a message
158
+ pulled in via propagation sync (step 8) whose sender identity is not yet
159
+ recalled is dispatched unverified, so the bridge re-verifies every
160
+ inbound owner message itself (see §11).
161
+ 7. **Sending.** Replies are `LXMessage`s from our `deliveryDest` to the
162
+ owner's LXMF address (their `lxmf.delivery` destination hash), sent with
163
+ `lxmf.send(reply, identity, link)` — reusing the inbound link when one
164
+ exists. The router handles direct-link
165
+ delivery (Resources for bodies over the link MDU) and falls back to
166
+ opportunistic delivery when no link can be established.
167
+ 8. **Propagation (optional).** When `propagationNode` is configured (an
168
+ `lxmf.propagation` destination hash), it is set as the outbound node
169
+ (`setOutboundPropagationNode`) and, when `syncIntervalSec` > 0, the
170
+ daemon periodically calls `syncFromPropagationNode(identity)` so
171
+ messages that arrived while the daemon was down are still delivered.
172
+
173
+ ### 5.1 Outbound message shape
174
+
175
+ - **Title:** the current session display name (from `get_state`), else the
176
+ configured announce name — only on the first chunk of a reply.
177
+ - **Chunking:** content longer than `chunkChars` (default 2500) is split on
178
+ paragraph boundaries where possible, each chunk suffixed
179
+ `[… n/N]` except the last. Chunking keeps single LXMF messages
180
+ reasonable for phone UIs and for mesh airtime.
181
+ - **Errors:** failures to deliver a reply are logged and retried once;
182
+ persistent failure is reported in the next successful message (LXMF has
183
+ no channel over which to report its own failure).
184
+
185
+ ## 6. Bridge semantics
186
+
187
+ `src/bridge.js` connects the two subsystems. All inbound LXMF handling is
188
+ serialized through a single promise chain so prompts keep their order.
189
+
190
+ ### 6.1 Owner model
191
+
192
+ - The controlling owner is **configured, never learned**: the required
193
+ `owner` config field holds their **Reticulum identity hash** (32 hex) —
194
+ the protocol-agnostic identifier of their Ed25519 identity, not the
195
+ `lxmf.delivery` destination hash ("LXMF Address") the wire carries. The
196
+ identity → destination-hash derivation (`src/identity.js`, proven against
197
+ `Destination.IN` in tests) expands it to the wire form, and inbound
198
+ messages whose LXMF source hash differs are dropped (logged at debug). No
199
+ reply is sent to non-owners — the bridge must not acknowledge its existence
200
+ to strangers.
201
+ - Rationale: one identity can host many destinations (`lxmf.delivery`,
202
+ `nomadnetwork.node`, propagation nodes, …), so identity-keyed access is
203
+ stable across protocols and maps directly onto future DACAR-based
204
+ permission management (§13) — grants are made to identities. The same
205
+ lesson was learned in `../signalk-reticulum`, which migrated crew entries
206
+ from destination hashes to identity hashes.
207
+ - There is deliberately **no first-contact pairing**: a stray message can
208
+ never seize control.
209
+
210
+ ### 6.2 Inbound pipeline
211
+
212
+ For each accepted message:
213
+
214
+ 1. Strip whitespace; ignore empty content.
215
+ 2. If the text is a **bridge command** (§7), execute it locally — these
216
+ never reach the LLM.
217
+ 3. Otherwise the text is a **prompt**: query `get_state` for authoritative
218
+ `isStreaming`, then send a `prompt` RPC command (opening the reply
219
+ exchange optimistically, before the write):
220
+ - idle → `{"type":"prompt","message":…}` (no `streamingBehavior`);
221
+ - streaming → `streamingBehavior` from `midRunBehavior` config
222
+ (`"steer"` default, matching pi-msg: mid-run chat messages are
223
+ injected at the next yield point; `"followUp"` queues them instead).
224
+ - If Pi rejects the prompt because a run started in between, retry once
225
+ with `streamingBehavior` set; a final rejection closes the exchange
226
+ again and is reported to the owner.
227
+ - Slash-prefixed text that is *not* a bridge command is passed through
228
+ unchanged: Pi's `prompt` dispatches extension commands immediately
229
+ (even mid-run) and expands `/skill:…` and prompt templates itself.
230
+ Unknown `/…` text simply becomes a prompt, as in pi-msg.
231
+ 4. Increment the *pending reply* counter and, on success, remember the
232
+ message's link id as the preferred reply channel.
233
+
234
+ ### 6.3 Reply delivery
235
+
236
+ - `message_end` events with `role === "assistant"` are delivered as their
237
+ own LXMF message whenever an LXMF-triggered exchange is active — pi-msg's
238
+ model. Delivering each finalized message (rather than only the last one at
239
+ settle time) means intermediate replies are never lost when several owner
240
+ messages were queued mid-run; commentary blocks that precede tool calls
241
+ also give the owner progress visibility on a channel where nothing else
242
+ would. An exchange is opened **optimistically at prompt-write time**: pi can
243
+ emit the acceptance response and the run's events in the same stdout
244
+ chunk, and the response promise only resolves on a later microtask, so
245
+ events handled in between must already count towards the exchange (a race
246
+ proven by the smoketest's fake pi, which answers in a single chunk).
247
+ - On `agent_settled` the exchange closes; if nothing at all was delivered
248
+ for it:
249
+ - a recorded run failure (`auto_retry_end`/`compaction_end` error) is
250
+ reported as `⚠️ run failed: …`;
251
+ - otherwise **empty-tail recovery** runs once per exchange: a `prompt`
252
+ asking the agent to write the reply it never wrote (a run that ends on a
253
+ tool call produced no text; pi-msg's "empty-tail recovery"), opened with
254
+ the same optimistic exchange semantics; if that recovery run *also*
255
+ settles without text, the `✅ done (no reply) — your turn` nudge is sent
256
+ instead of looping.
257
+ - Retries, compaction, and queued follow-ups all precede `agent_settled`,
258
+ so the no-reply nudge only fires after Pi truly stops.
259
+ - Runs not triggered by LXMF (there is no local user in the intended
260
+ deployment, but scheduled extensions could start runs) do not open an
261
+ exchange; their output is not mirrored to the owner in v1.
262
+
263
+ ### 6.4 Extension UI requests
264
+
265
+ `extension_ui_request` events (`select`/`confirm`/`input`/`editor`) are
266
+ answered with `{"type":"extension_ui_response","id":…,"cancelled":true}`
267
+ (nobody is at a TUI; approval-gated tools are declined over the bridge),
268
+ and the owner is informed: `⛔ dialog dismissed: <title>`. Fire-and-forget
269
+ UI methods (`notify`, `setStatus`, `setWidget`, …) are ignored.
270
+
271
+ ### 6.5 z.ai GLM quota watcher and peak-hours warning
272
+
273
+ When the active model is a z.ai GLM model (`provider === "zai"`), the bridge
274
+ runs a `GlmQuotaWatcher` (`src/quota.js`) that does two things, both gated
275
+ on the active model being GLM — nothing fires for non-z.ai providers (e.g.
276
+ Cortecs, Anthropic):
277
+
278
+ - **Quota-recovery notification.** When a run fails with a z.ai
279
+ quota-exhausted error (matched from `auto_retry_end`/`compaction_end`
280
+ error messages), the watcher polls the same z.ai quota endpoint
281
+ `pi-glm-usage` uses (`https://api.z.ai/api/monitor/usage/quota/limit`,
282
+ Bearer `~/.pi/agent/auth.json` → `zai.key`, honouring `PI_AUTH_DIR`) every
283
+ 60s and delivers **exactly one** LXMF message to the owner the moment
284
+ the 5h bucket drops below 100%. One notification per exhausted episode;
285
+ rate-limit-only errors (transient, retried by Pi) do not arm it. A
286
+ missing `zai.key` disables the watcher gracefully (logged once).
287
+ - **Peak-hours warning.** z.ai charges 3× tokens Mon–Fri 14:00–18:00
288
+ Singapore Standard Time (UTC+8). The owner is warned when an
289
+ owner-triggered run starts inside that window, and when the window
290
+ opens mid-run, so they can decide whether to stop or continue. The
291
+ internal empty-reply recovery run is never warned.
292
+
293
+ Notifications and warnings go to the configured `owner`; under future
294
+ DACAR per-subtree identity ACLs (§13) the "who to notify" decision stays
295
+ in one place so it can be retargeted per subtree.
296
+
297
+ ## 7. Chat command reference
298
+
299
+ | Owner sends | Action | LLM turn |
300
+ |---|---|---|
301
+ | plain text | `prompt` (steered/queued mid-run per config) | yes |
302
+ | `/help` | list bridge commands and available Pi commands (`get_commands`) | no |
303
+ | `/status` | model, thinking level, busy state, session name/file, bridge uptime, node identity hashes | no |
304
+ | `/session` | `get_session_stats` — message counts, tokens, cost, context usage | no |
305
+ | `/new` | `new_session`, persist the new session pointer | no |
306
+ | `/name [name]` | `set_session_name`, or show current name | no |
307
+ | `/compact [instructions]` | `compact` (custom instructions appended) | summarizer only |
308
+ | `/model [query]` | no arg: list models (`get_available_models`, current marked); with arg: fuzzy-match `provider/id` or name, then `set_model` | no |
309
+ | `/think <level>` | `set_thinking_level` | no |
310
+ | `/abort` (or `/stop`) | `clear_queue` (tolerated if unknown) then `abort`; reports how many queued messages were dropped | no |
311
+ | `!` (bare) | `abort` only — quick interrupt, queue intact | no |
312
+ | `/quit` | graceful bridge shutdown (Pi and daemon) | no |
313
+ | any other `/…` | passed to Pi `prompt` (extension commands, `/skill:…`, templates) | maybe |
314
+
315
+ Command parsing: first whitespace-separated token, case-insensitive,
316
+ leading `!` equivalent to `/` for bridge commands (a bare `!` is the
317
+ interrupt). Commands are recognized only from the owner.
318
+
319
+ ## 8. Configuration and state
320
+
321
+ **Config file** — first of: `--config <path>` flag, `$PI_LXMF_CONFIG`,
322
+ `$XDG_CONFIG_HOME/pi-lxmf/config.json`, `~/.config/pi-lxmf/config.json`.
323
+ JSON, `0600`, unknown keys rejected with a warning.
324
+
325
+ | Field | Type | Default | Meaning |
326
+ |---|---|---|---|
327
+ | `owner` | string | *(required)* | the owner's 32-hex **Reticulum identity hash** (not the LXMF address); expanded to the `lxmf.delivery` destination hash for wire comparison |
328
+ | `name` | string | `pi-lxmf <version>` | announce display name |
329
+ | `workdir` | string | daemon cwd | project directory Pi runs in (also where Pi discovers `AGENTS.md`) |
330
+ | `model` | string | Pi default | `--model` pattern passed to Pi |
331
+ | `piBin` | string | `pi` | Pi binary |
332
+ | `dataDir` | string | `$XDG_DATA_HOME/pi-lxmf` (`~/.local/share/pi-lxmf`) | state root (see below) |
333
+ | `rnsHost` / `rnsPort` | string / number | — | rnsd TCP interface, used only when no shared instance is found |
334
+ | `propagationNode` | string | — | `lxmf.propagation` hash for outbound submits + optional sync |
335
+ | `syncIntervalSec` | number | `0` (off) | propagation sync cadence |
336
+ | `midRunBehavior` | `"steer"` \| `"followUp"` | `"steer"` | `streamingBehavior` for prompts arriving mid-run |
337
+ | `chunkChars` | number | `2500` | max characters per outbound LXMF message |
338
+ | `announceIntervalSec` | number | router default | re-announce cadence |
339
+
340
+ **State files** under `dataDir` (machine-managed, never hand-edited):
341
+
342
+ - `storage/` — Reticulum persistence (identity, known destinations,
343
+ ratchets) via `FileStorageAdapter`.
344
+ - `sessions/<key>.json` — per-workdir Pi session pointers
345
+ (`{ "sessionFile": … }`), keyed by the first 16 hex chars of
346
+ `SHA-256(workdir)` so distinct repos keep distinct sessions (the session
347
+ is the conversation; switching models mid-session keeps the same pointer).
348
+ Written on `new_session`, on graceful shutdown, and whenever
349
+ `get_state` observes a change. On daemon start the pointer for the
350
+ current `workdir` is passed as `--session` if it still exists (a
351
+ missing/empty pointer starts a fresh session). One-time migration: a
352
+ pre-scoping legacy `session` file is adopted for the first workdir that
353
+ reads it, then removed, so the adoption runs exactly once.
354
+
355
+ **Operational note:** Pi's project trust is not prompted for over LXMF.
356
+ Operators run Pi interactively once in `workdir` (or preconfigure trust) so
357
+ project-local `.pi` resources load in RPC mode.
358
+
359
+ ## 9. Package layout
360
+
361
+ ```text
362
+ pi-lxmf/
363
+ ├── package.json # name pi-lxmf, type module, bin { "pi-lxmf": "src/bin.js" }
364
+ ├── CHANGELOG.md # Keep a Changelog format, Unreleased segment
365
+ ├── src/
366
+ │ ├── bin.js # CLI: flags (--config, --version), config load, daemon loop, signals
367
+ │ ├── config.js # load/merge/validate config; XDG paths; session-pointer persistence
368
+ │ ├── identity.js # identity-hash → destination-hash derivation (DACAR-ready owner keying)
369
+ │ ├── rpc.js # PiRpcClient: spawn, JSONL framing, id correlation, typed command helpers, restart
370
+ │ ├── lxmf.js # mesh side: storage, identity, interfaces, router, announce, send (chunking), sync
371
+ │ ├── bridge.js # inbound pipeline, command dispatch, reply delivery, empty-tail recovery, dialogs
372
+ │ ├── commands.js # command table: parse + implementations as pure-ish functions over (rpc, ctx)
373
+ │ ├── text.js # chunking + formatting helpers (dependency-free)
374
+ │ └── bz2.js # bzip2-wasm adapter (as in pi-rngit-work-document-skill)
375
+ ├── scripts/
376
+ │ ├── fake-pi.mjs # minimal `pi --mode rpc` stand-in for the smoketest
377
+ │ └── smoke.mjs # end-to-end smoketest against a local rnsd (below)
378
+ └── test/ # node --test, see §12
379
+ ```
380
+
381
+ Dependencies (runtime): `@reticulum/core`, `@reticulum/lxmf`,
382
+ `@reticulum/node` (all `^0.9.0`), `@digitaldefiance/bzip2-wasm` — the same
383
+ set `pi-rngit-work-document-skill` uses. Node ≥ 20. License EUPL-1.2.
384
+ `rngit: rns://adafb3153efd4d96d532568a5208b3b5/reticulum/pi-lxmf` once the
385
+ repository exists on the node.
386
+
387
+ ## 10. Logging and diagnostics
388
+
389
+ - Daemon logs to stderr: startup banner (identity hash, delivery
390
+ destination hash, interfaces, workdir, paired owner), Pi lifecycle
391
+ events, LXMF send/receive summaries (hashes and sizes, never full
392
+ bodies), errors.
393
+ - `PI_LXMF_DEBUG=1` enables Pi's stderr forwarding and RPC event tracing.
394
+ - The startup banner doubles as the setup script: it prints the exact
395
+ LXMF address the owner should configure/expect.
396
+
397
+ ## 11. Security model
398
+
399
+ - **Owner-only control, configured not learned.** Exactly one Reticulum
400
+ identity (configured by identity hash; see §6.1) can drive the agent.
401
+ The bridge signature-verifies every inbound owner message itself before
402
+ processing (`mesh.verifySender`, recalling the sender identity and
403
+ checking the LXMF signature). This is necessary because the router only
404
+ verifies signatures on the direct-delivery path: a message pulled in via
405
+ propagation-node sync whose sender identity is not yet recalled is
406
+ dispatched without verification, and the owner source-hash check alone is
407
+ forgeable on that path (a 16-byte hash, no private key needed). An
408
+ unverified message — including one whose sender identity is unknown
409
+ ("parked") — is dropped; a re-sync after the owner's announce lands will
410
+ re-deliver it. There is no first-contact pairing: a stray message can
411
+ never seize control, and the daemon never answers strangers. Identity-
412
+ keyed access is ready for delegation to DACAR-based permission management
413
+ (§13) without reconfiguration.
414
+ - **No shell surface.** The bridge never executes chat text locally; only
415
+ Pi's RPC commands are used. The agent's own tool use is governed by Pi's
416
+ normal permissions, not relaxed by the bridge. Declined dialogs mean
417
+ approval-gated tools stay gated.
418
+ - **Key hygiene.** The Reticulum identity key is the root of the node's
419
+ identity; `dataDir` must be backed up and treated as secret
420
+ (`FileStorageAdapter` writes `0600`).
421
+ - **Unauthenticated replies are impossible** (outbound messages are signed
422
+ by our identity); non-owner inbound is dropped silently.
423
+
424
+ ## 12. Testing strategy
425
+
426
+ `node --test`, no network, no mesh:
427
+
428
+ - `config.test.js` — defaults, XDG env overrides, merge precedence,
429
+ validation errors, and session-pointer persistence round-trips.
430
+ - `rpc.test.js` — the JSONL reader (chunked multi-byte UTF-8, `\r\n`
431
+ tolerance, U+2028 inside strings must *not* split a record), and
432
+ request/response correlation against a fake child stream.
433
+ - `identity.test.js` — the identity → destination-hash derivation,
434
+ cross-validated against @reticulum/core's `Destination.IN` (so a configured
435
+ identity hash really expands to the wire form the router compares).
436
+ - `commands.test.js` — command parsing (`/x`, `!`, bare `!`, case,
437
+ args), model fuzzy matching over a fixture model list, command routing
438
+ (bridge command vs passthrough prompt).
439
+ - `bridge.test.js` — smoketests of the full loop with a fake `PiRpcClient`
440
+ and a fake LXMF sender: prompt→reply delivery, mid-run steering choice,
441
+ empty-tail recovery then nudge, `/new` + session pointer update,
442
+ dialog auto-dismiss, non-owner drop (by derived destination hash),
443
+ chunking.
444
+
445
+ Mesh-level behaviour is exercised end-to-end by `scripts/smoke.mjs` (`npm
446
+ run smoke`), which needs a local rnsd shared instance: it spawns the real
447
+ daemon against a fake `pi --mode rpc` child, pairs a second in-process LXMF
448
+ node as the owner (admitted via a preconfigured identity hash, exercising
449
+ the derivation end-to-end), and verifies a prompt round-trip, `/help`, and
450
+ reply chunking — all over real LXMF. Announce visibility against Sideband on
451
+ an actual mesh is the remaining manual step.
452
+
453
+ ## 13. Future work
454
+
455
+ - **File attachments** both ways: LXMF `fields` file attachments for
456
+ inbound images into Pi prompts, and a `send_file` companion extension
457
+ tool for outbound artifacts (pi-msg's XEP-0363 analogue).
458
+ - **Durable inbound journal** (pi-msg's inbox): persist inbound messages
459
+ before handing them to Pi and replay unacknowledged ones after a crash,
460
+ for exactly-once-ish delivery on top of LXMF's at-least-once.
461
+ - **Proactive notifications:** mirror locally-started runs' output to the
462
+ owner (pi-telegram's "connected companion projection").
463
+ - **DACAR-based permissions** (../dacar): replace the single `owner`
464
+ identity with grants/revocations synced over the mesh, keyed by identity
465
+ hash as v1 already is.
466
+ - **Multiple owners.**
467
+ - **`/export` → LXMF attachment** of the rendered HTML session.
468
+ - **Propagation-node role** for the bridge itself, serving its owner's
469
+ messages while the daemon is down.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-lxmf",
3
+ "version": "0.1.0",
4
+ "description": "Drive the Pi coding agent over LXMF messaging (Reticulum mesh) from a headless server.",
5
+ "license": "EUPL-1.2",
6
+ "author": "Henri Bergius <henri.bergius@iki.fi>",
7
+ "type": "module",
8
+ "main": "src/bridge.js",
9
+ "bin": {
10
+ "pi-lxmf": "src/bin.js"
11
+ },
12
+ "keywords": [
13
+ "pi",
14
+ "pi-package",
15
+ "reticulum",
16
+ "rns",
17
+ "lxmf",
18
+ "mesh",
19
+ "bot"
20
+ ],
21
+ "files": [
22
+ "src",
23
+ "CHANGELOG.md",
24
+ "SPEC.md"
25
+ ],
26
+ "scripts": {
27
+ "start": "node src/bin.js",
28
+ "lint": "npx @biomejs/biome check --use-editorconfig=true src/ test/ scripts/",
29
+ "format": "npx @biomejs/biome check --use-editorconfig=true --write src/ test/ scripts/",
30
+ "types": "tsc",
31
+ "test": "node --test --test-force-exit test/*.test.js",
32
+ "smoke": "node scripts/smoke.mjs"
33
+ },
34
+ "dependencies": {
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"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.6.2",
42
+ "typescript": "^6.0.3"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git://github.com/bergie/pi-lxmf.git"
50
+ },
51
+ "rngit": "rns://adafb3153efd4d96d532568a5208b3b5/reticulum/pi-lxmf"
52
+ }