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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
@@ -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 { isWorkosJwt, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
9
+ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
20
10
  import { fetchWithSystemCA } from "./system-ca-fetch.ts";
21
-
22
- /** Env override for the pool file location (tests/CI sandbox). */
23
- const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
24
-
25
- /** Resolve the per-user pool file: beside the relay state file. */
26
- function resolveClinePoolPath(): string {
27
- const override = (process.env[CLINE_POOL_FILE_ENV] || "").trim();
28
- if (override) return override;
29
- return path.join(path.dirname(RELAY_STATE_FILE), "pi-freeflow-cline-pool.json");
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" && /^\d+\.\d+\.\d+/.test(data.version)) {
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
- async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
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
- return null;
121
+ } catch (e) {
122
+ if (e instanceof ClineAuthError && e.status !== undefined && e.status >= 400 && e.status < 500) {
123
+ return null;
124
+ }
125
+ throw e;
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
- export const CLINE_MODEL_PREFIX = "cline/";
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";