pi-freeflow 1.42.0 → 1.43.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.43.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Auto-detect the installed OpenCode 1.x version and auto-repair the OpenCode lane on version drift. The proxy now parses the real client version (`parseOpenCodeBinaryVersion` plus a memoized `probeInstalledOpenCodeVersion` via `opencode --version`) with precedence env override > installed 1.x > live registry > disk > `1.19.4` fallback, so fingerprint User-Agent and headers track the actual installed client instead of a pinned constant. A 2.x major never auto-switches the compat UA; it surfaces via `getLiveOpenCodeVersion` only. Outgoing requests strip stale caller `x-opencode-*` stamps and always stamp the live roster, relay shape-429s stay terminal with no health mark, and the proxy retries once through `refreshOpenCodeUserAgent` on 426/version-drift language.
8
+
9
+ ### Patch Changes
10
+
11
+ - Harden the OpenCode fingerprint, account pools, and relay/update paths. Requests collapse to a single live OpenCode fingerprint (11-tool roster, full client User-Agent, fresh request id per prepare); silent Zen-to-Kilo failover moves behind the `PI_FREEFLOW_ZEN_KILO_FAILOVER` opt-in. The Cline pool clones on load/save with trimmed tokens and tightened stream sniffing, hedge rate-limit signals are preserved, sticky relay removal is trimmed, quota markers are narrowed, and state writes share one atomic path. Also fixes version comparison, updater probing, token mtime caching, and lease validation. The OpenCode lane is live SERVING (200 billing free).
12
+ - fb521b1: Retire two OpenCode Zen models that upstream removed (issue #121): `longcat-2.5-preview-free` answers `410 Model has been deprecated`, and `nemotron-3-ultra-free` answers `400 Model is unavailable` — both ids are also absent from the live Zen free listing, so neither can serve traffic anymore. The catalog drops from 34 to 32 models (Zen lane 10 to 8); both ids are dead-listed so no stale disk cache or upstream merge can re-register them, and their `longcat` / `longcat-2.5-preview` short aliases are removed. The eight models that answered `429 FreeUsageLimitError` in the same run are untouched: rate-limit verdicts are an availability fault, never a catalog action. The sibling Kilo id `nvidia/nemotron-3-ultra-550b-a55b:free` is a separate serving route and is unaffected.
13
+ - fb521b1: Record opt-in OpenCode Zen free-tier request variants for A/B testing once upstream quota recovers, with no change to the default request. A newer four-part client identifier, alternate project header values (fixed, global, or working-directory digest), and the alternate usage-marked request lane are now available as selectable variants for controlled one-variable comparisons. Default traffic is byte-identical: same User-Agent, project value, lane, tool set, and exactly-one environment block as before.
14
+ - fb521b1: Fix OpenCode Zen free-tier requests being refused with `429 FreeUsageLimitError` ("Free usage limit reached") when the system prompt carries no environment block. The same body answers `200` with the block and `429` without it on the same machine, in the same minute, so the countdown to 00:00 UTC is a generic error body rather than a spent quota. Outgoing Zen requests now carry exactly one `<env>` block containing `Working directory:`: a complete block the host already sent is passed through untouched, a stale or incomplete one is replaced instead of duplicated, and the Kilo, Cline and OVHcloud lanes are unchanged. A conversation that already ate a refusal also keeps failing over to Kilo on its retry instead of replaying the refusal.
15
+
16
+ ## 1.42.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 5ab7509: Enable forced round-robin relay selection for stateless OVHcloud AI requests across all healthy pool relays, load-balancing requests to prevent hitting upstream per-IP rate limits (2 req/min) while preserving sticky session affinity for OpenCode Zen.
21
+
3
22
  ## 1.42.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 34 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 32 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
- | **34 free models** | 10 from OpenCode Zen, 15 from KiloCode Gateway, 1 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
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
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** |
@@ -214,7 +214,7 @@ The same command set works identically in OMP and Pi:
214
214
 
215
215
  ---
216
216
 
217
- ### 34 models, one command
217
+ ### 32 models, one command
218
218
 
219
219
  ```bash
220
220
  /model → freeflow → pick
@@ -224,7 +224,7 @@ Every model specification is reconciled against [models.dev](https://models.dev/
224
224
 
225
225
  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`.
226
226
 
227
- #### OpenCode Zen (10 models), Chat Completions API
227
+ #### OpenCode Zen (8 models), Chat Completions API
228
228
 
229
229
  Good defaults for long coding sessions and agentic work.
230
230
 
@@ -233,9 +233,7 @@ Good defaults for long coding sessions and agentic work.
233
233
  | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **128K** (131.072) | `minimal … xhigh`\* | ❌ |
234
234
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **235K** (235.929) | `minimal … xhigh` | ❌ |
235
235
  | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.048.576) | **235K** (235.929) | `minimal … xhigh` | ✅ |
236
- | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **182K** (182.520) | `minimal … xhigh` | ❌ |
237
236
  | `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
238
- | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
239
237
  | `glm-5.3-flash-free` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ✅ |
240
238
  | `beam-free` | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
241
239
  | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.048.576) | **393K** (393.216) | `low / high / max` | ✅ |
@@ -394,7 +392,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
394
392
  #### Project structure
395
393
 
396
394
  ```
397
- ├── models.ts # 34-model catalog definitions
395
+ ├── models.ts # 32-model catalog definitions
398
396
  ├── index.ts # extension entry, lifecycle hooks
399
397
  ├── catalog.ts # model catalog cache (24h disk)
400
398
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.42.0",
4
+ "version": "1.43.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
@@ -182,6 +182,19 @@ export const DEAD_MODEL_IDS = new Set<string>([
182
182
  // model that drains account credits and answers HTTP 402 insufficient_credits.
183
183
  // Replaced with Cline's verified working free tier model.
184
184
  "z-ai/glm-5.3-flash",
185
+ // longcat-2.5-preview-free: deprecated upstream (2026-10-26, issue #121) —
186
+ // the endpoint answers HTTP 410 "Model longcat-2.5-preview-free has been
187
+ // deprecated", and the id is absent from the live Zen free listing (watch
188
+ // state 2026-10-24). Permanently dead-listed so no disk cache or upstream
189
+ // merge can put it back.
190
+ "longcat-2.5-preview-free",
191
+ // nemotron-3-ultra-free: retired upstream (2026-10-26, issue #121) — the
192
+ // endpoint answers HTTP 400 "Model is unavailable", and the id is absent
193
+ // from the live Zen free listing (watch state 2026-10-24 lastChecked), the
194
+ // same removal-plus-unavailable signature as step-5-preview-free. The
195
+ // sibling Kilo id nvidia/nemotron-3-ultra-550b-a55b:free is a separate
196
+ // serving route and is unaffected.
197
+ "nemotron-3-ultra-free",
185
198
  ]);
186
199
  /**
187
200
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -221,7 +234,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
221
234
  }
222
235
  /**
223
236
  * In-memory cache of currently active/available free models.
224
- * Initialized with all 35 verified models for 0ms instant availability.
237
+ * Initialized with all 32 verified models for 0ms instant availability.
225
238
  */
226
239
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
227
240
  ...m,
@@ -320,8 +333,12 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
320
333
  idLower.includes("glm") ||
321
334
  idLower.includes("solar");
322
335
 
336
+ // Declared fields win over wire aliases: a listing that already names
337
+ // contextWindow/maxTokens/reasoning keeps its values; the snake_case
338
+ // upstream aliases and the id heuristics only fill gaps.
323
339
  let contextWindow =
324
- typeof raw.context_length === "number" ? raw.context_length : 262_144;
340
+ typeof raw.contextWindow === "number" ? raw.contextWindow
341
+ : typeof raw.context_length === "number" ? raw.context_length : 262_144;
325
342
  if (
326
343
  idLower.includes("1m") ||
327
344
  idLower.includes("ultra") ||
@@ -333,7 +350,8 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
333
350
  }
334
351
 
335
352
  let maxTokens =
336
- typeof raw.max_output_tokens === "number"
353
+ typeof raw.maxTokens === "number" ? raw.maxTokens
354
+ : typeof raw.max_output_tokens === "number"
337
355
  ? raw.max_output_tokens
338
356
  : 65_536;
339
357
  if (idLower.includes("ultra") || idLower.includes("lightning")) {
@@ -351,7 +369,7 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
351
369
  id: raw.id,
352
370
  name: formatCleanDisplayName(raw.id),
353
371
  source,
354
- reasoning: hasReasoning,
372
+ reasoning: typeof raw.reasoning === "boolean" ? raw.reasoning : hasReasoning,
355
373
  contextWindow,
356
374
  maxTokens,
357
375
  api: isResponses ? "openai-responses" : undefined,
package/src/client.ts CHANGED
@@ -206,8 +206,8 @@ export function resolveDaemonRuntime(opts?: {
206
206
  }
207
207
 
208
208
  function daemonScriptPath(): string {
209
- const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
210
- return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
209
+ const p = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
210
+ return process.platform === "win32" ? p.replace(/\\/g, "/") : p;
211
211
  }
212
212
 
213
213
  function getHeartbeatMs(): number {
@@ -601,18 +601,18 @@ async function killStaleDaemon(
601
601
  remoteVer,
602
602
  expected: PKG_VERSION,
603
603
  });
604
- await killPortHolder(port);
605
- for (let i = 0; i < 15; i++) {
606
- await new Promise<void>((r) => setTimeout(r, 100));
607
- if (!(await isProxyAlive(port))) {
608
- lastSpawnAt = 0;
609
- return true;
610
- }
611
- }
612
- logInfo(
613
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
614
- );
615
- return false;
604
+ await killPortHolder(port);
605
+ for (let i = 0; i < 15; i++) {
606
+ await new Promise<void>((r) => setTimeout(r, 100));
607
+ if (!(await isProxyAlive(port))) {
608
+ lastSpawnAt = 0;
609
+ return true;
610
+ }
611
+ }
612
+ logInfo(
613
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
614
+ );
615
+ return false;
616
616
  }
617
617
  /**
618
618
  * Open the shared log file for the detached daemon's stdout/stderr so a
@@ -1009,40 +1009,40 @@ function spawnDaemonProcess(): DaemonChild | null {
1009
1009
  }
1010
1010
 
1011
1011
  export async function waitForReady(
1012
- port: number,
1013
- timeoutMs: number,
1014
- child?: DaemonChild | null,
1012
+ port: number,
1013
+ timeoutMs: number,
1014
+ child?: DaemonChild | null,
1015
1015
  ): Promise<boolean> {
1016
- const deadline = Date.now() + timeoutMs;
1017
- while (Date.now() < deadline) {
1018
- if (await isProxyAlive(port, 150)) return true;
1019
- if (child && (hasChildExited(child) || failedDaemonSpawns.has(child))) {
1020
- logWarn("daemon child exited before becoming ready", {
1021
- port,
1022
- exitCode: child.exitCode,
1023
- signalCode: child.signalCode,
1024
- });
1025
- return false;
1026
- }
1027
- await new Promise<void>((r) => setTimeout(r, 80));
1028
- }
1029
- return false;
1016
+ const deadline = Date.now() + timeoutMs;
1017
+ while (Date.now() < deadline) {
1018
+ if (await isProxyAlive(port, 150)) return true;
1019
+ if (child && (hasChildExited(child) || failedDaemonSpawns.has(child))) {
1020
+ logWarn("daemon child exited before becoming ready", {
1021
+ port,
1022
+ exitCode: child.exitCode,
1023
+ signalCode: child.signalCode,
1024
+ });
1025
+ return false;
1026
+ }
1027
+ await new Promise<void>((r) => setTimeout(r, 80));
1028
+ }
1029
+ return false;
1030
1030
  }
1031
1031
 
1032
1032
  async function probeAndMaybeReplace(
1033
- port: number,
1034
- label: string,
1035
- probeTimeoutMs = 150,
1033
+ port: number,
1034
+ label: string,
1035
+ probeTimeoutMs = 150,
1036
1036
  ): Promise<number | null> {
1037
- if (!(await isProxyAlive(port, probeTimeoutMs))) return null;
1038
- const ver = await getDaemonVersion(port);
1039
- if (ver !== null && ver !== PKG_VERSION) {
1040
- logWarn(`detected proxy daemon version mismatch: running v${ver}, required v${PKG_VERSION} — auto-restarting`);
1041
- await killStaleDaemon(port, ver, label);
1042
- lastSpawnAt = 0;
1043
- return null;
1044
- }
1045
- return port;
1037
+ if (!(await isProxyAlive(port, probeTimeoutMs))) return null;
1038
+ const ver = await getDaemonVersion(port);
1039
+ if (ver !== null && ver !== PKG_VERSION) {
1040
+ logWarn(`detected proxy daemon version mismatch: running v${ver}, required v${PKG_VERSION} — auto-restarting`);
1041
+ await killStaleDaemon(port, ver, label);
1042
+ lastSpawnAt = 0;
1043
+ return null;
1044
+ }
1045
+ return port;
1046
1046
  }
1047
1047
 
1048
1048
  /**
@@ -1078,8 +1078,8 @@ async function ensureDaemonAttempt(): Promise<number> {
1078
1078
  }
1079
1079
 
1080
1080
 
1081
- if (isSpawnEnabled()) {
1082
- const child = spawnDaemonProcess();
1081
+ if (isSpawnEnabled()) {
1082
+ const child = spawnDaemonProcess();
1083
1083
  if (child) {
1084
1084
  const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS, child);
1085
1085
  if (ready) {
@@ -118,7 +118,6 @@ export function _resetClinePoolCacheForTest(): void {
118
118
 
119
119
  /** Bundled fallback version if dynamic fetch and disk cache are absent. */
120
120
  export const BUNDLED_CLINE_VERSION = "4.1.25";
121
- export const CLINE_CLIENT_VERSION = BUNDLED_CLINE_VERSION;
122
121
 
123
122
  const CLINE_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
124
123
  let liveClineVersion: string | null = null;
@@ -377,6 +376,24 @@ function emptyPool(): ClinePoolState {
377
376
  return { accounts: [] };
378
377
  }
379
378
 
379
+ /**
380
+ * Deep clone a pool snapshot so callers can never alias the cache: mutating a
381
+ * loaded pool must not corrupt the next load, and mutating the argument after
382
+ * savePool must not rewrite what was cached.
383
+ */
384
+ function clonePool(pool: ClinePoolState): ClinePoolState {
385
+ return {
386
+ accounts: pool.accounts.map((a) => ({ ...a })),
387
+ ...(pool.activeSlot !== undefined ? { activeSlot: pool.activeSlot } : {}),
388
+ ...(pool.limits
389
+ ? { limits: Object.fromEntries(Object.entries(pool.limits).map(([slot, models]) => [slot, { ...models }])) }
390
+ : {}),
391
+ ...(pool.usage
392
+ ? { usage: Object.fromEntries(Object.entries(pool.usage).map(([slot, row]) => [slot, { ...row }])) }
393
+ : {}),
394
+ };
395
+ }
396
+
380
397
  /**
381
398
  * Read the optional `limits` map out of a pool document, dropping anything
382
399
  * malformed and every entry whose reset has already passed. Returns undefined
@@ -455,8 +472,11 @@ function parsePoolDoc(raw: string): ClinePoolState | null {
455
472
  if (typeof entry !== "object" || entry === null) continue;
456
473
  const rec: Record<string, unknown> = entry as Record<string, unknown>;
457
474
  const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
458
- const token = typeof rec.token === "string" ? rec.token : "";
459
- if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
475
+ const token = typeof rec.token === "string" ? rec.token.trim() : "";
476
+ if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) {
477
+ logWarn("cline pool entry rejected", { slot: slot || "(unnamed)" });
478
+ continue;
479
+ }
460
480
  const refreshRaw: unknown = rec.refreshToken;
461
481
  const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
462
482
  const expiresRaw: unknown = rec.expiresAt;
@@ -542,7 +562,7 @@ function diskMtime(): number {
542
562
  */
543
563
  export function loadPool(): ClinePoolState {
544
564
  const before = diskMtime();
545
- if (cached && before === cachedMtime) return cached;
565
+ if (cached && before === cachedMtime) return clonePool(cached);
546
566
  let loaded = readPoolFile();
547
567
  let stamp = diskMtime();
548
568
  // The read is only trustworthy when the file did not move across it. Reading
@@ -553,9 +573,9 @@ export function loadPool(): ClinePoolState {
553
573
  loaded = readPoolFile();
554
574
  stamp = diskMtime();
555
575
  }
556
- cached = loaded;
576
+ cached = clonePool(loaded);
557
577
  cachedMtime = stamp;
558
- return cached;
578
+ return clonePool(cached);
559
579
  }
560
580
 
561
581
  /**
@@ -653,7 +673,7 @@ export function savePool(pool: ClinePoolState): boolean {
653
673
  }
654
674
  // Compare the pool just written too, so growth is never the copy that lags.
655
675
  keepLargestBackup(serialized);
656
- cached = pool;
676
+ cached = clonePool(pool);
657
677
  cachedMtime = diskMtime();
658
678
  return true;
659
679
  } catch (e) {
@@ -811,16 +831,17 @@ export function addAccount(
811
831
  const cleanSlot = (slot || "").trim();
812
832
  if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
813
833
  if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
814
- if (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
834
+ const cleanToken = (token || "").trim();
835
+ if (!cleanToken.startsWith("workos:") && !isWorkosJwt(cleanToken) && !cleanToken.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
815
836
  const pool = loadPool();
816
837
  const existing = pool.accounts.find((a) => a.slot === cleanSlot);
817
- const duplicate = findClineAccountSlot(pool, { token, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
838
+ const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
818
839
  if (duplicate) {
819
840
  const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
820
841
  throw new Error(`That Cline account (${who}) is already saved as [${duplicate}] — log in with a different account, or use /freeflow cline logout ${duplicate} first`);
821
842
  }
822
843
  if (existing) {
823
- existing.token = token;
844
+ existing.token = cleanToken;
824
845
  // A fresh login is proof the grant works again: a quarantine recorded
825
846
  // against the credential it replaces must never outlive it.
826
847
  delete existing.deadUntil;
@@ -840,7 +861,7 @@ export function addAccount(
840
861
  } else {
841
862
  pool.accounts.push({
842
863
  slot: cleanSlot,
843
- token,
864
+ token: cleanToken,
844
865
  addedAt: new Date().toISOString(),
845
866
  ...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
846
867
  ...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
@@ -858,7 +879,7 @@ export function addAccount(
858
879
  // Re-read the way the relay deploy path does before blaming the write.
859
880
  let landed = false;
860
881
  try {
861
- landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === token);
882
+ landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
862
883
  } catch {
863
884
  landed = false;
864
885
  }
@@ -868,7 +889,7 @@ export function addAccount(
868
889
  );
869
890
  }
870
891
  }
871
- return pool;
892
+ return clonePool(pool);
872
893
  }
873
894
 
874
895
  /** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
@@ -911,7 +932,8 @@ async function refreshAccountInPlace(
911
932
  void e;
912
933
  return null;
913
934
  }
914
- if (!fresh || typeof fresh.token !== "string" || (!fresh.token.startsWith("workos:") && !isWorkosJwt(fresh.token) && !fresh.token.startsWith("clp_"))) {
935
+ const freshToken = fresh !== null && typeof fresh.token === "string" ? fresh.token.trim() : "";
936
+ if (fresh === null || !freshToken || (!freshToken.startsWith("workos:") && !isWorkosJwt(freshToken) && !freshToken.startsWith("clp_"))) {
915
937
  // Cline rejected the grant outright: park the slot on disk so the next
916
938
  // turns skip it instead of re-paying a refresh that cannot succeed. The
917
939
  // credential stays in the pool — a fresh login into the slot clears this —
@@ -924,7 +946,7 @@ async function refreshAccountInPlace(
924
946
  patchAccountOnDisk(account.slot, { deadUntil: account.deadUntil });
925
947
  return false;
926
948
  }
927
- account.token = fresh.token;
949
+ account.token = freshToken;
928
950
  if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
929
951
  if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
930
952
  account.expiresAt = fresh.expiresAt;
@@ -1078,17 +1100,19 @@ async function peekStreamForInStreamError(res: Response): Promise<{ res: Respons
1078
1100
  };
1079
1101
  }
1080
1102
  const text = new TextDecoder().decode(value);
1081
- const isInStream429 = (
1082
- text.includes('"stream_initialization_failed"') ||
1083
- text.includes('"error":') ||
1084
- text.includes('"Rate limit exceeded"') ||
1085
- text.includes("Daily limit reached")
1086
- ) && (
1087
- text.includes("429") ||
1088
- /status["\s:]+429/i.test(text) ||
1103
+ // Strict: only an in-stream initialization failure carrying a 429 signal
1104
+ // converts. A 200 whose first chunk merely mentions "error" or "429" (model
1105
+ // content, tool output) must pass through untouched.
1106
+ const hasInitFailed = text.includes("stream_initialization_failed");
1107
+ const has429Signal = (
1108
+ /status[^0-9]{0,12}429/i.test(text) ||
1109
+ /"code"\s*:\s*429/.test(text) ||
1089
1110
  /Rate limit exceeded/i.test(text) ||
1090
- /Daily limit reached/i.test(text)
1111
+ /Daily limit reached/i.test(text) ||
1112
+ /free limit reached/i.test(text) ||
1113
+ /Daily free limit/i.test(text)
1091
1114
  );
1115
+ const isInStream429 = hasInitFailed && has429Signal;
1092
1116
 
1093
1117
  if (isInStream429) {
1094
1118
  try { await reader.cancel(); } catch { }
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Cline WorkOS device-flow auth (extension login, no paste).
3
- * Mirrors C:/tmp/cline-login-proto/src/cline-auth.ts + reference/cline
4
- * sdk/packages/core/src/auth/cline.ts (device authorize / poll /
5
- * register / refresh) and provider-auth-registry.ts (expiry derive,
6
- * workos: prefix). All network goes through injectable fetchImpl;
3
+ * Mirrors the Cline device authorize / poll / register / refresh flow
4
+ * (sdk device-auth plus provider-auth-registry expiry derive, workos:
5
+ * prefix). All network goes through injectable fetchImpl;
7
6
  * mocked tests only, no live calls here.
8
7
  */
9
8
 
package/src/commands.ts CHANGED
@@ -1228,7 +1228,7 @@ export function createCommandSpec(
1228
1228
  if (cached && typeof cached.latest === "string") {
1229
1229
  latest = cached.latest;
1230
1230
  }
1231
- if (latest && compareVersions(latest, local) > 0) {
1231
+ if (latest && local && compareVersions(latest, local) > 0) {
1232
1232
  ctx.ui.notify(
1233
1233
  `Update available: ${local} -> ${latest} - run /freeflow update`,
1234
1234
  "info",
@@ -1325,11 +1325,12 @@ export function createCommandSpec(
1325
1325
  ctx.ui.notify("Could not check latest version (offline?)", "warning");
1326
1326
  } else {
1327
1327
  const local = getLocalVersion();
1328
- const cmp = compareVersions(latest, local);
1328
+ // Null local (unreadable package.json) can never prove up-to-date: run.
1329
+ const cmp = local === null ? 1 : compareVersions(latest, local);
1329
1330
  if (cmp <= 0) {
1330
1331
  ctx.ui.notify(`Already on latest (v${local})`, "info");
1331
1332
  } else {
1332
- ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
1333
+ ctx.ui.notify(`Update available: v${local ?? "unknown"} → v${latest} — updating…`, "info");
1333
1334
  // Host plugin managers first (neither host has an `update` action for
1334
1335
  // registry plugins: omp updates by reinstall, pi updates the named
1335
1336
  // package). Global npm/bun is the last resort, not the default.