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 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.5 z.ai GLM quota watcher and peak-hours warning
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": … }`), keyed by the first 16 hex chars of
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lxmf",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Drive the Pi coding agent over LXMF messaging (Reticulum mesh) from a headless server.",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Henri Bergius <henri.bergius@iki.fi>",
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 so the next start
103
- // resumes this session.
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
- const sessionPointer = readSessionPointer(config.dataDir, config.workdir);
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: config.workdir,
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, config.workdir),
186
- saveSession: (file) =>
187
- writeSessionPointer(config.dataDir, config.workdir, file),
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 pointer).
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
- try {
500
- this.state.saveSession(file);
501
- } catch (e) {
502
- this.log.error(`pi-lxmf: could not persist session pointer: ${e}`);
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 { basename } from "node:path";
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), { sessionFile });
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
  *