@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.
- package/README.md +4 -4
- package/dist/ui/assets/{index-qaKjH5fH.css → index-CBuNmrDT.css} +1 -1
- package/dist/ui/assets/index-Dv6ZykyY.js +102 -0
- package/dist/ui/index.html +2 -2
- package/package.json +12 -4
- package/scripts/flypi.mjs +20 -2
- package/scripts/pi.ts +150 -0
- package/scripts/probe-pi.ts +18 -0
- package/scripts/serve.ts +2 -0
- package/src/agent/packages.ts +104 -11
- package/src/agent/pi-compat.ts +204 -0
- package/src/daemon/index.ts +58 -4
- package/src/daemon/pi.ts +373 -0
- package/src/memory/recall.ts +11 -2
- package/src/server/index.ts +32 -2
- package/src/server/protocol.ts +79 -0
- package/dist/ui/assets/index-CG1CuGxu.js +0 -105
package/src/daemon/pi.ts
ADDED
|
@@ -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
|
+
}
|
package/src/memory/recall.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
package/src/server/protocol.ts
CHANGED
|
@@ -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),
|