pi-freeflow 1.9.1 → 1.9.4

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/health.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type * as http from "node:http";
7
7
  import { ALL_MODELS } from "./models.ts";
8
8
  import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
9
+ import { getLastActivityAt, getLeaseCount, getLeaseSnapshot } from "./lease.ts";
9
10
  import { PKG_VERSION, PORT } from "./config.ts";
10
11
 
11
12
  export interface HealthRelayInfo {
@@ -26,7 +27,14 @@ export interface HealthData {
26
27
  version: string;
27
28
  /** In-flight proxied requests right now (stale-daemon replacement guard). */
28
29
  activeRequests: number;
30
+ /** Clients holding a live lease (detached-daemon GC). */
31
+ clients: number;
32
+ /** clientId -> lastSeenAt for every live lease. */
33
+ leases: Record<string, number>;
34
+ /** Last time any request was proxied (request-touch for legacy clients). */
35
+ lastActivityAt: number;
29
36
  }
37
+
30
38
  /**
31
39
  * Collect current health snapshot.
32
40
  * @param portOverride - actual listening port (defaults to config PORT)
@@ -54,10 +62,16 @@ export function getHealthData(portOverride?: number, activeRequests = 0): Health
54
62
  catalog: ALL_MODELS.length,
55
63
  version: PKG_VERSION,
56
64
  activeRequests,
65
+ clients: getLeaseCount(),
66
+ leases: getLeaseSnapshot(),
67
+ lastActivityAt: getLastActivityAt(),
57
68
  };
58
69
  }
59
70
 
60
- function isLoopbackIP(ip: string): boolean {
71
+ /**
72
+ * Check whether an IP address is a loopback address (127.0.0.1, ::1, localhost).
73
+ */
74
+ export function isLoopbackIP(ip: string): boolean {
61
75
  if (!ip) return false;
62
76
  const withoutZone = ip.split("%")[0];
63
77
  const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
@@ -75,38 +89,31 @@ export function handleHealthRequest(
75
89
  portOverride?: number,
76
90
  activeRequests = 0,
77
91
  ): boolean {
78
- let pathname: string | null = null;
92
+ if (req.method !== "GET") return false;
93
+
94
+ let reqPathname: string | null = null;
79
95
  try {
80
- pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
96
+ reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
81
97
  } catch {
82
98
  return false;
83
99
  }
100
+ if (reqPathname === null) return false;
101
+ if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
84
102
 
85
- const isHealthPath = pathname === "/_health" || pathname === "/health";
86
- if (req.method !== "GET" || !isHealthPath) {
87
- return false;
88
- }
89
-
90
- const sock: unknown = req.socket;
91
- let rawIp = "";
92
- if (sock && typeof sock === "object" && "remoteAddress" in sock) {
93
- const v = sock.remoteAddress;
94
- if (typeof v === "string") rawIp = v;
95
- }
96
- const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
103
+ const clientIP = req.socket.remoteAddress ?? "";
97
104
  if (!isLoopbackIP(clientIP)) {
98
- const body = JSON.stringify({ error: "forbidden" });
99
- res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
100
- res.end(body);
105
+ res.writeHead(403, { "content-type": "application/json" });
106
+ res.end(JSON.stringify({ error: "loopback only" }));
101
107
  return true;
102
108
  }
103
109
 
104
110
  const data = getHealthData(portOverride, activeRequests);
105
- const body = JSON.stringify(data);
111
+ const body = JSON.stringify(data, null, 2);
106
112
  res.writeHead(200, {
107
113
  "content-type": "application/json",
108
114
  "content-length": Buffer.byteLength(body),
115
+ "cache-control": "no-store",
109
116
  });
110
117
  res.end(body);
111
118
  return true;
112
- }
119
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  */
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import type * as http from "node:http";
13
12
  import {
14
13
  getAliveCatalog,
15
14
  mergeCatalog,
@@ -17,13 +16,12 @@ import {
17
16
  refreshCatalog,
18
17
  setAliveCatalog,
19
18
  } from "./catalog.ts";
19
+ import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
20
20
  import { createCommandSpec, updateStatusBar } from "./commands.ts";
21
- import { HOST, LEGACY_PORT, NO_KILL_ENV, ONBOARDED_FLAG_FILE, PKG_VERSION, PORT } from "./config.ts";
22
- import { log, logInfo, logWarn } from "./logger.ts";
21
+ import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
22
+ import { logInfo, logWarn } from "./logger.ts";
23
23
  import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
24
- import { getDaemonHealth, getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
25
- import { resetRateLimits } from "./rate-limiter.ts";
26
- import { checkForUpdateInBackground, compareVersions } from "./update-checker.ts";
24
+ import { checkForUpdateInBackground } from "./update-checker.ts";
27
25
  import {
28
26
  ensureRelay,
29
27
  getActiveRelayState,
@@ -160,150 +158,18 @@ export function buildProviderConfig(
160
158
  }),
161
159
  };
162
160
  }
163
-
164
- /**
165
- * Stale-daemon replacement guard.
166
- * Never interrupt a working shared daemon: a newer-version daemon is left
167
- * running (downgrade case), an in-flight daemon is left running (busy case —
168
- * its streams would die mid-flight), a pre-1.9 daemon that cannot report
169
- * usage is left running (cannot verify), and NO_KILL_ENV disables replacement
170
- * entirely. Only older, VERIFIED-idle daemons are replaced.
171
- */
172
- async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
173
- if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
174
- logInfo(
175
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
176
- );
177
- return false;
178
- }
179
- if (compareVersions(remoteVer, PKG_VERSION) > 0) {
180
- logInfo(
181
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
182
- );
183
- return false;
184
- }
185
- const health = await getDaemonHealth(port);
186
- if (health === null || health.activeRequests === undefined) {
187
- // Pre-1.9 daemons do not report in-flight requests: usage cannot be
188
- // verified, so the running daemon is left untouched. Interrupting a
189
- // possibly-busy session is worse than keeping an older daemon alive.
190
- logInfo(
191
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
192
- );
193
- return false;
194
- }
195
- if (health.activeRequests > 0) {
196
- logInfo(
197
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
198
- );
199
- return false;
200
- }
201
- return true;
202
- }
203
-
204
- /**
205
- * Guarded kill ritual for a stale daemon: decide (older-only, verified-idle,
206
- * NO_KILL) → log → kill → wait for the port to free. Returns true when the
207
- * port stopped answering (caller re-binds or re-attaches below); false when
208
- * the daemon is left running — every skip reason is logged here.
209
- */
210
- async function killStaleDaemon(
211
- port: number,
212
- remoteVer: string,
213
- what: string,
214
- ): Promise<boolean> {
215
- if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
216
- logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
217
- remoteVer,
218
- expected: PKG_VERSION,
219
- });
220
- await killPortHolder(port);
221
- for (let i = 0; i < 10; i++) {
222
- await new Promise<void>((r) => setTimeout(r, 200));
223
- if (!(await isProxyAlive(port))) return true;
224
- }
225
- logInfo(
226
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
227
- );
228
- return false;
229
- }
230
161
  /**
231
162
  * Main extension entrypoint
232
163
  */
233
164
  export default async function (pi: ExtensionAPI): Promise<void> {
234
165
  logInfo("pi-freeflow extension initializing...");
235
166
 
236
-
237
- let server: http.Server | null = null;
238
- let actualPort = PORT;
239
-
240
- // 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
241
- // Dual-probe: probe current PORT first; if missing, check LEGACY_PORT so existing v1.4.9
242
- // sessions on 18080 are seamlessly reused without split-brain or duplicate daemons.
243
- // Stale-daemon heal: if the alive daemon reports a different version (e.g. 1.7.1 vs 1.8.0
244
- // after an upgrade), the auth fix never loads for the new session. Detect via /_health
245
- // and best-effort replace the stale holder so old users are auto-healed.
246
- let alreadyRunning = await isProxyAlive(PORT);
247
- if (alreadyRunning) {
248
- const remoteVer = await getDaemonVersion(PORT);
249
- if (remoteVer !== null && remoteVer !== PKG_VERSION) {
250
- if (await killStaleDaemon(PORT, remoteVer, "proxy daemon")) {
251
- try {
252
- const r = await startProxy();
253
- server = r.server;
254
- actualPort = r.port;
255
- alreadyRunning = false;
256
- } catch (e) {
257
- log("error", "stale daemon replaced but fresh bind failed — reusing stale as fallback", {
258
- error: String(e),
259
- });
260
- actualPort = PORT;
261
- }
262
- } else {
263
- actualPort = PORT;
264
- }
265
- } else {
266
- logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`);
267
- actualPort = PORT;
268
- }
269
- } else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
270
- const remoteVer = await getDaemonVersion(LEGACY_PORT);
271
- if (remoteVer !== null && remoteVer !== PKG_VERSION) {
272
- if (await killStaleDaemon(LEGACY_PORT, remoteVer, "legacy proxy daemon")) {
273
- try {
274
- const r = await startProxy();
275
- server = r.server;
276
- actualPort = r.port;
277
- alreadyRunning = false;
278
- } catch (e) {
279
- log("error", "stale legacy daemon replaced but fresh bind failed — reusing as fallback", {
280
- error: String(e),
281
- });
282
- alreadyRunning = true;
283
- actualPort = LEGACY_PORT;
284
- }
285
- } else {
286
- alreadyRunning = true;
287
- actualPort = LEGACY_PORT;
288
- }
289
- } else {
290
- logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
291
- alreadyRunning = true;
292
- actualPort = LEGACY_PORT;
293
- }
294
- } else {
295
- try {
296
- const r = await startProxy();
297
- server = r.server;
298
- actualPort = r.port;
299
- } catch (e) {
300
- log(
301
- "error",
302
- "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
303
- { error: String(e) },
304
- );
305
- return;
306
- }
167
+ let actualPort: number;
168
+ try {
169
+ actualPort = await ensureClientDaemon();
170
+ } catch (e) {
171
+ logWarn("daemon ensure failed — using configured port", { error: String(e) });
172
+ actualPort = getClientPort();
307
173
  }
308
174
  // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
309
175
  const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
@@ -318,63 +184,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
318
184
  buildProviderConfig(models, actualPort),
319
185
  );
320
186
  };
321
- let ensuringDaemon = false;
322
187
  const ensureDaemon = async (): Promise<void> => {
323
- if (ensuringDaemon) return;
324
- ensuringDaemon = true;
325
188
  try {
326
- if (await isProxyAlive(actualPort)) {
327
- const v = await getDaemonVersion(actualPort);
328
- if (v === null || v === PKG_VERSION) return;
329
- if (!(await killStaleDaemon(actualPort, v, "proxy daemon"))) return;
330
- }
331
- if (await isProxyAlive(PORT)) {
332
- const v = await getDaemonVersion(PORT);
333
- if (v === null || v === PKG_VERSION) {
334
- actualPort = PORT;
335
- registerCatalog(getAliveCatalog());
336
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
337
- return;
338
- }
339
- if (await killStaleDaemon(PORT, v, "proxy daemon")) {
340
- // Port freed — fall through to legacy probe / re-bind below.
341
- } else if (await isProxyAlive(PORT)) {
342
- actualPort = PORT;
343
- registerCatalog(getAliveCatalog());
344
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
345
- return;
346
- } else {
347
- return; // daemon replaced; re-bind happens below
348
- }
189
+ const port = await ensureClientDaemon();
190
+ if (port !== actualPort) {
191
+ actualPort = port;
192
+ registerCatalog(getAliveCatalog());
193
+ logInfo(`Re-attached to proxy daemon on http://${HOST}:${port}`);
349
194
  }
350
- if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
351
- const v = await getDaemonVersion(LEGACY_PORT);
352
- if (v === null || v === PKG_VERSION) {
353
- actualPort = LEGACY_PORT;
354
- registerCatalog(getAliveCatalog());
355
- logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
356
- return;
357
- }
358
- if (await killStaleDaemon(LEGACY_PORT, v, "legacy proxy daemon")) {
359
- // Port freed — fall through to the re-bind below.
360
- } else if (await isProxyAlive(LEGACY_PORT)) {
361
- actualPort = LEGACY_PORT;
362
- registerCatalog(getAliveCatalog());
363
- logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
364
- return;
365
- } else {
366
- return; // daemon replaced; re-bind happens below
367
- }
368
- }
369
- const r = await startProxy();
370
- if (r.server) server = r.server;
371
- if (r.port) actualPort = r.port;
372
- registerCatalog(getAliveCatalog());
373
- logWarn("proxy daemon was lost — re-bound locally", { port: actualPort });
374
195
  } catch (e) {
375
- logWarn("proxy daemon lost and re-bind failed", { error: String(e) });
376
- } finally {
377
- ensuringDaemon = false;
196
+ logWarn("proxy daemon re-attach failed", { error: String(e) });
378
197
  }
379
198
  };
380
199
 
@@ -510,16 +329,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
510
329
  }
511
330
  });
512
331
  pi.on?.("session_shutdown", () => {
513
- if (server) {
514
- logInfo("shutting down proxy daemon...");
515
- const closing = server;
516
- server = null;
517
- closing.close();
518
- // Node 18.2+: closeIdleConnections exists at runtime even if lib types lag
519
- const closable = closing as unknown as { closeIdleConnections?: () => void };
520
- closable.closeIdleConnections?.();
521
- resetRateLimits();
522
- logInfo("shutdown complete");
523
- }
332
+ stopHeartbeat();
524
333
  });
525
334
  }
package/src/lease.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Client lease registry for the detached pi-freeflow proxy daemon.
3
+ *
4
+ * Each OMP/Pi session is a client that registers a lease and renews it with a
5
+ * heartbeat while alive. The daemon drops expired leases and, once NO client
6
+ * holds a live lease AND no request has been proxied recently, retires itself.
7
+ *
8
+ * The request-touch (`lastActivityAt`) is the fallback for legacy clients that
9
+ * never heartbeated: any proxied request counts as a live user, so the daemon
10
+ * is never idle-killed while a session is actually using it.
11
+ */
12
+
13
+ export interface LeaseOptions {
14
+ /** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
15
+ ttlMs: number;
16
+ /** GC sweep interval (ms). */
17
+ gcMs: number;
18
+ /** Idle grace after the last proxied request before a lease-less daemon exits (ms). */
19
+ graceMs: number;
20
+ /** Current in-flight proxied requests — daemon never exits mid-stream. */
21
+ getActiveRequests: () => number;
22
+ /** Called once when the daemon should retire (close server + exit). */
23
+ onIdle: () => void;
24
+ }
25
+
26
+ const leases = new Map<string, number>();
27
+ let lastActivityAt = Date.now();
28
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
29
+
30
+ /** Register or refresh a client lease. */
31
+ export function registerClient(clientId: string): void {
32
+ leases.set(clientId, Date.now());
33
+ }
34
+
35
+ /** Renew an existing client lease (unknown ids are ignored). */
36
+ export function renewClient(clientId: string): void {
37
+ if (leases.has(clientId)) {
38
+ leases.set(clientId, Date.now());
39
+ }
40
+ }
41
+
42
+ /** Remove a client lease (graceful detach on session end). */
43
+ export function unregisterClient(clientId: string): void {
44
+ leases.delete(clientId);
45
+ }
46
+
47
+ /** Number of clients holding a live lease. */
48
+ export function getLeaseCount(): number {
49
+ return leases.size;
50
+ }
51
+
52
+ /** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
53
+ export function getLeaseSnapshot(): Record<string, number> {
54
+ return Object.fromEntries(leases);
55
+ }
56
+
57
+ /** Record proxy activity — any proxied request counts as a live user. */
58
+ export function touchActivity(): void {
59
+ lastActivityAt = Date.now();
60
+ }
61
+
62
+ /** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
63
+ export function getLastActivityAt(): number {
64
+ return lastActivityAt;
65
+ }
66
+
67
+ /**
68
+ * Start the lease GC sweep. Prunes expired leases and, when no client holds a
69
+ * lease, nothing is in flight, and no request has been proxied within the
70
+ * grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
71
+ * call is a no-op.
72
+ */
73
+ export function startLeaseGC(opts: LeaseOptions): void {
74
+ if (gcTimer !== null) return;
75
+ gcTimer = setInterval(() => {
76
+ const now = Date.now();
77
+ for (const [id, seenAt] of leases) {
78
+ if (now - seenAt > opts.ttlMs) {
79
+ leases.delete(id);
80
+ }
81
+ }
82
+ if (
83
+ leases.size === 0 &&
84
+ opts.getActiveRequests() === 0 &&
85
+ now - lastActivityAt > opts.graceMs
86
+ ) {
87
+ stopLeaseGC();
88
+ opts.onIdle();
89
+ }
90
+ }, opts.gcMs);
91
+ }
92
+
93
+ /** Stop the GC sweep (test teardown / daemon shutdown). */
94
+ export function stopLeaseGC(): void {
95
+ if (gcTimer !== null) {
96
+ clearInterval(gcTimer);
97
+ gcTimer = null;
98
+ }
99
+ }
100
+
101
+ /** Test-only: reset all lease state. */
102
+ export function _resetLeaseStateForTest(): void {
103
+ leases.clear();
104
+ lastActivityAt = Date.now();
105
+ stopLeaseGC();
106
+ }
package/src/proxy.ts CHANGED
@@ -9,9 +9,11 @@ import { randomUUID } from "node:crypto";
9
9
  import { execSync } from "node:child_process";
10
10
  import * as http from "node:http";
11
11
  import * as https from "node:https";
12
- import { handleHealthRequest } from "./health.ts";
12
+ import { handleHealthRequest, isLoopbackIP } from "./health.ts";
13
+ import { registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
13
14
  import { Readable } from "node:stream";
14
15
  import type { ReadableStream as WebReadableStream } from "node:stream/web";
16
+
15
17
  import { getAliveCatalog } from "./catalog.ts";
16
18
  import {
17
19
  ALLOWED_METHODS,
@@ -25,15 +27,22 @@ import {
25
27
  UPSTREAM_OPENCODE,
26
28
  opencodeHeaders,
27
29
  } from "./config.ts";
30
+
28
31
  import { isDebugEnabled, log } from "./logger.ts";
29
32
  import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
30
33
  // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
31
34
  import { checkRateLimit } from "./rate-limiter.ts";
32
- import { agent, canUseCustomDispatcher, relayFetch } from "./relay.ts";
35
+ import { relayFetch } from "./relay.ts";
33
36
  import { getActiveRelayState } from "./relay-state.ts";
34
37
  import { pipeUpstreamStream } from "./stream-pipe.ts";
35
38
  import type { Upstream } from "./types.ts";
36
39
 
40
+
41
+ let shutdownShouldExit = false;
42
+ export function setShutdownShouldExit(v: boolean): void {
43
+ shutdownShouldExit = v;
44
+ }
45
+
37
46
  /**
38
47
  * Direct-mode 429 hint throttle: the guidance hint is emitted at most once per
39
48
  * 10 minutes per process so repeated rate-limit responses don't spam clients.
@@ -173,7 +182,11 @@ export async function killPortHolder(port: number): Promise<boolean> {
173
182
  try {
174
183
  if (process.platform === "win32") {
175
184
  try {
176
- const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", timeout: 3000 }) as string;
185
+ const out = execSync(`netstat -ano | findstr :${port}`, {
186
+ encoding: "utf8",
187
+ timeout: 3000,
188
+ windowsHide: true,
189
+ }) as string;
177
190
  for (const line of out.split("\n")) {
178
191
  if (!line.includes("LISTENING")) continue;
179
192
  if (!line.includes(`:${port}`)) continue;
@@ -182,7 +195,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
182
195
  if (!pid || !/^\d+$/.test(pid)) continue;
183
196
  // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
184
197
  if (Number(pid) === process.pid) continue; // never self-kill
185
- execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
198
+ execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
186
199
  return true;
187
200
  }
188
201
  } catch {}
@@ -206,6 +219,71 @@ export async function killPortHolder(port: number): Promise<boolean> {
206
219
  }
207
220
  }
208
221
 
222
+ /**
223
+ * Loopback-only client lease + control endpoints used by the detached-daemon
224
+ * protocol (src/client.ts). Returns false when the request is not a control
225
+ * endpoint (caller continues). Control writes are async (chunked JSON).
226
+ */
227
+ export function handleControlRequest(
228
+ req: http.IncomingMessage,
229
+ res: http.ServerResponse,
230
+ reqPathname: string | null,
231
+ closeServer: () => void,
232
+ ): boolean {
233
+ if (reqPathname === null) return false;
234
+ const isShutdown = reqPathname === "/_shutdown";
235
+ const isClientRoute = reqPathname.startsWith("/_client/");
236
+ if (!isShutdown && !isClientRoute) return false;
237
+ const clientIP = req.socket.remoteAddress ?? "";
238
+ if (!isLoopbackIP(clientIP)) {
239
+ res.writeHead(403, { "content-type": "application/json" });
240
+ res.end(JSON.stringify({ error: "loopback only" }));
241
+ return true;
242
+ }
243
+ if (isShutdown) {
244
+ res.writeHead(200, { "content-type": "application/json" });
245
+ res.end(JSON.stringify({ ok: true }));
246
+ setTimeout(() => {
247
+ try { closeServer(); } catch {}
248
+ if (shutdownShouldExit) process.exit(0);
249
+ }, 50);
250
+ return true;
251
+ }
252
+ const chunks: Buffer[] = [];
253
+ let total = 0;
254
+ req.on("data", (c: Buffer) => {
255
+ total += c.length;
256
+ if (total > 4096) { req.destroy(); return; }
257
+ chunks.push(c);
258
+ });
259
+ req.on("end", () => {
260
+ let clientId = "";
261
+ try {
262
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
263
+ if (parsed && typeof parsed === "object" && "id" in parsed) {
264
+ const candidate = parsed.id;
265
+ if (typeof candidate === "string" && candidate) clientId = candidate;
266
+ }
267
+ } catch {}
268
+ if (!clientId) {
269
+ res.writeHead(400, { "content-type": "application/json" });
270
+ res.end(JSON.stringify({ error: "missing client id" }));
271
+ return;
272
+ }
273
+ if (reqPathname === "/_client/attach") registerClient(clientId);
274
+ else if (reqPathname === "/_client/heartbeat") renewClient(clientId);
275
+ else if (reqPathname === "/_client/detach") unregisterClient(clientId);
276
+ else {
277
+ res.writeHead(404, { "content-type": "application/json" });
278
+ res.end(JSON.stringify({ error: "not found" }));
279
+ return;
280
+ }
281
+ res.writeHead(200, { "content-type": "application/json" });
282
+ res.end(JSON.stringify({ ok: true }));
283
+ });
284
+ return true;
285
+ }
286
+
209
287
  /**
210
288
  * Tagged abort reason for the proxy-internal header-wait timeout.
211
289
  * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
@@ -213,9 +291,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
213
291
  * rolls nor penalizes a healthy relay when the request merely ran slow.
214
292
  */
215
293
  function upstreamTimeoutError(): Error & { code: string } {
216
- const err = new Error(
217
- `upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`,
218
- ) as Error & { code: string };
294
+ const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
219
295
  err.name = "AbortError";
220
296
  err.code = "FF_INTERNAL_ABORT";
221
297
  return err;
@@ -261,6 +337,7 @@ export function startProxy(
261
337
  try {
262
338
  reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
263
339
  } catch {}
340
+ if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
264
341
  // Loopback-only health endpoint — always accessible even when widget hidden
265
342
  if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
266
343
  const addr = server.address();
@@ -268,6 +345,7 @@ export function startProxy(
268
345
  if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
269
346
  }
270
347
  if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
348
+ touchActivity();
271
349
  const alive = getAliveCatalog();
272
350
  const body = JSON.stringify({
273
351
  object: "list",
@@ -298,6 +376,7 @@ export function startProxy(
298
376
  }
299
377
  return;
300
378
  }
379
+ touchActivity();
301
380
  // Buffer request body to inspect model ID for upstream routing
302
381
  const bodyChunks: Buffer[] = [];
303
382
  activeRequests += 1;
@@ -566,7 +645,6 @@ export function startProxy(
566
645
  headers: fwd,
567
646
  body: directBody.length > 0 ? directBody : undefined,
568
647
  signal: controller.signal,
569
- ...(canUseCustomDispatcher ? { dispatcher: agent } : {}),
570
648
  } as unknown as RequestInit);
571
649
  clearTimeout(timeoutId);
572
650
  res.off("close", onClientClose);