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
package/src/commands.ts CHANGED
@@ -4,14 +4,10 @@
4
4
  * log viewing, debug level configuration, and live catalog refreshing.
5
5
  */
6
6
 
7
- import { execSync } from "node:child_process";
8
- import fs from "node:fs";
9
- import os from "node:os";
10
7
  import path from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { getClientPort, isJsRuntimeExecutable, materializeDaemonSources, resolveDaemonRuntime } from "./client.ts";
8
+ import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
13
9
  import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
14
- import { DAEMON_SPAWN_ENV, DATA_DIR_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE, ZEN_TRANSPORT_ENV } from "./config.ts";
10
+ import { DATA_DIR_ENV, DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE, ZEN_TRANSPORT_ENV } from "./config.ts";
15
11
  import { getCachedUpdate, isLinkedInstall } from "./update-checker.ts";
16
12
  import {
17
13
  compareVersions,
@@ -44,31 +40,20 @@ import { resolveZenTransport } from "./zen-transport.ts";
44
40
  import { killPortHolder } from "./proxy.ts";
45
41
  import { getUpstreamHealth, resetUpstreamGate } from "./upstream-health.ts";
46
42
  import {
47
- buildRelayExport,
48
43
  ensureRelay,
49
- EXPORT_DEFAULT_FILENAME,
50
- EXPORT_MAX_BYTES,
51
44
  findRelay,
52
45
  getActiveRelayState,
53
46
  ewmaBadge,
54
47
  getRelayHealth,
55
48
  loadRelayState,
56
- parseRelayImport,
57
- planRelayImport,
58
- removeRelay,
59
49
  setActiveRelayState,
60
- setRelayLabel,
61
50
  setStatusUi,
62
51
  shortRelayLabel,
63
52
  withRelayState,
64
53
  formatRelayFlash,
65
- formatRelayPickerItem,
66
54
  formatRelayStatusLabel,
67
55
  } from "./relay-state.ts";
68
- import { addAccount, findClineAccountSlot, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
69
- import type { ClinePoolState } from "./cline-accounts.ts";
70
- import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
71
- import { isCertError } from "./system-ca-fetch.ts";
56
+ import { loadPool } from "./cline-accounts.ts";
72
57
  import type {
73
58
  ExtensionAPI,
74
59
  ExtensionContext,
@@ -77,20 +62,91 @@ import type {
77
62
  LogLevel,
78
63
  RegisteredCommand,
79
64
  RegisteredModel,
80
- RelayState,
81
65
  } from "./types.ts";
66
+ import {
67
+ STARTUP_SERVICE_NAME,
68
+ STARTUP_TASK_NAME,
69
+ escapeSchTasksAction,
70
+ getStartupPlan,
71
+ handleStartupSubcommand,
72
+ installStartupHook,
73
+ uninstallStartupHook,
74
+ _setStartupHookRunnerForTest,
75
+ type StartupPlan,
76
+ } from "./commands-startup.ts";
77
+ import {
78
+ addRelayInteractive,
79
+ classifyImportFailure,
80
+ editRelayLabelMenu,
81
+ handleRelaySubcommand,
82
+ removeRelayMenu,
83
+ runExport,
84
+ runImport,
85
+ showList,
86
+ showRelayInteractiveMenu,
87
+ switchRelay,
88
+ type RelayCommandDeps,
89
+ } from "./commands-relay.ts";
90
+ import {
91
+ clineErrorMessage,
92
+ clineUsageRows,
93
+ formatClineAccountLines,
94
+ formatClineStatusSnippet,
95
+ formatClineUsageSuffix,
96
+ handleClineSubcommand,
97
+ maskClineEmail,
98
+ nextClineSlot,
99
+ type ClineCommandDeps,
100
+ type ClineUsageRow,
101
+ } from "./commands-cline.ts";
102
+
103
+ export {
104
+ STARTUP_SERVICE_NAME,
105
+ STARTUP_TASK_NAME,
106
+ escapeSchTasksAction,
107
+ getStartupPlan,
108
+ handleStartupSubcommand,
109
+ installStartupHook,
110
+ uninstallStartupHook,
111
+ _setStartupHookRunnerForTest,
112
+ type StartupPlan,
113
+ };
114
+
115
+ export {
116
+ addRelayInteractive,
117
+ classifyImportFailure,
118
+ editRelayLabelMenu,
119
+ handleRelaySubcommand,
120
+ removeRelayMenu,
121
+ runExport,
122
+ runImport,
123
+ showList,
124
+ showRelayInteractiveMenu,
125
+ switchRelay,
126
+ type RelayCommandDeps,
127
+ };
128
+
129
+ export {
130
+ clineErrorMessage,
131
+ clineUsageRows,
132
+ formatClineAccountLines,
133
+ formatClineStatusSnippet,
134
+ formatClineUsageSuffix,
135
+ handleClineSubcommand,
136
+ maskClineEmail,
137
+ nextClineSlot,
138
+ type ClineCommandDeps,
139
+ type ClineUsageRow,
140
+ };
82
141
 
83
142
  /**
84
143
  * Update the TUI status bar widget with active relay state.
85
144
  */
86
145
  export function updateStatusBar(ui?: ExtensionUIContext): void {
87
146
  if (!ui) return;
88
- ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
147
+ ui.setStatus?.("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
89
148
  }
90
149
 
91
- export const STARTUP_TASK_NAME = "pi-freeflow-daemon";
92
- export const STARTUP_SERVICE_NAME = "pi-freeflow.service";
93
-
94
150
  let deployProbeRetries = 5;
95
151
  let deployProbeRetryDelayMs: number | null = null;
96
152
 
@@ -102,235 +158,6 @@ export function _setDeployProbeOptionsForTest(
102
158
  deployProbeRetryDelayMs = opts?.retryDelayMs ?? null;
103
159
  }
104
160
 
105
- /**
106
- * How the POSIX startup hook talks to the user systemd manager. A seam
107
- * because CI (and this Windows dev box) has no user bus: tests must be able to
108
- * prove both the enabled and the systemd-less outcome deterministically.
109
- */
110
- let startupHookRunner: (cmd: string) => void = (cmd: string): void => {
111
- execSync(cmd, { timeout: 10_000, stdio: "ignore" });
112
- };
113
-
114
- /** Test-only: replace the user-systemd runner (null restores execSync). */
115
- export function _setStartupHookRunnerForTest(runner: ((cmd: string) => void) | null): void {
116
- startupHookRunner =
117
- runner ??
118
- ((cmd: string): void => {
119
- execSync(cmd, { timeout: 10_000, stdio: "ignore" });
120
- });
121
- }
122
-
123
- export interface StartupPlan {
124
- platform: string;
125
- taskName: string;
126
- command: string;
127
- filePath: string;
128
- fileContent: string;
129
- undo: string;
130
- }
131
-
132
- function daemonEntryForStartup(scriptOverride?: string): string {
133
- if (scriptOverride) return scriptOverride;
134
- try {
135
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
136
- } catch {
137
- return path.join("src", "daemon.ts");
138
- }
139
- }
140
-
141
- /**
142
- * The `/tr` payload for a Windows scheduled task action.
143
- *
144
- * A task action is launched by CreateProcess, which has no single-quote
145
- * syntax: the old `'…'` substitution handed it a first token carrying a
146
- * literal `'` ("The filename, directory name, or volume label syntax is
147
- * incorrect"), so the startup task could never start. Double quotes are the
148
- * only grouping CreateProcess understands, so keep them and escape them for
149
- * cmd.exe (`\"`); schtasks stores the resulting action verbatim, which is
150
- * exactly the quoted runtime command the task must run.
151
- */
152
- export function escapeSchTasksAction(quotedCommand: string): string {
153
- return quotedCommand.replace(/"/g, '\\"');
154
- }
155
-
156
- /**
157
- * Build the single-shot login-startup plan without touching the system.
158
- * Windows → logon Scheduled Task (schtasks /sc onlogon, single run at logon).
159
- * Linux/macOS → user systemd unit (Type=oneshot, WantedBy=default.target).
160
- * Pure (testable): all paths/commands are derived from the overrides.
161
- */
162
- export function getStartupPlan(opts?: {
163
- platform?: string;
164
- homeDir?: string;
165
- execPath?: string;
166
- scriptPath?: string;
167
- }): StartupPlan {
168
- const platform = opts?.platform ?? process.platform;
169
- const home = opts?.homeDir ?? os.homedir();
170
- const script = daemonEntryForStartup(opts?.scriptPath);
171
- // A Node runtime cannot strip types for files under node_modules, so the
172
- // hook must point at staged sources when the package is installed (issue #52).
173
- // Bun runs TypeScript anywhere — keep its command side-effect free.
174
- const nodeEntry = (): string => materializeDaemonSources(script);
175
- let quoted: string;
176
- if (opts?.execPath) {
177
- const type = isJsRuntimeExecutable(opts.execPath);
178
- if (type === "bun") {
179
- quoted = `"${opts.execPath}" "${script}"`;
180
- } else {
181
- quoted = `"${opts.execPath}" --experimental-strip-types "${nodeEntry()}"`;
182
- }
183
- } else {
184
- const runtime = resolveDaemonRuntime({ scriptPath: script });
185
- if (runtime) {
186
- if (runtime.runtimeType === "bun") {
187
- quoted = `"${runtime.execPath}" "${script}"`;
188
- } else {
189
- quoted = `"${runtime.execPath}" --experimental-strip-types "${nodeEntry()}"`;
190
- }
191
- } else {
192
- quoted = `"node" --experimental-strip-types "${nodeEntry()}"`;
193
- }
194
- }
195
- if (platform === "win32") {
196
- const command = `schtasks /create /tn "${STARTUP_TASK_NAME}" /tr "${escapeSchTasksAction(quoted)}" /sc onlogon /rl limited /f`;
197
- return {
198
- platform,
199
- taskName: STARTUP_TASK_NAME,
200
- command,
201
- filePath: `Scheduled Task \\${STARTUP_TASK_NAME} (logon trigger, single-shot)`,
202
- fileContent: command,
203
- undo: `schtasks /delete /tn "${STARTUP_TASK_NAME}" /f`,
204
- };
205
- }
206
- const filePath = path.join(home, ".config", "systemd", "user", STARTUP_SERVICE_NAME);
207
- const fileContent = [
208
- "[Unit]",
209
- "Description=pi-freeflow proxy daemon (single-shot at login)",
210
- "After=default.target",
211
- "",
212
- "[Service]",
213
- "Type=oneshot",
214
- `ExecStart=${quoted}`,
215
- "RemainAfterExit=yes",
216
- "",
217
- "[Install]",
218
- "WantedBy=default.target",
219
- "",
220
- ].join("\n");
221
- return {
222
- platform,
223
- taskName: STARTUP_SERVICE_NAME,
224
- command: `systemctl --user enable ${STARTUP_SERVICE_NAME}`,
225
- filePath,
226
- fileContent,
227
- undo: `systemctl --user disable ${STARTUP_SERVICE_NAME} && rm "${filePath}"`,
228
- };
229
- }
230
-
231
- /**
232
- * Install the login-startup hook (single-shot, idempotent). Returns whether a
233
- * change was made plus the exact undo text for the caller to display.
234
- * Refuses when DAEMON_SPAWN=0. Never throws — failures are returned.
235
- */
236
- export function installStartupHook(opts?: {
237
- platform?: string;
238
- homeDir?: string;
239
- execPath?: string;
240
- scriptPath?: string;
241
- }): { ok: boolean; changed: boolean; undo: string; detail: string } {
242
- if (process.env[DAEMON_SPAWN_ENV] === "0") {
243
- return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
244
- }
245
- const plan = getStartupPlan(opts);
246
- try {
247
- if (plan.platform === "win32") {
248
- try {
249
- const q = execSync(`schtasks /query /tn "${STARTUP_TASK_NAME}"`, { encoding: "utf8", timeout: 5000, windowsHide: true }) as string;
250
- if (q && q.includes(STARTUP_TASK_NAME)) {
251
- execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
252
- return { ok: true, changed: false, undo: plan.undo, detail: `already installed — refreshed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
253
- }
254
- } catch { }
255
- execSync(plan.command, { timeout: 10_000, stdio: "ignore", windowsHide: true });
256
- return { ok: true, changed: true, undo: plan.undo, detail: `installed logon task "${STARTUP_TASK_NAME}" (undo: ${plan.undo})` };
257
- }
258
- const dir = path.dirname(plan.filePath);
259
- fs.mkdirSync(dir, { recursive: true });
260
- let existing = "";
261
- try {
262
- existing = fs.readFileSync(plan.filePath, "utf8");
263
- } catch { }
264
- if (existing === plan.fileContent) {
265
- return { ok: true, changed: false, undo: plan.undo, detail: `already installed — ${plan.filePath} up to date (undo: ${plan.undo})` };
266
- }
267
- fs.writeFileSync(plan.filePath, plan.fileContent, "utf8");
268
- // Writing the unit file arms nothing by itself: `systemctl --user enable`
269
- // creates the login symlink. WSL, containers and hosts without a user
270
- // manager fail here, and reporting ok:true left the hook silently dead.
271
- let enableError = "";
272
- for (const cmd of ["systemctl --user daemon-reload", plan.command]) {
273
- try {
274
- startupHookRunner(cmd);
275
- } catch (e) {
276
- enableError = enableError || String(e);
277
- }
278
- }
279
- if (enableError !== "") {
280
- return {
281
- ok: false,
282
- changed: true,
283
- undo: plan.undo,
284
- detail: `installed ${plan.filePath} but could not enable it at login: ${enableError} — enable by hand: "${plan.command}" (undo: ${plan.undo})`,
285
- };
286
- }
287
- return { ok: true, changed: true, undo: plan.undo, detail: `installed ${plan.filePath} (undo: ${plan.undo})` };
288
- } catch (e) {
289
- return { ok: false, changed: false, undo: plan.undo, detail: `install failed: ${String(e)} (undo: ${plan.undo})` };
290
- }
291
- }
292
-
293
- /**
294
- * Remove the login-startup hook (idempotent). Returns the undo/redo text.
295
- * Refuses when DAEMON_SPAWN=0, matching install.
296
- */
297
- export function uninstallStartupHook(opts?: {
298
- platform?: string;
299
- homeDir?: string;
300
- execPath?: string;
301
- scriptPath?: string;
302
- }): { ok: boolean; changed: boolean; undo: string; detail: string } {
303
- if (process.env[DAEMON_SPAWN_ENV] === "0") {
304
- return { ok: false, changed: false, undo: "", detail: "refused: daemon spawn is disabled (DAEMON_SPAWN=0)" };
305
- }
306
- const plan = getStartupPlan(opts);
307
- try {
308
- if (plan.platform === "win32") {
309
- try {
310
- execSync(plan.undo, { timeout: 10_000, stdio: "ignore", windowsHide: true });
311
- return { ok: true, changed: true, undo: plan.command, detail: `removed logon task "${STARTUP_TASK_NAME}" (re-install: ${plan.command})` };
312
- } catch {
313
- return { ok: true, changed: false, undo: plan.command, detail: `already removed — no logon task "${STARTUP_TASK_NAME}" found` };
314
- }
315
- }
316
- let existed = false;
317
- try {
318
- fs.accessSync(plan.filePath, fs.constants.F_OK);
319
- existed = true;
320
- } catch { }
321
- if (!existed) {
322
- return { ok: true, changed: false, undo: plan.command, detail: `already removed — no unit file at ${plan.filePath}` };
323
- }
324
- try {
325
- execSync(`systemctl --user disable ${STARTUP_SERVICE_NAME}`, { timeout: 10_000, stdio: "ignore" });
326
- } catch { }
327
- fs.rmSync(plan.filePath, { force: true });
328
- return { ok: true, changed: true, undo: plan.command, detail: `removed ${plan.filePath} (re-install: ${plan.command})` };
329
- } catch (e) {
330
- return { ok: false, changed: false, undo: plan.undo, detail: `uninstall failed: ${String(e)}` };
331
- }
332
- }
333
-
334
161
  function spawnWithProgress(
335
162
  cmd: string,
336
163
  args: string[],
@@ -384,120 +211,6 @@ function startLogsFollow(
384
211
  logsFollowTimer.unref?.();
385
212
  }
386
213
 
387
- /**
388
- * Pick the login slot when the user gives none: "default" for the first
389
- * login, then the smallest free "slot-2", "slot-3", … — never overwriting
390
- * a saved login. Pure — takes the pool, returns a name.
391
- */
392
- function nextClineSlot(pool: ClinePoolState): string {
393
- if (!pool.accounts.some((a) => a.slot === "default")) return "default";
394
- let n = 2;
395
- while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
396
- return `slot-${n}`;
397
- }
398
-
399
- /**
400
- * Per-slot usage row as persisted under pool.usage. Read defensively: pools
401
- * saved before usage tracking existed carry no such field, so every property
402
- * is narrowed at use. Never carries identity — callers map slot to identity.
403
- */
404
- interface ClineUsageRow {
405
- served: number;
406
- lastAt: number;
407
- lastModel: string;
408
- }
409
-
410
- /** Usage map off the pool without depending on the tracking lane's types. */
411
- function clineUsageRows(pool: ClinePoolState): Record<string, ClineUsageRow> {
412
- const raw = (pool as unknown as { usage?: unknown }).usage;
413
- if (!raw || typeof raw !== "object") return {};
414
- const out: Record<string, ClineUsageRow> = {};
415
- for (const [slot, entry] of Object.entries(raw as Record<string, unknown>)) {
416
- if (!entry || typeof entry !== "object") continue;
417
- const rec = entry as Record<string, unknown>;
418
- if (typeof rec.served !== "number" || !(rec.served >= 0)) continue;
419
- out[slot] = {
420
- served: rec.served,
421
- lastAt: typeof rec.lastAt === "number" ? rec.lastAt : 0,
422
- lastModel: typeof rec.lastModel === "string" ? rec.lastModel : "",
423
- };
424
- }
425
- return out;
426
- }
427
-
428
- /** Masked identity for NEW display text — the raw email never appears there. */
429
- function maskClineEmail(email: string): string {
430
- const at = email.indexOf("@");
431
- if (at <= 0) return "***";
432
- const domain = email.slice(at + 1);
433
- if (!domain) return "***";
434
- return `${email[0]}***@${domain}`;
435
- }
436
-
437
- /** Relative age for a usage timestamp, ISO fallback for old/future values. */
438
- function clineUsageAge(lastAt: number, now = Date.now()): string {
439
- if (!(lastAt > 0)) return "";
440
- const diff = now - lastAt;
441
- if (diff < 0) return new Date(lastAt).toISOString();
442
- if (diff < 60_000) return "just now";
443
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
444
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
445
- if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`;
446
- return new Date(lastAt).toISOString();
447
- }
448
-
449
- /** Usage suffix for one account line; zero-state reads `served 0 · never used`. */
450
- function formatClineUsageSuffix(slot: string, usage: Record<string, ClineUsageRow>, now = Date.now()): string {
451
- const row = usage[slot];
452
- if (!row || row.served <= 0) return " · served 0 · never used";
453
- const age = clineUsageAge(row.lastAt, now);
454
- const last = row.lastModel ? ` · last ${row.lastModel}${age ? ` ${age}` : ""}` : "";
455
- return ` · served ${row.served}${last}`;
456
- }
457
-
458
- /**
459
- * Compact Cline block for the status banner. Masked identity only — the raw
460
- * email never appears here even though account lines still show it.
461
- */
462
- function formatClineStatusSnippet(pool: ClinePoolState, now = Date.now()): string {
463
- const n = pool.accounts.length;
464
- if (!n) return "Cline: no logins";
465
- const usage = clineUsageRows(pool);
466
- let best: { slot: string; row: ClineUsageRow } | null = null;
467
- for (const a of pool.accounts) {
468
- const row = usage[a.slot];
469
- if (!row || row.served <= 0) continue;
470
- if (!best || row.lastAt > best.row.lastAt) best = { slot: a.slot, row };
471
- }
472
- if (!best) return `Cline: ${n} login(s) · never used`;
473
- const account = pool.accounts.find((a) => a.slot === best.slot);
474
- const who = account?.email ? maskClineEmail(account.email) : (account?.accountId || best.slot);
475
- const model = best.row.lastModel ? ` (${best.row.lastModel})` : "";
476
- return `Cline: ${n} login(s) · last used: [${best.slot}] ${who}${model}`;
477
- }
478
-
479
- /** One notify-ready line per saved login, in pool order, with usage. */
480
- function formatClineAccountLines(pool: ClinePoolState): string[] {
481
- const usage = clineUsageRows(pool);
482
- return pool.accounts.map((a, idx) => {
483
- const star = a.slot === pool.activeSlot ? "*" : " ";
484
- const who = a.email || a.accountId;
485
- const whoPart = who ? ` ${who}` : "";
486
- // Two slots on one account share one quota, so rotation between them buys
487
- // nothing. Mark the later one against the earlier slot it duplicates.
488
- const earlier = { accounts: pool.accounts.slice(0, idx) };
489
- const dup = findClineAccountSlot(earlier, a);
490
- const dupPart = dup ? ` — same account as [${dup}]` : "";
491
- return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}${dupPart}${formatClineUsageSuffix(a.slot, usage)}`;
492
- });
493
- }
494
- /** User-safe one-line message for caught values (never echoes secrets). */
495
- function clineErrorMessage(e: unknown): string {
496
- if (e instanceof Error && e.message) return e.message;
497
- if (typeof e === "string" && e) return e;
498
- return "unknown error";
499
- }
500
-
501
214
  /**
502
215
  * Validate a user-supplied deploy project name before any network call.
503
216
  * Mirrors the cleanup the deploy builders apply (lowercase, runs of
@@ -540,7 +253,8 @@ export function validateDeployProjectName(
540
253
  */
541
254
  let updateStepRunner = spawnWithProgress;
542
255
  let attachedPortResolver = (): number => getClientPort() || PORT;
543
- let attachedPortKiller = (port: number): Promise<unknown> => killPortHolder(port);
256
+ let attachedPortKiller: (port: number) => Promise<unknown> = killPortHolder;
257
+ let daemonEnsurer: () => Promise<unknown> = ensureClientDaemon;
544
258
 
545
259
  /** Test seam for the /freeflow update restart path (null restores the real deps). */
546
260
  export function _setUpdateRestartDepsForTest(
@@ -548,11 +262,13 @@ export function _setUpdateRestartDepsForTest(
548
262
  runUpdateStep?: (cmd: string, args: string[], ctx: ExtensionContext) => Promise<number>;
549
263
  resolveAttachedPort?: () => number;
550
264
  killAttachedPort?: (port: number) => Promise<unknown>;
265
+ ensureDaemon?: () => Promise<unknown>;
551
266
  } | null,
552
267
  ): void {
553
268
  updateStepRunner = opts?.runUpdateStep ?? spawnWithProgress;
554
269
  attachedPortResolver = opts?.resolveAttachedPort ?? ((): number => getClientPort() || PORT);
555
- attachedPortKiller = opts?.killAttachedPort ?? ((port: number): Promise<unknown> => killPortHolder(port));
270
+ attachedPortKiller = opts?.killAttachedPort ?? killPortHolder;
271
+ daemonEnsurer = opts?.ensureDaemon ?? ensureClientDaemon;
556
272
  }
557
273
 
558
274
  export function createCommandSpec(
@@ -642,87 +358,6 @@ export function createCommandSpec(
642
358
  setStatusUi(ctx.ui);
643
359
  updateStatusBar(ctx.ui);
644
360
  };
645
- /** Re-apply an operation to the freshest on-disk state and persist it. */
646
- const applyRelayState = (
647
- updater: (s: RelayState) => RelayState,
648
- ): RelayState => {
649
- relayState = withRelayState(updater);
650
- return relayState;
651
- };
652
- const addRelayInteractive = async () => {
653
- const inputUrl = (
654
- await ctx.ui.input(
655
- "Custom relay URL (e.g. https://my-relay.workers.dev):",
656
- "",
657
- )
658
- )?.trim();
659
- if (!inputUrl) {
660
- ctx.ui.notify("Cancelled — no URL provided", "warning");
661
- return;
662
- }
663
- const defaultLabel = shortRelayLabel(inputUrl, relayState.relays);
664
- const inputLabel = (
665
- await ctx.ui.input(
666
- "Short name / alias for TUI (e.g. cf-sg, vercel-1):",
667
- defaultLabel,
668
- )
669
- )?.trim() || defaultLabel;
670
-
671
- try {
672
- applyRelayState((s) => {
673
- const added = ensureRelay(s, inputUrl, inputLabel);
674
- s.enabled = true;
675
- s.url = added.url;
676
- return s;
677
- });
678
- } catch (e) {
679
- ctx.ui.notify((e as Error).message, "warning");
680
- return;
681
- }
682
- persist();
683
- flash();
684
- ctx.ui.notify(
685
- `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
686
- "info",
687
- );
688
- };
689
-
690
- const editRelayLabelMenu = async () => {
691
- if (!relayState.relays.length) {
692
- ctx.ui.notify("No saved relays to rename", "warning");
693
- return;
694
- }
695
- const fmt = (r: KnownRelay, idx: number) =>
696
- `[${idx + 1}] ${r.label ? `[${r.label}] ` : ""}${r.url}`;
697
- const opts = relayState.relays.map(fmt);
698
- const choice = await ctx.ui.select("Select relay to rename / set short name", opts);
699
- if (!choice) return;
700
- const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
701
- if (!match) return;
702
-
703
- const curLabel = match.label || shortRelayLabel(match.url, relayState.relays);
704
- const newLabel = (
705
- await ctx.ui.input(
706
- `New short name for ${match.url}:`,
707
- curLabel,
708
- )
709
- )?.trim();
710
- if (!newLabel) {
711
- ctx.ui.notify("Cancelled — short name not changed", "warning");
712
- return;
713
- }
714
- applyRelayState((s) => {
715
- setRelayLabel(s, match.url, newLabel);
716
- return s;
717
- });
718
- persist();
719
- flash();
720
- ctx.ui.notify(
721
- `✓ Relay short name updated to [${newLabel}] for ${match.url}`,
722
- "info",
723
- );
724
- };
725
-
726
361
  const DEPLOY_OPTIONS: Record<string, DeployPlatform> = {
727
362
  "Cloudflare (100k req/day — recommended)": "cloudflare",
728
363
  "Deno Deploy (100k req/day)": "deno",
@@ -798,7 +433,7 @@ export function createCommandSpec(
798
433
  }
799
434
  }
800
435
 
801
- ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
436
+ ctx.ui.setStatus?.("freeflow", `deploying ${platform} relay…`);
802
437
  try {
803
438
  const deployer =
804
439
  platform === "cloudflare"
@@ -906,325 +541,20 @@ export function createCommandSpec(
906
541
  }
907
542
  };
908
543
 
909
- const switchRelay = async () => {
910
- if (!relayState.relays.length) {
911
- ctx.ui.notify("No saved relays yet", "warning");
912
- return;
913
- }
914
- const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
915
- const opts = relayState.relays.map(fmt);
916
- const choice = await ctx.ui.select("Switch active relay", opts);
917
- if (!choice) return;
918
- const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
919
- if (!match) return;
920
- applyRelayState((s) => {
921
- const fresh = s.relays.find((r) => r.url === match.url);
922
- if (!fresh) return s;
923
- s.enabled = true;
924
- s.url = fresh.url;
925
- return s;
926
- });
927
- persist();
928
- flash();
929
- };
930
-
931
- const showList = () => {
932
- if (!relayState.relays.length) {
933
- ctx.ui.notify("No saved relays (direct upstream mode)", "info");
934
- return;
935
- }
936
- const isDeadParked = (url: string): boolean => {
937
- const h = getRelayHealth(url);
938
- return h != null && h.lastStatus === 402 && Date.now() < h.cooldownUntil;
939
- };
940
- const lines = relayState.relays.map((r, idx) => {
941
- const star = r.url === relayState.url ? "★" : " ";
942
- const shortName = r.label ? `[${r.label}]` : `[${shortRelayLabel(r.url, relayState.relays)}]`;
943
- const paddedName = shortName.padEnd(16, " ");
944
- const health = getRelayHealth(r.url);
945
- const isCooling = health && Date.now() < health.cooldownUntil;
946
- const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
947
- const healthBadge = isDeadParked(r.url)
948
- ? ` ✖ [dead — HTTP 402 disabled deployment, parked ${remainingSec}s]`
949
- : isCooling
950
- ? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
951
- : health?.lastLatencyMs != null && Number.isFinite(health.lastLatencyMs)
952
- ? ` ✓ [${health.lastLatencyMs}ms]${ewmaBadge(health)}`
953
- : " ✓";
954
- const counterText = health && (health.successCount != null || health.failureCount != null)
955
- ? ` ${health.successCount ?? 0} ok / ${health.failureCount ?? 0} fail`
956
- : "";
957
- return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}${counterText}`;
958
- });
959
- const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
960
- const activeIdx = Math.max(
961
- 1,
962
- relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
963
- );
964
- const modeStr = (relayState.mode || "auto").toUpperCase();
965
- const header = `Saved Relays (${relayState.relays.length}):\n${lines.join("\n")}\n\nActive: [${activeIdx}] ${activeLabel} | Mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})`;
966
- ctx.ui.notify(header, "info");
967
- };
968
-
969
- const removeRelayMenu = async () => {
970
- const removable = relayState.relays.filter(
971
- (r) => r.url !== relayState.url,
972
- );
973
- if (!removable.length) {
974
- ctx.ui.notify(
975
- "Nothing to remove — the active relay cannot be removed (switch first)",
976
- "warning",
977
- );
978
- return;
979
- }
980
- const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
981
- const choice = await ctx.ui.select(
982
- "Remove relay",
983
- removable.map(fmt),
984
- );
985
- if (!choice) return;
986
- const match = removable.find((r, idx) => fmt(r, idx) === choice);
987
- if (!match) return;
988
- applyRelayState((s) => {
989
- removeRelay(s, match.url);
990
- return s;
991
- });
992
- persist();
993
- ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
994
- };
995
- const classifyImportFailure = (filePath: string, raw: string): string => {
996
- let parsed: unknown = null;
997
- try {
998
- parsed = JSON.parse(raw);
999
- } catch (e) {
1000
- return `Relay file ${filePath} is not valid JSON (${(e as Error).message}). Nothing changed.`;
1001
- }
1002
- const version = parsed !== null && typeof parsed === "object" && "version" in parsed
1003
- ? parsed.version
1004
- : undefined;
1005
- if (typeof version !== "undefined" && version !== 1) {
1006
- return `Relay file ${filePath} uses version ${String(version)}, this version understands version 1. Update then try again. Nothing changed.`;
1007
- }
1008
- return `Relay file ${filePath} is not a relay export (missing relay list). Nothing changed.`;
1009
- };
1010
-
1011
- const runExport = async (pathArg?: string, includeSecrets = false) => {
1012
- let state: RelayState;
1013
- try {
1014
- state = getActiveRelayState();
1015
- } catch {
1016
- state = loadRelayState();
1017
- }
1018
- const artifact = buildRelayExport(state, { includeSecrets });
1019
- const count = artifact.state.relays.length;
1020
- let target = pathArg ? path.resolve(pathArg) : path.resolve(EXPORT_DEFAULT_FILENAME);
1021
- if (!pathArg) {
1022
- const choice = await ctx.ui.select("Share relays (export file)", [
1023
- `Export to ${target}`,
1024
- "Choose another path…",
1025
- "Cancel",
1026
- ]);
1027
- if (!choice || choice === "Cancel") return;
1028
- if (choice === "Choose another path…") {
1029
- const picked = (await ctx.ui.input("Export path:", target))?.trim();
1030
- if (!picked) return;
1031
- target = path.resolve(picked);
1032
- }
1033
- }
1034
- if (fs.existsSync(target)) {
1035
- if (typeof ctx.ui.confirm === "function") {
1036
- const ok = await ctx.ui.confirm(
1037
- "Export relays",
1038
- `File already exists: ${target}\nOverwrite it?`,
1039
- );
1040
- if (!ok) {
1041
- ctx.ui.notify("Export cancelled — existing file left alone", "warning");
1042
- return;
1043
- }
1044
- }
1045
- }
1046
- try {
1047
- fs.writeFileSync(target, JSON.stringify(artifact, null, 2));
1048
- } catch (e) {
1049
- ctx.ui.notify(`Could not write relay file ${target}: ${(e as Error).message}.`, "error");
1050
- return;
1051
- }
1052
- updateStatusBar(ctx.ui);
1053
- flash();
1054
- ctx.ui.notify(
1055
- includeSecrets
1056
- ? `Exported ${count} relay(s) to ${target} WITH passwords included — keep this file private.`
1057
- : `Exported ${count} relay(s) to ${target} (passwords stripped — file is safe to share).`,
1058
- "info",
1059
- );
1060
- };
1061
-
1062
- const runImport = async (
1063
- pathArg: string | undefined,
1064
- mode: "merge" | "replace",
1065
- dryRun: boolean,
1066
- ) => {
1067
- const filePath = (pathArg || "").trim();
1068
- if (!filePath) {
1069
- ctx.ui.notify("Usage: /freeflow import <path> [--merge|--replace] [--dry-run] — path is required.", "warning");
1070
- return;
1071
- }
1072
- let byteSize = 0;
1073
- try {
1074
- const st = fs.statSync(filePath);
1075
- if (!st.isFile()) {
1076
- ctx.ui.notify(`Could not read relay file ${filePath}: not a regular file.`, "error");
1077
- return;
1078
- }
1079
- byteSize = st.size;
1080
- } catch (e) {
1081
- const code = e !== null && typeof e === "object" && "code" in e ? e.code : undefined;
1082
- if (code === "ENOENT") {
1083
- ctx.ui.notify(`Relay file not found: ${filePath} — check the path and try again.`, "warning");
1084
- } else {
1085
- ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
1086
- }
1087
- return;
1088
- }
1089
- if (byteSize > EXPORT_MAX_BYTES) {
1090
- ctx.ui.notify(`Relay file ${filePath} is too large (${byteSize} bytes; limit ${EXPORT_MAX_BYTES} bytes). Nothing changed.`, "warning");
1091
- return;
1092
- }
1093
- let raw: string;
1094
- try {
1095
- raw = fs.readFileSync(filePath, "utf8");
1096
- } catch (e) {
1097
- ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
1098
- return;
1099
- }
1100
- let fragment: Pick<RelayState, "mode" | "enabled" | "url" | "relays" | "hideWidget">;
1101
- let parseSkipped: Array<{ index: number; url: string; reason: string }> = [];
1102
- try {
1103
- const parsed = parseRelayImport(raw);
1104
- fragment = parsed.fragment;
1105
- parseSkipped = parsed.skipped;
1106
- } catch {
1107
- ctx.ui.notify(classifyImportFailure(filePath, raw), "warning");
1108
- return;
1109
- }
1110
- if (!fragment || !Array.isArray(fragment.relays) || fragment.relays.length === 0) {
1111
- if (parseSkipped.length > 0) {
1112
- ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (${parseSkipped.length} skipped: ${parseSkipped[0].reason}). Nothing changed.`, "warning");
1113
- } else {
1114
- ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (0 skipped). Nothing changed.`, "warning");
1115
- }
1116
- return;
1117
- }
1118
- const plan = planRelayImport(loadRelayState(), fragment, { mode });
1119
- const skipped = [...parseSkipped];
1120
- for (const s of plan.skipped) {
1121
- if (!skipped.some((x) => x.index === s.index && x.url === s.url)) skipped.push(s);
1122
- }
1123
- const skippedCount = skipped.length;
1124
- const reasons = skippedCount > 0
1125
- ? skipped.map((s) => s.reason).filter((r, i, arr) => arr.indexOf(r) === i).join("; ")
1126
- : "none";
1127
- const plannedActive = plan.next.url
1128
- ? `[${shortRelayLabel(plan.next.url, plan.next.relays)}]`
1129
- : "none";
1130
- if (dryRun) {
1131
- if (mode === "replace") {
1132
- ctx.ui.notify(`DRY RUN — replace from ${filePath}: would install ${plan.added}, would remove ${plan.removed}, would skip ${skippedCount} (${reasons}). Active relay would be ${plannedActive}. Nothing changed.`, "info");
1133
- } else {
1134
- ctx.ui.notify(`DRY RUN — merge from ${filePath}: would add ${plan.added}, would update short name on ${plan.updated}, would skip ${skippedCount} (${reasons}). Active and mode unchanged. Nothing changed.`, "info");
1135
- }
1136
- return;
1137
- }
1138
- if (mode === "replace") {
1139
- ctx.ui.notify(`Replace from ${filePath}: will install ${plan.added}, remove ${plan.removed}, skip ${skippedCount} (${reasons}). Active relay will be ${plannedActive}.`, "info");
1140
- if (typeof ctx.ui.confirm === "function") {
1141
- const ok = await ctx.ui.confirm(
1142
- "Replace all relays?",
1143
- `Install ${plan.added} relay(s) from ${filePath} and remove ${plan.removed} existing? This cannot be undone.`,
1144
- );
1145
- if (!ok) {
1146
- ctx.ui.notify("Replace cancelled — relay list unchanged.", "warning");
1147
- return;
1148
- }
1149
- }
1150
- }
1151
- applyRelayState((s) => planRelayImport(s, fragment, { mode }).next);
1152
- persist();
1153
- flash();
1154
- if (mode === "replace") {
1155
- const activeName = relayState.url
1156
- ? `[${shortRelayLabel(relayState.url, relayState.relays)}]`
1157
- : "none";
1158
- ctx.ui.notify(`Replaced relay list from ${filePath}: installed ${plan.added}, skipped ${skippedCount}. Active relay is now ${activeName}.`, "info");
1159
- } else {
1160
- ctx.ui.notify(`Merge from ${filePath}: added ${plan.added}, updated short name on ${plan.updated}, skipped ${skippedCount}. Relay list now holds ${relayState.relays.length}.`, "info");
1161
- }
1162
- const withoutPassword = relayState.relays.filter((r) => !r.auth).length;
1163
- if (withoutPassword > 0) {
1164
- ctx.ui.notify(`${withoutPassword} relay(s) have no password — reconnect each one before use.`, "warning");
1165
- }
1166
- for (const s of skipped) {
1167
- const shown = s.url || `#${s.index + 1}`;
1168
- const reason = s.reason.endsWith(".") ? s.reason : `${s.reason}.`;
1169
- ctx.ui.notify(`Skipped ${shown}: ${reason}`, "warning");
1170
- }
1171
- };
544
+ // 1. Delegate relay subcommands
545
+ if (await handleRelaySubcommand(sub, ctx, { rest, updateStatusBar, doDeploy })) {
546
+ return;
547
+ }
548
+ // 2. Delegate cline subcommands
549
+ if (await handleClineSubcommand(sub, rest, ctx)) {
550
+ return;
551
+ }
552
+ // 3. Delegate startup subcommands
553
+ if (handleStartupSubcommand(sub, ctx)) {
554
+ return;
555
+ }
1172
556
 
1173
- if (sub === "auto" || sub === "sticky") {
1174
- applyRelayState((s) => {
1175
- s.mode = "auto";
1176
- s.enabled = true;
1177
- s.url = s.url || "";
1178
- if (s.url) {
1179
- ensureRelay(s, s.url);
1180
- }
1181
- return s;
1182
- });
1183
- persist();
1184
- flash();
1185
- } else if (sub === "spread") {
1186
- applyRelayState((s) => {
1187
- s.mode = "spread";
1188
- s.enabled = true;
1189
- s.url = s.url || "";
1190
- if (s.url) {
1191
- ensureRelay(s, s.url);
1192
- }
1193
- return s;
1194
- });
1195
- persist();
1196
- flash();
1197
- } else if (sub === "on") {
1198
- applyRelayState((s) => {
1199
- s.mode = "on";
1200
- s.enabled = true;
1201
- s.url = s.url || "";
1202
- if (s.url) {
1203
- ensureRelay(s, s.url);
1204
- }
1205
- return s;
1206
- });
1207
- persist();
1208
- flash();
1209
- } else if (sub === "off") {
1210
- applyRelayState((s) => {
1211
- s.mode = "off";
1212
- s.enabled = false;
1213
- return s;
1214
- });
1215
- persist();
1216
- flash();
1217
- } else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
1218
- applyRelayState((s) => ({ ...s, hideWidget: true }));
1219
- persist();
1220
- updateStatusBar(ctx.ui);
1221
- ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
1222
- } else if (sub === "show" || (sub === "widget" && rest === "show")) {
1223
- applyRelayState((s) => ({ ...s, hideWidget: false }));
1224
- persist();
1225
- flash();
1226
- ctx.ui.notify("Widget shown", "info");
1227
- } else if (sub === "status") {
557
+ if (sub === "status") {
1228
558
  flash();
1229
559
  const local = getLocalVersion();
1230
560
  const versionLine = formatVersionLine(local);
@@ -1312,6 +642,22 @@ export function createCommandSpec(
1312
642
  } catch {
1313
643
  ctx.ui.notify("Daemon not running or not reachable", "warning");
1314
644
  }
645
+ try { await stopHeartbeat(); } catch { }
646
+ } else if (sub === "restart") {
647
+ const port = getClientPort() || PORT;
648
+ try {
649
+ await fetch(`http://${HOST}:${port}/_shutdown`, {
650
+ method: "POST",
651
+ headers: tokenAuthHeaders(),
652
+ signal: AbortSignal.timeout(1500),
653
+ });
654
+ } catch { }
655
+ try {
656
+ await ensureClientDaemon();
657
+ ctx.ui.notify("Proxy daemon restarted", "info");
658
+ } catch (e) {
659
+ ctx.ui.notify(`Restart failed: ${(e as Error).message}`, "error");
660
+ }
1315
661
  } else if (sub === "autoupdate" || (sub === "update" && rest.trim().toLowerCase().startsWith("auto"))) {
1316
662
  const arg = sub === "autoupdate"
1317
663
  ? rest.trim().toLowerCase()
@@ -1391,6 +737,7 @@ export function createCommandSpec(
1391
737
  // Restart the daemon that is actually attached — a walked port must
1392
738
  // not leave the old build serving after a successful update.
1393
739
  try { await attachedPortKiller(attachedPortResolver()); } catch { }
740
+ try { await daemonEnsurer(); } catch { }
1394
741
  ctx.ui.notify(`Updated to ${latest} and restarted proxy daemon`, "info");
1395
742
  } else {
1396
743
  ctx.ui.notify(
@@ -1404,113 +751,6 @@ export function createCommandSpec(
1404
751
  ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
1405
752
  }
1406
753
  }
1407
- } else if (sub === "list") {
1408
- showList();
1409
- } else if (sub === "add") {
1410
- if (!rest.trim()) {
1411
- await addRelayInteractive();
1412
- } else {
1413
- const tokens = rest.trim().split(/\s+/);
1414
- const targetUrl = tokens[0];
1415
- const customLabel = tokens.slice(1).join(" ").trim() || undefined;
1416
- try {
1417
- applyRelayState((s) => {
1418
- const added = ensureRelay(s, targetUrl, customLabel);
1419
- s.enabled = true;
1420
- s.url = added.url;
1421
- return s;
1422
- });
1423
- } catch (e) {
1424
- ctx.ui.notify((e as Error).message, "warning");
1425
- return;
1426
- }
1427
- persist();
1428
- flash();
1429
- ctx.ui.notify(
1430
- `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
1431
- "info",
1432
- );
1433
- }
1434
- } else if (sub === "use") {
1435
- if (!rest.trim()) {
1436
- await switchRelay();
1437
- } else {
1438
- const tokens = rest.trim().split(/\s+/);
1439
- const targetToken = tokens[0];
1440
- const customLabel = tokens.slice(1).join(" ").trim() || undefined;
1441
- const matched = findRelay(relayState, targetToken);
1442
- if (matched) {
1443
- applyRelayState((s) => {
1444
- const fresh = s.relays.find((r) => r.url === matched.url);
1445
- if (!fresh) return s;
1446
- s.enabled = true;
1447
- s.url = fresh.url;
1448
- if (customLabel) {
1449
- fresh.label = customLabel;
1450
- }
1451
- return s;
1452
- });
1453
- persist();
1454
- flash();
1455
- } else {
1456
- // Only treat unmatched tokens as new relays when they are absolute
1457
- // http(s) URLs — typos or bad indices must not become saved junk relays.
1458
- let parsedUrl = null;
1459
- try {
1460
- parsedUrl = new URL(targetToken);
1461
- } catch { }
1462
- const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
1463
- if (looksLikeUrl) {
1464
- try {
1465
- applyRelayState((s) => {
1466
- const added = ensureRelay(s, targetToken, customLabel);
1467
- s.enabled = true;
1468
- s.url = added.url;
1469
- return s;
1470
- });
1471
- } catch (e) {
1472
- ctx.ui.notify((e as Error).message, "warning");
1473
- return;
1474
- }
1475
- persist();
1476
- flash();
1477
- } else {
1478
- ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
1479
- }
1480
- }
1481
- }
1482
- } else if (sub === "label" || sub === "rename") {
1483
- if (!rest.trim()) {
1484
- await editRelayLabelMenu();
1485
- } else {
1486
- const tokens = rest.trim().split(/\s+/);
1487
- const targetToken = tokens[0];
1488
- const newLabel = tokens.slice(1).join(" ").trim();
1489
- const matched = findRelay(relayState, targetToken);
1490
- if (!matched) {
1491
- ctx.ui.notify(`Relay '${targetToken}' not found in saved list`, "warning");
1492
- return;
1493
- }
1494
- let finalLabel = newLabel;
1495
- if (!finalLabel) {
1496
- finalLabel = (
1497
- await ctx.ui.input(
1498
- `New short name / alias for ${matched.url}:`,
1499
- matched.label || shortRelayLabel(matched.url, relayState.relays),
1500
- )
1501
- )?.trim() || "";
1502
- }
1503
- applyRelayState((s) => {
1504
- setRelayLabel(s, matched.url, finalLabel);
1505
- return s;
1506
- });
1507
- persist();
1508
- flash();
1509
- ctx.ui.notify(
1510
- `✓ Relay short name updated to [${shortRelayLabel(matched.url, relayState.relays)}] for ${matched.url}`,
1511
- "info",
1512
- );
1513
- }
1514
754
  } else if (sub === "debug") {
1515
755
  const arg = rest.trim().toLowerCase();
1516
756
  if (arg === "on" || arg === "enable" || arg === "true") {
@@ -1572,7 +812,9 @@ export function createCommandSpec(
1572
812
  let count = 25;
1573
813
  const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
1574
814
  const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
1575
-
815
+ if (!isFollow) {
816
+ stopLogsFollow();
817
+ }
1576
818
  if (sub === "trace" && rawRest) {
1577
819
  filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
1578
820
  } else if (rawRest) {
@@ -1679,30 +921,6 @@ export function createCommandSpec(
1679
921
  `✓ Refreshed ${updated.length} models with full-spec metadata (upstream health reset)!`,
1680
922
  "info",
1681
923
  );
1682
- } else if (sub === "remove") {
1683
- if (!rest.trim()) {
1684
- await removeRelayMenu();
1685
- } else {
1686
- const target = rest.trim();
1687
- const matched = findRelay(relayState, target);
1688
- if (!matched) {
1689
- ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
1690
- return;
1691
- }
1692
- if (matched.url === relayState.url) {
1693
- ctx.ui.notify(
1694
- "Cannot remove the active relay — switch to another relay first",
1695
- "warning",
1696
- );
1697
- return;
1698
- }
1699
- applyRelayState((s) => {
1700
- removeRelay(s, matched.url);
1701
- return s;
1702
- });
1703
- persist();
1704
- ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
1705
- }
1706
924
  } else if (sub === "test") {
1707
925
  const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
1708
926
  let target: string | undefined;
@@ -1765,214 +983,6 @@ export function createCommandSpec(
1765
983
  } else {
1766
984
  ctx.ui.notify(`✗ ${shortRelayLabel(matched.url, relayState.relays)} failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
1767
985
  }
1768
- } else if (sub === "cline") {
1769
- const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
1770
- const action = (tokens[0] || "accounts").toLowerCase();
1771
- const arg = tokens.slice(1).join(" ").trim();
1772
- if (action === "login") {
1773
- const raw = tokens.slice(1);
1774
- const pasteMode = raw.some((t) => t === "--key");
1775
- const slotArg = raw.filter((t) => t !== "--key").join(" ").trim();
1776
- if (pasteMode) {
1777
- // Manual fallback: paste a workos: API key (never shown back).
1778
- const slot = slotArg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
1779
- const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
1780
- if (!token) {
1781
- ctx.ui.notify("Cancelled — no API key provided", "warning");
1782
- } else {
1783
- try {
1784
- addAccount(slot, token);
1785
- ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
1786
- } catch (e) {
1787
- ctx.ui.notify((e as Error).message, "warning");
1788
- }
1789
- }
1790
- return;
1791
- }
1792
- // Device login inside the extension: approve in the browser, no paste.
1793
- const slot = slotArg || nextClineSlot(loadPool());
1794
- let started;
1795
- try {
1796
- started = await startDeviceAuth();
1797
- } catch (e) {
1798
- if (isCertError(e)) {
1799
- 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");
1800
- return;
1801
- }
1802
- ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
1803
- return;
1804
- }
1805
- const link = started.verificationUriComplete ?? started.verificationUri;
1806
- const minutes = Math.max(1, Math.round(started.expiresIn / 60));
1807
- 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)`;
1808
- ctx.ui.notify(loginLine, "info");
1809
- try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
1810
- const progress = setInterval(() => {
1811
- ctx.ui.notify(loginLine, "info");
1812
- }, 45000);
1813
- // @ts-ignore allow unref to not block process exit in CLI
1814
- progress.unref?.();
1815
- try {
1816
- const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
1817
- const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
1818
- const apiKey = toApiKey(creds.access);
1819
- const dup = findClineAccountSlot(loadPool(), { token: apiKey, accountId: creds.accountId, email: creds.email }, slot);
1820
- if (dup) {
1821
- // Same account twice shares one free quota, so it would never add capacity.
1822
- ctx.ui.notify(
1823
- `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}`,
1824
- "warning",
1825
- );
1826
- return;
1827
- }
1828
- addAccount(slot, apiKey, {
1829
- refreshToken: creds.refresh,
1830
- expiresAt: creds.expires,
1831
- ...(creds.accountId ? { accountId: creds.accountId } : {}),
1832
- ...(creds.email ? { email: creds.email } : {}),
1833
- });
1834
- ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(apiKey)}) — free Cline models are ready to use`, "info");
1835
- const pool = loadPool();
1836
- ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
1837
- } catch (e) {
1838
- let code: string | undefined;
1839
- if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
1840
- code = e.errorCode;
1841
- }
1842
- if (code === "access_denied") {
1843
- ctx.ui.notify("Cline login cancelled.", "warning");
1844
- } else if (code === "expired_token") {
1845
- ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
1846
- } else {
1847
- ctx.ui.notify(`Cline login did not finish: ${clineErrorMessage(e)}`, "warning");
1848
- }
1849
- } finally {
1850
- clearInterval(progress);
1851
- try { ctx.ui.setStatus("cline-login", undefined); } catch { }
1852
- }
1853
- } else if (action === "accounts" || action === "list") {
1854
- const pool = loadPool();
1855
- if (!pool.accounts.length) {
1856
- ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
1857
- } else {
1858
- ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
1859
- }
1860
- } else if (action === "logout" || action === "remove") {
1861
- const pool = loadPool();
1862
- if (!pool.accounts.length) {
1863
- ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
1864
- } else {
1865
- const resolveSlot = (raw: string): string | null => {
1866
- const clean = (raw || "").trim();
1867
- if (!clean) return null;
1868
- const byIndex = Number.parseInt(clean, 10);
1869
- if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
1870
- const exact = pool.accounts.find((a) => a.slot === clean);
1871
- if (exact) return exact.slot;
1872
- const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
1873
- return folded ? folded.slot : null;
1874
- };
1875
- let slot = resolveSlot(arg);
1876
- if (arg && !slot) {
1877
- const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
1878
- ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
1879
- } else {
1880
- if (!slot) {
1881
- if (pool.accounts.length === 1) {
1882
- slot = pool.accounts[0].slot;
1883
- } else {
1884
- const opts = formatClineAccountLines(pool);
1885
- const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
1886
- if (!choice || choice === "Cancel") {
1887
- ctx.ui.notify("Cancelled — no slot removed", "warning");
1888
- return;
1889
- }
1890
- const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
1891
- slot = hit ? hit.slot : null;
1892
- }
1893
- }
1894
- if (!slot) {
1895
- ctx.ui.notify("Cancelled — no slot removed", "warning");
1896
- } else if (!removeAccount(slot)) {
1897
- ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
1898
- } else {
1899
- ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
1900
- }
1901
- }
1902
- }
1903
- } else if (action === "signout" || action === "sign-out" || action === "switch") {
1904
- // Signing out is a browser action; it never touches saved logins here.
1905
- const saved = loadPool().accounts.length;
1906
- ctx.ui.notify(
1907
- `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())}).`,
1908
- "info",
1909
- );
1910
- } else {
1911
- ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
1912
- }
1913
- } else if (sub === "export") {
1914
- const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
1915
- let target: string | undefined;
1916
- let includeSecrets = false;
1917
- for (const t of tokens) {
1918
- if (t === "--include-secrets") {
1919
- includeSecrets = true;
1920
- } else if (t.startsWith("--")) {
1921
- ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow export [path] [--include-secrets].`, "warning");
1922
- return;
1923
- } else if (target === undefined) {
1924
- target = t;
1925
- }
1926
- }
1927
- await runExport(target, includeSecrets);
1928
- } else if (sub === "import") {
1929
- const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
1930
- let target: string | undefined;
1931
- let sawMerge = false;
1932
- let sawReplace = false;
1933
- let dryRun = false;
1934
- for (const t of tokens) {
1935
- if (t === "--merge") {
1936
- sawMerge = true;
1937
- } else if (t === "--replace") {
1938
- sawReplace = true;
1939
- } else if (t === "--dry-run") {
1940
- dryRun = true;
1941
- } else if (t.startsWith("--")) {
1942
- ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow import <path> [--merge|--replace] [--dry-run].`, "warning");
1943
- return;
1944
- } else if (target === undefined) {
1945
- target = t;
1946
- }
1947
- }
1948
- if (sawMerge && sawReplace) {
1949
- ctx.ui.notify("Both --merge and --replace given — showing a merge preview. Nothing changed.", "info");
1950
- await runImport(target, "merge", true);
1951
- return;
1952
- }
1953
- await runImport(target, sawReplace ? "replace" : "merge", dryRun);
1954
- } else if (sub === "url") {
1955
- const input =
1956
- rest ||
1957
- (await ctx.ui.input(
1958
- "Relay URL (empty = default):",
1959
- relayState.url || "",
1960
- ));
1961
- const cleanInput = (input || "").trim() || "";
1962
- try {
1963
- applyRelayState((s) => {
1964
- s.url = cleanInput;
1965
- if (cleanInput) {
1966
- ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
1967
- }
1968
- return s;
1969
- });
1970
- } catch (e) {
1971
- ctx.ui.notify((e as Error).message, "warning");
1972
- return;
1973
- }
1974
- persist();
1975
- flash();
1976
986
  } else if (sub === "deploy") {
1977
987
  if (!rest.trim()) {
1978
988
  const choice = await ctx.ui.select(
@@ -1991,101 +1001,8 @@ export function createCommandSpec(
1991
1001
  await doDeploy(pf);
1992
1002
  }
1993
1003
  }
1994
- } else if (sub === "install-startup") {
1995
- const r = installStartupHook();
1996
- ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
1997
- } else if (sub === "uninstall-startup") {
1998
- const r = uninstallStartupHook();
1999
- ctx.ui.notify(r.detail, r.ok ? "info" : "warning");
2000
1004
  } else {
2001
- const currentMode = (relayState.mode || "auto").toUpperCase();
2002
- const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
2003
- const choice = await ctx.ui.select("freeflow relay", [
2004
- `Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`,
2005
- "Add custom relay (URL + Short name)…",
2006
- "Switch active relay…",
2007
- "Rename / Set relay short name…",
2008
- "Remove relay…",
2009
- "Share relays (export file)…",
2010
- "Load relays from file (import)…",
2011
- "List saved relays",
2012
- "Deploy Cloudflare relay…",
2013
- "Deploy Deno relay…",
2014
- "Deploy Vercel relay (last resort)…",
2015
- "Mode: AUTO (sticky primary, auto-detect on model select)",
2016
- "Mode: ON (always relay, sticky primary)",
2017
- "Mode: OFF (always direct)",
2018
- "Mode: SPREAD (rotate across healthy relays)",
2019
- ]);
2020
- if (choice === `Mode: ${currentMode} (${relayState.enabled ? "ON" : "OFF"}) → ${activeLabel}`) {
2021
- flash();
2022
- } else if (choice === "Add custom relay (URL + Short name)…") {
2023
- await addRelayInteractive();
2024
- } else if (choice === "Switch active relay…") {
2025
- await switchRelay();
2026
- } else if (choice === "Rename / Set relay short name…") {
2027
- await editRelayLabelMenu();
2028
- } else if (choice === "Remove relay…") {
2029
- await removeRelayMenu();
2030
- } else if (choice === "Share relays (export file)…") {
2031
- await runExport(undefined, false);
2032
- } else if (choice === "Load relays from file (import)…") {
2033
- const picked = (await ctx.ui.input("Relay file path:", EXPORT_DEFAULT_FILENAME))?.trim();
2034
- if (!picked) return;
2035
- await runImport(picked, "merge", false);
2036
- } else if (choice === "List saved relays") {
2037
- showList();
2038
- } else if (choice === "Deploy Vercel relay (last resort)…") {
2039
- await doDeploy("vercel");
2040
- } else if (choice === "Deploy Cloudflare relay…") {
2041
- await doDeploy("cloudflare");
2042
- } else if (choice === "Deploy Deno relay…") {
2043
- await doDeploy("deno");
2044
- } else if (choice === "Mode: AUTO (sticky primary, auto-detect on model select)") {
2045
- applyRelayState((s) => {
2046
- s.mode = "auto";
2047
- s.enabled = true;
2048
- s.url = s.url || "";
2049
- if (s.url) {
2050
- ensureRelay(s, s.url);
2051
- }
2052
- return s;
2053
- });
2054
- persist();
2055
- flash();
2056
- } else if (choice === "Mode: ON (always relay, sticky primary)") {
2057
- applyRelayState((s) => {
2058
- s.mode = "on";
2059
- s.enabled = true;
2060
- s.url = s.url || "";
2061
- if (s.url) {
2062
- ensureRelay(s, s.url);
2063
- }
2064
- return s;
2065
- });
2066
- persist();
2067
- flash();
2068
- } else if (choice === "Mode: OFF (always direct)") {
2069
- applyRelayState((s) => {
2070
- s.mode = "off";
2071
- s.enabled = false;
2072
- return s;
2073
- });
2074
- persist();
2075
- flash();
2076
- } else if (choice === "Mode: SPREAD (rotate across healthy relays)") {
2077
- applyRelayState((s) => {
2078
- s.mode = "spread";
2079
- s.enabled = true;
2080
- s.url = s.url || "";
2081
- if (s.url) {
2082
- ensureRelay(s, s.url);
2083
- }
2084
- return s;
2085
- });
2086
- persist();
2087
- flash();
2088
- }
1005
+ await showRelayInteractiveMenu(ctx, { rest, updateStatusBar, doDeploy });
2089
1006
  }
2090
1007
  },
2091
1008
  };