pi-freeflow 1.9.5 → 1.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to pi-freeflow. Public, user-visible behavior only.
4
4
 
5
+ ## 1.9.7 - 2026-09-06
6
+
7
+ ### Fixes
8
+ - **Model picker only shows verified free models (fixes #6).** Background catalog refresh now filters out non-free models from upstream endpoints so paid models requiring an API key no longer leak into your picker.
9
+ - **Relay pool automatically recovers from dead deployments (fixes #5).** If a relay URL in your pool returns an infrastructure 404 (such as a missing or deleted Vercel deployment), the proxy marks it as failed and immediately rolls over to your next healthy relay or direct mode instead of getting trapped in a 404 loop.
10
+
11
+ ### Changes
12
+ - **Updated model catalog (26 verified free models: 7 OpenCode Zen + 19 KiloCode Gateway).**
13
+ - Added `inclusionai/ling-3.0-flash-sante:free` (Ling 3.0 Flash Sante, 262K context, 32K output, reasoning supported). Clean CLI alias `ling-3.0-flash-sante` supported.
14
+ - Removed `laguna-s-2.1-free` (OpenCode Zen) after upstream dropped free-tier access. `poolside/laguna-s-2.1:free` on Kilo remains active.
15
+ - **Public by default for new relay deployments.** Relays deployed via `/freeflow deploy` are now public by default with no mandatory authentication tokens. This enables seamless copy-paste migration across proxy tools (such as 9router) while maintaining safety guards for allowed AI upstreams.
16
+
17
+ ### Validation
18
+ - TypeScript typecheck passed cleanly (`tsc --noEmit`).
19
+ - Full test suite passed across Windows (299 tests) and Ubuntu Linux (`acerblue-local`, 300/300 tests passed).
20
+ - End-to-end concurrency and failover stress testing verified (100 parallel requests, 20 subagent leases, burst failover).
21
+
22
+ ## 1.9.6 - 2026-09-03
23
+
24
+ ### Fixes
25
+ - **Hiding the status widget now sticks.** `/freeflow hide` survives restarts, new sessions, and background update notices — previously the widget could reappear on the next session.
26
+ - **Calmer startup with many sessions.** Sessions starting at the same time now converge on a single background proxy instead of each spawning its own; a spawn that never becomes ready falls back to in-process mode instead of running untracked.
27
+ - **Survives daemon restarts mid-session.** If the background proxy is replaced while sessions run, connected sessions re-register automatically instead of silently losing their lease (which could retire the daemon mid-use).
28
+ - **Stale-version cleanup targets the right process.** Windows port matching is now exact, and Linux without `lsof`/`fuser` resolves the holder via `/proc` instead of giving up.
29
+ - **`/freeflow logs --follow` no longer runs forever.** The live tail stops when the session ends and caps burst output; the update/install subprocess now has a 2-minute timeout.
30
+
31
+ ### Validation
32
+ - `npx tsc --noEmit` clean, `npm test` 299 (298 pass + 1 Linux-only skip) + smoke green on Windows; stress harness 7/7 on Windows and Linux `acerblue-local`. **`macOS not tested`** this cycle.
33
+
5
34
  ## 1.9.5 - 2026-09-03
6
35
 
7
36
  ### Changes
package/README.md CHANGED
@@ -18,7 +18,7 @@ Join devs bypassing rate limits with their own relay pools. BYO, add as many as
18
18
 
19
19
  | Feature | Description | Value | Cost |
20
20
  | :--- | :--- | :--- | :--- |
21
- | **26 Curated Free Models** | 8 OpenCode Zen + 18 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
21
+ | **26 Curated Free Models** | 7 OpenCode Zen + 19 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
22
22
  | **BYO Relay Pool** | Round-robin load balancing across your Cloudflare Workers & Vercel Edges | Zero Rate Limits | **$0** (your free tiers) |
23
23
  | **Adaptive Health & Error Detection** | Auto-cooldown on 429 rate limits, 504 timeouts, and socket drops | 0ms Wasted Latency | **$0** |
24
24
  | **Stream Truncation Resilience** | Stateful SSE terminal tracking (`response.failed` / `response.incomplete` injection) | Zero Host Crashes | **$0** |
@@ -38,7 +38,7 @@ Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Hos
38
38
  /model → freeflow → pick
39
39
  ```
40
40
 
41
- #### OpenCode Zen (8 Models), Responses & Chat API
41
+ #### OpenCode Zen (7 Models), Responses & Chat API
42
42
  Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows.
43
43
 
44
44
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
@@ -46,13 +46,11 @@ Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows
46
46
  | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
47
47
  | `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
48
48
  | `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
49
- | `laguna-s-2.1-free` | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal … xhigh` | ❌ |
50
49
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
51
50
  | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
52
51
  | `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
53
52
  | `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
54
-
55
- #### KiloCode Gateway (18 Models), OpenRouter Compatible
53
+ #### KiloCode Gateway (19 Models), OpenRouter Compatible
56
54
  Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
57
55
 
58
56
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
@@ -74,6 +72,7 @@ Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI alia
74
72
  | `minimax-m3` (`minimax/minimax-m3:free`) | MiniMax | **1M** (1.048.576) | **512K** (524.288) | `minimal…xhigh`\* | ❌ |
75
73
  | `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
76
74
  | `inkling` (`thinkingmachines/inkling:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
75
+ | `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
77
76
  | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
78
77
 
79
78
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
@@ -215,7 +214,7 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
215
214
 
216
215
  **Canonical worker source (all platforms)**
217
216
 
218
- The relay worker template is generated per deployment by `/freeflow deploy` and lives in [`src/deploy.ts`](src/deploy.ts): one hardened core plus thin Vercel / Cloudflare / Deno wrappers. Every deployment embeds its own shared secret and enforces the target allowlist (`https://opencode.ai`, `https://api.kilo.ai`), SSRF/private-host guard, relay-path validation, and a header denylist — `x-relay-auth` is checked by the worker and never forwarded upstream.
217
+ The relay worker template is generated per deployment by `/freeflow deploy` and lives in [`src/deploy.ts`](src/deploy.ts): one hardened core plus thin Vercel / Cloudflare / Deno wrappers. Deployed relays are public by default for seamless migration across proxy tools (such as 9router), while enforcing the target allowlist (`https://opencode.ai`, `https://api.kilo.ai`), SSRF/private-host guard, relay-path validation, and a header denylist.
219
218
 
220
219
  ```js
221
220
  // Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.9.5",
4
+ "version": "1.9.7",
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
@@ -20,6 +20,7 @@ import {
20
20
  ALL_MODELS,
21
21
  KILO_MODEL_IDS,
22
22
  MODEL_MAP,
23
+ OPENCODE_MODELS,
23
24
  } from "./models.ts";
24
25
  import type {
25
26
  CatalogCacheData,
@@ -37,6 +38,7 @@ export const DEAD_MODEL_IDS = new Set<string>([
37
38
  "hy3-free",
38
39
  "tencent/hy3:free",
39
40
  "meituan/longcat-2.0-free",
41
+ "laguna-s-2.1-free",
40
42
  ]);
41
43
  /**
42
44
  * In-memory cache of currently active/available free models.
@@ -304,7 +306,15 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
304
306
  }
305
307
  }
306
308
  if (rawList.length > 0) {
307
- const fresh = rawList.map((r) => enrichModelDef(r, "opencode"));
309
+ const freeRawList = rawList.filter((r) => {
310
+ if (!r || typeof r.id !== "string") return false;
311
+ if (DEAD_MODEL_IDS.has(r.id)) return false;
312
+ return (
313
+ r.id.includes("-free") ||
314
+ OPENCODE_MODELS.some((m) => m.id === r.id)
315
+ );
316
+ });
317
+ const fresh = freeRawList.map((r) => enrichModelDef(r, "opencode"));
308
318
  const merged = mergeCatalog(aliveCatalog, fresh);
309
319
  aliveCatalog = merged;
310
320
  writeCatalogCache({
package/src/client.ts CHANGED
@@ -63,7 +63,7 @@ function isSpawnEnabled(): boolean {
63
63
  return process.env[DAEMON_SPAWN_ENV] !== "0";
64
64
  }
65
65
 
66
- type ControlResult = "ok" | "legacy" | "gone";
66
+ type ControlResult = "ok" | "unknown" | "legacy" | "gone";
67
67
 
68
68
  async function controlCall(
69
69
  port: number,
@@ -77,7 +77,12 @@ async function controlCall(
77
77
  body: JSON.stringify(payload),
78
78
  signal: AbortSignal.timeout(1500),
79
79
  });
80
- return res.ok ? "ok" : "legacy";
80
+ if (!res.ok) return "legacy";
81
+ try {
82
+ const data: unknown = await res.json();
83
+ if (data && typeof data === "object" && "ok" in data && data.ok === false) return "unknown";
84
+ } catch {}
85
+ return "ok";
81
86
  } catch {
82
87
  return "gone";
83
88
  }
@@ -122,6 +127,9 @@ async function beatOnce(port: number): Promise<void> {
122
127
  const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
123
128
  if (result === "gone") {
124
129
  void ensureDaemon();
130
+ } else if (result === "unknown") {
131
+ // Daemon restarted since attach: re-register our lease.
132
+ await attachTo(port);
125
133
  }
126
134
  }
127
135
 
@@ -259,13 +267,22 @@ export async function ensureDaemon(): Promise<number> {
259
267
  }
260
268
 
261
269
  if (isSpawnEnabled()) {
270
+ // Cross-process race guard: a sibling session may be spawning right
271
+ // now (singleflight is per-process). Jitter + re-probe converts most
272
+ // cold-start races into attach instead of N detached spawns.
273
+ await new Promise<void>((r) => setTimeout(r, 100 + Math.random() * 200));
274
+ if (await isProxyAlive(PORT)) {
275
+ await attachTo(PORT);
276
+ return PORT;
277
+ }
262
278
  spawnDaemonProcess();
263
279
  const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
264
280
  if (ready) {
265
281
  const ver = await getDaemonVersion(PORT);
266
282
  if (ver !== null && ver !== PKG_VERSION) {
267
- if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
268
- spawnDaemonProcess();
283
+ if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
284
+ lastSpawnAt = 0; // kill-confirmed retry bypasses the spawn throttle
285
+ spawnDaemonProcess();
269
286
  const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
270
287
  if (retryReady) {
271
288
  await attachTo(PORT);
@@ -282,7 +299,8 @@ export async function ensureDaemon(): Promise<number> {
282
299
  } else {
283
300
  logWarn("daemon spawn did not become ready — is the port blocked?");
284
301
  }
285
- return PORT;
302
+ // Fall through to the in-process fallback below — never hand the caller
303
+ // a port we did not attach to (no lease, no heartbeat).
286
304
  }
287
305
 
288
306
  try {
package/src/commands.ts CHANGED
@@ -68,6 +68,7 @@ function spawnWithProgress(
68
68
  cmd: string,
69
69
  args: string[],
70
70
  ctx: ExtensionContext,
71
+ timeoutMs = 120_000,
71
72
  ): Promise<number> {
72
73
  const { promise, resolve } = Promise.withResolvers<number>();
73
74
  try {
@@ -76,6 +77,16 @@ function spawnWithProgress(
76
77
  stdio: "pipe",
77
78
  windowsHide: true,
78
79
  });
80
+ const timer = setTimeout(() => {
81
+ try { child.kill(); } catch {}
82
+ ctx.ui.notify(`spawn ${cmd} timed out after ${timeoutMs}ms — killed`, "warning");
83
+ resolve(1);
84
+ }, timeoutMs);
85
+ try { timer.unref?.(); } catch {}
86
+ const done = (code: number | null): void => {
87
+ clearTimeout(timer);
88
+ resolve(code ?? 0);
89
+ };
79
90
  child.stdout?.on("data", (d: Buffer) => {
80
91
  const s = String(d).trim();
81
92
  if (s) ctx.ui.notify(s, "info");
@@ -85,10 +96,11 @@ function spawnWithProgress(
85
96
  if (s) ctx.ui.notify(s, "info");
86
97
  });
87
98
  child.on("error", (err: Error) => {
99
+ clearTimeout(timer);
88
100
  ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
89
101
  resolve(1);
90
102
  });
91
- child.on("close", (code: number | null) => resolve(code ?? 0));
103
+ child.on("close", done);
92
104
  } catch (e) {
93
105
  ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
94
106
  resolve(1);
@@ -100,8 +112,15 @@ function spawnWithProgress(
100
112
  * Follow-mode log poller: tracks the count of matched lines already printed
101
113
  * and notifies only lines beyond it, so repeated ticks never re-notify the
102
114
  * same tail. One poller at a time — a re-run clears the previous interval.
115
+ * Stop with stopLogsFollow (session shutdown / non-follow commands).
103
116
  */
104
117
  let logsFollowTimer: NodeJS.Timeout | null = null;
118
+ export function stopLogsFollow(): void {
119
+ if (logsFollowTimer) {
120
+ clearInterval(logsFollowTimer);
121
+ logsFollowTimer = null;
122
+ }
123
+ }
105
124
  function startLogsFollow(
106
125
  ctx: ExtensionContext,
107
126
  filterLevel: LogLevel | null,
@@ -110,10 +129,7 @@ function startLogsFollow(
110
129
  filterText: string | null,
111
130
  baselineMatched: number,
112
131
  ): void {
113
- if (logsFollowTimer) {
114
- clearInterval(logsFollowTimer);
115
- logsFollowTimer = null;
116
- }
132
+ stopLogsFollow();
117
133
  let lastPrinted = baselineMatched;
118
134
  logsFollowTimer = setInterval(() => {
119
135
  try {
@@ -127,7 +143,7 @@ function startLogsFollow(
127
143
  if (newCount <= 0) return;
128
144
  const newLines = tail.lines.slice(
129
145
  tail.lines.length - Math.min(newCount, tail.lines.length),
130
- );
146
+ ).slice(-50);
131
147
  if (newLines.length > 0) {
132
148
  ctx.ui.notify(newLines.join("\n"), "info");
133
149
  }
@@ -354,7 +370,8 @@ export function createCommandSpec(
354
370
  );
355
371
  relayState = withRelayState((s) => {
356
372
  const r = ensureRelay(s, url, `deployed ${name}`);
357
- r.auth = auth;
373
+ if (auth) r.auth = auth;
374
+ else delete r.auth;
358
375
  s.enabled = true;
359
376
  s.url = url;
360
377
  return s;
package/src/deploy.ts CHANGED
@@ -160,6 +160,7 @@ export async function deployVercelRelay(
160
160
  token: string,
161
161
  name: string,
162
162
  onProgress?: (msg: string) => void,
163
+ authSecret: string = "",
163
164
  ): Promise<{ url: string; auth: string }> {
164
165
  name = baseRelayName(name) || "relay-worker";
165
166
  const auth = {
@@ -169,9 +170,9 @@ export async function deployVercelRelay(
169
170
  // 1. Create deployment (3 inline files, no git repository required)
170
171
  onProgress?.("Uploading relay files to Vercel…");
171
172
  log("info", `Starting Vercel deployment: ${name}`);
172
- // Per-deployment shared secret: embedded in the worker, persisted on the
173
- // relay-state entry by the caller, and required via x-relay-auth.
174
- const relayAuth = randomBytes(24).toString("base64url");
173
+ // Public by default for easy migration with 9router and other proxy tools.
174
+ // When authSecret is provided, embeds the shared secret for private auth.
175
+ const relayAuth = authSecret || "";
175
176
 
176
177
  const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
177
178
  method: "POST",
@@ -339,6 +340,7 @@ export async function deployCloudflareWorker(
339
340
  token: string,
340
341
  name: string,
341
342
  onProgress?: (msg: string) => void,
343
+ authSecret: string = "",
342
344
  ): Promise<{ url: string; auth: string }> {
343
345
  const auth = { Authorization: `Bearer ${token}` };
344
346
  const scriptName = cloudflareScriptName(name);
@@ -358,9 +360,9 @@ export async function deployCloudflareWorker(
358
360
 
359
361
  // 2. Upload the module worker script (multipart: main module + metadata)
360
362
  onProgress?.("Uploading relay worker to Cloudflare…");
361
- // Per-deployment shared secret: embedded in the worker, persisted on the
362
- // relay-state entry by the caller, and required via x-relay-auth.
363
- const relayAuth = randomBytes(24).toString("base64url");
363
+ // Public by default for easy migration with 9router and other proxy tools.
364
+ // When authSecret is provided, embeds the shared secret for private auth.
365
+ const relayAuth = authSecret || "";
364
366
  const formData = new FormData();
365
367
  formData.append(
366
368
  "index.js",
@@ -452,12 +454,11 @@ export async function deployDenoRelay(
452
454
  token: string,
453
455
  name: string,
454
456
  onProgress?: (msg: string) => void,
457
+ authSecret: string = "",
455
458
  ): Promise<{ url: string; auth: string }> {
459
+ const slug = denoProjectName(name);
456
460
  const auth = { Authorization: `Bearer ${token}` };
457
461
  const jsonHeaders = { ...auth, "Content-Type": "application/json" };
458
- const slug = denoProjectName(name);
459
-
460
- // 1. Create the app
461
462
  onProgress?.("Creating Deno Deploy app…");
462
463
  log("info", `Starting Deno Deploy deployment: ${slug}`);
463
464
  const createRes = await fetch(`${DENO_API}/apps`, {
@@ -495,9 +496,9 @@ export async function deployDenoRelay(
495
496
 
496
497
  // 2. Push the relay source as a single-file revision
497
498
  onProgress?.("Uploading relay script to Deno Deploy…");
498
- // Per-deployment shared secret: embedded in the worker, persisted on the
499
- // relay-state entry by the caller, and required via x-relay-auth.
500
- const relayAuth = randomBytes(24).toString("base64url");
499
+ // Public by default for easy migration with 9router and other proxy tools.
500
+ // When authSecret is provided, embeds the shared secret for private auth.
501
+ const relayAuth = authSecret || "";
501
502
  const deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
502
503
  method: "POST",
503
504
  headers: jsonHeaders,
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 26 free models (8 OpenCode Zen + 18 KiloCode Gateway) with:
4
+ * Provides access to 26 free models (7 OpenCode Zen + 19 KiloCode Gateway) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
@@ -17,7 +17,7 @@ import {
17
17
  setAliveCatalog,
18
18
  } from "./catalog.ts";
19
19
  import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
20
- import { createCommandSpec, updateStatusBar } from "./commands.ts";
20
+ import { createCommandSpec, stopLogsFollow, updateStatusBar } from "./commands.ts";
21
21
  import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
22
22
  import { logInfo, logWarn } from "./logger.ts";
23
23
  import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
@@ -330,5 +330,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
330
330
  });
331
331
  pi.on?.("session_shutdown", () => {
332
332
  stopHeartbeat();
333
+ stopLogsFollow();
333
334
  });
334
335
  }
package/src/lease.ts CHANGED
@@ -32,11 +32,13 @@ export function registerClient(clientId: string): void {
32
32
  leases.set(clientId, Date.now());
33
33
  }
34
34
 
35
- /** Renew an existing client lease (unknown ids are ignored). */
36
- export function renewClient(clientId: string): void {
35
+ /** Renew an existing client lease. Returns false when the id is unknown (daemon restarted). */
36
+ export function renewClient(clientId: string): boolean {
37
37
  if (leases.has(clientId)) {
38
38
  leases.set(clientId, Date.now());
39
+ return true;
39
40
  }
41
+ return false;
40
42
  }
41
43
 
42
44
  /** Remove a client lease (graceful detach on session end). */
package/src/models.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
4
  * Defines the 26 verified free models:
5
- * - 8 OpenCode Zen models (2 Responses API + 6 Chat Completions)
6
- * - 18 KiloCode Keyless Gateway models (17 OpenRouter format + 1 Standard format)
5
+ * - 7 OpenCode Zen models (2 Responses API + 5 Chat Completions)
6
+ * - 19 KiloCode Keyless Gateway models (18 OpenRouter format + 1 Standard format)
7
7
  */
8
8
 
9
9
  import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
@@ -111,22 +111,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
111
111
  xhigh: null,
112
112
  max: "max",},
113
113
  },
114
- {
115
- id: "laguna-s-2.1-free",
116
- name: "Laguna S 2.1 (256K)",
117
- reasoning: true,
118
- contextWindow: 262_144,
119
- maxTokens: 32_768,
120
- input: ["text"],
121
- thinkingLevelMap: {
122
- off: null,
123
- minimal: "minimal",
124
- low: "low",
125
- medium: "medium",
126
- high: "high",
127
- xhigh: "xhigh",
128
- max: null,},
129
- },
130
114
  {
131
115
  id: "ling-3.0-flash-fin-free",
132
116
  name: "Ling 3.0 Flash Fin (256K)",
@@ -345,6 +329,16 @@ export const KILO_MODELS: ModelDef[] = [
345
329
  thinkingFormat: "openrouter",
346
330
  thinkingLevelMap: KILO_REASONING_MAP,
347
331
  },
332
+ {
333
+ id: "inclusionai/ling-3.0-flash-sante:free",
334
+ name: "Ling 3.0 Flash Sante",
335
+ reasoning: true,
336
+ contextWindow: 262_144,
337
+ maxTokens: 32_768,
338
+ input: ["text"],
339
+ thinkingFormat: "openrouter",
340
+ thinkingLevelMap: KILO_REASONING_MAP,
341
+ },
348
342
  ];
349
343
 
350
344
  /**
@@ -368,6 +362,7 @@ export const MODEL_ALIASES: Record<string, string> = {
368
362
  "minimax-m2.7": "minimax/minimax-m2.7:free",
369
363
  "minimax-m3": "minimax/minimax-m3:free",
370
364
  "ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
365
+ "ling-3.0-flash-sante": "inclusionai/ling-3.0-flash-sante:free",
371
366
  "inkling": "thinkingmachines/inkling:free",
372
367
  "inkling-small": "thinkingmachines/inkling-small:free",
373
368
  // provider-prefixed short aliases (slash-normalized)
package/src/proxy.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
+ import fs from "node:fs";
9
10
  import { execSync } from "node:child_process";
10
11
  import * as http from "node:http";
11
12
  import * as https from "node:https";
@@ -177,6 +178,63 @@ export async function getDaemonVersion(port: number): Promise<string | null> {
177
178
  return h ? h.version : null;
178
179
  }
179
180
 
181
+ /**
182
+ * Linux fallback when neither lsof nor fuser exists: resolve the LISTEN
183
+ * socket inode for `port` via /proc/net/tcp{,6}, then find the owning pid by
184
+ * scanning /proc fds. Dependency-free, same-user only, never self-kills.
185
+ */
186
+ function killViaProcNet(port: number): boolean {
187
+ if (process.platform !== "linux") return false;
188
+ try {
189
+ const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
190
+ const inodes = new Set<string>();
191
+ for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
192
+ let text = "";
193
+ try {
194
+ text = fs.readFileSync(table, "utf8");
195
+ } catch {
196
+ continue;
197
+ }
198
+ for (const line of text.split("\n").slice(1)) {
199
+ const cols = line.trim().split(/\s+/);
200
+ if (cols.length < 10) continue;
201
+ const addr = cols[1].split(":");
202
+ if (addr.length !== 2 || addr[1].toUpperCase() !== hexPort) continue;
203
+ if (cols[3] !== "0A") continue; // LISTEN only
204
+ inodes.add(cols[9]);
205
+ }
206
+ }
207
+ if (inodes.size === 0) return false;
208
+ for (const pid of fs.readdirSync("/proc")) {
209
+ if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
210
+ let fds: string[];
211
+ try {
212
+ fds = fs.readdirSync(`/proc/${pid}/fd`);
213
+ } catch {
214
+ continue;
215
+ }
216
+ for (const fd of fds) {
217
+ let link = "";
218
+ try {
219
+ link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
220
+ } catch {
221
+ continue;
222
+ }
223
+ const sock = link.match(/^socket:\[(\d+)\]$/);
224
+ if (sock && inodes.has(sock[1])) {
225
+ try {
226
+ process.kill(Number(pid), "SIGKILL");
227
+ return true;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ } catch {}
235
+ return false;
236
+ }
237
+
180
238
  export async function killPortHolder(port: number): Promise<boolean> {
181
239
  if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
182
240
  try {
@@ -189,8 +247,10 @@ export async function killPortHolder(port: number): Promise<boolean> {
189
247
  }) as string;
190
248
  for (const line of out.split("\n")) {
191
249
  if (!line.includes("LISTENING")) continue;
192
- if (!line.includes(`:${port}`)) continue;
193
250
  const parts = line.trim().split(/\s+/);
251
+ // Match the port on the Local Address column only — a raw
252
+ // substring match can hit longer ports (:2818 vs :28180).
253
+ if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
194
254
  const pid = parts[parts.length - 1];
195
255
  if (!pid || !/^\d+$/.test(pid)) continue;
196
256
  // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
@@ -206,13 +266,14 @@ export async function killPortHolder(port: number): Promise<boolean> {
206
266
  encoding: "utf8",
207
267
  timeout: 3000,
208
268
  }) as string;
209
- const pid = out.trim().split(/\s+/)[0];
210
- if (!pid || !/^\d+$/.test(pid)) return false;
211
- if (Number(pid) === process.pid) return false; // never self-kill (Unix) matches Windows guard at 161
212
- execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
269
+ const pids = out.trim().split(/\s+/).filter((p) => /^\d+$/.test(p) && Number(p) !== process.pid);
270
+ // lsof matches both ends of a connection: our own recent probe socket
271
+ // (TIME_WAIT) can sort first, so skip self instead of taking [0].
272
+ if (pids.length === 0) return killViaProcNet(port);
273
+ execSync(`kill -9 ${pids[0]}`, { timeout: 3000, stdio: "ignore" });
213
274
  return true;
214
275
  } catch {
215
- return false;
276
+ return killViaProcNet(port);
216
277
  }
217
278
  } catch {
218
279
  return false;
@@ -271,8 +332,15 @@ export function handleControlRequest(
271
332
  return;
272
333
  }
273
334
  if (reqPathname === "/_client/attach") registerClient(clientId);
274
- else if (reqPathname === "/_client/heartbeat") renewClient(clientId);
275
- else if (reqPathname === "/_client/detach") unregisterClient(clientId);
335
+ else if (reqPathname === "/_client/heartbeat") {
336
+ // Unknown id = daemon restarted since attach. Report ok:false so the
337
+ // client re-attaches instead of heartbeating lease-less.
338
+ if (!renewClient(clientId)) {
339
+ res.writeHead(200, { "content-type": "application/json" });
340
+ res.end(JSON.stringify({ ok: false }));
341
+ return;
342
+ }
343
+ } else if (reqPathname === "/_client/detach") unregisterClient(clientId);
276
344
  else {
277
345
  res.writeHead(404, { "content-type": "application/json" });
278
346
  res.end(JSON.stringify({ error: "not found" }));
@@ -345,7 +413,6 @@ export function startProxy(
345
413
  if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
346
414
  }
347
415
  if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
348
- touchActivity();
349
416
  const alive = getAliveCatalog();
350
417
  const body = JSON.stringify({
351
418
  object: "list",
@@ -117,6 +117,7 @@ function parseRelayState(raw: string): RelayState | null {
117
117
  : s?.enabled !== false && relays.length > 0,
118
118
  url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
119
119
  relays,
120
+ hideWidget: s?.hideWidget === true,
120
121
  };
121
122
  } catch {
122
123
  return null;
@@ -182,9 +183,10 @@ export function loadRelayState(): RelayState {
182
183
  if (healed) {
183
184
  return healed;
184
185
  }
185
- return { mode: "auto", enabled: true, url: "", relays: [] };
186
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
186
187
  }
187
- const main = parseRelayState(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
188
+ const raw = fs.readFileSync(RELAY_STATE_FILE, "utf8");
189
+ const main = parseRelayState(raw);
188
190
  if (main) {
189
191
  return main;
190
192
  }
@@ -196,9 +198,15 @@ export function loadRelayState(): RelayState {
196
198
  logWarn("relay state file unusable and no valid backup — starting fresh", {
197
199
  path: RELAY_STATE_FILE,
198
200
  });
199
- return { mode: "auto", enabled: true, url: "", relays: [] };
201
+ // parse returns null on an empty pool by design (wipe-protection above);
202
+ // preserve an explicit hide so hide-with-zero-relays survives reload.
203
+ let hide = false;
204
+ try {
205
+ hide = (JSON.parse(raw) as RelayState)?.hideWidget === true;
206
+ } catch {}
207
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: hide };
200
208
  } catch {
201
- return { mode: "auto", enabled: true, url: "", relays: [] };
209
+ return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
202
210
  }
203
211
  }
204
212
 
@@ -537,9 +545,6 @@ export function setActiveRelayState(s: RelayState, persist = true): void {
537
545
  // concurrent sessions cannot silently clobber each other's edits.
538
546
 
539
547
  const RELAY_STATE_LOCK_FILE = `${RELAY_STATE_FILE}.lock`;
540
- const LOCK_WAIT_MAX_MS = 2_000;
541
- const LOCK_RETRY_START_MS = 25;
542
- const LOCK_RETRY_MAX_MS = 200;
543
548
  /** A lock older than this is presumed abandoned (crashed holder) and taken over. */
544
549
  const LOCK_STALE_MS = 30_000;
545
550
 
@@ -571,24 +576,19 @@ function tryAcquireRelayLock(): boolean {
571
576
 
572
577
  /**
573
578
  * Apply a mutation to relay state with a compare-and-swap discipline.
574
- * Locks the state file (bounded retry, ~2s), loads the freshest on-disk state,
579
+ * Makes a single lock acquisition attempt, loads the freshest on-disk state,
575
580
  * runs the updater against it, persists atomically, and releases the lock.
576
- * On lock timeout the write proceeds unlocked — the atomic tmp+rename save
577
- * already prevents torn files; the lock only serializes write intent.
581
+ * When the lock is held the write proceeds unlocked — the atomic tmp+rename
582
+ * save already prevents torn files; the lock only serializes write intent.
583
+ * (Never spin here: this runs on the proxy request path and blocking the
584
+ * event loop stalls all in-flight streams.)
578
585
  * Returns the state that was actually persisted (also becomes the in-memory
579
586
  * active state).
580
587
  */
581
588
  export function withRelayState(updater: (s: RelayState) => RelayState): RelayState {
582
- const deadline = Date.now() + LOCK_WAIT_MAX_MS;
583
- let locked = tryAcquireRelayLock();
584
- let delay = LOCK_RETRY_START_MS;
585
- while (!locked && Date.now() < deadline) {
586
- sleepSync(delay);
587
- locked = tryAcquireRelayLock();
588
- delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS);
589
- }
589
+ const locked = tryAcquireRelayLock();
590
590
  if (!locked) {
591
- logWarn("relay state lock held — proceeding without lock after timeout", {
591
+ logWarn("relay state lock held — proceeding without lock", {
592
592
  lock: RELAY_STATE_LOCK_FILE,
593
593
  });
594
594
  }
package/src/relay.ts CHANGED
@@ -156,6 +156,36 @@ export async function relayFetch(
156
156
  break;
157
157
  }
158
158
 
159
+ // Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
160
+ // When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare
161
+ // returns edge 404. This is a relay failure, not an upstream API response.
162
+ const isRelayEdge404 =
163
+ res.status === 404 &&
164
+ (Boolean(res.headers.get("x-vercel-error")) ||
165
+ Boolean(res.headers.get("x-vercel-id")) ||
166
+ res.headers.get("server")?.toLowerCase().includes("vercel") ||
167
+ !res.headers.get("content-type")?.includes("json"));
168
+
169
+ if (isRelayEdge404) {
170
+ markRelayFailure(targetUrl, 404, "Deployment or route not found on relay host");
171
+ lastResponse?.body?.cancel().catch(() => {});
172
+ lastResponse = res;
173
+ log(
174
+ "warn",
175
+ `relay ${targetUrl} returned edge 404 (deployment missing or route not found) — rolling to next relay`,
176
+ { upstream: url },
177
+ rid,
178
+ );
179
+ const now = Date.now();
180
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
181
+ lastRollNotify = now;
182
+ const ui = getStatusUi();
183
+ if (ui?.notify) {
184
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} failed (HTTP 404) — rolled to next relay`, "warning");
185
+ }
186
+ }
187
+ continue;
188
+ }
159
189
  if (isRetriableStatus(res.status)) {
160
190
  markRelayFailure(targetUrl, res.status);
161
191
  lastResponse?.body?.cancel().catch(() => {});
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { UPDATE_CACHE_FILE, UPDATE_CHECK_TTL_MS } from "./config.ts";
5
5
  import { logInfo } from "./logger.ts";
6
6
  import { logWarn } from "./logger.ts";
7
- import { getStatusUi } from "./relay-state.ts";
7
+ import { getActiveRelayState, getStatusUi } from "./relay-state.ts";
8
8
  import type { ExtensionUIContext } from "./types.ts";
9
9
  const REGISTRY_URL = "https://registry.npmjs.org/pi-freeflow/latest";
10
10
  /** Strict semver shape: major.minor.patch with optional -prerelease or +build suffix. */
@@ -143,6 +143,10 @@ export function checkForUpdateInBackground(ui?: ExtensionUIContext | null): void
143
143
  const local = getLocalVersion();
144
144
  if (local && compareVersions(latest, local) > 0) {
145
145
  logInfo(`pi-freeflow update available: ${local} -> ${latest} (run /freeflow update)`);
146
+ // Respect hide: log only, never re-show the widget.
147
+ try {
148
+ if (getActiveRelayState().hideWidget) return;
149
+ } catch {}
146
150
  const targetUi = ui ?? null;
147
151
  if (targetUi?.setStatus) {
148
152
  try { targetUi.setStatus("freeflow", `update: ${local} → ${latest}`); } catch {}