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
package/src/client.ts CHANGED
@@ -13,21 +13,14 @@ import { randomUUID } from "node:crypto";
13
13
  import { spawn } from "node:child_process";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
- import { fileURLToPath } from "node:url";
17
16
  import type * as http from "node:http";
18
17
  import {
19
18
  BASE_PORT_REPROBE_MS,
20
- BREAKER_FAILURES,
21
- BREAKER_HALT_MS,
22
- BREAKER_WINDOW_MS,
23
- BUSY_BYPASS_CONTINUOUS_MS,
24
- BUSY_BYPASS_QUIET_MS,
25
19
  DAEMON_CONTROL_TIMEOUT_MS,
26
20
  DAEMON_CONTROL_TIMEOUT_MS_ENV,
27
21
  DAEMON_HEARTBEAT_MS,
28
22
  DAEMON_HEARTBEAT_MS_ENV,
29
23
  DAEMON_READY_TIMEOUT_MS,
30
- DAEMON_RUNTIME_ENV,
31
24
  DAEMON_SPAWN_ENV,
32
25
  DAEMON_WATCHDOG_MS,
33
26
  DAEMON_WATCHDOG_MS_ENV,
@@ -37,10 +30,7 @@ import {
37
30
  OPENCODE_VERSION_TTL_MS,
38
31
  PKG_VERSION,
39
32
  PORT,
40
- RECOVERY_BACKOFF_BASE_MS,
41
- RECOVERY_BACKOFF_CAP_MS,
42
33
  refreshOpenCodeUserAgent,
43
- resolveDaemonSrcCacheDir,
44
34
  ZEN_TRANSPORT_ENV,
45
35
  } from "./config.ts";
46
36
  import { logInfo, logWarn } from "./logger.ts";
@@ -55,8 +45,73 @@ import {
55
45
  } from "./proxy.ts";
56
46
  import { compareVersions } from "./update-core.ts";
57
47
  import { isDaemonIdentityBody } from "./health.ts";
58
- import type { DaemonHealthSnapshot } from "./health.ts";
59
48
  import { tokenAuthHeaders } from "./token.ts";
49
+ import {
50
+ daemonScriptPath,
51
+ findExecutableOnPath,
52
+ findSupportedNodeOnPath,
53
+ isJsRuntimeExecutable,
54
+ isUnderNodeModules,
55
+ materializeDaemonSources,
56
+ pruneStaleDaemonSrcDirs,
57
+ readPackageJsonObject,
58
+ resolveDaemonRuntime,
59
+ resolvePackageDir,
60
+ stageRuntimeDependencies,
61
+ type DaemonRuntime,
62
+ } from "./client-staging.ts";
63
+ import {
64
+ computeRecoveryBackoffMs,
65
+ getBusySince,
66
+ isBreakerHalted,
67
+ isOlderDaemonVersion,
68
+ isStuckBusy,
69
+ recordRecoveryFailure,
70
+ recordRecoverySuccess,
71
+ resetBusySince,
72
+ shouldRecoverOnHealth,
73
+ trackBusyEdge,
74
+ triggerRecovery,
75
+ watchdogCheck,
76
+ _getRecoveryStateForTest,
77
+ _resetRecoveryForTest,
78
+ type HealthForRecovery,
79
+ type RecoveryDeps,
80
+ } from "./client-recovery.ts";
81
+
82
+ export {
83
+ daemonScriptPath,
84
+ findExecutableOnPath,
85
+ findSupportedNodeOnPath,
86
+ isJsRuntimeExecutable,
87
+ isUnderNodeModules,
88
+ materializeDaemonSources,
89
+ pruneStaleDaemonSrcDirs,
90
+ readPackageJsonObject,
91
+ resolveDaemonRuntime,
92
+ resolvePackageDir,
93
+ stageRuntimeDependencies,
94
+ type DaemonRuntime,
95
+ };
96
+
97
+ export {
98
+ computeRecoveryBackoffMs,
99
+ getBusySince,
100
+ isBreakerHalted,
101
+ isOlderDaemonVersion,
102
+ isStuckBusy,
103
+ recordRecoveryFailure,
104
+ recordRecoverySuccess,
105
+ resetBusySince,
106
+ shouldRecoverOnHealth,
107
+ trackBusyEdge,
108
+ triggerRecovery,
109
+ watchdogCheck,
110
+ _getRecoveryStateForTest,
111
+ _resetRecoveryForTest,
112
+ type HealthForRecovery,
113
+ type RecoveryDeps,
114
+ };
60
115
 
61
116
  const CLIENT_ID = randomUUID();
62
117
 
@@ -71,146 +126,11 @@ let ensuring = false;
71
126
  */
72
127
  let ensureInFlight: Promise<number> | null = null;
73
128
  let fallbackServer: http.Server | null = null;
74
- /** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
75
- let busySince = 0;
76
- /** Consecutive recovery attempts (backoff exponent n). Reset on healthy watchdog tick. */
77
- let backoffAttempt = 0;
78
- /** Timestamps of recent recovery failures (breaker window). */
79
- let recoveryFailures: number[] = [];
80
- /** Watchdog respawns halted until this time (breaker open). */
81
- let breakerOpenUntil = 0;
82
129
  /** Last wall-clock attempt of a background UA refresh; storm-guard for heartbeat cadence. */
83
130
  let lastUaRefreshAt = 0;
84
131
  /** Test-injected fetch for the background UA refresh; null = global fetch. */
85
132
  let uaRefreshFetch: typeof fetch | null = null;
86
133
 
87
- interface DaemonRuntime {
88
- execPath: string;
89
- args: string[];
90
- runtimeType: "bun" | "node";
91
- }
92
-
93
- /**
94
- * Detect whether an executable path is a JavaScript/TypeScript runtime (bun or node).
95
- * Rejects host binaries like omp, pi, electron, code, etc.
96
- */
97
- export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
98
- if (!execPath || typeof execPath !== "string") return null;
99
- const checkName = (target: string): "bun" | "node" | null => {
100
- const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.exe$/i, "");
101
- if (base === "bun" || base === "bunx") return "bun";
102
- if (base === "node" || base === "nodejs") return "node";
103
- return null;
104
- };
105
-
106
- const direct = checkName(execPath);
107
- if (direct) return direct;
108
-
109
- try {
110
- const real = fs.realpathSync(execPath);
111
- const realMatch = checkName(real);
112
- if (realMatch) return realMatch;
113
- } catch { }
114
-
115
- return null;
116
- }
117
-
118
- /**
119
- * Search directories in PATH for an executable matching name.
120
- * On Windows, tests extensions from PATHEXT (.exe, .cmd, .bat).
121
- * On POSIX, checks file existence and executable bit (X_OK).
122
- */
123
- export function findExecutableOnPath(
124
- name: string,
125
- envPath: string = process.env.PATH || process.env.Path || "",
126
- ): string | null {
127
- if (!envPath || typeof envPath !== "string") return null;
128
- const dirs = envPath.split(path.delimiter).filter(Boolean);
129
- const hasExt = path.extname(name).length > 0;
130
- const extensions = process.platform === "win32"
131
- ? (hasExt ? [""] : (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"]))
132
- : [""];
133
-
134
- for (const dir of dirs) {
135
- for (const ext of extensions) {
136
- const candidate = ext ? path.join(dir, `${name}${ext}`) : path.join(dir, name);
137
- try {
138
- const stat = fs.statSync(candidate);
139
- if (stat.isFile()) {
140
- if (process.platform !== "win32") {
141
- fs.accessSync(candidate, fs.constants.X_OK);
142
- }
143
- return candidate;
144
- }
145
- } catch { }
146
- }
147
- }
148
- return null;
149
- }
150
-
151
- /**
152
- * Resolve the executable and arguments needed to run src/daemon.ts.
153
- *
154
- * Priority order:
155
- * 1. Explicit override via opts.overrideExecPath or PI_FREEFLOW_DAEMON_RUNTIME env var.
156
- * 2. Current process.execPath (if and only if it is actually a node or bun executable).
157
- * 3. Search PATH for `bun` (preferred: native TypeScript support with zero flags).
158
- * 4. Search PATH for `node` (Node.js >= 22.6 with --experimental-strip-types).
159
- *
160
- * Returns null if no valid runtime is found (e.g. running as a standalone `omp` binary
161
- * without bun or node on PATH). The caller MUST fall back to an in-process proxy rather
162
- * than spawning an arbitrary binary (which would cause recursive agent fork-bombs).
163
- */
164
- export function resolveDaemonRuntime(opts?: {
165
- overrideExecPath?: string;
166
- scriptPath?: string;
167
- envPath?: string;
168
- currentExecPath?: string;
169
- }): DaemonRuntime | null {
170
- const script = opts?.scriptPath ?? daemonScriptPath();
171
- const envExec = opts?.overrideExecPath ?? (typeof process.env[DAEMON_RUNTIME_ENV] === "string" ? process.env[DAEMON_RUNTIME_ENV]?.trim() : undefined);
172
-
173
- // 1. Explicit override
174
- if (envExec) {
175
- const type = isJsRuntimeExecutable(envExec) ?? "node";
176
- const args = type === "bun" ? [script] : ["--experimental-strip-types", script];
177
- return { execPath: envExec, args, runtimeType: type };
178
- }
179
-
180
- // 2. Check current process.execPath
181
- const currentExec = opts?.currentExecPath ?? process.execPath;
182
- const currentType = isJsRuntimeExecutable(currentExec);
183
- if (currentType !== null) {
184
- const args = currentType === "bun" ? [script] : ["--experimental-strip-types", script];
185
- return { execPath: currentExec, args, runtimeType: currentType };
186
- }
187
-
188
- // 3. Current executable is not a JS runtime (e.g. omp, pi, electron).
189
- // Search PATH for bun first, then node.
190
- const pathVal = opts?.envPath ?? process.env.PATH ?? process.env.Path ?? "";
191
-
192
- const bunOnPath = findExecutableOnPath("bun", pathVal);
193
- if (bunOnPath) {
194
- return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
195
- }
196
-
197
- const nodeOnPath = findExecutableOnPath("node", pathVal);
198
- if (nodeOnPath) {
199
- return {
200
- execPath: nodeOnPath,
201
- args: ["--experimental-strip-types", script],
202
- runtimeType: "node",
203
- };
204
- }
205
-
206
- return null;
207
- }
208
-
209
- function daemonScriptPath(): string {
210
- const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
211
- return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
212
- }
213
-
214
134
  function getHeartbeatMs(): number {
215
135
  const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
216
136
  if (raw) {
@@ -309,7 +229,7 @@ function startHeartbeat(port: number): void {
309
229
  } catch { }
310
230
  const wms = getWatchdogMs();
311
231
  watchdogTimer = setInterval(() => {
312
- void watchdogCheck(port);
232
+ void watchdogCheck(port, recoveryDeps);
313
233
  }, wms);
314
234
  try {
315
235
  watchdogTimer.unref();
@@ -327,19 +247,24 @@ function stopHeartbeatInternal(): void {
327
247
  }
328
248
  }
329
249
 
330
- export function stopHeartbeat(): void {
250
+ export async function stopHeartbeat(): Promise<void> {
331
251
  const port = heartbeatPort;
252
+ const hadFallback = fallbackServer !== null;
332
253
  stopHeartbeatInternal();
333
254
  heartbeatPort = 0;
334
- if (port) {
335
- void controlCall(port, "/_client/detach", { id: CLIENT_ID });
336
- }
337
255
  if (fallbackServer) {
338
256
  try {
257
+ fallbackServer.closeIdleConnections?.();
258
+ fallbackServer.closeAllConnections?.();
339
259
  fallbackServer.close();
340
260
  } catch { }
341
261
  fallbackServer = null;
342
262
  }
263
+ if (port && !hadFallback) {
264
+ try {
265
+ await controlCall(port, "/_client/detach", { id: CLIENT_ID });
266
+ } catch { }
267
+ }
343
268
  }
344
269
 
345
270
  async function beatOnce(port: number): Promise<void> {
@@ -354,117 +279,17 @@ async function beatOnce(port: number): Promise<void> {
354
279
  }
355
280
  }
356
281
 
357
- async function attachTo(port: number): Promise<void> {
282
+ async function attachTo(port: number): Promise<boolean> {
358
283
  attachedPort = port;
359
284
  const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
360
285
  if (result === "gone") {
361
- return;
286
+ return false;
362
287
  }
363
288
  startHeartbeat(port);
364
289
  maybeRefreshOpenCodeUserAgent();
365
- }
366
-
367
- /**
368
- * Respawn backoff: min(2s * 2^n, 60s) + jitter. n is the consecutive-failure
369
- * count; pass jitterMs = 0 in tests for a deterministic base.
370
- */
371
- export function computeRecoveryBackoffMs(attempt: number, jitterMs = Math.random() * 500): number {
372
- const n = Math.max(0, Math.floor(attempt));
373
- const base = Math.min(RECOVERY_BACKOFF_BASE_MS * 2 ** n, RECOVERY_BACKOFF_CAP_MS);
374
- return base + Math.max(0, jitterMs);
375
- }
376
-
377
- /**
378
- * Stuck-busy check (pure, testable): a busy daemon may only be replaced after
379
- * 5min of continuous busy AND zero forwarded stream bytes for 60s+. Anything
380
- * else must be left untouched mid-request.
381
- */
382
- export function isStuckBusy(busySinceMs: number, lastBytesAt: number, now = Date.now()): boolean {
383
- if (!busySinceMs) return false;
384
- if (now - busySinceMs < BUSY_BYPASS_CONTINUOUS_MS) return false;
385
- if (lastBytesAt && now - lastBytesAt < BUSY_BYPASS_QUIET_MS) return false;
386
290
  return true;
387
291
  }
388
292
 
389
- /** Track the busy/idle edge from a health snapshot; returns the updated busySince. */
390
- export function trackBusyEdge(activeRequests: number, lastBytesAt: number, now = Date.now()): number {
391
- if (activeRequests > 0) {
392
- if (!busySince) busySince = now;
393
- } else {
394
- busySince = 0;
395
- }
396
- return busySince;
397
- }
398
-
399
- /** True while the breaker halt is in effect (respawns paused, fallback serves). */
400
- export function isBreakerHalted(now = Date.now()): boolean {
401
- return now < breakerOpenUntil;
402
- }
403
-
404
- /** Record a recovery failure; opens the breaker after 5 straight failures in 5min. */
405
- export function recordRecoveryFailure(now = Date.now()): boolean {
406
- recoveryFailures.push(now);
407
- recoveryFailures = recoveryFailures.filter((t) => now - t <= BREAKER_WINDOW_MS);
408
- backoffAttempt += 1;
409
- if (recoveryFailures.length >= BREAKER_FAILURES) {
410
- breakerOpenUntil = now + BREAKER_HALT_MS;
411
- logWarn("recovery breaker open — 5 straight failures in 5min, halting respawn for 10min (in-process fallback meanwhile)");
412
- return true;
413
- }
414
- return false;
415
- }
416
-
417
- /** Record a healthy watchdog tick: clears the straight-failure run and backoff. */
418
- export function recordRecoverySuccess(): void {
419
- recoveryFailures = [];
420
- backoffAttempt = 0;
421
- breakerOpenUntil = 0;
422
- }
423
-
424
- /** Test seam: snapshot of the recovery counters. */
425
- export function _getRecoveryStateForTest(): {
426
- backoffAttempt: number;
427
- failures: number[];
428
- breakerOpenUntil: number;
429
- busySince: number;
430
- } {
431
- return { backoffAttempt, failures: [...recoveryFailures], breakerOpenUntil, busySince };
432
- }
433
-
434
- /** Test seam: reset recovery counters without touching timers. */
435
- export function _resetRecoveryForTest(): void {
436
- backoffAttempt = 0;
437
- recoveryFailures = [];
438
- breakerOpenUntil = 0;
439
- busySince = 0;
440
- }
441
-
442
- /** Recovery-decision shape; canonical definition lives in health.ts. */
443
- export type HealthForRecovery = DaemonHealthSnapshot;
444
-
445
- /**
446
- * Older-version guard: only a daemon strictly older than this build is worth
447
- * recovering. shouldReplaceDaemon deliberately leaves a NEWER daemon running,
448
- * so recovering from one could never succeed (watchdog → recover → refused →
449
- * reattach → watchdog, until the breaker parks into the in-process fallback).
450
- * Null/undefined version (pre-version daemon) is not older on its own.
451
- */
452
- function isOlderDaemonVersion(version: string | null | undefined): boolean {
453
- return version != null && compareVersions(version, PKG_VERSION) < 0;
454
- }
455
-
456
- /**
457
- * Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
458
- * serves an older version, or reports a degraded failed-SSE window. Missing
459
- * sseDegraded (pre-window daemon) never triggers on its own.
460
- */
461
- export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
462
- if (health === null) return true;
463
- if (isOlderDaemonVersion(health.version)) return true;
464
- if (health.sseDegraded === true) return true;
465
- return false;
466
- }
467
-
468
293
  async function ensureInProcessFallback(): Promise<number> {
469
294
  try {
470
295
  const r = await startProxy();
@@ -477,85 +302,21 @@ async function ensureInProcessFallback(): Promise<number> {
477
302
  }
478
303
  }
479
304
 
480
- async function triggerRecovery(port: number, why: string): Promise<void> {
481
- if (ensuring) return;
482
- ensuring = true;
483
- try {
484
- const now = Date.now();
485
- if (isBreakerHalted(now)) {
486
- logWarn(`recovery breaker halted — serving in-process fallback meanwhile (${why})`);
487
- void ensureInProcessFallback();
488
- return;
489
- }
490
- const opened = recordRecoveryFailure(now);
491
- if (opened) {
492
- void ensureInProcessFallback();
493
- return;
494
- }
495
- const delay = computeRecoveryBackoffMs(backoffAttempt - 1);
496
- logWarn(`watchdog recovery (${why}) — backing off ${Math.round(delay)}ms before respawn`);
497
- stopHeartbeatInternal();
305
+ const recoveryDeps: RecoveryDeps = {
306
+ isEnsuring: () => ensuring,
307
+ setEnsuring: (val: boolean) => {
308
+ ensuring = val;
309
+ },
310
+ ensureInProcessFallback,
311
+ stopHeartbeatInternal,
312
+ resetHeartbeatPort: () => {
498
313
  heartbeatPort = 0;
499
- await new Promise<void>((r) => setTimeout(r, delay));
500
- try {
501
- const health = await getDaemonHealth(port);
502
- if (health && (health.activeRequests ?? 0) > 0) {
503
- trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0, Date.now());
504
- if (!isStuckBusy(busySince, health.lastBytesAt ?? 0, Date.now())) {
505
- logInfo(`watchdog recovery deferred — daemon busy (${health.activeRequests} active, waiting for 5min-stuck + 60s-quiet)`);
506
- await attachTo(port);
507
- return;
508
- }
509
- }
510
- const ver = health?.version ?? PKG_VERSION;
511
- await killStaleDaemon(port, ver, "proxy daemon");
512
- } catch (e) {
513
- logWarn("watchdog pre-respawn probe failed", { error: String(e) });
514
- }
515
- // Release before delegating: ensureDaemon owns the flag from here (it
516
- // early-returns while ensuring is true). The handoff is synchronous up
517
- // to ensureDaemon's own ensuring=true, so no tick slips between.
518
- ensuring = false;
519
- await ensureDaemon();
520
- } finally {
521
- ensuring = false;
522
- }
523
- }
524
-
525
- /**
526
- * 5s watchdog tick: polls /_health for version + failed-SSE rate and drives
527
- * stopHeartbeat → busy-ruled kill → ensureDaemon when the daemon is stale or
528
- * degraded. Healthy ticks reset backoff/breaker and track the busy edge.
529
- */
530
- async function watchdogCheck(port: number): Promise<void> {
531
- if (ensuring) return;
532
- if (isBreakerHalted()) return;
533
- let health: Awaited<ReturnType<typeof getDaemonHealth>>;
534
- try {
535
- health = await getDaemonHealth(port);
536
- } catch {
537
- return;
538
- }
539
- if (health === null) {
540
- await new Promise<void>((r) => setTimeout(r, 200));
541
- try {
542
- health = await getDaemonHealth(port);
543
- } catch {
544
- return;
545
- }
546
- }
547
- if (health && (health.activeRequests ?? 0) > 0) {
548
- trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
549
- } else {
550
- trackBusyEdge(0, 0);
551
- }
552
- if (!shouldRecoverOnHealth(health)) {
553
- recordRecoverySuccess();
554
- return;
555
- }
556
- if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
557
- void triggerRecovery(port, health === null ? "daemon gone" : isOlderDaemonVersion(health.version) ? `stale v${health.version}` : "failed-SSE degraded").catch((e) => logWarn("watchdog recovery failed", { error: String(e) }));
558
- }
314
+ },
315
+ attachTo: async (p: number) => { await attachTo(p); },
316
+ killStaleDaemon: (p, v, w) => killStaleDaemon(p, v, w),
317
+ ensureDaemon: () => ensureDaemon(),
318
+ getDaemonHealth: (p: number) => getDaemonHealth(p),
319
+ };
559
320
 
560
321
  async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
561
322
  if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
@@ -578,8 +339,8 @@ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boo
578
339
  return false;
579
340
  }
580
341
  if (health.activeRequests > 0) {
581
- trackBusyEdge(health.activeRequests, health.lastBytesAt ?? 0);
582
- if (!isStuckBusy(busySince, health.lastBytesAt ?? 0)) {
342
+ const currentBusy = trackBusyEdge(health.activeRequests, health.lastBytesAt ?? 0);
343
+ if (!isStuckBusy(currentBusy, health.lastBytesAt ?? 0)) {
583
344
  logInfo(
584
345
  `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
585
346
  );
@@ -588,7 +349,7 @@ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boo
588
349
  logWarn(`busy daemon on :${port} stuck (busy 5min+, no forwarded bytes 60s+) — bypassing busy guard`);
589
350
  return true;
590
351
  }
591
- busySince = 0;
352
+ resetBusySince();
592
353
  return true;
593
354
  }
594
355
 
@@ -801,132 +562,6 @@ export async function ensureProxyReady(
801
562
  return { ok: false, port, reason: proxyDownMessage(port) };
802
563
  }
803
564
 
804
- /** True when any path segment of `filePath` is `node_modules`. Node refuses to
805
- * strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
806
- * so a Node-spawned daemon must run from a staged copy elsewhere. */
807
- function isUnderNodeModules(filePath: string): boolean {
808
- return filePath.split(/[\\/]/).includes("node_modules");
809
- }
810
-
811
- function readPackageJsonObject(dir: string): Record<string, unknown> | null {
812
- try {
813
- const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
814
- return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
815
- } catch {
816
- return null;
817
- }
818
- }
819
-
820
- /** Resolve a dependency's package directory the way Node would: walk up the
821
- * node_modules chain from the importing package's root. */
822
- function resolvePackageDir(name: string, fromDir: string): string | null {
823
- let dir = fromDir;
824
- for (; ;) {
825
- const pkgJson = path.join(dir, "node_modules", name, "package.json");
826
- if (fs.existsSync(pkgJson)) return path.dirname(pkgJson);
827
- const parent = path.dirname(dir);
828
- if (parent === dir) return null;
829
- dir = parent;
830
- }
831
- }
832
-
833
- /** Link (or copy, when linking is unavailable) each runtime dependency beside
834
- * the staged sources so bare imports such as `undici` still resolve. */
835
- function stageRuntimeDependencies(pkgRoot: string, pkgJson: Record<string, unknown>, stagedDir: string): void {
836
- const deps = pkgJson.dependencies;
837
- if (!deps || typeof deps !== "object") return;
838
- const nodeModulesDir = path.join(stagedDir, "node_modules");
839
- for (const name of Object.keys(deps as Record<string, unknown>)) {
840
- const target = resolvePackageDir(name, pkgRoot);
841
- if (target === null) {
842
- logWarn("daemon dependency not found for staging", { dependency: name });
843
- continue;
844
- }
845
- try {
846
- fs.mkdirSync(nodeModulesDir, { recursive: true });
847
- // Junction on Windows (no elevated privileges needed); dir symlink elsewhere.
848
- fs.symlinkSync(target, path.join(nodeModulesDir, name), process.platform === "win32" ? "junction" : "dir");
849
- } catch {
850
- try {
851
- fs.cpSync(target, path.join(nodeModulesDir, name), { recursive: true, dereference: true });
852
- } catch (e) {
853
- logWarn("daemon dependency staging failed", { dependency: name, error: String(e) });
854
- }
855
- }
856
- }
857
- }
858
-
859
- /** Best-effort prune of staged daemon sources from earlier versions (1 day TTL). */
860
- function pruneStaleDaemonSrcDirs(root: string, currentVersion: string): void {
861
- try {
862
- const now = Date.now();
863
- for (const entry of fs.readdirSync(root)) {
864
- if (entry === currentVersion) continue;
865
- const full = path.join(root, entry);
866
- try {
867
- if (now - fs.statSync(full).mtimeMs > 24 * 60 * 60 * 1000) {
868
- fs.rmSync(full, { recursive: true, force: true });
869
- }
870
- } catch { }
871
- }
872
- } catch { }
873
- }
874
-
875
- /**
876
- * Materialize the daemon's sources outside node_modules so a Node runtime can
877
- * execute `src/daemon.ts` (Node refuses type stripping for any file under a
878
- * node_modules path, and this package ships TypeScript sources). Copies
879
- * `src/` + `package.json` into a per-version cache directory, stages the
880
- * runtime dependencies beside them, and returns the staged daemon entry.
881
- * Returns the input path unchanged when the script is already outside
882
- * node_modules (dev checkouts) or the package root is unreadable.
883
- */
884
- export function materializeDaemonSources(scriptPath: string, cacheDir?: string): string {
885
- if (!isUnderNodeModules(scriptPath)) return scriptPath;
886
- const pkgRoot = path.dirname(path.dirname(scriptPath));
887
- const pkgJson = readPackageJsonObject(pkgRoot);
888
- const version = pkgJson !== null && typeof pkgJson.version === "string" ? pkgJson.version : "";
889
- if (pkgJson === null || !version) return scriptPath;
890
-
891
- const root = cacheDir ?? resolveDaemonSrcCacheDir();
892
- const finalDir = path.join(root, version);
893
- const finalEntry = path.join(finalDir, "src", "daemon.ts");
894
- if (fs.existsSync(finalEntry) && fs.existsSync(path.join(finalDir, "package.json"))) {
895
- pruneStaleDaemonSrcDirs(root, version);
896
- return finalEntry;
897
- }
898
-
899
- const tmpDir = `${finalDir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
900
- try {
901
- fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
902
- fs.cpSync(path.join(pkgRoot, "src"), path.join(tmpDir, "src"), { recursive: true });
903
- fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
904
- stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
905
- try {
906
- fs.renameSync(tmpDir, finalDir);
907
- logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
908
- } catch (e) {
909
- // A concurrent starter won the rename (or the dir appeared meanwhile):
910
- // its copy is byte-identical for the same version, so reuse it.
911
- try {
912
- fs.rmSync(tmpDir, { recursive: true, force: true });
913
- } catch { }
914
- if (!fs.existsSync(finalEntry)) {
915
- logWarn("daemon source staging failed", { error: String(e), dir: finalDir });
916
- return scriptPath;
917
- }
918
- }
919
- } catch (e) {
920
- try {
921
- fs.rmSync(tmpDir, { recursive: true, force: true });
922
- } catch { }
923
- logWarn("daemon source staging failed — spawning from the installed path", { error: String(e), dir: tmpDir });
924
- return scriptPath;
925
- }
926
- pruneStaleDaemonSrcDirs(root, version);
927
- return finalEntry;
928
- }
929
-
930
565
  interface DaemonChild {
931
566
  pid?: number;
932
567
  exitCode: number | null;
@@ -979,20 +614,31 @@ function spawnDaemonProcess(): DaemonChild | null {
979
614
  lastSpawnAt = now;
980
615
  const logFd = openDaemonLogFd();
981
616
  try {
982
- const child = daemonSpawner(runtime.execPath, args, {
983
- detached: true,
984
- stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
985
- windowsHide: true,
986
- env: {
987
- ...process.env,
988
- [DAEMON_SPAWN_ENV]: "0",
989
- },
990
- });
617
+ const child = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(runtime.execPath) && daemonSpawner === spawn
618
+ ? daemonSpawner(`"${runtime.execPath}" ${args.map((a) => `"${a}"`).join(" ")}`, [], {
619
+ detached: true,
620
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
621
+ windowsHide: true,
622
+ shell: true,
623
+ env: {
624
+ ...process.env,
625
+ [DAEMON_SPAWN_ENV]: "0",
626
+ },
627
+ })
628
+ : daemonSpawner(runtime.execPath, args, {
629
+ detached: true,
630
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
631
+ windowsHide: true,
632
+ env: {
633
+ ...process.env,
634
+ [DAEMON_SPAWN_ENV]: "0",
635
+ },
636
+ });
991
637
  child.unref();
992
638
  logInfo(
993
639
  `spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}${stagedScript === scriptArg ? "" : " [staged sources]"}`,
994
640
  );
995
- child.on("error", (err) => {
641
+ child.on("error", (err: Error) => {
996
642
  failedDaemonSpawns.add(child);
997
643
  logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
998
644
  });
@@ -1104,8 +750,7 @@ export async function ensureDaemon(): Promise<number> {
1104
750
  async function ensureDaemonAttempt(): Promise<number> {
1105
751
  const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
1106
752
  if (primary !== null) {
1107
- await attachTo(primary);
1108
- return primary;
753
+ if (await attachTo(primary)) return primary;
1109
754
  }
1110
755
 
1111
756