pi-freeflow 1.22.2 → 1.23.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.
@@ -4,7 +4,8 @@
4
4
  * Cline serves its free models direct-only (never through the relay pool),
5
5
  * one bearer key per login slot. Keys live in a 0600 file beside the relay
6
6
  * state; a corrupt file reads back as an empty pool (never throws, never
7
- * logs secrets). Request failover walks the pool with per-slot cooldowns.
7
+ * logs secrets). Request failover walks the pool in order, trying every slot
8
+ * fresh on every request — no slot is ever locked out.
8
9
  */
9
10
 
10
11
  import fs from "node:fs";
@@ -31,14 +32,13 @@ export interface ClineAccount {
31
32
  slot: string;
32
33
  token: string;
33
34
  addedAt: string;
34
- lastOkAt?: number;
35
35
  /** WorkOS refresh token for device-login slots; absent on legacy key slots. */
36
36
  refreshToken?: string;
37
37
  /** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
38
38
  expiresAt?: number;
39
- /** WorkOS account id, kept for display/diagnostics only. */
39
+ /** WorkOS account id, shown when choosing which login to remove. */
40
40
  accountId?: string;
41
- /** Login email, kept for display/diagnostics only. */
41
+ /** Login email, shown when choosing which login to remove. */
42
42
  email?: string;
43
43
  }
44
44
 
@@ -66,41 +66,31 @@ export interface ClineRefreshResult {
66
66
  */
67
67
  export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
68
68
 
69
- /** Usable bearer for one slot, resolved via refresh when stale. */
70
- export interface ValidSlotCredentials {
71
- slot: string;
72
- token: string;
73
- }
74
-
75
- /** Cooldowns after failures so a bad key stops absorbing traffic. */
76
- const AUTH_COOLDOWN_MS = 10 * 60_000;
77
- const RATE_COOLDOWN_MS = 90_000;
78
- const SERVER_COOLDOWN_MS = 45_000;
79
- const DEFAULT_COOLDOWN_MS = 30_000;
80
-
81
- const slotCooldowns = new Map<string, number>();
82
-
83
- /** Test-only: clear in-memory slot cooldowns. */
84
- export function _resetClineCooldownsForTest(): void {
85
- slotCooldowns.clear();
86
- }
87
-
88
69
  /** Test-only: drop the loadPool mtime cache. */
89
70
  export function _resetClinePoolCacheForTest(): void {
90
71
  cached = null;
91
72
  cachedMtime = -1;
92
73
  }
93
74
 
94
- function markSlotCooldown(slot: string, ms: number): void {
95
- slotCooldowns.set(slot, Date.now() + ms);
96
- }
75
+ /** Version this proxy reports as its Cline client build. */
76
+ export const CLINE_CLIENT_VERSION = "3.5.54";
97
77
 
98
- /** True when the slot is not cooling down. */
99
- export function isClineSlotHealthy(slot: string): boolean {
100
- const until = slotCooldowns.get(slot);
101
- if (!until) return true;
102
- return Date.now() >= until;
103
- }
78
+ /**
79
+ * Client headers the Cline API expects. Measured live (2026-09-21): the
80
+ * `cline-free/*` models answer 403 "only available via Cline product surfaces"
81
+ * with a bare bearer, and 200 with this set; every non-prefixed catalog model
82
+ * (e.g. z-ai/glm-5.3-flash) answers 200 either way. Header names and shape
83
+ * mirror reference/cline providers/request-headers.ts
84
+ * (DEFAULT_CLINE_REQUEST_HEADERS) — no credential is derived from them.
85
+ */
86
+ export const CLINE_CLIENT_HEADERS: Record<string, string> = {
87
+ "HTTP-Referer": "https://cline.bot",
88
+ "X-Title": "Cline",
89
+ "X-IS-MULTIROOT": "false",
90
+ "X-CLIENT-TYPE": "cline-sdk",
91
+ "X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
92
+ "User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
93
+ };
104
94
 
105
95
  /** Machine-classified outcome of one Cline chat attempt. */
106
96
  export type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
@@ -161,7 +151,6 @@ function readPoolFile(): ClinePoolState {
161
151
  slot,
162
152
  token,
163
153
  addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
164
- ...(typeof rec.lastOkAt === "number" ? { lastOkAt: rec.lastOkAt } : {}),
165
154
  ...(refreshToken ? { refreshToken } : {}),
166
155
  ...(expiresAt !== undefined ? { expiresAt } : {}),
167
156
  ...(accountId ? { accountId } : {}),
@@ -226,6 +215,29 @@ export function redactedToken(token: string): string {
226
215
  return `…${tail}`;
227
216
  }
228
217
 
218
+ /**
219
+ * Persist a patch to one slot against the file as it is on disk right now.
220
+ * A request holds its pool snapshot across network round trips, and the host
221
+ * process writes the same file for login/logout, so writing the snapshot back
222
+ * would revert a login added or removed while the request was in flight. A
223
+ * slot that no longer exists is left alone rather than resurrected.
224
+ */
225
+ function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
226
+ const live = readPoolFile();
227
+ const target = live.accounts.find((a) => a.slot === slot);
228
+ if (!target) return;
229
+ Object.assign(target, patch);
230
+ savePool(live);
231
+ }
232
+
233
+ /** Record which slot served a turn, without clobbering concurrent edits. */
234
+ function markActiveSlotOnDisk(slot: string): void {
235
+ const live = readPoolFile();
236
+ if (!live.accounts.some((a) => a.slot === slot)) return;
237
+ live.activeSlot = slot;
238
+ savePool(live);
239
+ }
240
+
229
241
  /**
230
242
  * Save (or replace) one login slot. The token must carry the `workos:` prefix.
231
243
  * Throws on bad input — the message never echoes the token.
@@ -272,7 +284,6 @@ export function addAccount(
272
284
  });
273
285
  }
274
286
  if (!pool.activeSlot) pool.activeSlot = cleanSlot;
275
- slotCooldowns.delete(cleanSlot);
276
287
  savePool(pool);
277
288
  return pool;
278
289
  }
@@ -296,7 +307,6 @@ export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_
296
307
  * caller should fall back to the stale bearer (`null`).
297
308
  */
298
309
  async function refreshAccountInPlace(
299
- pool: ClinePoolState,
300
310
  account: ClineAccount,
301
311
  refreshImpl: ClineRefresher,
302
312
  ): Promise<boolean | null> {
@@ -323,33 +333,18 @@ async function refreshAccountInPlace(
323
333
  }
324
334
  if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
325
335
  if (fresh.email?.trim()) account.email = fresh.email.trim();
326
- savePool(pool);
336
+ // Persist only the refreshed fields: the snapshot this request holds may
337
+ // predate a login added or removed in the host process.
338
+ patchAccountOnDisk(account.slot, {
339
+ token: account.token,
340
+ ...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
341
+ expiresAt: account.expiresAt,
342
+ ...(account.accountId ? { accountId: account.accountId } : {}),
343
+ ...(account.email ? { email: account.email } : {}),
344
+ });
327
345
  return true;
328
346
  }
329
347
 
330
- /**
331
- * Resolve a usable bearer for one slot. Fresh slots and legacy slots without
332
- * a known expiry return immediately; stale slots with a refresh grant try one
333
- * refresh and persist it. Resolves null when the slot is unknown or its grant
334
- * is dead (invalid_grant) — the caller should prompt for a fresh login.
335
- */
336
- export async function getValidSlotCredentials(
337
- slot: string,
338
- opts?: { refreshImpl?: ClineRefresher; skewMs?: number },
339
- ): Promise<ValidSlotCredentials | null> {
340
- const cleanSlot = (slot || "").trim();
341
- if (!cleanSlot) return null;
342
- const pool = loadPool();
343
- const account = pool.accounts.find((a) => a.slot === cleanSlot);
344
- if (!account) return null;
345
- if (!isClineTokenStale(account, opts?.skewMs)) return { slot: account.slot, token: account.token };
346
- if (!account.refreshToken || !opts?.refreshImpl) return { slot: account.slot, token: account.token };
347
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
348
- if (outcome === true) return { slot: account.slot, token: account.token };
349
- if (outcome === false) return null;
350
- return { slot: account.slot, token: account.token };
351
- }
352
-
353
348
  /** Drop one login slot. Returns false when the slot was not saved. */
354
349
  export function removeAccount(slot: string): boolean {
355
350
  const cleanSlot = (slot || "").trim();
@@ -358,7 +353,6 @@ export function removeAccount(slot: string): boolean {
358
353
  if (idx < 0) return false;
359
354
  pool.accounts.splice(idx, 1);
360
355
  if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
361
- slotCooldowns.delete(cleanSlot);
362
356
  savePool(pool);
363
357
  return true;
364
358
  }
@@ -368,13 +362,10 @@ export interface ClineRollOpts {
368
362
  body: string;
369
363
  /** Chat completions endpoint (proxy passes its configured URL explicitly). */
370
364
  chatUrl: string;
371
- /** Restrict the roll to these slots; default is the whole pool. */
372
- slots?: string[];
373
365
  fetchImpl?: FetchImpl;
374
- reqId?: string;
375
366
  /**
376
367
  * Device-login refresher. When present, a stale slot refreshes once before
377
- * its attempt, and a 401/403 refreshes once before the slot cools out.
368
+ * its attempt, and a 401/403 refreshes once before the slot is skipped.
378
369
  * Absent: every slot serves its stored bearer exactly as before.
379
370
  */
380
371
  refreshImpl?: ClineRefresher;
@@ -384,52 +375,54 @@ export interface ClineRollResult {
384
375
  res: Response;
385
376
  /** Slot that served the response; null when no account was tried. */
386
377
  slot: string | null;
387
- /** True when every account cooled out (or the pool is empty). */
378
+ /** True when every account failed (or the pool is empty). */
388
379
  exhausted: boolean;
389
380
  kind: ClineErrorKind;
390
381
  }
391
382
 
392
- function exhaustedResponse(reason: string): Response {
383
+ /**
384
+ * Synthetic body for the two states that never reached Cline: nothing is
385
+ * saved to sign in with, or no saved login could reach Cline. Neither is a
386
+ * rate limit, so it must not answer 429 — that status makes hosts back off
387
+ * and hide the actionable message behind a "rate limited" notice.
388
+ */
389
+ function exhaustedResponse(reason: string, status: number): Response {
393
390
  return new Response(
394
391
  JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
395
- { status: 429, headers: { "content-type": "application/json" } },
392
+ { status, headers: { "content-type": "application/json" } },
396
393
  );
397
394
  }
398
395
 
399
396
  /**
400
- * POST one chat body to the Cline endpoint, rolling across saved slots with
401
- * per-slot cooldowns. Each attempt sends its slot bearer explicitly; raw
402
- * tokens never leave this module. Response bodies are never consumed here —
403
- * the caller owns the returned Response.
397
+ * POST one chat body to the Cline endpoint, rolling across every saved slot.
398
+ * Each attempt sends its slot bearer explicitly; raw tokens never leave this
399
+ * module. Superseded failure bodies are cancelled here; the response handed
400
+ * back to the caller is left untouched so the caller can read it.
404
401
  */
405
402
  export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
406
403
  const fetchImpl = opts.fetchImpl ?? fetch;
407
404
  const pool = loadPool();
408
- const wanted = opts.slots?.map((s) => s.trim()).filter(Boolean);
409
- let candidates = wanted?.length
410
- ? pool.accounts.filter((a) => wanted.includes(a.slot))
411
- : [...pool.accounts];
405
+ let candidates = [...pool.accounts];
412
406
  if (pool.activeSlot) {
413
407
  const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
414
408
  if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
415
409
  }
416
- candidates = candidates.filter((a) => isClineSlotHealthy(a.slot));
417
410
  if (candidates.length === 0) {
418
- const reason = pool.accounts.length === 0
419
- ? "No Cline accounts saved — add one with /freeflow cline login"
420
- : "All Cline accounts are cooling down — try again shortly";
421
- logWarn("cline pool exhausted", { slots: pool.accounts.length });
422
- return { res: exhaustedResponse(reason), slot: null, exhausted: true, kind: "exhausted" };
411
+ const reason = "No Cline logins saved — add one with /freeflow cline login";
412
+ logWarn("cline pool empty", { slots: pool.accounts.length });
413
+ return { res: exhaustedResponse(reason, 401), slot: null, exhausted: true, kind: "exhausted" };
423
414
  }
415
+ let lastRes: Response | null = null;
416
+ let lastSlot: string | null = null;
417
+ let lastKind: ClineErrorKind = "exhausted";
424
418
  for (const account of candidates) {
425
419
  // Stale device-login bearer: one refresh before the attempt. A dead grant
426
- // cools the slot out immediately; a transient fault keeps the stale token.
420
+ // skips the slot for this turn; a transient fault keeps the stale token.
427
421
  let refreshedThisSlot = false;
428
422
  if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
429
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
423
+ const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
430
424
  if (outcome === false) {
431
- markSlotCooldown(account.slot, AUTH_COOLDOWN_MS);
432
- logWarn("cline slot refresh rejected — cooling slot", { slot: account.slot });
425
+ logWarn("cline slot refresh rejected — skipping slot this turn", { slot: account.slot });
433
426
  continue;
434
427
  }
435
428
  refreshedThisSlot = outcome === true;
@@ -440,67 +433,73 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
440
433
  method: "POST",
441
434
  headers: {
442
435
  "content-type": "application/json",
436
+ ...CLINE_CLIENT_HEADERS,
443
437
  authorization: `Bearer ${token}`,
444
438
  },
445
439
  body: opts.body,
446
440
  });
447
441
  } catch (e) {
448
- markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
449
- logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
442
+ logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
450
443
  void e;
451
444
  return null;
452
445
  }
453
446
  };
454
- const succeed = (res: Response, kind: ClineErrorKind): ClineRollResult => {
455
- if (kind === "ok") {
456
- account.lastOkAt = Date.now();
457
- pool.activeSlot = account.slot;
458
- savePool(pool);
447
+ const succeed = async (res: Response, kind: ClineErrorKind): Promise<ClineRollResult> => {
448
+ if (kind === "ok") markActiveSlotOnDisk(account.slot);
449
+ if (lastRes && lastRes !== res) {
450
+ try { await lastRes.body?.cancel(); } catch { }
459
451
  }
452
+ lastRes = null;
460
453
  return { res, slot: account.slot, exhausted: false, kind };
461
454
  };
462
- const cool = (kind: ClineErrorKind, status: number): void => {
463
- markSlotCooldown(
464
- account.slot,
465
- kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
466
- );
467
- logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status });
455
+ const stashFailure = async (res: Response, kind: ClineErrorKind): Promise<void> => {
456
+ if (lastRes && lastRes !== res) {
457
+ try { await lastRes.body?.cancel(); } catch { }
458
+ }
459
+ lastRes = res;
460
+ lastSlot = account.slot;
461
+ lastKind = kind;
468
462
  };
469
463
  let res = await attempt(account.token);
470
464
  if (!res) continue;
471
465
  let kind = mapClineError(res.status);
472
- if (kind === "ok" || kind === "client") return succeed(res, kind);
466
+ if (kind === "ok" || kind === "client") return await succeed(res, kind);
473
467
  // Auth failure on a refreshable slot that has not refreshed yet: one
474
468
  // refresh, then exactly one retry with the fresh bearer.
475
469
  if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
476
- try {
477
- await res.body?.cancel();
478
- } catch { }
479
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
470
+ const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
480
471
  if (outcome === true) {
472
+ try {
473
+ await res.body?.cancel();
474
+ } catch { }
481
475
  refreshedThisSlot = true;
482
476
  res = await attempt(account.token);
483
477
  if (!res) continue;
484
478
  kind = mapClineError(res.status);
485
- if (kind === "ok" || kind === "client") return succeed(res, kind);
479
+ if (kind === "ok" || kind === "client") return await succeed(res, kind);
486
480
  } else if (outcome === false) {
487
481
  try {
488
482
  await res.body?.cancel();
489
483
  } catch { }
490
- cool(kind, res.status);
484
+ logWarn("cline slot refresh rejected — trying next slot", { slot: account.slot });
491
485
  continue;
492
486
  }
493
- // Transient refresh fault: fall through and cool the slot on the
494
- // original auth result below.
487
+ // Transient refresh fault: the original response is still intact, so it is
488
+ // stashed below and can be surfaced to the caller.
495
489
  }
496
- cool(kind, res.status);
497
- try {
498
- await res.body?.cancel();
499
- } catch { }
490
+ logWarn("cline slot failed — trying next slot", { slot: account.slot, status: res.status });
491
+ await stashFailure(res, kind);
492
+ }
493
+ if (lastRes) {
494
+ logWarn("cline pool exhausted after roll — returning last upstream failure", { slots: candidates.length });
495
+ return { res: lastRes, slot: lastSlot, exhausted: true, kind: lastKind };
500
496
  }
501
- logWarn("cline pool exhausted after roll", { slots: candidates.length });
497
+ // Every slot was skipped before producing a response: its saved login was
498
+ // rejected, or the network call threw. Nothing is rate-limited here, so say
499
+ // what actually happened instead of implying the user should wait.
500
+ logWarn("cline pool exhausted with no upstream response", { slots: candidates.length });
502
501
  return {
503
- res: exhaustedResponse("All Cline accounts failed or are cooling down — try again shortly"),
502
+ res: exhaustedResponse("Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login", 502),
504
503
  slot: null,
505
504
  exhausted: true,
506
505
  kind: "exhausted",
@@ -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
 
@@ -60,9 +70,6 @@ export function resolveClineWorkosClientId(): string {
60
70
  return BUNDLED_CLINE_WORKOS_CLIENT_ID;
61
71
  }
62
72
 
63
- /** Resolved client id used by start/poll. */
64
- export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
65
-
66
73
  /**
67
74
  * Wire form of a Cline credential. Cline OAuth access tokens are WorkOS JWTs
68
75
  * (base64url eyJ header) and must ride as workos:<jwt>; dashboard API keys
@@ -212,7 +219,7 @@ export async function pollDeviceToken(
212
219
  }
213
220
  if (error instanceof ClineAuthError) throw error;
214
221
  if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
215
- await sleepMs(interval * 1000, outerSignal);
222
+ await sleepMs(remainingMs(deadline, interval), outerSignal);
216
223
  continue;
217
224
  }
218
225
  const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
@@ -232,6 +239,9 @@ export async function pollDeviceToken(
232
239
  payload.error === "invalid_grant"
233
240
  ) {
234
241
  throwDenied(payload, response.status, `Device login refused (${String(payload.error)})`);
242
+ } else if (response.status >= 500 || response.status === 429) {
243
+ // Transient WorkOS blip: keep polling until the code's own deadline rather
244
+ // than failing a login that would have succeeded seconds later.
235
245
  } else {
236
246
  throw new ClineAuthError(
237
247
  typeof payload.error_description === "string"
@@ -241,10 +251,19 @@ export async function pollDeviceToken(
241
251
  );
242
252
  }
243
253
  if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
244
- await sleepMs(interval * 1000, outerSignal);
254
+ await sleepMs(remainingMs(deadline, interval), outerSignal);
245
255
  }
246
256
  }
247
257
 
258
+ /**
259
+ * Wait before the next poll, never past the device code's own expiry: a
260
+ * `slow_down` reply grows the interval, and sleeping the full interval after
261
+ * the deadline check would keep a dead login alive for minutes.
262
+ */
263
+ function remainingMs(deadline: number, intervalSeconds: number): number {
264
+ return Math.max(0, Math.min(intervalSeconds * 1000, deadline - Date.now()));
265
+ }
266
+
248
267
  async function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
249
268
  if (signal?.aborted) throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
250
269
  if (!signal) {
@@ -271,7 +290,7 @@ interface ClineTokenData {
271
290
  refreshToken?: string;
272
291
  tokenType: string;
273
292
  expiresAt: string;
274
- userInfo: { email: string; clineUserId: string | null };
293
+ userInfo?: { email?: string; clineUserId?: string | null };
275
294
  }
276
295
 
277
296
  function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCredentials {
@@ -287,8 +306,8 @@ function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCre
287
306
  access: data.accessToken,
288
307
  refresh,
289
308
  expires: deriveExpiry(explicit, data.accessToken),
290
- accountId: data.userInfo.clineUserId ?? undefined,
291
- email: data.userInfo.email || undefined,
309
+ accountId: data.userInfo?.clineUserId ?? undefined,
310
+ email: data.userInfo?.email || undefined,
292
311
  metadata: { tokenType: data.tokenType },
293
312
  };
294
313
  }
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, isClineSlotHealthy, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
61
+ import { addAccount, 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,
@@ -367,8 +367,9 @@ function nextClineSlot(pool: ClinePoolState): string {
367
367
  function formatClineAccountLines(pool: ClinePoolState): string[] {
368
368
  return pool.accounts.map((a, idx) => {
369
369
  const star = a.slot === pool.activeSlot ? "*" : " ";
370
- const health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
371
- return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
370
+ const who = a.email || a.accountId;
371
+ const whoPart = who ? ` ${who}` : "";
372
+ return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}`;
372
373
  });
373
374
  }
374
375
  /** User-safe one-line message for caught values (never echoes secrets). */
@@ -1462,7 +1463,7 @@ export function createCommandSpec(
1462
1463
  }
1463
1464
  const link = started.verificationUriComplete ?? started.verificationUri;
1464
1465
  const minutes = Math.max(1, Math.round(started.expiresIn / 60));
1465
- 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.`;
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.\nDifferent account? Sign out first: ${CLINE_BROWSER_SIGNOUT_URL} (or use a private window)`;
1466
1467
  ctx.ui.notify(loginLine, "info");
1467
1468
  try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
1468
1469
  const progress = setInterval(() => {
@@ -1488,9 +1489,7 @@ export function createCommandSpec(
1488
1489
  if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
1489
1490
  code = e.errorCode;
1490
1491
  }
1491
- if (code === "authorization_pending") {
1492
- ctx.ui.notify(loginLine, "info");
1493
- } else if (code === "access_denied" || code === "cancelled") {
1492
+ if (code === "access_denied") {
1494
1493
  ctx.ui.notify("Cline login cancelled.", "warning");
1495
1494
  } else if (code === "expired_token") {
1496
1495
  ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
@@ -1506,24 +1505,60 @@ export function createCommandSpec(
1506
1505
  if (!pool.accounts.length) {
1507
1506
  ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
1508
1507
  } else {
1509
- const lines = pool.accounts.map((a, idx) => {
1510
- const star = a.slot === pool.activeSlot ? "*" : " ";
1511
- const health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
1512
- return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
1513
- });
1514
- ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${lines.join("\n")}`, "info");
1508
+ ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
1515
1509
  }
1516
1510
  } else if (action === "logout" || action === "remove") {
1517
- const slot = arg || ((await ctx.ui.input("Cline slot to remove:", ""))?.trim() || "");
1518
- if (!slot) {
1519
- ctx.ui.notify("Cancelled — no slot provided", "warning");
1520
- } else if (!removeAccount(slot)) {
1521
- ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
1511
+ const pool = loadPool();
1512
+ if (!pool.accounts.length) {
1513
+ ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
1522
1514
  } else {
1523
- ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
1515
+ const resolveSlot = (raw: string): string | null => {
1516
+ const clean = (raw || "").trim();
1517
+ if (!clean) return null;
1518
+ const byIndex = Number.parseInt(clean, 10);
1519
+ if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
1520
+ const exact = pool.accounts.find((a) => a.slot === clean);
1521
+ if (exact) return exact.slot;
1522
+ const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
1523
+ return folded ? folded.slot : null;
1524
+ };
1525
+ let slot = resolveSlot(arg);
1526
+ if (arg && !slot) {
1527
+ const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
1528
+ ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
1529
+ } else {
1530
+ if (!slot) {
1531
+ if (pool.accounts.length === 1) {
1532
+ slot = pool.accounts[0].slot;
1533
+ } else {
1534
+ const opts = formatClineAccountLines(pool);
1535
+ const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
1536
+ if (!choice || choice === "Cancel") {
1537
+ ctx.ui.notify("Cancelled — no slot removed", "warning");
1538
+ return;
1539
+ }
1540
+ const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
1541
+ slot = hit ? hit.slot : null;
1542
+ }
1543
+ }
1544
+ if (!slot) {
1545
+ ctx.ui.notify("Cancelled — no slot removed", "warning");
1546
+ } else if (!removeAccount(slot)) {
1547
+ ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
1548
+ } else {
1549
+ ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
1550
+ }
1551
+ }
1524
1552
  }
1553
+ } else if (action === "signout" || action === "sign-out" || action === "switch") {
1554
+ // Signing out is a browser action; it never touches saved logins here.
1555
+ const saved = loadPool().accounts.length;
1556
+ ctx.ui.notify(
1557
+ `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())}).`,
1558
+ "info",
1559
+ );
1525
1560
  } else {
1526
- ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
1561
+ ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
1527
1562
  }
1528
1563
  } else if (sub === "export") {
1529
1564
  const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];