pi-freeflow 1.43.0 → 1.44.1
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 +22 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/client-recovery.ts +235 -0
- package/src/client-staging.ts +266 -0
- package/src/client.ts +114 -456
- package/src/cline-accounts.ts +20 -1220
- package/src/cline-pool.ts +641 -0
- package/src/cline-roll.ts +609 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +261 -0
- package/src/commands.ts +121 -1200
- package/src/config-paths.ts +254 -0
- package/src/config.ts +16 -654
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/health.ts +9 -1
- package/src/lane-translation.ts +244 -0
- package/src/opencode-fingerprint.ts +69 -1325
- package/src/opencode-identity.ts +457 -0
- package/src/proxy-lanes.ts +1113 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +134 -1333
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +1 -363
- package/src/relay.ts +7 -8
- package/src/sse-aggregator.ts +658 -0
- package/src/stream-cloak.ts +879 -0
- package/src/stream-pipe.ts +10 -869
- package/src/tool-translation.ts +4 -236
- package/src/update-core.ts +25 -0
- package/src/zen-sidecar.ts +96 -0
- package/src/zen-transport.ts +413 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.44.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Split oversized source modules into focused files with no behavior change. The proxy, command registry, stream pipeline, fingerprint, tool translation, Cline pool, and config internals now live in single-responsibility modules behind the same import paths, so updates and reviews land in smaller files while every command, lane, and gate behaves exactly as before.
|
|
8
|
+
|
|
9
|
+
## 1.44.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Add an opt-in Bun transport for direct OpenCode Zen requests, and fix stream handling on the same lane.
|
|
14
|
+
|
|
15
|
+
Setting `PI_FREEFLOW_ZEN_TRANSPORT=bun-sidecar` sends direct Zen traffic through a bundled Bun helper, so the TLS connection originates from Bun's networking stack while the request bytes stay identical. The helper is used only when a Bun 1.1+ binary is found and passes a quick version check; otherwise the proxy stays on its normal transport, and any helper failure falls back to it per request. Relay routing is skipped while the helper is active, because a relay would originate a different connection. Exporting or clearing `PI_FREEFLOW_ZEN_TRANSPORT=bun-sidecar` automatically restarts an idle background proxy daemon so the transport change takes effect immediately. Direct OpenCode Zen `200 OK` streams now flush immediately instead of buffering the full stream before forwarding.
|
|
16
|
+
|
|
17
|
+
`/freeflow status` now shows the installed `pi-freeflow` version on its first line and displays the active Zen transport mode when `PI_FREEFLOW_ZEN_TRANSPORT` is set. A new `/freeflow version` command (alias `/freeflow ver`) reports the installed plugin version and cached update status offline.
|
|
18
|
+
|
|
19
|
+
The direct lane also stops cutting a response body when the header wait expires after headers have arrived, reports a child that dies mid-body instead of ending the stream silently, and returns responses that must not carry a body (204, 205, 304) correctly. Bun installs whose path contains spaces are now detected, and the helper refuses plain-HTTP targets outside loopback and URLs carrying embedded credentials.
|
|
20
|
+
|
|
21
|
+
The working-directory project variant is now read only when `PI_FREEFLOW_OPENCODE_PROJECT=cwd`; setting the working directory alone keeps the default `global` project value. Opt-in extra request headers can no longer forward credentials, cookies, or hop-by-hop framing headers even when listed.
|
|
22
|
+
|
|
23
|
+
Default traffic is otherwise unchanged: with the new environment variables unset, requests match the previous release except for two deliberate session-continuity fixes. Bodies carrying only camelCase `promptCacheKey` now also gain `prompt_cache_key`, so camel-only callers keep a stable session instead of falling through to a fresh random id per request; and 29-character `ses_` session keys now pass through verbatim instead of being remapped, matching the live CLI shape. The 11-tool roster, User-Agent, project value, lane, and exactly-one environment block are otherwise byte-identical.
|
|
24
|
+
|
|
3
25
|
## 1.43.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -183,7 +183,8 @@ each password once afterwards.
|
|
|
183
183
|
The same command set works identically in OMP and Pi:
|
|
184
184
|
|
|
185
185
|
```bash
|
|
186
|
-
/freeflow status # View active relay, pool status, and candidates
|
|
186
|
+
/freeflow status # View installed version, active relay, pool status, and candidates
|
|
187
|
+
/freeflow version # Show installed plugin version and cached update status (alias: ver)
|
|
187
188
|
/freeflow hide|show # Hide or restore the status-bar widget (also `widget hide`/`widget show`)
|
|
188
189
|
/freeflow list # List all relays with real-time health badges
|
|
189
190
|
/freeflow use <url|index|label> # Switch active relay
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.44.1",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog recovery, backoff, and circuit-breaker logic for pi-freeflow daemon.
|
|
3
|
+
*
|
|
4
|
+
* Implements exponential backoff, stuck-busy heuristics, and automatic
|
|
5
|
+
* circuit-breaker halting when repeated recovery attempts fail.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BREAKER_FAILURES,
|
|
10
|
+
BREAKER_HALT_MS,
|
|
11
|
+
BREAKER_WINDOW_MS,
|
|
12
|
+
BUSY_BYPASS_CONTINUOUS_MS,
|
|
13
|
+
BUSY_BYPASS_QUIET_MS,
|
|
14
|
+
PKG_VERSION,
|
|
15
|
+
RECOVERY_BACKOFF_BASE_MS,
|
|
16
|
+
RECOVERY_BACKOFF_CAP_MS,
|
|
17
|
+
} from "./config.ts";
|
|
18
|
+
import type { DaemonHealthSnapshot } from "./health.ts";
|
|
19
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
20
|
+
import { compareVersions } from "./update-core.ts";
|
|
21
|
+
|
|
22
|
+
/** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
|
|
23
|
+
let busySince = 0;
|
|
24
|
+
/** Consecutive recovery attempts (backoff exponent n). Reset on healthy watchdog tick. */
|
|
25
|
+
let backoffAttempt = 0;
|
|
26
|
+
/** Timestamps of recent recovery failures (breaker window). */
|
|
27
|
+
let recoveryFailures: number[] = [];
|
|
28
|
+
/** Watchdog respawns halted until this time (breaker open). */
|
|
29
|
+
let breakerOpenUntil = 0;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Respawn backoff: min(2s * 2^n, 60s) + jitter. n is the consecutive-failure
|
|
33
|
+
* count; pass jitterMs = 0 in tests for a deterministic base.
|
|
34
|
+
*/
|
|
35
|
+
export function computeRecoveryBackoffMs(attempt: number, jitterMs = Math.random() * 500): number {
|
|
36
|
+
const n = Math.max(0, Math.floor(attempt));
|
|
37
|
+
const base = Math.min(RECOVERY_BACKOFF_BASE_MS * 2 ** n, RECOVERY_BACKOFF_CAP_MS);
|
|
38
|
+
return base + Math.max(0, jitterMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stuck-busy check (pure, testable): a busy daemon may only be replaced after
|
|
43
|
+
* 5min of continuous busy AND zero forwarded stream bytes for 60s+. Anything
|
|
44
|
+
* else must be left untouched mid-request.
|
|
45
|
+
*/
|
|
46
|
+
export function isStuckBusy(busySinceMs: number, lastBytesAt: number, now = Date.now()): boolean {
|
|
47
|
+
if (!busySinceMs) return false;
|
|
48
|
+
if (now - busySinceMs < BUSY_BYPASS_CONTINUOUS_MS) return false;
|
|
49
|
+
if (lastBytesAt && now - lastBytesAt < BUSY_BYPASS_QUIET_MS) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Track the busy/idle edge from a health snapshot; returns the updated busySince. */
|
|
54
|
+
export function trackBusyEdge(activeRequests: number, lastBytesAt: number, now = Date.now()): number {
|
|
55
|
+
if (activeRequests > 0) {
|
|
56
|
+
if (!busySince) busySince = now;
|
|
57
|
+
} else {
|
|
58
|
+
busySince = 0;
|
|
59
|
+
}
|
|
60
|
+
return busySince;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getBusySince(): number {
|
|
64
|
+
return busySince;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resetBusySince(): void {
|
|
68
|
+
busySince = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True while the breaker halt is in effect (respawns paused, fallback serves). */
|
|
72
|
+
export function isBreakerHalted(now = Date.now()): boolean {
|
|
73
|
+
return now < breakerOpenUntil;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Record a recovery failure; opens the breaker after 5 straight failures in 5min. */
|
|
77
|
+
export function recordRecoveryFailure(now = Date.now()): boolean {
|
|
78
|
+
recoveryFailures.push(now);
|
|
79
|
+
recoveryFailures = recoveryFailures.filter((t) => now - t <= BREAKER_WINDOW_MS);
|
|
80
|
+
backoffAttempt += 1;
|
|
81
|
+
if (recoveryFailures.length >= BREAKER_FAILURES) {
|
|
82
|
+
breakerOpenUntil = now + BREAKER_HALT_MS;
|
|
83
|
+
logWarn("recovery breaker open — 5 straight failures in 5min, halting respawn for 10min (in-process fallback meanwhile)");
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Record a healthy watchdog tick: clears the straight-failure run and backoff. */
|
|
90
|
+
export function recordRecoverySuccess(): void {
|
|
91
|
+
recoveryFailures = [];
|
|
92
|
+
backoffAttempt = 0;
|
|
93
|
+
breakerOpenUntil = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Test seam: snapshot of the recovery counters. */
|
|
97
|
+
export function _getRecoveryStateForTest(): {
|
|
98
|
+
backoffAttempt: number;
|
|
99
|
+
failures: number[];
|
|
100
|
+
breakerOpenUntil: number;
|
|
101
|
+
busySince: number;
|
|
102
|
+
} {
|
|
103
|
+
return { backoffAttempt, failures: [...recoveryFailures], breakerOpenUntil, busySince };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test seam: reset recovery counters without touching timers. */
|
|
107
|
+
export function _resetRecoveryForTest(): void {
|
|
108
|
+
backoffAttempt = 0;
|
|
109
|
+
recoveryFailures = [];
|
|
110
|
+
breakerOpenUntil = 0;
|
|
111
|
+
busySince = 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Recovery-decision shape; canonical definition lives in health.ts. */
|
|
115
|
+
export type HealthForRecovery = DaemonHealthSnapshot;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Older-version guard: only a daemon strictly older than this build is worth
|
|
119
|
+
* recovering. shouldReplaceDaemon deliberately leaves a NEWER daemon running,
|
|
120
|
+
* so recovering from one could never succeed (watchdog → recover → refused →
|
|
121
|
+
* reattach → watchdog, until the breaker parks into the in-process fallback).
|
|
122
|
+
* Null/undefined version (pre-version daemon) is not older on its own.
|
|
123
|
+
*/
|
|
124
|
+
export function isOlderDaemonVersion(version: string | null | undefined): boolean {
|
|
125
|
+
return version != null && compareVersions(version, PKG_VERSION) < 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
|
|
130
|
+
* serves an older version, or reports a degraded failed-SSE window. Missing
|
|
131
|
+
* sseDegraded (pre-window daemon) never triggers on its own.
|
|
132
|
+
*/
|
|
133
|
+
export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
|
|
134
|
+
if (health === null) return true;
|
|
135
|
+
if (isOlderDaemonVersion(health.version)) return true;
|
|
136
|
+
if (health.sseDegraded === true) return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RecoveryDeps {
|
|
141
|
+
readonly isEnsuring: () => boolean;
|
|
142
|
+
readonly setEnsuring: (val: boolean) => void;
|
|
143
|
+
readonly ensureInProcessFallback: () => Promise<number>;
|
|
144
|
+
readonly stopHeartbeatInternal: () => void;
|
|
145
|
+
readonly resetHeartbeatPort: () => void;
|
|
146
|
+
readonly attachTo: (port: number) => Promise<void>;
|
|
147
|
+
readonly killStaleDaemon: (port: number, ver: string, what: string) => Promise<boolean>;
|
|
148
|
+
readonly ensureDaemon: () => Promise<number>;
|
|
149
|
+
readonly getDaemonHealth?: (port: number) => Promise<DaemonHealthSnapshot>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function triggerRecovery(port: number, why: string, deps: RecoveryDeps): Promise<void> {
|
|
153
|
+
if (deps.isEnsuring()) return;
|
|
154
|
+
deps.setEnsuring(true);
|
|
155
|
+
try {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
if (isBreakerHalted(now)) {
|
|
158
|
+
logWarn(`recovery breaker halted — serving in-process fallback meanwhile (${why})`);
|
|
159
|
+
void deps.ensureInProcessFallback();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const opened = recordRecoveryFailure(now);
|
|
163
|
+
if (opened) {
|
|
164
|
+
void deps.ensureInProcessFallback();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const delay = computeRecoveryBackoffMs(backoffAttempt - 1);
|
|
168
|
+
logWarn(`watchdog recovery (${why}) — backing off ${Math.round(delay)}ms before respawn`);
|
|
169
|
+
deps.stopHeartbeatInternal();
|
|
170
|
+
deps.resetHeartbeatPort();
|
|
171
|
+
await new Promise<void>((r) => setTimeout(r, delay));
|
|
172
|
+
try {
|
|
173
|
+
const health = deps.getDaemonHealth ? await deps.getDaemonHealth(port) : null;
|
|
174
|
+
if (health && (health.activeRequests ?? 0) > 0) {
|
|
175
|
+
trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0, Date.now());
|
|
176
|
+
if (!isStuckBusy(busySince, health.lastBytesAt ?? 0, Date.now())) {
|
|
177
|
+
logInfo(`watchdog recovery deferred — daemon busy (${health.activeRequests} active, waiting for 5min-stuck + 60s-quiet)`);
|
|
178
|
+
await deps.attachTo(port);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const ver = health?.version ?? PKG_VERSION;
|
|
183
|
+
await deps.killStaleDaemon(port, ver, "proxy daemon");
|
|
184
|
+
} catch (e) {
|
|
185
|
+
logWarn("watchdog pre-respawn probe failed", { error: String(e) });
|
|
186
|
+
}
|
|
187
|
+
// Release before delegating: ensureDaemon owns the flag from here (it
|
|
188
|
+
// early-returns while ensuring is true). The handoff is synchronous up
|
|
189
|
+
// to ensureDaemon's own ensuring=true, so no tick slips between.
|
|
190
|
+
deps.setEnsuring(false);
|
|
191
|
+
await deps.ensureDaemon();
|
|
192
|
+
} finally {
|
|
193
|
+
deps.setEnsuring(false);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 5s watchdog tick: polls /_health for version + failed-SSE rate and drives
|
|
199
|
+
* stopHeartbeat → busy-ruled kill → ensureDaemon when the daemon is stale or
|
|
200
|
+
* degraded. Healthy ticks reset backoff/breaker and track the busy edge.
|
|
201
|
+
*/
|
|
202
|
+
export async function watchdogCheck(port: number, deps: RecoveryDeps): Promise<void> {
|
|
203
|
+
if (deps.isEnsuring()) return;
|
|
204
|
+
if (isBreakerHalted()) return;
|
|
205
|
+
if (!deps.getDaemonHealth) return;
|
|
206
|
+
let health: DaemonHealthSnapshot;
|
|
207
|
+
try {
|
|
208
|
+
health = await deps.getDaemonHealth(port);
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (health === null) {
|
|
213
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
214
|
+
try {
|
|
215
|
+
health = await deps.getDaemonHealth(port);
|
|
216
|
+
} catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (health && (health.activeRequests ?? 0) > 0) {
|
|
221
|
+
trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
|
|
222
|
+
} else {
|
|
223
|
+
trackBusyEdge(0, 0);
|
|
224
|
+
}
|
|
225
|
+
if (!shouldRecoverOnHealth(health)) {
|
|
226
|
+
recordRecoverySuccess();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
|
|
230
|
+
void triggerRecovery(
|
|
231
|
+
port,
|
|
232
|
+
health === null ? "daemon gone" : isOlderDaemonVersion(health.version) ? `stale v${health.version}` : "failed-SSE degraded",
|
|
233
|
+
deps,
|
|
234
|
+
).catch((e) => logWarn("watchdog recovery failed", { error: String(e) }));
|
|
235
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
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 { randomUUID } from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { DAEMON_RUNTIME_ENV, resolveDaemonSrcCacheDir } from "./config.ts";
|
|
13
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
14
|
+
|
|
15
|
+
export interface DaemonRuntime {
|
|
16
|
+
execPath: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
runtimeType: "bun" | "node";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect whether an executable path is a JavaScript/TypeScript runtime (bun or node).
|
|
23
|
+
* Rejects host binaries like omp, pi, electron, code, etc.
|
|
24
|
+
*/
|
|
25
|
+
export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
|
|
26
|
+
if (!execPath || typeof execPath !== "string") return null;
|
|
27
|
+
const checkName = (target: string): "bun" | "node" | null => {
|
|
28
|
+
const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.exe$/i, "");
|
|
29
|
+
if (base === "bun" || base === "bunx") return "bun";
|
|
30
|
+
if (base === "node" || base === "nodejs") return "node";
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const direct = checkName(execPath);
|
|
35
|
+
if (direct) return direct;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const real = fs.realpathSync(execPath);
|
|
39
|
+
const realMatch = checkName(real);
|
|
40
|
+
if (realMatch) return realMatch;
|
|
41
|
+
} catch { }
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Search directories in PATH for an executable matching name.
|
|
48
|
+
* On Windows, tests extensions from PATHEXT (.exe, .cmd, .bat).
|
|
49
|
+
* On POSIX, checks file existence and executable bit (X_OK).
|
|
50
|
+
*/
|
|
51
|
+
export function findExecutableOnPath(
|
|
52
|
+
name: string,
|
|
53
|
+
envPath: string = process.env.PATH || process.env.Path || "",
|
|
54
|
+
): string | null {
|
|
55
|
+
if (!envPath || typeof envPath !== "string") return null;
|
|
56
|
+
const dirs = envPath.split(path.delimiter).filter(Boolean);
|
|
57
|
+
const hasExt = path.extname(name).length > 0;
|
|
58
|
+
const extensions = process.platform === "win32"
|
|
59
|
+
? (hasExt ? [""] : (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"]))
|
|
60
|
+
: [""];
|
|
61
|
+
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
for (const ext of extensions) {
|
|
64
|
+
const candidate = ext ? path.join(dir, `${name}${ext}`) : path.join(dir, name);
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.statSync(candidate);
|
|
67
|
+
if (stat.isFile()) {
|
|
68
|
+
if (process.platform !== "win32") {
|
|
69
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
70
|
+
}
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
} catch { }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the executable and arguments needed to run src/daemon.ts.
|
|
81
|
+
*
|
|
82
|
+
* Priority order:
|
|
83
|
+
* 1. Explicit override via opts.overrideExecPath or PI_FREEFLOW_DAEMON_RUNTIME env var.
|
|
84
|
+
* 2. Current process.execPath (if and only if it is actually a node or bun executable).
|
|
85
|
+
* 3. Search PATH for `bun` (preferred: native TypeScript support with zero flags).
|
|
86
|
+
* 4. Search PATH for `node` (Node.js >= 22.6 with --experimental-strip-types).
|
|
87
|
+
*
|
|
88
|
+
* Returns null if no valid runtime is found (e.g. running as a standalone `omp` binary
|
|
89
|
+
* without bun or node on PATH). The caller MUST fall back to an in-process proxy rather
|
|
90
|
+
* than spawning an arbitrary binary (which would cause recursive agent fork-bombs).
|
|
91
|
+
*/
|
|
92
|
+
export function resolveDaemonRuntime(opts?: {
|
|
93
|
+
overrideExecPath?: string;
|
|
94
|
+
scriptPath?: string;
|
|
95
|
+
envPath?: string;
|
|
96
|
+
currentExecPath?: string;
|
|
97
|
+
}): DaemonRuntime | null {
|
|
98
|
+
const script = opts?.scriptPath ?? daemonScriptPath();
|
|
99
|
+
const envExec = opts?.overrideExecPath ?? (typeof process.env[DAEMON_RUNTIME_ENV] === "string" ? process.env[DAEMON_RUNTIME_ENV]?.trim() : undefined);
|
|
100
|
+
|
|
101
|
+
// 1. Explicit override
|
|
102
|
+
if (envExec) {
|
|
103
|
+
const type = isJsRuntimeExecutable(envExec) ?? "node";
|
|
104
|
+
const args = type === "bun" ? [script] : ["--experimental-strip-types", script];
|
|
105
|
+
return { execPath: envExec, args, runtimeType: type };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Check current process.execPath
|
|
109
|
+
const currentExec = opts?.currentExecPath ?? process.execPath;
|
|
110
|
+
const currentType = isJsRuntimeExecutable(currentExec);
|
|
111
|
+
if (currentType !== null) {
|
|
112
|
+
const args = currentType === "bun" ? [script] : ["--experimental-strip-types", script];
|
|
113
|
+
return { execPath: currentExec, args, runtimeType: currentType };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Current executable is not a JS runtime (e.g. omp, pi, electron).
|
|
117
|
+
// Search PATH for bun first, then node.
|
|
118
|
+
const pathVal = opts?.envPath ?? process.env.PATH ?? process.env.Path ?? "";
|
|
119
|
+
|
|
120
|
+
const bunOnPath = findExecutableOnPath("bun", pathVal);
|
|
121
|
+
if (bunOnPath) {
|
|
122
|
+
return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const nodeOnPath = findExecutableOnPath("node", pathVal);
|
|
126
|
+
if (nodeOnPath) {
|
|
127
|
+
return {
|
|
128
|
+
execPath: nodeOnPath,
|
|
129
|
+
args: ["--experimental-strip-types", script],
|
|
130
|
+
runtimeType: "node",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function daemonScriptPath(): string {
|
|
138
|
+
const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
139
|
+
return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when any path segment of `filePath` is `node_modules`. Node refuses to
|
|
143
|
+
* strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
|
|
144
|
+
* so a Node-spawned daemon must run from a staged copy elsewhere. */
|
|
145
|
+
export function isUnderNodeModules(filePath: string): boolean {
|
|
146
|
+
return filePath.split(/[\\/]/).includes("node_modules");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function readPackageJsonObject(dir: string): Record<string, unknown> | null {
|
|
150
|
+
try {
|
|
151
|
+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
152
|
+
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolve a dependency's package directory the way Node would: walk up the
|
|
159
|
+
* node_modules chain from the importing package's root. */
|
|
160
|
+
export function resolvePackageDir(name: string, fromDir: string): string | null {
|
|
161
|
+
let dir = fromDir;
|
|
162
|
+
for (; ;) {
|
|
163
|
+
const pkgJson = path.join(dir, "node_modules", name, "package.json");
|
|
164
|
+
if (fs.existsSync(pkgJson)) return path.dirname(pkgJson);
|
|
165
|
+
const parent = path.dirname(dir);
|
|
166
|
+
if (parent === dir) return null;
|
|
167
|
+
dir = parent;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Link (or copy, when linking is unavailable) each runtime dependency beside
|
|
172
|
+
* the staged sources so bare imports such as `undici` still resolve. */
|
|
173
|
+
export function stageRuntimeDependencies(pkgRoot: string, pkgJson: Record<string, unknown>, stagedDir: string): void {
|
|
174
|
+
const deps = pkgJson.dependencies;
|
|
175
|
+
if (!deps || typeof deps !== "object") return;
|
|
176
|
+
const nodeModulesDir = path.join(stagedDir, "node_modules");
|
|
177
|
+
for (const name of Object.keys(deps as Record<string, unknown>)) {
|
|
178
|
+
const target = resolvePackageDir(name, pkgRoot);
|
|
179
|
+
if (target === null) {
|
|
180
|
+
logWarn("daemon dependency not found for staging", { dependency: name });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
185
|
+
// Junction on Windows (no elevated privileges needed); dir symlink elsewhere.
|
|
186
|
+
fs.symlinkSync(target, path.join(nodeModulesDir, name), process.platform === "win32" ? "junction" : "dir");
|
|
187
|
+
} catch {
|
|
188
|
+
try {
|
|
189
|
+
fs.cpSync(target, path.join(nodeModulesDir, name), { recursive: true, dereference: true });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
logWarn("daemon dependency staging failed", { dependency: name, error: String(e) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Best-effort prune of staged daemon sources from earlier versions (1 day TTL). */
|
|
198
|
+
export function pruneStaleDaemonSrcDirs(root: string, currentVersion: string): void {
|
|
199
|
+
try {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
for (const entry of fs.readdirSync(root)) {
|
|
202
|
+
if (entry === currentVersion) continue;
|
|
203
|
+
const full = path.join(root, entry);
|
|
204
|
+
try {
|
|
205
|
+
if (now - fs.statSync(full).mtimeMs > 24 * 60 * 60 * 1000) {
|
|
206
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
} catch { }
|
|
209
|
+
}
|
|
210
|
+
} catch { }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Materialize the daemon's sources outside node_modules so a Node runtime can
|
|
215
|
+
* execute `src/daemon.ts` (Node refuses type stripping for any file under a
|
|
216
|
+
* node_modules path, and this package ships TypeScript sources). Copies
|
|
217
|
+
* `src/` + `package.json` into a per-version cache directory, stages the
|
|
218
|
+
* runtime dependencies beside them, and returns the staged daemon entry.
|
|
219
|
+
* Returns the input path unchanged when the script is already outside
|
|
220
|
+
* node_modules (dev checkouts) or the package root is unreadable.
|
|
221
|
+
*/
|
|
222
|
+
export function materializeDaemonSources(scriptPath: string, cacheDir?: string): string {
|
|
223
|
+
if (!isUnderNodeModules(scriptPath)) return scriptPath;
|
|
224
|
+
const pkgRoot = path.dirname(path.dirname(scriptPath));
|
|
225
|
+
const pkgJson = readPackageJsonObject(pkgRoot);
|
|
226
|
+
const version = pkgJson !== null && typeof pkgJson.version === "string" ? pkgJson.version : "";
|
|
227
|
+
if (pkgJson === null || !version) return scriptPath;
|
|
228
|
+
|
|
229
|
+
const root = cacheDir ?? resolveDaemonSrcCacheDir();
|
|
230
|
+
const finalDir = path.join(root, version);
|
|
231
|
+
const finalEntry = path.join(finalDir, "src", "daemon.ts");
|
|
232
|
+
if (fs.existsSync(finalEntry) && fs.existsSync(path.join(finalDir, "package.json"))) {
|
|
233
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
234
|
+
return finalEntry;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const tmpDir = `${finalDir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
238
|
+
try {
|
|
239
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
240
|
+
fs.cpSync(path.join(pkgRoot, "src"), path.join(tmpDir, "src"), { recursive: true });
|
|
241
|
+
fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
|
|
242
|
+
stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
|
|
243
|
+
try {
|
|
244
|
+
fs.renameSync(tmpDir, finalDir);
|
|
245
|
+
logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
// A concurrent starter won the rename (or the dir appeared meanwhile):
|
|
248
|
+
// its copy is byte-identical for the same version, so reuse it.
|
|
249
|
+
try {
|
|
250
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
251
|
+
} catch { }
|
|
252
|
+
if (!fs.existsSync(finalEntry)) {
|
|
253
|
+
logWarn("daemon source staging failed", { error: String(e), dir: finalDir });
|
|
254
|
+
return scriptPath;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
260
|
+
} catch { }
|
|
261
|
+
logWarn("daemon source staging failed — spawning from the installed path", { error: String(e), dir: tmpDir });
|
|
262
|
+
return scriptPath;
|
|
263
|
+
}
|
|
264
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
265
|
+
return finalEntry;
|
|
266
|
+
}
|