@makerstufferupper/flypi 0.1.3 → 0.1.4

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Which pi a flypi runs, and how it gets moved (docs/pi-upgrades.md Phase 8.1 and 8.3).
3
+ *
4
+ * The version is read off the module that was **loaded**, never off flypi's own `package.json`: the whole
5
+ * point of the field is that those two can disagree. A checkout resolves pi from its lockfile, an installed
6
+ * flypi resolves it from its own `node_modules`, and a pin chooses a third tree that is neither. It cannot be
7
+ * read off a specifier either — pi's `exports` map does not publish `package.json`, so
8
+ * `import.meta.resolve("…/package.json")` throws (docs/pi-upgrades.md C1); it is the entry's directory, twice
9
+ * up.
10
+ *
11
+ * How the move works: `.flypi/pi/<version>/` holds a complete npm tree (npm resolved pi's sibling packages
12
+ * and their dependencies, so Node resolves them the way the process that would run them does), and
13
+ * `<package dir>/node_modules/@earendil-works` is a **symlink** at the chosen tree's scope directory. That
14
+ * is why nothing in the resolution path has to change: `flypi start`, `npm run daemon`, the Docker `CMD`,
15
+ * `probe` and the tests all resolve bare specifiers from the package dir and get the chosen pi with no flag,
16
+ * no env var and no loader. The tree flypi shipped with is moved to `.flypi/pi/bundled/` the first time a
17
+ * pin is applied — `.flypi/` is state, so a flypi upgrade neither erases it nor touches the pin.
18
+ *
19
+ * The switch is **provisional until the new process answers**: the link flips, the daemon restarts, and the
20
+ * switch is kept only if `/healthz` reports the candidate version. Otherwise the link goes back and the
21
+ * daemon starts again. This cannot be a web-UI feature — the page is served by the process being replaced
22
+ * (docs/risks.md risk 13) — so the automatic half lives here, in the CLI restart path.
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import { accessSync, constants, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { getPackageDir } from "@earendil-works/pi-coding-agent";
28
+ import { checkPiCompat } from "../agent/pi-compat.ts";
29
+ import { CHECK_INTERVAL_MS, isGlobalInstall, packageDir } from "./update.ts";
30
+
31
+ /** The chosen pi, next to the trees it points at (`.flypi/pi/<version>/`). Written by `flypi pi use`. */
32
+ export const PI_PIN_FILE = join(".flypi", "pi", "pin.json");
33
+ /** The daemon's daily answer about published pi versions, next to the trees. The UI reads this stamp. */
34
+ export const PI_CHECK_FILE = join(".flypi", "pi-check.json");
35
+
36
+ /** What the daily check wrote. Shaped like flypi's own stamp (src/daemon/update.ts) on purpose. */
37
+ export interface PiCheck {
38
+ checkedAt: string;
39
+ latest: string | null;
40
+ next: string | null;
41
+ error?: string;
42
+ }
43
+
44
+ export function readPiCheck(root: string): PiCheck | null {
45
+ try {
46
+ return JSON.parse(readFileSync(join(root, PI_CHECK_FILE), "utf8")) as PiCheck;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function writePiCheck(root: string, check: PiCheck): void {
53
+ mkdirSync(join(root, PI_DIR), { recursive: true });
54
+ writeFileSync(join(root, PI_CHECK_FILE), JSON.stringify(check, null, " "));
55
+ }
56
+
57
+ /** Is the published answer stale? The daemon's day, the same rhythm as flypi's own and the extensions'. */
58
+ export function dueForPiCheck(check: PiCheck | null, now = Date.now()): boolean {
59
+ if (!check?.checkedAt) return true;
60
+ const at = Date.parse(check.checkedAt);
61
+ return Number.isNaN(at) || now - at >= CHECK_INTERVAL_MS;
62
+ }
63
+
64
+ /** Where the trees live, and where the tree flypi shipped with goes when a pin takes over. */
65
+ export const PI_DIR = join(".flypi", "pi");
66
+ export const PI_BUNDLED = join(PI_DIR, "bundled");
67
+ /** The npm name of the pi family. The whole `@earendil-works` scope moves together, never one package. */
68
+ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
69
+
70
+ /** What `.flypi/pi/pin.json` holds. `.flypi/` is state, so a flypi upgrade never touches it. */
71
+ export interface PiPin {
72
+ /** The pi version the pin asks for. */
73
+ version: string;
74
+ /** The version the tree flypi shipped with held before the pin moved it — the way back. */
75
+ from: string | null;
76
+ pinnedAt: string;
77
+ /** The candidate answered `/healthz` reporting this version, so the switch committed. */
78
+ verified: boolean;
79
+ }
80
+
81
+ /** What `/healthz`, `hello` and `flypi status` say about pi. */
82
+ export interface PiInfo {
83
+ /** The version of the pi module this process actually loaded. */
84
+ version: string;
85
+ /** A pin chose it, rather than the tree flypi was installed with. */
86
+ pinned: boolean;
87
+ /** What the tree flypi shipped with holds — present only when that is not what is running. */
88
+ bundled?: string;
89
+ }
90
+
91
+ /** The pin, or null when there is none — which is every machine that has never run `flypi pi use`. */
92
+ export function readPiPin(root: string): PiPin | null {
93
+ try {
94
+ const pin = JSON.parse(readFileSync(join(root, PI_PIN_FILE), "utf8")) as PiPin;
95
+ return typeof pin.version === "string" ? pin : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export function writePiPin(root: string, pin: PiPin): void {
102
+ mkdirSync(join(root, PI_DIR), { recursive: true });
103
+ writeFileSync(join(root, PI_PIN_FILE), JSON.stringify(pin, null, "\t"));
104
+ }
105
+
106
+ /** The loaded pi, and what chose it. */
107
+ export function loadedPi(root: string): PiInfo {
108
+ const version = (JSON.parse(readFileSync(join(getPackageDir(), "package.json"), "utf8")) as { version: string }).version;
109
+ const pin = readPiPin(root);
110
+ const pinned = pin?.version === version;
111
+ // The tree on disk is the truth; the pin's `from` is the record of what was moved aside.
112
+ const bundled = bundledPiVersion(root) ?? pin?.from ?? null;
113
+ return { version, pinned, ...(bundled && bundled !== version ? { bundled } : {}) };
114
+ }
115
+
116
+ /** The scope directory of the running flypi — the thing the link replaces. */
117
+ export function piScope(): string {
118
+ return join(packageDir(), "node_modules", "@earendil-works");
119
+ }
120
+
121
+ export function piTreeDir(root: string, version: string): string {
122
+ return join(root, PI_DIR, version);
123
+ }
124
+
125
+ /** The `@earendil-works` directory inside a tree — what the link points at. */
126
+ function scopeOf(treeDir: string): string {
127
+ return join(treeDir, "node_modules", "@earendil-works");
128
+ }
129
+
130
+ /** The version a tree holds, or null when it is not a pi tree. */
131
+ export function treeVersion(treeDir: string): string | null {
132
+ try {
133
+ return (JSON.parse(readFileSync(join(scopeOf(treeDir), "pi-coding-agent", "package.json"), "utf8")) as { version: string }).version;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** What the package's own `@earendil-works` holds right now — linked or real, that is what a process resolves. */
140
+ export function scopeVersion(scope: string): string | null {
141
+ try {
142
+ return (JSON.parse(readFileSync(join(scope, "pi-coding-agent", "package.json"), "utf8")) as { version: string }).version;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /** The version the tree flypi shipped with holds — the way back when nothing was pinned. */
149
+ export function bundledPiVersion(root: string): string | null {
150
+ return treeVersion(join(root, PI_BUNDLED));
151
+ }
152
+
153
+ /** The pi versions with a tree on disk, newest first. The bundled tree is not one of them. */
154
+ export function piTrees(root: string): string[] {
155
+ try {
156
+ return readdirSync(join(root, PI_DIR))
157
+ .filter((name) => name !== "bundled" && treeVersion(piTreeDir(root, name)) !== null)
158
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A complete npm tree for one pi version. npm does the resolution — pi's sibling packages, their ranges and
166
+ * the integrity checking — for free; the one-line `package.json` only names the three specifiers flypi
167
+ * imports (docs/pi-upgrades.md C3: `pi-mcp` and `pi-ai` are not flypi dependencies, so a tree that names
168
+ * only `pi-coding-agent` would leave the smoke script and the test stub unable to resolve them).
169
+ */
170
+ export function installPiTree(root: string, version: string): { ok: boolean; error?: string } {
171
+ const dir = piTreeDir(root, version);
172
+ mkdirSync(dir, { recursive: true });
173
+ writeFileSync(
174
+ join(dir, "package.json"),
175
+ `${JSON.stringify({ name: "flypi-pi-tree", private: true, dependencies: { [PI_PACKAGE]: version, "@earendil-works/pi-mcp": version, "@earendil-works/pi-ai": version } }, null, "\t")}\n`,
176
+ );
177
+ const npm = spawnSync("npm", ["install", "--prefix", dir, "--no-audit", "--no-fund"], { encoding: "utf8" });
178
+ if (npm.status !== 0) return { ok: false, error: npm.stderr?.trim() || `npm exited ${npm.status}` };
179
+ const got = treeVersion(dir);
180
+ if (got !== version) return { ok: false, error: `npm installed ${got ?? "nothing"}, not ${version}` };
181
+ return { ok: true };
182
+ }
183
+
184
+ /** Delete the trees nothing can go back to, keeping the named ones. The bundled tree is never touched. */
185
+ export function gcPiTrees(root: string, keep: string[]): string[] {
186
+ return piTrees(root)
187
+ .filter((version) => !keep.includes(version))
188
+ .filter((version) => {
189
+ rmSync(piTreeDir(root, version), { recursive: true, force: true });
190
+ return true;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Point the package's `@earendil-works` at a tree. The installed scope is moved to `.flypi/pi/bundled/`
196
+ * first, once, so the way back does not need the network. On Windows this is a junction — a symlink there
197
+ * needs developer mode or admin — and if neither can be made the answer is a refusal, not a 100 MB copy.
198
+ */
199
+ export function linkPiScope(root: string, version: string, scope: string): { ok: boolean; message: string } {
200
+ const target = scopeOf(piTreeDir(root, version));
201
+ if (treeVersion(piTreeDir(root, version)) !== version) return { ok: false, message: `there is no pi ${version} tree on disk` };
202
+ const unwritable = unwritableScope(scope);
203
+ if (unwritable) return { ok: false, message: unwritable };
204
+ const link = lstatSync(scope, { throwIfNoEntry: false })?.isSymbolicLink();
205
+ if (link && realpathSync(scope) === realpathSync(target)) return { ok: true, message: `pi already resolves to ${version}` };
206
+
207
+ const bundled = scopeOf(join(root, PI_BUNDLED));
208
+ let replaced = false;
209
+ if (!link && existsSync(scope)) {
210
+ // Only the first pin moves it; after that the installed scope is already a link. A real directory here
211
+ // with a bundled copy already kept means an upgrade replaced the package directory and brought a fresh
212
+ // installed pi with it — that one is the baseline now, and the copy we kept is the version the pin has
213
+ // already moved past.
214
+ if (existsSync(bundled)) {
215
+ rmSync(bundled, { recursive: true, force: true });
216
+ replaced = true;
217
+ }
218
+ try {
219
+ mkdirSync(join(bundled, ".."), { recursive: true });
220
+ renameSync(scope, bundled);
221
+ } catch (error) {
222
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") return { ok: false, message: `could not move the installed pi aside: ${(error as Error).message}` };
223
+ // Different volume: copy it instead. Slow, and only happens when `.flypi/` and the global
224
+ // node_modules are on different filesystems.
225
+ cpSync(scope, bundled, { recursive: true });
226
+ rmSync(scope, { recursive: true, force: true });
227
+ }
228
+ }
229
+
230
+ const temp = `${scope}.link`;
231
+ rmSync(temp, { force: true });
232
+ try {
233
+ symlinkSync(target, temp, process.platform === "win32" ? "junction" : "dir");
234
+ renameSync(temp, scope);
235
+ } catch (error) {
236
+ rmSync(temp, { force: true });
237
+ restoreBundledScope(root, scope);
238
+ return { ok: false, message: `could not point ${scope} at ${target}: ${(error as Error).message} — the installed pi has been put back` };
239
+ }
240
+ return { ok: true, message: replaced ? `pi now resolves to ${version}; the pi the upgrade installed is kept as the way back` : `pi now resolves to ${version}` };
241
+ }
242
+
243
+ /** Put the tree flypi shipped with back where npm installed it, and drop the link. */
244
+ export function restoreBundledScope(root: string, scope: string): { ok: boolean; message: string } {
245
+ const bundled = scopeOf(join(root, PI_BUNDLED));
246
+ if (lstatSync(scope, { throwIfNoEntry: false })?.isSymbolicLink()) rmSync(scope, { force: true });
247
+ if (existsSync(scope)) return { ok: true, message: "the installed pi is already in place" };
248
+ if (!existsSync(bundled)) return { ok: false, message: "there is no bundled pi tree to put back — `npm install -g` is the way back from here" };
249
+ renameSync(bundled, scope);
250
+ return { ok: true, message: "the installed pi is back in place" };
251
+ }
252
+
253
+ /**
254
+ * The startup self-heal, beside the other ones (rewriting `mcp.json` when the port moved, releasing stale
255
+ * job claims): a flypi upgrade can replace the package directory and take the link with it, so a pin whose
256
+ * tree exists gets re-applied. Returns a line for the log, or null when there was nothing to say.
257
+ */
258
+ export function relinkFromPin(root: string, scope: string): string | null {
259
+ const pin = readPiPin(root);
260
+ if (!pin) return null;
261
+ const target = scopeOf(piTreeDir(root, pin.version));
262
+ if (!existsSync(target)) return `the pin asks for pi ${pin.version}, but its tree is not on disk — running the pi flypi was installed with`;
263
+ if (lstatSync(scope, { throwIfNoEntry: false })?.isSymbolicLink() && realpathSync(scope) === realpathSync(target)) return null;
264
+ const link = linkPiScope(root, pin.version, scope);
265
+ return link.ok ? `pi re-pointed at the pinned ${pin.version}` : `the pi pin could not be applied: ${link.message}`;
266
+ }
267
+
268
+ /**
269
+ * Whether the pointer can be moved at all. A container runs the app as a user the image's `node_modules` is
270
+ * not writable by, and `.flypi/` sits on a different filesystem from the package dir, so the aside-move goes
271
+ * down the EXDEV path — copy, then delete — and the delete throws. Say so first, in one sentence, rather than
272
+ * half-copying a pi into the volume and taking the stack trace with it (docs/pi-upgrades.md 8.3).
273
+ */
274
+ function unwritableScope(scope: string): string | null {
275
+ const dir = join(scope, "..");
276
+ try {
277
+ accessSync(dir, constants.W_OK);
278
+ return null;
279
+ } catch {
280
+ return `${dir} is not writable by this user, so there is no pointer to move — in a container the image is the pi, so rebuild the image rather than pinning one`;
281
+ }
282
+ }
283
+
284
+ /** What moving pi needs from the caller: the restart, the answer, and the log for when it does not come back. */
285
+ export interface PiSwitchDeps {
286
+ root: string;
287
+ /** Stop the daemon and start it again, waiting until it answers. */
288
+ restart: () => Promise<void>;
289
+ /** What `/healthz` says about pi — null when nothing answered in time. */
290
+ pi: () => Promise<PiInfo | null>;
291
+ /** The last lines of the daemon log, printed when the candidate did not start. */
292
+ logTail?: () => string;
293
+ /** Overridable so the whole rule is testable without a network, a restart or a second daemon. */
294
+ install?: (root: string, version: string) => { ok: boolean; error?: string };
295
+ probe?: (treeDir: string) => Promise<string[]>;
296
+ log?: (line: string) => void;
297
+ /** Overridable because a checkout and a container both refuse, and the test suite is a checkout. */
298
+ global?: () => boolean;
299
+ /** The package's `@earendil-works` directory — the thing the pointer moves. Required, not defaulted:
300
+ * a test that let it fall back to the running package would move that package's own pi. */
301
+ scope: string;
302
+ }
303
+
304
+ /**
305
+ * Move to a published pi version, or back to an older one. Stage 1 (the compat probe) runs before anything
306
+ * moves; the flip is provisional and stage 2 (the restarted daemon answering with the candidate version)
307
+ * decides whether it is kept. Nothing here asks a human to notice a failed start — by then the page would be
308
+ * gone with the process it would click (risk 13).
309
+ */
310
+ export async function switchPiVersion(version: string, deps: PiSwitchDeps): Promise<{ ok: boolean; message: string }> {
311
+ const log = deps.log ?? (() => {});
312
+ const current = loadedPi(deps.root);
313
+ if (version === current.version) return { ok: false, message: `pi ${version} is what is already running` };
314
+ if (!(deps.global ?? isGlobalInstall)()) {
315
+ return {
316
+ ok: false,
317
+ message: "this flypi is not the npm global package, so there is no pointer to move — in a checkout run `npm install @earendil-works/pi-coding-agent@X && npm test && npm run smoke`; in a container the package dir is read-only, so rebuild the image",
318
+ };
319
+ }
320
+
321
+ // Before the tree install: an image the app user cannot write needs a new image, and finding that out
322
+ // after fetching a 23 MB tree leaves the download and a pin nothing confirmed.
323
+ const unwritable = unwritableScope(deps.scope);
324
+ if (unwritable) return { ok: false, message: unwritable };
325
+
326
+ const tree = piTreeDir(deps.root, version);
327
+ if (treeVersion(tree) !== version) {
328
+ log(`installing pi ${version} into ${tree} …`);
329
+ const installed = (deps.install ?? installPiTree)(deps.root, version);
330
+ if (!installed.ok) return { ok: false, message: `pi ${version} could not be installed: ${installed.error}` };
331
+ }
332
+
333
+ const failures = await (deps.probe ?? checkPiCompat)(tree);
334
+ if (failures.length > 0) return { ok: false, message: `pi ${version} does not offer what flypi imports:\n ${failures.join("\n ")}` };
335
+
336
+ // Where to go back to: the pinned version if one is pinned, otherwise what the package's own scope holds
337
+ // — the tree flypi was installed with, which the link is about to move aside.
338
+ const previous = current.pinned ? current.version : (scopeVersion(deps.scope) ?? bundledPiVersion(deps.root) ?? current.version);
339
+ writePiPin(deps.root, { version, from: previous, pinnedAt: new Date().toISOString(), verified: false });
340
+ const link = linkPiScope(deps.root, version, deps.scope);
341
+ if (!link.ok) {
342
+ const pin = readPiPin(deps.root);
343
+ if (pin) writePiPin(deps.root, { version: previous, from: version, pinnedAt: new Date().toISOString(), verified: true });
344
+ return { ok: false, message: link.message };
345
+ }
346
+
347
+ await deps.restart();
348
+ const after = await deps.pi();
349
+ if (after?.version === version) {
350
+ writePiPin(deps.root, { version, from: previous, pinnedAt: new Date().toISOString(), verified: true });
351
+ const removed = gcPiTrees(deps.root, [version, previous]);
352
+ return { ok: true, message: `pi ${version} is running${removed.length ? ` (dropped ${removed.join(", ")})` : ""}` };
353
+ }
354
+
355
+ // It did not come back on the candidate. Go back, start again, and say what the log said — this is the
356
+ // failure with no UI, so it cannot wait for someone to notice.
357
+ const why = after ? `it came back reporting pi ${after.version}` : "it did not answer after the restart";
358
+ log(`pi ${version} did not start (${why}) — going back to ${previous}`);
359
+ if (previous === (bundledPiVersion(deps.root) ?? previous)) {
360
+ restoreBundledScope(deps.root, deps.scope);
361
+ rmSync(join(deps.root, PI_PIN_FILE), { force: true });
362
+ } else {
363
+ linkPiScope(deps.root, previous, deps.scope);
364
+ writePiPin(deps.root, { version: previous, from: version, pinnedAt: new Date().toISOString(), verified: true });
365
+ }
366
+ await deps.restart();
367
+ const back = await deps.pi();
368
+ const tail = deps.logTail?.() ?? "";
369
+ return {
370
+ ok: false,
371
+ message: `pi ${version} did not start (${why}); back on pi ${previous}${back?.version === previous ? " and it answers" : " — and it did not answer either"}.${tail ? `\nLast log lines:\n${tail}` : ""}`,
372
+ };
373
+ }
@@ -173,11 +173,20 @@ export function createMemoryRecallExtension(
173
173
  failures = 0;
174
174
  seen.clear();
175
175
  reported.clear();
176
- prompt = event.prompt;
177
176
  tools.length = 0;
178
177
  modelWroteMemory = false;
179
178
  logged = false;
180
- const outcome = await search(server, event.prompt);
179
+ // pi 1.0.4 gave every run a `prompt`; 1.4.0 split the event in two — a submitted prompt carries
180
+ // `prompt`, an idle `pi.sendMessage(…, { triggerTurn: true })` carries the custom message instead.
181
+ // Retrieval asks with the user's own words, so a custom trigger clears the turn and searches nothing;
182
+ // a *user* turn with empty text still searches, because the refusal is what tells the model recall
183
+ // ran (risk 8). flypi sends no custom trigger-turn messages today.
184
+ if (event.messageType === "custom") {
185
+ prompt = "";
186
+ return;
187
+ }
188
+ prompt = event.prompt;
189
+ const outcome = await search(server, prompt);
181
190
  for (const row of outcome.rows) seen.add(row.id);
182
191
  onInjected?.(renderBlock(outcome));
183
192
  // A search that could not do all it was asked says so. When it brought rows with it the reason goes in
@@ -13,7 +13,8 @@ import { extname, resolve } from "node:path";
13
13
  import { WebSocketServer, type WebSocket } from "ws";
14
14
  import { MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES } from "../shared/attachments.ts";
15
15
  import { parsePath, VIEW_PATHS } from "../shared/views.ts";
16
- import { PROTOCOL_VERSION, encodeFrame, parseClientCommand, type InterruptedRun, type ResultFrame, type SchedulerSnapshot, type ServerFrame } from "./protocol.ts";
16
+ import { PROTOCOL_VERSION, encodeFrame, parseClientCommand, type InterruptedRun, type PiVersions, type ResultFrame, type SchedulerSnapshot, type ServerFrame } from "./protocol.ts";
17
+ import type { PiInfo } from "../daemon/pi.ts";
17
18
  import type { MemoryApi } from "./memory-api.ts";
18
19
  import type { SessionRegistry } from "./session-registry.ts";
19
20
  import type { SecretsApi } from "./secrets-api.ts";
@@ -25,6 +26,11 @@ export interface FlypiServerOptions {
25
26
  port?: number;
26
27
  host?: string;
27
28
  serverVersion?: string;
29
+ /**
30
+ * Which pi this process loaded (docs/pi-upgrades.md Phase 8.1). Sent in `hello` so a client knows what it
31
+ * is talking to before it asks for anything; `/healthz` carries the same object for `flypi status`.
32
+ */
33
+ pi?: () => PiInfo;
28
34
  /** How often to sweep idle, unattached conversations. */
29
35
  evictIntervalMs?: number;
30
36
  /** Built UI to serve (`npm run build`). Omit and the server is headless, as in Phase 1. */
@@ -40,6 +46,12 @@ export interface FlypiServerOptions {
40
46
  * it did not. Absent in a process that does not manage its own updates — a checkout, or `npm run serve`.
41
47
  */
42
48
  installUpdate?: () => Promise<string | null>;
49
+ /** Which pi this process runs and what it could move to (docs/pi-upgrades.md Phase 8.4). */
50
+ piVersions?: () => PiVersions;
51
+ /** The registry asked now, with the answer written to the stamp. Absent where `piVersions` is. */
52
+ piCheck?: () => Promise<PiVersions>;
53
+ /** Move to a pi version and restart. Null when it started; a sentence when it refused. */
54
+ usePi?: (version: string) => Promise<string | null>;
43
55
  /** The scheduler tab: what is queued, when it next runs, what the last runs changed. */
44
56
  scheduler?: () => SchedulerSnapshot;
45
57
  /**
@@ -202,7 +214,7 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
202
214
  }
203
215
  };
204
216
 
205
- send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev" });
217
+ send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev", ...(options.pi ? { pi: options.pi() } : {}) });
206
218
 
207
219
  const handle = async (raw: string): Promise<void> => {
208
220
  const parsed = parseClientCommand(raw);
@@ -306,6 +318,24 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
306
318
  // npm or git does the work, so this is the slow one of the settings commands; the page shows a
307
319
  // busy state while it is open. The refreshed list comes back with the ack, as with saveConfig.
308
320
  return reply(requestId, { packages: await registry.applyPackages(command) });
321
+ case "piVersions":
322
+ // Which pi answers, and what could be moved to. The daemon's daily stamp, never a live
323
+ // check — the same rule `packages` follows.
324
+ if (!options.piVersions) return fail("this flypi process does not manage pi versions — it is a checkout or a headless server", { requestId });
325
+ return reply(requestId, { pi: options.piVersions() });
326
+ case "piCheck":
327
+ // The daily stamp, refreshed on request. Slow by nature (one registry call), and the fresh
328
+ // answer comes back as the same `pi` frame the `piVersions` command sends.
329
+ if (!options.piCheck) return fail("this flypi process does not manage pi versions — it is a checkout or a headless server", { requestId });
330
+ return options.piCheck().then((pi) => reply(requestId, { pi }));
331
+ case "usePi":
332
+ case "rollbackPi": {
333
+ // The pointer moves and the daemon restarts through the CLI, so the ack has to go out
334
+ // first: this socket is the one being replaced. What the page shows afterwards is the
335
+ // version the new process reports, not a promise about this one.
336
+ if (!options.usePi) return fail("this flypi process does not manage pi versions — in a checkout, `npm install @earendil-works/pi-coding-agent@X && npm test && npm run smoke`", { requestId });
337
+ return options.usePi(command.version).then((why) => (why ? fail(why, { requestId }) : reply(requestId)));
338
+ }
309
339
  case "mcpStatus":
310
340
  // Not config and not a conversation: the servers themselves are asked. Slow by nature (a
311
341
  // stdio server has to start), which is why the panel asks once and shows a busy state
@@ -332,7 +332,17 @@ export const PackageRow = Type.Object({
332
332
  scope: Type.Union([Type.Literal("user"), Type.Literal("project")]),
333
333
  installed: Type.Boolean(),
334
334
  path: Type.String(),
335
+ /** The version the installed code's own `package.json` says. A source that names a version is pinned,
336
+ * and this is what tells the reader which version that is (docs/pi-upgrades.md). */
337
+ version: Type.Optional(Type.String()),
335
338
  update: Type.Optional(Type.Boolean()),
339
+ /** The npm package an `npm:` source names, version stripped. Absent for a git URL or a path, and its
340
+ * presence next to a source that is not `npm:<name>` is what reads as pinned. */
341
+ name: Type.Optional(Type.String()),
342
+ /** Published releases, oldest first, from the same daily stamp — what "install this version" offers. */
343
+ versions: Type.Optional(Type.Array(Type.String())),
344
+ /** The newest published release newer than what is installed. */
345
+ available: Type.Optional(Type.String()),
336
346
  });
337
347
 
338
348
  export const PackagesSnapshot = Type.Object({
@@ -367,6 +377,60 @@ export const SavePackagesCommand = Type.Object({
367
377
  requestId: Type.Optional(RequestId),
368
378
  });
369
379
 
380
+ /**
381
+ * Which pi answers, what is on disk to move to, and what the daemon's daily registry answer said (Phase 8.4,
382
+ * docs/pi-upgrades.md). `latest`/`next` are absent until the daemon has asked — the request itself reaches no
383
+ * network, the same rule `packages` follows.
384
+ */
385
+ export const PiVersions = Type.Object({
386
+ /** The version this process loaded. */
387
+ running: Type.String(),
388
+ pinned: Type.Boolean(),
389
+ /** What the tree flypi was installed with holds, when that is not what is running. */
390
+ bundled: Type.Optional(Type.String()),
391
+ /** The pi versions with a tree on disk — what "switch back" can offer. */
392
+ trees: Type.Array(Type.String()),
393
+ next: Type.Optional(Type.String()),
394
+ latest: Type.Optional(Type.String()),
395
+ checkedAt: Type.Optional(Type.String()),
396
+ });
397
+
398
+ export type PiVersions = Static<typeof PiVersions>;
399
+
400
+ /** Ask what pi this flypi runs and what it could move to. Reaches no network. */
401
+ export const PiVersionsCommand = Type.Object({
402
+ type: Type.Literal("piVersions"),
403
+ requestId: Type.Optional(RequestId),
404
+ });
405
+
406
+ /**
407
+ * Ask the registry for pi now instead of on the daemon's daily rhythm. The reply is a fresh `piVersions`, so
408
+ * the page needs no new frame; nothing moves by itself either way.
409
+ */
410
+ export const PiCheckCommand = Type.Object({
411
+ type: Type.Literal("piCheck"),
412
+ requestId: Type.Optional(RequestId),
413
+ });
414
+
415
+ /**
416
+ * Move to a published pi version. The daemon flips the pointer and hands the restart to the CLI — the same
417
+ * path `installUpdate` takes — and the ack goes out before the restart closes this socket. The switch is
418
+ * provisional: if the new process does not answer reporting that version, the pointer goes back on its own
419
+ * (docs/risks.md risk 13), so the page comes back and says which version it is on.
420
+ */
421
+ export const UsePiCommand = Type.Object({
422
+ type: Type.Literal("usePi"),
423
+ version: Type.String({ minLength: 1 }),
424
+ requestId: Type.Optional(RequestId),
425
+ });
426
+
427
+ /** The same call with an older version — only offered for the trees still on disk. */
428
+ export const RollbackPiCommand = Type.Object({
429
+ type: Type.Literal("rollbackPi"),
430
+ version: Type.String({ minLength: 1 }),
431
+ requestId: Type.Optional(RequestId),
432
+ });
433
+
370
434
  /** What kind of secret a row claims to be — the same list the vault's schema allows (src/secrets/crypto.ts). */
371
435
  export const SecretKind = Type.Union([
372
436
  Type.Literal("password"),
@@ -567,6 +631,10 @@ export const ClientCommandSchema = Type.Union([
567
631
  McpRedirectCommand,
568
632
  PackagesCommand,
569
633
  SavePackagesCommand,
634
+ PiVersionsCommand,
635
+ PiCheckCommand,
636
+ UsePiCommand,
637
+ RollbackPiCommand,
570
638
  SecretsCommand,
571
639
  SaveSecretsCommand,
572
640
  RevealSecretCommand,
@@ -579,10 +647,20 @@ export type ClientCommand = Static<typeof ClientCommandSchema>;
579
647
  /** A pi session event, passed through verbatim. Only the discriminator is checked here. */
580
648
  export const SessionEventFrameBody = Type.Object({ type: Type.String() }, { additionalProperties: true });
581
649
 
650
+ /** Which pi answers (docs/pi-upgrades.md Phase 8.1): the version loaded, and what chose it. */
651
+ export const PiInfoFrame = Type.Object({
652
+ version: Type.String(),
653
+ pinned: Type.Boolean(),
654
+ /** What the tree flypi was installed with holds, when that is not what is running. */
655
+ bundled: Type.Optional(Type.String()),
656
+ });
657
+
582
658
  export const HelloFrame = Type.Object({
583
659
  type: Type.Literal("hello"),
584
660
  protocolVersion: Type.Integer(),
585
661
  serverVersion: Type.String(),
662
+ /** Optional so an older server still parses; every flypi server from here on sends it. */
663
+ pi: Type.Optional(PiInfoFrame),
586
664
  });
587
665
 
588
666
  export const EventFrame = Type.Object({
@@ -817,6 +895,7 @@ export const ResultFrame = Type.Object({
817
895
  mcpStatus: Type.Optional(McpStatusSnapshot),
818
896
  config: Type.Optional(ConfigSnapshot),
819
897
  packages: Type.Optional(PackagesSnapshot),
898
+ pi: Type.Optional(PiVersions),
820
899
  scheduler: Type.Optional(SchedulerSnapshot),
821
900
  secrets: Type.Optional(SecretsSnapshot),
822
901
  revealed: Type.Optional(RevealedSecret),