pi-freeflow 1.38.5 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7427771: Security, wire-path and automation fixes from the October 2026 review:
8
+
9
+ - The local proxy now requires its own access token: every `/v1/*` and `/_*` request must carry `Authorization: Bearer <token>` (the token is written to the data directory on first start and the plugin supplies it automatically), the Host header must be loopback, and the daemon refuses to kill a process it cannot identify as its own. `GET /health` stays open for liveness checks. External tools that call the proxy directly must read the token file and send the header.
10
+ - A request for one model is never silently served by another: the internal Kilo 400 retry no longer substitutes a different model, so upstream verdicts reach the caller unchanged.
11
+ - Answers are no longer corrupted before they reach the client: a plain JSON reply that merely mentions `data:` is forwarded byte-identically instead of being replaced by an empty completion, a complete final SSE event that lost its trailing blank line is no longer dropped, and malformed tool arguments can no longer stall the proxy with a quadratic scan.
12
+ - Relay health reflects reality: an upstream 404 or 401 forwarded by a healthy relay no longer cools the pool, and a repeated 500 no longer resets the failing relay's health or steals the sticky slot.
13
+ - Cline logins: a rejected grant is parked instead of re-tried on every turn, a failed save is reported instead of being shown as saved, and logging out no longer leaves the removed token in the recovery file.
14
+ - Startup hooks and updates: the Windows logon task stores a command Windows can launch, a failed `systemctl --user enable` is reported, and auto-update no longer blocks the host while installing.
15
+ - Automation: the free-model health check has a timeout that fits a fully hanging catalog, a new guardrail keeps that budget honest, drift alerts close themselves once recovered, and the published-tarball check now also fails when the package ships a file the tree does not have.
16
+
17
+ ### Patch Changes
18
+
19
+ - 152862b: Retire Muse Spark 1.2 contributor from the OpenCode Zen lineup — the model was deprecated upstream and now answers HTTP 410. The catalog, docs, and health checks move to the remaining 35 live models (12 on Zen), and the retired id is dead-listed so an old disk cache or an upstream merge cannot bring it back into the picker.
20
+
3
21
  ## 1.38.5
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # pi-freeflow
2
2
 
3
- > 36 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 35 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/pi-freeflow?style=flat-square&color=00E5FF)](https://www.npmjs.com/package/pi-freeflow)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/pi-freeflow?style=flat-square)](https://www.npmjs.com/package/pi-freeflow)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
10
- [![Pi](https://img.shields.io/badge/Powered%20by-Pi-7c3aed?style=flat-square)](https://github.com/badlogic/pi-ai)
11
- [![Oh My Pi](https://img.shields.io/badge/Compatible-OMP-black?style=flat-square)](https://github.com/coder/oh-my-pi)
10
+ [![Pi](https://img.shields.io/badge/Powered%20by-Pi-7c3aed?style=flat-square)](https://github.com/earendil-works/pi)
11
+ [![Oh My Pi](https://img.shields.io/badge/Compatible-OMP-black?style=flat-square)](https://github.com/can1357/oh-my-pi)
12
12
 
13
13
  ---
14
14
 
@@ -16,11 +16,11 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **36 free models** | 13 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **35 free models** | 12 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own relays: a self-hosted egress pool (many egress IPs on one small host) or Cloudflare Workers / Deno Deploy / Vercel Edge workers. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your host or platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
23
- | **Shared local proxy** | One daemon on `127.0.0.1:28180` serves every session on the machine, so parallel subagents reuse it instead of opening their own connections. | **$0** |
23
+ | **Shared local proxy** | One daemon on `127.0.0.1:28180` serves every session on the machine, so parallel subagents reuse it instead of opening their own connections. Loopback-only and token-gated: hosts send the token automatically, external tools read `~/.pi/agent/token`. | **$0** |
24
24
  | **Logs you can read** | `~/.pi/agent/pi-freeflow.log`, rotated at 10MB. Tail it with `/freeflow logs`. | **$0** |
25
25
 
26
26
  ---
@@ -48,12 +48,12 @@ pi install npm:pi-freeflow
48
48
  **OMP, interactive:**
49
49
  ```bash
50
50
  omp
51
- /model → freeflow → muse-spark-1.2-contributor-free (1M) → max
51
+ /model → freeflow → deepseek-v4.1-flash-free (1M) → max
52
52
  ```
53
53
 
54
54
  **OMP, one shot:**
55
55
  ```bash
56
- omp -p --model freeflow/muse-spark-1.2-contributor-free "build me a SaaS"
56
+ omp -p --model freeflow/deepseek-v4.1-flash-free "build me a SaaS"
57
57
  omp -p --model freeflow/space-bunny:high "solve this bug" # alias + thinking level
58
58
  ```
59
59
 
@@ -183,6 +183,7 @@ The same command set works identically in OMP and Pi:
183
183
 
184
184
  ```bash
185
185
  /freeflow status # View active relay, pool status, and candidates
186
+ /freeflow hide|show # Hide or restore the status-bar widget (also `widget hide`/`widget show`)
186
187
  /freeflow list # List all relays with real-time health badges
187
188
  /freeflow use <url|index|label> # Switch active relay
188
189
  /freeflow url <url> # Set the active relay URL directly
@@ -190,12 +191,16 @@ The same command set works identically in OMP and Pi:
190
191
  /freeflow label <index|url> <name># Assign a friendly label to a relay
191
192
  /freeflow remove <index|url|label># Remove a relay from the pool
192
193
  /freeflow test <index|url|label> # Probe a relay for reachability and latency
193
- /freeflow on|off|auto|spread # Relay mode (auto = enabled for freeflow, spread = rotate pool)
194
+ /freeflow on|off|auto|sticky|spread # Relay mode (auto/sticky = sticky primary, spread = rotate pool)
194
195
  /freeflow deploy <platform> # Guided relay deploy: vercel|cloudflare|deno, token in-memory, auto-adds
195
196
  /freeflow logs [lines] # Inspect recent proxy logs
196
197
  /freeflow trace [req-id] # Tail logs filtered by request correlation ID
197
198
  /freeflow refresh # Reload the model catalog from live upstreams
199
+ /freeflow reload models # Alias of refresh
198
200
  /freeflow update # Check for and install a package update
201
+ /freeflow autoupdate [on|off] # View or toggle automatic background updates
202
+ /freeflow install-startup # Install the login-startup hook so the daemon comes up with your session
203
+ /freeflow uninstall-startup # Remove the login-startup hook
199
204
  /freeflow debug on | off # Toggle full HTTP lifecycle debug logging
200
205
  /freeflow kill # Stop the shared proxy daemon now (restarts on next use)
201
206
  /freeflow cline login # Sign in through the browser, save to the per-user pool
@@ -208,21 +213,20 @@ The same command set works identically in OMP and Pi:
208
213
 
209
214
  ---
210
215
 
211
- ### 36 models, one command
216
+ ### 35 models, one command
212
217
 
213
218
  ```bash
214
219
  /model → freeflow → pick
215
220
  ```
216
221
 
217
222
  Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
218
- #### OpenCode Zen (13 models), Responses and Chat API
223
+ #### OpenCode Zen (12 models), Chat Completions API
219
224
 
220
225
  Good defaults for long coding sessions and agentic work.
221
226
 
222
227
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
223
228
  | :--- | :--- | :--- | :--- | :--- | :--- |
224
229
  | `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low … max` | ❌ |
225
- | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
226
230
  | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
227
231
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
228
232
  | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal … xhigh` | ✅ |
@@ -305,14 +309,14 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 50
305
309
 
306
310
  This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step and one runtime dependency (`undici`). Thinking and prompt normalization stay with the host (`pi-ai`).
307
311
 
308
- About 45k lines including tests: `src/` is roughly 17.7k across 27 files, and the 82 test files add roughly 27k more. The full suite (sandboxed, network-mocked) and typecheck pass before every release. See CHANGELOG.md.
312
+ About 52k lines including tests: `src/` is roughly 19.4k across 30 files, and the 90 test files add roughly 32.4k more. The full suite (sandboxed, network-mocked) and typecheck pass before every release. See CHANGELOG.md.
309
313
 
310
314
  ---
311
315
 
312
316
  ### FAQ
313
317
 
314
318
  **Do I need API keys?**
315
- No. OpenCode and Kilo free models are both keyless: the proxy sends no `Authorization` header at all, and a placeholder credential is rejected rather than merely unused — `Bearer kilo-free` answers `401 INVALID_TOKEN`, while sending nothing answers 200 for every free catalog model. You never paste a key. Cline free models need a browser login instead: `/freeflow cline login` saves it to a per-user pool on your machine. OVHcloud AI Endpoints models need no account and no key at all.
319
+ No. OpenCode and Kilo free models need no key of your own. The proxy strips the placeholder credential the host provider registers with — forwarding it upstream gets a `401` (for Kilo, `Bearer kilo-free` answers `401 INVALID_TOKEN`) — so your key, if any, never leaves the machine. For OpenCode Zen it then adds the fixed `Authorization: Bearer public` the current `/inference/v1` gateway requires; the Kilo and OVHcloud lanes go out with no `Authorization` header at all. You never paste a key. Cline free models need a browser login instead: `/freeflow cline login` saves it to a per-user pool on your machine. OVHcloud AI Endpoints models need no account and no key at all.
316
320
 
317
321
  **What if all relays hit rate limits?**
318
322
  The proxy tries direct upstream. If that is also rate-limited, the host shows the limit. That number is the shared upstream cap; without relays you would hit the same wall sooner.
@@ -378,7 +382,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
378
382
  #### Project structure
379
383
 
380
384
  ```
381
- ├── models.ts # 36-model catalog definitions
385
+ ├── models.ts # 35-model catalog definitions
382
386
  ├── index.ts # extension entry, lifecycle hooks
383
387
  ├── catalog.ts # model catalog cache (24h disk)
384
388
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.38.5",
4
+ "version": "1.39.0",
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",
@@ -52,13 +52,13 @@
52
52
  "docs:build": "vitepress build docs"
53
53
  },
54
54
  "devDependencies": {
55
- "@changesets/cli": "^2.27.0",
55
+ "@changesets/cli": "^3.0.3",
56
56
  "@types/node": "^22.13.9",
57
- "js-yaml": "^4.1.0",
58
- "typescript": "^5.8.2",
57
+ "js-yaml": "^5.5.0",
58
+ "typescript": "^7.0.2",
59
59
  "vitepress": "^1.6.4"
60
60
  },
61
61
  "dependencies": {
62
- "undici": "7.30.0"
62
+ "undici": "7.30.1"
63
63
  }
64
64
  }
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ /** Block synchronously for ms without burning CPU. */
5
+ function sleepSync(ms: number): void {
6
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
7
+ }
8
+
9
+ /**
10
+ * Rename with bounded retry: Windows briefly locks the target when another
11
+ * process holds the file open, surfacing as EPERM/EACCES/EBUSY. Three attempts
12
+ * with a fixed 50ms backoff by default; any other error fails immediately.
13
+ */
14
+ export function renameWithRetry(from: string, to: string, attempts = 3, delayMs = 50): void {
15
+ const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
16
+ for (let attempt = 1; ; attempt++) {
17
+ try {
18
+ fs.renameSync(from, to);
19
+ return;
20
+ } catch (e) {
21
+ const code = (e as NodeJS.ErrnoException | null)?.code;
22
+ if (!code || !retryable[code] || attempt >= attempts) {
23
+ throw e;
24
+ }
25
+ sleepSync(delayMs);
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Write `data` to `file` atomically: stage the bytes in a sibling temp file,
32
+ * then rename over the target, so a crash leaves either the old or the new
33
+ * file intact. The staged `<file>.<uuid>.tmp` is deliberately left in place
34
+ * when the rename never lands — it is the only copy of the bytes that failed
35
+ * to land, and relay-state's cleanupStaleTmpFiles reaps stale litter later.
36
+ *
37
+ * `mode` is applied to the staged file and re-applied to the final path
38
+ * (best-effort — Windows ignores POSIX modes), so secret-bearing files keep
39
+ * owner-only permissions.
40
+ */
41
+ export function writeFileAtomic(file: string, data: string, opts?: { mode?: number }): void {
42
+ const tmp = `${file}.${randomUUID()}.tmp`;
43
+ fs.writeFileSync(tmp, data, opts?.mode ? { encoding: "utf8", mode: opts.mode } : "utf8");
44
+ if (opts?.mode) {
45
+ try {
46
+ fs.chmodSync(tmp, opts.mode);
47
+ } catch { }
48
+ }
49
+ renameWithRetry(tmp, file);
50
+ if (opts?.mode) {
51
+ try {
52
+ fs.chmodSync(file, opts.mode);
53
+ } catch { }
54
+ }
55
+ }
package/src/catalog.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Provides 24-hour atomic disk caching with safe offline fallback to verified static definitions.
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
8
7
  import fs from "node:fs";
9
8
  import path from "node:path";
9
+ import { writeFileAtomic } from "./atomic-write.ts";
10
10
  import {
11
11
  CATALOG_CACHE_FILE,
12
12
  CATALOG_CACHE_TTL_MS,
@@ -130,6 +130,11 @@ export const DEAD_MODEL_IDS = new Set<string>([
130
130
  // muse-spark-1.3-contributor-free: decommissioned upstream (2026-10-17) — gone from
131
131
  // live Zen /v1/models list, answers HTTP 410 "Model muse-spark-1.3-contributor-free has been deprecated".
132
132
  "muse-spark-1.3-contributor-free",
133
+ // muse-spark-1.2-contributor-free: decommissioned upstream (2026-10-18) — answers
134
+ // HTTP 410 "Model muse-spark-1.2-contributor-free has been deprecated" while the
135
+ // same request shape on the surviving Zen models answers 200. Retired from the
136
+ // static catalog the same day; the Cline Muse Spark 1.3 entry is unaffected.
137
+ "muse-spark-1.2-contributor-free",
133
138
  ]);
134
139
  /**
135
140
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -169,7 +174,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
169
174
  }
170
175
  /**
171
176
  * In-memory cache of currently active/available free models.
172
- * Initialized with all 36 verified models for 0ms instant availability.
177
+ * Initialized with all 35 verified models for 0ms instant availability.
173
178
  */
174
179
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
175
180
  ...m,
@@ -288,10 +293,11 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
288
293
  maxTokens = 131_072;
289
294
  }
290
295
 
291
- // Muse Spark is served on the responses API upstream (both static catalogue
292
- // entries declare it). Every known id short-circuits above, so this only
293
- // decides for ids Zen lists dynamically — matching the family keeps a future
294
- // muse-spark id off the chat endpoint it would 500 on.
296
+ // Muse Spark is served on the responses API upstream (the Cline entry still
297
+ // declares it; the Zen 1.2/1.3 contributor ids were retired upstream). Every
298
+ // known id short-circuits above, so this only decides for ids Zen lists
299
+ // dynamically — matching the family keeps a future muse-spark id off the chat
300
+ // endpoint it would 500 on.
295
301
  const isResponses = idLower.includes("muse-spark");
296
302
 
297
303
  return {
@@ -333,39 +339,18 @@ export function readCatalogCache(): CatalogCacheData | null {
333
339
  }
334
340
 
335
341
  /**
336
- * Atomically write catalog cache data to disk using temporary file + rename.
342
+ * Atomically write catalog cache data to disk (temp file + rename, see
343
+ * src/atomic-write.ts).
337
344
  * Persists etag alongside models for subsequent If-None-Match conditional requests.
338
345
  * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
339
346
  */
340
- function sleepSync(ms: number): void {
341
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
342
- }
343
-
344
- function renameWithRetry(from: string, to: string): void {
345
- const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
346
- for (let attempt = 1; ; attempt++) {
347
- try {
348
- fs.renameSync(from, to);
349
- return;
350
- } catch (e) {
351
- const code = (e as NodeJS.ErrnoException | null)?.code;
352
- if (!code || !retryable[code] || attempt >= 3) {
353
- throw e;
354
- }
355
- sleepSync(50);
356
- }
357
- }
358
- }
359
-
360
347
  export function writeCatalogCache(data: CatalogCacheData): void {
361
348
  try {
362
349
  const dir = path.dirname(CATALOG_CACHE_FILE);
363
350
  if (!fs.existsSync(dir)) {
364
351
  fs.mkdirSync(dir, { recursive: true });
365
352
  }
366
- const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
367
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
368
- renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
353
+ writeFileAtomic(CATALOG_CACHE_FILE, JSON.stringify(data, null, 2));
369
354
  } catch (err) {
370
355
  logWarn("Could not persist catalog cache to disk", { error: String(err) });
371
356
  }
@@ -430,7 +415,14 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
430
415
  signal: AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS),
431
416
  });
432
417
  if (res.status === 304) {
433
- // Not modified — skip merge, extend timestamp to avoid tight loop
418
+ // Not modified upstream, but the cached list is still the only record of
419
+ // dynamically discovered ids this process never merged (a 304 revalidates
420
+ // before any 200 merge), so overlay it exactly like the stale-cache paths
421
+ // below instead of returning the untouched in-memory catalog.
422
+ if (staleForEtag && Array.isArray(staleForEtag.models) && staleForEtag.models.length > 0) {
423
+ aliveCatalog = mergeCatalog(aliveCatalog, sanitizeCatalogModels(staleForEtag.models));
424
+ }
425
+ // Extend the timestamp to avoid a tight loop on the same 304
434
426
  if (staleForEtag && Array.isArray(staleForEtag.models)) {
435
427
  try {
436
428
  writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
package/src/client.ts CHANGED
@@ -52,8 +52,10 @@ import {
52
52
  reprobeBasePortAlive,
53
53
  startProxy,
54
54
  } from "./proxy.ts";
55
- import { compareVersions } from "./update-checker.ts";
55
+ import { compareVersions } from "./update-core.ts";
56
+ import { isDaemonIdentityBody } from "./health.ts";
56
57
  import type { DaemonHealthSnapshot } from "./health.ts";
58
+ import { tokenAuthHeaders } from "./token.ts";
57
59
 
58
60
  const CLIENT_ID = randomUUID();
59
61
 
@@ -81,7 +83,7 @@ let lastUaRefreshAt = 0;
81
83
  /** Test-injected fetch for the background UA refresh; null = global fetch. */
82
84
  let uaRefreshFetch: typeof fetch | null = null;
83
85
 
84
- export interface DaemonRuntime {
86
+ interface DaemonRuntime {
85
87
  execPath: string;
86
88
  args: string[];
87
89
  runtimeType: "bun" | "node";
@@ -250,7 +252,7 @@ async function controlCall(
250
252
  try {
251
253
  const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
252
254
  method: "POST",
253
- headers: { "content-type": "application/json" },
255
+ headers: { "content-type": "application/json", ...tokenAuthHeaders() },
254
256
  body: JSON.stringify(payload),
255
257
  signal: AbortSignal.timeout(getControlTimeoutMs()),
256
258
  });
@@ -524,7 +526,7 @@ async function triggerRecovery(port: number, why: string): Promise<void> {
524
526
  * stopHeartbeat → busy-ruled kill → ensureDaemon when the daemon is stale or
525
527
  * degraded. Healthy ticks reset backoff/breaker and track the busy edge.
526
528
  */
527
- export async function watchdogCheck(port: number): Promise<void> {
529
+ async function watchdogCheck(port: number): Promise<void> {
528
530
  if (ensuring) return;
529
531
  if (isBreakerHalted()) return;
530
532
  let health: Awaited<ReturnType<typeof getDaemonHealth>>;
@@ -618,7 +620,7 @@ async function killStaleDaemon(
618
620
  * stdio "ignore". Returns the fd, or null when the file is unavailable
619
621
  * (the spawn then falls back to ignore).
620
622
  */
621
- export function openDaemonLogFd(): number | null {
623
+ function openDaemonLogFd(): number | null {
622
624
  try {
623
625
  fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
624
626
  return fs.openSync(LOG_FILE, "a");
@@ -627,58 +629,56 @@ export function openDaemonLogFd(): number | null {
627
629
  }
628
630
  }
629
631
 
630
- export type DarkHolderProbe = {
631
- /** True when GET /v1/models answers with a JSON catalog on the port. */
632
+ type DarkHolderProbe = {
633
+ /** True when GET /health identifies a pi-freeflow daemon on the port. */
632
634
  alive: boolean;
633
635
  /** True when a TCP connection to the port succeeds (something holds it). */
634
636
  bound: boolean;
635
637
  /**
636
- * True when GET /_health answers in OUR daemon's shape. A stranger squatting
637
- * the loopback port can answer `{}` or arbitrary JSON: that is not a daemon
638
- * of ours, whatever it claims.
638
+ * True when GET /health answers in OUR daemon's identity shape (service +
639
+ * the bound port). A stranger squatting the loopback port can answer `{}` or
640
+ * arbitrary JSON: that is not a daemon of ours, whatever it claims.
639
641
  */
640
642
  healthOurs: boolean;
641
643
  };
642
644
 
643
645
  /**
644
- * True when a /_health response body carries OUR daemon's shape: a JSON object
645
- * whose numeric `port` names the port it is bound to (src/health.ts builds it
646
- * from the listening socket). Anything else is a stranger on the loopback port
647
- * — attaching to it would route the user's prompts into a process we do not
648
- * own, so it must never be read as a daemon.
646
+ * True when a /health response body carries OUR daemon's identity: the
647
+ * pi-freeflow service marker plus the port it is bound to (src/health.ts
648
+ * builds both from the listening socket). Anything else is a stranger on the
649
+ * loopback port — attaching to it would route the user's prompts into a
650
+ * process we do not own, so it must never be read as a daemon.
649
651
  */
650
652
  export function isOurDaemonHealthBody(port: number, body: unknown): boolean {
651
- if (!body || typeof body !== "object") return false;
652
- const reported = (body as { port?: unknown }).port;
653
- return typeof reported === "number" && reported === port;
653
+ return isDaemonIdentityBody(port, body);
654
654
  }
655
655
 
656
656
  /**
657
657
  * Probe whatever holds `port` for the three facts the reclaim decision needs.
658
658
  * Ordered cheap-to-expensive and short-circuiting exactly like the inline
659
- * sequence it replaces: a models-answering daemon is live, a refused port is
660
- * free, and only a bound-but-model-silent holder is asked for /_health.
659
+ * sequence it replaces: an identity-answering daemon is live, a refused port
660
+ * is free, and only a bound-but-silent holder is asked again with a longer
661
+ * budget (a slow daemon still counts as ours).
661
662
  */
662
663
  export async function probeDarkHolder(port: number): Promise<DarkHolderProbe> {
663
- if (await isProxyAlive(port)) return { alive: true, bound: true, healthOurs: false };
664
+ if (await isProxyAlive(port)) return { alive: true, bound: true, healthOurs: true };
664
665
  if (!(await isPortBound(port))) return { alive: false, bound: false, healthOurs: false };
665
666
  return { alive: false, bound: true, healthOurs: await probeOurHealth(port) };
666
667
  }
667
668
 
668
669
  /**
669
- * Fetch /_health and keep the verdict only when the body is OUR daemon's
670
- * shape. Same request and budget as proxy.getDaemonHealth, minus its snapshot
671
- * mapping — which drops `port`, the one field that tells our daemon apart
672
- * from a stranger answering arbitrary JSON.
670
+ * Fetch GET /health and keep the verdict only when the body is OUR daemon's
671
+ * identity shape. Same request as proxy.isProxyAlive but with the longer
672
+ * daemon budget, so a slow-but-ours daemon is not mistaken for a stranger.
673
673
  */
674
674
  async function probeOurHealth(port: number): Promise<boolean> {
675
675
  if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
676
676
  try {
677
- const res = await fetch(`http://${HOST}:${port}/_health`, {
677
+ const res = await fetch(`http://${HOST}:${port}/health`, {
678
678
  signal: AbortSignal.timeout(2500),
679
679
  });
680
680
  if (!res.ok) return false;
681
- return isOurDaemonHealthBody(port, await res.json());
681
+ return isDaemonIdentityBody(port, await res.json());
682
682
  } catch {
683
683
  return false;
684
684
  }
@@ -686,13 +686,13 @@ async function probeOurHealth(port: number): Promise<boolean> {
686
686
 
687
687
  /**
688
688
  * Pure decision: reclaim a base port that is actually held (TCP connect
689
- * succeeds) yet answers neither /v1/models nor /_health in OUR shape. A live
690
- * daemon — stale, busy, even mid-stream — always answers both from its event
689
+ * succeeds) yet does not answer GET /health in OUR identity shape. A live
690
+ * daemon — stale, busy, even mid-stream — always answers it from its event
691
691
  * loop, so a dark holder cannot be serving traffic and replacing it cannot
692
692
  * interrupt a real stream. A FREE port is not a dark holder: treating it as
693
693
  * one cost a blind ~2s kill-and-poll on every cold start (issue #52). A holder
694
- * answering /_health with anything but our shape (a stranger's `{}`) is dark:
695
- * it is not a daemon of ours, so it is reclaimed rather than attached to.
694
+ * answering /health with anything but our identity (a stranger's `{}`) is
695
+ * dark: it is not a daemon of ours, so it is reclaimed rather than attached to.
696
696
  */
697
697
  export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
698
698
  if (probe.alive) return false;
@@ -725,6 +725,9 @@ async function reclaimDarkHolder(port: number): Promise<boolean> {
725
725
  await new Promise<void>((r) => setTimeout(r, 200));
726
726
  if (await isProxyAlive(port)) return true;
727
727
  }
728
+ if (await isPortBound(port)) {
729
+ logWarn(`port :${port} is still held by a process we could not (and must not) kill — starting the in-process proxy instead`);
730
+ }
728
731
  return false;
729
732
  }
730
733
 
@@ -773,7 +776,7 @@ export function proxyDownMessage(port: number): string {
773
776
  );
774
777
  }
775
778
 
776
- export type ProxyReadiness = { ok: true; port: number } | { ok: false; port: number; reason: string };
779
+ type ProxyReadiness = { ok: true; port: number } | { ok: false; port: number; reason: string };
777
780
 
778
781
  /**
779
782
  * Health probe before requests: when the loopback answers, no respawn runs.
@@ -800,7 +803,7 @@ export async function ensureProxyReady(
800
803
  /** True when any path segment of `filePath` is `node_modules`. Node refuses to
801
804
  * strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
802
805
  * so a Node-spawned daemon must run from a staged copy elsewhere. */
803
- export function isUnderNodeModules(filePath: string): boolean {
806
+ function isUnderNodeModules(filePath: string): boolean {
804
807
  return filePath.split(/[\\/]/).includes("node_modules");
805
808
  }
806
809
 
@@ -923,7 +926,7 @@ export function materializeDaemonSources(scriptPath: string, cacheDir?: string):
923
926
  return finalEntry;
924
927
  }
925
928
 
926
- export interface DaemonChild {
929
+ interface DaemonChild {
927
930
  pid?: number;
928
931
  exitCode: number | null;
929
932
  signalCode: string | null;
@@ -931,7 +934,7 @@ export interface DaemonChild {
931
934
  on(event: "error", listener: (err: Error) => void): unknown;
932
935
  }
933
936
 
934
- export type DaemonSpawner = (
937
+ type DaemonSpawner = (
935
938
  command: string,
936
939
  args: string[],
937
940
  options: Parameters<typeof spawn>[2],
@@ -1145,19 +1148,10 @@ export function getClientPort(): number {
1145
1148
  return attachedPort || PORT;
1146
1149
  }
1147
1150
 
1148
- export function getClientId(): string {
1149
- return CLIENT_ID;
1150
- }
1151
-
1152
1151
  export function hasFallbackServer(): boolean {
1153
1152
  return fallbackServer !== null;
1154
1153
  }
1155
1154
 
1156
- /** Test seam: true when client heartbeat timer is active. */
1157
- export function isHeartbeatActive(): boolean {
1158
- return heartbeatTimer !== null;
1159
- }
1160
-
1161
1155
  export function _resetClientForTest(): void {
1162
1156
  stopHeartbeatInternal();
1163
1157
  heartbeatPort = 0;