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/CHANGELOG.md +111 -0
- package/README.md +202 -0
- package/SPEC.md +469 -0
- package/package.json +52 -0
- package/src/bin.js +220 -0
- package/src/bridge.js +622 -0
- package/src/bz2.js +46 -0
- package/src/commands.js +288 -0
- package/src/config.js +393 -0
- package/src/identity.js +93 -0
- package/src/lxmf.js +380 -0
- package/src/quota.js +512 -0
- package/src/rpc.js +627 -0
- package/src/text.js +97 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-24
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- z.ai GLM quota watcher + peak-hours warning (`src/quota.js`, work doc #3):
|
|
13
|
+
when the active model is a z.ai GLM model (`provider === "zai"`), the
|
|
14
|
+
bridge watches the z.ai quota endpoint (`pi-glm-usage`'s) and delivers
|
|
15
|
+
exactly one LXMF message to the owner the moment the 5h quota bucket
|
|
16
|
+
recovers after a quota-exhausted run failure. Also warns the owner at
|
|
17
|
+
run start (and when the window opens mid-run) during z.ai peak hours
|
|
18
|
+
(Mon–Fri 14:00–18:00 SGT / UTC+8, 3× token cost). Entirely gated on
|
|
19
|
+
the active model being `zai` — nothing fires for Cortecs/Anthropic/etc.
|
|
20
|
+
A missing `zai.key` disables the watcher gracefully.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Pi session pointer is now scoped to the resolved `workdir` (work doc #4):
|
|
25
|
+
pointers live at `dataDir/sessions/<sha256(workdir)[:16]>.json` so
|
|
26
|
+
distinct repos keep distinct sessions. Previously the single
|
|
27
|
+
`dataDir/session` file was shared across every repo, so starting the
|
|
28
|
+
daemon in a different cwd resumed the previous repo's conversation.
|
|
29
|
+
One-time migration: the legacy `dataDir/session` is adopted for the
|
|
30
|
+
first workdir that reads it, then removed. Foundational to the
|
|
31
|
+
multi-repo `/cd` work (doc #2).
|
|
32
|
+
|
|
33
|
+
- Initial implementation of the `pi-lxmf` bridge: an LXMF ↔ Pi RPC daemon per
|
|
34
|
+
`SPEC.md`.
|
|
35
|
+
- `PiRpcClient` (`src/rpc.js`): spawns and supervises `pi --mode rpc`,
|
|
36
|
+
strict-JSONL framing (LF-only, `\r`-tolerant), id-correlated requests,
|
|
37
|
+
readiness probing, crash-restart with backoff and a crash-loop guard.
|
|
38
|
+
- Mesh side (`src/lxmf.js`): persistent Reticulum identity, shared-instance →
|
|
39
|
+
AutoInterface → TCP interface fallback, `LXMRouter` with periodic
|
|
40
|
+
announcing, optional propagation-node sync, chunked outbound delivery. A
|
|
41
|
+
`skipSharedInstance` config option bypasses the local rnsd shared instance
|
|
42
|
+
entirely in favour of own interfaces (AutoInterface + the `rnsHost`/
|
|
43
|
+
`rnsPort` TCP client) — for shared instances that do not forward routed,
|
|
44
|
+
multi-hop traffic to their local clients (observed on a Termux↔Columba
|
|
45
|
+
setup where the daemon's announces reached the mesh but every inbound link
|
|
46
|
+
request died at the rnsd, so senders never got delivery proofs). The
|
|
47
|
+
startup banner now also lists the attached interfaces.
|
|
48
|
+
- Bridge (`src/bridge.js`): owner-only access by configured Reticulum
|
|
49
|
+
identity hash (no first-contact pairing; derivation cross-validated
|
|
50
|
+
against @reticulum/core), serialized inbound pipeline, prompts (steer/follow-up mid-run), assistant
|
|
51
|
+
reply delivery with empty-tail recovery, extension-dialog auto-dismissal.
|
|
52
|
+
- Chat commands (`src/commands.js`): `/help`, `/status`, `/session`, `/new`,
|
|
53
|
+
`/name`, `/compact`, `/model`, `/think`, `/abort`, `/quit`, and the bare
|
|
54
|
+
`!` interrupt.
|
|
55
|
+
- Configuration and state (`src/config.js`): XDG-based config file with the
|
|
56
|
+
required `owner` identity hash, Pi session pointer persistence.
|
|
57
|
+
- End-to-end smoketest (`scripts/smoke.mjs` + `scripts/fake-pi.mjs`): runs
|
|
58
|
+
the real daemon against a fake `pi --mode rpc` and a second in-process
|
|
59
|
+
LXMF owner over a local rnsd shared instance.
|
|
60
|
+
- Inbound LXMF diagnostics (`attachInboundDiagnostics` in `src/lxmf.js`):
|
|
61
|
+
surfaces the inbound failure mode the bridge itself can't see — a
|
|
62
|
+
packet that decrypts but never dispatches. When the sender's identity
|
|
63
|
+
is unknown the router parks the message until an announce arrives (the
|
|
64
|
+
most common reason a sender sees its packet acknowledged but the bridge
|
|
65
|
+
never receives anything), and that is now logged; unparseable packets
|
|
66
|
+
are logged too. The peer-learn (`"peer"` event) log is filtered to the
|
|
67
|
+
configured owner identity only — routine mesh peers are no longer
|
|
68
|
+
logged.
|
|
69
|
+
Successfully-dispatched owner traffic is intentionally silent here —
|
|
70
|
+
the bridge logs its disposition (ignored / command / prompt) where the
|
|
71
|
+
decision is made. Ported from signalk-reticulum where this
|
|
72
|
+
instrumentation proved out the identity-parking failure mode.
|
|
73
|
+
- Outbound reply fallback: a failed reply over the arrival link is now
|
|
74
|
+
retried once more without the link (fresh DIRECT link, then
|
|
75
|
+
opportunistic packet). Battery-conscious mobile clients tear their
|
|
76
|
+
link down right after their own message is acknowledged, so the arrival
|
|
77
|
+
link is frequently gone by reply time. The same `LXMessage` object is
|
|
78
|
+
re-sent so every wire copy shares one message id and a deduplicating
|
|
79
|
+
client (Sideband, NomadNet) renders the reply once.
|
|
80
|
+
- Run-start acknowledgement via LXMF reaction: when a run starts and no
|
|
81
|
+
reply lands within a 2 s debounce window, the bridge sends a 🤔
|
|
82
|
+
reaction (LXMF `FIELD_REACTION`, §5.9.8) targeting the message that
|
|
83
|
+
triggered the run, so the owner sees their message acknowledged while
|
|
84
|
+
the agent works. A fast run whose reply beats the window sends no
|
|
85
|
+
extra message. The reaction is carried solely by the reaction field
|
|
86
|
+
(Sideband/NomadNet render it natively; no separate chat bubble is
|
|
87
|
+
emitted alongside it). The internal empty-reply recovery run is never
|
|
88
|
+
acknowledged. Added
|
|
89
|
+
`sendReaction(destinationHex, targetMessageId, emoji, opts)` to the
|
|
90
|
+
mesh adapter (`src/lxmf.js`), reusing the same retry path as
|
|
91
|
+
`sendText`.
|
|
92
|
+
- Fixed bzip2 compression provider (`src/bz2.js`): the wasm `compress()`
|
|
93
|
+
defaults its output buffer to the input length, so an LXMF reply that
|
|
94
|
+
compressed worse than its input (incompressible / small / already-
|
|
95
|
+
compressed data) threw `BZ_OUTBUFF_FULL` and failed delivery. The adapter
|
|
96
|
+
now sizes the output buffer with bzip2's documented worst-case headroom
|
|
97
|
+
(`input + 1% + 600` bytes).
|
|
98
|
+
- Security: the bridge now signature-verifies every inbound owner message
|
|
99
|
+
itself, closing a gap on the propagation-sync path. The router verifies
|
|
100
|
+
signatures on direct delivery, but a message pulled in via
|
|
101
|
+
`syncFromPropagationNode` whose sender identity is not yet recalled is
|
|
102
|
+
dispatched WITHOUT verification (mirroring Python's `SOURCE_UNKNOWN`
|
|
103
|
+
handling) — and the bridge's owner-hash check alone is forgeable there
|
|
104
|
+
(a 16-byte hash, no private key needed). The bridge now calls
|
|
105
|
+
`verifySender(message)` on the mesh adapter (recalls the sender identity
|
|
106
|
+
and checks the signature) and drops anything that isn't cryptographically
|
|
107
|
+
proven, including `unknown` (parked) results, so a synced message is only
|
|
108
|
+
admitted once the owner's identity has been learned. This makes the
|
|
109
|
+
SPEC.md §11 / README "signature-verified" claim hold for every path.
|
|
110
|
+
- GitHub Actions CI: tests (with lint and type checks) on every push, and
|
|
111
|
+
OIDC-based npm publishing on tag pushes (no registry token stored).
|
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# pi-lxmf
|
|
2
|
+
|
|
3
|
+
Drive the [Pi](https://pi.dev) coding agent **entirely from an LXMF messaging
|
|
4
|
+
client** — [Sideband](https://github.com/markqvist/Sideband),
|
|
5
|
+
[Nomad Network](https://github.com/markqvist/NomadNet), or any other
|
|
6
|
+
[LXMF](https://github.com/markqvist/LXMF) peer — over the
|
|
7
|
+
[Reticulum](https://reticulum.network) mesh. Built on
|
|
8
|
+
[reticulum-js](https://reticulum.js.org/).
|
|
9
|
+
|
|
10
|
+
`pi-lxmf` is a small daemon for headless servers: your chat messages become
|
|
11
|
+
prompts, finished assistant replies are delivered back as chat messages, and
|
|
12
|
+
the usual Pi controls (`/new`, `/compact`, `/abort`, model and thinking
|
|
13
|
+
switches) work from chat. Exactly one configured owner — identified by their
|
|
14
|
+
**Reticulum identity hash** — controls the agent; everyone else is ignored.
|
|
15
|
+
Architecture and internals: [SPEC.md](SPEC.md).
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
Sideband / NomadNet ◄──LXMF──► pi-lxmf daemon ◄──RPC──► pi --mode rpc
|
|
19
|
+
(identity, announce, (supervised child,
|
|
20
|
+
session) restartable)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js ≥ 20
|
|
26
|
+
- `pi` on `PATH`, logged into a provider
|
|
27
|
+
- A Reticulum transport: a local `rnsd` (recommended), or AutoInterface
|
|
28
|
+
(IPv6 multicast), or an `rnsd` reachable over TCP
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g pi-lxmf
|
|
34
|
+
# or from a checkout:
|
|
35
|
+
git clone … && cd pi-lxmf && npm install
|
|
36
|
+
node src/bin.js --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
1. **Configure** — create `~/.config/pi-lxmf/config.json` (override with
|
|
42
|
+
`--config <path>` or `$PI_LXMF_CONFIG`) and `chmod 600` it:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"name": "pi on myserver",
|
|
47
|
+
"workdir": "/srv/myproject",
|
|
48
|
+
"model": "anthropic/claude-sonnet-4-5",
|
|
49
|
+
"owner": "<your Reticulum identity hash, 32 hex chars>"
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`owner` is **required** and takes your **Reticulum identity hash** — the
|
|
54
|
+
32-hex identifier of your Ed25519 identity, *not* the `lxmf.delivery`
|
|
55
|
+
destination hash ("LXMF Address") your client displays. The identity hash
|
|
56
|
+
is protocol-agnostic (the same you across LXMF, Nomadnet, …) and is what a
|
|
57
|
+
future DACAR-style permission system would grant to; the daemon derives
|
|
58
|
+
the wire form internally.
|
|
59
|
+
|
|
60
|
+
2. **Run the daemon** in the project directory (or set `workdir`):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pi-lxmf
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The startup banner prints the node's identity and LXMF address:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
pi-lxmf: pi on myserver starting
|
|
70
|
+
identity 6bd23ddae42b6ab1b90ef1ce62a41f31
|
|
71
|
+
lxmf 92b5be0e43370f01de77126b0eb11b53
|
|
72
|
+
announce pi on myserver
|
|
73
|
+
owner (identity) 3f9dd04e9216c0a3b8c7e5f1d2a60b44
|
|
74
|
+
pi-lxmf: ready — listening for LXMF messages
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
3. **Say hello** — in Sideband, start a conversation with the daemon's LXMF
|
|
78
|
+
address (scan the announce or enter the hash manually) and send a message.
|
|
79
|
+
Only the configured owner is answered; everyone else is dropped.
|
|
80
|
+
|
|
81
|
+
4. **Trust the project once** — Pi's project trust cannot be prompted over
|
|
82
|
+
LXMF. Run `pi` interactively once in `workdir` (or preconfigure trust) so
|
|
83
|
+
project-local `.pi` resources load in RPC mode.
|
|
84
|
+
|
|
85
|
+
## Chat commands
|
|
86
|
+
|
|
87
|
+
| You send | Action |
|
|
88
|
+
|---|---|
|
|
89
|
+
| plain text | a prompt to the agent (steered into a running turn by default) |
|
|
90
|
+
| `/help` | bridge commands + Pi commands available via prompt |
|
|
91
|
+
| `/status` | model, thinking, session, uptime, node + owner identity hashes |
|
|
92
|
+
| `/session` | message counts, tokens, cost, context usage |
|
|
93
|
+
| `/new` | fresh Pi session |
|
|
94
|
+
| `/name [name]` | show / set the session display name |
|
|
95
|
+
| `/compact [instructions]` | compact the conversation context |
|
|
96
|
+
| `/model [query]` | list models, or switch (`/model sonnet`) |
|
|
97
|
+
| `/think <level>` | set thinking level (`off`…`max`) |
|
|
98
|
+
| `/abort` | abort the run **and** drop queued messages |
|
|
99
|
+
| `!` | quick interrupt — abort only, queue intact |
|
|
100
|
+
| `/quit` | shut down the daemon and Pi |
|
|
101
|
+
| any other `/…` | passed to Pi (extension commands, `/skill:…`, templates) |
|
|
102
|
+
|
|
103
|
+
Replies are delivered per finished assistant message, chunked to fit
|
|
104
|
+
`chunkChars`. A run that ends without any reply triggers one recovery attempt,
|
|
105
|
+
then a `✅ done (no reply)` nudge. Extension dialogs raised inside Pi are
|
|
106
|
+
auto-declined (nobody is at a terminal) and reported to you.
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
`~/.config/pi-lxmf/config.json` (XDG env vars respected). A missing file runs
|
|
111
|
+
on defaults.
|
|
112
|
+
|
|
113
|
+
| Field | Default | Meaning |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `owner` | *(required)* | the owner's 32-hex **Reticulum identity hash** (not the LXMF address); the daemon derives the `lxmf.delivery` destination hash for wire comparison |
|
|
116
|
+
| `name` | `pi-lxmf <version>` | announce display name |
|
|
117
|
+
| `workdir` | daemon cwd | project directory Pi runs in (also where `AGENTS.md` is found) |
|
|
118
|
+
| `model` | Pi default | `--model` pattern passed to Pi |
|
|
119
|
+
| `piBin` | `pi` | Pi binary |
|
|
120
|
+
| `dataDir` | `~/.local/share/pi-lxmf` | state root (see below) |
|
|
121
|
+
| `rnsHost` / `rnsPort` | — | rnsd TCP interface, used when no local shared instance is found |
|
|
122
|
+
| `propagationNode` | — | `lxmf.propagation` hash for outbound submits + optional sync |
|
|
123
|
+
| `syncIntervalSec` | `0` (off) | pull messages from the propagation node on this cadence |
|
|
124
|
+
| `midRunBehavior` | `steer` | how prompts arriving mid-run are delivered: `steer` or `followUp` |
|
|
125
|
+
| `chunkChars` | `2500` | max characters per outbound LXMF message |
|
|
126
|
+
| `announceIntervalSec` | router default | re-announce cadence |
|
|
127
|
+
|
|
128
|
+
### State (`dataDir`)
|
|
129
|
+
|
|
130
|
+
- `storage/` — the node's persistent Reticulum identity and caches. **This key
|
|
131
|
+
is the node's address; back it up and keep it secret.** Deleting it changes
|
|
132
|
+
your LXMF address.
|
|
133
|
+
- `session` — pointer to the current Pi session file. Sessions survive daemon
|
|
134
|
+
restarts (`pi --session`); `/new` starts a fresh one.
|
|
135
|
+
|
|
136
|
+
### Mesh connectivity
|
|
137
|
+
|
|
138
|
+
The daemon attaches to a local `rnsd` shared instance when available (the
|
|
139
|
+
recommended setup — rnsd owns the radio/network interfaces), and otherwise
|
|
140
|
+
falls back to AutoInterface, optionally plus a TCP interface via
|
|
141
|
+
`rnsHost`/`rnsPort`. It announces itself periodically so peers can find it and
|
|
142
|
+
cached paths stay fresh.
|
|
143
|
+
|
|
144
|
+
If you configure a `propagationNode`, replies can be submitted for
|
|
145
|
+
store-and-forward delivery while you are unreachable, and `syncIntervalSec`
|
|
146
|
+
retrieves messages that arrived while the daemon was down.
|
|
147
|
+
|
|
148
|
+
## Security
|
|
149
|
+
|
|
150
|
+
- Every inbound owner message is **signature-verified by the bridge**
|
|
151
|
+
(recalling the sender identity and checking the LXMF signature) before
|
|
152
|
+
it is processed; only messages from the configured owner's identity get
|
|
153
|
+
through (matched via the derived `lxmf.delivery` destination hash),
|
|
154
|
+
everyone else is dropped silently — the daemon never answers strangers.
|
|
155
|
+
The router itself only verifies signatures on direct delivery, so the
|
|
156
|
+
bridge re-checks every path (including propagation-node sync, where the
|
|
157
|
+
router dispatches unverified when the sender identity is unknown) — the
|
|
158
|
+
owner source hash alone is forgeable, so the cryptographic proof is what
|
|
159
|
+
actually authenticates the sender.
|
|
160
|
+
- The owner is **configured, never learned**: there is no first-contact
|
|
161
|
+
pairing, so a stray message can never seize control.
|
|
162
|
+
- Access is keyed by **identity hash**, so it can later be delegated to a
|
|
163
|
+
DACAR-style permission system without reconfiguration.
|
|
164
|
+
- The bridge never executes chat text locally — everything goes through Pi's
|
|
165
|
+
RPC protocol. Approval-gated tools stay gated (dialogs are declined).
|
|
166
|
+
- `/quit` and full agent control are available to the owner only.
|
|
167
|
+
|
|
168
|
+
## Running as a service
|
|
169
|
+
|
|
170
|
+
Example systemd unit (adjust paths):
|
|
171
|
+
|
|
172
|
+
```ini
|
|
173
|
+
[Unit]
|
|
174
|
+
Description=pi-lxmf bridge
|
|
175
|
+
After=network-online.target rnsd.service
|
|
176
|
+
|
|
177
|
+
[Service]
|
|
178
|
+
ExecStart=/usr/bin/pi-lxmf --config /etc/pi-lxmf/config.json
|
|
179
|
+
Restart=on-failure
|
|
180
|
+
User=pi
|
|
181
|
+
|
|
182
|
+
[Install]
|
|
183
|
+
WantedBy=multi-user.target
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The daemon supervises Pi itself: an unexpected Pi exit triggers a restart
|
|
187
|
+
(with the session resumed) and is reported to you over LXMF; a crash loop
|
|
188
|
+
gives up and says so.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm test # unit tests (node --test)
|
|
194
|
+
npm run types # tsc --noEmit over the JSDoc-typed sources
|
|
195
|
+
npm run lint # biome
|
|
196
|
+
npm run smoke # end-to-end against a local rnsd: daemon + fake pi +
|
|
197
|
+
# a second in-process LXMF node as the owner
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
EUPL-1.2
|