pi-freeflow 1.8.0 → 1.8.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/README.md CHANGED
@@ -269,7 +269,7 @@ Current size: about 11.3k lines including tests. 230 tests pass, typecheck clean
269
269
  ### FAQ
270
270
 
271
271
  **Do I need API keys?**
272
- No. Kilo uses `Bearer kilo-free`, OpenCode uses `opencodeHeaders()`.
272
+ No. Kilo uses `Bearer kilo-free`, OpenCode free models are anonymous (no header needed).
273
273
 
274
274
  **What if all relays are 429?**
275
275
  Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is the global upstream cap; without relays you would hit the same wall.
@@ -277,6 +277,12 @@ Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is
277
277
  **Can I use without relays?**
278
278
  Yes. `/freeflow off` → direct. Add relays later to scale.
279
279
 
280
+ **What happens when I update to a new version?**
281
+ The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
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.
285
+
280
286
  **Where's the normalizer?**
281
287
  Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
282
288
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.8.0",
4
+ "version": "1.8.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",
package/src/config.ts CHANGED
@@ -6,8 +6,21 @@ import { randomUUID } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { readFileSync } from "node:fs";
9
10
  import type { Upstream } from "./types.ts";
10
11
 
12
+ // Package version — stale-daemon detection in the shared-port reuse path.
13
+ let PKG_VERSION = "0.0.0";
14
+ try {
15
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
16
+ const raw: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
17
+ if (raw && typeof raw === "object" && "version" in raw) {
18
+ const v = raw.version;
19
+ if (typeof v === "string" && v) PKG_VERSION = v;
20
+ }
21
+ } catch {}
22
+ export { PKG_VERSION };
23
+
11
24
  // ── Upstream endpoints ──────────────────────────────────────────────
12
25
  export const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
13
26
  export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
package/src/health.ts CHANGED
@@ -6,8 +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 { PORT } from "./config.ts";
10
- import { getClientIP } from "./proxy.ts";
9
+ import { PKG_VERSION, PORT } from "./config.ts";
11
10
 
12
11
  export interface HealthRelayInfo {
13
12
  url: string;
@@ -24,8 +23,8 @@ export interface HealthData {
24
23
  enabled: boolean;
25
24
  relays: HealthRelayInfo[];
26
25
  catalog: number;
26
+ version: string;
27
27
  }
28
-
29
28
  /**
30
29
  * Collect current health snapshot.
31
30
  * @param portOverride - actual listening port (defaults to config PORT)
@@ -50,6 +49,7 @@ export function getHealthData(portOverride?: number): HealthData {
50
49
  enabled: Boolean(state.enabled),
51
50
  relays,
52
51
  catalog: ALL_MODELS.length,
52
+ version: PKG_VERSION,
53
53
  };
54
54
  }
55
55
 
@@ -76,13 +76,18 @@ export function handleHealthRequest(
76
76
  return false;
77
77
  }
78
78
 
79
- const isHealthPath =
80
- pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
79
+ const isHealthPath = pathname === "/_health" || pathname === "/health";
81
80
  if (req.method !== "GET" || !isHealthPath) {
82
81
  return false;
83
82
  }
84
83
 
85
- const clientIP = getClientIP(req);
84
+ const sock: unknown = req.socket;
85
+ let rawIp = "";
86
+ if (sock && typeof sock === "object" && "remoteAddress" in sock) {
87
+ const v = sock.remoteAddress;
88
+ if (typeof v === "string") rawIp = v;
89
+ }
90
+ const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
86
91
  if (!isLoopbackIP(clientIP)) {
87
92
  const body = JSON.stringify({ error: "forbidden" });
88
93
  res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
package/src/index.ts CHANGED
@@ -18,10 +18,10 @@ 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, PORT } from "./config.ts";
21
+ import { HOST, LEGACY_PORT, 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 { isProxyAlive, startProxy } from "./proxy.ts";
24
+ import { getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
25
25
  import { resetRateLimits } from "./rate-limiter.ts";
26
26
  import { checkForUpdateInBackground } from "./update-checker.ts";
27
27
  import {
@@ -174,18 +174,81 @@ export default async function (pi: ExtensionAPI): Promise<void> {
174
174
  // 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
175
175
  // Dual-probe: probe current PORT first; if missing, check LEGACY_PORT so existing v1.4.9
176
176
  // sessions on 18080 are seamlessly reused without split-brain or duplicate daemons.
177
+ // Stale-daemon heal: if the alive daemon reports a different version (e.g. 1.7.1 vs 1.8.0
178
+ // after an upgrade), the auth fix never loads for the new session. Detect via /_health
179
+ // and best-effort replace the stale holder so old users are auto-healed.
177
180
  let alreadyRunning = await isProxyAlive(PORT);
178
181
  if (alreadyRunning) {
179
- logInfo(
180
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`,
181
- );
182
- actualPort = PORT;
182
+ const remoteVer = await getDaemonVersion(PORT);
183
+ 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))) {
196
+ try {
197
+ const r = await startProxy();
198
+ server = r.server;
199
+ actualPort = r.port;
200
+ alreadyRunning = false;
201
+ } catch (e) {
202
+ log("error", "stale daemon replaced but fresh bind failed — reusing stale as fallback", {
203
+ error: String(e),
204
+ });
205
+ actualPort = PORT;
206
+ }
207
+ } else {
208
+ logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
209
+ actualPort = PORT;
210
+ }
211
+ } else {
212
+ logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`);
213
+ actualPort = PORT;
214
+ }
183
215
  } else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
184
- logInfo(
185
- `Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`,
186
- );
187
- alreadyRunning = true;
188
- actualPort = LEGACY_PORT;
216
+ const remoteVer = await getDaemonVersion(LEGACY_PORT);
217
+ 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))) {
230
+ try {
231
+ const r = await startProxy();
232
+ server = r.server;
233
+ actualPort = r.port;
234
+ alreadyRunning = false;
235
+ } catch (e) {
236
+ log("error", "stale legacy daemon replaced but fresh bind failed — reusing as fallback", {
237
+ error: String(e),
238
+ });
239
+ alreadyRunning = true;
240
+ actualPort = LEGACY_PORT;
241
+ }
242
+ } else {
243
+ logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
244
+ alreadyRunning = true;
245
+ actualPort = LEGACY_PORT;
246
+ }
247
+ } else {
248
+ logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
249
+ alreadyRunning = true;
250
+ actualPort = LEGACY_PORT;
251
+ }
189
252
  } else {
190
253
  try {
191
254
  const r = await startProxy();
@@ -200,8 +263,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
200
263
  return;
201
264
  }
202
265
  }
203
-
204
- // 3. Instant 0ms Static Catalog Registration
205
266
  // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
206
267
  const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
207
268
  ...m,
@@ -215,27 +276,80 @@ export default async function (pi: ExtensionAPI): Promise<void> {
215
276
  buildProviderConfig(models, actualPort),
216
277
  );
217
278
  };
218
- registerCatalog(registeredCatalog);
219
- // Self-heal: if this session attached to an external daemon that later died,
220
- // re-bind on the next lifecycle event so model calls recover without a restart.
221
279
  let ensuringDaemon = false;
222
280
  const ensureDaemon = async (): Promise<void> => {
223
281
  if (ensuringDaemon) return;
224
282
  ensuringDaemon = true;
225
283
  try {
226
- if (await isProxyAlive(actualPort)) return;
227
- // If actualPort died, check if another daemon is alive on PORT or LEGACY_PORT before binding
284
+ if (await isProxyAlive(actualPort)) {
285
+ const v = await getDaemonVersion(actualPort);
286
+ 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;
299
+ }
228
300
  if (await isProxyAlive(PORT)) {
229
- actualPort = PORT;
230
- registerCatalog(getAliveCatalog());
231
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
232
- return;
301
+ 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 {
321
+ actualPort = PORT;
322
+ registerCatalog(getAliveCatalog());
323
+ logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
324
+ return;
325
+ }
233
326
  }
234
327
  if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
235
- actualPort = LEGACY_PORT;
236
- registerCatalog(getAliveCatalog());
237
- logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
238
- return;
328
+ 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 {
348
+ actualPort = LEGACY_PORT;
349
+ registerCatalog(getAliveCatalog());
350
+ logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
351
+ return;
352
+ }
239
353
  }
240
354
  const r = await startProxy();
241
355
  if (r.server) server = r.server;
package/src/proxy.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
+ import { execSync } from "node:child_process";
9
10
  import * as http from "node:http";
10
11
  import * as https from "node:https";
11
12
  import { handleHealthRequest } from "./health.ts";
@@ -112,6 +113,7 @@ export function sanitizeHeaders(
112
113
  return sanitized;
113
114
  }
114
115
  export async function isProxyAlive(port: number): Promise<boolean> {
116
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
115
117
  try {
116
118
  const res = await fetch(`http://${HOST}:${port}/v1/models`, {
117
119
  signal: AbortSignal.timeout(500),
@@ -123,6 +125,62 @@ export async function isProxyAlive(port: number): Promise<boolean> {
123
125
  }
124
126
  }
125
127
 
128
+ export async function getDaemonVersion(port: number): Promise<string | null> {
129
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
130
+ try {
131
+ const res = await fetch(`http://${HOST}:${port}/_health`, {
132
+ signal: AbortSignal.timeout(800),
133
+ });
134
+ if (!res.ok) return null;
135
+ const data: unknown = await res.json();
136
+ if (data && typeof data === "object" && "version" in data) {
137
+ const v = data.version;
138
+ if (typeof v === "string" && v) return v;
139
+ }
140
+ // Alive but no version field = pre-1.8.0 daemon (never embedded it) — stale.
141
+ return "";
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ export async function killPortHolder(port: number): Promise<boolean> {
148
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
149
+ try {
150
+ if (process.platform === "win32") {
151
+ try {
152
+ const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", timeout: 3000 }) as string;
153
+ for (const line of out.split("\n")) {
154
+ if (!line.includes("LISTENING")) continue;
155
+ if (!line.includes(`:${port}`)) continue;
156
+ const parts = line.trim().split(/\s+/);
157
+ const pid = parts[parts.length - 1];
158
+ if (!pid || !/^\d+$/.test(pid)) continue;
159
+ // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
160
+ if (Number(pid) === process.pid) continue; // never self-kill
161
+ execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
162
+ return true;
163
+ }
164
+ } catch {}
165
+ return false;
166
+ }
167
+ try {
168
+ const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
169
+ encoding: "utf8",
170
+ timeout: 3000,
171
+ }) as string;
172
+ const pid = out.trim().split(/\s+/)[0];
173
+ if (!pid || !/^\d+$/.test(pid)) return false;
174
+ execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
126
184
  /**
127
185
  * Tagged abort reason for the proxy-internal header-wait timeout.
128
186
  * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
@@ -179,7 +237,7 @@ export function startProxy(
179
237
  reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
180
238
  } catch {}
181
239
  // Loopback-only health endpoint — always accessible even when widget hidden
182
- if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health" || reqPathname.endsWith("/health"))) {
240
+ if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
183
241
  const addr = server.address();
184
242
  const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
185
243
  if (handleHealthRequest(req, res, realPort)) return;