pi-freeflow 1.38.0 → 1.38.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +13 -14
- package/package.json +2 -1
- package/src/catalog.ts +23 -7
- package/src/client.ts +102 -21
- package/src/cline-accounts.ts +41 -5
- package/src/commands.ts +89 -61
- package/src/daemon.ts +146 -144
- package/src/index.ts +2 -2
- package/src/logger.ts +491 -367
- package/src/models.ts +143 -43
- package/src/opencode-fingerprint.ts +2 -1
- package/src/proxy.ts +5 -0
- package/src/relay-state.ts +1 -1
- package/src/relay.ts +32 -0
- package/src/stream-pipe.ts +188 -45
- package/src/tool-args.ts +51 -14
- package/src/tool-translation.ts +48 -20
- package/src/updater.ts +158 -155
- package/src/upstream-health.ts +32 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.38.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add Exo to the OpenCode free tier — a live preview model with chat, tools, reasoning, and image input that the official free lineup lists.
|
|
8
|
+
- Add GLM-5.3 Flash and LL Alpha to the OpenCode free tier, and retire Step 5 Preview — it is gone from the Zen lineup and demoted to paid on the Kilo lane, so those entries can no longer be selected.
|
|
9
|
+
|
|
10
|
+
## 1.38.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Add the Nemotron 3.5 Super VL free model and enable the max thinking level on Cat Jam — both live-verified through the proxy; align the README and docs with the shipped catalog.
|
|
15
|
+
- The first-run welcome now names the local proxy port actually in use instead of the default port.
|
|
16
|
+
- Streaming requests now pass through replies that arrive in a non-streaming format instead of dropping the answer, concurrent startups share a single connection attempt instead of racing, and a foreign process squatting the local proxy port is no longer mistaken for the daemon we own.
|
|
17
|
+
- Following logs now reads only the lines appended since the last tick instead of re-reading the whole log file every second, so `/freeflow logs --follow` stays light even on large logs.
|
|
18
|
+
- Fix a batch of verified edge cases: non-ASCII streaming text is no longer corrupted when a character spans network chunks, assistant text stays visible on the responses path when it is served from a chat-only upstream, tool-argument repair also works on strict tool schemas, a relay that rejects our auth rolls to the next egress instead of being marked healthy, the update flow restarts the proxy it is actually attached to, and daemon/health state handling no longer loops or reads stale data.
|
|
19
|
+
|
|
3
20
|
## 1.38.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 37 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
|
|
4
4
|
|
|
5
5
|
Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
|
|
|
16
16
|
|
|
17
17
|
| Feature | What it does | Cost |
|
|
18
18
|
| :--- | :--- | :--- |
|
|
19
|
-
| **
|
|
19
|
+
| **37 free models** | 14 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
|
|
20
20
|
| **Relay pool** | Route requests through your own Cloudflare Workers, Deno Deploy, and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
|
|
21
21
|
| **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
|
|
22
22
|
| **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
|
|
@@ -197,32 +197,35 @@ The same command set works identically in OMP and Pi:
|
|
|
197
197
|
|
|
198
198
|
---
|
|
199
199
|
|
|
200
|
-
###
|
|
200
|
+
### 37 models, one command
|
|
201
201
|
|
|
202
202
|
```bash
|
|
203
203
|
/model → freeflow → pick
|
|
204
204
|
```
|
|
205
205
|
|
|
206
206
|
Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
|
|
207
|
-
#### OpenCode Zen (
|
|
207
|
+
#### OpenCode Zen (14 models), Responses and Chat API
|
|
208
208
|
|
|
209
209
|
Good defaults for long coding sessions and agentic work.
|
|
210
210
|
|
|
211
211
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
212
212
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
213
|
-
| `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low …
|
|
213
|
+
| `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low … max` | ❌ |
|
|
214
214
|
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
215
215
|
| `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
216
216
|
| `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
217
217
|
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
218
|
+
| `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
218
219
|
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
219
220
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
220
221
|
| `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
|
|
221
222
|
| `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
|
|
222
|
-
| `
|
|
223
|
+
| `glm-5.3-flash-free` | Zhipu AI | **1M** (1.000.000) | **32K** (32.000) | `minimal … max` | ✅ |
|
|
224
|
+
| `ll-alpha-free` | Undisclosed | **262K** (262.144) | **64K** (65.536) | `low … max` | ❌ |
|
|
225
|
+
| `exo-free` | Undisclosed | **1M** (1.048.576) | **131K** (131.072) | `high` | ✅ |
|
|
223
226
|
| `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
|
|
224
227
|
|
|
225
|
-
#### KiloCode Gateway (
|
|
228
|
+
#### KiloCode Gateway (13 models), OpenRouter compatible
|
|
226
229
|
|
|
227
230
|
Keyless access. Short aliases work for every row (the full ID is in parentheses).
|
|
228
231
|
|
|
@@ -240,7 +243,6 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
|
|
|
240
243
|
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
241
244
|
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
242
245
|
| `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
|
|
243
|
-
| `step-5-preview:kilo` (`stepfun/...:free`) | StepFun | **1M** (1.000.000) | **64K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
244
246
|
| `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
245
247
|
|
|
246
248
|
\* 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.
|
|
@@ -258,13 +260,11 @@ Each account can only be saved once: an extra login for an account you already h
|
|
|
258
260
|
| `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
259
261
|
| `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low/high/max` | ✅ |
|
|
260
262
|
#### OVHcloud AI Endpoints (8 models), OpenAI compatible
|
|
261
|
-
#### OVHcloud AI Endpoints (9 models), OpenAI compatible
|
|
262
263
|
|
|
263
264
|
Anonymous access — no account, no key, nothing to sign in to. Direct only (never through the relay pool), because OVH has no per-IP gate for relays to route around. OVH rate-limits anonymous callers per IP, so a burst of parallel subagents can hit `429 API rate limit exceeded`; the model list itself never costs anything.
|
|
264
265
|
|
|
265
266
|
Pick them with the slash-free short name (the full ID is in parentheses). Only `low` and `high` thinking levels are offered, because those are the two OVH confirmed.
|
|
266
267
|
All eight talk the normal chat API. Hosts that prefer the responses API work too — the proxy maps that shape to chat and maps the answer back, so tool calls and streamed output behave exactly as they do on the other providers.
|
|
267
|
-
All nine talk the normal chat API. Hosts that prefer the responses API work too — the proxy maps that shape to chat and maps the answer back, so tool calls and streamed output behave exactly as they do on the other providers.
|
|
268
268
|
|
|
269
269
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
270
270
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
@@ -274,7 +274,7 @@ All nine talk the normal chat API. Hosts that prefer the responses API work too
|
|
|
274
274
|
| `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
275
275
|
| `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
276
276
|
| `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **32K** (32.768) | `low/high` | ✅ |
|
|
277
|
-
| `mistral-
|
|
277
|
+
| `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **16K** (16.384) | ❌ | ✅ |
|
|
278
278
|
| `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **16K** (16.384) | ❌ | ❌ |
|
|
279
279
|
|
|
280
280
|
---
|
|
@@ -295,7 +295,7 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
|
295
295
|
|
|
296
296
|
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step and one runtime dependency (`undici`). Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
297
297
|
|
|
298
|
-
About
|
|
298
|
+
About 45k lines including tests: `src/` is roughly 17.7k across 27 files, and the 82 test files add roughly 27k more. The full suite (sandboxed, network-mocked) and typecheck pass before every release. See CHANGELOG.md.
|
|
299
299
|
|
|
300
300
|
---
|
|
301
301
|
|
|
@@ -368,9 +368,8 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
368
368
|
#### Project structure
|
|
369
369
|
|
|
370
370
|
```
|
|
371
|
-
├── models.ts # 34-model catalog definitions
|
|
372
|
-
├── index.ts # extension entry, lifecycle hooks
|
|
373
371
|
├── models.ts # 37-model catalog definitions
|
|
372
|
+
├── index.ts # extension entry, lifecycle hooks
|
|
374
373
|
├── catalog.ts # model catalog cache (24h disk)
|
|
375
374
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
376
375
|
├── client.ts # session-side daemon attach/spawn and heartbeat
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.38.
|
|
4
|
+
"version": "1.38.2",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@changesets/cli": "^2.27.0",
|
|
56
56
|
"@types/node": "^22.13.9",
|
|
57
|
+
"js-yaml": "^4.1.0",
|
|
57
58
|
"typescript": "^5.8.2",
|
|
58
59
|
"vitepress": "^1.6.4"
|
|
59
60
|
},
|
package/src/catalog.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dynamic catalog discovery, caching, and model enrichment for pi-freeflow
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
4
|
+
* Provides 24-hour atomic disk caching with safe offline fallback to verified static definitions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
CATALOG_CACHE_FILE,
|
|
12
12
|
CATALOG_CACHE_TTL_MS,
|
|
13
13
|
CATALOG_REFRESH_TIMEOUT_MS,
|
|
14
|
-
KILO_CHAT_URL,
|
|
15
14
|
OPENCODE_API_URL,
|
|
16
15
|
opencodeHeaders,
|
|
17
16
|
} from "./config.ts";
|
|
@@ -44,8 +43,14 @@ import type {
|
|
|
44
43
|
* - inclusionai/ling-3.0-flash-fin:free: gone from Kilo (2026-10-01) — keyless chat 404
|
|
45
44
|
* "The requested model ... does not exist".
|
|
46
45
|
* - mimo-v2.5-free: decommissioned upstream (2026-10-07) — HTTP 401 "Model mimo-v2.5-free is not supported".
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Re-probed 2026-10-16: 410 "Model mimo-v2.5-free has been deprecated. Use
|
|
47
|
+
* mimo-v2.6-flash-free instead." The docs still list it as free; they are stale.
|
|
48
|
+
* - exo-free: dead-listed 2026-10-09 (410 "Model exo-free has been deprecated")
|
|
49
|
+
* but revived upstream on 2026-10-16 — back on the official free lineup and
|
|
50
|
+
* answering live through the proxy (chat 200 with tools, stream, reasoning,
|
|
51
|
+
* vision; dual-canary recall at ~520K tokens; a 1.2M-word prompt draws the
|
|
52
|
+
* empty-bodied 400). Re-registered statically the same day: do NOT add this
|
|
53
|
+
* id back to this set unless it dies again.
|
|
49
54
|
* - fledge-alpha-free: withdrawn upstream (2026-10-09) — absent from the live
|
|
50
55
|
* Zen list, chat 401 "Model fledge-alpha-free is not supported".
|
|
51
56
|
* - inclusionai/ling-3.0-flash-sante:free: demoted to paid on Kilo (2026-10-09) —
|
|
@@ -95,7 +100,6 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
95
100
|
"ling-3.0-flash-fin-free",
|
|
96
101
|
"inclusionai/ling-3.0-flash-fin:free",
|
|
97
102
|
"mimo-v2.5-free",
|
|
98
|
-
"exo-free",
|
|
99
103
|
"stealth/space-bunny-alpha",
|
|
100
104
|
"qwen/qwen3.8-27b:free",
|
|
101
105
|
"nvidia/nemotron-3.5-content-safety:free",
|
|
@@ -111,6 +115,15 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
111
115
|
"inclusionai/ling-3.1-flash",
|
|
112
116
|
// ovh-free/Mistral-7B-Instruct-v0.3: removed upstream by OVH (2026-10-15) — answers 404 no route matched.
|
|
113
117
|
"ovh-free/Mistral-7B-Instruct-v0.3",
|
|
118
|
+
// step-5-preview-free: removed from the Zen free docs (2026-10-16 drift,
|
|
119
|
+
// v1.19.2) and the endpoint answers 400 "Model is unavailable" on both the
|
|
120
|
+
// chat and the responses shape — the same retirement signature as
|
|
121
|
+
// deepseek-v4-flash-free.
|
|
122
|
+
"step-5-preview-free",
|
|
123
|
+
// stepfun/step-5-preview-free: demoted to paid on Kilo (2026-10-16) —
|
|
124
|
+
// answers 401 PAID_MODEL_AUTH_REQUIRED while the sibling z-ai/glm-5.3-flash
|
|
125
|
+
// answers 200 keyless with the same request shape.
|
|
126
|
+
"stepfun/step-5-preview-free",
|
|
114
127
|
]);
|
|
115
128
|
/**
|
|
116
129
|
* Free-tier allowlist for anything entering the picker via network or stale disk.
|
|
@@ -150,7 +163,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
|
|
|
150
163
|
}
|
|
151
164
|
/**
|
|
152
165
|
* In-memory cache of currently active/available free models.
|
|
153
|
-
* Initialized with all
|
|
166
|
+
* Initialized with all 37 verified models for 0ms instant availability.
|
|
154
167
|
*/
|
|
155
168
|
let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
156
169
|
...m,
|
|
@@ -353,7 +366,10 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
353
366
|
}
|
|
354
367
|
|
|
355
368
|
/**
|
|
356
|
-
* Refresh free model catalog from OpenCode Zen
|
|
369
|
+
* Refresh the free model catalog from the OpenCode Zen listing. Only Zen is
|
|
370
|
+
* fetched — Kilo ids stay static (merged from ALL_MODELS; the cache's kilo
|
|
371
|
+
* field is preserved but never re-fetched), so Kilo entries change only in
|
|
372
|
+
* src/models.ts.
|
|
357
373
|
* Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
|
|
358
374
|
* catalogs; a 304 Not Modified response skips merge and returns the in-memory
|
|
359
375
|
* catalog unchanged. Falls back gracefully to cached or static models if network
|
package/src/client.ts
CHANGED
|
@@ -62,6 +62,11 @@ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
62
62
|
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
|
63
63
|
let heartbeatPort = 0;
|
|
64
64
|
let ensuring = false;
|
|
65
|
+
/**
|
|
66
|
+
* In-flight ensureDaemon() attempt. Every concurrent caller awaits this same
|
|
67
|
+
* promise instead of guessing a port; ensureDaemon clears it on settle.
|
|
68
|
+
*/
|
|
69
|
+
let ensureInFlight: Promise<number> | null = null;
|
|
65
70
|
let fallbackServer: http.Server | null = null;
|
|
66
71
|
/** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
|
|
67
72
|
let busySince = 0;
|
|
@@ -434,14 +439,25 @@ export function _resetRecoveryForTest(): void {
|
|
|
434
439
|
/** Recovery-decision shape; canonical definition lives in health.ts. */
|
|
435
440
|
export type HealthForRecovery = DaemonHealthSnapshot;
|
|
436
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Older-version guard: only a daemon strictly older than this build is worth
|
|
444
|
+
* recovering. shouldReplaceDaemon deliberately leaves a NEWER daemon running,
|
|
445
|
+
* so recovering from one could never succeed (watchdog → recover → refused →
|
|
446
|
+
* reattach → watchdog, until the breaker parks into the in-process fallback).
|
|
447
|
+
* Null/undefined version (pre-version daemon) is not older on its own.
|
|
448
|
+
*/
|
|
449
|
+
function isOlderDaemonVersion(version: string | null | undefined): boolean {
|
|
450
|
+
return version != null && compareVersions(version, PKG_VERSION) < 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
437
453
|
/**
|
|
438
454
|
* Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
|
|
439
|
-
* serves
|
|
455
|
+
* serves an older version, or reports a degraded failed-SSE window. Missing
|
|
440
456
|
* sseDegraded (pre-window daemon) never triggers on its own.
|
|
441
457
|
*/
|
|
442
458
|
export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
|
|
443
459
|
if (health === null) return true;
|
|
444
|
-
if (health.version
|
|
460
|
+
if (isOlderDaemonVersion(health.version)) return true;
|
|
445
461
|
if (health.sseDegraded === true) return true;
|
|
446
462
|
return false;
|
|
447
463
|
}
|
|
@@ -535,7 +551,7 @@ export async function watchdogCheck(port: number): Promise<void> {
|
|
|
535
551
|
return;
|
|
536
552
|
}
|
|
537
553
|
if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
|
|
538
|
-
void triggerRecovery(port, health === null ? "daemon gone" : health.version
|
|
554
|
+
void triggerRecovery(port, health === null ? "daemon gone" : isOlderDaemonVersion(health.version) ? `stale v${health.version}` : "failed-SSE degraded").catch((e) => logWarn("watchdog recovery failed", { error: String(e) }));
|
|
539
555
|
}
|
|
540
556
|
|
|
541
557
|
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
@@ -616,22 +632,72 @@ export type DarkHolderProbe = {
|
|
|
616
632
|
alive: boolean;
|
|
617
633
|
/** True when a TCP connection to the port succeeds (something holds it). */
|
|
618
634
|
bound: boolean;
|
|
619
|
-
/**
|
|
620
|
-
|
|
635
|
+
/**
|
|
636
|
+
* True when GET /_health answers in OUR daemon's shape. A stranger squatting
|
|
637
|
+
* the loopback port can answer `{}` or arbitrary JSON: that is not a daemon
|
|
638
|
+
* of ours, whatever it claims.
|
|
639
|
+
*/
|
|
640
|
+
healthOurs: boolean;
|
|
621
641
|
};
|
|
622
642
|
|
|
643
|
+
/**
|
|
644
|
+
* True when a /_health response body carries OUR daemon's shape: a JSON object
|
|
645
|
+
* whose numeric `port` names the port it is bound to (src/health.ts builds it
|
|
646
|
+
* from the listening socket). Anything else is a stranger on the loopback port
|
|
647
|
+
* — attaching to it would route the user's prompts into a process we do not
|
|
648
|
+
* own, so it must never be read as a daemon.
|
|
649
|
+
*/
|
|
650
|
+
export function isOurDaemonHealthBody(port: number, body: unknown): boolean {
|
|
651
|
+
if (!body || typeof body !== "object") return false;
|
|
652
|
+
const reported = (body as { port?: unknown }).port;
|
|
653
|
+
return typeof reported === "number" && reported === port;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Probe whatever holds `port` for the three facts the reclaim decision needs.
|
|
658
|
+
* Ordered cheap-to-expensive and short-circuiting exactly like the inline
|
|
659
|
+
* sequence it replaces: a models-answering daemon is live, a refused port is
|
|
660
|
+
* free, and only a bound-but-model-silent holder is asked for /_health.
|
|
661
|
+
*/
|
|
662
|
+
export async function probeDarkHolder(port: number): Promise<DarkHolderProbe> {
|
|
663
|
+
if (await isProxyAlive(port)) return { alive: true, bound: true, healthOurs: false };
|
|
664
|
+
if (!(await isPortBound(port))) return { alive: false, bound: false, healthOurs: false };
|
|
665
|
+
return { alive: false, bound: true, healthOurs: await probeOurHealth(port) };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Fetch /_health and keep the verdict only when the body is OUR daemon's
|
|
670
|
+
* shape. Same request and budget as proxy.getDaemonHealth, minus its snapshot
|
|
671
|
+
* mapping — which drops `port`, the one field that tells our daemon apart
|
|
672
|
+
* from a stranger answering arbitrary JSON.
|
|
673
|
+
*/
|
|
674
|
+
async function probeOurHealth(port: number): Promise<boolean> {
|
|
675
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
676
|
+
try {
|
|
677
|
+
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
678
|
+
signal: AbortSignal.timeout(2500),
|
|
679
|
+
});
|
|
680
|
+
if (!res.ok) return false;
|
|
681
|
+
return isOurDaemonHealthBody(port, await res.json());
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
623
687
|
/**
|
|
624
688
|
* Pure decision: reclaim a base port that is actually held (TCP connect
|
|
625
|
-
* succeeds) yet answers neither /v1/models nor /_health. A live
|
|
626
|
-
* stale, busy, even mid-stream — always answers both from its event
|
|
627
|
-
* a dark holder cannot be serving traffic and replacing it cannot
|
|
628
|
-
* real stream. A FREE port is not a dark holder: treating it as
|
|
629
|
-
* blind ~2s kill-and-poll on every cold start (issue #52).
|
|
689
|
+
* succeeds) yet answers neither /v1/models nor /_health in OUR shape. A live
|
|
690
|
+
* daemon — stale, busy, even mid-stream — always answers both from its event
|
|
691
|
+
* loop, so a dark holder cannot be serving traffic and replacing it cannot
|
|
692
|
+
* interrupt a real stream. A FREE port is not a dark holder: treating it as
|
|
693
|
+
* one cost a blind ~2s kill-and-poll on every cold start (issue #52). A holder
|
|
694
|
+
* answering /_health with anything but our shape (a stranger's `{}`) is dark:
|
|
695
|
+
* it is not a daemon of ours, so it is reclaimed rather than attached to.
|
|
630
696
|
*/
|
|
631
697
|
export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
|
|
632
698
|
if (probe.alive) return false;
|
|
633
699
|
if (!probe.bound) return false;
|
|
634
|
-
if (probe.
|
|
700
|
+
if (probe.healthOurs) return false;
|
|
635
701
|
return true;
|
|
636
702
|
}
|
|
637
703
|
|
|
@@ -643,18 +709,17 @@ export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
|
|
|
643
709
|
* stale-replace path owns versioned daemons.
|
|
644
710
|
*/
|
|
645
711
|
async function reclaimDarkHolder(port: number): Promise<boolean> {
|
|
646
|
-
const
|
|
647
|
-
if (alive) return true;
|
|
648
|
-
if (!
|
|
649
|
-
|
|
650
|
-
if (!shouldReclaimDarkHolder({ alive, bound: true, health })) return true;
|
|
712
|
+
const probe = await probeDarkHolder(port);
|
|
713
|
+
if (probe.alive) return true;
|
|
714
|
+
if (!probe.bound) return false; // port is free — spawn fresh
|
|
715
|
+
if (!shouldReclaimDarkHolder(probe)) return true;
|
|
651
716
|
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
652
717
|
logInfo(
|
|
653
|
-
`proxy on :${port} answers neither health nor models — leaving it alone (replacement disabled by env)`,
|
|
718
|
+
`proxy on :${port} answers neither our health nor models — leaving it alone (replacement disabled by env)`,
|
|
654
719
|
);
|
|
655
720
|
return false;
|
|
656
721
|
}
|
|
657
|
-
logWarn(`proxy on :${port} holds the port but answers nothing — replacing dark holder`);
|
|
722
|
+
logWarn(`proxy on :${port} holds the port but answers nothing of ours — replacing dark holder`);
|
|
658
723
|
await killPortHolder(port);
|
|
659
724
|
for (let i = 0; i < 10; i++) {
|
|
660
725
|
await new Promise<void>((r) => setTimeout(r, 200));
|
|
@@ -982,9 +1047,27 @@ async function probeAndMaybeReplace(
|
|
|
982
1047
|
* Returns the port the caller should use for its ProviderConfig.
|
|
983
1048
|
*/
|
|
984
1049
|
export async function ensureDaemon(): Promise<number> {
|
|
1050
|
+
if (ensureInFlight) return ensureInFlight;
|
|
985
1051
|
if (ensuring) return attachedPort || PORT;
|
|
986
1052
|
ensuring = true;
|
|
1053
|
+
const attempt = ensureDaemonAttempt();
|
|
1054
|
+
ensureInFlight = attempt;
|
|
987
1055
|
try {
|
|
1056
|
+
return await attempt;
|
|
1057
|
+
} finally {
|
|
1058
|
+
if (ensureInFlight === attempt) ensureInFlight = null;
|
|
1059
|
+
ensuring = false;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* One ensure/attach attempt: probe → attach → spawn → dark-holder reclaim →
|
|
1065
|
+
* in-process fallback. Split out of ensureDaemon so concurrent callers share
|
|
1066
|
+
* the same promise instead of reading a half-settled port. Keeps `ensuring`
|
|
1067
|
+
* latched for the whole attempt so the heartbeat/watchdog/recovery ticks stay
|
|
1068
|
+
* out of the way (they early-return on it).
|
|
1069
|
+
*/
|
|
1070
|
+
async function ensureDaemonAttempt(): Promise<number> {
|
|
988
1071
|
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
989
1072
|
if (primary !== null) {
|
|
990
1073
|
await attachTo(primary);
|
|
@@ -1056,9 +1139,6 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
1056
1139
|
logWarn("in-process fallback bind failed", { error: String(e) });
|
|
1057
1140
|
return PORT;
|
|
1058
1141
|
}
|
|
1059
|
-
} finally {
|
|
1060
|
-
ensuring = false;
|
|
1061
|
-
}
|
|
1062
1142
|
}
|
|
1063
1143
|
|
|
1064
1144
|
export function getClientPort(): number {
|
|
@@ -1083,6 +1163,7 @@ export function _resetClientForTest(): void {
|
|
|
1083
1163
|
heartbeatPort = 0;
|
|
1084
1164
|
attachedPort = 0;
|
|
1085
1165
|
ensuring = false;
|
|
1166
|
+
ensureInFlight = null;
|
|
1086
1167
|
lastSpawnAt = 0;
|
|
1087
1168
|
daemonSpawner = spawn;
|
|
1088
1169
|
_resetUaRefreshForTest();
|
package/src/cline-accounts.ts
CHANGED
|
@@ -362,10 +362,20 @@ function diskMtime(): number {
|
|
|
362
362
|
* file (mtime moved). Never throws; corrupt reads come back empty.
|
|
363
363
|
*/
|
|
364
364
|
export function loadPool(): ClinePoolState {
|
|
365
|
-
const
|
|
366
|
-
if (cached &&
|
|
367
|
-
|
|
368
|
-
|
|
365
|
+
const before = diskMtime();
|
|
366
|
+
if (cached && before === cachedMtime) return cached;
|
|
367
|
+
let loaded = readPoolFile();
|
|
368
|
+
let stamp = diskMtime();
|
|
369
|
+
// The read is only trustworthy when the file did not move across it. Reading
|
|
370
|
+
// first and stamping afterwards latches pre-read content under the post-write
|
|
371
|
+
// mtime, which hides the change until the next write. Re-read once; the mtime
|
|
372
|
+
// observed after that read is the one the returned pool is cached under.
|
|
373
|
+
if (stamp !== before) {
|
|
374
|
+
loaded = readPoolFile();
|
|
375
|
+
stamp = diskMtime();
|
|
376
|
+
}
|
|
377
|
+
cached = loaded;
|
|
378
|
+
cachedMtime = stamp;
|
|
369
379
|
return cached;
|
|
370
380
|
}
|
|
371
381
|
|
|
@@ -409,6 +419,32 @@ function accountCountIn(raw: string): number {
|
|
|
409
419
|
}
|
|
410
420
|
}
|
|
411
421
|
|
|
422
|
+
/** Block synchronously for ms without burning CPU. */
|
|
423
|
+
function sleepSync(ms: number): void {
|
|
424
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Rename with bounded retry: Windows briefly locks the target when another
|
|
429
|
+
* process holds the pool file open, surfacing as EPERM/EACCES/EBUSY. Three
|
|
430
|
+
* attempts with a fixed 50ms backoff; any other error fails immediately.
|
|
431
|
+
*/
|
|
432
|
+
function renameWithRetry(from: string, to: string): void {
|
|
433
|
+
const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
|
|
434
|
+
for (let attempt = 1; ; attempt++) {
|
|
435
|
+
try {
|
|
436
|
+
fs.renameSync(from, to);
|
|
437
|
+
return;
|
|
438
|
+
} catch (e) {
|
|
439
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
440
|
+
if (!code || !retryable[code] || attempt >= 3) {
|
|
441
|
+
throw e;
|
|
442
|
+
}
|
|
443
|
+
sleepSync(50);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
412
448
|
/** Atomically persist the pool with owner-only permissions. Never logs secrets. */
|
|
413
449
|
export function savePool(pool: ClinePoolState): void {
|
|
414
450
|
try {
|
|
@@ -424,7 +460,7 @@ export function savePool(pool: ClinePoolState): void {
|
|
|
424
460
|
try {
|
|
425
461
|
fs.chmodSync(tmp, 0o600);
|
|
426
462
|
} catch { }
|
|
427
|
-
|
|
463
|
+
renameWithRetry(tmp, CLINE_POOL_FILE);
|
|
428
464
|
// Compare the pool just written too, so growth is never the copy that lags.
|
|
429
465
|
keepLargestBackup(serialized);
|
|
430
466
|
cached = pool;
|