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 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>`; the Cline model has no alias and must be typed in full. | **$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>` (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
- ### 32 models, one command
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 (8 models), Chat Completions API
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 (15 models), OpenRouter compatible
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
- | `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ❌ |
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.44.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
- let contextWindow =
340
- typeof raw.contextWindow === "number" ? raw.contextWindow
341
- : typeof raw.context_length === "number" ? raw.context_length : 262_144;
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
- idLower.includes("1m") ||
344
- idLower.includes("ultra") ||
345
- idLower.includes("lightning") ||
346
- idLower.includes("mimo-v2.5") ||
347
- idLower.includes("muse-spark")
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
- let maxTokens =
353
- typeof raw.maxTokens === "number" ? raw.maxTokens
354
- : typeof raw.max_output_tokens === "number"
355
- ? raw.max_output_tokens
356
- : 65_536;
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
 
@@ -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
- await new Promise<void>((r) => setTimeout(r, delay));
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) {
@@ -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 = findExecutableOnPath("node", pathVal);
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
- fs.renameSync(tmpDir, finalDir);
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<void> {
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 = daemonSpawner(runtime.execPath, args, {
610
- detached: true,
611
- stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
612
- windowsHide: true,
613
- env: {
614
- ...process.env,
615
- [DAEMON_SPAWN_ENV]: "0",
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
 
@@ -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
- return null;
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
 
@@ -250,14 +250,16 @@ export async function pollDeviceToken(
250
250
  device_code: deviceCode,
251
251
  client_id: resolveClineWorkosClientId(),
252
252
  }),
253
- signal: AbortSignal.timeout(options?.timeoutMs ?? HTTP_TIMEOUT_MS),
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() > deadline) throw new Error("WorkOS device authorization timed out");
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() > deadline) throw new Error("WorkOS device authorization timed out");
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
  }