pi-freeflow 1.23.0 → 1.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.23.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix a test that failed intermittently on CI: it compared the whole response body of a chat request, including the timestamp each side stamps when it builds the body. When a request happened to cross a second boundary the two timestamps differed by one and the test went red even though nothing was wrong. The timestamp is no longer compared digit-for-digit, and the test now checks it is a real timestamp instead. No change to how the proxy behaves.
8
+
9
+ ## 1.23.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Saved Cline logins are now recoverable. The pool keeps a backup copy beside it, so a pool that is missing or unreadable restores itself from the last good set of logins instead of silently coming back empty — losing every saved login to one bad write is no longer permanent. The backup also holds the largest set of logins ever seen, so it survives a run of smaller bad writes rather than being overwritten by the first one. Removing your last login still sticks: an intentionally empty pool is left alone rather than being repopulated from the backup.
14
+
15
+ ## 1.23.1
16
+
17
+ ### Patch Changes
18
+
19
+ - Cline logins no longer accept the same account twice. Two logins for one account share a single free quota, so the second one added nothing while making it look like you had more capacity — signing in with an account you already saved now says which slot has it and leaves your logins untouched. If a set of logins already contains the same account twice, `/freeflow cline accounts` marks the later one `— same account as [slot]` so you can tell them apart and remove the spare.
20
+
3
21
  ## 1.23.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -239,6 +239,8 @@ Rotating per-account promo, direct only (never through the relay pool). Sign in
239
239
 
240
240
  Add as many logins as you want — each one gets its own slot (`default`, `slot-2`, `slot-3`, …) and a request that hits a limit on one rolls straight to the next. To sign in with a different email, sign out of Cline in that browser first (`/freeflow cline signout` prints the link), or use a private window so your current session stays put.
241
241
 
242
+ Each account can only be saved once: an extra login for an account you already have shares the same free quota, so it is refused rather than saved. If a pool already holds two logins for one account (saved before this check), `/freeflow cline accounts` marks the later one `— same account as [default]`, and `/freeflow cline logout <slot>` drops it.
243
+
242
244
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
243
245
  | :--- | :--- | :--- | :--- | :--- | :--- |
244
246
  | `cline-free/deepseek-v4.1-flash` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `low/high/max` | ✅ |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.23.0",
4
+ "version": "1.23.3",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
@@ -27,6 +27,15 @@ export function resolveClinePoolPath(): string {
27
27
 
28
28
  export const CLINE_POOL_FILE = resolveClinePoolPath();
29
29
 
30
+ /**
31
+ * Recovery copy of the pool, read only when the main file is missing or
32
+ * unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
33
+ * difference: an empty pool is a legitimate state here (`/freeflow cline
34
+ * logout` of the last login must stick), so a valid empty main file is never
35
+ * overridden from the backup.
36
+ */
37
+ export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
38
+
30
39
  /** One saved login slot. The token is only ever sent as an explicit bearer header. */
31
40
  export interface ClineAccount {
32
41
  slot: string;
@@ -108,60 +117,89 @@ function emptyPool(): ClinePoolState {
108
117
  return { accounts: [] };
109
118
  }
110
119
 
111
- function readPoolFile(): ClinePoolState {
120
+ /**
121
+ * Parse a pool document. Returns null when the blob is unusable (bad JSON,
122
+ * wrong shape) so callers can fall back to the backup copy. A valid document
123
+ * with zero accounts is NOT null: that is a real empty pool.
124
+ */
125
+ function parsePoolDoc(raw: string): ClinePoolState | null {
126
+ let parsed: unknown;
112
127
  try {
113
- if (!fs.existsSync(CLINE_POOL_FILE)) return emptyPool();
114
- const raw = fs.readFileSync(CLINE_POOL_FILE, "utf8");
115
- let parsed: unknown;
116
- try {
117
- parsed = JSON.parse(raw);
118
- } catch {
119
- logWarn("cline pool file corrupt — starting with an empty pool", { path: CLINE_POOL_FILE });
120
- return emptyPool();
121
- }
122
- if (typeof parsed !== "object" || parsed === null) {
123
- logWarn("cline pool file unusable — starting with an empty pool", { path: CLINE_POOL_FILE });
124
- return emptyPool();
125
- }
126
- // Boundary-narrowed once: the on-disk blob is external input, so check
127
- // its shape here and read only validated fields below.
128
- const doc: Record<string, unknown> = parsed as Record<string, unknown>;
129
- if (!Array.isArray(doc.accounts)) {
130
- logWarn("cline pool file unusable — starting with an empty pool", { path: CLINE_POOL_FILE });
131
- return emptyPool();
128
+ parsed = JSON.parse(raw);
129
+ } catch {
130
+ logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
131
+ return null;
132
+ }
133
+ if (typeof parsed !== "object" || parsed === null) {
134
+ logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
135
+ return null;
136
+ }
137
+ // Boundary-narrowed once: the on-disk blob is external input, so check
138
+ // its shape here and read only validated fields below.
139
+ const doc: Record<string, unknown> = parsed as Record<string, unknown>;
140
+ if (!Array.isArray(doc.accounts)) {
141
+ logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
142
+ return null;
143
+ }
144
+ const accounts: ClineAccount[] = [];
145
+ for (const entry of doc.accounts as unknown[]) {
146
+ if (typeof entry !== "object" || entry === null) continue;
147
+ const rec: Record<string, unknown> = entry as Record<string, unknown>;
148
+ const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
149
+ const token = typeof rec.token === "string" ? rec.token : "";
150
+ if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
151
+ const refreshRaw: unknown = rec.refreshToken;
152
+ const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
153
+ const expiresRaw: unknown = rec.expiresAt;
154
+ const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
155
+ ? expiresRaw
156
+ : undefined;
157
+ const accountIdRaw: unknown = rec.accountId;
158
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
159
+ const emailRaw: unknown = rec.email;
160
+ const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
161
+ accounts.push({
162
+ slot,
163
+ token,
164
+ addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
165
+ ...(refreshToken ? { refreshToken } : {}),
166
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
167
+ ...(accountId ? { accountId } : {}),
168
+ ...(email ? { email } : {}),
169
+ });
170
+ }
171
+ const activeRaw: unknown = doc.activeSlot;
172
+ const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
173
+ ? activeRaw.trim()
174
+ : undefined;
175
+ return activeSlot ? { accounts, activeSlot } : { accounts };
176
+ }
177
+
178
+ /**
179
+ * Recover the pool from the backup copy and heal the main file so the recovery
180
+ * sticks. Returns null when no usable backup exists. Only ever called for a
181
+ * missing or unusable main file — never to override a valid empty pool.
182
+ */
183
+ function recoverPoolFromBackup(description: string): ClinePoolState | null {
184
+ try {
185
+ const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
186
+ if (parsed && parsed.accounts.length > 0) {
187
+ logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
188
+ savePool(parsed);
189
+ return parsed;
132
190
  }
133
- const accounts: ClineAccount[] = [];
134
- for (const entry of doc.accounts as unknown[]) {
135
- if (typeof entry !== "object" || entry === null) continue;
136
- const rec: Record<string, unknown> = entry as Record<string, unknown>;
137
- const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
138
- const token = typeof rec.token === "string" ? rec.token : "";
139
- if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
140
- const refreshRaw: unknown = rec.refreshToken;
141
- const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
142
- const expiresRaw: unknown = rec.expiresAt;
143
- const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
144
- ? expiresRaw
145
- : undefined;
146
- const accountIdRaw: unknown = rec.accountId;
147
- const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
148
- const emailRaw: unknown = rec.email;
149
- const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
150
- accounts.push({
151
- slot,
152
- token,
153
- addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
154
- ...(refreshToken ? { refreshToken } : {}),
155
- ...(expiresAt !== undefined ? { expiresAt } : {}),
156
- ...(accountId ? { accountId } : {}),
157
- ...(email ? { email } : {}),
158
- });
191
+ } catch { }
192
+ return null;
193
+ }
194
+
195
+ function readPoolFile(): ClinePoolState {
196
+ try {
197
+ if (!fs.existsSync(CLINE_POOL_FILE)) {
198
+ return recoverPoolFromBackup("main file missing") ?? emptyPool();
159
199
  }
160
- const activeRaw: unknown = doc.activeSlot;
161
- const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
162
- ? activeRaw.trim()
163
- : undefined;
164
- return activeSlot ? { accounts, activeSlot } : { accounts };
200
+ const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
201
+ if (parsed) return parsed;
202
+ return recoverPoolFromBackup("file unusable") ?? emptyPool();
165
203
  } catch {
166
204
  return emptyPool();
167
205
  }
@@ -190,17 +228,64 @@ export function loadPool(): ClinePoolState {
190
228
  return cached;
191
229
  }
192
230
 
231
+ /**
232
+ * Keep the largest pool ever seen as the recovery copy.
233
+ *
234
+ * Largest-wins rather than last-write-wins, deliberately. The backup is read
235
+ * only when the main file is missing or unreadable, and two failure modes had
236
+ * to be covered at once:
237
+ * - an existing richer pool being overwritten (compare the file being
238
+ * replaced, before the write), and
239
+ * - growth being lost because the backup always lagged one write (compare
240
+ * the pool just written, after the write).
241
+ * Ties do not replace, so a run of equally-sized destructive writes cannot
242
+ * evict a good copy — which is how a real pool was lost: two good logins were
243
+ * replaced by fixtures, and the second fixture write replaced the backup too.
244
+ */
245
+ function keepLargestBackup(raw: string): void {
246
+ try {
247
+ const count = accountCountIn(raw);
248
+ if (count <= 0) return;
249
+ let backupCount = -1;
250
+ try {
251
+ backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
252
+ } catch {
253
+ backupCount = -1;
254
+ }
255
+ if (count <= backupCount) return;
256
+ fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
257
+ try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
258
+ } catch { }
259
+ }
260
+
261
+ /** How many accounts a raw pool blob holds; -1 when it is unreadable. */
262
+ function accountCountIn(raw: string): number {
263
+ try {
264
+ const doc = JSON.parse(raw) as { accounts?: unknown };
265
+ return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
266
+ } catch {
267
+ return -1;
268
+ }
269
+ }
270
+
193
271
  /** Atomically persist the pool with owner-only permissions. Never logs secrets. */
194
272
  export function savePool(pool: ClinePoolState): void {
195
273
  try {
196
274
  const dir = path.dirname(CLINE_POOL_FILE);
197
275
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
276
+ // The pool being replaced is the only copy of itself: secure it first.
277
+ try {
278
+ if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
279
+ } catch { }
198
280
  const tmp = `${CLINE_POOL_FILE}.${Date.now()}.tmp`;
199
- fs.writeFileSync(tmp, JSON.stringify(pool, null, 2), { encoding: "utf8", mode: 0o600 });
281
+ const serialized = JSON.stringify(pool, null, 2);
282
+ fs.writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
200
283
  try {
201
284
  fs.chmodSync(tmp, 0o600);
202
285
  } catch { }
203
286
  fs.renameSync(tmp, CLINE_POOL_FILE);
287
+ // Compare the pool just written too, so growth is never the copy that lags.
288
+ keepLargestBackup(serialized);
204
289
  cached = pool;
205
290
  cachedMtime = diskMtime();
206
291
  } catch (e) {
@@ -238,11 +323,48 @@ function markActiveSlotOnDisk(slot: string): void {
238
323
  savePool(live);
239
324
  }
240
325
 
326
+ /**
327
+ * Identity of a Cline login. Two slots holding the same account share one free
328
+ * quota, so rotating between them buys nothing — callers use this to detect
329
+ * that instead of saving a duplicate.
330
+ *
331
+ * accountId (WorkOS user id) is authoritative; email is the readable fallback.
332
+ * When neither side carries an identity — a legacy `clp_` key slot has none —
333
+ * only an identical bearer proves the same account.
334
+ */
335
+ function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
336
+ const idA = account.accountId?.trim();
337
+ const idB = identity.accountId?.trim();
338
+ if (idA && idB) return idA === idB;
339
+ const mailA = account.email?.trim().toLowerCase();
340
+ const mailB = identity.email?.trim().toLowerCase();
341
+ if (mailA && mailB) return mailA === mailB;
342
+ const token = identity.token;
343
+ return typeof token === "string" && token.length > 0 && account.token === token;
344
+ }
345
+
346
+ /**
347
+ * The other slot already holding this account, or null when it is new.
348
+ * `exceptSlot` skips the slot being written, so re-logging into an existing
349
+ * slot is never treated as a duplicate.
350
+ */
351
+ export function findClineAccountSlot(
352
+ pool: ClinePoolState,
353
+ identity: { token?: string; accountId?: string; email?: string },
354
+ exceptSlot?: string,
355
+ ): string | null {
356
+ const hit = pool.accounts.find(
357
+ (a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
358
+ );
359
+ return hit ? hit.slot : null;
360
+ }
361
+
241
362
  /**
242
363
  * Save (or replace) one login slot. The token must carry the `workos:` prefix.
243
364
  * Throws on bad input — the message never echoes the token.
244
365
  * Extras carry the device-login grant (refresh token, expiry, identity);
245
366
  * legacy key slots keep calling with two args and load untouched.
367
+ * Refuses an account that another slot already holds.
246
368
  */
247
369
  export function addAccount(
248
370
  slot: string,
@@ -255,6 +377,11 @@ export function addAccount(
255
377
  if (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
256
378
  const pool = loadPool();
257
379
  const existing = pool.accounts.find((a) => a.slot === cleanSlot);
380
+ const duplicate = findClineAccountSlot(pool, { token, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
381
+ if (duplicate) {
382
+ const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
383
+ 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`);
384
+ }
258
385
  if (existing) {
259
386
  existing.token = token;
260
387
  if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
package/src/commands.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  formatRelayPickerItem,
59
59
  formatRelayStatusLabel,
60
60
  } from "./relay-state.ts";
61
- import { addAccount, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
61
+ import { addAccount, findClineAccountSlot, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
62
62
  import type { ClinePoolState } from "./cline-accounts.ts";
63
63
  import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
64
64
  import { isCertError } from "./system-ca-fetch.ts";
@@ -369,7 +369,12 @@ function formatClineAccountLines(pool: ClinePoolState): string[] {
369
369
  const star = a.slot === pool.activeSlot ? "*" : " ";
370
370
  const who = a.email || a.accountId;
371
371
  const whoPart = who ? ` ${who}` : "";
372
- return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}`;
372
+ // Two slots on one account share one quota, so rotation between them buys
373
+ // nothing. Mark the later one against the earlier slot it duplicates.
374
+ const earlier = { accounts: pool.accounts.slice(0, idx) };
375
+ const dup = findClineAccountSlot(earlier, a);
376
+ const dupPart = dup ? ` — same account as [${dup}]` : "";
377
+ return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}${dupPart}`;
373
378
  });
374
379
  }
375
380
  /** User-safe one-line message for caught values (never echoes secrets). */
@@ -1475,6 +1480,15 @@ export function createCommandSpec(
1475
1480
  const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
1476
1481
  const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
1477
1482
  const apiKey = toApiKey(creds.access);
1483
+ const dup = findClineAccountSlot(loadPool(), { token: apiKey, accountId: creds.accountId, email: creds.email }, slot);
1484
+ if (dup) {
1485
+ // Same account twice shares one free quota, so it would never add capacity.
1486
+ ctx.ui.notify(
1487
+ `That Cline account${creds.email ? ` (${creds.email})` : ""} is already saved as [${dup}] — nothing added.\nUse a different account: /freeflow cline signout shows the sign-out link. Or drop the existing one first: /freeflow cline logout ${dup}`,
1488
+ "warning",
1489
+ );
1490
+ return;
1491
+ }
1478
1492
  addAccount(slot, apiKey, {
1479
1493
  refreshToken: creds.refresh,
1480
1494
  expiresAt: creds.expires,