pi-freeflow 1.42.1 → 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 +13 -0
- package/README.md +5 -7
- package/package.json +1 -1
- package/src/catalog.ts +22 -4
- package/src/client.ts +45 -45
- package/src/cline-accounts.ts +48 -24
- package/src/cline-device-auth.ts +3 -4
- package/src/commands.ts +4 -3
- package/src/config.ts +275 -29
- package/src/health.ts +117 -116
- package/src/lease.ts +57 -53
- package/src/logger.ts +357 -357
- package/src/models.ts +13 -54
- package/src/opencode-fingerprint.ts +348 -117
- package/src/probe.ts +31 -10
- package/src/proxy.ts +169 -72
- package/src/relay-state.ts +106 -58
- package/src/relay.ts +752 -717
- package/src/stream-pipe.ts +5 -5
- package/src/system-ca-fetch.ts +8 -2
- package/src/token.ts +66 -38
- package/src/tool-args.ts +26 -26
- package/src/tool-translation.ts +266 -61
- package/src/toolcall-audit.ts +2 -2
- package/src/types.ts +4 -2
- package/src/update-checker.ts +88 -88
- package/src/update-core.ts +182 -137
- package/src/updater.ts +161 -103
- package/src/upstream-health.ts +66 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 1.42.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
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
|
-
| **
|
|
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
|
-
###
|
|
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 (
|
|
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 #
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1012
|
+
port: number,
|
|
1013
|
+
timeoutMs: number,
|
|
1014
|
+
child?: DaemonChild | null,
|
|
1015
1015
|
): Promise<boolean> {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1033
|
+
port: number,
|
|
1034
|
+
label: string,
|
|
1035
|
+
probeTimeoutMs = 150,
|
|
1036
1036
|
): Promise<number | null> {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
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) {
|
package/src/cline-accounts.ts
CHANGED
|
@@ -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_")))
|
|
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
|
-
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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 { }
|
package/src/cline-device-auth.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cline WorkOS device-flow auth (extension login, no paste).
|
|
3
|
-
* Mirrors
|
|
4
|
-
* sdk
|
|
5
|
-
*
|
|
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
|
-
|
|
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.
|