pi-lxmf 0.1.1 β 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 +51 -0
- package/SPEC.md +48 -3
- package/package.json +1 -1
- package/src/bin.js +43 -8
- package/src/bridge.js +104 -8
- package/src/commands.js +92 -2
- package/src/config.js +75 -3
- package/src/lxmf.js +265 -36
- package/src/rpc.js +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ 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
|
+
|
|
40
|
+
## [0.1.2] - 2026-09-27
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Multi-repo support via `/cd` (work doc #2): one bridge can serve every
|
|
45
|
+
repo under the daemon's start folder. `/cd <path>` switches the
|
|
46
|
+
supervised Pi at runtime through a deliberate supervised respawn in the
|
|
47
|
+
new cwd (no backoff, no crash-loop counting; `--model` carried across,
|
|
48
|
+
the target repo's per-workdir session pointer applied via `--session`, so
|
|
49
|
+
revisiting a repo resumes its conversation and a new repo starts fresh).
|
|
50
|
+
Mid-run switches close the open exchange without empty-tail recovery.
|
|
51
|
+
Boundary: only paths that resolve under the daemon `workdir` β the
|
|
52
|
+
trust root β are accepted (`..` traversal, outside absolute paths,
|
|
53
|
+
missing or non-directory targets are refused without touching the child);
|
|
54
|
+
`resolveCwdTarget` in `src/commands.js` is the single choke point, kept
|
|
55
|
+
ready for future DACAR per-subtree identity checks. The active cwd is
|
|
56
|
+
persisted (`dataDir/cwd.json`) and revalidated at startup so restarts
|
|
57
|
+
resume in the last repo. `/cd` without arguments lists the current and
|
|
58
|
+
recently used repos (per-workdir session pointers now record their
|
|
59
|
+
workdir), and `/status` shows the current `cwd` and `workdir`.
|
|
60
|
+
|
|
10
61
|
## [0.1.1] - 2026-09-24
|
|
11
62
|
|
|
12
63
|
### Added
|
package/SPEC.md
CHANGED
|
@@ -123,6 +123,9 @@ agent.
|
|
|
123
123
|
respawns it after a short backoff, re-applies `--session` from the last
|
|
124
124
|
persisted pointer, and notifies the owner over LXMF. Repeated crashes
|
|
125
125
|
(e.g. 3 within a minute) stop the respawn loop and report to the owner.
|
|
126
|
+
A `/cd` switch (Β§6.6) respawns *deliberately* β the old child is killed,
|
|
127
|
+
the replacement spawned immediately (no backoff, no crash-loop counting)
|
|
128
|
+
in the new cwd, carrying `--model` and the target repo's session pointer.
|
|
126
129
|
- **Shutdown:** SIGINT/SIGTERM or `/quit` β best-effort `get_state` to
|
|
127
130
|
persist the session pointer, SIGINT to Pi (hard kill after 3 s), stop
|
|
128
131
|
announcing, exit.
|
|
@@ -268,7 +271,41 @@ answered with `{"type":"extension_ui_response","id":β¦,"cancelled":true}`
|
|
|
268
271
|
and the owner is informed: `β dialog dismissed: <title>`. Fire-and-forget
|
|
269
272
|
UI methods (`notify`, `setStatus`, `setWidget`, β¦) are ignored.
|
|
270
273
|
|
|
271
|
-
### 6.
|
|
274
|
+
### 6.6 Multi-repo: `/cd`
|
|
275
|
+
|
|
276
|
+
One bridge can serve every repo under the daemon's start folder. `/cd
|
|
277
|
+
<path>` switches the supervised Pi into another directory at runtime β a
|
|
278
|
+
supervised respawn, not an in-process `chdir`: the child is killed (pending
|
|
279
|
+
requests failed, any open exchange closed without empty-tail recovery β
|
|
280
|
+
the killed run never settles), respawned with the new `cwd` (carrying
|
|
281
|
+
`--model`; applying the target repo's per-workdir session pointer via
|
|
282
|
+
`--session`, Β§8 β so revisiting a repo resumes its conversation and a new
|
|
283
|
+
repo starts fresh), and probed until ready; inbound messages queue during
|
|
284
|
+
the switch. Pi therefore discovers the new repo's `AGENTS.md`/`.pi` and
|
|
285
|
+
re-evaluates project trust exactly as a manual restart would.
|
|
286
|
+
|
|
287
|
+
**Boundary:** `/cd` accepts only paths that resolve under the daemon's
|
|
288
|
+
`workdir` (the folder `pi-lxmf` was started under β the trust root for
|
|
289
|
+
switchable repos). `..` traversal and absolute paths outside the tree, and
|
|
290
|
+
missing or non-directory targets, are refused without ever touching the
|
|
291
|
+
child. The path resolution in `resolveCwdTarget` (`src/commands.js`) is the
|
|
292
|
+
single choke point β the same check is applied to the persisted active
|
|
293
|
+
cwd at startup β so a future DACAR per-subtree identity check can sit in
|
|
294
|
+
the same place: DACAR's per-folder ACLs may *narrow* which identities may
|
|
295
|
+
enter a given subtree, and the daemon-wide `owner` must not `/cd` past
|
|
296
|
+
such a restriction. When DACAR also brings per-ACL-group LXMF
|
|
297
|
+
identities, `/cd` may evolve from "one child, switch cwd" to a pool of
|
|
298
|
+
children keyed by (identity, repo); the command and its boundary stay
|
|
299
|
+
valid under either model.
|
|
300
|
+
|
|
301
|
+
The active cwd is persisted (`dataDir/cwd.json`, Β§8) and revalidated at
|
|
302
|
+
startup (still under `workdir`, still an existing directory β otherwise
|
|
303
|
+
fall back to `workdir`), so a daemon restart resumes in the last repo the
|
|
304
|
+
owner switched into. `/cd` without arguments lists the current repo and
|
|
305
|
+
the recently used ones (derived from the per-workdir session pointers);
|
|
306
|
+
`/status` shows the current `cwd` and `workdir`.
|
|
307
|
+
|
|
308
|
+
### 6.7 z.ai GLM quota watcher and peak-hours warning
|
|
272
309
|
|
|
273
310
|
When the active model is a z.ai GLM model (`provider === "zai"`), the bridge
|
|
274
311
|
runs a `GlmQuotaWatcher` (`src/quota.js`) that does two things, both gated
|
|
@@ -303,6 +340,7 @@ in one place so it can be retargeted per subtree.
|
|
|
303
340
|
| `/status` | model, thinking level, busy state, session name/file, bridge uptime, node identity hashes | no |
|
|
304
341
|
| `/session` | `get_session_stats` β message counts, tokens, cost, context usage | no |
|
|
305
342
|
| `/new` | `new_session`, persist the new session pointer | no |
|
|
343
|
+
| `/cd <path>` | switch the supervised Pi to another repo under `workdir` (supervised respawn, Β§6.6); without an argument, list the current and recent repos | no |
|
|
306
344
|
| `/name [name]` | `set_session_name`, or show current name | no |
|
|
307
345
|
| `/compact [instructions]` | `compact` (custom instructions appended) | summarizer only |
|
|
308
346
|
| `/model [query]` | no arg: list models (`get_available_models`, current marked); with arg: fuzzy-match `provider/id` or name, then `set_model` | no |
|
|
@@ -342,7 +380,8 @@ JSON, `0600`, unknown keys rejected with a warning.
|
|
|
342
380
|
- `storage/` β Reticulum persistence (identity, known destinations,
|
|
343
381
|
ratchets) via `FileStorageAdapter`.
|
|
344
382
|
- `sessions/<key>.json` β per-workdir Pi session pointers
|
|
345
|
-
(`{ "sessionFile": β¦ }`
|
|
383
|
+
(`{ "workdir": β¦, "sessionFile": β¦ }` β the `workdir` field feeds the
|
|
384
|
+
`/cd` recent-repos list), keyed by the first 16 hex chars of
|
|
346
385
|
`SHA-256(workdir)` so distinct repos keep distinct sessions (the session
|
|
347
386
|
is the conversation; switching models mid-session keeps the same pointer).
|
|
348
387
|
Written on `new_session`, on graceful shutdown, and whenever
|
|
@@ -351,6 +390,9 @@ JSON, `0600`, unknown keys rejected with a warning.
|
|
|
351
390
|
missing/empty pointer starts a fresh session). One-time migration: a
|
|
352
391
|
pre-scoping legacy `session` file is adopted for the first workdir that
|
|
353
392
|
reads it, then removed, so the adoption runs exactly once.
|
|
393
|
+
- `cwd.json` β the active cwd (`{ "cwd": β¦ }`): the repo the owner last
|
|
394
|
+
`/cd`'ed into (Β§6.6). Revalidated at startup against the `workdir`
|
|
395
|
+
boundary and the filesystem; unusable values fall back to `workdir`.
|
|
354
396
|
|
|
355
397
|
**Operational note:** Pi's project trust is not prompted for over LXMF.
|
|
356
398
|
Operators run Pi interactively once in `workdir` (or preconfigure trust) so
|
|
@@ -462,7 +504,10 @@ an actual mesh is the remaining manual step.
|
|
|
462
504
|
owner (pi-telegram's "connected companion projection").
|
|
463
505
|
- **DACAR-based permissions** (../dacar): replace the single `owner`
|
|
464
506
|
identity with grants/revocations synced over the mesh, keyed by identity
|
|
465
|
-
hash as v1 already is.
|
|
507
|
+
hash as v1 already is. Per-subtree ACLs will narrow which identities may
|
|
508
|
+
enter a given subtree β the `/cd` boundary (Β§6.6) is the choke point where
|
|
509
|
+
that check slots in β and per-ACL-group LXMF identities may turn the
|
|
510
|
+
single supervised child into a pool keyed by (identity, repo).
|
|
466
511
|
- **Multiple owners.**
|
|
467
512
|
- **`/export` β LXMF attachment** of the rendered HTML session.
|
|
468
513
|
- **Propagation-node role** for the bridge itself, serving its owner's
|
package/package.json
CHANGED
package/src/bin.js
CHANGED
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
* signalled or told to quit over LXMF.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { statSync } from "node:fs";
|
|
11
12
|
import { basename } from "node:path";
|
|
12
13
|
import { Bridge } from "./bridge.js";
|
|
14
|
+
import { isUnderWorkdir } from "./commands.js";
|
|
13
15
|
import {
|
|
16
|
+
listSessionPointers,
|
|
14
17
|
loadConfig,
|
|
18
|
+
readActiveCwd,
|
|
15
19
|
readSessionPointer,
|
|
20
|
+
writeActiveCwd,
|
|
16
21
|
writeSessionPointer,
|
|
17
22
|
} from "./config.js";
|
|
18
23
|
import { deriveLxmfDestinationHash } from "./identity.js";
|
|
@@ -99,8 +104,9 @@ function gracefulShutdown(reason, parts) {
|
|
|
99
104
|
parts.mesh?.stop();
|
|
100
105
|
process.exit(0);
|
|
101
106
|
};
|
|
102
|
-
// Best effort: persist the current session pointer
|
|
103
|
-
//
|
|
107
|
+
// Best effort: persist the current session pointer (under the repo the
|
|
108
|
+
// supervised Pi currently runs in β it may have moved via /cd) so the
|
|
109
|
+
// next start resumes this session.
|
|
104
110
|
const persist =
|
|
105
111
|
parts.bridge && parts.rpc && parts.config
|
|
106
112
|
? parts.rpc
|
|
@@ -109,7 +115,7 @@ function gracefulShutdown(reason, parts) {
|
|
|
109
115
|
if (state?.sessionFile) {
|
|
110
116
|
writeSessionPointer(
|
|
111
117
|
parts.config?.dataDir ?? "",
|
|
112
|
-
parts.config?.workdir ?? "",
|
|
118
|
+
parts.rpc?.cwd ?? parts.config?.workdir ?? "",
|
|
113
119
|
state.sessionFile,
|
|
114
120
|
);
|
|
115
121
|
}
|
|
@@ -158,7 +164,34 @@ async function main() {
|
|
|
158
164
|
|
|
159
165
|
bannerLine("owner (identity)", config.owner);
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
// The last repo the owner /cd'ed into, if it is still usable: under the
|
|
168
|
+
// daemon workdir (the trust root) and still an existing directory.
|
|
169
|
+
// Otherwise fall back to the workdir itself.
|
|
170
|
+
/** @type {string} */
|
|
171
|
+
let startCwd = config.workdir;
|
|
172
|
+
const persistedCwd = readActiveCwd(config.dataDir);
|
|
173
|
+
if (persistedCwd && persistedCwd !== config.workdir) {
|
|
174
|
+
let usable = isUnderWorkdir(config.workdir, persistedCwd);
|
|
175
|
+
if (usable) {
|
|
176
|
+
try {
|
|
177
|
+
usable = statSync(persistedCwd).isDirectory();
|
|
178
|
+
} catch {
|
|
179
|
+
usable = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (usable) {
|
|
183
|
+
startCwd = persistedCwd;
|
|
184
|
+
} else {
|
|
185
|
+
console.log(
|
|
186
|
+
`pi-lxmf: persisted cwd ${persistedCwd} is no longer under workdir β starting in workdir`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (startCwd !== config.workdir) {
|
|
191
|
+
bannerLine("cwd", startCwd);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sessionPointer = readSessionPointer(config.dataDir, startCwd);
|
|
162
195
|
if (sessionPointer) {
|
|
163
196
|
bannerLine("resume", basename(sessionPointer.sessionFile));
|
|
164
197
|
}
|
|
@@ -173,7 +206,7 @@ async function main() {
|
|
|
173
206
|
rpc = new PiRpcClient({
|
|
174
207
|
piBin: config.piBin,
|
|
175
208
|
model: config.model,
|
|
176
|
-
cwd:
|
|
209
|
+
cwd: startCwd,
|
|
177
210
|
sessionPath: sessionPointer?.sessionFile ?? null,
|
|
178
211
|
});
|
|
179
212
|
|
|
@@ -182,9 +215,11 @@ async function main() {
|
|
|
182
215
|
rpc,
|
|
183
216
|
mesh,
|
|
184
217
|
state: {
|
|
185
|
-
loadSession: () => readSessionPointer(config.dataDir,
|
|
186
|
-
saveSession: (file) =>
|
|
187
|
-
writeSessionPointer(config.dataDir,
|
|
218
|
+
loadSession: (workdir) => readSessionPointer(config.dataDir, workdir),
|
|
219
|
+
saveSession: (workdir, file) =>
|
|
220
|
+
writeSessionPointer(config.dataDir, workdir, file),
|
|
221
|
+
saveCwd: (cwd) => writeActiveCwd(config.dataDir, cwd),
|
|
222
|
+
listSessions: () => listSessionPointers(config.dataDir),
|
|
188
223
|
},
|
|
189
224
|
quotaWatcher: new GlmQuotaWatcher({
|
|
190
225
|
ownerDestinationHash: deriveLxmfDestinationHash(config.owner),
|
package/src/bridge.js
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* before falling back to a "done (no reply)" nudge.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { basename } from "node:path";
|
|
16
|
+
import { basename, relative } from "node:path";
|
|
17
17
|
import {
|
|
18
18
|
bridgeCommands,
|
|
19
19
|
EMPTY_REPLY_RECOVERY_PROMPT,
|
|
20
|
+
isUnderWorkdir,
|
|
20
21
|
parseCommand,
|
|
21
22
|
} from "./commands.js";
|
|
22
23
|
import { deriveLxmfDestinationHash } from "./identity.js";
|
|
@@ -41,11 +42,14 @@ const REACTION_DEBOUNCE_MS = 2000;
|
|
|
41
42
|
*/
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
|
-
* Machine-managed state persistence (session
|
|
45
|
+
* Machine-managed state persistence (per-workdir session pointers, the
|
|
46
|
+
* active cwd).
|
|
45
47
|
*
|
|
46
48
|
* @typedef {object} BridgeState
|
|
47
|
-
* @property {() => {sessionFile: string}|null} loadSession
|
|
48
|
-
* @property {(file: string) => void} saveSession
|
|
49
|
+
* @property {(workdir: string) => {sessionFile: string}|null} loadSession
|
|
50
|
+
* @property {(workdir: string, file: string) => void} saveSession
|
|
51
|
+
* @property {(cwd: string) => void} [saveCwd] - Persist the active cwd (the `/cd` target).
|
|
52
|
+
* @property {() => Array<{workdir: string|null, sessionFile: string, mtimeMs: number}>} [listSessions] - Per-workdir pointers, recent first.
|
|
49
53
|
*/
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -99,6 +103,8 @@ export class Bridge {
|
|
|
99
103
|
this.ownerIdentity = options.config.owner;
|
|
100
104
|
/** The owner's derived lxmf.delivery destination hash (wire form). */
|
|
101
105
|
this.ownerDestinationHash = deriveLxmfDestinationHash(options.config.owner);
|
|
106
|
+
/** The repo the supervised Pi currently runs in (moves via `/cd`). */
|
|
107
|
+
this.currentCwd = options.rpc?.cwd || options.config.workdir || null;
|
|
102
108
|
/** @type {string|null} */
|
|
103
109
|
this.sessionName = null;
|
|
104
110
|
/** @type {string|null} */
|
|
@@ -160,6 +166,16 @@ export class Bridge {
|
|
|
160
166
|
`β οΈ pi exited unexpectedly (code ${code ?? "?"} signal ${signal ?? "?"}) β restartingβ¦`,
|
|
161
167
|
);
|
|
162
168
|
});
|
|
169
|
+
this.rpc.addEventListener("switching", () => {
|
|
170
|
+
// A `/cd` respawn begins: the old child is about to be killed and its
|
|
171
|
+
// run will never settle β close any open exchange (without recovery)
|
|
172
|
+
// and stop considering the client ready until the replacement answers.
|
|
173
|
+
this.setRpcReady(false);
|
|
174
|
+
this.clearReaction();
|
|
175
|
+
this.busy = false;
|
|
176
|
+
this.exchangeActive = false;
|
|
177
|
+
this.sentThisExchange = 0;
|
|
178
|
+
});
|
|
163
179
|
this.rpc.addEventListener("dead", (/** @type {any} */ event) => {
|
|
164
180
|
this.setRpcReady(false);
|
|
165
181
|
const reason = event.detail?.reason ?? "unknown reason";
|
|
@@ -497,10 +513,12 @@ export class Bridge {
|
|
|
497
513
|
const file = state.sessionFile;
|
|
498
514
|
if (typeof file === "string" && file && file !== this.lastSessionFile) {
|
|
499
515
|
this.lastSessionFile = file;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
516
|
+
if (this.currentCwd) {
|
|
517
|
+
try {
|
|
518
|
+
this.state.saveSession(this.currentCwd, file);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
this.log.error(`pi-lxmf: could not persist session pointer: ${e}`);
|
|
521
|
+
}
|
|
504
522
|
}
|
|
505
523
|
this.rpc.setSessionPath(file);
|
|
506
524
|
}
|
|
@@ -518,16 +536,94 @@ export class Bridge {
|
|
|
518
536
|
commandContext() {
|
|
519
537
|
return {
|
|
520
538
|
rpc: this.rpc,
|
|
539
|
+
workdir: this.config.workdir,
|
|
540
|
+
/** @param {string} absPath */
|
|
541
|
+
changeWorkdir: (absPath) => this.changeWorkdir(absPath),
|
|
542
|
+
/** @param {string} msg */
|
|
543
|
+
log: (msg) => this.log.log(msg),
|
|
521
544
|
getTitle: () => this.replyTitle(),
|
|
522
545
|
getBridgeInfo: () => ({
|
|
523
546
|
identityHash: this.mesh.identityHash,
|
|
524
547
|
deliveryHash: this.mesh.deliveryHash,
|
|
525
548
|
owner: this.ownerIdentity,
|
|
526
549
|
uptimeMs: Date.now() - this.startedAt,
|
|
550
|
+
workdir: this.config.workdir,
|
|
551
|
+
cwd: this.currentCwd,
|
|
552
|
+
recentWorkdirs: this.recentWorkdirs(),
|
|
527
553
|
}),
|
|
528
554
|
};
|
|
529
555
|
}
|
|
530
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Recently used repos under the daemon workdir (most recent first,
|
|
559
|
+
* excluding the current one), derived from the per-workdir session
|
|
560
|
+
* pointers. Best-effort: unreadable state lists as empty.
|
|
561
|
+
*
|
|
562
|
+
* @returns {string[]}
|
|
563
|
+
*/
|
|
564
|
+
recentWorkdirs() {
|
|
565
|
+
if (!this.currentCwd || typeof this.state.listSessions !== "function") {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
/** @type {string[]} */
|
|
569
|
+
const out = [];
|
|
570
|
+
try {
|
|
571
|
+
for (const entry of this.state.listSessions()) {
|
|
572
|
+
if (typeof entry.workdir !== "string") continue;
|
|
573
|
+
if (entry.workdir === this.currentCwd) continue;
|
|
574
|
+
if (!isUnderWorkdir(this.config.workdir, entry.workdir)) continue;
|
|
575
|
+
out.push(entry.workdir);
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Switches the supervised Pi into another repo under the daemon workdir
|
|
585
|
+
* (the `/cd` command): resumes the target repo's session pointer (when
|
|
586
|
+
* one exists β the per-workdir keying of SPEC Β§8), respawns the child
|
|
587
|
+
* there via `rpc.setCwd()`, persists the active cwd, and returns the
|
|
588
|
+
* reply text. `absPath` must already be validated (`resolveCwdTarget`).
|
|
589
|
+
*
|
|
590
|
+
* @param {string} absPath - Absolute directory under `config.workdir`.
|
|
591
|
+
* @returns {Promise<string>} Reply text for the owner.
|
|
592
|
+
*/
|
|
593
|
+
async changeWorkdir(absPath) {
|
|
594
|
+
const pointer = this.state.loadSession(absPath);
|
|
595
|
+
// Session observations from the previous repo are stale: reset before
|
|
596
|
+
// the respawn so observeState() persists the new session under the
|
|
597
|
+
// new workdir's key.
|
|
598
|
+
this.sessionName = null;
|
|
599
|
+
this.lastSessionFile = pointer?.sessionFile ?? null;
|
|
600
|
+
this.rpc.setSessionPath(pointer?.sessionFile ?? null);
|
|
601
|
+
this.currentCwd = absPath;
|
|
602
|
+
try {
|
|
603
|
+
this.state.saveCwd?.(absPath);
|
|
604
|
+
} catch (e) {
|
|
605
|
+
this.log.error(`pi-lxmf: could not persist active cwd: ${e}`);
|
|
606
|
+
}
|
|
607
|
+
await this.rpc.setCwd(absPath);
|
|
608
|
+
// Observe (and persist) the new repo's session β resumed or fresh.
|
|
609
|
+
try {
|
|
610
|
+
this.observeState(await this.rpc.getState());
|
|
611
|
+
} catch {
|
|
612
|
+
/* switch reply still goes out; the next prompt re-observes */
|
|
613
|
+
}
|
|
614
|
+
const rel = relative(this.config.workdir, absPath) || ".";
|
|
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");
|
|
625
|
+
}
|
|
626
|
+
|
|
531
627
|
/**
|
|
532
628
|
* @returns {string} Title for the first chunk of a reply.
|
|
533
629
|
*/
|
package/src/commands.js
CHANGED
|
@@ -8,9 +8,52 @@
|
|
|
8
8
|
* text (or a `{ text, shutdown }` action).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { statSync } from "node:fs";
|
|
12
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
12
13
|
import { formatDuration, formatTokens } from "./text.js";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Whether `candidate` is the workdir itself or nested beneath it
|
|
17
|
+
* (normalized absolute paths). The multi-repo trust boundary: everything
|
|
18
|
+
* `/cd`-switchable must stay under the daemon's start folder.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} workdir - The daemon's configured workdir (trust root).
|
|
21
|
+
* @param {string} candidate - Absolute path to check.
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isUnderWorkdir(workdir, candidate) {
|
|
25
|
+
const root = resolve(workdir);
|
|
26
|
+
const abs = resolve(candidate);
|
|
27
|
+
return abs === root || abs.startsWith(`${root}${sep}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves a `/cd` target to an absolute directory under `workdir` β the
|
|
32
|
+
* single choke point for the multi-repo boundary (also applied to the
|
|
33
|
+
* persisted active cwd at daemon startup, so a future DACAR per-subtree
|
|
34
|
+
* identity check can sit in the same place). `target` is resolved against
|
|
35
|
+
* `workdir`; anything that escapes the tree (`..` traversal, absolute
|
|
36
|
+
* paths outside it), does not exist, or is not a directory is refused
|
|
37
|
+
* with `null`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} workdir - The daemon's configured workdir (trust root).
|
|
40
|
+
* @param {string} target - User-supplied path (relative to workdir or absolute).
|
|
41
|
+
* @param {(path: string) => {isDirectory: () => boolean}} [statFn] - Injectable for tests.
|
|
42
|
+
* @returns {string|null} The validated absolute path, or `null` when refused.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCwdTarget(workdir, target, statFn = statSync) {
|
|
45
|
+
const trimmed = (target ?? "").trim();
|
|
46
|
+
if (!trimmed) return null;
|
|
47
|
+
const abs = resolve(workdir, trimmed);
|
|
48
|
+
if (!isUnderWorkdir(workdir, abs)) return null;
|
|
49
|
+
try {
|
|
50
|
+
if (!statFn(abs).isDirectory()) return null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return abs;
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
/**
|
|
15
58
|
* Parses bridge-command syntax out of a chat message.
|
|
16
59
|
*
|
|
@@ -83,6 +126,8 @@ export function formatModelList(models, current) {
|
|
|
83
126
|
* @param {string} bridgeInfo.deliveryHash - This node's `lxmf.delivery` destination hash.
|
|
84
127
|
* @param {string|null} bridgeInfo.owner - Paired owner hash.
|
|
85
128
|
* @param {number} bridgeInfo.uptimeMs
|
|
129
|
+
* @param {string} [bridgeInfo.workdir] - The daemon's configured workdir.
|
|
130
|
+
* @param {string|null} [bridgeInfo.cwd] - The repo the supervised Pi currently runs in.
|
|
86
131
|
* @returns {string}
|
|
87
132
|
*/
|
|
88
133
|
export function formatStatus(state, bridgeInfo) {
|
|
@@ -97,6 +142,8 @@ export function formatStatus(state, bridgeInfo) {
|
|
|
97
142
|
`thinking: ${state?.thinkingLevel ?? "off"}`,
|
|
98
143
|
`busy: ${state?.isStreaming ? "yes" : "no"}`,
|
|
99
144
|
`session: ${session}`,
|
|
145
|
+
`cwd: ${bridgeInfo.cwd ?? "?"}`,
|
|
146
|
+
`workdir: ${bridgeInfo.workdir ?? "?"}`,
|
|
100
147
|
`node: ${bridgeInfo.identityHash}`,
|
|
101
148
|
`lxmf: ${bridgeInfo.deliveryHash}`,
|
|
102
149
|
`owner (identity): ${bridgeInfo.owner ?? "?"}`,
|
|
@@ -104,6 +151,30 @@ export function formatStatus(state, bridgeInfo) {
|
|
|
104
151
|
].join("\n");
|
|
105
152
|
}
|
|
106
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Formats the `/cd` (no arguments) reply: the current repo and the
|
|
156
|
+
* recently used repos under the daemon workdir.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} bridgeInfo
|
|
159
|
+
* @param {string} bridgeInfo.workdir
|
|
160
|
+
* @param {string|null} [bridgeInfo.cwd]
|
|
161
|
+
* @param {string[]} [bridgeInfo.recentWorkdirs]
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function formatRepoList(bridgeInfo) {
|
|
165
|
+
const workdir = bridgeInfo.workdir;
|
|
166
|
+
const cwd = bridgeInfo.cwd ?? workdir;
|
|
167
|
+
const lines = [`cwd: ${relative(workdir, cwd) || "."} (${cwd})`];
|
|
168
|
+
const recent = bridgeInfo.recentWorkdirs ?? [];
|
|
169
|
+
if (recent.length === 0) {
|
|
170
|
+
lines.push("recent: (none)");
|
|
171
|
+
} else {
|
|
172
|
+
lines.push("recent:");
|
|
173
|
+
for (const w of recent) lines.push(` ${relative(workdir, w) || "."}`);
|
|
174
|
+
}
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
107
178
|
/**
|
|
108
179
|
* Formats `get_session_stats` data as the `/session` reply.
|
|
109
180
|
*
|
|
@@ -123,8 +194,11 @@ export function formatSessionStats(stats) {
|
|
|
123
194
|
/**
|
|
124
195
|
* @typedef {object} CommandContext
|
|
125
196
|
* @property {import("./rpc.js").PiRpcClient} rpc
|
|
197
|
+
* @property {string} workdir - The daemon's configured workdir (trust root for `/cd`).
|
|
198
|
+
* @property {(absPath: string) => Promise<string>} changeWorkdir - Switch the supervised Pi into a validated repo; resolves to the reply text.
|
|
126
199
|
* @property {() => string} getTitle - Reply title (session name or node name).
|
|
127
|
-
* @property {() => {identityHash: string, deliveryHash: string, owner: string, uptimeMs: number}} getBridgeInfo
|
|
200
|
+
* @property {() => {identityHash: string, deliveryHash: string, owner: string, uptimeMs: number, workdir: string, cwd: string|null, recentWorkdirs: string[]}} getBridgeInfo
|
|
201
|
+
* @property {(msg: string) => void} [log] - Diagnostic sink for refusals.
|
|
128
202
|
*/
|
|
129
203
|
|
|
130
204
|
/**
|
|
@@ -193,6 +267,22 @@ export const bridgeCommands = {
|
|
|
193
267
|
},
|
|
194
268
|
},
|
|
195
269
|
|
|
270
|
+
cd: {
|
|
271
|
+
description: "Switch repo: /cd <path under workdir>, or list repos",
|
|
272
|
+
async run(ctx, args) {
|
|
273
|
+
if (!args) return formatRepoList(ctx.getBridgeInfo());
|
|
274
|
+
const target = resolveCwdTarget(ctx.workdir, args);
|
|
275
|
+
if (!target) {
|
|
276
|
+
ctx.log?.(`pi-lxmf: /cd refused: ${args} not under workdir`);
|
|
277
|
+
return `β οΈ /cd refused: "${args}" is not a directory under ${ctx.workdir}.`;
|
|
278
|
+
}
|
|
279
|
+
if (target === ctx.getBridgeInfo().cwd) {
|
|
280
|
+
return `Already in ${target}.`;
|
|
281
|
+
}
|
|
282
|
+
return ctx.changeWorkdir(target);
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
|
|
196
286
|
name: {
|
|
197
287
|
description: "Show or set the session display name",
|
|
198
288
|
async run(ctx, args) {
|
package/src/config.js
CHANGED
|
@@ -19,8 +19,10 @@ import { createHash } from "node:crypto";
|
|
|
19
19
|
import {
|
|
20
20
|
existsSync,
|
|
21
21
|
mkdirSync,
|
|
22
|
+
readdirSync,
|
|
22
23
|
readFileSync,
|
|
23
24
|
rmSync,
|
|
25
|
+
statSync,
|
|
24
26
|
writeFileSync,
|
|
25
27
|
} from "node:fs";
|
|
26
28
|
import { homedir } from "node:os";
|
|
@@ -368,7 +370,7 @@ export function readSessionPointer(dataDir, workdir) {
|
|
|
368
370
|
const legacyPath = join(dataDir, LEGACY_SESSION_FILE);
|
|
369
371
|
const legacy = readStateFile(legacyPath);
|
|
370
372
|
if (legacy?.sessionFile && typeof legacy.sessionFile === "string") {
|
|
371
|
-
writeStateFile(path, { sessionFile: legacy.sessionFile });
|
|
373
|
+
writeStateFile(path, { workdir, sessionFile: legacy.sessionFile });
|
|
372
374
|
try {
|
|
373
375
|
rmSync(legacyPath, { force: true });
|
|
374
376
|
} catch {
|
|
@@ -382,12 +384,82 @@ export function readSessionPointer(dataDir, workdir) {
|
|
|
382
384
|
/**
|
|
383
385
|
* Persists the Pi session pointer for `workdir` (the JSONL file `pi
|
|
384
386
|
* --session` resumes), keyed by the workdir so distinct repos keep distinct
|
|
385
|
-
* sessions.
|
|
387
|
+
* sessions. The workdir is stored alongside the pointer so the recently
|
|
388
|
+
* used repos can be listed for `/cd` (see {@link listSessionPointers}).
|
|
386
389
|
*
|
|
387
390
|
* @param {string} dataDir
|
|
388
391
|
* @param {string} workdir - The resolved workdir the pointer is scoped to.
|
|
389
392
|
* @param {string} sessionFile - Absolute path to the session file.
|
|
390
393
|
*/
|
|
391
394
|
export function writeSessionPointer(dataDir, workdir, sessionFile) {
|
|
392
|
-
writeStateFile(sessionPointerPath(dataDir, workdir), {
|
|
395
|
+
writeStateFile(sessionPointerPath(dataDir, workdir), {
|
|
396
|
+
workdir,
|
|
397
|
+
sessionFile,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The persisted active cwd (the last repo the owner `/cd`'ed into).
|
|
403
|
+
*/
|
|
404
|
+
const ACTIVE_CWD_FILE = "cwd.json";
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Loads the persisted active cwd β the repo a daemon restart should resume
|
|
408
|
+
* in. Callers must revalidate it against the daemon workdir (still beneath
|
|
409
|
+
* it, still an existing directory) before use; `isUnderWorkdir` in
|
|
410
|
+
* `src/commands.js` is the shared boundary check.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} dataDir
|
|
413
|
+
* @returns {string|null}
|
|
414
|
+
*/
|
|
415
|
+
export function readActiveCwd(dataDir) {
|
|
416
|
+
const state = readStateFile(join(dataDir, ACTIVE_CWD_FILE));
|
|
417
|
+
return typeof state?.cwd === "string" && state.cwd ? state.cwd : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Persists the active cwd so a daemon restart resumes in the last repo the
|
|
422
|
+
* owner switched into.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} dataDir
|
|
425
|
+
* @param {string} cwd - Absolute path under the daemon workdir.
|
|
426
|
+
*/
|
|
427
|
+
export function writeActiveCwd(dataDir, cwd) {
|
|
428
|
+
writeStateFile(join(dataDir, ACTIVE_CWD_FILE), { cwd });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Lists the per-workdir session pointers, most recently modified first β
|
|
433
|
+
* the "recently used repos" shown by `/cd`. Entries written before the
|
|
434
|
+
* workdir was stored alongside the pointer report `workdir: null`.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} dataDir
|
|
437
|
+
* @returns {Array<{workdir: string|null, sessionFile: string, mtimeMs: number}>}
|
|
438
|
+
*/
|
|
439
|
+
export function listSessionPointers(dataDir) {
|
|
440
|
+
const dir = join(dataDir, SESSIONS_DIR);
|
|
441
|
+
if (!existsSync(dir)) return [];
|
|
442
|
+
/** @type {Array<{workdir: string|null, sessionFile: string, mtimeMs: number}>} */
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const entry of readdirSync(dir)) {
|
|
445
|
+
if (!entry.endsWith(".json")) continue;
|
|
446
|
+
const path = join(dir, entry);
|
|
447
|
+
const data = readStateFile(path);
|
|
448
|
+
if (!data || typeof data.sessionFile !== "string" || !data.sessionFile) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
let mtimeMs = 0;
|
|
452
|
+
try {
|
|
453
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
454
|
+
} catch {
|
|
455
|
+
/* deleted between readdir and stat: entry is stale anyway */
|
|
456
|
+
}
|
|
457
|
+
out.push({
|
|
458
|
+
workdir: typeof data.workdir === "string" ? data.workdir : null,
|
|
459
|
+
sessionFile: data.sessionFile,
|
|
460
|
+
mtimeMs,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
464
|
+
return out;
|
|
393
465
|
}
|
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
|
|
220
|
-
// pulls messages that arrived while
|
|
221
|
-
|
|
222
|
-
|
|
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)
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* reply
|
|
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
|
package/src/rpc.js
CHANGED
|
@@ -112,6 +112,7 @@ export function assistantText(message) {
|
|
|
112
112
|
* - `"event"` β `{ detail: event }` for every non-response Pi event.
|
|
113
113
|
* - `"ready"` β the child is accepting commands (initially and after restarts).
|
|
114
114
|
* - `"restarting"` β `{ detail: { code, signal, attempt } }` unexpected exit; respawn scheduled.
|
|
115
|
+
* - `"switching"` β `{ detail: { cwd } }` a `setCwd()` respawn starts; the old child is about to be killed.
|
|
115
116
|
* - `"dead"` β `{ detail: { reason } }` no more respawns will be attempted.
|
|
116
117
|
*
|
|
117
118
|
* @fires PiRpcClient#event
|
|
@@ -145,6 +146,8 @@ export class PiRpcClient extends EventTarget {
|
|
|
145
146
|
this.pending = new Map();
|
|
146
147
|
this.nextId = 0;
|
|
147
148
|
this.stopped = false;
|
|
149
|
+
/** Set while a `setCwd()` respawn is pending: the old child's exit is expected, not a crash. */
|
|
150
|
+
this.intentionalRespawn = false;
|
|
148
151
|
/** @type {number[]} */
|
|
149
152
|
this.restartTimestamps = [];
|
|
150
153
|
this.ready = false;
|
|
@@ -316,6 +319,16 @@ export class PiRpcClient extends EventTarget {
|
|
|
316
319
|
|
|
317
320
|
if (this.stopped) return;
|
|
318
321
|
|
|
322
|
+
if (this.intentionalRespawn) {
|
|
323
|
+
// A setCwd() switch: the exit was expected. Spawn the replacement in
|
|
324
|
+
// the new cwd immediately β no backoff, no crash-loop counting.
|
|
325
|
+
this.intentionalRespawn = false;
|
|
326
|
+
this.log(`pi-lxmf: respawning pi in ${this.cwd} (cwd switch)`);
|
|
327
|
+
this.spawnChild();
|
|
328
|
+
this.probeUntilReady();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
319
332
|
const now = Date.now();
|
|
320
333
|
this.restartTimestamps = this.restartTimestamps.filter(
|
|
321
334
|
(t) => now - t < 60000,
|
|
@@ -395,6 +408,77 @@ export class PiRpcClient extends EventTarget {
|
|
|
395
408
|
this.sessionPath = path;
|
|
396
409
|
}
|
|
397
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Switches the child's working directory by respawning it β a supervised
|
|
413
|
+
* restart in the new cwd (the `/cd` path): the current child is killed
|
|
414
|
+
* (pending requests failed) and the replacement spawned immediately (no
|
|
415
|
+
* backoff, no crash-loop counting), carrying `--model` and whatever
|
|
416
|
+
* session path `setSessionPath()` last set. Pi therefore discovers the
|
|
417
|
+
* new repo's `AGENTS.md`/`.pi` and re-evaluates project trust fresh,
|
|
418
|
+
* exactly as a manual restart would. Resolves once the replacement
|
|
419
|
+
* answers commands; rejects on timeout or when restarting is given up.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} absPath - Absolute directory to run Pi in.
|
|
422
|
+
* @param {object} [options]
|
|
423
|
+
* @param {number} [options.readyTimeoutMs=30000] - Budget for the replacement's first answer.
|
|
424
|
+
* @returns {Promise<void>}
|
|
425
|
+
*/
|
|
426
|
+
async setCwd(absPath, options = {}) {
|
|
427
|
+
if (absPath === this.cwd) return;
|
|
428
|
+
if (this.stopped) throw new RpcError("PiRpcClient is stopped");
|
|
429
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 30000;
|
|
430
|
+
this.cwd = absPath;
|
|
431
|
+
if (!this.child && this.firstReady === null) {
|
|
432
|
+
// Never started: start() picks the new cwd up.
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.intentionalRespawn = true;
|
|
436
|
+
this.dispatchEvent(
|
|
437
|
+
new CustomEvent("switching", { detail: { cwd: absPath } }),
|
|
438
|
+
);
|
|
439
|
+
const switched = new Promise((resolve, reject) => {
|
|
440
|
+
const timer = setTimeout(() => {
|
|
441
|
+
cleanup();
|
|
442
|
+
reject(
|
|
443
|
+
new RpcError(
|
|
444
|
+
`pi not ready in ${absPath} within ${readyTimeoutMs} ms`,
|
|
445
|
+
"setCwd",
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
}, readyTimeoutMs);
|
|
449
|
+
const onReady = () => {
|
|
450
|
+
cleanup();
|
|
451
|
+
resolve(undefined);
|
|
452
|
+
};
|
|
453
|
+
const onDead = (/** @type {any} */ e) => {
|
|
454
|
+
cleanup();
|
|
455
|
+
reject(
|
|
456
|
+
new RpcError(
|
|
457
|
+
`pi in ${absPath} is not recovering: ${e.detail?.reason ?? "unknown reason"}`,
|
|
458
|
+
"setCwd",
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
const cleanup = () => {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
this.removeEventListener("ready", onReady);
|
|
465
|
+
this.removeEventListener("dead", onDead);
|
|
466
|
+
};
|
|
467
|
+
this.addEventListener("ready", onReady);
|
|
468
|
+
this.addEventListener("dead", onDead);
|
|
469
|
+
});
|
|
470
|
+
if (this.child) {
|
|
471
|
+
// handleExit() sees the intentional flag and spawns the replacement
|
|
472
|
+
// as soon as the killed child reports its exit.
|
|
473
|
+
this.child.kill("SIGTERM");
|
|
474
|
+
} else {
|
|
475
|
+
// Between restarts: spawn the replacement directly.
|
|
476
|
+
this.spawnChild();
|
|
477
|
+
this.probeUntilReady();
|
|
478
|
+
}
|
|
479
|
+
await switched;
|
|
480
|
+
}
|
|
481
|
+
|
|
398
482
|
/**
|
|
399
483
|
* Writes one JSON command as a line to pi's stdin.
|
|
400
484
|
*
|