pi-freeflow 1.8.2 → 1.9.1

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 ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ All notable changes to pi-freeflow. Public, user-visible behavior only.
4
+
5
+ ## 1.9.1 - 2026-08-30
6
+
7
+ ### Fixes
8
+ - Stale-daemon guard completed: a pre-1.9.0 daemon cannot report its in-flight requests, and usage cannot be verified — so it is now left running instead of being replaced. Previously such a daemon could still be killed mid-stream while busy (a one-time window when upgrading from 1.8.x). The guarantee now holds in every case: only older, verified-idle daemons are replaced.
9
+ - The stale-daemon kill ritual was consolidated into one path (it was duplicated at five call sites); behavior unchanged.
10
+ - Tests: new coverage for the pre-1.9 daemon case; shared sandbox helpers extracted.
11
+
12
+ ## 1.9.0 - 2026-08-30
13
+
14
+ ### Development hardening
15
+ - Test suite now runs fully sandboxed: every test uses a temporary data directory, so no test can ever write to your real `~/.pi/agent/` files or interfere with a running local proxy. A single env override (`PI_*_DATA_DIR`) re-roots all data files for tests/CI.
16
+ - Added a complete mocked user-flow e2e suite: fresh install → onboarding → proxy health → direct chat → relay add/roll/fallback → guided deploy → update check → command surface — all deterministic, zero network.
17
+ - Docs: test counts no longer hardcoded in README (they drifted with releases); release history tracked here.
18
+
19
+ ### Fixes
20
+ - **Stale-daemon replacement no longer interrupts running sessions.** The shared local proxy can be held by another OMP/Pi session; the old upgrade logic killed that holder on version mismatch, which could terminate the session that owned the proxy mid-stream. Replacement now only happens when the running daemon is strictly older AND idle (0 in-flight requests, reported via `/_health`); newer or busy daemons are reused with a log note instead. Optional opt-out: set the no-kill env to `1` (see README FAQ for upgrades).
21
+
22
+ ## 1.8.2 - 2026-08-30
23
+
24
+ ### Fixes & polish
25
+ - Deploy: compare-and-swap on concurrent deployments (no duplicate relays when two sessions deploy at once).
26
+ - Proxy: URL-encoding edge cases (`%` in paths), startup timeout and kill handling, port conflict fallback.
27
+ - Logs: sanitized sensitive headers in debug output.
28
+ - Kilo gateway models: compatibility pass for all 25 models.
29
+ - Docs site build included; README per-host usage guide (Oh My Pi & Pi install + pick + manage).
30
+
31
+ ## 1.8.1 - 2026-08-29
32
+
33
+ - Fix: stale-daemon auto-heal is now shipped in the published package (previously only in the repo).
34
+
35
+ ## 1.8.0 - 2026-08-29
36
+
37
+ ### Automatic upgrades
38
+ - On version upgrade, the extension detects a stale local proxy daemon and replaces it automatically — users get fixes without manual restarts or killing sessions.
39
+ - Fix: client auth key stripped before reaching the opencode.ai/zen upstream (failed with 401 for some clients).
40
+ - 3 new free models (28 total).
41
+
42
+ ## 1.7.1 - 2026-08-29
43
+
44
+ - Docs: clarified npm is the distribution channel (the package is an extension loaded by OMP/Pi, not a standalone CLI).
45
+
46
+ ## 1.7.0 - 2026-08-29
47
+
48
+ - 4 new free models (25 total) with live-verified specs (context/output limits, vision, thinking levels).
49
+ - New alias map aligned with host model selectors.
50
+
51
+ ## 1.6.1 - 2026-08-29
52
+
53
+ - Catalog: thinking-level map locked per model; docs sync.
54
+
55
+ ## 1.6.0 - 2026-08-29
56
+
57
+ ### Onboarding & UX
58
+ - First-run onboarding message; 429 guidance hint (add your own relay egress).
59
+ - `/freeflow test` to probe a relay; relay latency tracking with health badges in `/freeflow list`.
60
+ - Guided deploy with context picker + confirmation; post-deploy health check.
61
+ - Status clarity: current mode + state file path; log text filter; per-relay usage counters; throttled roll notifications.
62
+
63
+ ### Reliability
64
+ - Relay state write-protection: before every save, the current state is snapshotted to `.bak`; if the main file is corrupted or missing, the backup is recovered automatically.
65
+ - New-user flow never seeds a fake relay; starts in direct mode with an empty pool.
66
+
67
+ ## 1.5.1 - 2026-08-28
68
+
69
+ - Edge-sweep fixes: port 28180 with legacy 18080 dual-probe auto-migration, OMP/Pi compatibility.
package/README.md CHANGED
@@ -262,7 +262,7 @@ Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
262
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
264
 
265
- Current size: about 11.3k lines including tests. 230 tests pass, typecheck clean.
265
+ Current size: about 11.3k lines including tests. The full suite (sandboxed, mocked network) and typecheck pass before every release — see CHANGELOG.md.
266
266
 
267
267
  ---
268
268
 
@@ -280,8 +280,11 @@ Yes. `/freeflow off` → direct. Add relays later to scale.
280
280
  **What happens when I update to a new version?**
281
281
  The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
282
282
  detects a stale daemon (mismatched internal version) and replaces it automatically — no manual
283
- kill, no restart of other sessions required. If an old daemon cannot be replaced (e.g. port held
284
- by an unrelated process), it falls back to reusing it with a warning.
283
+ kill, no restart of other sessions required. Replacement only happens when the running daemon is
284
+ idle: sessions with in-flight requests are never interrupted (busy or newer daemons are reused
285
+ with a log note instead). If a daemon cannot be replaced (e.g. port held by an unrelated process),
286
+ it falls back to reusing it with a warning. To disable replacement entirely, set the no-kill env
287
+ to `1` before starting a session.
285
288
 
286
289
  **Where's the normalizer?**
287
290
  Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
@@ -320,7 +323,7 @@ cd pi-freeflow
320
323
  pnpm install
321
324
 
322
325
  # run all three before opening a PR
323
- pnpm test # 228 tests across 30 test files
326
+ pnpm test # full suite; sandboxed + network-mocked
324
327
  pnpm typecheck # tsc --noEmit, must pass clean
325
328
  pnpm smoke # verifies extensions/index.ts loads without crashing
326
329
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.8.2",
4
+ "version": "1.9.1",
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",
@@ -40,10 +40,11 @@
40
40
  "extensions",
41
41
  "src",
42
42
  "README.md",
43
+ "CHANGELOG.md",
43
44
  "LICENSE"
44
45
  ],
45
46
  "scripts": {
46
- "test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
47
+ "test": "node --experimental-strip-types --import ./test/setup.mjs --test --test-concurrency=1 test/**/*.test.ts",
47
48
  "typecheck": "tsc --noEmit",
48
49
  "smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
49
50
  "changeset": "changeset",
package/src/config.ts CHANGED
@@ -95,10 +95,19 @@ export const STRIP_HEADERS = new Set([
95
95
  "proxy-authorization",
96
96
  ]);
97
97
 
98
- // ── File Path Resolvers ─────────────────────────────────────────────
98
+ /** Env override that re-roots ALL pi-freeflow data files (tests/CI sandbox).
99
+ * Unset → ~/.pi/agent. */
100
+ export const DATA_DIR_ENV = "PI_FREEFLOW_DATA_DIR";
101
+
102
+ function dataDirOverride(): string | null {
103
+ const d = process.env[DATA_DIR_ENV];
104
+ return typeof d === "string" && d.trim() !== "" ? d : null;
105
+ }
99
106
 
100
107
  export function resolveRelayStatePath(): string {
101
108
  try {
109
+ const override = dataDirOverride();
110
+ if (override) return path.join(override, "pi-freeflow-relay-state.json");
102
111
  return path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
103
112
  } catch {
104
113
  return path.join(
@@ -111,6 +120,8 @@ export function resolveRelayStatePath(): string {
111
120
 
112
121
  export function resolveLogFilePath(): string {
113
122
  try {
123
+ const override = dataDirOverride();
124
+ if (override) return path.join(override, "pi-freeflow.log");
114
125
  return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
115
126
  } catch {
116
127
  return path.join(
@@ -123,6 +134,8 @@ export function resolveLogFilePath(): string {
123
134
 
124
135
  export function resolveCatalogCachePath(): string {
125
136
  try {
137
+ const override = dataDirOverride();
138
+ if (override) return path.join(override, "pi-freeflow-catalog-cache.json");
126
139
  return path.join(
127
140
  homedir(),
128
141
  ".pi",
@@ -140,6 +153,8 @@ export function resolveCatalogCachePath(): string {
140
153
 
141
154
  export function resolveDebugStatePath(): string {
142
155
  try {
156
+ const override = dataDirOverride();
157
+ if (override) return path.join(override, "pi-freeflow-debug.json");
143
158
  return path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
144
159
  } catch {
145
160
  return path.join(
@@ -152,6 +167,8 @@ export function resolveDebugStatePath(): string {
152
167
 
153
168
  export function resolveUpdateCachePath(): string {
154
169
  try {
170
+ const override = dataDirOverride();
171
+ if (override) return path.join(override, "pi-freeflow-update.json");
155
172
  return path.join(homedir(), ".pi", "agent", "pi-freeflow-update.json");
156
173
  } catch {
157
174
  return path.join(
@@ -164,6 +181,8 @@ export function resolveUpdateCachePath(): string {
164
181
 
165
182
  export function resolveOnboardedFlagPath(): string {
166
183
  try {
184
+ const override = dataDirOverride();
185
+ if (override) return path.join(override, "pi-freeflow-onboarded");
167
186
  return path.join(homedir(), ".pi", "agent", "pi-freeflow-onboarded");
168
187
  } catch {
169
188
  return path.join(
@@ -184,6 +203,8 @@ export const UPDATE_CHECK_TTL_MS = 86_400_000;
184
203
  // ── Security & Relay Validation ─────────────────────────────────────
185
204
  /** Opt-out for tests/dev: when set to "1", relay URLs on http:// and private hosts are allowed. */
186
205
  export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
206
+ /** Opt-out for the stale-daemon replace: when "1", a version-mismatched daemon is never killed. */
207
+ export const NO_KILL_ENV = ALLOW_UNSAFE_RELAY_ENV.replace("_ALLOW_UNSAFE_RELAY", "_NO_KILL");
187
208
  /** Max request body the proxy buffers before responding 413. */
188
209
  export const MAX_BODY_BYTES = 32 * 1024 * 1024;
189
210
  /** Default timeout for upstream headers while proxying a request. */
package/src/health.ts CHANGED
@@ -24,12 +24,15 @@ export interface HealthData {
24
24
  relays: HealthRelayInfo[];
25
25
  catalog: number;
26
26
  version: string;
27
+ /** In-flight proxied requests right now (stale-daemon replacement guard). */
28
+ activeRequests: number;
27
29
  }
28
30
  /**
29
31
  * Collect current health snapshot.
30
32
  * @param portOverride - actual listening port (defaults to config PORT)
33
+ * @param activeRequests - in-flight proxied requests (defaults to 0 for callers that do not track)
31
34
  */
32
- export function getHealthData(portOverride?: number): HealthData {
35
+ export function getHealthData(portOverride?: number, activeRequests = 0): HealthData {
33
36
  const state = getActiveRelayState();
34
37
  const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
35
38
  const h = getRelayHealth(r.url);
@@ -50,6 +53,7 @@ export function getHealthData(portOverride?: number): HealthData {
50
53
  relays,
51
54
  catalog: ALL_MODELS.length,
52
55
  version: PKG_VERSION,
56
+ activeRequests,
53
57
  };
54
58
  }
55
59
 
@@ -69,6 +73,7 @@ export function handleHealthRequest(
69
73
  req: http.IncomingMessage,
70
74
  res: http.ServerResponse,
71
75
  portOverride?: number,
76
+ activeRequests = 0,
72
77
  ): boolean {
73
78
  let pathname: string | null = null;
74
79
  try {
@@ -96,7 +101,7 @@ export function handleHealthRequest(
96
101
  return true;
97
102
  }
98
103
 
99
- const data = getHealthData(portOverride);
104
+ const data = getHealthData(portOverride, activeRequests);
100
105
  const body = JSON.stringify(data);
101
106
  res.writeHead(200, {
102
107
  "content-type": "application/json",
package/src/index.ts CHANGED
@@ -18,12 +18,12 @@ import {
18
18
  setAliveCatalog,
19
19
  } from "./catalog.ts";
20
20
  import { createCommandSpec, updateStatusBar } from "./commands.ts";
21
- import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PKG_VERSION, PORT } from "./config.ts";
21
+ import { HOST, LEGACY_PORT, NO_KILL_ENV, ONBOARDED_FLAG_FILE, PKG_VERSION, PORT } from "./config.ts";
22
22
  import { log, logInfo, logWarn } from "./logger.ts";
23
23
  import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
24
- import { getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
24
+ import { getDaemonHealth, getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
25
25
  import { resetRateLimits } from "./rate-limiter.ts";
26
- import { checkForUpdateInBackground } from "./update-checker.ts";
26
+ import { checkForUpdateInBackground, compareVersions } from "./update-checker.ts";
27
27
  import {
28
28
  ensureRelay,
29
29
  getActiveRelayState,
@@ -161,6 +161,72 @@ export function buildProviderConfig(
161
161
  };
162
162
  }
163
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
+ }
164
230
  /**
165
231
  * Main extension entrypoint
166
232
  */
@@ -181,18 +247,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
181
247
  if (alreadyRunning) {
182
248
  const remoteVer = await getDaemonVersion(PORT);
183
249
  if (remoteVer !== null && remoteVer !== PKG_VERSION) {
184
- logWarn(`stale proxy daemon v${remoteVer} on :${PORT} (need v${PKG_VERSION}) — replacing`, {
185
- remoteVer,
186
- expected: PKG_VERSION,
187
- });
188
- const killed = await killPortHolder(PORT);
189
- if (killed) {
190
- for (let i = 0; i < 10; i++) {
191
- await new Promise<void>((r) => setTimeout(r, 200));
192
- if (!(await isProxyAlive(PORT))) break;
193
- }
194
- }
195
- if (!(await isProxyAlive(PORT))) {
250
+ if (await killStaleDaemon(PORT, remoteVer, "proxy daemon")) {
196
251
  try {
197
252
  const r = await startProxy();
198
253
  server = r.server;
@@ -205,7 +260,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
205
260
  actualPort = PORT;
206
261
  }
207
262
  } else {
208
- logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
209
263
  actualPort = PORT;
210
264
  }
211
265
  } else {
@@ -215,18 +269,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
215
269
  } else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
216
270
  const remoteVer = await getDaemonVersion(LEGACY_PORT);
217
271
  if (remoteVer !== null && remoteVer !== PKG_VERSION) {
218
- logWarn(`stale legacy daemon v${remoteVer} on :${LEGACY_PORT} (need v${PKG_VERSION}) — replacing`, {
219
- remoteVer,
220
- expected: PKG_VERSION,
221
- });
222
- const killed = await killPortHolder(LEGACY_PORT);
223
- if (killed) {
224
- for (let i = 0; i < 10; i++) {
225
- await new Promise<void>((r) => setTimeout(r, 200));
226
- if (!(await isProxyAlive(LEGACY_PORT))) break;
227
- }
228
- }
229
- if (!(await isProxyAlive(LEGACY_PORT)) && !(await isProxyAlive(PORT))) {
272
+ if (await killStaleDaemon(LEGACY_PORT, remoteVer, "legacy proxy daemon")) {
230
273
  try {
231
274
  const r = await startProxy();
232
275
  server = r.server;
@@ -240,7 +283,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
240
283
  actualPort = LEGACY_PORT;
241
284
  }
242
285
  } else {
243
- logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
244
286
  alreadyRunning = true;
245
287
  actualPort = LEGACY_PORT;
246
288
  }
@@ -284,72 +326,45 @@ export default async function (pi: ExtensionAPI): Promise<void> {
284
326
  if (await isProxyAlive(actualPort)) {
285
327
  const v = await getDaemonVersion(actualPort);
286
328
  if (v === null || v === PKG_VERSION) return;
287
- logWarn(`proxy on :${actualPort} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
288
- remoteVer: v,
289
- expected: PKG_VERSION,
290
- });
291
- const killed = await killPortHolder(actualPort);
292
- if (killed) {
293
- for (let i = 0; i < 10; i++) {
294
- await new Promise<void>((r) => setTimeout(r, 200));
295
- if (!(await isProxyAlive(actualPort))) break;
296
- }
297
- }
298
- if (await isProxyAlive(actualPort)) return;
329
+ if (!(await killStaleDaemon(actualPort, v, "proxy daemon"))) return;
299
330
  }
300
331
  if (await isProxyAlive(PORT)) {
301
332
  const v = await getDaemonVersion(PORT);
302
- if (v !== null && v !== PKG_VERSION) {
303
- logWarn(`proxy on :${PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
304
- remoteVer: v,
305
- expected: PKG_VERSION,
306
- });
307
- const killed = await killPortHolder(PORT);
308
- if (killed) {
309
- for (let i = 0; i < 10; i++) {
310
- await new Promise<void>((r) => setTimeout(r, 200));
311
- if (!(await isProxyAlive(PORT))) break;
312
- }
313
- }
314
- if (await isProxyAlive(PORT)) {
315
- actualPort = PORT;
316
- registerCatalog(getAliveCatalog());
317
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
318
- return;
319
- }
320
- } else {
333
+ if (v === null || v === PKG_VERSION) {
321
334
  actualPort = PORT;
322
335
  registerCatalog(getAliveCatalog());
323
336
  logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
324
337
  return;
325
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
+ }
326
349
  }
327
350
  if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
328
351
  const v = await getDaemonVersion(LEGACY_PORT);
329
- if (v !== null && v !== PKG_VERSION) {
330
- logWarn(`legacy proxy on :${LEGACY_PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
331
- remoteVer: v,
332
- expected: PKG_VERSION,
333
- });
334
- const killed = await killPortHolder(LEGACY_PORT);
335
- if (killed) {
336
- for (let i = 0; i < 10; i++) {
337
- await new Promise<void>((r) => setTimeout(r, 200));
338
- if (!(await isProxyAlive(LEGACY_PORT))) break;
339
- }
340
- }
341
- if (await isProxyAlive(LEGACY_PORT)) {
342
- actualPort = LEGACY_PORT;
343
- registerCatalog(getAliveCatalog());
344
- logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
345
- return;
346
- }
347
- } else {
352
+ if (v === null || v === PKG_VERSION) {
348
353
  actualPort = LEGACY_PORT;
349
354
  registerCatalog(getAliveCatalog());
350
355
  logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
351
356
  return;
352
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
+ }
353
368
  }
354
369
  const r = await startProxy();
355
370
  if (r.server) server = r.server;
package/src/proxy.ts CHANGED
@@ -126,7 +126,21 @@ export async function isProxyAlive(port: number): Promise<boolean> {
126
126
  }
127
127
  }
128
128
 
129
- export async function getDaemonVersion(port: number): Promise<string | null> {
129
+ let activeRequests = 0;
130
+
131
+ /** Current in-flight proxied requests (guard for stale-daemon replacement). */
132
+ export function getActiveRequests(): number {
133
+ return activeRequests;
134
+ }
135
+
136
+ /**
137
+ * Fetch the full health snapshot from a running daemon.
138
+ * `activeRequests` is undefined on daemons older than the busy-tracking
139
+ * feature (1.9.0) — callers treat that as "cannot verify usage".
140
+ */
141
+ export async function getDaemonHealth(
142
+ port: number,
143
+ ): Promise<{ version: string | null; activeRequests: number | undefined } | null> {
130
144
  if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
131
145
  try {
132
146
  const res = await fetch(`http://${HOST}:${port}/_health`, {
@@ -134,17 +148,26 @@ export async function getDaemonVersion(port: number): Promise<string | null> {
134
148
  });
135
149
  if (!res.ok) return null;
136
150
  const data: unknown = await res.json();
137
- if (data && typeof data === "object" && "version" in data) {
138
- const v = data.version;
139
- if (typeof v === "string" && v) return v;
151
+ if (data && typeof data === "object") {
152
+ const v =
153
+ "version" in data && typeof data.version === "string" && data.version
154
+ ? data.version
155
+ : "";
156
+ const ar = "activeRequests" in data ? data.activeRequests : undefined;
157
+ return { version: v, activeRequests: typeof ar === "number" ? ar : undefined };
140
158
  }
141
- // Alive but no version field = pre-1.8.0 daemon (never embedded it) — stale.
142
- return "";
159
+ return null;
143
160
  } catch {
144
161
  return null;
145
162
  }
146
163
  }
147
164
 
165
+ /** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
166
+ export async function getDaemonVersion(port: number): Promise<string | null> {
167
+ const h = await getDaemonHealth(port);
168
+ return h ? h.version : null;
169
+ }
170
+
148
171
  export async function killPortHolder(port: number): Promise<boolean> {
149
172
  if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
150
173
  try {
@@ -242,7 +265,7 @@ export function startProxy(
242
265
  if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
243
266
  const addr = server.address();
244
267
  const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
245
- if (handleHealthRequest(req, res, realPort)) return;
268
+ if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
246
269
  }
247
270
  if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
248
271
  const alive = getAliveCatalog();
@@ -277,6 +300,11 @@ export function startProxy(
277
300
  }
278
301
  // Buffer request body to inspect model ID for upstream routing
279
302
  const bodyChunks: Buffer[] = [];
303
+ activeRequests += 1;
304
+ res.on("close", () => {
305
+ activeRequests -= 1;
306
+ });
307
+
280
308
  // Reject oversized bodies before buffering starts: the client declares
281
309
  // the size in content-length, so no transfer cost is wasted.
282
310
  const declaredLength = Number(req.headers["content-length"] ?? 0);