pi-freeflow 1.37.1 → 1.38.1
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 +20 -0
- package/README.md +9 -11
- package/package.json +2 -1
- package/src/catalog.ts +5 -3
- package/src/client.ts +304 -39
- package/src/cline-accounts.ts +41 -5
- package/src/commands.ts +97 -65
- package/src/config.ts +54 -30
- package/src/daemon.ts +146 -144
- package/src/index.ts +2 -2
- package/src/logger.ts +491 -367
- package/src/models.ts +45 -3
- package/src/opencode-fingerprint.ts +2 -1
- package/src/proxy.ts +31 -0
- package/src/relay-state.ts +3 -2
- 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,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.38.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- The first-run welcome now names the local proxy port actually in use instead of the default port.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
## 1.38.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- Follow pi's config-dir global: data files (relay state, logs, caches — including the staged daemon sources) now root at `PI_CODING_AGENT_DIR` when set, instead of always `~/.pi/agent`. CI and containers can relocate pi's config dir once and the extension writes alongside it. A leading `~` in either override expands to the home directory, and `PI_FREEFLOW_DATA_DIR` still overrides everything.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Startup no longer stalls for several seconds when the extension is installed as a package and runs under Node.js: the detached proxy daemon is now launched from a staged copy of its sources (Node refuses to strip types for files under `node_modules`), a daemon that exits immediately is detected instead of being awaited for the full ready timeout, and a free base port is no longer mistaken for an unresponsive port holder.
|
|
22
|
+
|
|
3
23
|
## 1.37.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 36 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
|
+
| **36 free models** | 12 from OpenCode Zen, 14 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,24 +197,25 @@ The same command set works identically in OMP and Pi:
|
|
|
197
197
|
|
|
198
198
|
---
|
|
199
199
|
|
|
200
|
-
###
|
|
200
|
+
### 36 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 (12 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` | ✅ |
|
|
@@ -258,13 +259,11 @@ Each account can only be saved once: an extra login for an account you already h
|
|
|
258
259
|
| `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
259
260
|
| `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low/high/max` | ✅ |
|
|
260
261
|
#### OVHcloud AI Endpoints (8 models), OpenAI compatible
|
|
261
|
-
#### OVHcloud AI Endpoints (9 models), OpenAI compatible
|
|
262
262
|
|
|
263
263
|
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
264
|
|
|
265
265
|
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
266
|
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
267
|
|
|
269
268
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
270
269
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
@@ -274,7 +273,7 @@ All nine talk the normal chat API. Hosts that prefer the responses API work too
|
|
|
274
273
|
| `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
275
274
|
| `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
|
|
276
275
|
| `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **32K** (32.768) | `low/high` | ✅ |
|
|
277
|
-
| `mistral-
|
|
276
|
+
| `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **16K** (16.384) | ❌ | ✅ |
|
|
278
277
|
| `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **16K** (16.384) | ❌ | ❌ |
|
|
279
278
|
|
|
280
279
|
---
|
|
@@ -295,7 +294,7 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
|
295
294
|
|
|
296
295
|
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
296
|
|
|
298
|
-
About
|
|
297
|
+
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
298
|
|
|
300
299
|
---
|
|
301
300
|
|
|
@@ -368,9 +367,8 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
368
367
|
#### Project structure
|
|
369
368
|
|
|
370
369
|
```
|
|
371
|
-
├── models.ts #
|
|
370
|
+
├── models.ts # 36-model catalog definitions
|
|
372
371
|
├── index.ts # extension entry, lifecycle hooks
|
|
373
|
-
├── models.ts # 37-model catalog definitions
|
|
374
372
|
├── catalog.ts # model catalog cache (24h disk)
|
|
375
373
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
376
374
|
├── 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.
|
|
4
|
+
"version": "1.38.1",
|
|
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";
|
|
@@ -353,7 +352,10 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
353
352
|
}
|
|
354
353
|
|
|
355
354
|
/**
|
|
356
|
-
* Refresh free model catalog from OpenCode Zen
|
|
355
|
+
* Refresh the free model catalog from the OpenCode Zen listing. Only Zen is
|
|
356
|
+
* fetched — Kilo ids stay static (merged from ALL_MODELS; the cache's kilo
|
|
357
|
+
* field is preserved but never re-fetched), so Kilo entries change only in
|
|
358
|
+
* src/models.ts.
|
|
357
359
|
* Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
|
|
358
360
|
* catalogs; a 304 Not Modified response skips merge and returns the in-memory
|
|
359
361
|
* catalog unchanged. Falls back gracefully to cached or static models if network
|
package/src/client.ts
CHANGED
|
@@ -40,12 +40,14 @@ import {
|
|
|
40
40
|
RECOVERY_BACKOFF_BASE_MS,
|
|
41
41
|
RECOVERY_BACKOFF_CAP_MS,
|
|
42
42
|
refreshOpenCodeUserAgent,
|
|
43
|
+
resolveDaemonSrcCacheDir,
|
|
43
44
|
} from "./config.ts";
|
|
44
45
|
import { logInfo, logWarn } from "./logger.ts";
|
|
45
46
|
import {
|
|
46
47
|
getDaemonHealth,
|
|
47
48
|
getDaemonVersion,
|
|
48
49
|
isProxyAlive,
|
|
50
|
+
isPortBound,
|
|
49
51
|
killPortHolder,
|
|
50
52
|
reprobeBasePortAlive,
|
|
51
53
|
startProxy,
|
|
@@ -60,6 +62,11 @@ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
60
62
|
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
|
61
63
|
let heartbeatPort = 0;
|
|
62
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;
|
|
63
70
|
let fallbackServer: http.Server | null = null;
|
|
64
71
|
/** First tick (Date.now()) the daemon has looked continuously busy; 0 = idle. */
|
|
65
72
|
let busySince = 0;
|
|
@@ -432,14 +439,25 @@ export function _resetRecoveryForTest(): void {
|
|
|
432
439
|
/** Recovery-decision shape; canonical definition lives in health.ts. */
|
|
433
440
|
export type HealthForRecovery = DaemonHealthSnapshot;
|
|
434
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
|
+
|
|
435
453
|
/**
|
|
436
454
|
* Watchdog decision matrix (pure, testable). Recover when the daemon is gone,
|
|
437
|
-
* serves
|
|
455
|
+
* serves an older version, or reports a degraded failed-SSE window. Missing
|
|
438
456
|
* sseDegraded (pre-window daemon) never triggers on its own.
|
|
439
457
|
*/
|
|
440
458
|
export function shouldRecoverOnHealth(health: HealthForRecovery): boolean {
|
|
441
459
|
if (health === null) return true;
|
|
442
|
-
if (health.version
|
|
460
|
+
if (isOlderDaemonVersion(health.version)) return true;
|
|
443
461
|
if (health.sseDegraded === true) return true;
|
|
444
462
|
return false;
|
|
445
463
|
}
|
|
@@ -533,7 +551,7 @@ export async function watchdogCheck(port: number): Promise<void> {
|
|
|
533
551
|
return;
|
|
534
552
|
}
|
|
535
553
|
if (health && (health.activeRequests ?? 0) > 0 && !isStuckBusy(busySince, health.lastBytesAt ?? 0)) return;
|
|
536
|
-
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) }));
|
|
537
555
|
}
|
|
538
556
|
|
|
539
557
|
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
@@ -610,42 +628,98 @@ export function openDaemonLogFd(): number | null {
|
|
|
610
628
|
}
|
|
611
629
|
|
|
612
630
|
export type DarkHolderProbe = {
|
|
613
|
-
/** True when GET /v1/models answers on the port. */
|
|
631
|
+
/** True when GET /v1/models answers with a JSON catalog on the port. */
|
|
614
632
|
alive: boolean;
|
|
615
|
-
/**
|
|
616
|
-
|
|
633
|
+
/** True when a TCP connection to the port succeeds (something holds it). */
|
|
634
|
+
bound: boolean;
|
|
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;
|
|
617
641
|
};
|
|
618
642
|
|
|
619
643
|
/**
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Pure decision: reclaim a base port that is actually held (TCP connect
|
|
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.
|
|
624
696
|
*/
|
|
625
697
|
export function shouldReclaimDarkHolder(probe: DarkHolderProbe): boolean {
|
|
626
698
|
if (probe.alive) return false;
|
|
627
|
-
if (probe.
|
|
699
|
+
if (!probe.bound) return false;
|
|
700
|
+
if (probe.healthOurs) return false;
|
|
628
701
|
return true;
|
|
629
702
|
}
|
|
630
703
|
|
|
631
704
|
/**
|
|
632
705
|
* Dislodge a dark holder from the base port so a fresh daemon can bind it.
|
|
633
|
-
* Returns true when a healthy proxy answers afterwards (attach to it);
|
|
634
|
-
*
|
|
635
|
-
* Honors the no-kill env opt-out; a live holder is never touched here —
|
|
636
|
-
*
|
|
706
|
+
* Returns true when a healthy proxy answers afterwards (attach to it); false
|
|
707
|
+
* when the caller should spawn fresh (the port is free — nothing to reclaim).
|
|
708
|
+
* Honors the no-kill env opt-out; a live holder is never touched here — the
|
|
709
|
+
* stale-replace path owns versioned daemons.
|
|
637
710
|
*/
|
|
638
711
|
async function reclaimDarkHolder(port: number): Promise<boolean> {
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
if (!
|
|
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;
|
|
642
716
|
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
643
717
|
logInfo(
|
|
644
|
-
`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)`,
|
|
645
719
|
);
|
|
646
720
|
return false;
|
|
647
721
|
}
|
|
648
|
-
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`);
|
|
649
723
|
await killPortHolder(port);
|
|
650
724
|
for (let i = 0; i < 10; i++) {
|
|
651
725
|
await new Promise<void>((r) => setTimeout(r, 200));
|
|
@@ -723,26 +797,185 @@ export async function ensureProxyReady(
|
|
|
723
797
|
return { ok: false, port, reason: proxyDownMessage(port) };
|
|
724
798
|
}
|
|
725
799
|
|
|
800
|
+
/** True when any path segment of `filePath` is `node_modules`. Node refuses to
|
|
801
|
+
* strip types for files under such a path (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
|
|
802
|
+
* so a Node-spawned daemon must run from a staged copy elsewhere. */
|
|
803
|
+
export function isUnderNodeModules(filePath: string): boolean {
|
|
804
|
+
return filePath.split(/[\\/]/).includes("node_modules");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function readPackageJsonObject(dir: string): Record<string, unknown> | null {
|
|
808
|
+
try {
|
|
809
|
+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
810
|
+
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
811
|
+
} catch {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Resolve a dependency's package directory the way Node would: walk up the
|
|
817
|
+
* node_modules chain from the importing package's root. */
|
|
818
|
+
function resolvePackageDir(name: string, fromDir: string): string | null {
|
|
819
|
+
let dir = fromDir;
|
|
820
|
+
for (; ;) {
|
|
821
|
+
const pkgJson = path.join(dir, "node_modules", name, "package.json");
|
|
822
|
+
if (fs.existsSync(pkgJson)) return path.dirname(pkgJson);
|
|
823
|
+
const parent = path.dirname(dir);
|
|
824
|
+
if (parent === dir) return null;
|
|
825
|
+
dir = parent;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Link (or copy, when linking is unavailable) each runtime dependency beside
|
|
830
|
+
* the staged sources so bare imports such as `undici` still resolve. */
|
|
831
|
+
function stageRuntimeDependencies(pkgRoot: string, pkgJson: Record<string, unknown>, stagedDir: string): void {
|
|
832
|
+
const deps = pkgJson.dependencies;
|
|
833
|
+
if (!deps || typeof deps !== "object") return;
|
|
834
|
+
const nodeModulesDir = path.join(stagedDir, "node_modules");
|
|
835
|
+
for (const name of Object.keys(deps as Record<string, unknown>)) {
|
|
836
|
+
const target = resolvePackageDir(name, pkgRoot);
|
|
837
|
+
if (target === null) {
|
|
838
|
+
logWarn("daemon dependency not found for staging", { dependency: name });
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
843
|
+
// Junction on Windows (no elevated privileges needed); dir symlink elsewhere.
|
|
844
|
+
fs.symlinkSync(target, path.join(nodeModulesDir, name), process.platform === "win32" ? "junction" : "dir");
|
|
845
|
+
} catch {
|
|
846
|
+
try {
|
|
847
|
+
fs.cpSync(target, path.join(nodeModulesDir, name), { recursive: true, dereference: true });
|
|
848
|
+
} catch (e) {
|
|
849
|
+
logWarn("daemon dependency staging failed", { dependency: name, error: String(e) });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Best-effort prune of staged daemon sources from earlier versions (1 day TTL). */
|
|
856
|
+
function pruneStaleDaemonSrcDirs(root: string, currentVersion: string): void {
|
|
857
|
+
try {
|
|
858
|
+
const now = Date.now();
|
|
859
|
+
for (const entry of fs.readdirSync(root)) {
|
|
860
|
+
if (entry === currentVersion) continue;
|
|
861
|
+
const full = path.join(root, entry);
|
|
862
|
+
try {
|
|
863
|
+
if (now - fs.statSync(full).mtimeMs > 24 * 60 * 60 * 1000) {
|
|
864
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
865
|
+
}
|
|
866
|
+
} catch { }
|
|
867
|
+
}
|
|
868
|
+
} catch { }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Materialize the daemon's sources outside node_modules so a Node runtime can
|
|
873
|
+
* execute `src/daemon.ts` (Node refuses type stripping for any file under a
|
|
874
|
+
* node_modules path, and this package ships TypeScript sources). Copies
|
|
875
|
+
* `src/` + `package.json` into a per-version cache directory, stages the
|
|
876
|
+
* runtime dependencies beside them, and returns the staged daemon entry.
|
|
877
|
+
* Returns the input path unchanged when the script is already outside
|
|
878
|
+
* node_modules (dev checkouts) or the package root is unreadable.
|
|
879
|
+
*/
|
|
880
|
+
export function materializeDaemonSources(scriptPath: string, cacheDir?: string): string {
|
|
881
|
+
if (!isUnderNodeModules(scriptPath)) return scriptPath;
|
|
882
|
+
const pkgRoot = path.dirname(path.dirname(scriptPath));
|
|
883
|
+
const pkgJson = readPackageJsonObject(pkgRoot);
|
|
884
|
+
const version = pkgJson !== null && typeof pkgJson.version === "string" ? pkgJson.version : "";
|
|
885
|
+
if (pkgJson === null || !version) return scriptPath;
|
|
886
|
+
|
|
887
|
+
const root = cacheDir ?? resolveDaemonSrcCacheDir();
|
|
888
|
+
const finalDir = path.join(root, version);
|
|
889
|
+
const finalEntry = path.join(finalDir, "src", "daemon.ts");
|
|
890
|
+
if (fs.existsSync(finalEntry) && fs.existsSync(path.join(finalDir, "package.json"))) {
|
|
891
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
892
|
+
return finalEntry;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const tmpDir = `${finalDir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
896
|
+
try {
|
|
897
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
898
|
+
fs.cpSync(path.join(pkgRoot, "src"), path.join(tmpDir, "src"), { recursive: true });
|
|
899
|
+
fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
|
|
900
|
+
stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
|
|
901
|
+
try {
|
|
902
|
+
fs.renameSync(tmpDir, finalDir);
|
|
903
|
+
logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
|
|
904
|
+
} catch (e) {
|
|
905
|
+
// A concurrent starter won the rename (or the dir appeared meanwhile):
|
|
906
|
+
// its copy is byte-identical for the same version, so reuse it.
|
|
907
|
+
try {
|
|
908
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
909
|
+
} catch { }
|
|
910
|
+
if (!fs.existsSync(finalEntry)) {
|
|
911
|
+
logWarn("daemon source staging failed", { error: String(e), dir: finalDir });
|
|
912
|
+
return scriptPath;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
} catch (e) {
|
|
916
|
+
try {
|
|
917
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
918
|
+
} catch { }
|
|
919
|
+
logWarn("daemon source staging failed — spawning from the installed path", { error: String(e), dir: tmpDir });
|
|
920
|
+
return scriptPath;
|
|
921
|
+
}
|
|
922
|
+
pruneStaleDaemonSrcDirs(root, version);
|
|
923
|
+
return finalEntry;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export interface DaemonChild {
|
|
927
|
+
pid?: number;
|
|
928
|
+
exitCode: number | null;
|
|
929
|
+
signalCode: string | null;
|
|
930
|
+
unref(): void;
|
|
931
|
+
on(event: "error", listener: (err: Error) => void): unknown;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export type DaemonSpawner = (
|
|
935
|
+
command: string,
|
|
936
|
+
args: string[],
|
|
937
|
+
options: Parameters<typeof spawn>[2],
|
|
938
|
+
) => DaemonChild;
|
|
939
|
+
|
|
940
|
+
let daemonSpawner: DaemonSpawner = spawn;
|
|
941
|
+
/** Children that failed to launch (spawn error) — the ready-wait treats them as exited. */
|
|
942
|
+
const failedDaemonSpawns = new WeakSet<DaemonChild>();
|
|
943
|
+
|
|
944
|
+
/** Test seam: replace the child spawner used for detached daemon starts. */
|
|
945
|
+
export function _setDaemonSpawnForTest(spawner: DaemonSpawner | null): void {
|
|
946
|
+
daemonSpawner = spawner ?? spawn;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** True once a spawned daemon child has exited (exit code or signal). */
|
|
950
|
+
export function hasChildExited(child: Pick<DaemonChild, "exitCode" | "signalCode">): boolean {
|
|
951
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
952
|
+
}
|
|
953
|
+
|
|
726
954
|
let lastSpawnAt = 0;
|
|
727
955
|
const SPAWN_THROTTLE_MS = 2_000;
|
|
728
956
|
|
|
729
|
-
function spawnDaemonProcess():
|
|
957
|
+
function spawnDaemonProcess(): DaemonChild | null {
|
|
730
958
|
const now = Date.now();
|
|
731
959
|
if (now - lastSpawnAt < SPAWN_THROTTLE_MS) {
|
|
732
960
|
logWarn("daemon spawn throttled — recent spawn still pending");
|
|
733
|
-
return
|
|
961
|
+
return null;
|
|
734
962
|
}
|
|
735
963
|
const runtime = resolveDaemonRuntime();
|
|
736
964
|
if (!runtime) {
|
|
737
965
|
logWarn(
|
|
738
966
|
`cannot spawn detached proxy daemon: no node or bun runtime found (process.execPath "${process.execPath}" is not a JS runtime) — falling back to in-process proxy`,
|
|
739
967
|
);
|
|
740
|
-
return
|
|
968
|
+
return null;
|
|
741
969
|
}
|
|
970
|
+
// Node refuses to strip types for files under node_modules, so stage the
|
|
971
|
+
// sources elsewhere before spawning with a Node runtime (issue #52).
|
|
972
|
+
const scriptArg = runtime.args[runtime.args.length - 1] ?? "";
|
|
973
|
+
const stagedScript = runtime.runtimeType === "node" ? materializeDaemonSources(scriptArg) : scriptArg;
|
|
974
|
+
const args = stagedScript === scriptArg ? runtime.args : [...runtime.args.slice(0, -1), stagedScript];
|
|
742
975
|
lastSpawnAt = now;
|
|
743
976
|
const logFd = openDaemonLogFd();
|
|
744
977
|
try {
|
|
745
|
-
const child =
|
|
978
|
+
const child = daemonSpawner(runtime.execPath, args, {
|
|
746
979
|
detached: true,
|
|
747
980
|
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
748
981
|
windowsHide: true,
|
|
@@ -753,15 +986,16 @@ function spawnDaemonProcess(): boolean {
|
|
|
753
986
|
});
|
|
754
987
|
child.unref();
|
|
755
988
|
logInfo(
|
|
756
|
-
`spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}`,
|
|
989
|
+
`spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}${stagedScript === scriptArg ? "" : " [staged sources]"}`,
|
|
757
990
|
);
|
|
758
991
|
child.on("error", (err) => {
|
|
992
|
+
failedDaemonSpawns.add(child);
|
|
759
993
|
logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
|
|
760
994
|
});
|
|
761
|
-
return
|
|
995
|
+
return child;
|
|
762
996
|
} catch (e) {
|
|
763
997
|
logWarn("daemon spawn failed", { error: String(e), execPath: runtime.execPath });
|
|
764
|
-
return
|
|
998
|
+
return null;
|
|
765
999
|
} finally {
|
|
766
1000
|
if (logFd !== null) {
|
|
767
1001
|
try {
|
|
@@ -771,10 +1005,22 @@ function spawnDaemonProcess(): boolean {
|
|
|
771
1005
|
}
|
|
772
1006
|
}
|
|
773
1007
|
|
|
774
|
-
async function waitForReady(
|
|
1008
|
+
export async function waitForReady(
|
|
1009
|
+
port: number,
|
|
1010
|
+
timeoutMs: number,
|
|
1011
|
+
child?: DaemonChild | null,
|
|
1012
|
+
): Promise<boolean> {
|
|
775
1013
|
const deadline = Date.now() + timeoutMs;
|
|
776
1014
|
while (Date.now() < deadline) {
|
|
777
1015
|
if (await isProxyAlive(port, 150)) return true;
|
|
1016
|
+
if (child && (hasChildExited(child) || failedDaemonSpawns.has(child))) {
|
|
1017
|
+
logWarn("daemon child exited before becoming ready", {
|
|
1018
|
+
port,
|
|
1019
|
+
exitCode: child.exitCode,
|
|
1020
|
+
signalCode: child.signalCode,
|
|
1021
|
+
});
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
778
1024
|
await new Promise<void>((r) => setTimeout(r, 80));
|
|
779
1025
|
}
|
|
780
1026
|
return false;
|
|
@@ -801,9 +1047,27 @@ async function probeAndMaybeReplace(
|
|
|
801
1047
|
* Returns the port the caller should use for its ProviderConfig.
|
|
802
1048
|
*/
|
|
803
1049
|
export async function ensureDaemon(): Promise<number> {
|
|
1050
|
+
if (ensureInFlight) return ensureInFlight;
|
|
804
1051
|
if (ensuring) return attachedPort || PORT;
|
|
805
1052
|
ensuring = true;
|
|
1053
|
+
const attempt = ensureDaemonAttempt();
|
|
1054
|
+
ensureInFlight = attempt;
|
|
806
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> {
|
|
807
1071
|
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
808
1072
|
if (primary !== null) {
|
|
809
1073
|
await attachTo(primary);
|
|
@@ -812,16 +1076,17 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
812
1076
|
|
|
813
1077
|
|
|
814
1078
|
if (isSpawnEnabled()) {
|
|
815
|
-
const
|
|
816
|
-
if (
|
|
817
|
-
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
1079
|
+
const child = spawnDaemonProcess();
|
|
1080
|
+
if (child) {
|
|
1081
|
+
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS, child);
|
|
818
1082
|
if (ready) {
|
|
819
1083
|
const ver = await getDaemonVersion(PORT);
|
|
820
1084
|
if (ver !== null && ver !== PKG_VERSION) {
|
|
821
1085
|
if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
|
|
822
1086
|
lastSpawnAt = 0; // kill-confirmed retry bypasses the spawn throttle
|
|
823
|
-
|
|
824
|
-
|
|
1087
|
+
const retryChild = spawnDaemonProcess();
|
|
1088
|
+
if (retryChild) {
|
|
1089
|
+
const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS, retryChild);
|
|
825
1090
|
if (retryReady) {
|
|
826
1091
|
await attachTo(PORT);
|
|
827
1092
|
return PORT;
|
|
@@ -836,13 +1101,14 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
836
1101
|
return PORT;
|
|
837
1102
|
}
|
|
838
1103
|
} else {
|
|
839
|
-
// The spawn never answered:
|
|
840
|
-
//
|
|
1104
|
+
// The spawn never answered: the child exited before binding (e.g. Node
|
|
1105
|
+
// refusing type stripping under a node_modules path) or a dark holder
|
|
1106
|
+
// owns the base port (bind fails, probes fail).
|
|
841
1107
|
if (await reclaimDarkHolder(PORT)) {
|
|
842
1108
|
await attachTo(PORT);
|
|
843
1109
|
return PORT;
|
|
844
1110
|
}
|
|
845
|
-
logWarn("daemon spawn did not become ready —
|
|
1111
|
+
logWarn("daemon spawn did not become ready — child exited or the port is blocked");
|
|
846
1112
|
}
|
|
847
1113
|
}
|
|
848
1114
|
// Fall through to the in-process fallback below — never hand the caller
|
|
@@ -873,9 +1139,6 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
873
1139
|
logWarn("in-process fallback bind failed", { error: String(e) });
|
|
874
1140
|
return PORT;
|
|
875
1141
|
}
|
|
876
|
-
} finally {
|
|
877
|
-
ensuring = false;
|
|
878
|
-
}
|
|
879
1142
|
}
|
|
880
1143
|
|
|
881
1144
|
export function getClientPort(): number {
|
|
@@ -900,7 +1163,9 @@ export function _resetClientForTest(): void {
|
|
|
900
1163
|
heartbeatPort = 0;
|
|
901
1164
|
attachedPort = 0;
|
|
902
1165
|
ensuring = false;
|
|
1166
|
+
ensureInFlight = null;
|
|
903
1167
|
lastSpawnAt = 0;
|
|
1168
|
+
daemonSpawner = spawn;
|
|
904
1169
|
_resetUaRefreshForTest();
|
|
905
1170
|
_resetRecoveryForTest();
|
|
906
1171
|
if (fallbackServer) {
|