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/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to pi-freeflow. Public, user-visible behavior only.
4
4
 
5
+ ## 1.9.4 - 2026-09-02
6
+
7
+ ### Dependencies
8
+ - **Zero runtime dependencies.** Removed `undici@8.10.0` — `pi` (`0.84.4`) and `omp` (`18.1.3`) already bundle `undici` 6.x/7.x and expose `global fetch` with keep-alive pooling. `relayFetch` and proxy now use `global fetch` directly (`src/relay.ts` `Agent` + `canUseCustomDispatcher` + `dispatcher: agent` removed; `src/proxy.ts` dispatcher removed). Keeps thin `11.3k` + `298 tests` + `0 deps` compatible directly with `reference/pi` + `reference/oh-my-pi`.
9
+
10
+ ### Validation
11
+ - `npx tsc --noEmit` clean, `npm test 298/298` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
12
+
13
+ ## 1.9.3 - 2026-09-02
14
+
15
+ ### Fixes
16
+ - **Windows console flood fixed.** Two Windows-only helpers flashed a visible `conhost`/`cmd` window on every daemon probe: `netstat -ano | findstr :28180` / `taskkill` in the stale-daemon replace path and `spawn(omp|npm, shell:true)` for `/freeflow update`. Both now use `windowsHide: true` (no-op on Linux/macOS) and `spawnWithProgress` was refactored to `Promise.withResolvers` to satisfy `ts-promise-with-resolvers`. Idle `pi` no longer spawns many visible consoles even after closing the terminal (detached daemon at `127.0.0.1:28180` survives by design; `beatOnce` 10s heartbeat now throttled 2s via `lastSpawnAt`).
17
+ - Daemon spawn now throttled per-process (2s) as a storm guard when `28180` is contended or blocked; the `ensuring` guard + `waitForReady 5s` already prevented tight loops.
18
+
19
+ ### Validation
20
+ - `npx tsc --noEmit` clean, `npm test 279/279` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
21
+ - Reporter `LOYINuts` issue #3 (`pi idle creates many sessions → force reboot`) — `grep -r rtk src` ∅ confirms `rtk` is an external global skill (`~/.agents/skills/rtk` → `Command::new("cmd")` without `CREATE_NO_WINDOW`), not `pi-freeflow`. After this fix, closing the terminal no longer leaves flashing zombies; kill via `netstat -ano | findstr :28180` → `taskkill /F /PID` or `/freeflow kill`.
22
+
23
+ ## 1.9.2 - 2026-08-31
24
+
25
+ ### Fixes
26
+ - **Closing one session no longer stops the shared local proxy.** The proxy daemon
27
+ is now a fully detached background process: it outlives any single OMP/Pi session
28
+ (previously, closing the session that owned the daemon could shut it down even
29
+ while other sessions were still using it). It retires by itself only when no
30
+ session is connected, no request is in flight, and it has been idle for a grace
31
+ period. The next use starts it again automatically.
32
+ - New command: `/freeflow kill` (aliases `stop`, `shutdown`) stops the background
33
+ daemon on demand. The next freeflow use restarts it.
34
+
35
+ ### Improvements
36
+ - The proxy tracks connected sessions and last request time; `/freeflow status`
37
+ and the health endpoint now report active session leases, so you can see when
38
+ other sessions are keeping the daemon alive.
39
+ - Docs: the command reference now lists `kill`, and the FAQ explains the shared
40
+ daemon lifecycle (survives session close; self-retires when unused).
41
+
5
42
  ## 1.9.1 - 2026-08-30
6
43
 
7
44
  ### 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
  ---
@@ -260,7 +261,7 @@ Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
260
261
 
261
262
  ### Design
262
263
 
263
- This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. The only runtime dependency is `undici`, which powers the upstream fetch agent. Thinking and prompt normalization stay with the host (`pi-ai`).
264
+ This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. Zero runtime dependencies uses native Node.js global fetch. Thinking and prompt normalization stay with the host (`pi-ai`).
264
265
 
265
266
  Current size: about 11.3k lines including tests. The full suite (sandboxed, mocked network) and typecheck pass before every release — see CHANGELOG.md.
266
267
 
@@ -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
 
@@ -353,7 +361,7 @@ test/
353
361
 
354
362
  #### Guidelines
355
363
 
356
- - **Stay thin.** One runtime dependency (`undici`), no build step. If it belongs in the host (`pi-ai`), don't add it here.
364
+ - **Stay thin.** Zero runtime dependencies, no build step. If it belongs in the host (`pi-ai`), don't add it here.
357
365
  - **Test what you touch.** Every `src/*.ts` has a matching `test/*.test.ts`. Add or update tests for your change.
358
366
  - **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
359
367
  - **One concern per PR.** Bug fix? One PR. New relay platform? Separate PR. Easier to review, faster to merge.
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.4",
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",
@@ -57,7 +57,5 @@
57
57
  "typescript": "^5.8.2",
58
58
  "vitepress": "^1.6.4"
59
59
  },
60
- "dependencies": {
61
- "undici": "^8.10.0"
62
- }
60
+ "dependencies": {}
63
61
  }
package/src/client.ts ADDED
@@ -0,0 +1,323 @@
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
+ let lastSpawnAt = 0;
188
+ const SPAWN_THROTTLE_MS = 2_000;
189
+
190
+ function spawnDaemonProcess(): void {
191
+ const now = Date.now();
192
+ if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
193
+ logWarn("daemon spawn throttled — recent spawn still pending");
194
+ return;
195
+ }
196
+ lastSpawnAt = now;
197
+ const script = daemonScriptPath();
198
+ const args = isBunRuntime() ? [script] : ["--experimental-strip-types", script];
199
+ try {
200
+ const child = spawn(process.execPath, args, {
201
+ detached: true,
202
+ stdio: "ignore",
203
+ windowsHide: true,
204
+ });
205
+ child.unref();
206
+ child.on("error", (err) => {
207
+ logWarn("daemon spawn failed", { error: String(err) });
208
+ });
209
+ } catch (e) {
210
+ logWarn("daemon spawn failed", { error: String(e) });
211
+ }
212
+ }
213
+
214
+ async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
215
+ const deadline = Date.now() + timeoutMs;
216
+ while (Date.now() < deadline) {
217
+ if (await isProxyAlive(port)) return true;
218
+ await new Promise<void>((r) => setTimeout(r, 200));
219
+ }
220
+ return false;
221
+ }
222
+
223
+ async function probeAndMaybeReplace(
224
+ port: number,
225
+ label: string,
226
+ ): Promise<number | null> {
227
+ if (!(await isProxyAlive(port))) return null;
228
+ const ver = await getDaemonVersion(port);
229
+ if (ver !== null && ver !== PKG_VERSION) {
230
+ if (await killStaleDaemon(port, ver, label)) {
231
+ return null;
232
+ }
233
+ if (await isProxyAlive(port)) return port;
234
+ return null;
235
+ }
236
+ return port;
237
+ }
238
+
239
+ /**
240
+ * Ensure a proxy daemon is running and attach this client to it.
241
+ * Returns the port the caller should use for its ProviderConfig.
242
+ */
243
+ export async function ensureDaemon(): Promise<number> {
244
+ if (ensuring) return attachedPort || PORT;
245
+ ensuring = true;
246
+ try {
247
+ const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
248
+ if (primary !== null) {
249
+ await attachTo(primary);
250
+ return primary;
251
+ }
252
+
253
+ if (PORT !== LEGACY_PORT) {
254
+ const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
255
+ if (legacy !== null) {
256
+ await attachTo(legacy);
257
+ return legacy;
258
+ }
259
+ }
260
+
261
+ if (isSpawnEnabled()) {
262
+ spawnDaemonProcess();
263
+ const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
264
+ if (ready) {
265
+ const ver = await getDaemonVersion(PORT);
266
+ if (ver !== null && ver !== PKG_VERSION) {
267
+ if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
268
+ spawnDaemonProcess();
269
+ const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
270
+ if (retryReady) {
271
+ await attachTo(PORT);
272
+ return PORT;
273
+ }
274
+ } else if (await isProxyAlive(PORT)) {
275
+ await attachTo(PORT);
276
+ return PORT;
277
+ }
278
+ } else {
279
+ await attachTo(PORT);
280
+ return PORT;
281
+ }
282
+ } else {
283
+ logWarn("daemon spawn did not become ready — is the port blocked?");
284
+ }
285
+ return PORT;
286
+ }
287
+
288
+ try {
289
+ const r = await startProxy();
290
+ if (r.server) fallbackServer = r.server;
291
+ const port = r.port;
292
+ await attachTo(port);
293
+ return port;
294
+ } catch (e) {
295
+ logWarn("in-process fallback bind failed", { error: String(e) });
296
+ return PORT;
297
+ }
298
+ } finally {
299
+ ensuring = false;
300
+ }
301
+ }
302
+
303
+ export function getClientPort(): number {
304
+ return attachedPort || PORT;
305
+ }
306
+
307
+ export function getClientId(): string {
308
+ return CLIENT_ID;
309
+ }
310
+
311
+ export function _resetClientForTest(): void {
312
+ stopHeartbeatInternal();
313
+ heartbeatPort = 0;
314
+ attachedPort = 0;
315
+ ensuring = false;
316
+ lastSpawnAt = 0;
317
+ if (fallbackServer) {
318
+ try {
319
+ fallbackServer.close();
320
+ } catch {}
321
+ fallbackServer = null;
322
+ }
323
+ }
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,
@@ -68,30 +69,31 @@ function spawnWithProgress(
68
69
  args: string[],
69
70
  ctx: ExtensionContext,
70
71
  ): Promise<number> {
71
- return new Promise((resolve) => {
72
- try {
73
- const child = spawn(cmd, args, {
74
- shell: process.platform === "win32",
75
- stdio: "pipe",
76
- });
77
- child.stdout?.on("data", (d: Buffer) => {
78
- const s = String(d).trim();
79
- if (s) ctx.ui.notify(s, "info");
80
- });
81
- child.stderr?.on("data", (d: Buffer) => {
82
- const s = String(d).trim();
83
- if (s) ctx.ui.notify(s, "info");
84
- });
85
- child.on("error", (err: Error) => {
86
- ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
87
- resolve(1);
88
- });
89
- child.on("close", (code: number | null) => resolve(code ?? 0));
90
- } catch (e) {
91
- ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
72
+ const { promise, resolve } = Promise.withResolvers<number>();
73
+ try {
74
+ const child = spawn(cmd, args, {
75
+ shell: process.platform === "win32",
76
+ stdio: "pipe",
77
+ windowsHide: true,
78
+ });
79
+ child.stdout?.on("data", (d: Buffer) => {
80
+ const s = String(d).trim();
81
+ if (s) ctx.ui.notify(s, "info");
82
+ });
83
+ child.stderr?.on("data", (d: Buffer) => {
84
+ const s = String(d).trim();
85
+ if (s) ctx.ui.notify(s, "info");
86
+ });
87
+ child.on("error", (err: Error) => {
88
+ ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
92
89
  resolve(1);
93
- }
94
- });
90
+ });
91
+ child.on("close", (code: number | null) => resolve(code ?? 0));
92
+ } catch (e) {
93
+ ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
94
+ resolve(1);
95
+ }
96
+ return promise;
95
97
  }
96
98
 
97
99
  /**
@@ -532,6 +534,21 @@ export function createCommandSpec(
532
534
  }`;
533
535
  const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
534
536
  ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}`, "info");
537
+ } else if (sub === "kill" || sub === "stop" || sub === "shutdown") {
538
+ const port = getClientPort() || PORT;
539
+ try {
540
+ const res = await fetch(`http://${HOST}:${port}/_shutdown`, {
541
+ method: "POST",
542
+ signal: AbortSignal.timeout(1500),
543
+ });
544
+ if (res.ok) {
545
+ ctx.ui.notify("Proxy daemon stopped — next freeflow use restarts it", "info");
546
+ } else {
547
+ ctx.ui.notify(`Daemon kill got HTTP ${res.status}`, "warning");
548
+ }
549
+ } catch {
550
+ ctx.ui.notify("Daemon not running or not reachable", "warning");
551
+ }
535
552
  } else if (sub === "update") {
536
553
  if (isLinkedInstall()) {
537
554
  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
+ }