pi-freeflow 1.22.4 → 1.23.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.23.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 1.23.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Adding more Cline logins is now easy. Every login gets its own slot (`default`, `slot-2`, `slot-3`, …) and requests roll across all of them, so a second or third account keeps you working when one hits its free daily limit. The sign-in prompt now shows the link to sign out of Cline in your browser (`/freeflow cline signout` prints it any time), which is what you need to approve the next account with a different email — or use a private window and keep the session you already have. `/freeflow cline accounts` shows each login's email so you can tell them apart.
14
+
3
15
  ## 1.22.4
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -177,6 +177,7 @@ The same command set works identically in OMP and Pi:
177
177
  /freeflow cline login # Sign in through the browser, save to the per-user pool
178
178
  /freeflow cline accounts # List saved Cline logins
179
179
  /freeflow cline logout # Remove a saved Cline login
180
+ /freeflow cline signout # Link to sign out of Cline in your browser (to add another account)
180
181
  /freeflow export [path] [--include-secrets] # Save the relay pool to a file (default freeflow-relays.json; passwords left out unless asked)
181
182
  /freeflow import <path> [--merge|--replace] [--dry-run] # Load a relay pool from a file (merge is default; replace asks first; dry-run previews only)
182
183
  ```
@@ -236,6 +237,10 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
236
237
 
237
238
  Rotating per-account promo, direct only (never through the relay pool). Sign in through your browser with `/freeflow cline login`, manage with `/freeflow cline accounts` and `/freeflow cline logout`. Requests roll across saved logins when one hits its daily free limit.
238
239
 
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
+
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
+
239
244
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
240
245
  | :--- | :--- | :--- | :--- | :--- | :--- |
241
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.22.4",
4
+ "version": "1.23.1",
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",
@@ -238,11 +238,48 @@ function markActiveSlotOnDisk(slot: string): void {
238
238
  savePool(live);
239
239
  }
240
240
 
241
+ /**
242
+ * Identity of a Cline login. Two slots holding the same account share one free
243
+ * quota, so rotating between them buys nothing — callers use this to detect
244
+ * that instead of saving a duplicate.
245
+ *
246
+ * accountId (WorkOS user id) is authoritative; email is the readable fallback.
247
+ * When neither side carries an identity — a legacy `clp_` key slot has none —
248
+ * only an identical bearer proves the same account.
249
+ */
250
+ function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
251
+ const idA = account.accountId?.trim();
252
+ const idB = identity.accountId?.trim();
253
+ if (idA && idB) return idA === idB;
254
+ const mailA = account.email?.trim().toLowerCase();
255
+ const mailB = identity.email?.trim().toLowerCase();
256
+ if (mailA && mailB) return mailA === mailB;
257
+ const token = identity.token;
258
+ return typeof token === "string" && token.length > 0 && account.token === token;
259
+ }
260
+
261
+ /**
262
+ * The other slot already holding this account, or null when it is new.
263
+ * `exceptSlot` skips the slot being written, so re-logging into an existing
264
+ * slot is never treated as a duplicate.
265
+ */
266
+ export function findClineAccountSlot(
267
+ pool: ClinePoolState,
268
+ identity: { token?: string; accountId?: string; email?: string },
269
+ exceptSlot?: string,
270
+ ): string | null {
271
+ const hit = pool.accounts.find(
272
+ (a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
273
+ );
274
+ return hit ? hit.slot : null;
275
+ }
276
+
241
277
  /**
242
278
  * Save (or replace) one login slot. The token must carry the `workos:` prefix.
243
279
  * Throws on bad input — the message never echoes the token.
244
280
  * Extras carry the device-login grant (refresh token, expiry, identity);
245
281
  * legacy key slots keep calling with two args and load untouched.
282
+ * Refuses an account that another slot already holds.
246
283
  */
247
284
  export function addAccount(
248
285
  slot: string,
@@ -255,6 +292,11 @@ export function addAccount(
255
292
  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
293
  const pool = loadPool();
257
294
  const existing = pool.accounts.find((a) => a.slot === cleanSlot);
295
+ const duplicate = findClineAccountSlot(pool, { token, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
296
+ if (duplicate) {
297
+ const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
298
+ 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`);
299
+ }
258
300
  if (existing) {
259
301
  existing.token = token;
260
302
  if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
@@ -45,6 +45,16 @@ export const DEFAULT_WORKOS_BASE = "https://api.workos.com";
45
45
  export const DEFAULT_API_BASE = "https://api.cline.bot";
46
46
  export const WORKOS_TOKEN_PREFIX = "workos:";
47
47
 
48
+ /**
49
+ * Cline's own sign-out route, for switching accounts in the browser. Verified
50
+ * live: it answers 302 -> https://app.cline.bot/dashboard. Cline ships no
51
+ * hosted sign-out page on its auth host (every /logout, /sign-out, /signout,
52
+ * /account path there is 404), so this app route is the one to point at.
53
+ * The device flow reuses whatever account the browser is signed in with, and
54
+ * a private window clears that without ending the current session.
55
+ */
56
+ export const CLINE_BROWSER_SIGNOUT_URL = "https://api.cline.bot/api/v1/auth/logout";
57
+
48
58
  /** Env override for the WorkOS client id (wins over the bundled default). */
49
59
  export const CLINE_WORKOS_CLIENT_ID_ENV = "PI_FREEFLOW_CLINE_WORKOS_CLIENT_ID";
50
60
 
package/src/commands.ts CHANGED
@@ -58,9 +58,9 @@ 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
- import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
63
+ import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
64
64
  import { isCertError } from "./system-ca-fetch.ts";
65
65
  import type {
66
66
  ExtensionAPI,
@@ -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). */
@@ -1463,7 +1468,7 @@ export function createCommandSpec(
1463
1468
  }
1464
1469
  const link = started.verificationUriComplete ?? started.verificationUri;
1465
1470
  const minutes = Math.max(1, Math.round(started.expiresIn / 60));
1466
- const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.`;
1471
+ const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.\nDifferent account? Sign out first: ${CLINE_BROWSER_SIGNOUT_URL} (or use a private window)`;
1467
1472
  ctx.ui.notify(loginLine, "info");
1468
1473
  try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
1469
1474
  const progress = setInterval(() => {
@@ -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,
@@ -1550,8 +1564,15 @@ export function createCommandSpec(
1550
1564
  }
1551
1565
  }
1552
1566
  }
1567
+ } else if (action === "signout" || action === "sign-out" || action === "switch") {
1568
+ // Signing out is a browser action; it never touches saved logins here.
1569
+ const saved = loadPool().accounts.length;
1570
+ ctx.ui.notify(
1571
+ `Sign out of Cline in your browser to use a different account:\n${CLINE_BROWSER_SIGNOUT_URL}\nA private window works too, and keeps your current session.\nThen run /freeflow cline login again — each login gets its own slot (${saved ? `you have ${saved}; next is ` : "next is "}${nextClineSlot(loadPool())}).`,
1572
+ "info",
1573
+ );
1553
1574
  } else {
1554
- ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
1575
+ ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
1555
1576
  }
1556
1577
  } else if (sub === "export") {
1557
1578
  const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];