@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,204 @@
1
+ /**
2
+ * The surface of pi that flypi depends on, and a check that a candidate tree still offers it
3
+ * (docs/pi-upgrades.md Phase 8.2, risk 14).
4
+ *
5
+ * This is a **pre-filter, not a gate**. It catches the dominant breakage class — a renamed or dropped export,
6
+ * a moved internal file, a raised Node floor — in about a second and without restarting anything. It cannot
7
+ * see the behavioural half of pi-api.md: pi 1.4.0 kept every export and changed the *shape* of the
8
+ * `before_agent_start` event, which no `typeof` check can find. Only `npm test` and `npm run smoke` catch
9
+ * that class, and they are the gate.
10
+ *
11
+ * The list is **generated from the source, never maintained by hand**: `tests/scripts/pi-compat.test.ts`
12
+ * greps every `from "@earendil-works/…"` in `src/ scripts/ tests/ ui/` and fails when the two disagree. A
13
+ * hand-kept list that drifts reports a clean pass, which is worse than no list.
14
+ *
15
+ * Three specifiers, not one (docs/pi-upgrades.md C3): `pi-coding-agent` is the bulk, `pi-mcp` is imported by
16
+ * the memory smoke script and three tests without being a declared dependency of flypi, and
17
+ * `pi-ai/providers/faux` is the stub every model-touching test runs on. A probe that checked only
18
+ * `pi-coding-agent` would report a clean pass while the whole suite was broken.
19
+ */
20
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
+
24
+ /** One specifier flypi imports from, and what it takes from it. */
25
+ export interface PiImport {
26
+ /** The bare specifier, exactly as the source writes it. */
27
+ specifier: string;
28
+ /** Names with a runtime presence. flypi calls every one of them, so each must stay a function. */
29
+ values: string[];
30
+ /** Type-only names: no runtime check is possible, so the entry's `.d.ts` is checked instead. */
31
+ types: string[];
32
+ }
33
+
34
+ export interface PiDeepImport {
35
+ /** The package whose internals flypi reaches into by path. */
36
+ specifier: string;
37
+ /** The file inside it, as `src/agent/pi-mcp.ts` joins it onto `getPackageDir()`. */
38
+ path: string;
39
+ }
40
+
41
+ /** What flypi imports from pi. Kept honest by `tests/scripts/pi-compat.test.ts`, not by anyone's memory. */
42
+ export const PI_COMPAT: { imports: PiImport[]; deep: PiDeepImport[] } = {
43
+ imports: [
44
+ {
45
+ specifier: "@earendil-works/pi-coding-agent",
46
+ values: [
47
+ "createAgentSession",
48
+ "DefaultResourceLoader",
49
+ "DefaultPackageManager",
50
+ "SettingsManager",
51
+ "SessionManager",
52
+ "ModelRuntime",
53
+ "getPackageDir",
54
+ "createMcpExtension",
55
+ "createToolSearchExtension",
56
+ ],
57
+ types: [
58
+ "AgentSessionEvent",
59
+ "CreateAgentSessionOptions",
60
+ "CustomMessageEntry",
61
+ "ExtensionAPI",
62
+ "ExtensionContext",
63
+ "ExtensionFactory",
64
+ "ExtensionUIContext",
65
+ "ExtensionUIDialogOptions",
66
+ "LoadedMcpConfig",
67
+ "McpExtensionOptions",
68
+ "McpServerConfig",
69
+ "McpServerEntry",
70
+ "SessionEntry",
71
+ ],
72
+ },
73
+ { specifier: "@earendil-works/pi-mcp", values: ["McpClient", "StreamableHttpTransport"], types: [] },
74
+ {
75
+ specifier: "@earendil-works/pi-ai/providers/faux",
76
+ values: ["fauxAssistantMessage", "fauxProvider", "fauxText", "fauxThinking", "fauxToolCall"],
77
+ types: ["FauxProviderHandle", "FauxResponseStep"],
78
+ },
79
+ ],
80
+ // Reached by path because the entry does not export them and its `exports` map hides the types
81
+ // (src/agent/pi-mcp.ts). A moved internal file breaks flypi while every export still passes.
82
+ deep: [
83
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/extensions/mcp/runtime.js" },
84
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/core/auth-storage.js" },
85
+ { specifier: "@earendil-works/pi-coding-agent", path: "dist/core/resolve-config-value.js" },
86
+ ],
87
+ };
88
+
89
+ /** `engines.node: ">=22.19.0"` against the Node that would run it. Numeric parts, left to right. */
90
+ function nodeSatisfied(range: string, version: string): boolean {
91
+ const min = /(\d+)\.(\d+)\.(\d+)/.exec(range);
92
+ if (!min) return true;
93
+ const parts = version.split(".").map(Number);
94
+ for (let i = 1; i <= 3; i++) {
95
+ const diff = Math.sign((parts[i - 1] ?? 0) - Number(min[i]));
96
+ if (diff) return diff > 0;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /** Walk up from a resolved file to the package that owns it — `dist/index.js` is not the package root. */
102
+ function packageRoot(fromFile: string, name: string): string | null {
103
+ let dir = dirname(fromFile);
104
+ while (dir !== dirname(dir)) {
105
+ try {
106
+ if ((JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string }).name === name) return dir;
107
+ } catch {
108
+ /* keep walking */
109
+ }
110
+ dir = dirname(dir);
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Resolve specifiers **from inside the candidate tree**. `import.meta.resolve(spec, parentURL)` cannot do
117
+ * it — Node 24 ignores the parent argument and resolves from the calling module, which would have the probe
118
+ * check flypi's own pi and report a clean pass for a candidate that is not even there. A module written at
119
+ * the candidate's root resolves from its own location, which is the tree being probed.
120
+ */
121
+ async function resolverFor(root: string): Promise<(specifier: string) => string | null> {
122
+ const helper = join(root, ".flypi-probe-resolver.mjs");
123
+ writeFileSync(helper, "export const resolveFrom = (specifier) => import.meta.resolve(specifier);\n");
124
+ let resolveFrom: (specifier: string) => string;
125
+ try {
126
+ ({ resolveFrom } = await import(pathToFileURL(helper).href));
127
+ } finally {
128
+ rmSync(helper, { force: true });
129
+ }
130
+ return (specifier) => {
131
+ try {
132
+ const url = resolveFrom(specifier);
133
+ return fileURLToPath(url).startsWith(join(root, "node_modules") + "/") ? url : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+ }
139
+
140
+ /**
141
+ * What a candidate tree is missing — one sentence per problem, empty when it looks usable. A complete tree
142
+ * resolves its own third-party dependencies, so the probe asks the candidate rather than flypi.
143
+ */
144
+ export async function checkPiCompat(candidateRoot: string): Promise<string[]> {
145
+ const failures: string[] = [];
146
+ let root: string;
147
+ try {
148
+ root = realpathSync(candidateRoot);
149
+ } catch {
150
+ return [`${candidateRoot} is not a directory — nothing to probe`];
151
+ }
152
+ if (!existsSync(join(root, "node_modules", "@earendil-works"))) return [`${candidateRoot} has no @earendil-works in its node_modules — that is not a candidate pi tree`];
153
+ const resolveFrom = await resolverFor(root);
154
+
155
+ for (const entry of PI_COMPAT.imports) {
156
+ const url = resolveFrom(entry.specifier);
157
+ if (!url) {
158
+ failures.push(`${entry.specifier}: does not resolve from inside ${candidateRoot}`);
159
+ continue;
160
+ }
161
+ let mod: Record<string, unknown>;
162
+ try {
163
+ // The query busts the ESM module cache: asked twice in one process, the probe has to look at the
164
+ // tree again rather than at what the first look left loaded.
165
+ mod = (await import(`${url}?pi-probe=1`)) as Record<string, unknown>;
166
+ } catch (error) {
167
+ failures.push(`${entry.specifier}: importing it threw — ${(error as Error).message}`);
168
+ continue;
169
+ }
170
+ for (const name of entry.values) {
171
+ if (typeof mod[name] !== "function") failures.push(`${entry.specifier}: ${name} is ${typeof mod[name]}, and flypi calls it as a function`);
172
+ }
173
+ if (entry.types.length === 0) continue;
174
+ // The entry's own declaration file: `dist/index.d.ts` for the package entry,
175
+ // `dist/providers/faux.d.ts` for the subpath. Every type flypi imports is re-exported there by name.
176
+ const declaration = fileURLToPath(url.replace(/\.js$/, ".d.ts"));
177
+ if (!existsSync(declaration)) {
178
+ failures.push(`${entry.specifier}: no ${declaration.slice(declaration.lastIndexOf("/") + 1)} beside the entry, so its type exports cannot be checked`);
179
+ continue;
180
+ }
181
+ const text = readFileSync(declaration, "utf8");
182
+ for (const name of entry.types) {
183
+ if (!new RegExp(`\\b${name}\\b`).test(text)) failures.push(`${entry.specifier}: the type ${name} is no longer re-exported from the entry`);
184
+ }
185
+ }
186
+
187
+ for (const deep of PI_COMPAT.deep) {
188
+ const url = resolveFrom(deep.specifier);
189
+ const owner = url ? packageRoot(fileURLToPath(url), deep.specifier) : null;
190
+ const file = owner ? join(owner, deep.path) : "";
191
+ if (!url || !owner || !existsSync(file)) failures.push(`${deep.specifier}: ${deep.path} is not there, and flypi imports it by path`);
192
+ }
193
+
194
+ // The Node floor: a candidate that needs a newer Node than the daemon runs on fails at import, which is
195
+ // the failure that leaves no web UI to click rollback on (docs/risks.md risk 13).
196
+ const entry = resolveFrom("@earendil-works/pi-coding-agent");
197
+ if (entry) {
198
+ const packageJson = join(packageRoot(fileURLToPath(entry), "@earendil-works/pi-coding-agent") ?? "", "package.json");
199
+ const engines = existsSync(packageJson) ? (JSON.parse(readFileSync(packageJson, "utf8")) as { engines?: { node?: string } }).engines?.node : undefined;
200
+ if (engines && !nodeSatisfied(engines, process.versions.node)) failures.push(`this pi needs Node ${engines}; this process is ${process.versions.node}`);
201
+ }
202
+
203
+ return failures;
204
+ }
@@ -11,13 +11,13 @@
11
11
  * and so is the run store, which is a file either entrypoint can open in a millisecond. Everything that makes
12
12
  * it a *daemon* — the reconciliation, `/healthz`, `daemon.json`, the queue — lives here, and is tested here.
13
13
  */
14
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { spawn } from "node:child_process";
16
16
  import { join, resolve } from "node:path";
17
17
  import type { DatabaseSync } from "node:sqlite";
18
18
  import type { FlypiPaths } from "../agent/paths.ts";
19
19
  import { applyConfigEdits, ensureAgentConfig, ensureMemoryEntry, memoryEndpoint } from "../agent/config-files.ts";
20
- import { applyPackageEdits, checkPackageUpdates, dueForPackageCheck, readPackageCheck, writePackageCheck } from "../agent/packages.ts";
20
+ import { applyPackageEdits, autoExtensions, checkPackageUpdates, dueForPackageCheck, readPackageCheck, writePackageCheck } from "../agent/packages.ts";
21
21
  import { CONSOLIDATE_JOB, consolidate, sweepDue } from "../memory/consolidate.ts";
22
22
  import { startMemoryServer, type MemoryServerHandle } from "../memory/mcp.ts";
23
23
  import type { MemoryStore } from "../memory/store.ts";
@@ -30,6 +30,7 @@ import { startFlypiServer, type FlypiServerHandle, type FlypiServerOptions } fro
30
30
  import { SessionRegistry, type CreateSession } from "../server/session-registry.ts";
31
31
  import type { RunStore } from "../server/run-store.ts";
32
32
  import { JobQueue, startJobLoop, type JobHandler } from "./jobs.ts";
33
+ import { PI_PACKAGE, dueForPiCheck, loadedPi, piTrees, readPiCheck, writePiCheck } from "./pi.ts";
33
34
  import { IDLE_BEFORE_INSTALL_MS, checkForUpdate, clearUpdateCheck, downloadUpdate, dueForCheck, installArgs, isGlobalInstall, isNewer, keepDownload, packageDir, packageInfo, readUpdateCheck, stagedUpdate, updateAvailable, writeUpdateCheck } from "./update.ts";
34
35
 
35
36
  /** What `.flypi/daemon.json` holds: enough for `flypi status` to find a running daemon again. */
@@ -191,6 +192,7 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
191
192
  pid: process.pid,
192
193
  startedAt: startedAt,
193
194
  version,
195
+ pi: loadedPi(paths.root),
194
196
  memoryPort: memory.port,
195
197
  jobs: queue.queued(),
196
198
  ...(newer ? { updateAvailable: newer } : {}),
@@ -205,6 +207,42 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
205
207
  interruptedRun: (sessionFile) => options.runs.interruptedFor(sessionFile),
206
208
  // The page's "install now": the same path the idle rule takes, without the wait.
207
209
  installUpdate: () => installStaged(),
210
+ // Which pi this is, what is on disk to move to, and the daily registry answer. No network here: the
211
+ // stamp is what the page shows (docs/pi-upgrades.md Phase 8.4).
212
+ piVersions: () => {
213
+ const pi = loadedPi(paths.root);
214
+ const stamp = readPiCheck(paths.root);
215
+ return {
216
+ running: pi.version,
217
+ pinned: pi.pinned,
218
+ ...(pi.bundled ? { bundled: pi.bundled } : {}),
219
+ trees: piTrees(paths.root),
220
+ ...(stamp?.next ? { next: stamp.next } : {}),
221
+ ...(stamp?.latest ? { latest: stamp.latest } : {}),
222
+ ...(stamp ? { checkedAt: stamp.checkedAt } : {}),
223
+ };
224
+ },
225
+ // "Check now": the daily registry answer asked again on request, then read back through the same shape.
226
+ piCheck: async () => {
227
+ await checkPiVersions();
228
+ return serverOptions.piVersions!();
229
+ },
230
+ // Move pi and restart, through the same CLI path `flypi pi use` takes — the daemon cannot re-pick the
231
+ // module graph it already imported, and the child is the process that survives the restart to notice a
232
+ // failed start and put the pointer back (docs/risks.md risk 13).
233
+ usePi: async (version: string) => {
234
+ if (pkg.checkout || !isGlobalInstall()) {
235
+ return "this flypi is not the npm global package — in a checkout, `npm install @earendil-works/pi-coding-agent@X && npm test && npm run smoke`; in a container, rebuild the image";
236
+ }
237
+ const logFile = openSync(resolve(paths.root, ".flypi", "daemon.log"), "a");
238
+ const child = spawn(process.execPath, ["--import", "tsx", resolve(packageDir(), "scripts", "pi.ts"), "--root", paths.root, "use", version], { cwd: packageDir(), detached: true, stdio: ["ignore", logFile, logFile] });
239
+ child.on("error", (error) => log(`flypi: the pi switch could not start: ${error.message}`));
240
+ child.unref();
241
+ log(`flypi: moving to pi ${version} — the daemon restarts to run it, and goes back on its own if it does not come up`);
242
+ return null;
243
+ },
244
+ // `hello` carries it too, so a client knows what pi it is talking to without a round trip to /healthz.
245
+ pi: () => loadedPi(paths.root),
208
246
  };
209
247
  let server: FlypiServerHandle;
210
248
  // The same rule as the memory port above, and the reason the pid file is written after the bind rather
@@ -344,12 +382,23 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
344
382
  if (check.error) log(`flypi: no extension update check: ${check.error}`);
345
383
  else if (check.updates.length) log(`flypi: ${check.updates.length} extension package(s) have updates — ${check.updates.join(", ")}; they install the next time the daemon is idle`);
346
384
  };
385
+ // The daily answer about published pi versions. It is only ever *shown*: moving pi restarts the process,
386
+ // so it never happens unattended (docs/pi-upgrades.md — the thing to recover from is exactly an unattended
387
+ // version change). The stamp is what the Versions section reads.
388
+ const checkPiVersions = async () => {
389
+ const check = await checkForUpdate({ current: loadedPi(paths.root).version, name: PI_PACKAGE });
390
+ writePiCheck(paths.root, { checkedAt: check.checkedAt, latest: check.latest, next: check.next, ...(check.error ? { error: check.error } : {}) });
391
+ if (check.next) log(`flypi: pi ${check.next} is available (running ${check.current}) — the Settings tab moves to it; nothing moves by itself`);
392
+ else if (check.error) log(`flypi: no pi version check: ${check.error}`);
393
+ };
394
+
347
395
  const updatePass = (idle: boolean) => {
348
396
  const stamp = readUpdateCheck(paths.root);
349
397
  const pkgStamp = readPackageCheck(paths.root);
350
398
  // A checkout is not an installed package, but the extensions it loads are: only flypi's own ladder is
351
399
  // skipped there.
352
400
  if (!pkg.checkout && dueForCheck(stamp)) void checkUpdates();
401
+ if (!pkg.checkout && dueForPiCheck(readPiCheck(paths.root))) void checkPiVersions();
353
402
  if (dueForPackageCheck(pkgStamp)) void checkPackages();
354
403
  const staged = pkg.checkout ? null : stagedUpdate(stamp, version);
355
404
  const pending = pkgStamp?.updates ?? [];
@@ -360,12 +409,17 @@ export async function startFlypiDaemon(options: DaemonOptions): Promise<DaemonHa
360
409
  if (!idleSince) idleSince = Date.now();
361
410
  if (Date.now() - idleSince < IDLE_BEFORE_INSTALL_MS) return;
362
411
  // Packages first: they need no restart, and the flypi install below may take this process away.
363
- if (pending.length) void updatePackages(pending);
412
+ // The choice is the user's (`updates.autoExtensions` in settings.json): an unattended extension
413
+ // update is how an incompatible one arrives.
414
+ if (pending.length && autoExtensions(paths)) void updatePackages(pending);
364
415
  if (staged && stamp?.download?.file) installPackage(staged, stamp.download.file);
365
416
  // Start the clock again either way: a failed install retries after another idle stretch, not every pass.
366
417
  idleSince = 0;
367
418
  };
368
- if (!pkg.checkout) void checkUpdates();
419
+ if (!pkg.checkout) {
420
+ void checkUpdates();
421
+ void checkPiVersions();
422
+ }
369
423
  if (dueForPackageCheck(readPackageCheck(paths.root))) void checkPackages();
370
424
 
371
425
  const handlers: Record<string, JobHandler> = {