pi-freeflow 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/catalog.ts CHANGED
@@ -191,7 +191,28 @@ export function readCatalogCache(): CatalogCacheData | null {
191
191
  /**
192
192
  * Atomically write catalog cache data to disk using temporary file + rename.
193
193
  * Persists etag alongside models for subsequent If-None-Match conditional requests.
194
+ * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
194
195
  */
196
+ function sleepSync(ms: number): void {
197
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
198
+ }
199
+
200
+ function renameWithRetry(from: string, to: string): void {
201
+ const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
202
+ for (let attempt = 1; ; attempt++) {
203
+ try {
204
+ fs.renameSync(from, to);
205
+ return;
206
+ } catch (e) {
207
+ const code = (e as NodeJS.ErrnoException | null)?.code;
208
+ if (!code || !retryable[code] || attempt >= 3) {
209
+ throw e;
210
+ }
211
+ sleepSync(50);
212
+ }
213
+ }
214
+ }
215
+
195
216
  export function writeCatalogCache(data: CatalogCacheData): void {
196
217
  try {
197
218
  const dir = path.dirname(CATALOG_CACHE_FILE);
@@ -200,7 +221,7 @@ export function writeCatalogCache(data: CatalogCacheData): void {
200
221
  }
201
222
  const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
202
223
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
203
- fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
224
+ renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
204
225
  } catch (err) {
205
226
  logWarn("Could not persist catalog cache to disk", { error: String(err) });
206
227
  }
package/src/commands.ts CHANGED
@@ -350,12 +350,13 @@ export function createCommandSpec(
350
350
  const { url, auth } = await deployer(token, name, (m) =>
351
351
  ctx.ui.notify(m, "info"),
352
352
  );
353
- setRelay(true, url, `deployed ${name}`);
354
- // Persist the per-deployment shared secret with the relay entry
355
- // (setRelay wrote without it; this save must include it).
356
- const deployed = relayState.relays.find((r) => r.url === url);
357
- if (deployed) deployed.auth = auth;
358
- setActiveRelayState(relayState);
353
+ relayState = withRelayState((s) => {
354
+ const r = ensureRelay(s, url, `deployed ${name}`);
355
+ r.auth = auth;
356
+ s.enabled = true;
357
+ s.url = url;
358
+ return s;
359
+ });
359
360
  persist();
360
361
  let probeNote = "";
361
362
  try {
@@ -589,12 +590,17 @@ export function createCommandSpec(
589
590
  const tokens = rest.trim().split(/\s+/);
590
591
  const targetUrl = tokens[0];
591
592
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
592
- applyRelayState((s) => {
593
- const added = ensureRelay(s, targetUrl, customLabel);
594
- s.enabled = true;
595
- s.url = added.url;
596
- return s;
597
- });
593
+ try {
594
+ applyRelayState((s) => {
595
+ const added = ensureRelay(s, targetUrl, customLabel);
596
+ s.enabled = true;
597
+ s.url = added.url;
598
+ return s;
599
+ });
600
+ } catch (e) {
601
+ ctx.ui.notify((e as Error).message, "warning");
602
+ return;
603
+ }
598
604
  persist();
599
605
  flash();
600
606
  ctx.ui.notify(
@@ -632,12 +638,17 @@ export function createCommandSpec(
632
638
  } catch {}
633
639
  const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
634
640
  if (looksLikeUrl) {
635
- applyRelayState((s) => {
636
- const added = ensureRelay(s, targetToken, customLabel);
637
- s.enabled = true;
638
- s.url = added.url;
639
- return s;
640
- });
641
+ try {
642
+ applyRelayState((s) => {
643
+ const added = ensureRelay(s, targetToken, customLabel);
644
+ s.enabled = true;
645
+ s.url = added.url;
646
+ return s;
647
+ });
648
+ } catch (e) {
649
+ ctx.ui.notify((e as Error).message, "warning");
650
+ return;
651
+ }
641
652
  persist();
642
653
  flash();
643
654
  } else {
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";
@@ -62,7 +75,7 @@ export const RATE_LIMIT_MAX: Record<Upstream, number> = {
62
75
  };
63
76
 
64
77
  // ── Whitelists & Security ───────────────────────────────────────────
65
- export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
78
+ export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&= %]*$/;
66
79
  export const PATH_TRAVERSAL_PATTERN = /\.\./;
67
80
  export const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
68
81
 
package/src/deploy.ts CHANGED
@@ -75,6 +75,8 @@ const timingSafeEqualStr = function(a, b) {
75
75
  // (never forwarded upstream) instead of living in the denylist.
76
76
  const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
77
77
  async function relayHandler(req) {
78
+ if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
79
+ if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
78
80
  const target = req.headers.get("x-relay-target");
79
81
  if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
80
82
  if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
@@ -159,11 +161,11 @@ export async function deployVercelRelay(
159
161
  name: string,
160
162
  onProgress?: (msg: string) => void,
161
163
  ): Promise<{ url: string; auth: string }> {
164
+ name = baseRelayName(name) || "relay-worker";
162
165
  const auth = {
163
166
  Authorization: `Bearer ${token}`,
164
167
  "Content-Type": "application/json",
165
168
  };
166
-
167
169
  // 1. Create deployment (3 inline files, no git repository required)
168
170
  onProgress?.("Uploading relay files to Vercel…");
169
171
  log("info", `Starting Vercel deployment: ${name}`);
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,12 +49,14 @@ 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
 
56
56
  function isLoopbackIP(ip: string): boolean {
57
57
  if (!ip) return false;
58
- const clean = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
58
+ const withoutZone = ip.split("%")[0];
59
+ const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
59
60
  return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
60
61
  }
61
62
 
@@ -76,13 +77,18 @@ export function handleHealthRequest(
76
77
  return false;
77
78
  }
78
79
 
79
- const isHealthPath =
80
- pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
80
+ const isHealthPath = pathname === "/_health" || pathname === "/health";
81
81
  if (req.method !== "GET" || !isHealthPath) {
82
82
  return false;
83
83
  }
84
84
 
85
- const clientIP = getClientIP(req);
85
+ const sock: unknown = req.socket;
86
+ let rawIp = "";
87
+ if (sock && typeof sock === "object" && "remoteAddress" in sock) {
88
+ const v = sock.remoteAddress;
89
+ if (typeof v === "string") rawIp = v;
90
+ }
91
+ const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
86
92
  if (!isLoopbackIP(clientIP)) {
87
93
  const body = JSON.stringify({ error: "forbidden" });
88
94
  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 {
@@ -150,7 +150,7 @@ export function buildProviderConfig(
150
150
  : m.source === "kilo"
151
151
  ? {
152
152
  supportsDeveloperRole: false,
153
- supportsReasoningEffort: false,
153
+ supportsReasoningEffort: !!m.thinkingLevelMap,
154
154
  }
155
155
  : {
156
156
  supportsDeveloperRole: false,
@@ -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/logger.ts CHANGED
@@ -197,6 +197,10 @@ export function formatLogMeta(
197
197
  if (meta && Object.keys(meta).length > 0) {
198
198
  const safe: Record<string, unknown> = {};
199
199
  for (const [k, v] of Object.entries(meta)) {
200
+ if (/(auth|token|secret|password|bearer|api.?key|cookie|x-relay-auth)/i.test(k)) {
201
+ safe[k] = "[REDACTED]";
202
+ continue;
203
+ }
200
204
  if (typeof v === "string" && v.length > 800) {
201
205
  safe[k] = `${v.slice(0, 800)}…(${v.length})`;
202
206
  } else if (v instanceof Error) {
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";
@@ -65,6 +66,7 @@ export function validatePath(rawUrl: string): URL | null {
65
66
  if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
66
67
  try {
67
68
  const decoded = decodeURIComponent(cleaned);
69
+ if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
68
70
  if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
69
71
  return null;
70
72
  }
@@ -112,6 +114,7 @@ export function sanitizeHeaders(
112
114
  return sanitized;
113
115
  }
114
116
  export async function isProxyAlive(port: number): Promise<boolean> {
117
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
115
118
  try {
116
119
  const res = await fetch(`http://${HOST}:${port}/v1/models`, {
117
120
  signal: AbortSignal.timeout(500),
@@ -123,6 +126,63 @@ export async function isProxyAlive(port: number): Promise<boolean> {
123
126
  }
124
127
  }
125
128
 
129
+ export async function getDaemonVersion(port: number): Promise<string | null> {
130
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
131
+ try {
132
+ const res = await fetch(`http://${HOST}:${port}/_health`, {
133
+ signal: AbortSignal.timeout(800),
134
+ });
135
+ if (!res.ok) return null;
136
+ 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;
140
+ }
141
+ // Alive but no version field = pre-1.8.0 daemon (never embedded it) — stale.
142
+ return "";
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ export async function killPortHolder(port: number): Promise<boolean> {
149
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
150
+ try {
151
+ if (process.platform === "win32") {
152
+ try {
153
+ const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", timeout: 3000 }) as string;
154
+ for (const line of out.split("\n")) {
155
+ if (!line.includes("LISTENING")) continue;
156
+ if (!line.includes(`:${port}`)) continue;
157
+ const parts = line.trim().split(/\s+/);
158
+ const pid = parts[parts.length - 1];
159
+ if (!pid || !/^\d+$/.test(pid)) continue;
160
+ // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
161
+ if (Number(pid) === process.pid) continue; // never self-kill
162
+ execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
163
+ return true;
164
+ }
165
+ } catch {}
166
+ return false;
167
+ }
168
+ try {
169
+ const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
170
+ encoding: "utf8",
171
+ timeout: 3000,
172
+ }) as string;
173
+ const pid = out.trim().split(/\s+/)[0];
174
+ if (!pid || !/^\d+$/.test(pid)) return false;
175
+ if (Number(pid) === process.pid) return false; // never self-kill (Unix) — matches Windows guard at 161
176
+ execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
126
186
  /**
127
187
  * Tagged abort reason for the proxy-internal header-wait timeout.
128
188
  * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
@@ -179,7 +239,7 @@ export function startProxy(
179
239
  reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
180
240
  } catch {}
181
241
  // Loopback-only health endpoint — always accessible even when widget hidden
182
- if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health" || reqPathname.endsWith("/health"))) {
242
+ if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
183
243
  const addr = server.address();
184
244
  const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
185
245
  if (handleHealthRequest(req, res, realPort)) return;
@@ -464,7 +524,7 @@ export function startProxy(
464
524
  fwd["connection"] = "keep-alive";
465
525
 
466
526
  const controller = new AbortController();
467
- const timeoutId = setTimeout(() => controller.abort(), 300_000);
527
+ const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
468
528
  const onClientClose = () => {
469
529
  if (!res.writableEnded) controller.abort();
470
530
  };
@@ -542,6 +602,7 @@ export function startProxy(
542
602
  let settled = false;
543
603
 
544
604
  const tryListen = async (port: number) => {
605
+ server.removeAllListeners("error");
545
606
  server.once("error", async (err: NodeJS.ErrnoException) => {
546
607
  if (settled) return;
547
608
  if (err.code === "EADDRINUSE") {
@@ -512,7 +512,7 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
512
512
  */
513
513
  export function getActiveRelayState(): RelayState {
514
514
  const currentMtime = currentDiskStateMtimeMs();
515
- if (currentMtime > 0 && currentMtime > lastKnownStateMtimeMs) {
515
+ if (currentMtime !== lastKnownStateMtimeMs) {
516
516
  activeRelayState = resolveRelayState();
517
517
  lastKnownStateMtimeMs = currentMtime;
518
518
  }