pi-freeflow 1.9.2 → 1.9.5
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 +30 -0
- package/README.md +12 -11
- package/package.json +2 -4
- package/src/catalog.ts +12 -4
- package/src/client.ts +323 -313
- package/src/commands.ts +24 -23
- package/src/index.ts +2 -2
- package/src/models.ts +16 -37
- package/src/proxy.ts +7 -4
- package/src/relay.ts +4 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
4
|
|
|
5
|
+
## 1.9.5 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
### Changes
|
|
8
|
+
- **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:
|
|
9
|
+
- **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.
|
|
10
|
+
- **Removed** `hy3-free` (OpenCode Zen) — upstream no longer serves it (`Model hy3-free is not supported`).
|
|
11
|
+
- **Removed** `tencent/hy3:free` and `meituan/longcat-2.0-free` (KiloCode Gateway) — upstream free tier dropped them (model unavailable / sign-in required).
|
|
12
|
+
- Pruned IDs added to the dead-model filter so a stale disk cache cannot resurrect them.
|
|
13
|
+
|
|
14
|
+
### Validation
|
|
15
|
+
- `npx tsc --noEmit` clean, `npm test` full suite green on Windows.
|
|
16
|
+
|
|
17
|
+
## 1.9.4 - 2026-09-02
|
|
18
|
+
|
|
19
|
+
### Dependencies
|
|
20
|
+
- **Zero runtime dependencies.** Removed `undici@8.10.0` — `pi` (`0.84.4`) and `omp` (`18.1.3`) already bundle `undici` 6.x/7.x and expose `global fetch` with keep-alive pooling. `relayFetch` and proxy now use `global fetch` directly (`src/relay.ts` `Agent` + `canUseCustomDispatcher` + `dispatcher: agent` removed; `src/proxy.ts` dispatcher removed). Keeps thin `11.3k` + `298 tests` + `0 deps` compatible directly with `reference/pi` + `reference/oh-my-pi`.
|
|
21
|
+
|
|
22
|
+
### Validation
|
|
23
|
+
- `npx tsc --noEmit` clean, `npm test 298/298` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
|
|
24
|
+
|
|
25
|
+
## 1.9.3 - 2026-09-02
|
|
26
|
+
|
|
27
|
+
### Fixes
|
|
28
|
+
- **Windows console flood fixed.** Two Windows-only helpers flashed a visible `conhost`/`cmd` window on every daemon probe: `netstat -ano | findstr :28180` / `taskkill` in the stale-daemon replace path and `spawn(omp|npm, shell:true)` for `/freeflow update`. Both now use `windowsHide: true` (no-op on Linux/macOS) and `spawnWithProgress` was refactored to `Promise.withResolvers` to satisfy `ts-promise-with-resolvers`. Idle `pi` no longer spawns many visible consoles even after closing the terminal (detached daemon at `127.0.0.1:28180` survives by design; `beatOnce` 10s heartbeat now throttled 2s via `lastSpawnAt`).
|
|
29
|
+
- Daemon spawn now throttled per-process (2s) as a storm guard when `28180` is contended or blocked; the `ensuring` guard + `waitForReady 5s` already prevented tight loops.
|
|
30
|
+
|
|
31
|
+
### Validation
|
|
32
|
+
- `npx tsc --noEmit` clean, `npm test 279/279` on **Windows** (`omp/18.1.3`, `pi 0.84.4`) and **Linux `acerblue-local`** (`Ubuntu 6.8.0-138`, `node v22.23.2`, `pi 0.84.4`) via `/tmp/pi-freeflow-validation`. **`macOS not tested`** this cycle.
|
|
33
|
+
- Reporter `LOYINuts` issue #3 (`pi idle creates many sessions → force reboot`) — `grep -r rtk src` ∅ confirms `rtk` is an external global skill (`~/.agents/skills/rtk` → `Command::new("cmd")` without `CREATE_NO_WINDOW`), not `pi-freeflow`. After this fix, closing the terminal no longer leaves flashing zombies; kill via `netstat -ano | findstr :28180` → `taskkill /F /PID` or `/freeflow kill`.
|
|
34
|
+
|
|
5
35
|
## 1.9.2 - 2026-08-31
|
|
6
36
|
|
|
7
37
|
### Fixes
|
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
|
|
|
@@ -261,7 +262,7 @@ Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
|
261
262
|
|
|
262
263
|
### Design
|
|
263
264
|
|
|
264
|
-
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step.
|
|
265
|
+
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. Zero runtime dependencies — uses native Node.js global fetch. Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
265
266
|
|
|
266
267
|
Current size: about 11.3k lines including tests. The full suite (sandboxed, mocked network) and typecheck pass before every release — see CHANGELOG.md.
|
|
267
268
|
|
|
@@ -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
|
|
@@ -361,7 +362,7 @@ test/
|
|
|
361
362
|
|
|
362
363
|
#### Guidelines
|
|
363
364
|
|
|
364
|
-
- **Stay thin.**
|
|
365
|
+
- **Stay thin.** Zero runtime dependencies, no build step. If it belongs in the host (`pi-ai`), don't add it here.
|
|
365
366
|
- **Test what you touch.** Every `src/*.ts` has a matching `test/*.test.ts`. Add or update tests for your change.
|
|
366
367
|
- **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
|
|
367
368
|
- **One concern per PR.** Bug fix? One PR. New relay platform? Separate PR. Easier to review, faster to merge.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.5",
|
|
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",
|
|
@@ -57,7 +57,5 @@
|
|
|
57
57
|
"typescript": "^5.8.2",
|
|
58
58
|
"vitepress": "^1.6.4"
|
|
59
59
|
},
|
|
60
|
-
"dependencies": {
|
|
61
|
-
"undici": "^8.10.0"
|
|
62
|
-
}
|
|
60
|
+
"dependencies": {}
|
|
63
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
|
@@ -1,313 +1,323 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side daemon lifecycle for pi-freeflow.
|
|
3
|
-
*
|
|
4
|
-
* Every OMP/Pi session is a client. It attaches to the shared detached daemon
|
|
5
|
-
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
|
-
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
|
-
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
-
* once no client holds a live lease and no request has been proxied recently.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import type * as http from "node:http";
|
|
16
|
-
import {
|
|
17
|
-
DAEMON_HEARTBEAT_MS,
|
|
18
|
-
DAEMON_HEARTBEAT_MS_ENV,
|
|
19
|
-
DAEMON_READY_TIMEOUT_MS,
|
|
20
|
-
DAEMON_SPAWN_ENV,
|
|
21
|
-
HOST,
|
|
22
|
-
LEGACY_PORT,
|
|
23
|
-
NO_KILL_ENV,
|
|
24
|
-
PKG_VERSION,
|
|
25
|
-
PORT,
|
|
26
|
-
} from "./config.ts";
|
|
27
|
-
import { logInfo, logWarn } from "./logger.ts";
|
|
28
|
-
import {
|
|
29
|
-
getDaemonHealth,
|
|
30
|
-
getDaemonVersion,
|
|
31
|
-
isProxyAlive,
|
|
32
|
-
killPortHolder,
|
|
33
|
-
startProxy,
|
|
34
|
-
} from "./proxy.ts";
|
|
35
|
-
import { compareVersions } from "./update-checker.ts";
|
|
36
|
-
|
|
37
|
-
const CLIENT_ID = randomUUID();
|
|
38
|
-
|
|
39
|
-
let attachedPort = 0;
|
|
40
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
-
let heartbeatPort = 0;
|
|
42
|
-
let ensuring = false;
|
|
43
|
-
let fallbackServer: http.Server | null = null;
|
|
44
|
-
|
|
45
|
-
function isBunRuntime(): boolean {
|
|
46
|
-
return typeof (process.versions as unknown as Record<string, string>).bun === "string";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function daemonScriptPath(): string {
|
|
50
|
-
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getHeartbeatMs(): number {
|
|
54
|
-
const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
|
|
55
|
-
if (raw) {
|
|
56
|
-
const parsed = Number(raw);
|
|
57
|
-
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
58
|
-
}
|
|
59
|
-
return DAEMON_HEARTBEAT_MS;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isSpawnEnabled(): boolean {
|
|
63
|
-
return process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
type ControlResult = "ok" | "legacy" | "gone";
|
|
67
|
-
|
|
68
|
-
async function controlCall(
|
|
69
|
-
port: number,
|
|
70
|
-
endpoint: string,
|
|
71
|
-
payload: Record<string, string>,
|
|
72
|
-
): Promise<ControlResult> {
|
|
73
|
-
try {
|
|
74
|
-
const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify(payload),
|
|
78
|
-
signal: AbortSignal.timeout(1500),
|
|
79
|
-
});
|
|
80
|
-
return res.ok ? "ok" : "legacy";
|
|
81
|
-
} catch {
|
|
82
|
-
return "gone";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function startHeartbeat(port: number): void {
|
|
87
|
-
stopHeartbeatInternal();
|
|
88
|
-
heartbeatPort = port;
|
|
89
|
-
const ms = getHeartbeatMs();
|
|
90
|
-
heartbeatTimer = setInterval(() => {
|
|
91
|
-
void beatOnce(port);
|
|
92
|
-
}, ms);
|
|
93
|
-
try {
|
|
94
|
-
heartbeatTimer.unref();
|
|
95
|
-
} catch {}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function stopHeartbeatInternal(): void {
|
|
99
|
-
if (heartbeatTimer !== null) {
|
|
100
|
-
clearInterval(heartbeatTimer);
|
|
101
|
-
heartbeatTimer = null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function stopHeartbeat(): void {
|
|
106
|
-
const port = heartbeatPort;
|
|
107
|
-
stopHeartbeatInternal();
|
|
108
|
-
heartbeatPort = 0;
|
|
109
|
-
if (port) {
|
|
110
|
-
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
111
|
-
}
|
|
112
|
-
if (fallbackServer) {
|
|
113
|
-
try {
|
|
114
|
-
fallbackServer.close();
|
|
115
|
-
} catch {}
|
|
116
|
-
fallbackServer = null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function beatOnce(port: number): Promise<void> {
|
|
121
|
-
if (ensuring) return;
|
|
122
|
-
const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
|
|
123
|
-
if (result === "gone") {
|
|
124
|
-
void ensureDaemon();
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function attachTo(port: number): Promise<void> {
|
|
129
|
-
attachedPort = port;
|
|
130
|
-
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
131
|
-
if (result === "gone") {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
startHeartbeat(port);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
138
|
-
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
139
|
-
logInfo(
|
|
140
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
141
|
-
);
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
145
|
-
logInfo(
|
|
146
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
147
|
-
);
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
const health = await getDaemonHealth(port);
|
|
151
|
-
if (health === null || health.activeRequests === undefined) {
|
|
152
|
-
logInfo(
|
|
153
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
154
|
-
);
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
if (health.activeRequests > 0) {
|
|
158
|
-
logInfo(
|
|
159
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
160
|
-
);
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function killStaleDaemon(
|
|
167
|
-
port: number,
|
|
168
|
-
remoteVer: string,
|
|
169
|
-
what: string,
|
|
170
|
-
): Promise<boolean> {
|
|
171
|
-
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
172
|
-
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
173
|
-
remoteVer,
|
|
174
|
-
expected: PKG_VERSION,
|
|
175
|
-
});
|
|
176
|
-
await killPortHolder(port);
|
|
177
|
-
for (let i = 0; i < 10; i++) {
|
|
178
|
-
await new Promise<void>((r) => setTimeout(r, 200));
|
|
179
|
-
if (!(await isProxyAlive(port))) return true;
|
|
180
|
-
}
|
|
181
|
-
logInfo(
|
|
182
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
183
|
-
);
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Client-side daemon lifecycle for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Every OMP/Pi session is a client. It attaches to the shared detached daemon
|
|
5
|
+
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
|
+
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
|
+
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
+
* once no client holds a live lease and no request has been proxied recently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import type * as http from "node:http";
|
|
16
|
+
import {
|
|
17
|
+
DAEMON_HEARTBEAT_MS,
|
|
18
|
+
DAEMON_HEARTBEAT_MS_ENV,
|
|
19
|
+
DAEMON_READY_TIMEOUT_MS,
|
|
20
|
+
DAEMON_SPAWN_ENV,
|
|
21
|
+
HOST,
|
|
22
|
+
LEGACY_PORT,
|
|
23
|
+
NO_KILL_ENV,
|
|
24
|
+
PKG_VERSION,
|
|
25
|
+
PORT,
|
|
26
|
+
} from "./config.ts";
|
|
27
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
28
|
+
import {
|
|
29
|
+
getDaemonHealth,
|
|
30
|
+
getDaemonVersion,
|
|
31
|
+
isProxyAlive,
|
|
32
|
+
killPortHolder,
|
|
33
|
+
startProxy,
|
|
34
|
+
} from "./proxy.ts";
|
|
35
|
+
import { compareVersions } from "./update-checker.ts";
|
|
36
|
+
|
|
37
|
+
const CLIENT_ID = randomUUID();
|
|
38
|
+
|
|
39
|
+
let attachedPort = 0;
|
|
40
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let heartbeatPort = 0;
|
|
42
|
+
let ensuring = false;
|
|
43
|
+
let fallbackServer: http.Server | null = null;
|
|
44
|
+
|
|
45
|
+
function isBunRuntime(): boolean {
|
|
46
|
+
return typeof (process.versions as unknown as Record<string, string>).bun === "string";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function daemonScriptPath(): string {
|
|
50
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getHeartbeatMs(): number {
|
|
54
|
+
const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
|
|
55
|
+
if (raw) {
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
58
|
+
}
|
|
59
|
+
return DAEMON_HEARTBEAT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSpawnEnabled(): boolean {
|
|
63
|
+
return process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ControlResult = "ok" | "legacy" | "gone";
|
|
67
|
+
|
|
68
|
+
async function controlCall(
|
|
69
|
+
port: number,
|
|
70
|
+
endpoint: string,
|
|
71
|
+
payload: Record<string, string>,
|
|
72
|
+
): Promise<ControlResult> {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
signal: AbortSignal.timeout(1500),
|
|
79
|
+
});
|
|
80
|
+
return res.ok ? "ok" : "legacy";
|
|
81
|
+
} catch {
|
|
82
|
+
return "gone";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function startHeartbeat(port: number): void {
|
|
87
|
+
stopHeartbeatInternal();
|
|
88
|
+
heartbeatPort = port;
|
|
89
|
+
const ms = getHeartbeatMs();
|
|
90
|
+
heartbeatTimer = setInterval(() => {
|
|
91
|
+
void beatOnce(port);
|
|
92
|
+
}, ms);
|
|
93
|
+
try {
|
|
94
|
+
heartbeatTimer.unref();
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopHeartbeatInternal(): void {
|
|
99
|
+
if (heartbeatTimer !== null) {
|
|
100
|
+
clearInterval(heartbeatTimer);
|
|
101
|
+
heartbeatTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function stopHeartbeat(): void {
|
|
106
|
+
const port = heartbeatPort;
|
|
107
|
+
stopHeartbeatInternal();
|
|
108
|
+
heartbeatPort = 0;
|
|
109
|
+
if (port) {
|
|
110
|
+
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
111
|
+
}
|
|
112
|
+
if (fallbackServer) {
|
|
113
|
+
try {
|
|
114
|
+
fallbackServer.close();
|
|
115
|
+
} catch {}
|
|
116
|
+
fallbackServer = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function beatOnce(port: number): Promise<void> {
|
|
121
|
+
if (ensuring) return;
|
|
122
|
+
const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
|
|
123
|
+
if (result === "gone") {
|
|
124
|
+
void ensureDaemon();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function attachTo(port: number): Promise<void> {
|
|
129
|
+
attachedPort = port;
|
|
130
|
+
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
131
|
+
if (result === "gone") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
startHeartbeat(port);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
138
|
+
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
139
|
+
logInfo(
|
|
140
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
141
|
+
);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
145
|
+
logInfo(
|
|
146
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const health = await getDaemonHealth(port);
|
|
151
|
+
if (health === null || health.activeRequests === undefined) {
|
|
152
|
+
logInfo(
|
|
153
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
154
|
+
);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (health.activeRequests > 0) {
|
|
158
|
+
logInfo(
|
|
159
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
160
|
+
);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function killStaleDaemon(
|
|
167
|
+
port: number,
|
|
168
|
+
remoteVer: string,
|
|
169
|
+
what: string,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
172
|
+
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
173
|
+
remoteVer,
|
|
174
|
+
expected: PKG_VERSION,
|
|
175
|
+
});
|
|
176
|
+
await killPortHolder(port);
|
|
177
|
+
for (let i = 0; i < 10; i++) {
|
|
178
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
179
|
+
if (!(await isProxyAlive(port))) return true;
|
|
180
|
+
}
|
|
181
|
+
logInfo(
|
|
182
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
183
|
+
);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let lastSpawnAt = 0;
|
|
188
|
+
const SPAWN_THROTTLE_MS = 2_000;
|
|
189
|
+
|
|
190
|
+
function spawnDaemonProcess(): void {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
|
|
193
|
+
logWarn("daemon spawn throttled — recent spawn still pending");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
lastSpawnAt = now;
|
|
197
|
+
const script = daemonScriptPath();
|
|
198
|
+
const args = isBunRuntime() ? [script] : ["--experimental-strip-types", script];
|
|
199
|
+
try {
|
|
200
|
+
const child = spawn(process.execPath, args, {
|
|
201
|
+
detached: true,
|
|
202
|
+
stdio: "ignore",
|
|
203
|
+
windowsHide: true,
|
|
204
|
+
});
|
|
205
|
+
child.unref();
|
|
206
|
+
child.on("error", (err) => {
|
|
207
|
+
logWarn("daemon spawn failed", { error: String(err) });
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
logWarn("daemon spawn failed", { error: String(e) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
|
|
215
|
+
const deadline = Date.now() + timeoutMs;
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
if (await isProxyAlive(port)) return true;
|
|
218
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function probeAndMaybeReplace(
|
|
224
|
+
port: number,
|
|
225
|
+
label: string,
|
|
226
|
+
): Promise<number | null> {
|
|
227
|
+
if (!(await isProxyAlive(port))) return null;
|
|
228
|
+
const ver = await getDaemonVersion(port);
|
|
229
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
230
|
+
if (await killStaleDaemon(port, ver, label)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (await isProxyAlive(port)) return port;
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
return port;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Ensure a proxy daemon is running and attach this client to it.
|
|
241
|
+
* Returns the port the caller should use for its ProviderConfig.
|
|
242
|
+
*/
|
|
243
|
+
export async function ensureDaemon(): Promise<number> {
|
|
244
|
+
if (ensuring) return attachedPort || PORT;
|
|
245
|
+
ensuring = true;
|
|
246
|
+
try {
|
|
247
|
+
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
248
|
+
if (primary !== null) {
|
|
249
|
+
await attachTo(primary);
|
|
250
|
+
return primary;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (PORT !== LEGACY_PORT) {
|
|
254
|
+
const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
|
|
255
|
+
if (legacy !== null) {
|
|
256
|
+
await attachTo(legacy);
|
|
257
|
+
return legacy;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (isSpawnEnabled()) {
|
|
262
|
+
spawnDaemonProcess();
|
|
263
|
+
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
264
|
+
if (ready) {
|
|
265
|
+
const ver = await getDaemonVersion(PORT);
|
|
266
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
267
|
+
if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
|
|
268
|
+
spawnDaemonProcess();
|
|
269
|
+
const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
270
|
+
if (retryReady) {
|
|
271
|
+
await attachTo(PORT);
|
|
272
|
+
return PORT;
|
|
273
|
+
}
|
|
274
|
+
} else if (await isProxyAlive(PORT)) {
|
|
275
|
+
await attachTo(PORT);
|
|
276
|
+
return PORT;
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
await attachTo(PORT);
|
|
280
|
+
return PORT;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
logWarn("daemon spawn did not become ready — is the port blocked?");
|
|
284
|
+
}
|
|
285
|
+
return PORT;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const r = await startProxy();
|
|
290
|
+
if (r.server) fallbackServer = r.server;
|
|
291
|
+
const port = r.port;
|
|
292
|
+
await attachTo(port);
|
|
293
|
+
return port;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
logWarn("in-process fallback bind failed", { error: String(e) });
|
|
296
|
+
return PORT;
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
ensuring = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function getClientPort(): number {
|
|
304
|
+
return attachedPort || PORT;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function getClientId(): string {
|
|
308
|
+
return CLIENT_ID;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function _resetClientForTest(): void {
|
|
312
|
+
stopHeartbeatInternal();
|
|
313
|
+
heartbeatPort = 0;
|
|
314
|
+
attachedPort = 0;
|
|
315
|
+
ensuring = false;
|
|
316
|
+
lastSpawnAt = 0;
|
|
317
|
+
if (fallbackServer) {
|
|
318
|
+
try {
|
|
319
|
+
fallbackServer.close();
|
|
320
|
+
} catch {}
|
|
321
|
+
fallbackServer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -69,30 +69,31 @@ function spawnWithProgress(
|
|
|
69
69
|
args: string[],
|
|
70
70
|
ctx: ExtensionContext,
|
|
71
71
|
): Promise<number> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
91
|
-
} catch (e) {
|
|
92
|
-
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
72
|
+
const { promise, resolve } = Promise.withResolvers<number>();
|
|
73
|
+
try {
|
|
74
|
+
const child = spawn(cmd, args, {
|
|
75
|
+
shell: process.platform === "win32",
|
|
76
|
+
stdio: "pipe",
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
80
|
+
const s = String(d).trim();
|
|
81
|
+
if (s) ctx.ui.notify(s, "info");
|
|
82
|
+
});
|
|
83
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
84
|
+
const s = String(d).trim();
|
|
85
|
+
if (s) ctx.ui.notify(s, "info");
|
|
86
|
+
});
|
|
87
|
+
child.on("error", (err: Error) => {
|
|
88
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
93
89
|
resolve(1);
|
|
94
|
-
}
|
|
95
|
-
|
|
90
|
+
});
|
|
91
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
94
|
+
resolve(1);
|
|
95
|
+
}
|
|
96
|
+
return promise;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
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
|
|
@@ -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
|
}
|
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
|
@@ -32,7 +32,7 @@ import { isDebugEnabled, log } from "./logger.ts";
|
|
|
32
32
|
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
33
33
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
34
34
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { relayFetch } from "./relay.ts";
|
|
36
36
|
import { getActiveRelayState } from "./relay-state.ts";
|
|
37
37
|
import { pipeUpstreamStream } from "./stream-pipe.ts";
|
|
38
38
|
import type { Upstream } from "./types.ts";
|
|
@@ -182,7 +182,11 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
182
182
|
try {
|
|
183
183
|
if (process.platform === "win32") {
|
|
184
184
|
try {
|
|
185
|
-
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
185
|
+
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: 3000,
|
|
188
|
+
windowsHide: true,
|
|
189
|
+
}) as string;
|
|
186
190
|
for (const line of out.split("\n")) {
|
|
187
191
|
if (!line.includes("LISTENING")) continue;
|
|
188
192
|
if (!line.includes(`:${port}`)) continue;
|
|
@@ -191,7 +195,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
191
195
|
if (!pid || !/^\d+$/.test(pid)) continue;
|
|
192
196
|
// Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
|
|
193
197
|
if (Number(pid) === process.pid) continue; // never self-kill
|
|
194
|
-
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
198
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
|
|
195
199
|
return true;
|
|
196
200
|
}
|
|
197
201
|
} catch {}
|
|
@@ -641,7 +645,6 @@ export function startProxy(
|
|
|
641
645
|
headers: fwd,
|
|
642
646
|
body: directBody.length > 0 ? directBody : undefined,
|
|
643
647
|
signal: controller.signal,
|
|
644
|
-
...(canUseCustomDispatcher ? { dispatcher: agent } : {}),
|
|
645
648
|
} as unknown as RequestInit);
|
|
646
649
|
clearTimeout(timeoutId);
|
|
647
650
|
res.off("close", onClientClose);
|
package/src/relay.ts
CHANGED
|
@@ -4,20 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
|
-
import { Agent } from "undici";
|
|
8
7
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
9
|
-
|
|
10
|
-
export const agent = new Agent({ keepAliveTimeout: 30_000 });
|
|
11
|
-
/**
|
|
12
|
-
* Node's global fetch runs on the node-process-bundled undici. Node 22/23
|
|
13
|
-
* bundle undici 6.x/7.x whose dispatcher interface rejects an npm-undici 8.x
|
|
14
|
-
* Agent passed as `dispatcher` (`invalid onRequestStart method`). Only attach
|
|
15
|
-
* the custom dispatcher when the bundled undici major matches (Node 25+);
|
|
16
|
-
* otherwise fall back to the built-in global dispatcher, which still pools
|
|
17
|
-
* keep-alive connections, so the relay proxy works on Node 22/23 too.
|
|
18
|
-
*/
|
|
19
|
-
const bundledUndiciMajor = Number((process.versions.undici ?? "0").split(".")[0]);
|
|
20
|
-
export const canUseCustomDispatcher = bundledUndiciMajor >= 8;
|
|
21
8
|
import {
|
|
22
9
|
getActiveRelayState,
|
|
23
10
|
getOrderedRelayUrls,
|
|
@@ -68,7 +55,7 @@ export async function relayFetch(
|
|
|
68
55
|
|
|
69
56
|
if (!relayState.enabled) {
|
|
70
57
|
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
71
|
-
return fetch(url,
|
|
58
|
+
return fetch(url, opts as unknown as RequestInit);
|
|
72
59
|
}
|
|
73
60
|
const candidates = getOrderedRelayUrls();
|
|
74
61
|
|
|
@@ -76,7 +63,7 @@ export async function relayFetch(
|
|
|
76
63
|
// Empty pool: skip straight to upstream instead of logging a misleading
|
|
77
64
|
// "relays bypassed/exhausted" WARN on every request.
|
|
78
65
|
log("debug", `relayFetch: direct (empty relay pool) -> ${url}`, undefined, rid);
|
|
79
|
-
return fetch(url,
|
|
66
|
+
return fetch(url, opts as unknown as RequestInit);
|
|
80
67
|
}
|
|
81
68
|
|
|
82
69
|
let lastResponse: Response | null = null;
|
|
@@ -145,7 +132,7 @@ export async function relayFetch(
|
|
|
145
132
|
}
|
|
146
133
|
|
|
147
134
|
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
148
|
-
const res = await fetch(targetUrl, { ...opts, headers, signal
|
|
135
|
+
const res = await fetch(targetUrl, { ...opts, headers, signal } as unknown as RequestInit);
|
|
149
136
|
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
150
137
|
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
151
138
|
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
@@ -251,7 +238,7 @@ export async function relayFetch(
|
|
|
251
238
|
directHeaders.set("host", u.host);
|
|
252
239
|
directHeaders.set("x-request-id", rid);
|
|
253
240
|
|
|
254
|
-
const directRes = await fetch(url, { ...opts, headers: directHeaders
|
|
241
|
+
const directRes = await fetch(url, { ...opts, headers: directHeaders } as unknown as RequestInit);
|
|
255
242
|
// lastResponse holds an unread body that would otherwise leak its socket
|
|
256
243
|
// until GC; the salvage path below still needs it, so only cancel here.
|
|
257
244
|
lastResponse?.body?.cancel().catch(() => {});
|