pi-freeflow 1.9.4 → 1.9.6
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 +24 -0
- package/README.md +10 -9
- package/package.json +61 -61
- package/src/catalog.ts +12 -4
- package/src/client.ts +23 -5
- package/src/commands.ts +22 -6
- package/src/index.ts +4 -3
- package/src/lease.ts +4 -2
- package/src/models.ts +16 -37
- package/src/proxy.ts +76 -9
- package/src/relay-state.ts +19 -19
- package/src/update-checker.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
4
|
|
|
5
|
+
## 1.9.6 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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).
|
|
11
|
+
- **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.
|
|
12
|
+
- **`/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.
|
|
13
|
+
|
|
14
|
+
### Validation
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
17
|
+
## 1.9.5 - 2026-09-03
|
|
18
|
+
|
|
19
|
+
### Changes
|
|
20
|
+
- **Catalog refreshed against live upstream model lists (26 models: 8 OpenCode Zen + 18 KiloCode Gateway).** Re-checked both upstreams against their live endpoints and live inference probes:
|
|
21
|
+
- **Added** `muse-spark-1.3-contributor-free` (OpenCode Zen, Responses API, 1M context, 131K output, vision) — verified live: completes with reasoning, accepts effort levels, and answers vision queries.
|
|
22
|
+
- **Removed** `hy3-free` (OpenCode Zen) — upstream no longer serves it (`Model hy3-free is not supported`).
|
|
23
|
+
- **Removed** `tencent/hy3:free` and `meituan/longcat-2.0-free` (KiloCode Gateway) — upstream free tier dropped them (model unavailable / sign-in required).
|
|
24
|
+
- Pruned IDs added to the dead-model filter so a stale disk cache cannot resurrect them.
|
|
25
|
+
|
|
26
|
+
### Validation
|
|
27
|
+
- `npx tsc --noEmit` clean, `npm test` full suite green on Windows.
|
|
28
|
+
|
|
5
29
|
## 1.9.4 - 2026-09-02
|
|
6
30
|
|
|
7
31
|
### Dependencies
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow 🌊
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **26 free models. Up to 1M context. Zero API keys. Infinite scale via your own relay pool.**
|
|
4
4
|
|
|
5
5
|
Thin by design: model list + dumb relay + log. Host `pi-ai` owns thinking, normalization & provider magic. We just make it free, fast, and unbreakable.
|
|
6
6
|
|
|
@@ -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
|
-
| **
|
|
21
|
+
| **26 Curated Free Models** | 8 OpenCode Zen + 18 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** |
|
|
@@ -32,24 +32,25 @@ Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Hos
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
###
|
|
35
|
+
### 26 Curated Models, One Command
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
/model → freeflow → pick
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
#### OpenCode Zen (
|
|
41
|
+
#### OpenCode Zen (8 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 |
|
|
45
45
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
46
46
|
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
47
|
+
| `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
47
48
|
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
48
49
|
| `laguna-s-2.1-free` | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal … xhigh` | ❌ |
|
|
49
50
|
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
50
51
|
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
51
|
-
| `hy3-free` | Tencent Hunyuan | **262K** (262.144) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
52
52
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
53
|
+
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
53
54
|
|
|
54
55
|
#### KiloCode Gateway (18 Models), OpenRouter Compatible
|
|
55
56
|
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
@@ -62,7 +63,6 @@ Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI alia
|
|
|
62
63
|
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
63
64
|
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
64
65
|
| `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
65
|
-
| `hy3:free` (`tencent/hy3:free`) | Tencent Hunyuan | **262K** (262.144) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
66
66
|
| `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
|
|
67
67
|
| `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
68
68
|
| `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
@@ -70,12 +70,13 @@ Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI alia
|
|
|
70
70
|
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
71
71
|
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
72
72
|
| `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
|
|
73
|
-
| `longcat-2.0` (`meituan/longcat-2.0-free`) | Meituan | **1M** (1.048.756) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
74
73
|
| `minimax-m2.7` (`minimax/minimax-m2.7:free`) | MiniMax | **196K** (196.608) | **196K** (196.608) | `minimal…xhigh`\* | ❌ |
|
|
75
74
|
| `minimax-m3` (`minimax/minimax-m3:free`) | MiniMax | **1M** (1.048.576) | **512K** (524.288) | `minimal…xhigh`\* | ❌ |
|
|
76
75
|
| `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
76
|
+
| `inkling` (`thinkingmachines/inkling:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
77
|
+
| `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
77
78
|
|
|
78
|
-
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model.
|
|
79
|
+
\* 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.
|
|
79
80
|
|
|
80
81
|
---
|
|
81
82
|
|
|
@@ -341,7 +342,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
341
342
|
```
|
|
342
343
|
src/
|
|
343
344
|
├── index.ts # extension entry, lifecycle hooks
|
|
344
|
-
├── models.ts #
|
|
345
|
+
├── models.ts # 26-model catalog definitions
|
|
345
346
|
├── catalog.ts # model catalog cache (24h disk)
|
|
346
347
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
347
348
|
├── relay.ts # relay selection & round-robin
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-freeflow",
|
|
3
|
-
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
5
|
-
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
-
"main": "extensions/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"keywords": [
|
|
9
|
-
"pi-package",
|
|
10
|
-
"pi-extension",
|
|
11
|
-
"oh-my-pi",
|
|
12
|
-
"omp",
|
|
13
|
-
"free-models",
|
|
14
|
-
"opencode",
|
|
15
|
-
"kilocode",
|
|
16
|
-
"ai-models",
|
|
17
|
-
"relay"
|
|
18
|
-
],
|
|
19
|
-
"author": "trefeon",
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
-
},
|
|
25
|
-
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=22.19.0"
|
|
28
|
-
},
|
|
29
|
-
"omp": {
|
|
30
|
-
"extensions": [
|
|
31
|
-
"./extensions"
|
|
32
|
-
]
|
|
33
|
-
},
|
|
34
|
-
"pi": {
|
|
35
|
-
"extensions": [
|
|
36
|
-
"./extensions"
|
|
37
|
-
]
|
|
38
|
-
},
|
|
39
|
-
"files": [
|
|
40
|
-
"extensions",
|
|
41
|
-
"src",
|
|
42
|
-
"README.md",
|
|
43
|
-
"CHANGELOG.md",
|
|
44
|
-
"LICENSE"
|
|
45
|
-
],
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-freeflow",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.9.6",
|
|
5
|
+
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"free-models",
|
|
14
|
+
"opencode",
|
|
15
|
+
"kilocode",
|
|
16
|
+
"ai-models",
|
|
17
|
+
"relay"
|
|
18
|
+
],
|
|
19
|
+
"author": "trefeon",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"omp": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"extensions",
|
|
41
|
+
"src",
|
|
42
|
+
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@changesets/cli": "^2.27.0",
|
|
48
|
+
"@types/node": "^22.13.9",
|
|
49
|
+
"typescript": "^5.8.2",
|
|
50
|
+
"vitepress": "^1.6.4"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "node --experimental-strip-types --import ./test/setup.mjs --test --test-concurrency=1 test/**/*.test.ts",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"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); })\"",
|
|
57
|
+
"changeset": "changeset",
|
|
58
|
+
"docs:dev": "vitepress dev docs",
|
|
59
|
+
"docs:build": "vitepress build docs"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/catalog.ts
CHANGED
|
@@ -31,10 +31,16 @@ import type {
|
|
|
31
31
|
/**
|
|
32
32
|
* Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
|
|
33
33
|
*/
|
|
34
|
-
export const DEAD_MODEL_IDS = new Set<string>([
|
|
34
|
+
export const DEAD_MODEL_IDS = new Set<string>([
|
|
35
|
+
"deepseek-v4-flash-free",
|
|
36
|
+
"x-preview-f-free",
|
|
37
|
+
"hy3-free",
|
|
38
|
+
"tencent/hy3:free",
|
|
39
|
+
"meituan/longcat-2.0-free",
|
|
40
|
+
]);
|
|
35
41
|
/**
|
|
36
42
|
* In-memory cache of currently active/available free models.
|
|
37
|
-
* Initialized with all
|
|
43
|
+
* Initialized with all 26 verified models for 0ms instant availability.
|
|
38
44
|
*/
|
|
39
45
|
let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
40
46
|
...m,
|
|
@@ -148,7 +154,9 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
|
|
|
148
154
|
maxTokens = 131_072;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
const isResponses =
|
|
157
|
+
const isResponses =
|
|
158
|
+
raw.id === "muse-spark-1.2-contributor-free" ||
|
|
159
|
+
raw.id === "muse-spark-1.3-contributor-free";
|
|
152
160
|
|
|
153
161
|
return {
|
|
154
162
|
id: raw.id,
|
|
@@ -349,6 +357,6 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
349
357
|
} catch (err) {
|
|
350
358
|
logDebug("Failed reading stale catalog cache", { error: String(err) });
|
|
351
359
|
}
|
|
352
|
-
// No valid cache — return in-memory static
|
|
360
|
+
// No valid cache — return in-memory static 26 (host will refresh if needed)
|
|
353
361
|
return aliveCatalog;
|
|
354
362
|
}
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
}
|
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
|
|
4
|
+
* Provides access to 26 free models (8 OpenCode Zen + 18 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";
|
|
@@ -258,7 +258,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
258
258
|
? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
|
|
259
259
|
: "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
|
|
260
260
|
ctx.ui?.notify?.(
|
|
261
|
-
`freeflow ready:
|
|
261
|
+
`freeflow ready: ${ALL_MODELS.length} free models via local proxy 127.0.0.1:28180. ${hint}`,
|
|
262
262
|
"info",
|
|
263
263
|
);
|
|
264
264
|
}
|
|
@@ -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
|
|
36
|
-
export function renewClient(clientId: string):
|
|
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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static model definitions and upstream routing catalogs for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Defines the
|
|
5
|
-
* - 8 OpenCode Zen models (
|
|
6
|
-
* -
|
|
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)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
|
|
@@ -31,35 +31,36 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
31
31
|
max: null,},
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
id: "
|
|
35
|
-
name: "
|
|
34
|
+
id: "muse-spark-1.3-contributor-free",
|
|
35
|
+
name: "Muse Spark 1.3 (1M)",
|
|
36
36
|
reasoning: true,
|
|
37
37
|
contextWindow: 1_048_576,
|
|
38
38
|
maxTokens: 131_072,
|
|
39
|
+
api: "openai-responses",
|
|
39
40
|
input: ["text", "image"],
|
|
40
41
|
thinkingLevelMap: {
|
|
41
42
|
off: null,
|
|
42
|
-
minimal: "
|
|
43
|
+
minimal: "minimal",
|
|
43
44
|
low: "low",
|
|
44
45
|
medium: "medium",
|
|
45
46
|
high: "high",
|
|
46
|
-
xhigh: "
|
|
47
|
+
xhigh: "xhigh",
|
|
47
48
|
max: null,},
|
|
48
49
|
},
|
|
49
50
|
{
|
|
50
|
-
id: "
|
|
51
|
-
name: "
|
|
51
|
+
id: "mimo-v2.5-free",
|
|
52
|
+
name: "MiMo V2.5 (1M)",
|
|
52
53
|
reasoning: true,
|
|
53
|
-
contextWindow:
|
|
54
|
-
maxTokens:
|
|
55
|
-
input: ["text"],
|
|
54
|
+
contextWindow: 1_048_576,
|
|
55
|
+
maxTokens: 131_072,
|
|
56
|
+
input: ["text", "image"],
|
|
56
57
|
thinkingLevelMap: {
|
|
57
58
|
off: null,
|
|
58
|
-
minimal: "
|
|
59
|
+
minimal: "low",
|
|
59
60
|
low: "low",
|
|
60
61
|
medium: "medium",
|
|
61
62
|
high: "high",
|
|
62
|
-
xhigh: "
|
|
63
|
+
xhigh: "high",
|
|
63
64
|
max: null,},
|
|
64
65
|
},
|
|
65
66
|
{
|
|
@@ -226,16 +227,6 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
226
227
|
thinkingFormat: "openrouter",
|
|
227
228
|
thinkingLevelMap: KILO_REASONING_MAP,
|
|
228
229
|
},
|
|
229
|
-
{
|
|
230
|
-
id: "tencent/hy3:free",
|
|
231
|
-
name: "Tencent Hy3 (Kilo)",
|
|
232
|
-
reasoning: true,
|
|
233
|
-
contextWindow: 262_144,
|
|
234
|
-
maxTokens: 128_000,
|
|
235
|
-
input: ["text"],
|
|
236
|
-
thinkingFormat: "openrouter",
|
|
237
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
238
|
-
},
|
|
239
230
|
{
|
|
240
231
|
id: "cohere/north-mini-code:free",
|
|
241
232
|
name: "North Mini Code",
|
|
@@ -304,16 +295,6 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
304
295
|
maxTokens: 8_192,
|
|
305
296
|
input: ["text", "image"],
|
|
306
297
|
},
|
|
307
|
-
{
|
|
308
|
-
id: "meituan/longcat-2.0-free",
|
|
309
|
-
name: "LongCat 2.0 (1M)",
|
|
310
|
-
reasoning: true,
|
|
311
|
-
contextWindow: 1_048_756,
|
|
312
|
-
maxTokens: 262_144,
|
|
313
|
-
input: ["text"],
|
|
314
|
-
thinkingFormat: "openrouter",
|
|
315
|
-
thinkingLevelMap: KILO_REASONING_MAP,
|
|
316
|
-
},
|
|
317
298
|
{
|
|
318
299
|
id: "minimax/minimax-m2.7:free",
|
|
319
300
|
name: "MiniMax M2.7 (free)",
|
|
@@ -384,14 +365,12 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
384
365
|
"north-mini-code": "cohere/north-mini-code:free",
|
|
385
366
|
"lfm-2.5": "liquid/lfm-2.5-2.6b:free",
|
|
386
367
|
"content-safety": "nvidia/nemotron-3.5-content-safety:free",
|
|
387
|
-
"longcat-2.0": "meituan/longcat-2.0-free",
|
|
388
368
|
"minimax-m2.7": "minimax/minimax-m2.7:free",
|
|
389
369
|
"minimax-m3": "minimax/minimax-m3:free",
|
|
390
370
|
"ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
|
|
391
371
|
"inkling": "thinkingmachines/inkling:free",
|
|
392
372
|
"inkling-small": "thinkingmachines/inkling-small:free",
|
|
393
373
|
// provider-prefixed short aliases (slash-normalized)
|
|
394
|
-
"hy3:free": "tencent/hy3:free",
|
|
395
374
|
"laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
|
|
396
375
|
"laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
|
|
397
376
|
"kilo-auto": "kilo-auto/free",
|
|
@@ -417,7 +396,7 @@ export const KILO_MODEL_IDS = new Set<string>([
|
|
|
417
396
|
]);
|
|
418
397
|
|
|
419
398
|
/**
|
|
420
|
-
* Combined list of all
|
|
399
|
+
* Combined list of all 26 static free models (canonical)
|
|
421
400
|
*/
|
|
422
401
|
export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS];
|
|
423
402
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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")
|
|
275
|
-
|
|
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",
|
package/src/relay-state.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
591
|
+
logWarn("relay state lock held — proceeding without lock", {
|
|
592
592
|
lock: RELAY_STATE_LOCK_FILE,
|
|
593
593
|
});
|
|
594
594
|
}
|
package/src/update-checker.ts
CHANGED
|
@@ -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 {}
|