pi-freeflow 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Interactive CLI slash command handlers for Cline account management and OAuth.
3
+ */
4
+
5
+ import { addAccount, findClineAccountSlot, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
6
+ import type { ClinePoolState } from "./cline-accounts.ts";
7
+ import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
8
+ import { isCertError } from "./system-ca-fetch.ts";
9
+ import type { ExtensionContext } from "./types.ts";
10
+
11
+ /**
12
+ * Deterministic next-slot picker: "default" if absent (the single-account
13
+ * login, then the smallest free "slot-2", "slot-3", … — never overwriting
14
+ * a saved login. Pure — takes the pool, returns a name.
15
+ */
16
+ export function nextClineSlot(pool: ClinePoolState): string {
17
+ if (!pool.accounts.some((a) => a.slot === "default")) return "default";
18
+ let n = 2;
19
+ while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
20
+ return `slot-${n}`;
21
+ }
22
+
23
+ /**
24
+ * Per-slot usage row as persisted under pool.usage. Read defensively: pools
25
+ * saved before usage tracking existed carry no such field, so every property
26
+ * is narrowed at use. Never carries identity — callers map slot to identity.
27
+ */
28
+ export interface ClineUsageRow {
29
+ served: number;
30
+ lastAt: number;
31
+ lastModel: string;
32
+ }
33
+
34
+ /** Usage map off the pool without depending on the tracking lane's types. */
35
+ export function clineUsageRows(pool: ClinePoolState): Record<string, ClineUsageRow> {
36
+ const raw = (pool as unknown as { usage?: unknown }).usage;
37
+ if (!raw || typeof raw !== "object") return {};
38
+ const out: Record<string, ClineUsageRow> = {};
39
+ for (const [slot, entry] of Object.entries(raw as Record<string, unknown>)) {
40
+ if (!entry || typeof entry !== "object") continue;
41
+ const rec = entry as Record<string, unknown>;
42
+ if (typeof rec.served !== "number" || !(rec.served >= 0)) continue;
43
+ out[slot] = {
44
+ served: rec.served,
45
+ lastAt: typeof rec.lastAt === "number" ? rec.lastAt : 0,
46
+ lastModel: typeof rec.lastModel === "string" ? rec.lastModel : "",
47
+ };
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Masked identity for NEW display text — the raw email never appears there. */
53
+ export function maskClineEmail(email: string): string {
54
+ const at = email.indexOf("@");
55
+ if (at <= 0) return "***";
56
+ const domain = email.slice(at + 1);
57
+ if (!domain) return "***";
58
+ return `${email[0]}***@${domain}`;
59
+ }
60
+
61
+ /** Relative age for a usage timestamp, ISO fallback for old/future values. */
62
+ export function clineUsageAge(lastAt: number, now = Date.now()): string {
63
+ if (!(lastAt > 0)) return "";
64
+ const diff = now - lastAt;
65
+ if (diff < 0) return new Date(lastAt).toISOString();
66
+ if (diff < 60_000) return "just now";
67
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
68
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
69
+ if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`;
70
+ return new Date(lastAt).toISOString();
71
+ }
72
+
73
+ /** Usage suffix for one account line; zero-state reads `served 0 · never used`. */
74
+ export function formatClineUsageSuffix(slot: string, usage: Record<string, ClineUsageRow>, now = Date.now()): string {
75
+ const row = usage[slot];
76
+ if (!row || row.served <= 0) return " · served 0 · never used";
77
+ const age = clineUsageAge(row.lastAt, now);
78
+ const last = row.lastModel ? ` · last ${row.lastModel}${age ? ` ${age}` : ""}` : "";
79
+ return ` · served ${row.served}${last}`;
80
+ }
81
+
82
+ /**
83
+ * Compact Cline block for the status banner. Masked identity only — the raw
84
+ * email never appears here even though account lines still show it.
85
+ */
86
+ export function formatClineStatusSnippet(pool: ClinePoolState, now = Date.now()): string {
87
+ const n = pool.accounts.length;
88
+ if (!n) return "Cline: no logins";
89
+ const usage = clineUsageRows(pool);
90
+ let best: { slot: string; row: ClineUsageRow } | null = null;
91
+ for (const a of pool.accounts) {
92
+ const row = usage[a.slot];
93
+ if (!row || row.served <= 0) continue;
94
+ if (!best || row.lastAt > best.row.lastAt) best = { slot: a.slot, row };
95
+ }
96
+ if (!best) return `Cline: ${n} login(s) · never used`;
97
+ const account = pool.accounts.find((a) => a.slot === best.slot);
98
+ const who = account?.email ? maskClineEmail(account.email) : (account?.accountId || best.slot);
99
+ const model = best.row.lastModel ? ` (${best.row.lastModel})` : "";
100
+ return `Cline: ${n} login(s) · last used: [${best.slot}] ${who}${model}`;
101
+ }
102
+
103
+ /** One notify-ready line per saved login, in pool order, with usage. */
104
+ export function formatClineAccountLines(pool: ClinePoolState): string[] {
105
+ const usage = clineUsageRows(pool);
106
+ return pool.accounts.map((a, idx) => {
107
+ const star = a.slot === pool.activeSlot ? "*" : " ";
108
+ const who = a.email || a.accountId;
109
+ const whoPart = who ? ` ${who}` : "";
110
+ // Two slots on one account share one quota, so rotation between them buys
111
+ // nothing. Mark the later one against the earlier slot it duplicates.
112
+ const earlier = { accounts: pool.accounts.slice(0, idx) };
113
+ const dup = findClineAccountSlot(earlier, a);
114
+ const dupPart = dup ? ` — same account as [${dup}]` : "";
115
+ return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}${dupPart}${formatClineUsageSuffix(a.slot, usage)}`;
116
+ });
117
+ }
118
+
119
+ /** User-safe one-line message for caught values (never echoes secrets). */
120
+ export function clineErrorMessage(e: unknown): string {
121
+ if (e instanceof Error && e.message) return e.message;
122
+ if (typeof e === "string" && e) return e;
123
+ return "unknown error";
124
+ }
125
+
126
+ export interface ClineCommandDeps {
127
+ readonly [key: string]: unknown;
128
+ }
129
+
130
+ export async function handleClineSubcommand(
131
+ sub: string,
132
+ args: string,
133
+ ctx: ExtensionContext,
134
+ _deps?: ClineCommandDeps,
135
+ ): Promise<boolean> {
136
+ if (sub !== "cline") return false;
137
+
138
+ const tokens = args.trim() ? args.trim().split(/\s+/) : [];
139
+ const action = (tokens[0] || "accounts").toLowerCase();
140
+ const arg = tokens.slice(1).join(" ").trim();
141
+ if (action === "login") {
142
+ const raw = tokens.slice(1);
143
+ const pasteMode = raw.some((t) => t === "--key");
144
+ const slotArg = raw.filter((t) => t !== "--key").join(" ").trim();
145
+ if (pasteMode) {
146
+ // Manual fallback: paste a workos: API key (never shown back).
147
+ const slot = slotArg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
148
+ const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
149
+ if (!token) {
150
+ ctx.ui.notify("Cancelled — no API key provided", "warning");
151
+ } else {
152
+ try {
153
+ addAccount(slot, token);
154
+ ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
155
+ } catch (e) {
156
+ ctx.ui.notify((e as Error).message, "warning");
157
+ }
158
+ }
159
+ return true;
160
+ }
161
+ // Device login inside the extension: approve in the browser, no paste.
162
+ const slot = slotArg || nextClineSlot(loadPool());
163
+ let started;
164
+ try {
165
+ started = await startDeviceAuth();
166
+ } catch (e) {
167
+ if (isCertError(e)) {
168
+ ctx.ui.notify("Could not reach Cline: your antivirus or network proxy intercepts TLS and Node does not trust it. The login retries with your OS certificates automatically — if this persists, restart the host with NODE_USE_SYSTEM_CA=1 set, or point NODE_EXTRA_CA_CERTS at your proxy CA file.", "error");
169
+ return true;
170
+ }
171
+ ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
172
+ return true;
173
+ }
174
+ const link = started.verificationUriComplete ?? started.verificationUri;
175
+ const minutes = Math.max(1, Math.round(started.expiresIn / 60));
176
+ 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)`;
177
+ ctx.ui.notify(loginLine, "info");
178
+ try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
179
+ const progress = setInterval(() => {
180
+ ctx.ui.notify(loginLine, "info");
181
+ }, 45000);
182
+ // @ts-ignore allow unref to not block process exit in CLI
183
+ progress.unref?.();
184
+ try {
185
+ const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
186
+ const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
187
+ const apiKey = toApiKey(creds.access);
188
+ const dup = findClineAccountSlot(loadPool(), { token: apiKey, accountId: creds.accountId, email: creds.email }, slot);
189
+ if (dup) {
190
+ // Same account twice shares one free quota, so it would never add capacity.
191
+ ctx.ui.notify(
192
+ `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}`,
193
+ "warning",
194
+ );
195
+ return true;
196
+ }
197
+ addAccount(slot, apiKey, {
198
+ refreshToken: creds.refresh,
199
+ expiresAt: creds.expires,
200
+ ...(creds.accountId ? { accountId: creds.accountId } : {}),
201
+ ...(creds.email ? { email: creds.email } : {}),
202
+ });
203
+ ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(apiKey)}) — free Cline models are ready to use`, "info");
204
+ const pool = loadPool();
205
+ ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
206
+ } catch (e) {
207
+ let code: string | undefined;
208
+ if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
209
+ code = e.errorCode;
210
+ }
211
+ if (code === "access_denied") {
212
+ ctx.ui.notify("Cline login cancelled.", "warning");
213
+ } else if (code === "expired_token") {
214
+ ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
215
+ } else {
216
+ ctx.ui.notify(`Cline login did not finish: ${clineErrorMessage(e)}`, "warning");
217
+ }
218
+ } finally {
219
+ clearInterval(progress);
220
+ try { ctx.ui.setStatus("cline-login", undefined); } catch { }
221
+ }
222
+ } else if (action === "accounts" || action === "list") {
223
+ const pool = loadPool();
224
+ if (!pool.accounts.length) {
225
+ ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
226
+ } else {
227
+ ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
228
+ }
229
+ } else if (action === "logout" || action === "remove") {
230
+ const pool = loadPool();
231
+ if (!pool.accounts.length) {
232
+ ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
233
+ } else {
234
+ const resolveSlot = (raw: string): string | null => {
235
+ const clean = (raw || "").trim();
236
+ if (!clean) return null;
237
+ const byIndex = Number.parseInt(clean, 10);
238
+ if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
239
+ const exact = pool.accounts.find((a) => a.slot === clean);
240
+ if (exact) return exact.slot;
241
+ const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
242
+ return folded ? folded.slot : null;
243
+ };
244
+ let slot = resolveSlot(arg);
245
+ if (arg && !slot) {
246
+ const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
247
+ ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
248
+ } else {
249
+ if (!slot) {
250
+ if (pool.accounts.length === 1) {
251
+ slot = pool.accounts[0].slot;
252
+ } else {
253
+ const opts = formatClineAccountLines(pool);
254
+ const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
255
+ if (!choice || choice === "Cancel") {
256
+ ctx.ui.notify("Cancelled — no slot removed", "warning");
257
+ return true;
258
+ }
259
+ const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
260
+ slot = hit ? hit.slot : null;
261
+ }
262
+ }
263
+ if (!slot) {
264
+ ctx.ui.notify("Cancelled — no slot removed", "warning");
265
+ } else if (!removeAccount(slot)) {
266
+ ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
267
+ } else {
268
+ ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
269
+ }
270
+ }
271
+ }
272
+ } else if (action === "signout" || action === "sign-out" || action === "switch") {
273
+ // Signing out is a browser action; it never touches saved logins here.
274
+ const saved = loadPool().accounts.length;
275
+ ctx.ui.notify(
276
+ `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())}).`,
277
+ "info",
278
+ );
279
+ } else {
280
+ ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
281
+ }
282
+ return true;
283
+ }