pi-freeflow 1.44.1 → 1.45.0
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 +23 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +4 -1
- package/src/client-staging.ts +53 -3
- package/src/client.ts +37 -19
- package/src/cline-accounts.ts +7 -4
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +176 -126
- package/src/cline-roll.ts +71 -38
- package/src/commands-relay.ts +2 -2
- package/src/commands-startup.ts +4 -1
- package/src/commands.ts +28 -6
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-tools.ts +1 -1
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +22 -3
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +5 -2
- package/src/opencode-identity.ts +10 -3
- package/src/proxy-lanes.ts +15 -3
- package/src/proxy-ports.ts +2 -2
- package/src/proxy.ts +29 -14
- package/src/relay-state.ts +9 -4
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +1 -0
- package/src/stream-cloak.ts +7 -4
- package/src/stream-pipe.ts +12 -2
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.45.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Synchronize the free model catalog with live upstreams and resolve cross-platform runtime, stream lifecycle, and process management issues across Windows and Linux (Node 22/24/26, Bun, Pi, and OMP).
|
|
8
|
+
|
|
9
|
+
- **Catalog Sync (30 Models Total)**:
|
|
10
|
+
- Add 3 live verified OpenCode Zen models: `crouton-alpha-free` (1M context, 270K output), `iceman-free` (262K context, 131K output), and `inkling-2-small-free` (1M context, 131K output, multimodal).
|
|
11
|
+
- Add `liquid/lfm-3-1.7b:free` on KiloCode Gateway.
|
|
12
|
+
- Retire 6 discontinued KiloCode models confirmed 404 upstream (`poolside/laguna-s-2.1:free`, `poolside/laguna-xs-2.1:free`, `liquid/lfm-2.5-2.6b:free`, `reflection/beam-free`, `recursive/flint-v1.0:free`, `xingchen-agi/xing4.0-29b-a4b:free`) and dead-list them to prevent revival from stale caches.
|
|
13
|
+
- Update model aliases with clean mappings (`crouton`, `iceman`, `inkling-2-small`, `lfm-3`).
|
|
14
|
+
|
|
15
|
+
- **Cross-Platform & Stream Lifecycle Fixes**:
|
|
16
|
+
- Add a `destroy` hook to the Zen sidecar stream and terminate orphaned child processes on stream cancellation, client disconnect, or stall timeouts.
|
|
17
|
+
- Support Windows `.cmd`/`.bat` runtime shims in child process spawns with `shell: true` and argument quoting, avoiding `EINVAL` under Node 22+.
|
|
18
|
+
- Fix `isDaemonMain()` to resolve symlinks and compare paths case-insensitively on Windows so symlinked daemon launches and lowercase drive paths start normally.
|
|
19
|
+
- Ensure client leases detach cleanly on `session_shutdown` and process exit across Node 22/24/26 and Bun.
|
|
20
|
+
- Filter `lsof` with `-sTCP:LISTEN` on Linux so active client sessions sharing port 28180 are never killed.
|
|
21
|
+
- Prioritize live registry versions and disk caches over installed 1.x binaries during User-Agent generation to fix version-drift auto-repair.
|
|
22
|
+
- Prevent tool argument corruption in `stream-cloak.ts` when function names repeat across stream deltas.
|
|
23
|
+
- Fix the hedged relay race so transient errors on one leg do not prematurely cancel healthy in-flight responses.
|
|
24
|
+
- Add single-flight deduplication and lockfile serialization to Cline token refreshes to prevent concurrent subagent requests from invalidating single-use refresh tokens.
|
|
25
|
+
|
|
3
26
|
## 1.44.1
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
|
|
|
19
19
|
| **32 free models** | 8 from OpenCode Zen, 15 from KiloCode Gateway, 1 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 relays: a self-hosted egress pool (many egress IPs on one small host) or Cloudflare Workers / Deno Deploy / Vercel Edge workers. 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 host or platforms' free tiers |
|
|
21
21
|
| **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
|
|
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
|
|
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>` (including `cline-super-vl` for Cline). | **$0** |
|
|
23
23
|
| **Shared local proxy** | One daemon on `127.0.0.1:28180` serves every session on the machine, so parallel subagents reuse it instead of opening their own connections. Loopback-only and token-gated: hosts send the token automatically, external tools read `~/.pi/agent/token`. | **$0** |
|
|
24
24
|
| **Logs you can read** | `~/.pi/agent/pi-freeflow.log`, rotated at 10MB. Tail it with `/freeflow logs`. | **$0** |
|
|
25
25
|
| **Tool-call diagnostics** | `~/.pi/agent/toolcall-audit.jsonl`, rotated at 5MB. One line per streamed turn that declared tools (and per non-streamed Chat Completions turn) — the calls it made, argument size and validity, finish reason, classified failure — plus one line per abandoned upstream attempt. Names, sizes and reasons only. | **$0** |
|
|
@@ -134,13 +134,13 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
|
|
|
134
134
|
|
|
135
135
|
**Canonical worker source (all platforms)**
|
|
136
136
|
|
|
137
|
-
The relay worker template is generated per deployment by `/freeflow deploy`, with one shared core adapted for Vercel, Cloudflare, and Deno. Every deploy embeds a random 32-byte secret, and the generated worker answers `401` to any request without a matching `x-relay-auth` header; the client sends that secret to each relay in the pool. Only a hand-pasted legacy worker built without a secret is open to anyone. Either way, a relay forwards only to an allowlist (`https://opencode.ai`, `https://api.kilo.ai`) with private-host and path checks.
|
|
137
|
+
The relay worker template is generated per deployment by `/freeflow deploy`, with one shared core adapted for Vercel, Cloudflare, and Deno. Every deploy embeds a random 32-byte secret, and the generated worker answers `401` to any request without a matching `x-relay-auth` header; the client sends that secret to each relay in the pool. Only a hand-pasted legacy worker built without a secret is open to anyone. Either way, a relay forwards only to an allowlist (`https://opencode.ai`, `https://api.kilo.ai`, `https://oai.endpoints.kepler.ai.cloud.ovh.net`) with private-host and path checks.
|
|
138
138
|
|
|
139
139
|
```js
|
|
140
140
|
// Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
|
|
141
141
|
// worker is the hardened source for all three platforms.
|
|
142
142
|
// This example omits the private-host guard, path validation, and auth.
|
|
143
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
143
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai", "https://oai.endpoints.kepler.ai.cloud.ovh.net"];
|
|
144
144
|
export default {
|
|
145
145
|
async fetch(req) {
|
|
146
146
|
const target = req.headers.get("x-relay-target");
|
|
@@ -215,7 +215,7 @@ The same command set works identically in OMP and Pi:
|
|
|
215
215
|
|
|
216
216
|
---
|
|
217
217
|
|
|
218
|
-
###
|
|
218
|
+
### 30 models, one command
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
221
|
/model → freeflow → pick
|
|
@@ -225,7 +225,7 @@ Every model specification is reconciled against [models.dev](https://models.dev/
|
|
|
225
225
|
|
|
226
226
|
The host catalog carries text and image inputs only. Upstream video and audio support (MiMo: audio + video; GLM 5.3 Flash: video + PDF; Nemotron Super VL and Nano Omni: video) is therefore not selectable from the picker and is not declared in `input`.
|
|
227
227
|
|
|
228
|
-
#### OpenCode Zen (
|
|
228
|
+
#### OpenCode Zen (11 models), Chat Completions API
|
|
229
229
|
|
|
230
230
|
Good defaults for long coding sessions and agentic work.
|
|
231
231
|
|
|
@@ -239,8 +239,11 @@ Good defaults for long coding sessions and agentic work.
|
|
|
239
239
|
| `beam-free` | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
|
|
240
240
|
| `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.048.576) | **393K** (393.216) | `low / high / max` | ✅ |
|
|
241
241
|
| `flint-40b-free` | Recursive | **262K** (262.144) | **65K** (65.536) | `low … max` | ❌ |
|
|
242
|
+
| `crouton-alpha-free` | Stealth (Thinking Machines) | **1M** (1.048.576) | **270K** (270.000) | `minimal … max` | ✅ |
|
|
243
|
+
| `iceman-free` | Stealth | **262K** (262.144) | **131K** (131.072) | `low / high / max` | ❌ |
|
|
244
|
+
| `inkling-2-small-free` | Thinking Machines | **1M** (1.048.576) | **131K** (131.072) | `minimal … max` | ✅ |
|
|
242
245
|
|
|
243
|
-
#### KiloCode Gateway (
|
|
246
|
+
#### KiloCode Gateway (10 models), OpenRouter compatible
|
|
244
247
|
|
|
245
248
|
Keyless access. Most rows have a short alias (shown with the full ID in parentheses); a row that shows only the full ID has no short form.
|
|
246
249
|
|
|
@@ -251,16 +254,11 @@ Keyless access. Most rows have a short alias (shown with the full ID in parenthe
|
|
|
251
254
|
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **182K** (182.520) | `minimal…xhigh`\* | ❌ |
|
|
252
255
|
| `nemotron-3.5-lightning` (`nvidia/nemotron-3.5-lightning:free`) | NVIDIA | **1M** (1.000.000) | **235K** (235.929) | `minimal…xhigh`\* | ❌ |
|
|
253
256
|
| `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
|
|
254
|
-
| `
|
|
255
|
-
| `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
256
|
-
| `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **128K** (128.000) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
257
|
+
| `lfm-3` (`liquid/lfm-3-1.7b:free`) | Liquid AI | **262K** (262.144) | **8K** (8.192) | `minimal…xhigh`\* | ❌ |
|
|
257
258
|
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
258
259
|
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
259
260
|
| `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
|
|
260
|
-
| `nvidia/nemotron-3.5-super-vl:free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
261
|
-
| `beam-kilo` (`reflection/beam-free`) | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low…max`\* | ❌ |
|
|
262
|
-
| `flint-kilo` (`recursive/flint-v1.0:free`) | Recursive | **262K** (262.144) | **65K** (65.536) | `minimal…xhigh`\* | ❌ |
|
|
263
|
-
| `xing4.0` (`xingchen-agi/xing4.0-29b-a4b:free`) | XingChen | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ❌ |
|
|
261
|
+
| `nemotron-3.5-super-vl-kilo` (`nvidia/nemotron-3.5-super-vl:free`) | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
264
262
|
|
|
265
263
|
\* 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.
|
|
266
264
|
|
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.45.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/catalog.ts
CHANGED
|
@@ -195,6 +195,25 @@ export const DEAD_MODEL_IDS = new Set<string>([
|
|
|
195
195
|
// sibling Kilo id nvidia/nemotron-3-ultra-550b-a55b:free is a separate
|
|
196
196
|
// serving route and is unaffected.
|
|
197
197
|
"nemotron-3-ultra-free",
|
|
198
|
+
// poolside/laguna-s-2.1:free and poolside/laguna-xs-2.1:free: discontinued
|
|
199
|
+
// upstream on KiloCode (2026-11-09) — removed from live /api/gateway/models,
|
|
200
|
+
// answers HTTP 404 "The requested model does not exist" across all egresses.
|
|
201
|
+
"poolside/laguna-s-2.1:free",
|
|
202
|
+
"poolside/laguna-xs-2.1:free",
|
|
203
|
+
// liquid/lfm-2.5-2.6b:free: replaced upstream on KiloCode by liquid/lfm-3-1.7b:free
|
|
204
|
+
// (2026-11-09) — answers HTTP 404 "The requested model is currently unavailable".
|
|
205
|
+
"liquid/lfm-2.5-2.6b:free",
|
|
206
|
+
// reflection/beam-free: discontinued upstream on KiloCode (2026-11-09) —
|
|
207
|
+
// answers HTTP 404 "The requested model does not exist". The Zen beam-free
|
|
208
|
+
// twin is a separate serving route and remains active.
|
|
209
|
+
"reflection/beam-free",
|
|
210
|
+
// recursive/flint-v1.0:free: discontinued upstream on KiloCode (2026-11-09) —
|
|
211
|
+
// answers HTTP 404 "The requested model is currently unavailable". The Zen
|
|
212
|
+
// flint-40b-free twin is a separate serving route and remains active.
|
|
213
|
+
"recursive/flint-v1.0:free",
|
|
214
|
+
// xingchen-agi/xing4.0-29b-a4b:free: discontinued upstream on KiloCode (2026-11-09) —
|
|
215
|
+
// answers HTTP 404 "The requested model is currently unavailable".
|
|
216
|
+
"xingchen-agi/xing4.0-29b-a4b:free",
|
|
198
217
|
]);
|
|
199
218
|
/**
|
|
200
219
|
* Free-tier allowlist for anything entering the picker via network or stale disk.
|
|
@@ -336,25 +355,30 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
|
|
|
336
355
|
// Declared fields win over wire aliases: a listing that already names
|
|
337
356
|
// contextWindow/maxTokens/reasoning keeps its values; the snake_case
|
|
338
357
|
// upstream aliases and the id heuristics only fill gaps.
|
|
339
|
-
|
|
340
|
-
typeof
|
|
341
|
-
|
|
358
|
+
const validPosNum = (n: unknown): n is number =>
|
|
359
|
+
typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
360
|
+
const declaredContext =
|
|
361
|
+
validPosNum(raw.contextWindow) ? raw.contextWindow
|
|
362
|
+
: validPosNum(raw.context_length) ? raw.context_length : undefined;
|
|
363
|
+
let contextWindow = declaredContext ?? 262_144;
|
|
342
364
|
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
365
|
+
declaredContext === undefined && (
|
|
366
|
+
idLower.includes("1m") ||
|
|
367
|
+
idLower.includes("ultra") ||
|
|
368
|
+
idLower.includes("lightning") ||
|
|
369
|
+
idLower.includes("mimo-v2.5") ||
|
|
370
|
+
idLower.includes("muse-spark")
|
|
371
|
+
)
|
|
348
372
|
) {
|
|
349
373
|
contextWindow = 1_048_576;
|
|
350
374
|
}
|
|
351
375
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (idLower.includes("ultra") || idLower.includes("lightning")) {
|
|
376
|
+
const declaredMaxTokens =
|
|
377
|
+
validPosNum(raw.maxTokens) ? raw.maxTokens
|
|
378
|
+
: validPosNum(raw.max_output_tokens) ? raw.max_output_tokens
|
|
379
|
+
: validPosNum(raw.max_tokens) ? raw.max_tokens : undefined;
|
|
380
|
+
let maxTokens = declaredMaxTokens ?? 65_536;
|
|
381
|
+
if (declaredMaxTokens === undefined && (idLower.includes("ultra") || idLower.includes("lightning"))) {
|
|
358
382
|
maxTokens = 131_072;
|
|
359
383
|
}
|
|
360
384
|
|
package/src/client-recovery.ts
CHANGED
|
@@ -168,7 +168,10 @@ export async function triggerRecovery(port: number, why: string, deps: RecoveryD
|
|
|
168
168
|
logWarn(`watchdog recovery (${why}) — backing off ${Math.round(delay)}ms before respawn`);
|
|
169
169
|
deps.stopHeartbeatInternal();
|
|
170
170
|
deps.resetHeartbeatPort();
|
|
171
|
-
|
|
171
|
+
const { promise: backoffDone, resolve: resolveBackoff } = Promise.withResolvers<void>();
|
|
172
|
+
const backoffTimer = setTimeout(resolveBackoff, delay);
|
|
173
|
+
try { backoffTimer.unref(); } catch { }
|
|
174
|
+
await backoffDone;
|
|
172
175
|
try {
|
|
173
176
|
const health = deps.getDaemonHealth ? await deps.getDaemonHealth(port) : null;
|
|
174
177
|
if (health && (health.activeRequests ?? 0) > 0) {
|
package/src/client-staging.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* required for Node's --experimental-strip-types execution.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
8
9
|
import { randomUUID } from "node:crypto";
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { DAEMON_RUNTIME_ENV, resolveDaemonSrcCacheDir } from "./config.ts";
|
|
14
|
+
import { renameWithRetry } from "./atomic-write.ts";
|
|
13
15
|
import { logInfo, logWarn } from "./logger.ts";
|
|
14
16
|
|
|
15
17
|
export interface DaemonRuntime {
|
|
@@ -25,7 +27,7 @@ export interface DaemonRuntime {
|
|
|
25
27
|
export function isJsRuntimeExecutable(execPath: string): "bun" | "node" | null {
|
|
26
28
|
if (!execPath || typeof execPath !== "string") return null;
|
|
27
29
|
const checkName = (target: string): "bun" | "node" | null => {
|
|
28
|
-
const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.exe$/i, "");
|
|
30
|
+
const base = path.basename(target.replace(/\\/g, "/")).toLowerCase().replace(/\.(?:exe|cmd|bat)$/i, "");
|
|
29
31
|
if (base === "bun" || base === "bunx") return "bun";
|
|
30
32
|
if (base === "node" || base === "nodejs") return "node";
|
|
31
33
|
return null;
|
|
@@ -75,6 +77,54 @@ export function findExecutableOnPath(
|
|
|
75
77
|
}
|
|
76
78
|
return null;
|
|
77
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Search directories in PATH for a Node.js binary meeting the >= 22.6 requirement
|
|
82
|
+
* for native type stripping (--experimental-strip-types).
|
|
83
|
+
*/
|
|
84
|
+
export function findSupportedNodeOnPath(
|
|
85
|
+
envPath: string = process.env.PATH || process.env.Path || "",
|
|
86
|
+
): string | null {
|
|
87
|
+
if (!envPath || typeof envPath !== "string") return null;
|
|
88
|
+
const dirs = envPath.split(path.delimiter).filter(Boolean);
|
|
89
|
+
const extensions = process.platform === "win32"
|
|
90
|
+
? (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"])
|
|
91
|
+
: [""];
|
|
92
|
+
|
|
93
|
+
for (const dir of dirs) {
|
|
94
|
+
for (const ext of extensions) {
|
|
95
|
+
const candidate = ext ? path.join(dir, `node${ext}`) : path.join(dir, "node");
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(candidate);
|
|
98
|
+
if (!stat.isFile()) continue;
|
|
99
|
+
if (process.platform !== "win32") {
|
|
100
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
101
|
+
}
|
|
102
|
+
if (stat.size === 0) {
|
|
103
|
+
return candidate;
|
|
104
|
+
}
|
|
105
|
+
const res = spawnSync(candidate, ["--version"], {
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
timeout: 1500,
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(candidate),
|
|
110
|
+
});
|
|
111
|
+
if (res.status === 0) {
|
|
112
|
+
const match = (res.stdout || "").match(/^v?(\d+)\.(\d+)\./);
|
|
113
|
+
if (match) {
|
|
114
|
+
const major = Number(match[1]);
|
|
115
|
+
const minor = Number(match[2]);
|
|
116
|
+
if (major < 22 || (major === 22 && minor < 6)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return candidate;
|
|
122
|
+
} catch { }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
|
|
79
129
|
/**
|
|
80
130
|
* Resolve the executable and arguments needed to run src/daemon.ts.
|
|
@@ -122,7 +172,7 @@ export function resolveDaemonRuntime(opts?: {
|
|
|
122
172
|
return { execPath: bunOnPath, args: [script], runtimeType: "bun" };
|
|
123
173
|
}
|
|
124
174
|
|
|
125
|
-
const nodeOnPath =
|
|
175
|
+
const nodeOnPath = findSupportedNodeOnPath(pathVal);
|
|
126
176
|
if (nodeOnPath) {
|
|
127
177
|
return {
|
|
128
178
|
execPath: nodeOnPath,
|
|
@@ -241,7 +291,7 @@ export function materializeDaemonSources(scriptPath: string, cacheDir?: string):
|
|
|
241
291
|
fs.copyFileSync(path.join(pkgRoot, "package.json"), path.join(tmpDir, "package.json"));
|
|
242
292
|
stageRuntimeDependencies(pkgRoot, pkgJson, tmpDir);
|
|
243
293
|
try {
|
|
244
|
-
|
|
294
|
+
renameWithRetry(tmpDir, finalDir);
|
|
245
295
|
logInfo(`staged proxy daemon sources outside node_modules at ${finalDir}`);
|
|
246
296
|
} catch (e) {
|
|
247
297
|
// A concurrent starter won the rename (or the dir appeared meanwhile):
|
package/src/client.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { tokenAuthHeaders } from "./token.ts";
|
|
|
49
49
|
import {
|
|
50
50
|
daemonScriptPath,
|
|
51
51
|
findExecutableOnPath,
|
|
52
|
+
findSupportedNodeOnPath,
|
|
52
53
|
isJsRuntimeExecutable,
|
|
53
54
|
isUnderNodeModules,
|
|
54
55
|
materializeDaemonSources,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
export {
|
|
82
83
|
daemonScriptPath,
|
|
83
84
|
findExecutableOnPath,
|
|
85
|
+
findSupportedNodeOnPath,
|
|
84
86
|
isJsRuntimeExecutable,
|
|
85
87
|
isUnderNodeModules,
|
|
86
88
|
materializeDaemonSources,
|
|
@@ -245,19 +247,24 @@ function stopHeartbeatInternal(): void {
|
|
|
245
247
|
}
|
|
246
248
|
}
|
|
247
249
|
|
|
248
|
-
export function stopHeartbeat(): void {
|
|
250
|
+
export async function stopHeartbeat(): Promise<void> {
|
|
249
251
|
const port = heartbeatPort;
|
|
252
|
+
const hadFallback = fallbackServer !== null;
|
|
250
253
|
stopHeartbeatInternal();
|
|
251
254
|
heartbeatPort = 0;
|
|
252
|
-
if (port) {
|
|
253
|
-
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
254
|
-
}
|
|
255
255
|
if (fallbackServer) {
|
|
256
256
|
try {
|
|
257
|
+
fallbackServer.closeIdleConnections?.();
|
|
258
|
+
fallbackServer.closeAllConnections?.();
|
|
257
259
|
fallbackServer.close();
|
|
258
260
|
} catch { }
|
|
259
261
|
fallbackServer = null;
|
|
260
262
|
}
|
|
263
|
+
if (port && !hadFallback) {
|
|
264
|
+
try {
|
|
265
|
+
await controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
266
|
+
} catch { }
|
|
267
|
+
}
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
async function beatOnce(port: number): Promise<void> {
|
|
@@ -272,14 +279,15 @@ async function beatOnce(port: number): Promise<void> {
|
|
|
272
279
|
}
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
async function attachTo(port: number): Promise<
|
|
282
|
+
async function attachTo(port: number): Promise<boolean> {
|
|
276
283
|
attachedPort = port;
|
|
277
284
|
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
278
285
|
if (result === "gone") {
|
|
279
|
-
return;
|
|
286
|
+
return false;
|
|
280
287
|
}
|
|
281
288
|
startHeartbeat(port);
|
|
282
289
|
maybeRefreshOpenCodeUserAgent();
|
|
290
|
+
return true;
|
|
283
291
|
}
|
|
284
292
|
|
|
285
293
|
async function ensureInProcessFallback(): Promise<number> {
|
|
@@ -304,7 +312,7 @@ const recoveryDeps: RecoveryDeps = {
|
|
|
304
312
|
resetHeartbeatPort: () => {
|
|
305
313
|
heartbeatPort = 0;
|
|
306
314
|
},
|
|
307
|
-
attachTo,
|
|
315
|
+
attachTo: async (p: number) => { await attachTo(p); },
|
|
308
316
|
killStaleDaemon: (p, v, w) => killStaleDaemon(p, v, w),
|
|
309
317
|
ensureDaemon: () => ensureDaemon(),
|
|
310
318
|
getDaemonHealth: (p: number) => getDaemonHealth(p),
|
|
@@ -606,20 +614,31 @@ function spawnDaemonProcess(): DaemonChild | null {
|
|
|
606
614
|
lastSpawnAt = now;
|
|
607
615
|
const logFd = openDaemonLogFd();
|
|
608
616
|
try {
|
|
609
|
-
const child =
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
617
|
+
const child = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(runtime.execPath) && daemonSpawner === spawn
|
|
618
|
+
? daemonSpawner(`"${runtime.execPath}" ${args.map((a) => `"${a}"`).join(" ")}`, [], {
|
|
619
|
+
detached: true,
|
|
620
|
+
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
621
|
+
windowsHide: true,
|
|
622
|
+
shell: true,
|
|
623
|
+
env: {
|
|
624
|
+
...process.env,
|
|
625
|
+
[DAEMON_SPAWN_ENV]: "0",
|
|
626
|
+
},
|
|
627
|
+
})
|
|
628
|
+
: daemonSpawner(runtime.execPath, args, {
|
|
629
|
+
detached: true,
|
|
630
|
+
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
631
|
+
windowsHide: true,
|
|
632
|
+
env: {
|
|
633
|
+
...process.env,
|
|
634
|
+
[DAEMON_SPAWN_ENV]: "0",
|
|
635
|
+
},
|
|
636
|
+
});
|
|
618
637
|
child.unref();
|
|
619
638
|
logInfo(
|
|
620
639
|
`spawned proxy daemon process (pid ${child.pid ?? "unknown"}) using ${runtime.runtimeType} (${runtime.execPath}) for :${PORT}${stagedScript === scriptArg ? "" : " [staged sources]"}`,
|
|
621
640
|
);
|
|
622
|
-
child.on("error", (err) => {
|
|
641
|
+
child.on("error", (err: Error) => {
|
|
623
642
|
failedDaemonSpawns.add(child);
|
|
624
643
|
logWarn("daemon spawn failed", { error: String(err), execPath: runtime.execPath });
|
|
625
644
|
});
|
|
@@ -731,8 +750,7 @@ export async function ensureDaemon(): Promise<number> {
|
|
|
731
750
|
async function ensureDaemonAttempt(): Promise<number> {
|
|
732
751
|
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
733
752
|
if (primary !== null) {
|
|
734
|
-
await attachTo(primary);
|
|
735
|
-
return primary;
|
|
753
|
+
if (await attachTo(primary)) return primary;
|
|
736
754
|
}
|
|
737
755
|
|
|
738
756
|
|
package/src/cline-accounts.ts
CHANGED
|
@@ -6,7 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { writeFileAtomic } from "./atomic-write.ts";
|
|
8
8
|
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
9
|
-
import { refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
9
|
+
import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
10
10
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
11
11
|
import {
|
|
12
12
|
CLINE_POOL_FILE,
|
|
@@ -114,12 +114,15 @@ export async function refreshClineVersion(fetchImpl: FetchImpl = fetchWithSystem
|
|
|
114
114
|
return getClineVersion();
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
|
|
117
|
+
export async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
|
|
118
118
|
try {
|
|
119
119
|
const creds = await refreshClineToken(undefined, refreshToken);
|
|
120
120
|
return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
|
|
121
|
-
} catch {
|
|
122
|
-
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof ClineAuthError && e.status !== undefined && e.status >= 400 && e.status < 500) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
throw e;
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
package/src/cline-device-auth.ts
CHANGED
|
@@ -250,14 +250,16 @@ export async function pollDeviceToken(
|
|
|
250
250
|
device_code: deviceCode,
|
|
251
251
|
client_id: resolveClineWorkosClientId(),
|
|
252
252
|
}),
|
|
253
|
-
signal:
|
|
253
|
+
signal: options?.signal
|
|
254
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(options?.timeoutMs ?? HTTP_TIMEOUT_MS)])
|
|
255
|
+
: AbortSignal.timeout(options?.timeoutMs ?? HTTP_TIMEOUT_MS),
|
|
254
256
|
});
|
|
255
257
|
} catch (error) {
|
|
256
258
|
if (outerSignal?.aborted) {
|
|
257
259
|
throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
|
|
258
260
|
}
|
|
259
261
|
if (error instanceof ClineAuthError) throw error;
|
|
260
|
-
if (Date.now()
|
|
262
|
+
if (Date.now() >= deadline) throw new Error("WorkOS device authorization timed out");
|
|
261
263
|
await sleepMs(remainingMs(deadline, interval), outerSignal);
|
|
262
264
|
continue;
|
|
263
265
|
}
|
|
@@ -289,7 +291,7 @@ export async function pollDeviceToken(
|
|
|
289
291
|
{ status: response.status, errorCode: typeof payload.error === "string" ? payload.error : undefined },
|
|
290
292
|
);
|
|
291
293
|
}
|
|
292
|
-
if (Date.now()
|
|
294
|
+
if (Date.now() >= deadline) throw new Error("WorkOS device authorization timed out");
|
|
293
295
|
await sleepMs(remainingMs(deadline, interval), outerSignal);
|
|
294
296
|
}
|
|
295
297
|
}
|