pi-freeflow 1.44.0 → 1.44.1

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.
@@ -0,0 +1,641 @@
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.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { writeFileAtomic } from "./atomic-write.ts";
16
+ import { RELAY_STATE_FILE } from "./config.ts";
17
+ import { logWarn } from "./logger.ts";
18
+ import { isWorkosJwt } from "./cline-device-auth.ts";
19
+
20
+ /** Env override for the pool file location (tests/CI sandbox). */
21
+ const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
22
+
23
+ /** Resolve the per-user pool file: beside the relay state file. */
24
+ function resolveClinePoolPath(): string {
25
+ const override = (process.env[CLINE_POOL_FILE_ENV] || "").trim();
26
+ if (override) return override;
27
+ return path.join(path.dirname(RELAY_STATE_FILE), "pi-freeflow-cline-pool.json");
28
+ }
29
+
30
+ export const CLINE_POOL_FILE = resolveClinePoolPath();
31
+
32
+ /**
33
+ * Recovery copy of the pool, read only when the main file is missing or
34
+ * unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
35
+ * difference: an empty pool is a legitimate state here (`/freeflow cline
36
+ * logout` of the last login must stick), so a valid empty main file is never
37
+ * overridden from the backup.
38
+ */
39
+ export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
40
+
41
+ /** One saved login slot. The token is only ever sent as an explicit bearer header. */
42
+ export interface ClineAccount {
43
+ slot: string;
44
+ token: string;
45
+ addedAt: string;
46
+ /** WorkOS refresh token for device-login slots; absent on legacy key slots. */
47
+ refreshToken?: string;
48
+ /** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
49
+ expiresAt?: number;
50
+ /** WorkOS account id, shown when choosing which login to remove. */
51
+ accountId?: string;
52
+ /** Login email, shown when choosing which login to remove. */
53
+ email?: string;
54
+ /**
55
+ * Epoch ms until which Cline has rejected this slot's grant outright: the
56
+ * roll skips the slot until then rather than paying a doomed refresh every
57
+ * turn. The credential itself is never dropped — past the stamp the slot is
58
+ * tried again, and saving a fresh login into the slot clears it at once.
59
+ */
60
+ deadUntil?: number;
61
+ }
62
+
63
+ /** One slot's serve counters. See `ClinePoolState.usage`. */
64
+ export interface ClineSlotUsage {
65
+ served: number;
66
+ lastAt: number;
67
+ lastModel: string;
68
+ }
69
+
70
+ export interface ClinePoolState {
71
+ accounts: ClineAccount[];
72
+ activeSlot?: string;
73
+ /**
74
+ * Daily free-limit resets per slot per *requested* model id (epoch ms):
75
+ * `limits[slot][modelId] = resetAt`. Cline reports its own model name in the
76
+ * 429 body, which never matches the id we asked for, so the requested id is
77
+ * the only key a later request can look up. Expired entries are dropped on
78
+ * read. Optional: pools written before this existed load unchanged.
79
+ */
80
+ limits?: Record<string, Record<string, number>>;
81
+ /**
82
+ * Per-slot serve counters: `usage[slot] = { served, lastAt, lastModel }`.
83
+ * Bumped every time the slot serves a turn (`served` counts up, `lastAt`
84
+ * is epoch ms, `lastModel` the requested model id). Counters only — no
85
+ * token, email, or other identity ever lives here; callers map
86
+ * slot→identity at display time. Optional: pools written before this
87
+ * existed load unchanged.
88
+ */
89
+ usage?: Record<string, ClineSlotUsage>;
90
+ }
91
+
92
+ let cached: ClinePoolState | null = null;
93
+ let cachedMtime = -1;
94
+
95
+ /** Test-only: drop the loadPool mtime cache. */
96
+ export function _resetClinePoolCacheForTest(): void {
97
+ cached = null;
98
+ cachedMtime = -1;
99
+ }
100
+
101
+ export function emptyPool(): ClinePoolState {
102
+ return { accounts: [] };
103
+ }
104
+
105
+ /**
106
+ * Deep clone a pool snapshot so callers can never alias the cache: mutating a
107
+ * loaded pool must not corrupt the next load, and mutating the argument after
108
+ * savePool must not rewrite what was cached.
109
+ */
110
+ export function clonePool(pool: ClinePoolState): ClinePoolState {
111
+ return {
112
+ accounts: pool.accounts.map((a) => ({ ...a })),
113
+ ...(pool.activeSlot !== undefined ? { activeSlot: pool.activeSlot } : {}),
114
+ ...(pool.limits
115
+ ? { limits: Object.fromEntries(Object.entries(pool.limits).map(([slot, models]) => [slot, { ...models }])) }
116
+ : {}),
117
+ ...(pool.usage
118
+ ? { usage: Object.fromEntries(Object.entries(pool.usage).map(([slot, row]) => [slot, { ...row }])) }
119
+ : {}),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Read the optional `limits` map out of a pool document, dropping anything
125
+ * malformed and every entry whose reset has already passed. Returns undefined
126
+ * when nothing usable is left, so a pool that never saw a cap keeps its old
127
+ * shape on disk.
128
+ */
129
+ export function parseLimits(raw: unknown, now: number): Record<string, Record<string, number>> | undefined {
130
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
131
+ const limits: Record<string, Record<string, number>> = {};
132
+ for (const [slot, modelsRaw] of Object.entries(raw as Record<string, unknown>)) {
133
+ if (!slot.trim()) continue;
134
+ if (typeof modelsRaw !== "object" || modelsRaw === null || Array.isArray(modelsRaw)) continue;
135
+ const models: Record<string, number> = {};
136
+ for (const [model, resetRaw] of Object.entries(modelsRaw as Record<string, unknown>)) {
137
+ if (!model.trim()) continue;
138
+ if (typeof resetRaw !== "number" || !Number.isFinite(resetRaw) || resetRaw <= now) continue;
139
+ models[model] = resetRaw;
140
+ }
141
+ if (Object.keys(models).length > 0) limits[slot.trim()] = models;
142
+ }
143
+ return Object.keys(limits).length > 0 ? limits : undefined;
144
+ }
145
+
146
+ /**
147
+ * Read the optional `usage` map out of a pool document, dropping anything
148
+ * malformed. Returns undefined when nothing usable is left, so a pool that
149
+ * never served a turn keeps its old shape on disk.
150
+ */
151
+ export function parseUsage(raw: unknown): Record<string, ClineSlotUsage> | undefined {
152
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
153
+ const usage: Record<string, ClineSlotUsage> = {};
154
+ for (const [slot, entryRaw] of Object.entries(raw as Record<string, unknown>)) {
155
+ if (!slot.trim()) continue;
156
+ if (typeof entryRaw !== "object" || entryRaw === null || Array.isArray(entryRaw)) continue;
157
+ const entry: Record<string, unknown> = entryRaw as Record<string, unknown>;
158
+ const served = entry.served;
159
+ const lastAt = entry.lastAt;
160
+ const lastModel = entry.lastModel;
161
+ if (typeof served !== "number" || !Number.isFinite(served) || served < 0) continue;
162
+ if (typeof lastAt !== "number" || !Number.isFinite(lastAt) || lastAt <= 0) continue;
163
+ if (typeof lastModel !== "string") continue;
164
+ usage[slot.trim()] = { served: Math.floor(served), lastAt, lastModel };
165
+ }
166
+ return Object.keys(usage).length > 0 ? usage : undefined;
167
+ }
168
+
169
+ /**
170
+ * Parse a pool document. Returns null when the blob is unusable (bad JSON,
171
+ * wrong shape) so callers can fall back to the backup copy. A valid document
172
+ * with zero accounts is NOT null: that is a real empty pool.
173
+ */
174
+ export function parsePoolDoc(raw: string): ClinePoolState | null {
175
+ let parsed: unknown;
176
+ try {
177
+ parsed = JSON.parse(raw);
178
+ } catch {
179
+ logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
180
+ return null;
181
+ }
182
+ if (typeof parsed !== "object" || parsed === null) {
183
+ logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
184
+ return null;
185
+ }
186
+ // Boundary-narrowed once: the on-disk blob is external input, so check
187
+ // its shape here and read only validated fields below.
188
+ const doc: Record<string, unknown> = parsed as Record<string, unknown>;
189
+ if (!Array.isArray(doc.accounts)) {
190
+ logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
191
+ return null;
192
+ }
193
+ const accounts: ClineAccount[] = [];
194
+ // One clock for the whole document: a quarantine stamp and a cap reset are
195
+ // both compared against the same instant.
196
+ const now = Date.now();
197
+ for (const entry of doc.accounts as unknown[]) {
198
+ if (typeof entry !== "object" || entry === null) continue;
199
+ const rec: Record<string, unknown> = entry as Record<string, unknown>;
200
+ const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
201
+ const token = typeof rec.token === "string" ? rec.token.trim() : "";
202
+ if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) {
203
+ logWarn("cline pool entry rejected", { slot: slot || "(unnamed)" });
204
+ continue;
205
+ }
206
+ const refreshRaw: unknown = rec.refreshToken;
207
+ const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
208
+ const expiresRaw: unknown = rec.expiresAt;
209
+ const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
210
+ ? expiresRaw
211
+ : undefined;
212
+ const accountIdRaw: unknown = rec.accountId;
213
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
214
+ const emailRaw: unknown = rec.email;
215
+ const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
216
+ // An elapsed quarantine is dropped on read, so a slot Cline rejected last
217
+ // week is a normal candidate again and the field never lingers in the file.
218
+ const deadRaw: unknown = rec.deadUntil;
219
+ const deadUntil = typeof deadRaw === "number" && Number.isFinite(deadRaw) && deadRaw > now ? deadRaw : undefined;
220
+ accounts.push({
221
+ slot,
222
+ token,
223
+ addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
224
+ ...(refreshToken ? { refreshToken } : {}),
225
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
226
+ ...(accountId ? { accountId } : {}),
227
+ ...(email ? { email } : {}),
228
+ ...(deadUntil !== undefined ? { deadUntil } : {}),
229
+ });
230
+ }
231
+ const activeRaw: unknown = doc.activeSlot;
232
+ const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
233
+ ? activeRaw.trim()
234
+ : undefined;
235
+ const limits = parseLimits(doc.limits, now);
236
+ const usage = parseUsage(doc.usage);
237
+ const state: ClinePoolState = { accounts };
238
+ if (activeSlot) state.activeSlot = activeSlot;
239
+ if (limits) state.limits = limits;
240
+ if (usage) state.usage = usage;
241
+ return state;
242
+ }
243
+
244
+ /**
245
+ * Recover the pool from the backup copy and heal the main file so the recovery
246
+ * sticks. Returns null when no usable backup exists. Only ever called for a
247
+ * missing or unusable main file — never to override a valid empty pool.
248
+ */
249
+ export function recoverPoolFromBackup(description: string): ClinePoolState | null {
250
+ try {
251
+ const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
252
+ if (parsed && parsed.accounts.length > 0) {
253
+ logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
254
+ savePool(parsed);
255
+ return parsed;
256
+ }
257
+ } catch { }
258
+ return null;
259
+ }
260
+
261
+ export function readPoolFile(): ClinePoolState {
262
+ try {
263
+ if (!fs.existsSync(CLINE_POOL_FILE)) {
264
+ return recoverPoolFromBackup("main file missing") ?? emptyPool();
265
+ }
266
+ const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
267
+ if (parsed) return parsed;
268
+ return recoverPoolFromBackup("file unusable") ?? emptyPool();
269
+ } catch {
270
+ return emptyPool();
271
+ }
272
+ }
273
+
274
+ function diskMtime(): number {
275
+ try {
276
+ return fs.statSync(CLINE_POOL_FILE).mtimeMs;
277
+ } catch {
278
+ return -1;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Load the pool, re-reading from disk only when another process changed the
284
+ * file (mtime moved). Never throws; corrupt reads come back empty.
285
+ */
286
+ export function loadPool(): ClinePoolState {
287
+ const before = diskMtime();
288
+ if (cached && before === cachedMtime) return clonePool(cached);
289
+ let loaded = readPoolFile();
290
+ let stamp = diskMtime();
291
+ // The read is only trustworthy when the file did not move across it. Reading
292
+ // first and stamping afterwards latches pre-read content under the post-write
293
+ // mtime, which hides the change until the next write. Re-read once; the mtime
294
+ // observed after that read is the one the returned pool is cached under.
295
+ if (stamp !== before) {
296
+ loaded = readPoolFile();
297
+ stamp = diskMtime();
298
+ }
299
+ cached = clonePool(loaded);
300
+ cachedMtime = stamp;
301
+ return clonePool(cached);
302
+ }
303
+
304
+ /** How many accounts a raw pool blob holds; -1 when it is unreadable. */
305
+ function accountCountIn(raw: string): number {
306
+ try {
307
+ const doc = JSON.parse(raw) as { accounts?: unknown };
308
+ return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
309
+ } catch {
310
+ return -1;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Keep the largest pool ever seen as the recovery copy.
316
+ *
317
+ * Largest-wins rather than last-write-wins, deliberately. The backup is read
318
+ * only when the main file is missing or unreadable, and two failure modes had
319
+ * to be covered at once:
320
+ * - an existing richer pool being overwritten (compare the file being
321
+ * replaced, before the write), and
322
+ * - growth being lost because the backup always lagged one write (compare
323
+ * the pool just written, after the write).
324
+ * Ties do not replace, so a run of equally-sized destructive writes cannot
325
+ * evict a good copy — which is how a real pool was lost: two good logins were
326
+ * replaced by fixtures, and the second fixture write replaced the backup too.
327
+ */
328
+ export function keepLargestBackup(raw: string): void {
329
+ try {
330
+ const count = accountCountIn(raw);
331
+ if (count <= 0) return;
332
+ let backupCount = -1;
333
+ try {
334
+ backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
335
+ } catch {
336
+ backupCount = -1;
337
+ }
338
+ if (count <= backupCount) return;
339
+ fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
340
+ try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
341
+ } catch { }
342
+ }
343
+
344
+ /**
345
+ * Scrub one explicitly removed login out of the recovery copy.
346
+ *
347
+ * Largest-wins keeps that copy rich, which is what saves a real pool from an
348
+ * accidental smaller write — but it also means a logout's token (and its
349
+ * refresh grant) can sit in `<pool>.bak` and sign the user back in if the main
350
+ * file is ever lost. A removal is an explicit instruction, so the removed
351
+ * identity is dropped from the copy too — matched by slot or by bearer, since
352
+ * a legacy pool can hold one account under two slots. Every other account
353
+ * stays available for recovery.
354
+ */
355
+ export function forgetAccountInBackup(removed: ClineAccount): void {
356
+ try {
357
+ if (!fs.existsSync(CLINE_POOL_BACKUP_FILE)) return;
358
+ const parsed: unknown = JSON.parse(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
359
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
360
+ const doc: Record<string, unknown> = parsed as Record<string, unknown>;
361
+ if (!Array.isArray(doc.accounts)) return;
362
+ const kept = (doc.accounts as unknown[]).filter((entry) => {
363
+ if (typeof entry !== "object" || entry === null) return true;
364
+ const rec: Record<string, unknown> = entry as Record<string, unknown>;
365
+ if (typeof rec.slot === "string" && rec.slot.trim() === removed.slot) return false;
366
+ return !(typeof rec.token === "string" && rec.token.length > 0 && rec.token === removed.token);
367
+ });
368
+ if (kept.length === doc.accounts.length) return;
369
+ doc.accounts = kept;
370
+ fs.writeFileSync(CLINE_POOL_BACKUP_FILE, JSON.stringify(doc, null, 2), { encoding: "utf8", mode: 0o600 });
371
+ try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
372
+ } catch { }
373
+ }
374
+
375
+ /**
376
+ * Atomically persist the pool with owner-only permissions (temp file + rename,
377
+ * see src/atomic-write.ts). Never logs secrets.
378
+ *
379
+ * Returns false when the pool did not reach the disk, so a caller that tells
380
+ * the user "saved" can tell the truth instead. The bytes are read back after
381
+ * the rename: a rename that reported success is not proof that the next read
382
+ * sees the new pool — a read-only target is the live failure mode.
383
+ */
384
+ export function savePool(pool: ClinePoolState): boolean {
385
+ try {
386
+ const dir = path.dirname(CLINE_POOL_FILE);
387
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
388
+ // The pool being replaced is the only copy of itself: secure it first.
389
+ try {
390
+ if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
391
+ } catch { }
392
+ const serialized = JSON.stringify(pool, null, 2);
393
+ writeFileAtomic(CLINE_POOL_FILE, serialized, { mode: 0o600 });
394
+ if (fs.readFileSync(CLINE_POOL_FILE, "utf8") !== serialized) {
395
+ throw new Error(`pool write did not land at ${CLINE_POOL_FILE}`);
396
+ }
397
+ // Compare the pool just written too, so growth is never the copy that lags.
398
+ keepLargestBackup(serialized);
399
+ cached = clonePool(pool);
400
+ cachedMtime = diskMtime();
401
+ return true;
402
+ } catch (e) {
403
+ logWarn("could not persist cline pool", { slots: pool.accounts.length, reason: e instanceof Error ? e.message : String(e) });
404
+ // The in-memory snapshot must not outlive the write that failed: with the
405
+ // cache dropped, the next load re-reads the disk and sees what actually
406
+ // landed instead of the pool this process hoped to write.
407
+ cached = null;
408
+ cachedMtime = -1;
409
+ return false;
410
+ }
411
+ }
412
+
413
+ /** Last-4 display for a token; the full value is never shown or logged. */
414
+ export function redactedToken(token: string): string {
415
+ const tail = (token || "").slice(-4) || "????";
416
+ return `…${tail}`;
417
+ }
418
+
419
+ /**
420
+ * Persist a patch to one slot against the file as it is on disk right now.
421
+ * A request holds its pool snapshot across network round trips, and the host
422
+ * process writes the same file for login/logout, so writing the snapshot back
423
+ * would revert a login added or removed while the request was in flight. A
424
+ * slot that no longer exists is left alone rather than resurrected.
425
+ */
426
+ export function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
427
+ const live = readPoolFile();
428
+ const target = live.accounts.find((a) => a.slot === slot);
429
+ if (!target) return;
430
+ Object.assign(target, patch);
431
+ savePool(live);
432
+ }
433
+
434
+ /** Record which slot served a turn, without clobbering concurrent edits. */
435
+ export function markActiveSlotOnDisk(slot: string): void {
436
+ const live = readPoolFile();
437
+ if (!live.accounts.some((a) => a.slot === slot)) return;
438
+ live.activeSlot = slot;
439
+ savePool(live);
440
+ }
441
+
442
+ /**
443
+ * Bump one slot's serve counters against the file as it is on disk right
444
+ * now: a request holds its pool snapshot across round trips, and the host
445
+ * process writes the same file for login/logout, so writing the snapshot
446
+ * back would revert a login added or removed while the request was in
447
+ * flight. A slot that no longer exists is left alone rather than
448
+ * resurrected. The row carries counters only — never token or identity.
449
+ */
450
+ export function recordClineUsageOnDisk(slot: string, modelId: string): void {
451
+ const cleanSlot = (slot || "").trim();
452
+ if (!cleanSlot) return;
453
+ const live = readPoolFile();
454
+ if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
455
+ const prev = live.usage?.[cleanSlot];
456
+ const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
457
+ ? Math.floor(prev.served) + 1
458
+ : 1;
459
+ live.usage = {
460
+ ...(live.usage ?? {}),
461
+ [cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
462
+ };
463
+ savePool(live);
464
+ }
465
+
466
+ /** The reset still in force for one slot+requested model, or null when free. */
467
+ export function activeLimitFor(pool: ClinePoolState, slot: string, model: string, now: number): number | null {
468
+ if (!model) return null;
469
+ const at = pool.limits?.[slot]?.[model];
470
+ return typeof at === "number" && Number.isFinite(at) && at > now ? at : null;
471
+ }
472
+
473
+ /**
474
+ * Persist one free-limit observation against the file as it is on disk right
475
+ * now: a request holds its pool snapshot across round trips, and the host
476
+ * process writes the same file for login/logout, so writing the snapshot back
477
+ * would revert a login added or removed while the request was in flight.
478
+ */
479
+ export function recordLimitOnDisk(slot: string, model: string, resetAt: number): void {
480
+ if (!model) return;
481
+ const live = readPoolFile();
482
+ if (!live.accounts.some((a) => a.slot === slot)) return;
483
+ const known = live.limits?.[slot]?.[model];
484
+ // Keep the later of the two: a fresh report must not shorten a window that is
485
+ // already known, and repeating the same reset writes nothing.
486
+ if (typeof known === "number" && known >= resetAt) return;
487
+ const limits = live.limits ?? {};
488
+ limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
489
+ live.limits = limits;
490
+ savePool(live);
491
+ }
492
+
493
+ /** Drop one slot+model limit once that slot serves the model again. */
494
+ export function clearLimitOnDisk(slot: string, model: string): void {
495
+ if (!model) return;
496
+ const live = readPoolFile();
497
+ const limits = live.limits;
498
+ const forSlot = limits?.[slot];
499
+ if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
500
+ delete forSlot[model];
501
+ if (Object.keys(forSlot).length === 0) delete limits[slot];
502
+ if (Object.keys(limits).length === 0) delete live.limits;
503
+ savePool(live);
504
+ }
505
+
506
+ /**
507
+ * Identity of a Cline login. Two slots holding the same account share one free
508
+ * quota, so rotating between them buys nothing — callers use this to detect
509
+ * that instead of saving a duplicate.
510
+ *
511
+ * accountId (WorkOS user id) is authoritative; email is the readable fallback.
512
+ * When neither side carries an identity — a legacy `clp_` key slot has none —
513
+ * only an identical bearer proves the same account.
514
+ */
515
+ export function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
516
+ const idA = account.accountId?.trim();
517
+ const idB = identity.accountId?.trim();
518
+ if (idA && idB) return idA === idB;
519
+ const mailA = account.email?.trim().toLowerCase();
520
+ const mailB = identity.email?.trim().toLowerCase();
521
+ if (mailA && mailB) return mailA === mailB;
522
+ const token = identity.token;
523
+ return typeof token === "string" && token.length > 0 && account.token === token;
524
+ }
525
+
526
+ /**
527
+ * The other slot already holding this account, or null when it is new.
528
+ * `exceptSlot` skips the slot being written, so re-logging into an existing
529
+ * slot is never treated as a duplicate.
530
+ */
531
+ export function findClineAccountSlot(
532
+ pool: ClinePoolState,
533
+ identity: { token?: string; accountId?: string; email?: string },
534
+ exceptSlot?: string,
535
+ ): string | null {
536
+ const hit = pool.accounts.find(
537
+ (a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
538
+ );
539
+ return hit ? hit.slot : null;
540
+ }
541
+
542
+ /**
543
+ * Save (or replace) one login slot. The token must carry the `workos:` prefix.
544
+ * Throws on bad input — the message never echoes the token.
545
+ * Extras carry the device-login grant (refresh token, expiry, identity);
546
+ * legacy key slots keep calling with two args and load untouched.
547
+ * Refuses an account that another slot already holds.
548
+ */
549
+ export function addAccount(
550
+ slot: string,
551
+ token: string,
552
+ extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
553
+ ): ClinePoolState {
554
+ const cleanSlot = (slot || "").trim();
555
+ if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
556
+ if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
557
+ const cleanToken = (token || "").trim();
558
+ 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");
559
+ const pool = loadPool();
560
+ const existing = pool.accounts.find((a) => a.slot === cleanSlot);
561
+ const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
562
+ if (duplicate) {
563
+ const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
564
+ 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`);
565
+ }
566
+ if (existing) {
567
+ existing.token = cleanToken;
568
+ // A fresh login is proof the grant works again: a quarantine recorded
569
+ // against the credential it replaces must never outlive it.
570
+ delete existing.deadUntil;
571
+ if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
572
+ if (extras?.expiresAt !== undefined) {
573
+ if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
574
+ else delete existing.expiresAt;
575
+ }
576
+ if (extras?.accountId !== undefined) {
577
+ if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
578
+ else delete existing.accountId;
579
+ }
580
+ if (extras?.email !== undefined) {
581
+ if (extras.email.trim()) existing.email = extras.email.trim();
582
+ else delete existing.email;
583
+ }
584
+ } else {
585
+ pool.accounts.push({
586
+ slot: cleanSlot,
587
+ token: cleanToken,
588
+ addedAt: new Date().toISOString(),
589
+ ...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
590
+ ...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
591
+ ? { expiresAt: extras.expiresAt }
592
+ : {}),
593
+ ...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
594
+ ...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
595
+ });
596
+ }
597
+ if (!pool.activeSlot) pool.activeSlot = cleanSlot;
598
+ // A save that never reached the disk must not report success: the caller
599
+ // prints "ready to use" on the strength of this return, and the login would
600
+ // vanish on the next read. The write helper already logged the cause.
601
+ if (!savePool(pool)) {
602
+ // Re-read the way the relay deploy path does before blaming the write.
603
+ let landed = false;
604
+ try {
605
+ landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
606
+ } catch {
607
+ landed = false;
608
+ }
609
+ if (!landed) {
610
+ throw new Error(
611
+ `Cline login [${cleanSlot}] could not be saved to ${CLINE_POOL_FILE} — check that the directory is writable and log in again`,
612
+ );
613
+ }
614
+ }
615
+ return clonePool(pool);
616
+ }
617
+
618
+ /** Drop one login slot. Returns false when the slot was not saved. */
619
+ export function removeAccount(slot: string): boolean {
620
+ const cleanSlot = (slot || "").trim();
621
+ const pool = loadPool();
622
+ const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
623
+ if (idx < 0) return false;
624
+ const [removed] = pool.accounts.splice(idx, 1);
625
+ if (pool.usage?.[cleanSlot] !== undefined) {
626
+ delete pool.usage[cleanSlot];
627
+ if (Object.keys(pool.usage).length === 0) delete pool.usage;
628
+ }
629
+ // A cap belongs to the login that hit it: keeping it would outlive the slot
630
+ // it names and key a later login of the same name into a stale window.
631
+ if (pool.limits?.[cleanSlot] !== undefined) {
632
+ delete pool.limits[cleanSlot];
633
+ if (Object.keys(pool.limits).length === 0) delete pool.limits;
634
+ }
635
+ if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
636
+ savePool(pool);
637
+ // A logout is explicit: the removed grant must not survive in the recovery
638
+ // copy, or losing the main file would sign that account back in.
639
+ forgetAccountInBackup(removed);
640
+ return true;
641
+ }