pi-lxmf 0.1.0 → 0.1.2
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 +34 -0
- package/SPEC.md +48 -3
- package/package.json +1 -1
- package/src/bin.js +47 -8
- package/src/bridge.js +114 -7
- package/src/commands.js +92 -2
- package/src/config.js +75 -3
- package/src/rpc.js +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.2] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Multi-repo support via `/cd` (work doc #2): one bridge can serve every
|
|
15
|
+
repo under the daemon's start folder. `/cd <path>` switches the
|
|
16
|
+
supervised Pi at runtime through a deliberate supervised respawn in the
|
|
17
|
+
new cwd (no backoff, no crash-loop counting; `--model` carried across,
|
|
18
|
+
the target repo's per-workdir session pointer applied via `--session`, so
|
|
19
|
+
revisiting a repo resumes its conversation and a new repo starts fresh).
|
|
20
|
+
Mid-run switches close the open exchange without empty-tail recovery.
|
|
21
|
+
Boundary: only paths that resolve under the daemon `workdir` — the
|
|
22
|
+
trust root — are accepted (`..` traversal, outside absolute paths,
|
|
23
|
+
missing or non-directory targets are refused without touching the child);
|
|
24
|
+
`resolveCwdTarget` in `src/commands.js` is the single choke point, kept
|
|
25
|
+
ready for future DACAR per-subtree identity checks. The active cwd is
|
|
26
|
+
persisted (`dataDir/cwd.json`) and revalidated at startup so restarts
|
|
27
|
+
resume in the last repo. `/cd` without arguments lists the current and
|
|
28
|
+
recently used repos (per-workdir session pointers now record their
|
|
29
|
+
workdir), and `/status` shows the current `cwd` and `workdir`.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] - 2026-09-24
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Startup notification to the owner (work doc #5): once the daemon is
|
|
36
|
+
fully available (LXMF destination announcing, `pi --mode rpc` ready), it
|
|
37
|
+
sends `🟢 pi-lxmf ready — listening for messages.` to the configured
|
|
38
|
+
owner — plus a `Resuming session <file>.` line when a session pointer
|
|
39
|
+
was resumed. Best-effort: a failed delivery is noted and carried by the
|
|
40
|
+
next successful reply.
|
|
41
|
+
|
|
8
42
|
## [0.1.0] - 2026-09-24
|
|
9
43
|
|
|
10
44
|
### 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),
|
|
@@ -210,6 +245,10 @@ async function main() {
|
|
|
210
245
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
211
246
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
212
247
|
|
|
248
|
+
// The daemon is fully available: tell the owner (they may be waiting on
|
|
249
|
+
// a restart). Delivery failures are noted, not fatal.
|
|
250
|
+
await bridge.notifyStartup(sessionPointer?.sessionFile ?? null);
|
|
251
|
+
|
|
213
252
|
// Run until signalled or shut down over LXMF.
|
|
214
253
|
await new Promise(() => {});
|
|
215
254
|
}
|
package/src/bridge.js
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* before falling back to a "done (no reply)" nudge.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { basename, relative } from "node:path";
|
|
16
17
|
import {
|
|
17
18
|
bridgeCommands,
|
|
18
19
|
EMPTY_REPLY_RECOVERY_PROMPT,
|
|
20
|
+
isUnderWorkdir,
|
|
19
21
|
parseCommand,
|
|
20
22
|
} from "./commands.js";
|
|
21
23
|
import { deriveLxmfDestinationHash } from "./identity.js";
|
|
@@ -40,11 +42,14 @@ const REACTION_DEBOUNCE_MS = 2000;
|
|
|
40
42
|
*/
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
* Machine-managed state persistence (session
|
|
45
|
+
* Machine-managed state persistence (per-workdir session pointers, the
|
|
46
|
+
* active cwd).
|
|
44
47
|
*
|
|
45
48
|
* @typedef {object} BridgeState
|
|
46
|
-
* @property {() => {sessionFile: string}|null} loadSession
|
|
47
|
-
* @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.
|
|
48
53
|
*/
|
|
49
54
|
|
|
50
55
|
/**
|
|
@@ -98,6 +103,8 @@ export class Bridge {
|
|
|
98
103
|
this.ownerIdentity = options.config.owner;
|
|
99
104
|
/** The owner's derived lxmf.delivery destination hash (wire form). */
|
|
100
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;
|
|
101
108
|
/** @type {string|null} */
|
|
102
109
|
this.sessionName = null;
|
|
103
110
|
/** @type {string|null} */
|
|
@@ -159,6 +166,16 @@ export class Bridge {
|
|
|
159
166
|
`⚠️ pi exited unexpectedly (code ${code ?? "?"} signal ${signal ?? "?"}) — restarting…`,
|
|
160
167
|
);
|
|
161
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
|
+
});
|
|
162
179
|
this.rpc.addEventListener("dead", (/** @type {any} */ event) => {
|
|
163
180
|
this.setRpcReady(false);
|
|
164
181
|
const reason = event.detail?.reason ?? "unknown reason";
|
|
@@ -496,10 +513,12 @@ export class Bridge {
|
|
|
496
513
|
const file = state.sessionFile;
|
|
497
514
|
if (typeof file === "string" && file && file !== this.lastSessionFile) {
|
|
498
515
|
this.lastSessionFile = file;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
}
|
|
503
522
|
}
|
|
504
523
|
this.rpc.setSessionPath(file);
|
|
505
524
|
}
|
|
@@ -517,16 +536,87 @@ export class Bridge {
|
|
|
517
536
|
commandContext() {
|
|
518
537
|
return {
|
|
519
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),
|
|
520
544
|
getTitle: () => this.replyTitle(),
|
|
521
545
|
getBridgeInfo: () => ({
|
|
522
546
|
identityHash: this.mesh.identityHash,
|
|
523
547
|
deliveryHash: this.mesh.deliveryHash,
|
|
524
548
|
owner: this.ownerIdentity,
|
|
525
549
|
uptimeMs: Date.now() - this.startedAt,
|
|
550
|
+
workdir: this.config.workdir,
|
|
551
|
+
cwd: this.currentCwd,
|
|
552
|
+
recentWorkdirs: this.recentWorkdirs(),
|
|
526
553
|
}),
|
|
527
554
|
};
|
|
528
555
|
}
|
|
529
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
|
+
return pointer
|
|
616
|
+
? `Switched to ${rel}. Resuming session ${basename(pointer.sessionFile)}.`
|
|
617
|
+
: `Switched to ${rel}. Fresh session.`;
|
|
618
|
+
}
|
|
619
|
+
|
|
530
620
|
/**
|
|
531
621
|
* @returns {string} Title for the first chunk of a reply.
|
|
532
622
|
*/
|
|
@@ -554,6 +644,23 @@ export class Bridge {
|
|
|
554
644
|
}
|
|
555
645
|
}
|
|
556
646
|
|
|
647
|
+
/**
|
|
648
|
+
* Tells the owner the bridge has started and is accepting messages
|
|
649
|
+
* (the startup case of SPEC §13 proactive notifications). Called by the
|
|
650
|
+
* daemon once the mesh side is announcing and the RPC child is ready.
|
|
651
|
+
* Best-effort via {@link deliver}: a failure is noted and carried by
|
|
652
|
+
* the next successful delivery instead of being lost.
|
|
653
|
+
*
|
|
654
|
+
* @param {string|null} [resumedSessionFile] - Absolute path of the
|
|
655
|
+
* session resumed from the persisted pointer, when one exists.
|
|
656
|
+
*/
|
|
657
|
+
async notifyStartup(resumedSessionFile = null) {
|
|
658
|
+
const resumed = resumedSessionFile
|
|
659
|
+
? `\nResuming session ${basename(resumedSessionFile)}.`
|
|
660
|
+
: "";
|
|
661
|
+
await this.deliver(`🟢 pi-lxmf ready — listening for messages.${resumed}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
557
664
|
/**
|
|
558
665
|
* @param {string} reason
|
|
559
666
|
*/
|
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/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
|
*
|