pi-freeflow 1.9.1 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to pi-freeflow. Public, user-visible behavior only.
4
4
 
5
+ ## 1.9.2 - 2026-08-31
6
+
7
+ ### Fixes
8
+ - **Closing one session no longer stops the shared local proxy.** The proxy daemon
9
+ is now a fully detached background process: it outlives any single OMP/Pi session
10
+ (previously, closing the session that owned the daemon could shut it down even
11
+ while other sessions were still using it). It retires by itself only when no
12
+ session is connected, no request is in flight, and it has been idle for a grace
13
+ period. The next use starts it again automatically.
14
+ - New command: `/freeflow kill` (aliases `stop`, `shutdown`) stops the background
15
+ daemon on demand. The next freeflow use restarts it.
16
+
17
+ ### Improvements
18
+ - The proxy tracks connected sessions and last request time; `/freeflow status`
19
+ and the health endpoint now report active session leases, so you can see when
20
+ other sessions are keeping the daemon alive.
21
+ - Docs: the command reference now lists `kill`, and the FAQ explains the shared
22
+ daemon lifecycle (survives session close; self-retires when unused).
23
+
5
24
  ## 1.9.1 - 2026-08-30
6
25
 
7
26
  ### Fixes
package/README.md CHANGED
@@ -117,6 +117,7 @@ Manage your relay pool directly from the OMP / Pi terminal:
117
117
  /freeflow refresh # Reload the model catalog from live upstreams
118
118
  /freeflow update # Check for and install a package update
119
119
  /freeflow debug on | off # Toggle full HTTP lifecycle debug logging
120
+ /freeflow kill # Stop the shared proxy daemon now (restarts on next use)
120
121
  ```
121
122
 
122
123
  ---
@@ -286,6 +287,13 @@ with a log note instead). If a daemon cannot be replaced (e.g. port held by an u
286
287
  it falls back to reusing it with a warning. To disable replacement entirely, set the no-kill env
287
288
  to `1` before starting a session.
288
289
 
290
+ **What happens when I close a session?**
291
+ Nothing visible to your other sessions. The proxy daemon is a separate background
292
+ process shared by every OMP/Pi session on the machine. Closing one session just
293
+ unregisters it; the daemon keeps serving the rest and retires itself automatically
294
+ once the last client disconnects and it has been idle for a short grace period.
295
+ To stop it manually, run `/freeflow kill` — the next freeflow use starts it again.
296
+
289
297
  **Where's the normalizer?**
290
298
  Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
291
299
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.9.1",
4
+ "version": "1.9.2",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/client.ts ADDED
@@ -0,0 +1,313 @@
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 and no request has been proxied recently.
9
+ */
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import { spawn } from "node:child_process";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type * as http from "node:http";
16
+ import {
17
+ DAEMON_HEARTBEAT_MS,
18
+ DAEMON_HEARTBEAT_MS_ENV,
19
+ DAEMON_READY_TIMEOUT_MS,
20
+ DAEMON_SPAWN_ENV,
21
+ HOST,
22
+ LEGACY_PORT,
23
+ NO_KILL_ENV,
24
+ PKG_VERSION,
25
+ PORT,
26
+ } from "./config.ts";
27
+ import { logInfo, logWarn } from "./logger.ts";
28
+ import {
29
+ getDaemonHealth,
30
+ getDaemonVersion,
31
+ isProxyAlive,
32
+ killPortHolder,
33
+ startProxy,
34
+ } from "./proxy.ts";
35
+ import { compareVersions } from "./update-checker.ts";
36
+
37
+ const CLIENT_ID = randomUUID();
38
+
39
+ let attachedPort = 0;
40
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
41
+ let heartbeatPort = 0;
42
+ let ensuring = false;
43
+ let fallbackServer: http.Server | null = null;
44
+
45
+ function isBunRuntime(): boolean {
46
+ return typeof (process.versions as unknown as Record<string, string>).bun === "string";
47
+ }
48
+
49
+ function daemonScriptPath(): string {
50
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
51
+ }
52
+
53
+ function getHeartbeatMs(): number {
54
+ const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
55
+ if (raw) {
56
+ const parsed = Number(raw);
57
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
58
+ }
59
+ return DAEMON_HEARTBEAT_MS;
60
+ }
61
+
62
+ function isSpawnEnabled(): boolean {
63
+ return process.env[DAEMON_SPAWN_ENV] !== "0";
64
+ }
65
+
66
+ type ControlResult = "ok" | "legacy" | "gone";
67
+
68
+ async function controlCall(
69
+ port: number,
70
+ endpoint: string,
71
+ payload: Record<string, string>,
72
+ ): Promise<ControlResult> {
73
+ try {
74
+ const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: JSON.stringify(payload),
78
+ signal: AbortSignal.timeout(1500),
79
+ });
80
+ return res.ok ? "ok" : "legacy";
81
+ } catch {
82
+ return "gone";
83
+ }
84
+ }
85
+
86
+ function startHeartbeat(port: number): void {
87
+ stopHeartbeatInternal();
88
+ heartbeatPort = port;
89
+ const ms = getHeartbeatMs();
90
+ heartbeatTimer = setInterval(() => {
91
+ void beatOnce(port);
92
+ }, ms);
93
+ try {
94
+ heartbeatTimer.unref();
95
+ } catch {}
96
+ }
97
+
98
+ function stopHeartbeatInternal(): void {
99
+ if (heartbeatTimer !== null) {
100
+ clearInterval(heartbeatTimer);
101
+ heartbeatTimer = null;
102
+ }
103
+ }
104
+
105
+ export function stopHeartbeat(): void {
106
+ const port = heartbeatPort;
107
+ stopHeartbeatInternal();
108
+ heartbeatPort = 0;
109
+ if (port) {
110
+ void controlCall(port, "/_client/detach", { id: CLIENT_ID });
111
+ }
112
+ if (fallbackServer) {
113
+ try {
114
+ fallbackServer.close();
115
+ } catch {}
116
+ fallbackServer = null;
117
+ }
118
+ }
119
+
120
+ async function beatOnce(port: number): Promise<void> {
121
+ if (ensuring) return;
122
+ const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
123
+ if (result === "gone") {
124
+ void ensureDaemon();
125
+ }
126
+ }
127
+
128
+ async function attachTo(port: number): Promise<void> {
129
+ attachedPort = port;
130
+ const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
131
+ if (result === "gone") {
132
+ return;
133
+ }
134
+ startHeartbeat(port);
135
+ }
136
+
137
+ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
138
+ if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
139
+ logInfo(
140
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
141
+ );
142
+ return false;
143
+ }
144
+ if (compareVersions(remoteVer, PKG_VERSION) > 0) {
145
+ logInfo(
146
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
147
+ );
148
+ return false;
149
+ }
150
+ const health = await getDaemonHealth(port);
151
+ if (health === null || health.activeRequests === undefined) {
152
+ logInfo(
153
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
154
+ );
155
+ return false;
156
+ }
157
+ if (health.activeRequests > 0) {
158
+ logInfo(
159
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
160
+ );
161
+ return false;
162
+ }
163
+ return true;
164
+ }
165
+
166
+ async function killStaleDaemon(
167
+ port: number,
168
+ remoteVer: string,
169
+ what: string,
170
+ ): Promise<boolean> {
171
+ if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
172
+ logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
173
+ remoteVer,
174
+ expected: PKG_VERSION,
175
+ });
176
+ await killPortHolder(port);
177
+ for (let i = 0; i < 10; i++) {
178
+ await new Promise<void>((r) => setTimeout(r, 200));
179
+ if (!(await isProxyAlive(port))) return true;
180
+ }
181
+ logInfo(
182
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
183
+ );
184
+ return false;
185
+ }
186
+
187
+ function spawnDaemonProcess(): void {
188
+ const script = daemonScriptPath();
189
+ const args = isBunRuntime() ? [script] : ["--experimental-strip-types", script];
190
+ try {
191
+ const child = spawn(process.execPath, args, {
192
+ detached: true,
193
+ stdio: "ignore",
194
+ windowsHide: true,
195
+ });
196
+ child.unref();
197
+ child.on("error", (err) => {
198
+ logWarn("daemon spawn failed", { error: String(err) });
199
+ });
200
+ } catch (e) {
201
+ logWarn("daemon spawn failed", { error: String(e) });
202
+ }
203
+ }
204
+
205
+ async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
206
+ const deadline = Date.now() + timeoutMs;
207
+ while (Date.now() < deadline) {
208
+ if (await isProxyAlive(port)) return true;
209
+ await new Promise<void>((r) => setTimeout(r, 200));
210
+ }
211
+ return false;
212
+ }
213
+
214
+ async function probeAndMaybeReplace(
215
+ port: number,
216
+ label: string,
217
+ ): Promise<number | null> {
218
+ if (!(await isProxyAlive(port))) return null;
219
+ const ver = await getDaemonVersion(port);
220
+ if (ver !== null && ver !== PKG_VERSION) {
221
+ if (await killStaleDaemon(port, ver, label)) {
222
+ return null;
223
+ }
224
+ if (await isProxyAlive(port)) return port;
225
+ return null;
226
+ }
227
+ return port;
228
+ }
229
+
230
+ /**
231
+ * Ensure a proxy daemon is running and attach this client to it.
232
+ * Returns the port the caller should use for its ProviderConfig.
233
+ */
234
+ export async function ensureDaemon(): Promise<number> {
235
+ if (ensuring) return attachedPort || PORT;
236
+ ensuring = true;
237
+ try {
238
+ const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
239
+ if (primary !== null) {
240
+ await attachTo(primary);
241
+ return primary;
242
+ }
243
+
244
+ if (PORT !== LEGACY_PORT) {
245
+ const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
246
+ if (legacy !== null) {
247
+ await attachTo(legacy);
248
+ return legacy;
249
+ }
250
+ }
251
+
252
+ if (isSpawnEnabled()) {
253
+ spawnDaemonProcess();
254
+ const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
255
+ if (ready) {
256
+ const ver = await getDaemonVersion(PORT);
257
+ if (ver !== null && ver !== PKG_VERSION) {
258
+ if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
259
+ spawnDaemonProcess();
260
+ const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
261
+ if (retryReady) {
262
+ await attachTo(PORT);
263
+ return PORT;
264
+ }
265
+ } else if (await isProxyAlive(PORT)) {
266
+ await attachTo(PORT);
267
+ return PORT;
268
+ }
269
+ } else {
270
+ await attachTo(PORT);
271
+ return PORT;
272
+ }
273
+ } else {
274
+ logWarn("daemon spawn did not become ready — is the port blocked?");
275
+ }
276
+ return PORT;
277
+ }
278
+
279
+ try {
280
+ const r = await startProxy();
281
+ if (r.server) fallbackServer = r.server;
282
+ const port = r.port;
283
+ await attachTo(port);
284
+ return port;
285
+ } catch (e) {
286
+ logWarn("in-process fallback bind failed", { error: String(e) });
287
+ return PORT;
288
+ }
289
+ } finally {
290
+ ensuring = false;
291
+ }
292
+ }
293
+
294
+ export function getClientPort(): number {
295
+ return attachedPort || PORT;
296
+ }
297
+
298
+ export function getClientId(): string {
299
+ return CLIENT_ID;
300
+ }
301
+
302
+ export function _resetClientForTest(): void {
303
+ stopHeartbeatInternal();
304
+ heartbeatPort = 0;
305
+ attachedPort = 0;
306
+ ensuring = false;
307
+ if (fallbackServer) {
308
+ try {
309
+ fallbackServer.close();
310
+ } catch {}
311
+ fallbackServer = null;
312
+ }
313
+ }
package/src/commands.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process";
8
+ import { getClientPort } from "./client.ts";
8
9
  import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
9
- import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
10
+ import { DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
10
11
  import {
11
12
  compareVersions,
12
13
  fetchLatestVersion,
@@ -532,6 +533,21 @@ export function createCommandSpec(
532
533
  }`;
533
534
  const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
534
535
  ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}`, "info");
536
+ } else if (sub === "kill" || sub === "stop" || sub === "shutdown") {
537
+ const port = getClientPort() || PORT;
538
+ try {
539
+ const res = await fetch(`http://${HOST}:${port}/_shutdown`, {
540
+ method: "POST",
541
+ signal: AbortSignal.timeout(1500),
542
+ });
543
+ if (res.ok) {
544
+ ctx.ui.notify("Proxy daemon stopped — next freeflow use restarts it", "info");
545
+ } else {
546
+ ctx.ui.notify(`Daemon kill got HTTP ${res.status}`, "warning");
547
+ }
548
+ } catch {
549
+ ctx.ui.notify("Daemon not running or not reachable", "warning");
550
+ }
535
551
  } else if (sub === "update") {
536
552
  if (isLinkedInstall()) {
537
553
  ctx.ui.notify(
package/src/config.ts CHANGED
@@ -205,7 +205,34 @@ export const UPDATE_CHECK_TTL_MS = 86_400_000;
205
205
  export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
206
206
  /** Opt-out for the stale-daemon replace: when "1", a version-mismatched daemon is never killed. */
207
207
  export const NO_KILL_ENV = ALLOW_UNSAFE_RELAY_ENV.replace("_ALLOW_UNSAFE_RELAY", "_NO_KILL");
208
- /** Max request body the proxy buffers before responding 413. */
208
+ /** When "0", the extension never spawns a detached proxy daemon (tests/CI). */
209
+ export const DAEMON_SPAWN_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_SPAWN");
210
+ /** Lease TTL for attached clients (ms); expired leases are dropped by the daemon GC. */
211
+ export const DAEMON_TTL_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_TTL_MS");
212
+ /** Client heartbeat interval (ms) — must stay well under the lease TTL. */
213
+ export const DAEMON_HEARTBEAT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_HEARTBEAT_MS");
214
+ /** Daemon GC sweep interval (ms). */
215
+ export const DAEMON_GC_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GC_MS");
216
+ /** Idle grace after the last request before a lease-less daemon exits (ms). */
217
+ export const DAEMON_GRACE_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GRACE_MS");
218
+ /** Max time a client waits for a freshly spawned daemon to answer /_health (ms). */
219
+ export const DAEMON_READY_TIMEOUT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_READY_TIMEOUT_MS");
220
+
221
+ function envMs(name: string, fallback: number): number {
222
+ const raw = process.env[name];
223
+ if (raw) {
224
+ const parsed = Number(raw);
225
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
226
+ }
227
+ return fallback;
228
+ }
229
+
230
+ export const DAEMON_SPAWN_ENABLED = process.env[DAEMON_SPAWN_ENV] !== "0";
231
+ export const DAEMON_TTL_MS = envMs(DAEMON_TTL_MS_ENV, 30_000);
232
+ export const DAEMON_HEARTBEAT_MS = envMs(DAEMON_HEARTBEAT_MS_ENV, 10_000);
233
+ export const DAEMON_GC_MS = envMs(DAEMON_GC_MS_ENV, 5_000);
234
+ export const DAEMON_GRACE_MS = envMs(DAEMON_GRACE_MS_ENV, 10_000);
235
+ export const DAEMON_READY_TIMEOUT_MS = envMs(DAEMON_READY_TIMEOUT_MS_ENV, 5_000);
209
236
  export const MAX_BODY_BYTES = 32 * 1024 * 1024;
210
237
  /** Default timeout for upstream headers while proxying a request. */
211
238
  export const UPSTREAM_HEADER_TIMEOUT_MS = 300_000;
package/src/daemon.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Detached proxy daemon entry for pi-freeflow.
3
+ *
4
+ * Spawned as a separate OS process by src/client.ts so the local proxy survives
5
+ * the OMP/Pi session that started it. Owns port 28180, serves the proxy plus
6
+ * the client lease/control endpoints, and retires itself once no client holds
7
+ * a live lease and no request has been proxied recently.
8
+ *
9
+ * Run directly: `node --experimental-strip-types src/daemon.ts` (or `bun src/daemon.ts`).
10
+ */
11
+
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ DAEMON_GC_MS,
16
+ DAEMON_GRACE_MS,
17
+ DAEMON_TTL_MS,
18
+ HOST,
19
+ PKG_VERSION,
20
+ PORT,
21
+ } from "./config.ts";
22
+ import { setShutdownShouldExit } from "./proxy.ts";
23
+ import {
24
+ getAliveCatalog,
25
+ readCatalogCache,
26
+ refreshCatalog,
27
+ setAliveCatalog,
28
+ } from "./catalog.ts";
29
+ import { log, logInfo, logWarn } from "./logger.ts";
30
+ import {
31
+ startLeaseGC,
32
+ stopLeaseGC,
33
+ touchActivity,
34
+ } from "./lease.ts";
35
+ import { getActiveRequests, startProxy } from "./proxy.ts";
36
+ import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
37
+
38
+ /** True when this module was run as the entry script (not imported by tests). */
39
+ export function isDaemonMain(): boolean {
40
+ try {
41
+ const entry = process.argv[1];
42
+ if (!entry) return false;
43
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Load the on-disk relay state into this process's in-memory cache so routing
51
+ * uses the latest client edits without a daemon restart. Non-fatal on failure.
52
+ */
53
+ export function syncRelayStateFromDisk(): void {
54
+ try {
55
+ setActiveRelayState(loadRelayState(), false);
56
+ } catch (e) {
57
+ log("warn", "daemon failed to load relay state from disk", { error: String(e) });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Serve the static+disk catalog and refresh it in the background, mirroring
63
+ * what the host session did in-process. Keeps /v1/models correct after the
64
+ * 24h cache TTL without requiring a daemon restart.
65
+ */
66
+ export async function seedCatalog(): Promise<void> {
67
+ const cached = readCatalogCache();
68
+ if (cached && Array.isArray(cached.models) && cached.models.length > 0) {
69
+ setAliveCatalog(cached.models);
70
+ }
71
+ try {
72
+ const fresh = await refreshCatalog(false);
73
+ if (fresh.length > 0) {
74
+ setAliveCatalog(fresh);
75
+ }
76
+ } catch (e) {
77
+ logWarn("daemon catalog refresh failed; retaining cached catalog", {
78
+ error: String(e),
79
+ });
80
+ }
81
+ logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
82
+ }
83
+
84
+ /**
85
+ * Boot the detached proxy daemon. Binds the port, seeds catalog + relay state,
86
+ * starts the lease GC, and installs signal handlers so the daemon exits
87
+ * cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
88
+ */
89
+ export async function runDaemon(): Promise<void> {
90
+ let server: { close(): void } | null = null;
91
+ const retire = (why: string): void => {
92
+ logInfo(`daemon retiring: ${why}`);
93
+ stopLeaseGC();
94
+ try {
95
+ server?.close();
96
+ } catch {}
97
+ process.exit(0);
98
+ };
99
+ process.on("SIGTERM", () => retire("SIGTERM"));
100
+ process.on("SIGINT", () => retire("SIGINT"));
101
+ setShutdownShouldExit(true);
102
+ try {
103
+ const r = await startProxy();
104
+ if (!r.server) {
105
+ // Port was taken by another (possibly newer) daemon — the parent
106
+ // attaches to the winner; this spawn exits quietly.
107
+ logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
108
+ process.exit(0);
109
+ }
110
+ server = r.server;
111
+ } catch (e) {
112
+ log("error", "daemon failed to bind proxy port", { error: String(e) });
113
+ process.exit(1);
114
+ }
115
+
116
+ // lastActivityAt is initialized AT BIND TIME: a freshly started daemon with
117
+ // zero leases must never be GC'd during the parent's readiness-poll window.
118
+ touchActivity();
119
+ syncRelayStateFromDisk();
120
+ void seedCatalog();
121
+
122
+ startLeaseGC({
123
+ ttlMs: DAEMON_TTL_MS,
124
+ gcMs: DAEMON_GC_MS,
125
+ graceMs: DAEMON_GRACE_MS,
126
+ getActiveRequests,
127
+ onIdle: () => retire("no clients and idle"),
128
+ });
129
+
130
+ logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
131
+ }
132
+
133
+ // Entry guard: run only when executed as the entry script. When imported by
134
+ // tests, isDaemonMain() is false and no port is bound.
135
+ if (isDaemonMain()) {
136
+ runDaemon().catch((e) => {
137
+ log("error", "daemon crashed", { error: String(e) });
138
+ process.exit(1);
139
+ });
140
+ }
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,6 +27,7 @@ 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
@@ -34,6 +37,12 @@ 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.
@@ -206,6 +215,71 @@ export async function killPortHolder(port: number): Promise<boolean> {
206
215
  }
207
216
  }
208
217
 
218
+ /**
219
+ * Loopback-only client lease + control endpoints used by the detached-daemon
220
+ * protocol (src/client.ts). Returns false when the request is not a control
221
+ * endpoint (caller continues). Control writes are async (chunked JSON).
222
+ */
223
+ export function handleControlRequest(
224
+ req: http.IncomingMessage,
225
+ res: http.ServerResponse,
226
+ reqPathname: string | null,
227
+ closeServer: () => void,
228
+ ): boolean {
229
+ if (reqPathname === null) return false;
230
+ const isShutdown = reqPathname === "/_shutdown";
231
+ const isClientRoute = reqPathname.startsWith("/_client/");
232
+ if (!isShutdown && !isClientRoute) return false;
233
+ const clientIP = req.socket.remoteAddress ?? "";
234
+ if (!isLoopbackIP(clientIP)) {
235
+ res.writeHead(403, { "content-type": "application/json" });
236
+ res.end(JSON.stringify({ error: "loopback only" }));
237
+ return true;
238
+ }
239
+ if (isShutdown) {
240
+ res.writeHead(200, { "content-type": "application/json" });
241
+ res.end(JSON.stringify({ ok: true }));
242
+ setTimeout(() => {
243
+ try { closeServer(); } catch {}
244
+ if (shutdownShouldExit) process.exit(0);
245
+ }, 50);
246
+ return true;
247
+ }
248
+ const chunks: Buffer[] = [];
249
+ let total = 0;
250
+ req.on("data", (c: Buffer) => {
251
+ total += c.length;
252
+ if (total > 4096) { req.destroy(); return; }
253
+ chunks.push(c);
254
+ });
255
+ req.on("end", () => {
256
+ let clientId = "";
257
+ try {
258
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
259
+ if (parsed && typeof parsed === "object" && "id" in parsed) {
260
+ const candidate = parsed.id;
261
+ if (typeof candidate === "string" && candidate) clientId = candidate;
262
+ }
263
+ } catch {}
264
+ if (!clientId) {
265
+ res.writeHead(400, { "content-type": "application/json" });
266
+ res.end(JSON.stringify({ error: "missing client id" }));
267
+ return;
268
+ }
269
+ if (reqPathname === "/_client/attach") registerClient(clientId);
270
+ else if (reqPathname === "/_client/heartbeat") renewClient(clientId);
271
+ else if (reqPathname === "/_client/detach") unregisterClient(clientId);
272
+ else {
273
+ res.writeHead(404, { "content-type": "application/json" });
274
+ res.end(JSON.stringify({ error: "not found" }));
275
+ return;
276
+ }
277
+ res.writeHead(200, { "content-type": "application/json" });
278
+ res.end(JSON.stringify({ ok: true }));
279
+ });
280
+ return true;
281
+ }
282
+
209
283
  /**
210
284
  * Tagged abort reason for the proxy-internal header-wait timeout.
211
285
  * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
@@ -213,9 +287,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
213
287
  * rolls nor penalizes a healthy relay when the request merely ran slow.
214
288
  */
215
289
  function upstreamTimeoutError(): Error & { code: string } {
216
- const err = new Error(
217
- `upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`,
218
- ) as Error & { code: string };
290
+ const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
219
291
  err.name = "AbortError";
220
292
  err.code = "FF_INTERNAL_ABORT";
221
293
  return err;
@@ -261,6 +333,7 @@ export function startProxy(
261
333
  try {
262
334
  reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
263
335
  } catch {}
336
+ if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
264
337
  // Loopback-only health endpoint — always accessible even when widget hidden
265
338
  if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
266
339
  const addr = server.address();
@@ -268,6 +341,7 @@ export function startProxy(
268
341
  if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
269
342
  }
270
343
  if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
344
+ touchActivity();
271
345
  const alive = getAliveCatalog();
272
346
  const body = JSON.stringify({
273
347
  object: "list",
@@ -298,6 +372,7 @@ export function startProxy(
298
372
  }
299
373
  return;
300
374
  }
375
+ touchActivity();
301
376
  // Buffer request body to inspect model ID for upstream routing
302
377
  const bodyChunks: Buffer[] = [];
303
378
  activeRequests += 1;