pi-freeflow 1.29.0 → 1.30.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/src/client.ts CHANGED
@@ -1,916 +1,925 @@
1
- /**
2
- * Client-side daemon lifecycle for pi-freeflow.
3
- *
4
- * Every OMP/Pi session is a client. It attaches to the shared detached daemon
5
- * at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
6
- * renews it with a heartbeat while the session lives. When the session ends
7
- * the heartbeat stops; the daemon drops the lease after its TTL and retires
8
- * once no client holds a live lease for a grace window with nothing in flight
9
- * (request-idleness alone never retires it).
10
- */
11
-
12
- import { randomUUID } from "node:crypto";
13
- import { spawn } from "node:child_process";
14
- import fs from "node:fs";
15
- import path from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import type * as http from "node:http";
18
- import {
19
- 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
- DAEMON_CONTROL_TIMEOUT_MS,
26
- DAEMON_CONTROL_TIMEOUT_MS_ENV,
27
- DAEMON_HEARTBEAT_MS,
28
- DAEMON_HEARTBEAT_MS_ENV,
29
- DAEMON_READY_TIMEOUT_MS,
30
- DAEMON_RUNTIME_ENV,
31
- DAEMON_SPAWN_ENV,
32
- DAEMON_WATCHDOG_MS,
33
- DAEMON_WATCHDOG_MS_ENV,
34
- HOST,
35
- LEGACY_PORT,
36
- LOG_FILE,
37
- NO_KILL_ENV,
38
- OPENCODE_VERSION_TTL_MS,
39
- PKG_VERSION,
40
- PORT,
41
- RECOVERY_BACKOFF_BASE_MS,
42
- RECOVERY_BACKOFF_CAP_MS,
43
- refreshOpenCodeUserAgent,
44
- } from "./config.ts";
45
- import { logInfo, logWarn } from "./logger.ts";
46
- import {
47
- getDaemonHealth,
48
- getDaemonVersion,
49
- isProxyAlive,
50
- killPortHolder,
51
- reprobeBasePortAlive,
52
- startProxy,
53
- } from "./proxy.ts";
54
- import { compareVersions } from "./update-checker.ts";
55
- import type { DaemonHealthSnapshot } from "./health.ts";
56
-
57
- const CLIENT_ID = randomUUID();
58
-
59
- let attachedPort = 0;
60
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
61
- let watchdogTimer: ReturnType<typeof setInterval> | null = null;
62
- let heartbeatPort = 0;
63
- let ensuring = false;
64
- let fallbackServer: http.Server | null = null;
65
- /** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
66
- let busySince = 0;
67
- /** Consecutive recovery attempts (backoff exponent n). Reset on healthy watchdog tick. */
68
- let backoffAttempt = 0;
69
- /** Timestamps of recent recovery failures (breaker window). */
70
- let recoveryFailures: number[] = [];
71
- /** Watchdog respawns halted until this time (breaker open). */
72
- let breakerOpenUntil = 0;
73
- /** Last wall-clock attempt of a background UA refresh; storm-guard for heartbeat cadence. */
74
- let lastUaRefreshAt = 0;
75
- /** Test-injected fetch for the background UA refresh; null = global fetch. */
76
- let uaRefreshFetch: typeof fetch | null = null;
77
-
78
- export interface DaemonRuntime {
79
- execPath: string;
80
- args: string[];
81
- runtimeType: "bun" | "node";
82
- }
83
-
84
- /**
85
- * Detect whether an executable path is a JavaScript/TypeScript runtime (bun or node).
86
- * Rejects host binaries like omp, pi, electron, code, etc.
87
- */
88
- export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
89
- if (!execPath || typeof execPath !== "string") return null;
90
- const checkName = (target: string): "bun" | "node" | null => {
91
- const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.exe$/i, "");
92
- if (base === "bun" || base === "bunx") return "bun";
93
- if (base === "node" || base === "nodejs") return "node";
94
- return null;
95
- };
96
-
97
- const direct = checkName(execPath);
98
- if (direct) return direct;
99
-
100
- try {
101
- const real = fs.realpathSync(execPath);
102
- const realMatch = checkName(real);
103
- if (realMatch) return realMatch;
104
- } catch { }
105
-
106
- return null;
107
- }
108
-
109
- /**
110
- * Search directories in PATH for an executable matching name.
111
- * On Windows, tests extensions from PATHEXT (.exe, .cmd, .bat).
112
- * On POSIX, checks file existence and executable bit (X_OK).
113
- */
114
- export function findExecutableOnPath(
115
- name: string,
116
- envPath: string = process.env.PATH || process.env.Path || "",
117
- ): string | null {
118
- if (!envPath || typeof envPath !== "string") return null;
119
- const dirs = envPath.split(path.delimiter).filter(Boolean);
120
- const hasExt = path.extname(name).length > 0;
121
- const extensions = process.platform === "win32"
122
- ? (hasExt ? [""] : (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"]))
123
- : [""];
124
-
125
- for (const dir of dirs) {
126
- for (const ext of extensions) {
127
- const candidate = ext ? path.join(dir, `${name}${ext}`) : path.join(dir, name);
128
- try {
129
- const stat = fs.statSync(candidate);
130
- if (stat.isFile()) {
131
- if (process.platform !== "win32") {
132
- fs.accessSync(candidate, fs.constants.X_OK);
133
- }
134
- return candidate;
135
- }
136
- } catch { }
137
- }
138
- }
139
- return null;
140
- }
141
-
142
- /**
143
- * Resolve the executable and arguments needed to run src/daemon.ts.
144
- *
145
- * Priority order:
146
- * 1. Explicit override via opts.overrideExecPath or PI_FREEFLOW_DAEMON_RUNTIME env var.
147
- * 2. Current process.execPath (if and only if it is actually a node or bun executable).
148
- * 3. Search PATH for `bun` (preferred: native TypeScript support with zero flags).
149
- * 4. Search PATH for `node` (Node.js >= 22.6 with --experimental-strip-types).
150
- *
151
- * Returns null if no valid runtime is found (e.g. running as a standalone `omp` binary
152
- * without bun or node on PATH). The caller MUST fall back to an in-process proxy rather
153
- * than spawning an arbitrary binary (which would cause recursive agent fork-bombs).
154
- */
155
- export function resolveDaemonRuntime(opts?: {
156
- overrideExecPath?: string;
157
- scriptPath?: string;
158
- envPath?: string;
159
- currentExecPath?: string;
160
- }): DaemonRuntime | null {
161
- const script = opts?.scriptPath ?? daemonScriptPath();
162
- const envExec = opts?.overrideExecPath ?? (typeof process.env[DAEMON_RUNTIME_ENV] === "string" ? process.env[DAEMON_RUNTIME_ENV]?.trim() : undefined);
163
-
164
- // 1. Explicit override
165
- if (envExec) {
166
- const type = isJsRuntimeExecutable(envExec) ?? "node";
167
- const args = type === "bun" ? [script] : ["--experimental-strip-types", script];
168
- return { execPath: envExec, args, runtimeType: type };
169
- }
170
-
171
- // 2. Check current process.execPath
172
- const currentExec = opts?.currentExecPath ?? process.execPath;
173
- const currentType = isJsRuntimeExecutable(currentExec);
174
- if (currentType !== null) {
175
- const args = currentType === "bun" ? [script] : ["--experimental-strip-types", script];
176
- return { execPath: currentExec, args, runtimeType: currentType };
177
- }
178
-
179
- // 3. Current executable is not a JS runtime (e.g. omp, pi, electron).
180
- // Search PATH for bun first, then node.
181
- const pathVal = opts?.envPath ?? process.env.PATH ?? process.env.Path ?? "";
182
-
183
- const bunOnPath = findExecutableOnPath("bun", pathVal);
184
- if (bunOnPath) {
185
- return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
186
- }
187
-
188
- const nodeOnPath = findExecutableOnPath("node", pathVal);
189
- if (nodeOnPath) {
190
- return {
191
- execPath: nodeOnPath,
192
- args: ["--experimental-strip-types", script],
193
- runtimeType: "node",
194
- };
195
- }
196
-
197
- return null;
198
- }
199
-
200
- function daemonScriptPath(): string {
201
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
202
- }
203
-
204
- function getHeartbeatMs(): number {
205
- const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
206
- if (raw) {
207
- const parsed = Number(raw);
208
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
209
- }
210
- return DAEMON_HEARTBEAT_MS;
211
- }
212
-
213
- /** Per-OS loopback control-call timeout (Windows 3s, Linux 1.5s) unless overridden by env. */
214
- export function getControlTimeoutMs(): number {
215
- const raw = process.env[DAEMON_CONTROL_TIMEOUT_MS_ENV];
216
- if (raw) {
217
- const parsed = Number(raw);
218
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
219
- }
220
- return DAEMON_CONTROL_TIMEOUT_MS;
221
- }
222
-
223
- function getWatchdogMs(): number {
224
- const raw = process.env[DAEMON_WATCHDOG_MS_ENV];
225
- if (raw) {
226
- const parsed = Number(raw);
227
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
228
- }
229
- return DAEMON_WATCHDOG_MS;
230
- }
231
-
232
- function isSpawnEnabled(): boolean {
233
- return process.env[DAEMON_SPAWN_ENV] !== "0";
234
- }
235
-
236
- type ControlResult = "ok" | "unknown" | "legacy" | "gone";
237
-
238
- async function controlCall(
239
- port: number,
240
- endpoint: string,
241
- payload: Record<string, string>,
242
- ): Promise<ControlResult> {
243
- try {
244
- const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
245
- method: "POST",
246
- headers: { "content-type": "application/json" },
247
- body: JSON.stringify(payload),
248
- signal: AbortSignal.timeout(getControlTimeoutMs()),
249
- });
250
- if (!res.ok) return "legacy";
251
- try {
252
- const data: unknown = await res.json();
253
- if (data && typeof data === "object" && "ok" in data && data.ok === false) return "unknown";
254
- } catch { }
255
- return "ok";
256
- } catch {
257
- return "gone";
258
- }
259
- }
260
-
261
- /**
262
- * Fire-and-forget background refresh of the OpenCode User-Agent.
263
- * Called once at attach/startup and re-armed on the heartbeat cadence.
264
- * Never on the request hot path, never awaited, failures silent: the pinned
265
- * fallback keeps serving traffic offline and the 6h disk TTL keeps the live
266
- * version fresh. The in-process last-attempt timestamp (set synchronously
267
- * before firing) stops overlapping heartbeats from stampeding npm while a
268
- * fetch is still in flight.
269
- */
270
- export function maybeRefreshOpenCodeUserAgent(): void {
271
- const now = Date.now();
272
- if (now - lastUaRefreshAt < OPENCODE_VERSION_TTL_MS) return;
273
- lastUaRefreshAt = now;
274
- try {
275
- void Promise.resolve(refreshOpenCodeUserAgent(uaRefreshFetch ?? fetch)).catch(() => { });
276
- } catch { }
277
- }
278
-
279
- /** Test seam: inject the fetch used by the background UA refresh. */
280
- export function _setUaRefreshFetchForTest(fetchImpl: typeof fetch | null): void {
281
- uaRefreshFetch = fetchImpl;
282
- }
283
-
284
- /** Test seam: reset the UA refresh storm-guard (and injected fetch). */
285
- export function _resetUaRefreshForTest(): void {
286
- lastUaRefreshAt = 0;
287
- uaRefreshFetch = null;
288
- }
289
-
290
- function startHeartbeat(port: number): void {
291
- stopHeartbeatInternal();
292
- heartbeatPort = port;
293
- const ms = getHeartbeatMs();
294
- heartbeatTimer = setInterval(() => {
295
- void beatOnce(port);
296
- }, ms);
297
- try {
298
- heartbeatTimer.unref();
299
- } catch { }
300
- const wms = getWatchdogMs();
301
- watchdogTimer = setInterval(() => {
302
- void watchdogCheck(port);
303
- }, wms);
304
- try {
305
- watchdogTimer.unref();
306
- } catch { }
307
- }
308
-
309
- function stopHeartbeatInternal(): void {
310
- if (heartbeatTimer !== null) {
311
- clearInterval(heartbeatTimer);
312
- heartbeatTimer = null;
313
- }
314
- if (watchdogTimer !== null) {
315
- clearInterval(watchdogTimer);
316
- watchdogTimer = null;
317
- }
318
- }
319
-
320
- export function stopHeartbeat(): void {
321
- const port = heartbeatPort;
322
- stopHeartbeatInternal();
323
- heartbeatPort = 0;
324
- if (port) {
325
- void controlCall(port, "/_client/detach", { id: CLIENT_ID });
326
- }
327
- if (fallbackServer) {
328
- try {
329
- fallbackServer.close();
330
- } catch { }
331
- fallbackServer = null;
332
- }
333
- }
334
-
335
- async function beatOnce(port: number): Promise<void> {
336
- maybeRefreshOpenCodeUserAgent();
337
- if (ensuring) return;
338
- const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
339
- if (result === "gone") {
340
- void ensureDaemon().catch((e) => logWarn("heartbeat respawn failed", { error: String(e) }));
341
- } else if (result === "unknown") {
342
- // Daemon restarted since attach: re-register our lease.
343
- await attachTo(port);
344
- }
345
- }
346
-
347
- async function attachTo(port: number): Promise<void> {
348
- attachedPort = port;
349
- const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
350
- if (result === "gone") {
351
- return;
352
- }
353
- startHeartbeat(port);
354
- maybeRefreshOpenCodeUserAgent();
355
- }
356
-
357
- /**
358
- * Respawn backoff: min(2s * 2^n, 60s) + jitter. n is the consecutive-failure
359
- * count; pass jitterMs = 0 in tests for a deterministic base.
360
- */
361
- export function computeRecoveryBackoffMs(attempt: number, jitterMs = Math.random() * 500): number {
362
- const n = Math.max(0, Math.floor(attempt));
363
- const base = Math.min(RECOVERY_BACKOFF_BASE_MS * 2 ** n, RECOVERY_BACKOFF_CAP_MS);
364
- return base + Math.max(0, jitterMs);
365
- }
366
-
367
- /**
368
- * Stuck-busy check (pure, testable): a busy daemon may only be replaced after
369
- * 5min of continuous busy AND zero forwarded stream bytes for 60s+. Anything
370
- * else must be left untouched mid-request.
371
- */
372
- export function isStuckBusy(busySinceMs: number, lastBytesAt: number, now = Date.now()): boolean {
373
- if (!busySinceMs) return false;
374
- if (now - busySinceMs < BUSY_BYPASS_CONTINUOUS_MS) return false;
375
- if (lastBytesAt && now - lastBytesAt < BUSY_BYPASS_QUIET_MS) return false;
376
- return true;
377
- }
378
-
379
- /** Track the busy/idle edge from a health snapshot; returns the updated busySince. */
380
- export function trackBusyEdge(activeRequests: number, lastBytesAt: number, now = Date.now()): number {
381
- if (activeRequests > 0) {
382
- if (!busySince) busySince = now;
383
- } else {
384
- busySince = 0;
385
- }
386
- return busySince;
387
- }
388
-
389
- /** True while the breaker halt is in effect (respawns paused, fallback serves). */
390
- export function isBreakerHalted(now = Date.now()): boolean {
391
- return now < breakerOpenUntil;
392
- }
393
-
394
- /** Record a recovery failure; opens the breaker after 5 straight failures in 5min. */
395
- export function recordRecoveryFailure(now = Date.now()): boolean {
396
- recoveryFailures.push(now);
397
- recoveryFailures = recoveryFailures.filter((t) => now - t <= BREAKER_WINDOW_MS);
398
- backoffAttempt += 1;
399
- if (recoveryFailures.length >= BREAKER_FAILURES) {
400
- breakerOpenUntil = now + BREAKER_HALT_MS;
401
- logWarn("recovery breaker open — 5 straight failures in 5min, halting respawn for 10min (in-process fallback meanwhile)");
402
- return true;
403
- }
404
- return false;
405
- }
406
-
407
- /** Record a healthy watchdog tick: clears the straight-failure run and backoff. */
408
- export function recordRecoverySuccess(): void {
409
- recoveryFailures = [];
410
- backoffAttempt = 0;
411
- breakerOpenUntil = 0;
412
- }
413
-
414
- /** Test seam: snapshot of the recovery counters. */
415
- export function _getRecoveryStateForTest(): {
416
- backoffAttempt: number;
417
- failures: number[];
418
- breakerOpenUntil: number;
419
- busySince: number;
420
- } {
421
- return { backoffAttempt, failures: [...recoveryFailures], breakerOpenUntil, busySince };
422
- }
423
-
424
- /** Test seam: reset recovery counters without touching timers. */
425
- export function _resetRecoveryForTest(): void {
426
- backoffAttempt = 0;
427
- recoveryFailures = [];
428
- breakerOpenUntil = 0;
429
- busySince = 0;
430
- }
431
-
432
- /** Recovery-decision shape; canonical definition lives in health.ts. */
433
- export type HealthForRecovery = DaemonHealthSnapshot;
434
-
435
- /**
436
- * Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
437
- * serves a stale version, or reports a degraded failed-SSE window. Missing
438
- * sseDegraded (pre-window daemon) never triggers on its own.
439
- */
440
- export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
441
- if (health === null) return true;
442
- if (health.version !== null && health.version !== PKG_VERSION) return true;
443
- if (health.sseDegraded === true) return true;
444
- return false;
445
- }
446
-
447
- async function ensureInProcessFallback(): Promise<number> {
448
- try {
449
- const r = await startProxy();
450
- if (r.server) fallbackServer = r.server;
451
- await attachTo(r.port);
452
- return r.port;
453
- } catch (e) {
454
- logWarn("in-process fallback bind failed", { error: String(e) });
455
- return PORT;
456
- }
457
- }
458
-
459
- async function triggerRecovery(port: number, why: string): Promise<void> {
460
- if (ensuring) return;
461
- const now = Date.now();
462
- if (isBreakerHalted(now)) {
463
- logWarn(`recovery breaker halted — serving in-process fallback meanwhile (${why})`);
464
- void ensureInProcessFallback();
465
- return;
466
- }
467
- const opened = recordRecoveryFailure(now);
468
- if (opened) {
469
- void ensureInProcessFallback();
470
- return;
471
- }
472
- const delay = computeRecoveryBackoffMs(backoffAttempt - 1);
473
- logWarn(`watchdog recovery (${why}) — backing off ${Math.round(delay)}ms before respawn`);
474
- stopHeartbeatInternal();
475
- heartbeatPort = 0;
476
- await new Promise<void>((r) => setTimeout(r, delay));
477
- try {
478
- const health = await getDaemonHealth(port);
479
- if (health && (health.activeRequests ?? 0) > 0) {
480
- trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0, Date.now());
481
- if (!isStuckBusy(busySince, health.lastBytesAt ?? 0, Date.now())) {
482
- logInfo(`watchdog recovery deferred — daemon busy (${health.activeRequests} active, waiting for 5min-stuck + 60s-quiet)`);
483
- await attachTo(port);
484
- return;
485
- }
486
- }
487
- const ver = health?.version ?? PKG_VERSION;
488
- await killStaleDaemon(port, ver, "proxy daemon");
489
- } catch (e) {
490
- logWarn("watchdog pre-respawn probe failed", { error: String(e) });
491
- }
492
- await ensureDaemon();
493
- }
494
-
495
- /**
496
- * 5s watchdog tick: polls /_health for version + failed-SSE rate and drives
497
- * stopHeartbeat → busy-ruled kill → ensureDaemon when the daemon is stale or
498
- * degraded. Healthy ticks reset backoff/breaker and track the busy edge.
499
- */
500
- export async function watchdogCheck(port: number): Promise<void> {
501
- if (ensuring) return;
502
- if (isBreakerHalted()) return;
503
- let health: Awaited<ReturnType<typeof getDaemonHealth>>;
504
- try {
505
- health = await getDaemonHealth(port);
506
- } catch {
507
- return;
508
- }
509
- if (health === null) {
510
- await new Promise<void>((r) => setTimeout(r, 200));
511
- try {
512
- health = await getDaemonHealth(port);
513
- } catch {
514
- return;
515
- }
516
- }
517
- if (health && (health.activeRequests ?? 0) > 0) {
518
- trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
519
- } else {
520
- trackBusyEdge(0, 0);
521
- }
522
- if (!shouldRecoverOnHealth(health)) {
523
- recordRecoverySuccess();
524
- return;
525
- }
526
- if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
527
- void triggerRecovery(port, health === null ? "daemon gone" : health.version !== PKG_VERSION ? `stale v${health.version}` : "failed-SSE degraded").catch((e) => logWarn("watchdog recovery failed", { error: String(e) }));
528
- }
529
-
530
- async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
531
- if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
532
- logInfo(
533
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
534
- );
535
- return false;
536
- }
537
- if (compareVersions(remoteVer, PKG_VERSION) > 0) {
538
- logInfo(
539
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
540
- );
541
- return false;
542
- }
543
- const health = await getDaemonHealth(port);
544
- if (health === null || health.activeRequests === undefined) {
545
- logInfo(
546
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
547
- );
548
- return false;
549
- }
550
- if (health.activeRequests > 0) {
551
- trackBusyEdge(health.activeRequests, health.lastBytesAt ?? 0);
552
- if (!isStuckBusy(busySince, health.lastBytesAt ?? 0)) {
553
- logInfo(
554
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
555
- );
556
- return false;
557
- }
558
- logWarn(`busy daemon on :${port} stuck (busy 5min+, no forwarded bytes 60s+) — bypassing busy guard`);
559
- return true;
560
- }
561
- busySince = 0;
562
- return true;
563
- }
564
-
565
- async function killStaleDaemon(
566
- port: number,
567
- remoteVer: string,
568
- what: string,
569
- ): Promise<boolean> {
570
- if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
571
- logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
572
- remoteVer,
573
- expected: PKG_VERSION,
574
- });
575
- await killPortHolder(port);
576
- for (let i = 0; i < 10; i++) {
577
- await new Promise<void>((r) => setTimeout(r, 200));
578
- if (!(await isProxyAlive(port))) return true;
579
- }
580
- logInfo(
581
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
582
- );
583
- return false;
584
- }
585
-
586
- /**
587
- * Open the shared log file for the detached daemon's stdout/stderr so a
588
- * native abort or crash leaves its reason on disk instead of vanishing into
589
- * stdio "ignore". Returns the fd, or null when the file is unavailable
590
- * (the spawn then falls back to ignore).
591
- */
592
- export function openDaemonLogFd(): number | null {
593
- try {
594
- fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
595
- return fs.openSync(LOG_FILE, "a");
596
- } catch {
597
- return null;
598
- }
599
- }
600
-
601
- export type DarkHolderProbe = {
602
- /** True when GET /v1/models answers on the port. */
603
- alive: boolean;
604
- /** Full /_health snapshot, or null when the holder answers nothing. */
605
- health: HealthForRecovery;
606
- };
607
-
608
- /**
609
- * Pure decision: reclaim a base port whose holder answers neither /v1/models
610
- * nor /_health. A live daemon — stale, busy, even mid-stream — always answers
611
- * both from its event loop, so a dark holder cannot be serving traffic and
612
- * replacing it cannot interrupt a real stream.
613
- */
614
- export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
615
- if (probe.alive) return false;
616
- if (probe.health !== null) return false;
617
- return true;
618
- }
619
-
620
- /**
621
- * Dislodge a dark holder from the base port so a fresh daemon can bind it.
622
- * Returns true when a healthy proxy answers afterwards (attach to it);
623
- * false when the caller should spawn fresh (port presumably free).
624
- * Honors the no-kill env opt-out; a live holder is never touched here —
625
- * the stale-replace path owns versioned daemons.
626
- */
627
- async function reclaimDarkHolder(port: number): Promise<boolean> {
628
- const alive = await isProxyAlive(port);
629
- const health = alive ? null : await getDaemonHealth(port);
630
- if (!shouldReclaimDarkHolder({ alive, health })) return true;
631
- if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
632
- logInfo(
633
- `proxy on :${port} answers neither health nor models — leaving it alone (replacement disabled by env)`,
634
- );
635
- return false;
636
- }
637
- logWarn(`proxy on :${port} holds the port but answers nothing — replacing dark holder`);
638
- await killPortHolder(port);
639
- for (let i = 0; i < 10; i++) {
640
- await new Promise<void>((r) => setTimeout(r, 200));
641
- if (await isProxyAlive(port)) return true;
642
- }
643
- return false;
644
- }
645
-
646
- function mentionsLoopback(text: string): boolean {
647
- return text.includes("127.0.0.1") || text.includes("::1") || text.includes("localhost");
648
- }
649
-
650
- /**
651
- * True when a fetch failure is a refused loopback connection — the local
652
- * proxy is gone, not the upstream. Walks the undici cause chain, so both
653
- * bare { code: "ECONNREFUSED" } errors and "fetch failed" wrappers classify.
654
- */
655
- export function isLoopbackRefused(err: unknown): boolean {
656
- let refused = false;
657
- let loopback = false;
658
- const seen = new Set<unknown>();
659
- const stack: unknown[] = [err];
660
- while (stack.length > 0) {
661
- const cur = stack.pop();
662
- if (typeof cur === "string") {
663
- if (mentionsLoopback(cur)) loopback = true;
664
- continue;
665
- }
666
- if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
667
- seen.add(cur);
668
- const rec = cur as Record<string, unknown>;
669
- if (rec.code === "ECONNREFUSED") refused = true;
670
- if (typeof rec.message === "string" && mentionsLoopback(rec.message)) loopback = true;
671
- if (typeof rec.host === "string" && mentionsLoopback(rec.host)) loopback = true;
672
- if (typeof rec.address === "string" && mentionsLoopback(rec.address)) loopback = true;
673
- if (rec.cause !== undefined) stack.push(rec.cause);
674
- }
675
- return refused && loopback;
676
- }
677
-
678
- /**
679
- * Explicit proxy-down message: names the loopback port and the recovery
680
- * action. Used wherever a refused loopback would otherwise surface as a
681
- * per-model connect failure.
682
- */
683
- export function proxyDownMessage(port: number): string {
684
- return (
685
- `pi-freeflow proxy is down on 127.0.0.1:${port} (connection refused) — ` +
686
- `model requests cannot reach the local proxy until it is back. ` +
687
- `Start a new session or run /freeflow to respawn it.`
688
- );
689
- }
690
-
691
- export type ProxyReadiness = { ok: true; port: number } | { ok: false; port: number; reason: string };
692
-
693
- /**
694
- * Health probe before requests: when the loopback answers, no respawn runs.
695
- * When it refuses, one bounded respawn retry runs, then an explicit
696
- * proxy-down result — never a silent per-model connect failure.
697
- * The respawn seam defaults to the full daemon ensure; tests inject a fake.
698
- */
699
- export async function ensureProxyReady(
700
- port: number,
701
- respawn: () => Promise<number> = ensureDaemon,
702
- ): Promise<ProxyReadiness> {
703
- if (await isProxyAlive(port)) return { ok: true, port };
704
- let next = port;
705
- try {
706
- next = await respawn();
707
- } catch (e) {
708
- logWarn("proxy respawn failed", { error: String(e) });
709
- }
710
- if (await isProxyAlive(next)) return { ok: true, port: next };
711
- if (next !== port && (await isProxyAlive(port))) return { ok: true, port };
712
- return { ok: false, port, reason: proxyDownMessage(port) };
713
- }
714
-
715
- let lastSpawnAt = 0;
716
- const SPAWN_THROTTLE_MS = 2_000;
717
-
718
- function spawnDaemonProcess(): boolean {
719
- const now = Date.now();
720
- if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
721
- logWarn("daemon spawn throttled — recent spawn still pending");
722
- return false;
723
- }
724
- const runtime = resolveDaemonRuntime();
725
- if (!runtime) {
726
- logWarn(
727
- `cannot spawn detached proxy daemon: no node or bun runtime found (process.execPath "${process.execPath}" is not a JS runtime) — falling back to in-process proxy`,
728
- );
729
- return false;
730
- }
731
- lastSpawnAt = now;
732
- const logFd = openDaemonLogFd();
733
- try {
734
- const child = spawn(runtime.execPath, runtime.args, {
735
- detached: true,
736
- stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
737
- windowsHide: true,
738
- env: {
739
- ...process.env,
740
- [DAEMON_SPAWN_ENV]: "0",
741
- },
742
- });
743
- child.unref();
744
- logInfo(
745
- `spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}`,
746
- );
747
- child.on("error", (err) => {
748
- logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
749
- });
750
- return true;
751
- } catch (e) {
752
- logWarn("daemon spawn failed", { error: String(e), execPath: runtime.execPath });
753
- return false;
754
- } finally {
755
- if (logFd !== null) {
756
- try {
757
- fs.closeSync(logFd);
758
- } catch { }
759
- }
760
- }
761
- }
762
-
763
- async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
764
- const deadline = Date.now() + timeoutMs;
765
- while (Date.now() < deadline) {
766
- if (await isProxyAlive(port)) return true;
767
- await new Promise<void>((r) => setTimeout(r, 200));
768
- }
769
- return false;
770
- }
771
-
772
- async function probeAndMaybeReplace(
773
- port: number,
774
- label: string,
775
- ): Promise<number | null> {
776
- if (!(await isProxyAlive(port))) return null;
777
- const ver = await getDaemonVersion(port);
778
- if (ver !== null && ver !== PKG_VERSION) {
779
- if (await killStaleDaemon(port, ver, label)) {
780
- return null;
781
- }
782
- if (await isProxyAlive(port)) return port;
783
- return null;
784
- }
785
- return port;
786
- }
787
-
788
- /**
789
- * Ensure a proxy daemon is running and attach this client to it.
790
- * Returns the port the caller should use for its ProviderConfig.
791
- */
792
- export async function ensureDaemon(): Promise<number> {
793
- if (ensuring) return attachedPort || PORT;
794
- ensuring = true;
795
- try {
796
- const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
797
- if (primary !== null) {
798
- await attachTo(primary);
799
- return primary;
800
- }
801
-
802
- if (PORT !== LEGACY_PORT) {
803
- const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
804
- if (legacy !== null) {
805
- await attachTo(legacy);
806
- return legacy;
807
- }
808
- }
809
-
810
- if (isSpawnEnabled()) {
811
- // Cross-process race guard: a sibling session may be spawning right
812
- // now (singleflight is per-process). Jitter + re-probe converts most
813
- // cold-start races into attach instead of N detached spawns.
814
- await new Promise<void>((r) => setTimeout(r, 100 + Math.random() * 200));
815
- if (await isProxyAlive(PORT)) {
816
- await attachTo(PORT);
817
- return PORT;
818
- }
819
- const spawned = spawnDaemonProcess();
820
- if (spawned) {
821
- const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
822
- if (ready) {
823
- const ver = await getDaemonVersion(PORT);
824
- if (ver !== null && ver !== PKG_VERSION) {
825
- if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
826
- lastSpawnAt = 0; // kill-confirmed retry bypasses the spawn throttle
827
- if (spawnDaemonProcess()) {
828
- const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
829
- if (retryReady) {
830
- await attachTo(PORT);
831
- return PORT;
832
- }
833
- }
834
- } else if (await isProxyAlive(PORT)) {
835
- await attachTo(PORT);
836
- return PORT;
837
- }
838
- } else {
839
- await attachTo(PORT);
840
- return PORT;
841
- }
842
- } else {
843
- // The spawn never answered: it crashed instantly or a dark holder owns
844
- // the base port (bind fails, probes fail).
845
- if (await reclaimDarkHolder(PORT)) {
846
- await attachTo(PORT);
847
- return PORT;
848
- }
849
- logWarn("daemon spawn did not become ready — is the port blocked?");
850
- }
851
- }
852
- // Fall through to the in-process fallback below — never hand the caller
853
- // a port we did not attach to (no lease, no heartbeat).
854
- }
855
-
856
- try {
857
- const r = await startProxy();
858
- if (r.server) fallbackServer = r.server;
859
- let port = r.port;
860
- if (port !== PORT && r.server) {
861
- // Walked-port guard: the base port may have freed during the
862
- // 3s bind race. Re-probe :28180 before settling on port+1 so
863
- // parallel startups converge instead of stranding followers.
864
- if (await reprobeBasePortAlive(PORT, BASE_PORT_REPROBE_MS)) {
865
- try {
866
- r.server.close();
867
- } catch { }
868
- if (fallbackServer === r.server) fallbackServer = null;
869
- logInfo(`base port ${PORT} freed during walk — re-attaching to :${PORT} instead of :${port}`);
870
- await attachTo(PORT);
871
- return PORT;
872
- }
873
- }
874
- await attachTo(port);
875
- return port;
876
- } catch (e) {
877
- logWarn("in-process fallback bind failed", { error: String(e) });
878
- return PORT;
879
- }
880
- } finally {
881
- ensuring = false;
882
- }
883
- }
884
-
885
- export function getClientPort(): number {
886
- return attachedPort || PORT;
887
- }
888
-
889
- export function getClientId(): string {
890
- return CLIENT_ID;
891
- }
892
-
893
- export function hasFallbackServer(): boolean {
894
- return fallbackServer !== null;
895
- }
896
-
897
- /** Test seam: true when client heartbeat timer is active. */
898
- export function isHeartbeatActive(): boolean {
899
- return heartbeatTimer !== null;
900
- }
901
-
902
- export function _resetClientForTest(): void {
903
- stopHeartbeatInternal();
904
- heartbeatPort = 0;
905
- attachedPort = 0;
906
- ensuring = false;
907
- lastSpawnAt = 0;
908
- _resetUaRefreshForTest();
909
- _resetRecoveryForTest();
910
- if (fallbackServer) {
911
- try {
912
- fallbackServer.close();
913
- } catch { }
914
- fallbackServer = null;
915
- }
916
- }
1
+ /**
2
+ * Client-side daemon lifecycle for pi-freeflow.
3
+ *
4
+ * Every OMP/Pi session is a client. It attaches to the shared detached daemon
5
+ * at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
6
+ * renews it with a heartbeat while the session lives. When the session ends
7
+ * the heartbeat stops; the daemon drops the lease after its TTL and retires
8
+ * once no client holds a live lease for a grace window with nothing in flight
9
+ * (request-idleness alone never retires it).
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import { spawn } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import type * as http from "node:http";
18
+ import {
19
+ 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
+ DAEMON_CONTROL_TIMEOUT_MS,
26
+ DAEMON_CONTROL_TIMEOUT_MS_ENV,
27
+ DAEMON_HEARTBEAT_MS,
28
+ DAEMON_HEARTBEAT_MS_ENV,
29
+ DAEMON_READY_TIMEOUT_MS,
30
+ DAEMON_RUNTIME_ENV,
31
+ DAEMON_SPAWN_ENV,
32
+ DAEMON_WATCHDOG_MS,
33
+ DAEMON_WATCHDOG_MS_ENV,
34
+ HOST,
35
+ LEGACY_PORT,
36
+ LOG_FILE,
37
+ NO_KILL_ENV,
38
+ OPENCODE_VERSION_TTL_MS,
39
+ PKG_VERSION,
40
+ PORT,
41
+ RECOVERY_BACKOFF_BASE_MS,
42
+ RECOVERY_BACKOFF_CAP_MS,
43
+ refreshOpenCodeUserAgent,
44
+ } from "./config.ts";
45
+ import { logInfo, logWarn } from "./logger.ts";
46
+ import {
47
+ getDaemonHealth,
48
+ getDaemonVersion,
49
+ isProxyAlive,
50
+ killPortHolder,
51
+ reprobeBasePortAlive,
52
+ startProxy,
53
+ } from "./proxy.ts";
54
+ import { compareVersions } from "./update-checker.ts";
55
+ import type { DaemonHealthSnapshot } from "./health.ts";
56
+
57
+ const CLIENT_ID = randomUUID();
58
+
59
+ let attachedPort = 0;
60
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
61
+ let watchdogTimer: ReturnType<typeof setInterval> | null = null;
62
+ let heartbeatPort = 0;
63
+ let ensuring = false;
64
+ let fallbackServer: http.Server | null = null;
65
+ /** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
66
+ let busySince = 0;
67
+ /** Consecutive recovery attempts (backoff exponent n). Reset on healthy watchdog tick. */
68
+ let backoffAttempt = 0;
69
+ /** Timestamps of recent recovery failures (breaker window). */
70
+ let recoveryFailures: number[] = [];
71
+ /** Watchdog respawns halted until this time (breaker open). */
72
+ let breakerOpenUntil = 0;
73
+ /** Last wall-clock attempt of a background UA refresh; storm-guard for heartbeat cadence. */
74
+ let lastUaRefreshAt = 0;
75
+ /** Test-injected fetch for the background UA refresh; null = global fetch. */
76
+ let uaRefreshFetch: typeof fetch | null = null;
77
+
78
+ export interface DaemonRuntime {
79
+ execPath: string;
80
+ args: string[];
81
+ runtimeType: "bun" | "node";
82
+ }
83
+
84
+ /**
85
+ * Detect whether an executable path is a JavaScript/TypeScript runtime (bun or node).
86
+ * Rejects host binaries like omp, pi, electron, code, etc.
87
+ */
88
+ export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
89
+ if (!execPath || typeof execPath !== "string") return null;
90
+ const checkName = (target: string): "bun" | "node" | null => {
91
+ const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.exe$/i, "");
92
+ if (base === "bun" || base === "bunx") return "bun";
93
+ if (base === "node" || base === "nodejs") return "node";
94
+ return null;
95
+ };
96
+
97
+ const direct = checkName(execPath);
98
+ if (direct) return direct;
99
+
100
+ try {
101
+ const real = fs.realpathSync(execPath);
102
+ const realMatch = checkName(real);
103
+ if (realMatch) return realMatch;
104
+ } catch { }
105
+
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Search directories in PATH for an executable matching name.
111
+ * On Windows, tests extensions from PATHEXT (.exe, .cmd, .bat).
112
+ * On POSIX, checks file existence and executable bit (X_OK).
113
+ */
114
+ export function findExecutableOnPath(
115
+ name: string,
116
+ envPath: string = process.env.PATH || process.env.Path || "",
117
+ ): string | null {
118
+ if (!envPath || typeof envPath !== "string") return null;
119
+ const dirs = envPath.split(path.delimiter).filter(Boolean);
120
+ const hasExt = path.extname(name).length > 0;
121
+ const extensions = process.platform === "win32"
122
+ ? (hasExt ? [""] : (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"]))
123
+ : [""];
124
+
125
+ for (const dir of dirs) {
126
+ for (const ext of extensions) {
127
+ const candidate = ext ? path.join(dir, `${name}${ext}`) : path.join(dir, name);
128
+ try {
129
+ const stat = fs.statSync(candidate);
130
+ if (stat.isFile()) {
131
+ if (process.platform !== "win32") {
132
+ fs.accessSync(candidate, fs.constants.X_OK);
133
+ }
134
+ return candidate;
135
+ }
136
+ } catch { }
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Resolve the executable and arguments needed to run src/daemon.ts.
144
+ *
145
+ * Priority order:
146
+ * 1. Explicit override via opts.overrideExecPath or PI_FREEFLOW_DAEMON_RUNTIME env var.
147
+ * 2. Current process.execPath (if and only if it is actually a node or bun executable).
148
+ * 3. Search PATH for `bun` (preferred: native TypeScript support with zero flags).
149
+ * 4. Search PATH for `node` (Node.js >= 22.6 with --experimental-strip-types).
150
+ *
151
+ * Returns null if no valid runtime is found (e.g. running as a standalone `omp` binary
152
+ * without bun or node on PATH). The caller MUST fall back to an in-process proxy rather
153
+ * than spawning an arbitrary binary (which would cause recursive agent fork-bombs).
154
+ */
155
+ export function resolveDaemonRuntime(opts?: {
156
+ overrideExecPath?: string;
157
+ scriptPath?: string;
158
+ envPath?: string;
159
+ currentExecPath?: string;
160
+ }): DaemonRuntime | null {
161
+ const script = opts?.scriptPath ?? daemonScriptPath();
162
+ const envExec = opts?.overrideExecPath ?? (typeof process.env[DAEMON_RUNTIME_ENV] === "string" ? process.env[DAEMON_RUNTIME_ENV]?.trim() : undefined);
163
+
164
+ // 1. Explicit override
165
+ if (envExec) {
166
+ const type = isJsRuntimeExecutable(envExec) ?? "node";
167
+ const args = type === "bun" ? [script] : ["--experimental-strip-types", script];
168
+ return { execPath: envExec, args, runtimeType: type };
169
+ }
170
+
171
+ // 2. Check current process.execPath
172
+ const currentExec = opts?.currentExecPath ?? process.execPath;
173
+ const currentType = isJsRuntimeExecutable(currentExec);
174
+ if (currentType !== null) {
175
+ const args = currentType === "bun" ? [script] : ["--experimental-strip-types", script];
176
+ return { execPath: currentExec, args, runtimeType: currentType };
177
+ }
178
+
179
+ // 3. Current executable is not a JS runtime (e.g. omp, pi, electron).
180
+ // Search PATH for bun first, then node.
181
+ const pathVal = opts?.envPath ?? process.env.PATH ?? process.env.Path ?? "";
182
+
183
+ const bunOnPath = findExecutableOnPath("bun", pathVal);
184
+ if (bunOnPath) {
185
+ return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
186
+ }
187
+
188
+ const nodeOnPath = findExecutableOnPath("node", pathVal);
189
+ if (nodeOnPath) {
190
+ return {
191
+ execPath: nodeOnPath,
192
+ args: ["--experimental-strip-types", script],
193
+ runtimeType: "node",
194
+ };
195
+ }
196
+
197
+ return null;
198
+ }
199
+
200
+ function daemonScriptPath(): string {
201
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
202
+ }
203
+
204
+ function getHeartbeatMs(): number {
205
+ const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
206
+ if (raw) {
207
+ const parsed = Number(raw);
208
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
209
+ }
210
+ return DAEMON_HEARTBEAT_MS;
211
+ }
212
+
213
+ /** Per-OS loopback control-call timeout (Windows 3s, Linux 1.5s) unless overridden by env. */
214
+ export function getControlTimeoutMs(): number {
215
+ const raw = process.env[DAEMON_CONTROL_TIMEOUT_MS_ENV];
216
+ if (raw) {
217
+ const parsed = Number(raw);
218
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
219
+ }
220
+ return DAEMON_CONTROL_TIMEOUT_MS;
221
+ }
222
+
223
+ function getWatchdogMs(): number {
224
+ const raw = process.env[DAEMON_WATCHDOG_MS_ENV];
225
+ if (raw) {
226
+ const parsed = Number(raw);
227
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
228
+ }
229
+ return DAEMON_WATCHDOG_MS;
230
+ }
231
+
232
+ function isSpawnEnabled(): boolean {
233
+ return process.env[DAEMON_SPAWN_ENV] !== "0";
234
+ }
235
+
236
+ type ControlResult = "ok" | "unknown" | "legacy" | "gone";
237
+
238
+ async function controlCall(
239
+ port: number,
240
+ endpoint: string,
241
+ payload: Record<string, string>,
242
+ ): Promise<ControlResult> {
243
+ try {
244
+ const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
245
+ method: "POST",
246
+ headers: { "content-type": "application/json" },
247
+ body: JSON.stringify(payload),
248
+ signal: AbortSignal.timeout(getControlTimeoutMs()),
249
+ });
250
+ if (!res.ok) return "legacy";
251
+ try {
252
+ const data: unknown = await res.json();
253
+ if (data && typeof data === "object" && "ok" in data && data.ok === false) return "unknown";
254
+ } catch { }
255
+ return "ok";
256
+ } catch {
257
+ return "gone";
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Fire-and-forget background refresh of the OpenCode User-Agent.
263
+ * Called once at attach/startup and re-armed on the heartbeat cadence.
264
+ * Never on the request hot path, never awaited, failures silent: the pinned
265
+ * fallback keeps serving traffic offline and the 6h disk TTL keeps the live
266
+ * version fresh. The in-process last-attempt timestamp (set synchronously
267
+ * before firing) stops overlapping heartbeats from stampeding npm while a
268
+ * fetch is still in flight.
269
+ */
270
+ export function maybeRefreshOpenCodeUserAgent(): void {
271
+ const now = Date.now();
272
+ if (now - lastUaRefreshAt < OPENCODE_VERSION_TTL_MS) return;
273
+ lastUaRefreshAt = now;
274
+ try {
275
+ void Promise.resolve(refreshOpenCodeUserAgent(uaRefreshFetch ?? fetch)).catch(() => { });
276
+ } catch { }
277
+ }
278
+
279
+ /** Test seam: inject the fetch used by the background UA refresh. */
280
+ export function _setUaRefreshFetchForTest(fetchImpl: typeof fetch | null): void {
281
+ uaRefreshFetch = fetchImpl;
282
+ }
283
+
284
+ /** Test seam: reset the UA refresh storm-guard (and injected fetch). */
285
+ export function _resetUaRefreshForTest(): void {
286
+ lastUaRefreshAt = 0;
287
+ uaRefreshFetch = null;
288
+ }
289
+
290
+ function startHeartbeat(port: number): void {
291
+ stopHeartbeatInternal();
292
+ heartbeatPort = port;
293
+ const ms = getHeartbeatMs();
294
+ heartbeatTimer = setInterval(() => {
295
+ void beatOnce(port);
296
+ }, ms);
297
+ try {
298
+ heartbeatTimer.unref();
299
+ } catch { }
300
+ const wms = getWatchdogMs();
301
+ watchdogTimer = setInterval(() => {
302
+ void watchdogCheck(port);
303
+ }, wms);
304
+ try {
305
+ watchdogTimer.unref();
306
+ } catch { }
307
+ }
308
+
309
+ function stopHeartbeatInternal(): void {
310
+ if (heartbeatTimer !== null) {
311
+ clearInterval(heartbeatTimer);
312
+ heartbeatTimer = null;
313
+ }
314
+ if (watchdogTimer !== null) {
315
+ clearInterval(watchdogTimer);
316
+ watchdogTimer = null;
317
+ }
318
+ }
319
+
320
+ export function stopHeartbeat(): void {
321
+ const port = heartbeatPort;
322
+ stopHeartbeatInternal();
323
+ heartbeatPort = 0;
324
+ if (port) {
325
+ void controlCall(port, "/_client/detach", { id: CLIENT_ID });
326
+ }
327
+ if (fallbackServer) {
328
+ try {
329
+ fallbackServer.close();
330
+ } catch { }
331
+ fallbackServer = null;
332
+ }
333
+ }
334
+
335
+ async function beatOnce(port: number): Promise<void> {
336
+ maybeRefreshOpenCodeUserAgent();
337
+ if (ensuring) return;
338
+ const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
339
+ if (result === "gone") {
340
+ void ensureDaemon().catch((e) => logWarn("heartbeat respawn failed", { error: String(e) }));
341
+ } else if (result === "unknown") {
342
+ // Daemon restarted since attach: re-register our lease.
343
+ await attachTo(port);
344
+ }
345
+ }
346
+
347
+ async function attachTo(port: number): Promise<void> {
348
+ attachedPort = port;
349
+ const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
350
+ if (result === "gone") {
351
+ return;
352
+ }
353
+ startHeartbeat(port);
354
+ maybeRefreshOpenCodeUserAgent();
355
+ }
356
+
357
+ /**
358
+ * Respawn backoff: min(2s * 2^n, 60s) + jitter. n is the consecutive-failure
359
+ * count; pass jitterMs = 0 in tests for a deterministic base.
360
+ */
361
+ export function computeRecoveryBackoffMs(attempt: number, jitterMs = Math.random() * 500): number {
362
+ const n = Math.max(0, Math.floor(attempt));
363
+ const base = Math.min(RECOVERY_BACKOFF_BASE_MS * 2 ** n, RECOVERY_BACKOFF_CAP_MS);
364
+ return base + Math.max(0, jitterMs);
365
+ }
366
+
367
+ /**
368
+ * Stuck-busy check (pure, testable): a busy daemon may only be replaced after
369
+ * 5min of continuous busy AND zero forwarded stream bytes for 60s+. Anything
370
+ * else must be left untouched mid-request.
371
+ */
372
+ export function isStuckBusy(busySinceMs: number, lastBytesAt: number, now = Date.now()): boolean {
373
+ if (!busySinceMs) return false;
374
+ if (now - busySinceMs < BUSY_BYPASS_CONTINUOUS_MS) return false;
375
+ if (lastBytesAt && now - lastBytesAt < BUSY_BYPASS_QUIET_MS) return false;
376
+ return true;
377
+ }
378
+
379
+ /** Track the busy/idle edge from a health snapshot; returns the updated busySince. */
380
+ export function trackBusyEdge(activeRequests: number, lastBytesAt: number, now = Date.now()): number {
381
+ if (activeRequests > 0) {
382
+ if (!busySince) busySince = now;
383
+ } else {
384
+ busySince = 0;
385
+ }
386
+ return busySince;
387
+ }
388
+
389
+ /** True while the breaker halt is in effect (respawns paused, fallback serves). */
390
+ export function isBreakerHalted(now = Date.now()): boolean {
391
+ return now < breakerOpenUntil;
392
+ }
393
+
394
+ /** Record a recovery failure; opens the breaker after 5 straight failures in 5min. */
395
+ export function recordRecoveryFailure(now = Date.now()): boolean {
396
+ recoveryFailures.push(now);
397
+ recoveryFailures = recoveryFailures.filter((t) => now - t <= BREAKER_WINDOW_MS);
398
+ backoffAttempt += 1;
399
+ if (recoveryFailures.length >= BREAKER_FAILURES) {
400
+ breakerOpenUntil = now + BREAKER_HALT_MS;
401
+ logWarn("recovery breaker open — 5 straight failures in 5min, halting respawn for 10min (in-process fallback meanwhile)");
402
+ return true;
403
+ }
404
+ return false;
405
+ }
406
+
407
+ /** Record a healthy watchdog tick: clears the straight-failure run and backoff. */
408
+ export function recordRecoverySuccess(): void {
409
+ recoveryFailures = [];
410
+ backoffAttempt = 0;
411
+ breakerOpenUntil = 0;
412
+ }
413
+
414
+ /** Test seam: snapshot of the recovery counters. */
415
+ export function _getRecoveryStateForTest(): {
416
+ backoffAttempt: number;
417
+ failures: number[];
418
+ breakerOpenUntil: number;
419
+ busySince: number;
420
+ } {
421
+ return { backoffAttempt, failures: [...recoveryFailures], breakerOpenUntil, busySince };
422
+ }
423
+
424
+ /** Test seam: reset recovery counters without touching timers. */
425
+ export function _resetRecoveryForTest(): void {
426
+ backoffAttempt = 0;
427
+ recoveryFailures = [];
428
+ breakerOpenUntil = 0;
429
+ busySince = 0;
430
+ }
431
+
432
+ /** Recovery-decision shape; canonical definition lives in health.ts. */
433
+ export type HealthForRecovery = DaemonHealthSnapshot;
434
+
435
+ /**
436
+ * Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
437
+ * serves a stale version, or reports a degraded failed-SSE window. Missing
438
+ * sseDegraded (pre-window daemon) never triggers on its own.
439
+ */
440
+ export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
441
+ if (health === null) return true;
442
+ if (health.version !== null && health.version !== PKG_VERSION) return true;
443
+ if (health.sseDegraded === true) return true;
444
+ return false;
445
+ }
446
+
447
+ async function ensureInProcessFallback(): Promise<number> {
448
+ try {
449
+ const r = await startProxy();
450
+ if (r.server) fallbackServer = r.server;
451
+ await attachTo(r.port);
452
+ return r.port;
453
+ } catch (e) {
454
+ logWarn("in-process fallback bind failed", { error: String(e) });
455
+ return PORT;
456
+ }
457
+ }
458
+
459
+ async function triggerRecovery(port: number, why: string): Promise<void> {
460
+ if (ensuring) return;
461
+ ensuring = true;
462
+ try {
463
+ const now = Date.now();
464
+ if (isBreakerHalted(now)) {
465
+ logWarn(`recovery breaker halted — serving in-process fallback meanwhile (${why})`);
466
+ void ensureInProcessFallback();
467
+ return;
468
+ }
469
+ const opened = recordRecoveryFailure(now);
470
+ if (opened) {
471
+ void ensureInProcessFallback();
472
+ return;
473
+ }
474
+ const delay = computeRecoveryBackoffMs(backoffAttempt - 1);
475
+ logWarn(`watchdog recovery (${why}) — backing off ${Math.round(delay)}ms before respawn`);
476
+ stopHeartbeatInternal();
477
+ heartbeatPort = 0;
478
+ await new Promise<void>((r) => setTimeout(r, delay));
479
+ try {
480
+ const health = await getDaemonHealth(port);
481
+ if (health && (health.activeRequests ?? 0) > 0) {
482
+ trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0, Date.now());
483
+ if (!isStuckBusy(busySince, health.lastBytesAt ?? 0, Date.now())) {
484
+ logInfo(`watchdog recovery deferred — daemon busy (${health.activeRequests} active, waiting for 5min-stuck + 60s-quiet)`);
485
+ await attachTo(port);
486
+ return;
487
+ }
488
+ }
489
+ const ver = health?.version ?? PKG_VERSION;
490
+ await killStaleDaemon(port, ver, "proxy daemon");
491
+ } catch (e) {
492
+ logWarn("watchdog pre-respawn probe failed", { error: String(e) });
493
+ }
494
+ // Release before delegating: ensureDaemon owns the flag from here (it
495
+ // early-returns while ensuring is true). The handoff is synchronous up
496
+ // to ensureDaemon's own ensuring=true, so no tick slips between.
497
+ ensuring = false;
498
+ await ensureDaemon();
499
+ } finally {
500
+ ensuring = false;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * 5s watchdog tick: polls /_health for version + failed-SSE rate and drives
506
+ * stopHeartbeat → busy-ruled kill → ensureDaemon when the daemon is stale or
507
+ * degraded. Healthy ticks reset backoff/breaker and track the busy edge.
508
+ */
509
+ export async function watchdogCheck(port: number): Promise<void> {
510
+ if (ensuring) return;
511
+ if (isBreakerHalted()) return;
512
+ let health: Awaited<ReturnType<typeof getDaemonHealth>>;
513
+ try {
514
+ health = await getDaemonHealth(port);
515
+ } catch {
516
+ return;
517
+ }
518
+ if (health === null) {
519
+ await new Promise<void>((r) => setTimeout(r, 200));
520
+ try {
521
+ health = await getDaemonHealth(port);
522
+ } catch {
523
+ return;
524
+ }
525
+ }
526
+ if (health && (health.activeRequests ?? 0) > 0) {
527
+ trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
528
+ } else {
529
+ trackBusyEdge(0, 0);
530
+ }
531
+ if (!shouldRecoverOnHealth(health)) {
532
+ recordRecoverySuccess();
533
+ return;
534
+ }
535
+ if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
536
+ void triggerRecovery(port, health === null ? "daemon gone" : health.version !== PKG_VERSION ? `stale v${health.version}` : "failed-SSE degraded").catch((e) => logWarn("watchdog recovery failed", { error: String(e) }));
537
+ }
538
+
539
+ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
540
+ if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
541
+ logInfo(
542
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
543
+ );
544
+ return false;
545
+ }
546
+ if (compareVersions(remoteVer, PKG_VERSION) > 0) {
547
+ logInfo(
548
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
549
+ );
550
+ return false;
551
+ }
552
+ const health = await getDaemonHealth(port);
553
+ if (health === null || health.activeRequests === undefined) {
554
+ logInfo(
555
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
556
+ );
557
+ return false;
558
+ }
559
+ if (health.activeRequests > 0) {
560
+ trackBusyEdge(health.activeRequests, health.lastBytesAt ?? 0);
561
+ if (!isStuckBusy(busySince, health.lastBytesAt ?? 0)) {
562
+ logInfo(
563
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
564
+ );
565
+ return false;
566
+ }
567
+ logWarn(`busy daemon on :${port} stuck (busy 5min+, no forwarded bytes 60s+) — bypassing busy guard`);
568
+ return true;
569
+ }
570
+ busySince = 0;
571
+ return true;
572
+ }
573
+
574
+ async function killStaleDaemon(
575
+ port: number,
576
+ remoteVer: string,
577
+ what: string,
578
+ ): Promise<boolean> {
579
+ if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
580
+ logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
581
+ remoteVer,
582
+ expected: PKG_VERSION,
583
+ });
584
+ await killPortHolder(port);
585
+ for (let i = 0; i < 10; i++) {
586
+ await new Promise<void>((r) => setTimeout(r, 200));
587
+ if (!(await isProxyAlive(port))) return true;
588
+ }
589
+ logInfo(
590
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
591
+ );
592
+ return false;
593
+ }
594
+
595
+ /**
596
+ * Open the shared log file for the detached daemon's stdout/stderr so a
597
+ * native abort or crash leaves its reason on disk instead of vanishing into
598
+ * stdio "ignore". Returns the fd, or null when the file is unavailable
599
+ * (the spawn then falls back to ignore).
600
+ */
601
+ export function openDaemonLogFd(): number | null {
602
+ try {
603
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
604
+ return fs.openSync(LOG_FILE, "a");
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ export type DarkHolderProbe = {
611
+ /** True when GET /v1/models answers on the port. */
612
+ alive: boolean;
613
+ /** Full /_health snapshot, or null when the holder answers nothing. */
614
+ health: HealthForRecovery;
615
+ };
616
+
617
+ /**
618
+ * Pure decision: reclaim a base port whose holder answers neither /v1/models
619
+ * nor /_health. A live daemon — stale, busy, even mid-stream — always answers
620
+ * both from its event loop, so a dark holder cannot be serving traffic and
621
+ * replacing it cannot interrupt a real stream.
622
+ */
623
+ export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
624
+ if (probe.alive) return false;
625
+ if (probe.health !== null) return false;
626
+ return true;
627
+ }
628
+
629
+ /**
630
+ * Dislodge a dark holder from the base port so a fresh daemon can bind it.
631
+ * Returns true when a healthy proxy answers afterwards (attach to it);
632
+ * false when the caller should spawn fresh (port presumably free).
633
+ * Honors the no-kill env opt-out; a live holder is never touched here —
634
+ * the stale-replace path owns versioned daemons.
635
+ */
636
+ async function reclaimDarkHolder(port: number): Promise<boolean> {
637
+ const alive = await isProxyAlive(port);
638
+ const health = alive ? null : await getDaemonHealth(port);
639
+ if (!shouldReclaimDarkHolder({ alive, health })) return true;
640
+ if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
641
+ logInfo(
642
+ `proxy on :${port} answers neither health nor models — leaving it alone (replacement disabled by env)`,
643
+ );
644
+ return false;
645
+ }
646
+ logWarn(`proxy on :${port} holds the port but answers nothing — replacing dark holder`);
647
+ await killPortHolder(port);
648
+ for (let i = 0; i < 10; i++) {
649
+ await new Promise<void>((r) => setTimeout(r, 200));
650
+ if (await isProxyAlive(port)) return true;
651
+ }
652
+ return false;
653
+ }
654
+
655
+ function mentionsLoopback(text: string): boolean {
656
+ return text.includes("127.0.0.1") || text.includes("::1") || text.includes("localhost");
657
+ }
658
+
659
+ /**
660
+ * True when a fetch failure is a refused loopback connection — the local
661
+ * proxy is gone, not the upstream. Walks the undici cause chain, so both
662
+ * bare { code: "ECONNREFUSED" } errors and "fetch failed" wrappers classify.
663
+ */
664
+ export function isLoopbackRefused(err: unknown): boolean {
665
+ let refused = false;
666
+ let loopback = false;
667
+ const seen = new Set<unknown>();
668
+ const stack: unknown[] = [err];
669
+ while (stack.length > 0) {
670
+ const cur = stack.pop();
671
+ if (typeof cur === "string") {
672
+ if (mentionsLoopback(cur)) loopback = true;
673
+ continue;
674
+ }
675
+ if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
676
+ seen.add(cur);
677
+ const rec = cur as Record<string, unknown>;
678
+ if (rec.code === "ECONNREFUSED") refused = true;
679
+ if (typeof rec.message === "string" && mentionsLoopback(rec.message)) loopback = true;
680
+ if (typeof rec.host === "string" && mentionsLoopback(rec.host)) loopback = true;
681
+ if (typeof rec.address === "string" && mentionsLoopback(rec.address)) loopback = true;
682
+ if (rec.cause !== undefined) stack.push(rec.cause);
683
+ }
684
+ return refused && loopback;
685
+ }
686
+
687
+ /**
688
+ * Explicit proxy-down message: names the loopback port and the recovery
689
+ * action. Used wherever a refused loopback would otherwise surface as a
690
+ * per-model connect failure.
691
+ */
692
+ export function proxyDownMessage(port: number): string {
693
+ return (
694
+ `pi-freeflow proxy is down on 127.0.0.1:${port} (connection refused) — ` +
695
+ `model requests cannot reach the local proxy until it is back. ` +
696
+ `Start a new session or run /freeflow to respawn it.`
697
+ );
698
+ }
699
+
700
+ export type ProxyReadiness = { ok: true; port: number } | { ok: false; port: number; reason: string };
701
+
702
+ /**
703
+ * Health probe before requests: when the loopback answers, no respawn runs.
704
+ * When it refuses, one bounded respawn retry runs, then an explicit
705
+ * proxy-down result — never a silent per-model connect failure.
706
+ * The respawn seam defaults to the full daemon ensure; tests inject a fake.
707
+ */
708
+ export async function ensureProxyReady(
709
+ port: number,
710
+ respawn: () => Promise<number> = ensureDaemon,
711
+ ): Promise<ProxyReadiness> {
712
+ if (await isProxyAlive(port)) return { ok: true, port };
713
+ let next = port;
714
+ try {
715
+ next = await respawn();
716
+ } catch (e) {
717
+ logWarn("proxy respawn failed", { error: String(e) });
718
+ }
719
+ if (await isProxyAlive(next)) return { ok: true, port: next };
720
+ if (next !== port && (await isProxyAlive(port))) return { ok: true, port };
721
+ return { ok: false, port, reason: proxyDownMessage(port) };
722
+ }
723
+
724
+ let lastSpawnAt = 0;
725
+ const SPAWN_THROTTLE_MS = 2_000;
726
+
727
+ function spawnDaemonProcess(): boolean {
728
+ const now = Date.now();
729
+ if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
730
+ logWarn("daemon spawn throttled — recent spawn still pending");
731
+ return false;
732
+ }
733
+ const runtime = resolveDaemonRuntime();
734
+ if (!runtime) {
735
+ logWarn(
736
+ `cannot spawn detached proxy daemon: no node or bun runtime found (process.execPath "${process.execPath}" is not a JS runtime) — falling back to in-process proxy`,
737
+ );
738
+ return false;
739
+ }
740
+ lastSpawnAt = now;
741
+ const logFd = openDaemonLogFd();
742
+ try {
743
+ const child = spawn(runtime.execPath, runtime.args, {
744
+ detached: true,
745
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
746
+ windowsHide: true,
747
+ env: {
748
+ ...process.env,
749
+ [DAEMON_SPAWN_ENV]: "0",
750
+ },
751
+ });
752
+ child.unref();
753
+ logInfo(
754
+ `spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}`,
755
+ );
756
+ child.on("error", (err) => {
757
+ logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
758
+ });
759
+ return true;
760
+ } catch (e) {
761
+ logWarn("daemon spawn failed", { error: String(e), execPath: runtime.execPath });
762
+ return false;
763
+ } finally {
764
+ if (logFd !== null) {
765
+ try {
766
+ fs.closeSync(logFd);
767
+ } catch { }
768
+ }
769
+ }
770
+ }
771
+
772
+ async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
773
+ const deadline = Date.now() + timeoutMs;
774
+ while (Date.now() < deadline) {
775
+ if (await isProxyAlive(port)) return true;
776
+ await new Promise<void>((r) => setTimeout(r, 200));
777
+ }
778
+ return false;
779
+ }
780
+
781
+ async function probeAndMaybeReplace(
782
+ port: number,
783
+ label: string,
784
+ ): Promise<number | null> {
785
+ if (!(await isProxyAlive(port))) return null;
786
+ const ver = await getDaemonVersion(port);
787
+ if (ver !== null && ver !== PKG_VERSION) {
788
+ if (await killStaleDaemon(port, ver, label)) {
789
+ return null;
790
+ }
791
+ if (await isProxyAlive(port)) return port;
792
+ return null;
793
+ }
794
+ return port;
795
+ }
796
+
797
+ /**
798
+ * Ensure a proxy daemon is running and attach this client to it.
799
+ * Returns the port the caller should use for its ProviderConfig.
800
+ */
801
+ export async function ensureDaemon(): Promise<number> {
802
+ if (ensuring) return attachedPort || PORT;
803
+ ensuring = true;
804
+ try {
805
+ const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
806
+ if (primary !== null) {
807
+ await attachTo(primary);
808
+ return primary;
809
+ }
810
+
811
+ if (PORT !== LEGACY_PORT) {
812
+ const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
813
+ if (legacy !== null) {
814
+ await attachTo(legacy);
815
+ return legacy;
816
+ }
817
+ }
818
+
819
+ if (isSpawnEnabled()) {
820
+ // Cross-process race guard: a sibling session may be spawning right
821
+ // now (singleflight is per-process). Jitter + re-probe converts most
822
+ // cold-start races into attach instead of N detached spawns.
823
+ await new Promise<void>((r) => setTimeout(r, 100 + Math.random() * 200));
824
+ if (await isProxyAlive(PORT)) {
825
+ await attachTo(PORT);
826
+ return PORT;
827
+ }
828
+ const spawned = spawnDaemonProcess();
829
+ if (spawned) {
830
+ const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
831
+ if (ready) {
832
+ const ver = await getDaemonVersion(PORT);
833
+ if (ver !== null && ver !== PKG_VERSION) {
834
+ if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
835
+ lastSpawnAt = 0; // kill-confirmed retry bypasses the spawn throttle
836
+ if (spawnDaemonProcess()) {
837
+ const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
838
+ if (retryReady) {
839
+ await attachTo(PORT);
840
+ return PORT;
841
+ }
842
+ }
843
+ } else if (await isProxyAlive(PORT)) {
844
+ await attachTo(PORT);
845
+ return PORT;
846
+ }
847
+ } else {
848
+ await attachTo(PORT);
849
+ return PORT;
850
+ }
851
+ } else {
852
+ // The spawn never answered: it crashed instantly or a dark holder owns
853
+ // the base port (bind fails, probes fail).
854
+ if (await reclaimDarkHolder(PORT)) {
855
+ await attachTo(PORT);
856
+ return PORT;
857
+ }
858
+ logWarn("daemon spawn did not become ready — is the port blocked?");
859
+ }
860
+ }
861
+ // Fall through to the in-process fallback below — never hand the caller
862
+ // a port we did not attach to (no lease, no heartbeat).
863
+ }
864
+
865
+ try {
866
+ const r = await startProxy();
867
+ if (r.server) fallbackServer = r.server;
868
+ let port = r.port;
869
+ if (port !== PORT && r.server) {
870
+ // Walked-port guard: the base port may have freed during the
871
+ // 3s bind race. Re-probe :28180 before settling on port+1 so
872
+ // parallel startups converge instead of stranding followers.
873
+ if (await reprobeBasePortAlive(PORT, BASE_PORT_REPROBE_MS)) {
874
+ try {
875
+ r.server.close();
876
+ } catch { }
877
+ if (fallbackServer === r.server) fallbackServer = null;
878
+ logInfo(`base port ${PORT} freed during walk — re-attaching to :${PORT} instead of :${port}`);
879
+ await attachTo(PORT);
880
+ return PORT;
881
+ }
882
+ }
883
+ await attachTo(port);
884
+ return port;
885
+ } catch (e) {
886
+ logWarn("in-process fallback bind failed", { error: String(e) });
887
+ return PORT;
888
+ }
889
+ } finally {
890
+ ensuring = false;
891
+ }
892
+ }
893
+
894
+ export function getClientPort(): number {
895
+ return attachedPort || PORT;
896
+ }
897
+
898
+ export function getClientId(): string {
899
+ return CLIENT_ID;
900
+ }
901
+
902
+ export function hasFallbackServer(): boolean {
903
+ return fallbackServer !== null;
904
+ }
905
+
906
+ /** Test seam: true when client heartbeat timer is active. */
907
+ export function isHeartbeatActive(): boolean {
908
+ return heartbeatTimer !== null;
909
+ }
910
+
911
+ export function _resetClientForTest(): void {
912
+ stopHeartbeatInternal();
913
+ heartbeatPort = 0;
914
+ attachedPort = 0;
915
+ ensuring = false;
916
+ lastSpawnAt = 0;
917
+ _resetUaRefreshForTest();
918
+ _resetRecoveryForTest();
919
+ if (fallbackServer) {
920
+ try {
921
+ fallbackServer.close();
922
+ } catch { }
923
+ fallbackServer = null;
924
+ }
925
+ }