pi-freeflow 1.44.0 → 1.45.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Runtime resolution and source materialization for pi-freeflow daemon.
3
+ *
4
+ * Handles discovery of Node/Bun runtimes and out-of-node_modules source staging
5
+ * required for Node's --experimental-strip-types execution.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { DAEMON_RUNTIME_ENV, resolveDaemonSrcCacheDir } from "./config.ts";
14
+ import { renameWithRetry } from "./atomic-write.ts";
15
+ import { logInfo, logWarn } from "./logger.ts";
16
+
17
+ export interface DaemonRuntime {
18
+ execPath: string;
19
+ args: string[];
20
+ runtimeType: "bun" | "node";
21
+ }
22
+
23
+ /**
24
+ * Detect whether an executable path is a JavaScript/TypeScript runtime (bun or node).
25
+ * Rejects host binaries like omp, pi, electron, code, etc.
26
+ */
27
+ export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
28
+ if (!execPath || typeof execPath !== "string") return null;
29
+ const checkName = (target: string): "bun" | "node" | null => {
30
+ const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.(?:exe|cmd|bat)$/i, "");
31
+ if (base === "bun" || base === "bunx") return "bun";
32
+ if (base === "node" || base === "nodejs") return "node";
33
+ return null;
34
+ };
35
+
36
+ const direct = checkName(execPath);
37
+ if (direct) return direct;
38
+
39
+ try {
40
+ const real = fs.realpathSync(execPath);
41
+ const realMatch = checkName(real);
42
+ if (realMatch) return realMatch;
43
+ } catch { }
44
+
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Search directories in PATH for an executable matching name.
50
+ * On Windows, tests extensions from PATHEXT (.exe, .cmd, .bat).
51
+ * On POSIX, checks file existence and executable bit (X_OK).
52
+ */
53
+ export function findExecutableOnPath(
54
+ name: string,
55
+ envPath: string = process.env.PATH || process.env.Path || "",
56
+ ): string | null {
57
+ if (!envPath || typeof envPath !== "string") return null;
58
+ const dirs = envPath.split(path.delimiter).filter(Boolean);
59
+ const hasExt = path.extname(name).length > 0;
60
+ const extensions = process.platform === "win32"
61
+ ? (hasExt ? [""] : (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"]))
62
+ : [""];
63
+
64
+ for (const dir of dirs) {
65
+ for (const ext of extensions) {
66
+ const candidate = ext ? path.join(dir, `${name}${ext}`) : path.join(dir, name);
67
+ try {
68
+ const stat = fs.statSync(candidate);
69
+ if (stat.isFile()) {
70
+ if (process.platform !== "win32") {
71
+ fs.accessSync(candidate, fs.constants.X_OK);
72
+ }
73
+ return candidate;
74
+ }
75
+ } catch { }
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Search directories in PATH for a Node.js binary meeting the >= 22.6 requirement
82
+ * for native type stripping (--experimental-strip-types).
83
+ */
84
+ export function findSupportedNodeOnPath(
85
+ envPath: string = process.env.PATH || process.env.Path || "",
86
+ ): string | null {
87
+ if (!envPath || typeof envPath !== "string") return null;
88
+ const dirs = envPath.split(path.delimiter).filter(Boolean);
89
+ const extensions = process.platform === "win32"
90
+ ? (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"])
91
+ : [""];
92
+
93
+ for (const dir of dirs) {
94
+ for (const ext of extensions) {
95
+ const candidate = ext ? path.join(dir, `node${ext}`) : path.join(dir, "node");
96
+ try {
97
+ const stat = fs.statSync(candidate);
98
+ if (!stat.isFile()) continue;
99
+ if (process.platform !== "win32") {
100
+ fs.accessSync(candidate, fs.constants.X_OK);
101
+ }
102
+ if (stat.size === 0) {
103
+ return candidate;
104
+ }
105
+ const res = spawnSync(candidate, ["--version"], {
106
+ encoding: "utf8",
107
+ timeout: 1500,
108
+ windowsHide: true,
109
+ shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(candidate),
110
+ });
111
+ if (res.status === 0) {
112
+ const match = (res.stdout || "").match(/^v?(\d+)\.(\d+)\./);
113
+ if (match) {
114
+ const major = Number(match[1]);
115
+ const minor = Number(match[2]);
116
+ if (major < 22 || (major === 22 && minor < 6)) {
117
+ continue;
118
+ }
119
+ }
120
+ }
121
+ return candidate;
122
+ } catch { }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+
129
+ /**
130
+ * Resolve the executable and arguments needed to run src/daemon.ts.
131
+ *
132
+ * Priority order:
133
+ * 1. Explicit override via opts.overrideExecPath or PI_FREEFLOW_DAEMON_RUNTIME env var.
134
+ * 2. Current process.execPath (if and only if it is actually a node or bun executable).
135
+ * 3. Search PATH for `bun` (preferred: native TypeScript support with zero flags).
136
+ * 4. Search PATH for `node` (Node.js >= 22.6 with --experimental-strip-types).
137
+ *
138
+ * Returns null if no valid runtime is found (e.g. running as a standalone `omp` binary
139
+ * without bun or node on PATH). The caller MUST fall back to an in-process proxy rather
140
+ * than spawning an arbitrary binary (which would cause recursive agent fork-bombs).
141
+ */
142
+ export function resolveDaemonRuntime(opts?: {
143
+ overrideExecPath?: string;
144
+ scriptPath?: string;
145
+ envPath?: string;
146
+ currentExecPath?: string;
147
+ }): DaemonRuntime | null {
148
+ const script = opts?.scriptPath ?? daemonScriptPath();
149
+ const envExec = opts?.overrideExecPath ?? (typeof process.env[DAEMON_RUNTIME_ENV] === "string" ? process.env[DAEMON_RUNTIME_ENV]?.trim() : undefined);
150
+
151
+ // 1. Explicit override
152
+ if (envExec) {
153
+ const type = isJsRuntimeExecutable(envExec) ?? "node";
154
+ const args = type === "bun" ? [script] : ["--experimental-strip-types", script];
155
+ return { execPath: envExec, args, runtimeType: type };
156
+ }
157
+
158
+ // 2. Check current process.execPath
159
+ const currentExec = opts?.currentExecPath ?? process.execPath;
160
+ const currentType = isJsRuntimeExecutable(currentExec);
161
+ if (currentType !== null) {
162
+ const args = currentType === "bun" ? [script] : ["--experimental-strip-types", script];
163
+ return { execPath: currentExec, args, runtimeType: currentType };
164
+ }
165
+
166
+ // 3. Current executable is not a JS runtime (e.g. omp, pi, electron).
167
+ // Search PATH for bun first, then node.
168
+ const pathVal = opts?.envPath ?? process.env.PATH ?? process.env.Path ?? "";
169
+
170
+ const bunOnPath = findExecutableOnPath("bun", pathVal);
171
+ if (bunOnPath) {
172
+ return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
173
+ }
174
+
175
+ const nodeOnPath = findSupportedNodeOnPath(pathVal);
176
+ if (nodeOnPath) {
177
+ return {
178
+ execPath: nodeOnPath,
179
+ args: ["--experimental-strip-types", script],
180
+ runtimeType: "node",
181
+ };
182
+ }
183
+
184
+ return null;
185
+ }
186
+
187
+ export function daemonScriptPath(): string {
188
+ const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
189
+ return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
190
+ }
191
+
192
+ /** True when any path segment of `filePath` is `node_modules`. Node refuses to
193
+ * strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
194
+ * so a Node-spawned daemon must run from a staged copy elsewhere. */
195
+ export function isUnderNodeModules(filePath: string): boolean {
196
+ return filePath.split(/[\\/]/).includes("node_modules");
197
+ }
198
+
199
+ export function readPackageJsonObject(dir: string): Record<string, unknown> | null {
200
+ try {
201
+ const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
202
+ return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /** Resolve a dependency's package directory the way Node would: walk up the
209
+ * node_modules chain from the importing package's root. */
210
+ export function resolvePackageDir(name: string, fromDir: string): string | null {
211
+ let dir = fromDir;
212
+ for (; ;) {
213
+ const pkgJson = path.join(dir, "node_modules", name, "package.json");
214
+ if (fs.existsSync(pkgJson)) return path.dirname(pkgJson);
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) return null;
217
+ dir = parent;
218
+ }
219
+ }
220
+
221
+ /** Link (or copy, when linking is unavailable) each runtime dependency beside
222
+ * the staged sources so bare imports such as `undici` still resolve. */
223
+ export function stageRuntimeDependencies(pkgRoot: string, pkgJson: Record<string, unknown>, stagedDir: string): void {
224
+ const deps = pkgJson.dependencies;
225
+ if (!deps || typeof deps !== "object") return;
226
+ const nodeModulesDir = path.join(stagedDir, "node_modules");
227
+ for (const name of Object.keys(deps as Record<string, unknown>)) {
228
+ const target = resolvePackageDir(name, pkgRoot);
229
+ if (target === null) {
230
+ logWarn("daemon dependency not found for staging", { dependency: name });
231
+ continue;
232
+ }
233
+ try {
234
+ fs.mkdirSync(nodeModulesDir, { recursive: true });
235
+ // Junction on Windows (no elevated privileges needed); dir symlink elsewhere.
236
+ fs.symlinkSync(target, path.join(nodeModulesDir, name), process.platform === "win32" ? "junction" : "dir");
237
+ } catch {
238
+ try {
239
+ fs.cpSync(target, path.join(nodeModulesDir, name), { recursive: true, dereference: true });
240
+ } catch (e) {
241
+ logWarn("daemon dependency staging failed", { dependency: name, error: String(e) });
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Best-effort prune of staged daemon sources from earlier versions (1 day TTL). */
248
+ export function pruneStaleDaemonSrcDirs(root: string, currentVersion: string): void {
249
+ try {
250
+ const now = Date.now();
251
+ for (const entry of fs.readdirSync(root)) {
252
+ if (entry === currentVersion) continue;
253
+ const full = path.join(root, entry);
254
+ try {
255
+ if (now - fs.statSync(full).mtimeMs > 24 * 60 * 60 * 1000) {
256
+ fs.rmSync(full, { recursive: true, force: true });
257
+ }
258
+ } catch { }
259
+ }
260
+ } catch { }
261
+ }
262
+
263
+ /**
264
+ * Materialize the daemon's sources outside node_modules so a Node runtime can
265
+ * execute `src/daemon.ts` (Node refuses type stripping for any file under a
266
+ * node_modules path, and this package ships TypeScript sources). Copies
267
+ * `src/` + `package.json` into a per-version cache directory, stages the
268
+ * runtime dependencies beside them, and returns the staged daemon entry.
269
+ * Returns the input path unchanged when the script is already outside
270
+ * node_modules (dev checkouts) or the package root is unreadable.
271
+ */
272
+ export function materializeDaemonSources(scriptPath: string, cacheDir?: string): string {
273
+ if (!isUnderNodeModules(scriptPath)) return scriptPath;
274
+ const pkgRoot = path.dirname(path.dirname(scriptPath));
275
+ const pkgJson = readPackageJsonObject(pkgRoot);
276
+ const version = pkgJson !== null && typeof pkgJson.version === "string" ? pkgJson.version : "";
277
+ if (pkgJson === null || !version) return scriptPath;
278
+
279
+ const root = cacheDir ?? resolveDaemonSrcCacheDir();
280
+ const finalDir = path.join(root, version);
281
+ const finalEntry = path.join(finalDir, "src", "daemon.ts");
282
+ if (fs.existsSync(finalEntry) && fs.existsSync(path.join(finalDir, "package.json"))) {
283
+ pruneStaleDaemonSrcDirs(root, version);
284
+ return finalEntry;
285
+ }
286
+
287
+ const tmpDir = `${finalDir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
288
+ try {
289
+ fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
290
+ fs.cpSync(path.join(pkgRoot, "src"), path.join(tmpDir, "src"), { recursive: true });
291
+ fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
292
+ stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
293
+ try {
294
+ renameWithRetry(tmpDir, finalDir);
295
+ logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
296
+ } catch (e) {
297
+ // A concurrent starter won the rename (or the dir appeared meanwhile):
298
+ // its copy is byte-identical for the same version, so reuse it.
299
+ try {
300
+ fs.rmSync(tmpDir, { recursive: true, force: true });
301
+ } catch { }
302
+ if (!fs.existsSync(finalEntry)) {
303
+ logWarn("daemon source staging failed", { error: String(e), dir: finalDir });
304
+ return scriptPath;
305
+ }
306
+ }
307
+ } catch (e) {
308
+ try {
309
+ fs.rmSync(tmpDir, { recursive: true, force: true });
310
+ } catch { }
311
+ logWarn("daemon source staging failed — spawning from the installed path", { error: String(e), dir: tmpDir });
312
+ return scriptPath;
313
+ }
314
+ pruneStaleDaemonSrcDirs(root, version);
315
+ return finalEntry;
316
+ }