pi-freeflow 1.44.0 → 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 +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- 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/src/cline-accounts.ts
CHANGED
|
@@ -1,120 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-user Cline API key pool for pi-freeflow.
|
|
3
|
-
*
|
|
4
|
-
* Cline serves its free models direct-only (never through the relay pool),
|
|
5
|
-
* one bearer key per login slot. Keys live in a 0600 file beside the relay
|
|
6
|
-
* state; a corrupt file reads back as an empty pool (never throws, never
|
|
7
|
-
* logs secrets). Request failover walks the pool in order, trying every slot
|
|
8
|
-
* fresh on every request. A slot whose grant Cline rejected outright is parked
|
|
9
|
-
* for a cooldown, never dropped: the credential stays in the pool and a fresh
|
|
10
|
-
* login into that slot clears the pause at once.
|
|
2
|
+
* Per-user Cline API key pool and version coordination for pi-freeflow.
|
|
11
3
|
*/
|
|
12
4
|
|
|
13
5
|
import fs from "node:fs";
|
|
14
6
|
import path from "node:path";
|
|
15
7
|
import { writeFileAtomic } from "./atomic-write.ts";
|
|
16
|
-
import { RELAY_STATE_FILE } from "./config.ts";
|
|
17
|
-
import { logWarn } from "./logger.ts";
|
|
18
8
|
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
19
|
-
import {
|
|
9
|
+
import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
20
10
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Recovery copy of the pool, read only when the main file is missing or
|
|
36
|
-
* unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
|
|
37
|
-
* difference: an empty pool is a legitimate state here (`/freeflow cline
|
|
38
|
-
* logout` of the last login must stick), so a valid empty main file is never
|
|
39
|
-
* overridden from the backup.
|
|
40
|
-
*/
|
|
41
|
-
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
42
|
-
|
|
43
|
-
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
44
|
-
interface ClineAccount {
|
|
45
|
-
slot: string;
|
|
46
|
-
token: string;
|
|
47
|
-
addedAt: string;
|
|
48
|
-
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
49
|
-
refreshToken?: string;
|
|
50
|
-
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
51
|
-
expiresAt?: number;
|
|
52
|
-
/** WorkOS account id, shown when choosing which login to remove. */
|
|
53
|
-
accountId?: string;
|
|
54
|
-
/** Login email, shown when choosing which login to remove. */
|
|
55
|
-
email?: string;
|
|
56
|
-
/**
|
|
57
|
-
* Epoch ms until which Cline has rejected this slot's grant outright: the
|
|
58
|
-
* roll skips the slot until then rather than paying a doomed refresh every
|
|
59
|
-
* turn. The credential itself is never dropped — past the stamp the slot is
|
|
60
|
-
* tried again, and saving a fresh login into the slot clears it at once.
|
|
61
|
-
*/
|
|
62
|
-
deadUntil?: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface ClinePoolState {
|
|
66
|
-
accounts: ClineAccount[];
|
|
67
|
-
activeSlot?: string;
|
|
68
|
-
/**
|
|
69
|
-
* Daily free-limit resets per slot per *requested* model id (epoch ms):
|
|
70
|
-
* `limits[slot][modelId] = resetAt`. Cline reports its own model name in the
|
|
71
|
-
* 429 body, which never matches the id we asked for, so the requested id is
|
|
72
|
-
* the only key a later request can look up. Expired entries are dropped on
|
|
73
|
-
* read. Optional: pools written before this existed load unchanged.
|
|
74
|
-
*/
|
|
75
|
-
limits?: Record<string, Record<string, number>>;
|
|
76
|
-
/**
|
|
77
|
-
* Per-slot serve counters: `usage[slot] = { served, lastAt, lastModel }`.
|
|
78
|
-
* Bumped every time the slot serves a turn (`served` counts up, `lastAt`
|
|
79
|
-
* is epoch ms, `lastModel` the requested model id). Counters only — no
|
|
80
|
-
* token, email, or other identity ever lives here; callers map
|
|
81
|
-
* slot→identity at display time. Optional: pools written before this
|
|
82
|
-
* existed load unchanged.
|
|
83
|
-
*/
|
|
84
|
-
usage?: Record<string, ClineSlotUsage>;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** One slot's serve counters. See `ClinePoolState.usage`. */
|
|
88
|
-
interface ClineSlotUsage {
|
|
89
|
-
served: number;
|
|
90
|
-
lastAt: number;
|
|
91
|
-
lastModel: string;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Fresh tokens minted via the device-login refresh flow. DeviceAuth owns the
|
|
96
|
-
* network call; this module only applies the result to the pool file.
|
|
97
|
-
*/
|
|
98
|
-
interface ClineRefreshResult {
|
|
99
|
-
token: string;
|
|
100
|
-
refreshToken?: string;
|
|
101
|
-
expiresAt?: number;
|
|
102
|
-
accountId?: string;
|
|
103
|
-
email?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Refresh one slot. Resolves to the fresh tokens, or null when the grant is
|
|
108
|
-
* dead (invalid_grant — the slot must be logged in again). May throw on
|
|
109
|
-
* transient network faults; callers treat a throw as "keep the stale token".
|
|
110
|
-
*/
|
|
111
|
-
type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
|
|
112
|
-
|
|
113
|
-
/** Test-only: drop the loadPool mtime cache. */
|
|
114
|
-
export function _resetClinePoolCacheForTest(): void {
|
|
115
|
-
cached = null;
|
|
116
|
-
cachedMtime = -1;
|
|
117
|
-
}
|
|
11
|
+
import {
|
|
12
|
+
CLINE_POOL_FILE,
|
|
13
|
+
loadPool,
|
|
14
|
+
} from "./cline-pool.ts";
|
|
15
|
+
import {
|
|
16
|
+
isClineTokenStale,
|
|
17
|
+
refreshAccountInPlace,
|
|
18
|
+
type ClineRefresher,
|
|
19
|
+
type ClineRefreshResult,
|
|
20
|
+
} from "./cline-roll.ts";
|
|
118
21
|
|
|
119
22
|
/** Bundled fallback version if dynamic fetch and disk cache are absent. */
|
|
120
23
|
export const BUNDLED_CLINE_VERSION = "4.1.25";
|
|
@@ -185,6 +88,8 @@ export const CLINE_CLIENT_HEADERS: Record<string, string> = new Proxy({} as Reco
|
|
|
185
88
|
},
|
|
186
89
|
});
|
|
187
90
|
|
|
91
|
+
const CLINE_SEMVER_RE = /^\d+\.\d+\.\d+/;
|
|
92
|
+
|
|
188
93
|
/**
|
|
189
94
|
* Refresh the live Cline version from open-vsx or GitHub releases and persist to disk.
|
|
190
95
|
*/
|
|
@@ -196,7 +101,7 @@ export async function refreshClineVersion(fetchImpl: FetchImpl = fetchWithSystem
|
|
|
196
101
|
});
|
|
197
102
|
if (res.ok) {
|
|
198
103
|
const data = (await res.json()) as { version?: string };
|
|
199
|
-
if (typeof data.version === "string" &&
|
|
104
|
+
if (typeof data.version === "string" && CLINE_SEMVER_RE.test(data.version)) {
|
|
200
105
|
liveClineVersion = data.version.trim();
|
|
201
106
|
try {
|
|
202
107
|
const cachePath = clineVersionCachePath();
|
|
@@ -208,14 +113,19 @@ export async function refreshClineVersion(fetchImpl: FetchImpl = fetchWithSystem
|
|
|
208
113
|
} catch { }
|
|
209
114
|
return getClineVersion();
|
|
210
115
|
}
|
|
211
|
-
|
|
116
|
+
|
|
117
|
+
export async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
|
|
212
118
|
try {
|
|
213
119
|
const creds = await refreshClineToken(undefined, refreshToken);
|
|
214
120
|
return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
|
|
215
|
-
} catch {
|
|
216
|
-
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof ClineAuthError && e.status !== undefined && e.status >= 400 && e.status < 500) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
throw e;
|
|
217
126
|
}
|
|
218
127
|
}
|
|
128
|
+
|
|
219
129
|
/**
|
|
220
130
|
* Background auto-probing & keep-alive for Cline account tokens.
|
|
221
131
|
* Proactively refreshes tokens nearing expiry (<30 min remaining or expired)
|
|
@@ -274,1113 +184,6 @@ export async function keepClineTokensAlive(
|
|
|
274
184
|
return { refreshed, probed };
|
|
275
185
|
}
|
|
276
186
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
* Maps a catalog Cline model ID to the exact wire model ID expected by Cline API.
|
|
281
|
-
*/
|
|
282
|
-
export function clineUpstreamModelId(id: string): string {
|
|
283
|
-
const trimmed = id.trim();
|
|
284
|
-
if (trimmed === "cline/nemotron-3.5-super-vl") {
|
|
285
|
-
return "nvidia/nemotron-3.5-super-vl:free";
|
|
286
|
-
}
|
|
287
|
-
if (trimmed.startsWith(CLINE_MODEL_PREFIX)) {
|
|
288
|
-
return trimmed.slice(CLINE_MODEL_PREFIX.length);
|
|
289
|
-
}
|
|
290
|
-
if (trimmed.startsWith("cline-free/")) {
|
|
291
|
-
return trimmed.slice("cline-free/".length);
|
|
292
|
-
}
|
|
293
|
-
return trimmed;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Free-limit report carried by a Cline 429 body, e.g.
|
|
298
|
-
* {"error":{"code":"INFERENCE_CAP_ERROR","message":"Error 429: Daily free limit
|
|
299
|
-
* reached on model meta/muse-spark-1.3-contributor. Try again in 20h 4m"}}.
|
|
300
|
-
*
|
|
301
|
-
* `modelId` is the name Cline spells in its own body, which is a different
|
|
302
|
-
* namespace from the id this proxy requests (measured live 2026-10-13: we ask
|
|
303
|
-
* for `cline-free/muse-spark-1.3-contributor`, Cline answers about
|
|
304
|
-
* `meta/muse-spark-1.3-contributor`). The limit cache is therefore keyed by the
|
|
305
|
-
* requested id; `modelId` is diagnostics only.
|
|
306
|
-
*/
|
|
307
|
-
interface ClineFreeLimit {
|
|
308
|
-
/** Model name as the upstream body spells it. */
|
|
309
|
-
modelId?: string;
|
|
310
|
-
/** Epoch ms the cap lifts; absent when the body stated no delay. */
|
|
311
|
-
resetAt?: number;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const CLINE_FREE_LIMIT_MARKER = /(?:free limit reached on model|Daily limit reached for)/i;
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Extract epoch reset from X-RateLimit-Reset if present in headers or metadata.
|
|
318
|
-
* Converts epoch seconds (<1e11) to ms.
|
|
319
|
-
*/
|
|
320
|
-
function parseXRateLimitReset(text: string): number | null {
|
|
321
|
-
const m = text.match(/X-RateLimit-Reset[\\"\s:]+([0-9.]+)/i);
|
|
322
|
-
if (!m) return null;
|
|
323
|
-
const val = Number(m[1]);
|
|
324
|
-
if (!Number.isFinite(val) || val <= 0) return null;
|
|
325
|
-
return val < 1e11 ? Math.round(val * 1000) : Math.round(val);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Delay stated as `try again in <N>h <N>m`, `<N>m`, or a bare `<N>` (hours).
|
|
330
|
-
* Returns null when the body states no delay this parser understands.
|
|
331
|
-
*/
|
|
332
|
-
function parseResetDelayMs(text: string): number | null {
|
|
333
|
-
const hoursMinutes = text.match(/try again in\s+([0-9]+)\s*h(?:ours?|rs?)?(?:\s*([0-9]+)\s*m(?:in(?:utes?)?)?)?/i);
|
|
334
|
-
if (hoursMinutes) {
|
|
335
|
-
const hours = Number(hoursMinutes[1]);
|
|
336
|
-
const minutes = hoursMinutes[2] ? Number(hoursMinutes[2]) : 0;
|
|
337
|
-
return (hours * 60 + minutes) * 60_000;
|
|
338
|
-
}
|
|
339
|
-
const minutesOnly = text.match(/try again in\s+([0-9]+)\s*m(?:in(?:utes?)?)?/i);
|
|
340
|
-
if (minutesOnly) return Number(minutesOnly[1]) * 60_000;
|
|
341
|
-
const bareHours = text.match(/try again in\s+([0-9]+)(?![0-9a-z])/i);
|
|
342
|
-
return bareHours ? Number(bareHours[1]) * 3600_000 : null;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Parse an upstream 429 body for the daily free-limit marker. Returns null for
|
|
347
|
-
* anything else, so a plain edge 429 keeps its existing behavior.
|
|
348
|
-
*/
|
|
349
|
-
export function parseClineFreeLimit(raw: string, now = Date.now()): ClineFreeLimit | null {
|
|
350
|
-
if (typeof raw !== "string" || !CLINE_FREE_LIMIT_MARKER.test(raw)) return null;
|
|
351
|
-
const named = raw.match(/(?:free limit reached on model|Daily limit reached for)\s+([^\\"\s,;]+)/i);
|
|
352
|
-
// The model name ends the sentence, so a trailing "." belongs to the prose.
|
|
353
|
-
const modelId = named ? named[1].replace(/[.,;:]+$/, "") : "";
|
|
354
|
-
const epochReset = parseXRateLimitReset(raw);
|
|
355
|
-
const delay = epochReset !== null ? null : parseResetDelayMs(raw);
|
|
356
|
-
const resetAt = epochReset !== null ? epochReset : delay !== null ? now + delay : undefined;
|
|
357
|
-
return {
|
|
358
|
-
...(modelId ? { modelId } : {}),
|
|
359
|
-
...(resetAt !== undefined ? { resetAt } : {}),
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Machine-classified outcome of one Cline chat attempt. */
|
|
364
|
-
type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
|
|
365
|
-
|
|
366
|
-
export function mapClineError(status: number): ClineErrorKind {
|
|
367
|
-
if (status >= 200 && status < 300) return "ok";
|
|
368
|
-
if (status === 401 || status === 403) return "auth";
|
|
369
|
-
if (status === 429) return "rate-limit";
|
|
370
|
-
if (status === 402) return "exhausted";
|
|
371
|
-
if (status >= 500) return "server";
|
|
372
|
-
return "client";
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function emptyPool(): ClinePoolState {
|
|
376
|
-
return { accounts: [] };
|
|
377
|
-
}
|
|
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
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Read the optional `limits` map out of a pool document, dropping anything
|
|
399
|
-
* malformed and every entry whose reset has already passed. Returns undefined
|
|
400
|
-
* when nothing usable is left, so a pool that never saw a cap keeps its old
|
|
401
|
-
* shape on disk.
|
|
402
|
-
*/
|
|
403
|
-
function parseLimits(raw: unknown, now: number): Record<string, Record<string, number>> | undefined {
|
|
404
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
405
|
-
const limits: Record<string, Record<string, number>> = {};
|
|
406
|
-
for (const [slot, modelsRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
407
|
-
if (!slot.trim()) continue;
|
|
408
|
-
if (typeof modelsRaw !== "object" || modelsRaw === null || Array.isArray(modelsRaw)) continue;
|
|
409
|
-
const models: Record<string, number> = {};
|
|
410
|
-
for (const [model, resetRaw] of Object.entries(modelsRaw as Record<string, unknown>)) {
|
|
411
|
-
if (!model.trim()) continue;
|
|
412
|
-
if (typeof resetRaw !== "number" || !Number.isFinite(resetRaw) || resetRaw <= now) continue;
|
|
413
|
-
models[model] = resetRaw;
|
|
414
|
-
}
|
|
415
|
-
if (Object.keys(models).length > 0) limits[slot.trim()] = models;
|
|
416
|
-
}
|
|
417
|
-
return Object.keys(limits).length > 0 ? limits : undefined;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Read the optional `usage` map out of a pool document, dropping anything
|
|
422
|
-
* malformed. Returns undefined when nothing usable is left, so a pool that
|
|
423
|
-
* never served a turn keeps its old shape on disk.
|
|
424
|
-
*/
|
|
425
|
-
function parseUsage(raw: unknown): Record<string, ClineSlotUsage> | undefined {
|
|
426
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
427
|
-
const usage: Record<string, ClineSlotUsage> = {};
|
|
428
|
-
for (const [slot, entryRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
429
|
-
if (!slot.trim()) continue;
|
|
430
|
-
if (typeof entryRaw !== "object" || entryRaw === null || Array.isArray(entryRaw)) continue;
|
|
431
|
-
const entry: Record<string, unknown> = entryRaw as Record<string, unknown>;
|
|
432
|
-
const served = entry.served;
|
|
433
|
-
const lastAt = entry.lastAt;
|
|
434
|
-
const lastModel = entry.lastModel;
|
|
435
|
-
if (typeof served !== "number" || !Number.isFinite(served) || served < 0) continue;
|
|
436
|
-
if (typeof lastAt !== "number" || !Number.isFinite(lastAt) || lastAt <= 0) continue;
|
|
437
|
-
if (typeof lastModel !== "string") continue;
|
|
438
|
-
usage[slot.trim()] = { served: Math.floor(served), lastAt, lastModel };
|
|
439
|
-
}
|
|
440
|
-
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Parse a pool document. Returns null when the blob is unusable (bad JSON,
|
|
445
|
-
* wrong shape) so callers can fall back to the backup copy. A valid document
|
|
446
|
-
* with zero accounts is NOT null: that is a real empty pool.
|
|
447
|
-
*/
|
|
448
|
-
function parsePoolDoc(raw: string): ClinePoolState | null {
|
|
449
|
-
let parsed: unknown;
|
|
450
|
-
try {
|
|
451
|
-
parsed = JSON.parse(raw);
|
|
452
|
-
} catch {
|
|
453
|
-
logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
457
|
-
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
// Boundary-narrowed once: the on-disk blob is external input, so check
|
|
461
|
-
// its shape here and read only validated fields below.
|
|
462
|
-
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
463
|
-
if (!Array.isArray(doc.accounts)) {
|
|
464
|
-
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
467
|
-
const accounts: ClineAccount[] = [];
|
|
468
|
-
// One clock for the whole document: a quarantine stamp and a cap reset are
|
|
469
|
-
// both compared against the same instant.
|
|
470
|
-
const now = Date.now();
|
|
471
|
-
for (const entry of doc.accounts as unknown[]) {
|
|
472
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
473
|
-
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
474
|
-
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
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
|
-
}
|
|
480
|
-
const refreshRaw: unknown = rec.refreshToken;
|
|
481
|
-
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
482
|
-
const expiresRaw: unknown = rec.expiresAt;
|
|
483
|
-
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
484
|
-
? expiresRaw
|
|
485
|
-
: undefined;
|
|
486
|
-
const accountIdRaw: unknown = rec.accountId;
|
|
487
|
-
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
488
|
-
const emailRaw: unknown = rec.email;
|
|
489
|
-
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
490
|
-
// An elapsed quarantine is dropped on read, so a slot Cline rejected last
|
|
491
|
-
// week is a normal candidate again and the field never lingers in the file.
|
|
492
|
-
const deadRaw: unknown = rec.deadUntil;
|
|
493
|
-
const deadUntil = typeof deadRaw === "number" && Number.isFinite(deadRaw) && deadRaw > now ? deadRaw : undefined;
|
|
494
|
-
accounts.push({
|
|
495
|
-
slot,
|
|
496
|
-
token,
|
|
497
|
-
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
498
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
499
|
-
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
500
|
-
...(accountId ? { accountId } : {}),
|
|
501
|
-
...(email ? { email } : {}),
|
|
502
|
-
...(deadUntil !== undefined ? { deadUntil } : {}),
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
const activeRaw: unknown = doc.activeSlot;
|
|
506
|
-
const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
|
|
507
|
-
? activeRaw.trim()
|
|
508
|
-
: undefined;
|
|
509
|
-
const limits = parseLimits(doc.limits, now);
|
|
510
|
-
const usage = parseUsage(doc.usage);
|
|
511
|
-
const state: ClinePoolState = { accounts };
|
|
512
|
-
if (activeSlot) state.activeSlot = activeSlot;
|
|
513
|
-
if (limits) state.limits = limits;
|
|
514
|
-
if (usage) state.usage = usage;
|
|
515
|
-
return state;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Recover the pool from the backup copy and heal the main file so the recovery
|
|
520
|
-
* sticks. Returns null when no usable backup exists. Only ever called for a
|
|
521
|
-
* missing or unusable main file — never to override a valid empty pool.
|
|
522
|
-
*/
|
|
523
|
-
function recoverPoolFromBackup(description: string): ClinePoolState | null {
|
|
524
|
-
try {
|
|
525
|
-
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
526
|
-
if (parsed && parsed.accounts.length > 0) {
|
|
527
|
-
logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
|
|
528
|
-
savePool(parsed);
|
|
529
|
-
return parsed;
|
|
530
|
-
}
|
|
531
|
-
} catch { }
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function readPoolFile(): ClinePoolState {
|
|
536
|
-
try {
|
|
537
|
-
if (!fs.existsSync(CLINE_POOL_FILE)) {
|
|
538
|
-
return recoverPoolFromBackup("main file missing") ?? emptyPool();
|
|
539
|
-
}
|
|
540
|
-
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
541
|
-
if (parsed) return parsed;
|
|
542
|
-
return recoverPoolFromBackup("file unusable") ?? emptyPool();
|
|
543
|
-
} catch {
|
|
544
|
-
return emptyPool();
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
let cached: ClinePoolState | null = null;
|
|
549
|
-
let cachedMtime = -1;
|
|
550
|
-
|
|
551
|
-
function diskMtime(): number {
|
|
552
|
-
try {
|
|
553
|
-
return fs.statSync(CLINE_POOL_FILE).mtimeMs;
|
|
554
|
-
} catch {
|
|
555
|
-
return -1;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Load the pool, re-reading from disk only when another process changed the
|
|
561
|
-
* file (mtime moved). Never throws; corrupt reads come back empty.
|
|
562
|
-
*/
|
|
563
|
-
export function loadPool(): ClinePoolState {
|
|
564
|
-
const before = diskMtime();
|
|
565
|
-
if (cached && before === cachedMtime) return clonePool(cached);
|
|
566
|
-
let loaded = readPoolFile();
|
|
567
|
-
let stamp = diskMtime();
|
|
568
|
-
// The read is only trustworthy when the file did not move across it. Reading
|
|
569
|
-
// first and stamping afterwards latches pre-read content under the post-write
|
|
570
|
-
// mtime, which hides the change until the next write. Re-read once; the mtime
|
|
571
|
-
// observed after that read is the one the returned pool is cached under.
|
|
572
|
-
if (stamp !== before) {
|
|
573
|
-
loaded = readPoolFile();
|
|
574
|
-
stamp = diskMtime();
|
|
575
|
-
}
|
|
576
|
-
cached = clonePool(loaded);
|
|
577
|
-
cachedMtime = stamp;
|
|
578
|
-
return clonePool(cached);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Keep the largest pool ever seen as the recovery copy.
|
|
583
|
-
*
|
|
584
|
-
* Largest-wins rather than last-write-wins, deliberately. The backup is read
|
|
585
|
-
* only when the main file is missing or unreadable, and two failure modes had
|
|
586
|
-
* to be covered at once:
|
|
587
|
-
* - an existing richer pool being overwritten (compare the file being
|
|
588
|
-
* replaced, before the write), and
|
|
589
|
-
* - growth being lost because the backup always lagged one write (compare
|
|
590
|
-
* the pool just written, after the write).
|
|
591
|
-
* Ties do not replace, so a run of equally-sized destructive writes cannot
|
|
592
|
-
* evict a good copy — which is how a real pool was lost: two good logins were
|
|
593
|
-
* replaced by fixtures, and the second fixture write replaced the backup too.
|
|
594
|
-
*/
|
|
595
|
-
function keepLargestBackup(raw: string): void {
|
|
596
|
-
try {
|
|
597
|
-
const count = accountCountIn(raw);
|
|
598
|
-
if (count <= 0) return;
|
|
599
|
-
let backupCount = -1;
|
|
600
|
-
try {
|
|
601
|
-
backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
602
|
-
} catch {
|
|
603
|
-
backupCount = -1;
|
|
604
|
-
}
|
|
605
|
-
if (count <= backupCount) return;
|
|
606
|
-
fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
|
|
607
|
-
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
608
|
-
} catch { }
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** How many accounts a raw pool blob holds; -1 when it is unreadable. */
|
|
612
|
-
function accountCountIn(raw: string): number {
|
|
613
|
-
try {
|
|
614
|
-
const doc = JSON.parse(raw) as { accounts?: unknown };
|
|
615
|
-
return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
|
|
616
|
-
} catch {
|
|
617
|
-
return -1;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Scrub one explicitly removed login out of the recovery copy.
|
|
623
|
-
*
|
|
624
|
-
* Largest-wins keeps that copy rich, which is what saves a real pool from an
|
|
625
|
-
* accidental smaller write — but it also means a logout's token (and its
|
|
626
|
-
* refresh grant) can sit in `<pool>.bak` and sign the user back in if the main
|
|
627
|
-
* file is ever lost. A removal is an explicit instruction, so the removed
|
|
628
|
-
* identity is dropped from the copy too — matched by slot or by bearer, since
|
|
629
|
-
* a legacy pool can hold one account under two slots. Every other account
|
|
630
|
-
* stays available for recovery.
|
|
631
|
-
*/
|
|
632
|
-
function forgetAccountInBackup(removed: ClineAccount): void {
|
|
633
|
-
try {
|
|
634
|
-
if (!fs.existsSync(CLINE_POOL_BACKUP_FILE)) return;
|
|
635
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
636
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
|
|
637
|
-
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
638
|
-
if (!Array.isArray(doc.accounts)) return;
|
|
639
|
-
const kept = (doc.accounts as unknown[]).filter((entry) => {
|
|
640
|
-
if (typeof entry !== "object" || entry === null) return true;
|
|
641
|
-
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
642
|
-
if (typeof rec.slot === "string" && rec.slot.trim() === removed.slot) return false;
|
|
643
|
-
return !(typeof rec.token === "string" && rec.token.length > 0 && rec.token === removed.token);
|
|
644
|
-
});
|
|
645
|
-
if (kept.length === doc.accounts.length) return;
|
|
646
|
-
doc.accounts = kept;
|
|
647
|
-
fs.writeFileSync(CLINE_POOL_BACKUP_FILE, JSON.stringify(doc, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
648
|
-
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
649
|
-
} catch { }
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Atomically persist the pool with owner-only permissions (temp file + rename,
|
|
654
|
-
* see src/atomic-write.ts). Never logs secrets.
|
|
655
|
-
*
|
|
656
|
-
* Returns false when the pool did not reach the disk, so a caller that tells
|
|
657
|
-
* the user "saved" can tell the truth instead. The bytes are read back after
|
|
658
|
-
* the rename: a rename that reported success is not proof that the next read
|
|
659
|
-
* sees the new pool — a read-only target is the live failure mode.
|
|
660
|
-
*/
|
|
661
|
-
export function savePool(pool: ClinePoolState): boolean {
|
|
662
|
-
try {
|
|
663
|
-
const dir = path.dirname(CLINE_POOL_FILE);
|
|
664
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
665
|
-
// The pool being replaced is the only copy of itself: secure it first.
|
|
666
|
-
try {
|
|
667
|
-
if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
668
|
-
} catch { }
|
|
669
|
-
const serialized = JSON.stringify(pool, null, 2);
|
|
670
|
-
writeFileAtomic(CLINE_POOL_FILE, serialized, { mode: 0o600 });
|
|
671
|
-
if (fs.readFileSync(CLINE_POOL_FILE, "utf8") !== serialized) {
|
|
672
|
-
throw new Error(`pool write did not land at ${CLINE_POOL_FILE}`);
|
|
673
|
-
}
|
|
674
|
-
// Compare the pool just written too, so growth is never the copy that lags.
|
|
675
|
-
keepLargestBackup(serialized);
|
|
676
|
-
cached = clonePool(pool);
|
|
677
|
-
cachedMtime = diskMtime();
|
|
678
|
-
return true;
|
|
679
|
-
} catch (e) {
|
|
680
|
-
logWarn("could not persist cline pool", { slots: pool.accounts.length, reason: e instanceof Error ? e.message : String(e) });
|
|
681
|
-
// The in-memory snapshot must not outlive the write that failed: with the
|
|
682
|
-
// cache dropped, the next load re-reads the disk and sees what actually
|
|
683
|
-
// landed instead of the pool this process hoped to write.
|
|
684
|
-
cached = null;
|
|
685
|
-
cachedMtime = -1;
|
|
686
|
-
return false;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/** Last-4 display for a token; the full value is never shown or logged. */
|
|
691
|
-
export function redactedToken(token: string): string {
|
|
692
|
-
const tail = (token || "").slice(-4) || "????";
|
|
693
|
-
return `…${tail}`;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Persist a patch to one slot against the file as it is on disk right now.
|
|
698
|
-
* A request holds its pool snapshot across network round trips, and the host
|
|
699
|
-
* process writes the same file for login/logout, so writing the snapshot back
|
|
700
|
-
* would revert a login added or removed while the request was in flight. A
|
|
701
|
-
* slot that no longer exists is left alone rather than resurrected.
|
|
702
|
-
*/
|
|
703
|
-
function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
|
|
704
|
-
const live = readPoolFile();
|
|
705
|
-
const target = live.accounts.find((a) => a.slot === slot);
|
|
706
|
-
if (!target) return;
|
|
707
|
-
Object.assign(target, patch);
|
|
708
|
-
savePool(live);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/** Record which slot served a turn, without clobbering concurrent edits. */
|
|
712
|
-
function markActiveSlotOnDisk(slot: string): void {
|
|
713
|
-
const live = readPoolFile();
|
|
714
|
-
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
715
|
-
live.activeSlot = slot;
|
|
716
|
-
savePool(live);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* Bump one slot's serve counters against the file as it is on disk right
|
|
721
|
-
* now: a request holds its pool snapshot across round trips, and the host
|
|
722
|
-
* process writes the same file for login/logout, so writing the snapshot
|
|
723
|
-
* back would revert a login added or removed while the request was in
|
|
724
|
-
* flight. A slot that no longer exists is left alone rather than
|
|
725
|
-
* resurrected. The row carries counters only — never token or identity.
|
|
726
|
-
*/
|
|
727
|
-
export function recordClineUsageOnDisk(slot: string, modelId: string): void {
|
|
728
|
-
const cleanSlot = (slot || "").trim();
|
|
729
|
-
if (!cleanSlot) return;
|
|
730
|
-
const live = readPoolFile();
|
|
731
|
-
if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
|
|
732
|
-
const prev = live.usage?.[cleanSlot];
|
|
733
|
-
const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
|
|
734
|
-
? Math.floor(prev.served) + 1
|
|
735
|
-
: 1;
|
|
736
|
-
live.usage = {
|
|
737
|
-
...(live.usage ?? {}),
|
|
738
|
-
[cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
|
|
739
|
-
};
|
|
740
|
-
savePool(live);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/** The reset still in force for one slot+requested model, or null when free. */
|
|
744
|
-
function activeLimitFor(pool: ClinePoolState, slot: string, model: string, now: number): number | null {
|
|
745
|
-
if (!model) return null;
|
|
746
|
-
const at = pool.limits?.[slot]?.[model];
|
|
747
|
-
return typeof at === "number" && Number.isFinite(at) && at > now ? at : null;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* Persist one free-limit observation against the file as it is on disk right
|
|
752
|
-
* now: a request holds its pool snapshot across round trips, and the host
|
|
753
|
-
* process writes the same file for login/logout, so writing the snapshot back
|
|
754
|
-
* would revert a login added or removed while the request was in flight.
|
|
755
|
-
*/
|
|
756
|
-
function recordLimitOnDisk(slot: string, model: string, resetAt: number): void {
|
|
757
|
-
if (!model) return;
|
|
758
|
-
const live = readPoolFile();
|
|
759
|
-
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
760
|
-
const known = live.limits?.[slot]?.[model];
|
|
761
|
-
// Keep the later of the two: a fresh report must not shorten a window that is
|
|
762
|
-
// already known, and repeating the same reset writes nothing.
|
|
763
|
-
if (typeof known === "number" && known >= resetAt) return;
|
|
764
|
-
const limits = live.limits ?? {};
|
|
765
|
-
limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
|
|
766
|
-
live.limits = limits;
|
|
767
|
-
savePool(live);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
/** Drop one slot+model limit once that slot serves the model again. */
|
|
771
|
-
function clearLimitOnDisk(slot: string, model: string): void {
|
|
772
|
-
if (!model) return;
|
|
773
|
-
const live = readPoolFile();
|
|
774
|
-
const limits = live.limits;
|
|
775
|
-
const forSlot = limits?.[slot];
|
|
776
|
-
if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
|
|
777
|
-
delete forSlot[model];
|
|
778
|
-
if (Object.keys(forSlot).length === 0) delete limits[slot];
|
|
779
|
-
if (Object.keys(limits).length === 0) delete live.limits;
|
|
780
|
-
savePool(live);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Identity of a Cline login. Two slots holding the same account share one free
|
|
785
|
-
* quota, so rotating between them buys nothing — callers use this to detect
|
|
786
|
-
* that instead of saving a duplicate.
|
|
787
|
-
*
|
|
788
|
-
* accountId (WorkOS user id) is authoritative; email is the readable fallback.
|
|
789
|
-
* When neither side carries an identity — a legacy `clp_` key slot has none —
|
|
790
|
-
* only an identical bearer proves the same account.
|
|
791
|
-
*/
|
|
792
|
-
function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
|
|
793
|
-
const idA = account.accountId?.trim();
|
|
794
|
-
const idB = identity.accountId?.trim();
|
|
795
|
-
if (idA && idB) return idA === idB;
|
|
796
|
-
const mailA = account.email?.trim().toLowerCase();
|
|
797
|
-
const mailB = identity.email?.trim().toLowerCase();
|
|
798
|
-
if (mailA && mailB) return mailA === mailB;
|
|
799
|
-
const token = identity.token;
|
|
800
|
-
return typeof token === "string" && token.length > 0 && account.token === token;
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* The other slot already holding this account, or null when it is new.
|
|
805
|
-
* `exceptSlot` skips the slot being written, so re-logging into an existing
|
|
806
|
-
* slot is never treated as a duplicate.
|
|
807
|
-
*/
|
|
808
|
-
export function findClineAccountSlot(
|
|
809
|
-
pool: ClinePoolState,
|
|
810
|
-
identity: { token?: string; accountId?: string; email?: string },
|
|
811
|
-
exceptSlot?: string,
|
|
812
|
-
): string | null {
|
|
813
|
-
const hit = pool.accounts.find(
|
|
814
|
-
(a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
|
|
815
|
-
);
|
|
816
|
-
return hit ? hit.slot : null;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Save (or replace) one login slot. The token must carry the `workos:` prefix.
|
|
821
|
-
* Throws on bad input — the message never echoes the token.
|
|
822
|
-
* Extras carry the device-login grant (refresh token, expiry, identity);
|
|
823
|
-
* legacy key slots keep calling with two args and load untouched.
|
|
824
|
-
* Refuses an account that another slot already holds.
|
|
825
|
-
*/
|
|
826
|
-
export function addAccount(
|
|
827
|
-
slot: string,
|
|
828
|
-
token: string,
|
|
829
|
-
extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
|
|
830
|
-
): ClinePoolState {
|
|
831
|
-
const cleanSlot = (slot || "").trim();
|
|
832
|
-
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
833
|
-
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
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");
|
|
836
|
-
const pool = loadPool();
|
|
837
|
-
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
838
|
-
const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
|
|
839
|
-
if (duplicate) {
|
|
840
|
-
const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
|
|
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`);
|
|
842
|
-
}
|
|
843
|
-
if (existing) {
|
|
844
|
-
existing.token = cleanToken;
|
|
845
|
-
// A fresh login is proof the grant works again: a quarantine recorded
|
|
846
|
-
// against the credential it replaces must never outlive it.
|
|
847
|
-
delete existing.deadUntil;
|
|
848
|
-
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
849
|
-
if (extras?.expiresAt !== undefined) {
|
|
850
|
-
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
851
|
-
else delete existing.expiresAt;
|
|
852
|
-
}
|
|
853
|
-
if (extras?.accountId !== undefined) {
|
|
854
|
-
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
855
|
-
else delete existing.accountId;
|
|
856
|
-
}
|
|
857
|
-
if (extras?.email !== undefined) {
|
|
858
|
-
if (extras.email.trim()) existing.email = extras.email.trim();
|
|
859
|
-
else delete existing.email;
|
|
860
|
-
}
|
|
861
|
-
} else {
|
|
862
|
-
pool.accounts.push({
|
|
863
|
-
slot: cleanSlot,
|
|
864
|
-
token: cleanToken,
|
|
865
|
-
addedAt: new Date().toISOString(),
|
|
866
|
-
...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
|
|
867
|
-
...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
|
|
868
|
-
? { expiresAt: extras.expiresAt }
|
|
869
|
-
: {}),
|
|
870
|
-
...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
|
|
871
|
-
...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
875
|
-
// A save that never reached the disk must not report success: the caller
|
|
876
|
-
// prints "ready to use" on the strength of this return, and the login would
|
|
877
|
-
// vanish on the next read. The write helper already logged the cause.
|
|
878
|
-
if (!savePool(pool)) {
|
|
879
|
-
// Re-read the way the relay deploy path does before blaming the write.
|
|
880
|
-
let landed = false;
|
|
881
|
-
try {
|
|
882
|
-
landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
|
|
883
|
-
} catch {
|
|
884
|
-
landed = false;
|
|
885
|
-
}
|
|
886
|
-
if (!landed) {
|
|
887
|
-
throw new Error(
|
|
888
|
-
`Cline login [${cleanSlot}] could not be saved to ${CLINE_POOL_FILE} — check that the directory is writable and log in again`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
return clonePool(pool);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
|
|
896
|
-
const CLINE_REFRESH_SKEW_MS = 60_000;
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* How long a slot stays parked after Cline rejects its grant outright. Long
|
|
900
|
-
* enough that a live session cannot re-pay the refresh round trip on every
|
|
901
|
-
* turn, short enough that the slot comes back on its own: the pause never
|
|
902
|
-
* blocks a slot whose credential a fresh login replaced.
|
|
903
|
-
*/
|
|
904
|
-
export const CLINE_DEAD_GRANT_COOLDOWN_MS = 15 * 60_000;
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* True when the slot carries a known expiry that has passed (or passes within
|
|
908
|
-
* the skew window). Slots without `expiresAt` — every legacy key slot — are
|
|
909
|
-
* never stale: they load and serve exactly as before.
|
|
910
|
-
*/
|
|
911
|
-
function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_SKEW_MS): boolean {
|
|
912
|
-
if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false;
|
|
913
|
-
return Date.now() + skewMs >= account.expiresAt;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
/**
|
|
917
|
-
* Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
|
|
918
|
-
* dead grant with nothing to keep (`false`), or a transient fault where the
|
|
919
|
-
* caller should fall back to the stale bearer (`null`).
|
|
920
|
-
*/
|
|
921
|
-
async function refreshAccountInPlace(
|
|
922
|
-
account: ClineAccount,
|
|
923
|
-
refreshImpl: ClineRefresher,
|
|
924
|
-
): Promise<boolean | null> {
|
|
925
|
-
const current = account.refreshToken;
|
|
926
|
-
if (!current) return null;
|
|
927
|
-
let fresh: ClineRefreshResult | null;
|
|
928
|
-
try {
|
|
929
|
-
fresh = await refreshImpl(current);
|
|
930
|
-
} catch (e) {
|
|
931
|
-
logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
|
|
932
|
-
void e;
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
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_"))) {
|
|
937
|
-
// Cline rejected the grant outright: park the slot on disk so the next
|
|
938
|
-
// turns skip it instead of re-paying a refresh that cannot succeed. The
|
|
939
|
-
// credential stays in the pool — a fresh login into the slot clears this —
|
|
940
|
-
// and the cooldown ends the pause on its own.
|
|
941
|
-
account.deadUntil = Date.now() + CLINE_DEAD_GRANT_COOLDOWN_MS;
|
|
942
|
-
logWarn("cline slot refresh rejected — quarantining slot", {
|
|
943
|
-
slot: account.slot,
|
|
944
|
-
forMs: CLINE_DEAD_GRANT_COOLDOWN_MS,
|
|
945
|
-
});
|
|
946
|
-
patchAccountOnDisk(account.slot, { deadUntil: account.deadUntil });
|
|
947
|
-
return false;
|
|
948
|
-
}
|
|
949
|
-
account.token = freshToken;
|
|
950
|
-
if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
|
|
951
|
-
if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
|
|
952
|
-
account.expiresAt = fresh.expiresAt;
|
|
953
|
-
} else {
|
|
954
|
-
delete account.expiresAt;
|
|
955
|
-
}
|
|
956
|
-
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
957
|
-
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
958
|
-
// Persist only the refreshed fields: the snapshot this request holds may
|
|
959
|
-
// predate a login added or removed in the host process.
|
|
960
|
-
patchAccountOnDisk(account.slot, {
|
|
961
|
-
token: account.token,
|
|
962
|
-
...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
|
|
963
|
-
expiresAt: account.expiresAt,
|
|
964
|
-
...(account.accountId ? { accountId: account.accountId } : {}),
|
|
965
|
-
...(account.email ? { email: account.email } : {}),
|
|
966
|
-
});
|
|
967
|
-
return true;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
971
|
-
export function removeAccount(slot: string): boolean {
|
|
972
|
-
const cleanSlot = (slot || "").trim();
|
|
973
|
-
const pool = loadPool();
|
|
974
|
-
const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
|
|
975
|
-
if (idx < 0) return false;
|
|
976
|
-
const [removed] = pool.accounts.splice(idx, 1);
|
|
977
|
-
if (pool.usage?.[cleanSlot] !== undefined) {
|
|
978
|
-
delete pool.usage[cleanSlot];
|
|
979
|
-
if (Object.keys(pool.usage).length === 0) delete pool.usage;
|
|
980
|
-
}
|
|
981
|
-
// A cap belongs to the login that hit it: keeping it would outlive the slot
|
|
982
|
-
// it names and key a later login of the same name into a stale window.
|
|
983
|
-
if (pool.limits?.[cleanSlot] !== undefined) {
|
|
984
|
-
delete pool.limits[cleanSlot];
|
|
985
|
-
if (Object.keys(pool.limits).length === 0) delete pool.limits;
|
|
986
|
-
}
|
|
987
|
-
if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
|
|
988
|
-
savePool(pool);
|
|
989
|
-
// A logout is explicit: the removed grant must not survive in the recovery
|
|
990
|
-
// copy, or losing the main file would sign that account back in.
|
|
991
|
-
forgetAccountInBackup(removed);
|
|
992
|
-
return true;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* The model id this request asks Cline for — the key every recorded limit is
|
|
997
|
-
* looked up under. Empty when the body carries no usable model, which
|
|
998
|
-
* disables limit bookkeeping for the roll rather than guessing a key.
|
|
999
|
-
*/
|
|
1000
|
-
function requestedModelId(body: string): string {
|
|
1001
|
-
try {
|
|
1002
|
-
const parsed: unknown = JSON.parse(body);
|
|
1003
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
1004
|
-
const model = (parsed as Record<string, unknown>).model;
|
|
1005
|
-
return typeof model === "string" ? model.trim() : "";
|
|
1006
|
-
} catch {
|
|
1007
|
-
return "";
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
/**
|
|
1012
|
-
* Read a free-limit report off a 429 body through a clone: the response handed
|
|
1013
|
-
* back to the caller must stay readable, so its body is never consumed here.
|
|
1014
|
-
*/
|
|
1015
|
-
async function freeLimitFrom(res: Response, now: number): Promise<ClineFreeLimit | null> {
|
|
1016
|
-
try {
|
|
1017
|
-
return parseClineFreeLimit(await res.clone().text(), now);
|
|
1018
|
-
} catch {
|
|
1019
|
-
return null;
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
interface ClineRollOpts {
|
|
1024
|
-
/** Serialized chat-completions body, reused verbatim on every attempt. */
|
|
1025
|
-
body: string;
|
|
1026
|
-
/** Chat completions endpoint (proxy passes its configured URL explicitly). */
|
|
1027
|
-
chatUrl: string;
|
|
1028
|
-
fetchImpl?: FetchImpl;
|
|
1029
|
-
/**
|
|
1030
|
-
* Device-login refresher. When present, a stale slot refreshes once before
|
|
1031
|
-
* its attempt, and a 401/403 refreshes once before the slot is skipped.
|
|
1032
|
-
* Absent: every slot serves its stored bearer exactly as before.
|
|
1033
|
-
*/
|
|
1034
|
-
refreshImpl?: ClineRefresher;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
interface ClineRollResult {
|
|
1038
|
-
res: Response;
|
|
1039
|
-
/** Slot that served the response; null when no account was tried. */
|
|
1040
|
-
slot: string | null;
|
|
1041
|
-
/** True when every account failed (or the pool is empty). */
|
|
1042
|
-
exhausted: boolean;
|
|
1043
|
-
kind: ClineErrorKind;
|
|
1044
|
-
/**
|
|
1045
|
-
* Saved logins this roll had to work with (the candidate count). On an
|
|
1046
|
-
* exhausted result every one of them was tried, so a caller may name this
|
|
1047
|
-
* number when telling the user how many logins are capped. It is never an
|
|
1048
|
-
* upstream-attempt count: one login that refreshes and retries is still one.
|
|
1049
|
-
* A slot already parked after a rejected grant is not counted — it was not a
|
|
1050
|
-
* candidate on this roll.
|
|
1051
|
-
*/
|
|
1052
|
-
logins: number;
|
|
1053
|
-
/**
|
|
1054
|
-
* True when the roll tried at least one login and every login it tried
|
|
1055
|
-
* answered the free-limit 429 for the requested model — nothing else was
|
|
1056
|
-
* tried and nothing succeeded. A login skipped for a dead grant keeps this
|
|
1057
|
-
* false, as does any other failure mixed into the roll.
|
|
1058
|
-
*/
|
|
1059
|
-
limitOnly: boolean;
|
|
1060
|
-
/** Earliest reset among the free-limit 429s seen (epoch ms); null when none was stated. */
|
|
1061
|
-
earliestResetAt: number | null;
|
|
1062
|
-
/** Model name as Cline spelled it in its own body, when a free limit was reported. */
|
|
1063
|
-
limitModelId?: string;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* Synthetic body for the two states that never reached Cline: nothing is
|
|
1068
|
-
* saved to sign in with, or no saved login could reach Cline. Neither is a
|
|
1069
|
-
* rate limit, so it must not answer 429 — that status makes hosts back off
|
|
1070
|
-
* and hide the actionable message behind a "rate limited" notice.
|
|
1071
|
-
*/
|
|
1072
|
-
function exhaustedResponse(reason: string, status: number): Response {
|
|
1073
|
-
return new Response(
|
|
1074
|
-
JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
|
|
1075
|
-
{ status, headers: { "content-type": "application/json" } },
|
|
1076
|
-
);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* For OpenRouter-backed Cline models, Cline sometimes answers HTTP 200
|
|
1081
|
-
* `text/event-stream` where the first event is an in-stream 429 error
|
|
1082
|
-
* (e.g. `stream_initialization_failed` with status 429 / daily limit reached).
|
|
1083
|
-
* Peek the first chunk: if it is an in-stream 429 error, convert it to a 429
|
|
1084
|
-
* Response so rollChat rolls to the next slot; otherwise, reconstruct the
|
|
1085
|
-
* stream with the peeked chunk re-enqueued.
|
|
1086
|
-
*/
|
|
1087
|
-
async function peekStreamForInStreamError(res: Response): Promise<{ res: Response; inStreamError: boolean; errorText: string }> {
|
|
1088
|
-
const contentType = res.headers.get("content-type") || "";
|
|
1089
|
-
if (!contentType.includes("text/event-stream") || !res.body) {
|
|
1090
|
-
return { res, inStreamError: false, errorText: "" };
|
|
1091
|
-
}
|
|
1092
|
-
try {
|
|
1093
|
-
const reader = res.body.getReader();
|
|
1094
|
-
const { done, value } = await reader.read();
|
|
1095
|
-
if (done || !value || value.byteLength === 0) {
|
|
1096
|
-
return {
|
|
1097
|
-
res: new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
1098
|
-
inStreamError: false,
|
|
1099
|
-
errorText: "",
|
|
1100
|
-
};
|
|
1101
|
-
}
|
|
1102
|
-
const text = new TextDecoder().decode(value);
|
|
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) ||
|
|
1110
|
-
/Rate limit exceeded/i.test(text) ||
|
|
1111
|
-
/Daily limit reached/i.test(text) ||
|
|
1112
|
-
/free limit reached/i.test(text) ||
|
|
1113
|
-
/Daily free limit/i.test(text)
|
|
1114
|
-
);
|
|
1115
|
-
const isInStream429 = hasInitFailed && has429Signal;
|
|
1116
|
-
|
|
1117
|
-
if (isInStream429) {
|
|
1118
|
-
try { await reader.cancel(); } catch { }
|
|
1119
|
-
const errRes = new Response(text, {
|
|
1120
|
-
status: 429,
|
|
1121
|
-
headers: { "content-type": "application/json" },
|
|
1122
|
-
});
|
|
1123
|
-
return { res: errRes, inStreamError: true, errorText: text };
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const reconstructed = new ReadableStream<Uint8Array>({
|
|
1127
|
-
start(controller) {
|
|
1128
|
-
controller.enqueue(value);
|
|
1129
|
-
},
|
|
1130
|
-
async pull(controller) {
|
|
1131
|
-
try {
|
|
1132
|
-
const { done: nextDone, value: nextValue } = await reader.read();
|
|
1133
|
-
if (nextDone) {
|
|
1134
|
-
controller.close();
|
|
1135
|
-
} else if (nextValue) {
|
|
1136
|
-
controller.enqueue(nextValue);
|
|
1137
|
-
}
|
|
1138
|
-
} catch (err) {
|
|
1139
|
-
controller.error(err);
|
|
1140
|
-
}
|
|
1141
|
-
},
|
|
1142
|
-
cancel(reason) {
|
|
1143
|
-
return reader.cancel(reason);
|
|
1144
|
-
},
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
return {
|
|
1148
|
-
res: new Response(reconstructed, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
1149
|
-
inStreamError: false,
|
|
1150
|
-
errorText: "",
|
|
1151
|
-
};
|
|
1152
|
-
} catch {
|
|
1153
|
-
return { res, inStreamError: false, errorText: "" };
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* POST one chat body to the Cline endpoint, rolling across every saved slot.
|
|
1159
|
-
* Each attempt sends its slot bearer explicitly; raw tokens never leave this
|
|
1160
|
-
* module. Superseded failure bodies are cancelled here; the response handed
|
|
1161
|
-
* back to the caller is left untouched so the caller can read it.
|
|
1162
|
-
*/
|
|
1163
|
-
export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
1164
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1165
|
-
const pool = loadPool();
|
|
1166
|
-
const now = Date.now();
|
|
1167
|
-
const requestedModel = requestedModelId(opts.body);
|
|
1168
|
-
// A slot Cline rejected outright is parked for a cooldown: retrying its dead
|
|
1169
|
-
// grant on every turn re-pays a refresh that cannot succeed. The credential
|
|
1170
|
-
// stays in the pool and the stamp expires, so this is a pause, not a
|
|
1171
|
-
// removal — pool order and size are untouched.
|
|
1172
|
-
const quarantinedSlots = new Set(
|
|
1173
|
-
pool.accounts
|
|
1174
|
-
.filter((a) => typeof a.deadUntil === "number" && Number.isFinite(a.deadUntil) && a.deadUntil > now)
|
|
1175
|
-
.map((a) => a.slot),
|
|
1176
|
-
);
|
|
1177
|
-
let candidates = pool.accounts.filter((a) => !quarantinedSlots.has(a.slot));
|
|
1178
|
-
if (pool.activeSlot) {
|
|
1179
|
-
const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
|
|
1180
|
-
if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
|
|
1181
|
-
}
|
|
1182
|
-
if (requestedModel) {
|
|
1183
|
-
// A slot recorded on this model's daily cap almost always answers 429 again:
|
|
1184
|
-
// it is tried last so a healthy login serves the turn without paying the
|
|
1185
|
-
// extra round trip. It stays in the list — a cap can lift before its
|
|
1186
|
-
// recorded reset, and only a real attempt can discover that.
|
|
1187
|
-
const open: ClineAccount[] = [];
|
|
1188
|
-
const capped: ClineAccount[] = [];
|
|
1189
|
-
for (const account of candidates) {
|
|
1190
|
-
(activeLimitFor(pool, account.slot, requestedModel, now) === null ? open : capped).push(account);
|
|
1191
|
-
}
|
|
1192
|
-
candidates = [...open, ...capped];
|
|
1193
|
-
}
|
|
1194
|
-
if (candidates.length === 0) {
|
|
1195
|
-
if (quarantinedSlots.size > 0) {
|
|
1196
|
-
// Logins are saved but Cline rejected every one of them: telling the user
|
|
1197
|
-
// nothing is saved would point at the wrong problem.
|
|
1198
|
-
logWarn("cline pool fully quarantined — every saved grant was rejected", {
|
|
1199
|
-
slots: pool.accounts.length,
|
|
1200
|
-
quarantined: quarantinedSlots.size,
|
|
1201
|
-
});
|
|
1202
|
-
return {
|
|
1203
|
-
res: exhaustedResponse("Every saved Cline login was rejected by Cline — sign in again with /freeflow cline login", 502),
|
|
1204
|
-
slot: null,
|
|
1205
|
-
exhausted: true,
|
|
1206
|
-
kind: "exhausted",
|
|
1207
|
-
logins: 0,
|
|
1208
|
-
limitOnly: false,
|
|
1209
|
-
earliestResetAt: null,
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
const reason = "No Cline logins saved — add one with /freeflow cline login";
|
|
1213
|
-
logWarn("cline pool empty", { slots: pool.accounts.length });
|
|
1214
|
-
return { res: exhaustedResponse(reason, 401), slot: null, exhausted: true, kind: "exhausted", logins: 0, limitOnly: false, earliestResetAt: null };
|
|
1215
|
-
}
|
|
1216
|
-
let lastRes: Response | null = null;
|
|
1217
|
-
let lastSlot: string | null = null;
|
|
1218
|
-
let lastKind: ClineErrorKind = "exhausted";
|
|
1219
|
-
// Slots whose grant Cline rejected during this roll: they are now parked on
|
|
1220
|
-
// disk, so the exhausted answer can say "rejected" instead of "unreachable".
|
|
1221
|
-
let rejectedThisRoll = 0;
|
|
1222
|
-
// Distinct logins that answered the cap, so one login refreshing and retrying
|
|
1223
|
-
// through the 401 path still counts once.
|
|
1224
|
-
const cappedSlots = new Set<string>();
|
|
1225
|
-
let earliestResetAt: number | null = null;
|
|
1226
|
-
let limitModelId: string | undefined;
|
|
1227
|
-
for (const account of candidates) {
|
|
1228
|
-
// Stale device-login bearer: one refresh before the attempt. A dead grant
|
|
1229
|
-
// parks the slot (see refreshAccountInPlace); a transient fault keeps the
|
|
1230
|
-
// stale token.
|
|
1231
|
-
let refreshedThisSlot = false;
|
|
1232
|
-
if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
|
|
1233
|
-
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
1234
|
-
if (outcome === false) {
|
|
1235
|
-
rejectedThisRoll += 1;
|
|
1236
|
-
continue;
|
|
1237
|
-
}
|
|
1238
|
-
refreshedThisSlot = outcome === true;
|
|
1239
|
-
}
|
|
1240
|
-
const attempt = async (token: string): Promise<Response | null> => {
|
|
1241
|
-
try {
|
|
1242
|
-
return await fetchImpl(opts.chatUrl, {
|
|
1243
|
-
method: "POST",
|
|
1244
|
-
headers: {
|
|
1245
|
-
"content-type": "application/json",
|
|
1246
|
-
...CLINE_CLIENT_HEADERS,
|
|
1247
|
-
authorization: `Bearer ${token}`,
|
|
1248
|
-
},
|
|
1249
|
-
body: opts.body,
|
|
1250
|
-
});
|
|
1251
|
-
} catch (e) {
|
|
1252
|
-
logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
|
|
1253
|
-
void e;
|
|
1254
|
-
return null;
|
|
1255
|
-
}
|
|
1256
|
-
};
|
|
1257
|
-
const succeed = async (res: Response, kind: ClineErrorKind): Promise<ClineRollResult> => {
|
|
1258
|
-
if (kind === "ok") {
|
|
1259
|
-
markActiveSlotOnDisk(account.slot);
|
|
1260
|
-
// This slot just served the model, so whatever cap it was on has lifted.
|
|
1261
|
-
// Reads the file as it is on disk and writes only when an entry exists.
|
|
1262
|
-
clearLimitOnDisk(account.slot, requestedModel);
|
|
1263
|
-
// Counters only — no token or identity ever lands in the usage row.
|
|
1264
|
-
recordClineUsageOnDisk(account.slot, requestedModel);
|
|
1265
|
-
}
|
|
1266
|
-
if (lastRes && lastRes !== res) {
|
|
1267
|
-
try { await lastRes.body?.cancel(); } catch { }
|
|
1268
|
-
}
|
|
1269
|
-
lastRes = null;
|
|
1270
|
-
return {
|
|
1271
|
-
res,
|
|
1272
|
-
slot: account.slot,
|
|
1273
|
-
exhausted: false,
|
|
1274
|
-
kind,
|
|
1275
|
-
logins: candidates.length,
|
|
1276
|
-
limitOnly: false,
|
|
1277
|
-
earliestResetAt,
|
|
1278
|
-
...(limitModelId ? { limitModelId } : {}),
|
|
1279
|
-
};
|
|
1280
|
-
};
|
|
1281
|
-
const stashFailure = async (res: Response, kind: ClineErrorKind): Promise<void> => {
|
|
1282
|
-
if (lastRes && lastRes !== res) {
|
|
1283
|
-
try { await lastRes.body?.cancel(); } catch { }
|
|
1284
|
-
}
|
|
1285
|
-
lastRes = res;
|
|
1286
|
-
lastSlot = account.slot;
|
|
1287
|
-
lastKind = kind;
|
|
1288
|
-
};
|
|
1289
|
-
let res = await attempt(account.token);
|
|
1290
|
-
if (!res) continue;
|
|
1291
|
-
let inStreamLimitText: string | null = null;
|
|
1292
|
-
if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
1293
|
-
const peek = await peekStreamForInStreamError(res);
|
|
1294
|
-
res = peek.res;
|
|
1295
|
-
if (peek.inStreamError) {
|
|
1296
|
-
inStreamLimitText = peek.errorText;
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
let kind = mapClineError(res.status);
|
|
1300
|
-
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
1301
|
-
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
1302
|
-
// refresh, then exactly one retry with the fresh bearer.
|
|
1303
|
-
if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
|
|
1304
|
-
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
1305
|
-
if (outcome === true) {
|
|
1306
|
-
try {
|
|
1307
|
-
await res.body?.cancel();
|
|
1308
|
-
} catch { }
|
|
1309
|
-
refreshedThisSlot = true;
|
|
1310
|
-
res = await attempt(account.token);
|
|
1311
|
-
if (!res) continue;
|
|
1312
|
-
kind = mapClineError(res.status);
|
|
1313
|
-
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
1314
|
-
} else if (outcome === false) {
|
|
1315
|
-
try {
|
|
1316
|
-
await res.body?.cancel();
|
|
1317
|
-
} catch { }
|
|
1318
|
-
rejectedThisRoll += 1;
|
|
1319
|
-
continue;
|
|
1320
|
-
}
|
|
1321
|
-
// Transient refresh fault: the original response is still intact, so it is
|
|
1322
|
-
// stashed below and can be surfaced to the caller.
|
|
1323
|
-
}
|
|
1324
|
-
// A 429 carrying the free-limit marker means this login is out of free use
|
|
1325
|
-
// for the requested model until the stated reset: remember it, so the next
|
|
1326
|
-
// turn tries the other logins first.
|
|
1327
|
-
if (res.status === 429) {
|
|
1328
|
-
const limit = inStreamLimitText ? parseClineFreeLimit(inStreamLimitText, now) : await freeLimitFrom(res, now);
|
|
1329
|
-
if (limit) {
|
|
1330
|
-
cappedSlots.add(account.slot);
|
|
1331
|
-
if (limit.modelId) limitModelId = limit.modelId;
|
|
1332
|
-
if (typeof limit.resetAt === "number") {
|
|
1333
|
-
if (earliestResetAt === null || limit.resetAt < earliestResetAt) earliestResetAt = limit.resetAt;
|
|
1334
|
-
recordLimitOnDisk(account.slot, requestedModel, limit.resetAt);
|
|
1335
|
-
}
|
|
1336
|
-
logWarn("cline slot is on this model's daily free cap", {
|
|
1337
|
-
slot: account.slot,
|
|
1338
|
-
model: requestedModel || limit.modelId || "unknown",
|
|
1339
|
-
resetInMs: typeof limit.resetAt === "number" ? limit.resetAt - now : null,
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
logWarn("cline slot failed — trying next slot", { slot: account.slot, status: res.status });
|
|
1344
|
-
await stashFailure(res, kind);
|
|
1345
|
-
}
|
|
1346
|
-
if (lastRes) {
|
|
1347
|
-
// Only every saved login answering the cap counts as a limit-only roll: a
|
|
1348
|
-
// login that never got through is a login problem, not a capped one.
|
|
1349
|
-
const limitOnly = cappedSlots.size > 0 && candidates.every((a) => cappedSlots.has(a.slot));
|
|
1350
|
-
logWarn("cline pool exhausted after roll — returning last upstream failure", {
|
|
1351
|
-
slots: candidates.length,
|
|
1352
|
-
cappedSlots: cappedSlots.size,
|
|
1353
|
-
limitOnly,
|
|
1354
|
-
...(limitModelId ? { limitModel: limitModelId } : {}),
|
|
1355
|
-
});
|
|
1356
|
-
return {
|
|
1357
|
-
res: lastRes,
|
|
1358
|
-
slot: lastSlot,
|
|
1359
|
-
exhausted: true,
|
|
1360
|
-
kind: lastKind,
|
|
1361
|
-
logins: candidates.length,
|
|
1362
|
-
limitOnly,
|
|
1363
|
-
earliestResetAt,
|
|
1364
|
-
...(limitModelId ? { limitModelId } : {}),
|
|
1365
|
-
};
|
|
1366
|
-
}
|
|
1367
|
-
// Every slot was skipped before producing a response: its saved login was
|
|
1368
|
-
// rejected, or the network call threw. Nothing is rate-limited here, so say
|
|
1369
|
-
// what actually happened instead of implying the user should wait.
|
|
1370
|
-
logWarn("cline pool exhausted with no upstream response", { slots: candidates.length, rejected: rejectedThisRoll });
|
|
1371
|
-
return {
|
|
1372
|
-
res: exhaustedResponse(
|
|
1373
|
-
rejectedThisRoll >= candidates.length
|
|
1374
|
-
? "Every saved Cline login was rejected by Cline — sign in again with /freeflow cline login"
|
|
1375
|
-
: "Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login",
|
|
1376
|
-
502,
|
|
1377
|
-
),
|
|
1378
|
-
slot: null,
|
|
1379
|
-
exhausted: true,
|
|
1380
|
-
kind: "exhausted",
|
|
1381
|
-
logins: candidates.length,
|
|
1382
|
-
limitOnly: false,
|
|
1383
|
-
earliestResetAt,
|
|
1384
|
-
...(limitModelId ? { limitModelId } : {}),
|
|
1385
|
-
};
|
|
1386
|
-
}
|
|
187
|
+
// ── Re-exports from split modules for complete backward compatibility ─
|
|
188
|
+
export * from "./cline-pool.ts";
|
|
189
|
+
export * from "./cline-roll.ts";
|