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.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- 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
|
+
}
|