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 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.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.1",
3
+ "version": "0.1.3",
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),
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 pointer).
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
- try {
501
- this.state.saveSession(file);
502
- } catch (e) {
503
- 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
+ }
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 { 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/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 cannot be established, and a periodic sync
220
- // pulls messages that arrived while this daemon was down.
221
- if (config.propagationNode) {
222
- lxmf.setOutboundPropagationNode(fromHex(config.propagationNode));
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) β€” battery-conscious mobile clients tear their link
251
- * down right after their message is acknowledged, so the arrival link can
252
- * be gone by reply time; the same `LXMessage` object is re-sent so both
253
- * wire copies share one message id and a deduplicating client shows the
254
- * reply once (learned in signalk-reticulum's deliverer).
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
  *