pi-freeflow 1.4.12 → 1.6.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.
package/src/commands.ts CHANGED
@@ -4,17 +4,15 @@
4
4
  * log viewing, debug level configuration, and live catalog refreshing.
5
5
  */
6
6
 
7
- import { spawn } from "node:child_process";
8
- import { readFileSync } from "node:fs";
9
- import path from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
12
- import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
13
- import {
14
- compareVersions,
15
- fetchLatestVersion,
16
- getCachedUpdate,
17
- isLinkedInstall,
7
+ import { spawn } from "node:child_process";
8
+ import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
9
+ import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
10
+ import {
11
+ compareVersions,
12
+ fetchLatestVersion,
13
+ getCachedUpdate,
14
+ isLinkedInstall,
15
+ getLocalVersion,
18
16
  } from "./update-checker.ts";
19
17
  import {
20
18
  deployCloudflareWorker,
@@ -30,18 +28,21 @@ import {
30
28
  readRecentLogs,
31
29
  saveDebugState,
32
30
  } from "./logger.ts";
31
+ import { probeRelay } from "./probe.ts";
33
32
  import {
34
33
  ensureRelay,
35
34
  findRelay,
36
35
  getActiveRelayState,
37
36
  getRelayHealth,
38
- isRelayHealthy,
39
37
  removeRelay,
40
- saveRelayState,
41
38
  setActiveRelayState,
42
39
  setRelayLabel,
43
40
  setStatusUi,
44
41
  shortRelayLabel,
42
+ withRelayState,
43
+ formatRelayFlash,
44
+ formatRelayPickerItem,
45
+ formatRelayStatusLabel,
45
46
  } from "./relay-state.ts";
46
47
  import type {
47
48
  ExtensionAPI,
@@ -59,81 +60,98 @@ import type {
59
60
  */
60
61
  export function updateStatusBar(ui?: ExtensionUIContext): void {
61
62
  if (!ui) return;
62
- const relayState = getActiveRelayState();
63
- if (relayState.enabled && relayState.relays.length > 0) {
64
- const label = shortRelayLabel(relayState.url);
65
- const idx = Math.max(
66
- 1,
67
- relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
68
- );
69
- const total = relayState.relays.length;
70
- const modeLabel = relayState.mode === "on" ? "ON" : "AUTO (ON)";
71
- ui.setStatus("freeflow", `relay: ${modeLabel} | ${label} ${idx}/${total}`);
72
- } else if (relayState.mode === "off" || !relayState.enabled) {
73
- ui.setStatus("freeflow", "relay: OFF (direct)");
74
- } else {
75
- ui.setStatus("freeflow", undefined);
63
+ ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
64
+ }
65
+
66
+ function spawnWithProgress(
67
+ cmd: string,
68
+ args: string[],
69
+ ctx: ExtensionContext,
70
+ ): Promise<number> {
71
+ return new Promise((resolve) => {
72
+ try {
73
+ const child = spawn(cmd, args, {
74
+ shell: process.platform === "win32",
75
+ stdio: "pipe",
76
+ });
77
+ child.stdout?.on("data", (d: Buffer) => {
78
+ const s = String(d).trim();
79
+ if (s) ctx.ui.notify(s, "info");
80
+ });
81
+ child.stderr?.on("data", (d: Buffer) => {
82
+ const s = String(d).trim();
83
+ if (s) ctx.ui.notify(s, "info");
84
+ });
85
+ child.on("error", (err: Error) => {
86
+ ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
87
+ resolve(1);
88
+ });
89
+ child.on("close", (code: number | null) => resolve(code ?? 0));
90
+ } catch (e) {
91
+ ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
92
+ resolve(1);
93
+ }
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Follow-mode log poller: tracks the count of matched lines already printed
99
+ * and notifies only lines beyond it, so repeated ticks never re-notify the
100
+ * same tail. One poller at a time — a re-run clears the previous interval.
101
+ */
102
+ let logsFollowTimer: NodeJS.Timeout | null = null;
103
+ function startLogsFollow(
104
+ ctx: ExtensionContext,
105
+ filterLevel: LogLevel | null,
106
+ filterReqId: string | null,
107
+ count: number,
108
+ filterText: string | null,
109
+ baselineMatched: number,
110
+ ): void {
111
+ if (logsFollowTimer) {
112
+ clearInterval(logsFollowTimer);
113
+ logsFollowTimer = null;
76
114
  }
115
+ let lastPrinted = baselineMatched;
116
+ logsFollowTimer = setInterval(() => {
117
+ try {
118
+ const tail = readRecentLogs(filterLevel, filterReqId, count, filterText);
119
+ if (tail.totalMatched < lastPrinted) {
120
+ // Log rotated/rewritten below the baseline — re-baseline silently.
121
+ lastPrinted = tail.totalMatched;
122
+ return;
123
+ }
124
+ const newCount = tail.totalMatched - lastPrinted;
125
+ if (newCount <= 0) return;
126
+ const newLines = tail.lines.slice(
127
+ tail.lines.length - Math.min(newCount, tail.lines.length),
128
+ );
129
+ if (newLines.length > 0) {
130
+ ctx.ui.notify(newLines.join("\n"), "info");
131
+ }
132
+ lastPrinted = tail.totalMatched;
133
+ } catch {}
134
+ }, 1000);
135
+ // @ts-ignore allow unref to not block process exit in CLI
136
+ logsFollowTimer.unref?.();
77
137
  }
78
138
 
79
- function getLocalVersion(): string {
80
- try {
81
- const thisDir = path.dirname(fileURLToPath(import.meta.url));
82
- const pkgPath = path.join(thisDir, "..", "package.json");
83
- const raw = readFileSync(pkgPath, "utf8");
84
- const pkg = JSON.parse(raw) as { version?: string };
85
- if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
86
- return pkg.version.trim();
87
- }
88
- return "0.0.0";
89
- } catch {
90
- return "0.0.0";
91
- }
92
- }
93
-
94
- function spawnWithProgress(
95
- cmd: string,
96
- args: string[],
97
- ctx: ExtensionContext,
98
- ): Promise<number> {
99
- return new Promise((resolve) => {
100
- try {
101
- const child = spawn(cmd, args, {
102
- shell: process.platform === "win32",
103
- stdio: "pipe",
104
- });
105
- child.stdout?.on("data", (d: Buffer) => {
106
- const s = String(d).trim();
107
- if (s) ctx.ui.notify(s, "info");
108
- });
109
- child.stderr?.on("data", (d: Buffer) => {
110
- const s = String(d).trim();
111
- if (s) ctx.ui.notify(s, "info");
112
- });
113
- child.on("error", (err: Error) => {
114
- ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
115
- resolve(1);
116
- });
117
- child.on("close", (code: number | null) => resolve(code ?? 0));
118
- } catch (e) {
119
- ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
120
- resolve(1);
121
- }
122
- });
123
- }
124
-
125
139
  export function createCommandSpec(
126
140
  _pi: ExtensionAPI,
127
141
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
128
142
  ): Omit<RegisteredCommand, "name"> {
129
143
  return {
130
144
  description:
131
- "Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
145
+ "Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
132
146
  getArgumentCompletions: (prefix: string) =>
133
147
  [
134
148
  "auto",
135
149
  "on",
136
150
  "off",
151
+ "hide",
152
+ "show",
153
+ "widget hide",
154
+ "widget show",
137
155
  "status",
138
156
  "add",
139
157
  "list",
@@ -141,6 +159,7 @@ export function createCommandSpec(
141
159
  "label",
142
160
  "rename",
143
161
  "remove",
162
+ "test",
144
163
  "url",
145
164
  "deploy",
146
165
  "deploy vercel",
@@ -160,39 +179,40 @@ export function createCommandSpec(
160
179
  .split(/\s+/);
161
180
  const sub = parts[0] || "";
162
181
  const rest = parts.slice(1).join(" ");
163
- const relayState = getActiveRelayState();
182
+ let relayState = getActiveRelayState();
164
183
 
165
184
  const flash = () => {
166
- const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
167
- const activeIdx = Math.max(
168
- 1,
169
- relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
170
- );
171
- const total = relayState.relays.length || 1;
172
- const modeStr = (relayState.mode || "auto").toUpperCase();
173
- ctx.ui.notify(
174
- `Relay mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
175
- "info",
176
- );
185
+ ctx.ui.notify(formatRelayFlash(relayState), "info");
177
186
  };
178
187
 
188
+ // UI refresh only — every disk write flows through CAS
189
+ // (withRelayState) so the operation is re-applied to the freshest
190
+ // on-disk state at write time instead of a stale snapshot.
179
191
  const persist = () => {
180
- setActiveRelayState(relayState, true);
181
192
  setStatusUi(ctx.ui);
182
193
  updateStatusBar(ctx.ui);
183
194
  };
195
+ /** Re-apply an operation to the freshest on-disk state and persist it. */
196
+ const applyRelayState = (
197
+ updater: (s: RelayState) => RelayState,
198
+ ): RelayState => {
199
+ relayState = withRelayState(updater);
200
+ return relayState;
201
+ };
184
202
  const setRelay = (
185
203
  enabled: boolean,
186
204
  url: string,
187
205
  addLabel?: string,
188
206
  ) => {
189
- relayState.enabled = enabled;
190
- const cleanUrl = (url || "").trim() || DEFAULT_RELAY_URL;
191
- relayState.url = cleanUrl;
192
- if (cleanUrl) {
193
- ensureRelay(relayState, cleanUrl, addLabel);
194
- }
195
- setActiveRelayState(relayState);
207
+ applyRelayState((s) => {
208
+ s.enabled = enabled;
209
+ const cleanUrl = (url || "").trim() || "";
210
+ s.url = cleanUrl;
211
+ if (cleanUrl) {
212
+ ensureRelay(s, cleanUrl, addLabel);
213
+ }
214
+ return s;
215
+ });
196
216
  };
197
217
 
198
218
  const addRelayInteractive = async () => {
@@ -214,12 +234,16 @@ export function createCommandSpec(
214
234
  )
215
235
  )?.trim() || defaultLabel;
216
236
 
217
- const added = ensureRelay(relayState, inputUrl, inputLabel);
218
- setRelay(true, added.url, added.label);
237
+ applyRelayState((s) => {
238
+ const added = ensureRelay(s, inputUrl, inputLabel);
239
+ s.enabled = true;
240
+ s.url = added.url;
241
+ return s;
242
+ });
219
243
  persist();
220
244
  flash();
221
245
  ctx.ui.notify(
222
- `✓ Added & activated relay [${added.label || shortRelayLabel(added.url)}]: ${added.url}`,
246
+ `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
223
247
  "info",
224
248
  );
225
249
  };
@@ -248,7 +272,10 @@ export function createCommandSpec(
248
272
  ctx.ui.notify("Cancelled — short name not changed", "warning");
249
273
  return;
250
274
  }
251
- setRelayLabel(relayState, match.url, newLabel);
275
+ applyRelayState((s) => {
276
+ setRelayLabel(s, match.url, newLabel);
277
+ return s;
278
+ });
252
279
  persist();
253
280
  flash();
254
281
  ctx.ui.notify(
@@ -257,6 +284,12 @@ export function createCommandSpec(
257
284
  );
258
285
  };
259
286
 
287
+ const DEPLOY_OPTIONS: Record<string, DeployPlatform> = {
288
+ "Vercel (1M req/mo — recommended)": "vercel",
289
+ "Cloudflare (100k req/day)": "cloudflare",
290
+ "Deno Deploy (100k req/day)": "deno",
291
+ };
292
+
260
293
  const parseDeployPlatform = (raw?: string): DeployPlatform | null => {
261
294
  const v = (raw || "").trim().toLowerCase();
262
295
  if (!v || v === "vercel") return "vercel";
@@ -273,6 +306,13 @@ export function createCommandSpec(
273
306
  : platform === "deno"
274
307
  ? "Deno Deploy"
275
308
  : "Vercel";
309
+ const tokenHint =
310
+ platform === "cloudflare"
311
+ ? "Cloudflare relay — token: dash.cloudflare.com → API Tokens (Workers Scripts: Edit)"
312
+ : platform === "deno"
313
+ ? "Deno Deploy relay — token: dash.deno.com → Access Tokens"
314
+ : "Vercel relay — token: vercel.com/account/tokens";
315
+ ctx.ui.notify(tokenHint, "info");
276
316
  const token = (
277
317
  await ctx.ui.input(`${label} API token:`, "")
278
318
  )?.trim();
@@ -288,6 +328,17 @@ export function createCommandSpec(
288
328
  )
289
329
  )?.trim() || defaultName;
290
330
 
331
+ if (typeof ctx.ui.confirm === "function") {
332
+ const ok = await ctx.ui.confirm(
333
+ "Deploy relay",
334
+ `Deploy ${label} relay '${name}' using token ending ${token.slice(-4)}?`,
335
+ );
336
+ if (!ok) {
337
+ ctx.ui.notify("Deploy cancelled", "warning");
338
+ return;
339
+ }
340
+ }
341
+
291
342
  ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
292
343
  try {
293
344
  const deployer =
@@ -296,12 +347,26 @@ export function createCommandSpec(
296
347
  : platform === "deno"
297
348
  ? deployDenoRelay
298
349
  : deployVercelRelay;
299
- const url = await deployer(token, name, (m) =>
350
+ const { url, auth } = await deployer(token, name, (m) =>
300
351
  ctx.ui.notify(m, "info"),
301
352
  );
302
353
  setRelay(true, url, `deployed ${name}`);
354
+ // Persist the per-deployment shared secret with the relay entry
355
+ // (setRelay wrote without it; this save must include it).
356
+ const deployed = relayState.relays.find((r) => r.url === url);
357
+ if (deployed) deployed.auth = auth;
358
+ setActiveRelayState(relayState);
303
359
  persist();
304
- ctx.ui.notify(`✓ Deployed & active: ${url}`, "info");
360
+ let probeNote = "";
361
+ try {
362
+ const probe = await probeRelay(url, auth);
363
+ probeNote = probe.ok
364
+ ? ` ✓ reachable (HTTP ${probe.status}, ${probe.latencyMs}ms)`
365
+ : ` ⚠ deployed but unreachable (${probe.error || `HTTP ${probe.status}`}) — verify with /freeflow test`;
366
+ } catch {
367
+ // probeRelay never throws, but keep the notify safe regardless
368
+ }
369
+ ctx.ui.notify(`✓ Deployed & active: ${url}${probeNote}`, "info");
305
370
  } catch (e) {
306
371
  updateStatusBar(ctx.ui);
307
372
  ctx.ui.notify(
@@ -316,17 +381,19 @@ export function createCommandSpec(
316
381
  ctx.ui.notify("No saved relays yet", "warning");
317
382
  return;
318
383
  }
319
- const fmt = (r: KnownRelay, idx: number) => {
320
- const isAct = r.url === relayState.url ? "★ " : " ";
321
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
322
- return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
323
- };
384
+ const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
324
385
  const opts = relayState.relays.map(fmt);
325
386
  const choice = await ctx.ui.select("Switch active relay", opts);
326
387
  if (!choice) return;
327
388
  const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
328
389
  if (!match) return;
329
- setRelay(true, match.url, match.label);
390
+ applyRelayState((s) => {
391
+ const fresh = s.relays.find((r) => r.url === match.url);
392
+ if (!fresh) return s;
393
+ s.enabled = true;
394
+ s.url = fresh.url;
395
+ return s;
396
+ });
330
397
  persist();
331
398
  flash();
332
399
  };
@@ -345,8 +412,13 @@ export function createCommandSpec(
345
412
  const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
346
413
  const healthBadge = isCooling
347
414
  ? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
348
- : " ✓";
349
- return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}`;
415
+ : health?.lastLatencyMs != null && Number.isFinite(health.lastLatencyMs)
416
+ ? ` ✓ [${health.lastLatencyMs}ms]`
417
+ : " ✓";
418
+ const counterText = health && (health.successCount != null || health.failureCount != null)
419
+ ? ` ${health.successCount ?? 0} ok / ${health.failureCount ?? 0} fail`
420
+ : "";
421
+ return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}${counterText}`;
350
422
  });
351
423
  const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
352
424
  const activeIdx = Math.max(
@@ -369,10 +441,7 @@ export function createCommandSpec(
369
441
  );
370
442
  return;
371
443
  }
372
- const fmt = (r: KnownRelay, idx: number) => {
373
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
374
- return `[${idx + 1}] ${lbl}→ ${r.url}`;
375
- };
444
+ const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
376
445
  const choice = await ctx.ui.select(
377
446
  "Remove relay",
378
447
  removable.map(fmt),
@@ -380,95 +449,137 @@ export function createCommandSpec(
380
449
  if (!choice) return;
381
450
  const match = removable.find((r, idx) => fmt(r, idx) === choice);
382
451
  if (!match) return;
383
- removeRelay(relayState, match.url);
452
+ applyRelayState((s) => {
453
+ removeRelay(s, match.url);
454
+ return s;
455
+ });
384
456
  persist();
385
457
  ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
386
458
  };
387
459
 
388
460
  if (sub === "auto") {
389
- relayState.mode = "auto";
390
- relayState.enabled = true;
391
- setRelay(true, relayState.url || DEFAULT_RELAY_URL);
461
+ applyRelayState((s) => {
462
+ s.mode = "auto";
463
+ s.enabled = true;
464
+ s.url = s.url || "";
465
+ if (s.url) {
466
+ ensureRelay(s, s.url);
467
+ }
468
+ return s;
469
+ });
392
470
  persist();
393
471
  flash();
394
472
  } else if (sub === "on") {
395
- relayState.mode = "on";
396
- setRelay(true, relayState.url || DEFAULT_RELAY_URL);
473
+ applyRelayState((s) => {
474
+ s.mode = "on";
475
+ s.enabled = true;
476
+ s.url = s.url || "";
477
+ if (s.url) {
478
+ ensureRelay(s, s.url);
479
+ }
480
+ return s;
481
+ });
397
482
  persist();
398
483
  flash();
399
484
  } else if (sub === "off") {
400
- relayState.mode = "off";
401
- relayState.enabled = false;
402
- setActiveRelayState(relayState);
485
+ applyRelayState((s) => {
486
+ s.mode = "off";
487
+ s.enabled = false;
488
+ return s;
489
+ });
490
+ persist();
491
+ flash();
492
+ } else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
493
+ applyRelayState((s) => ({ ...s, hideWidget: true }));
494
+ persist();
495
+ updateStatusBar(ctx.ui);
496
+ ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
497
+ } else if (sub === "show" || (sub === "widget" && rest === "show")) {
498
+ applyRelayState((s) => ({ ...s, hideWidget: false }));
403
499
  persist();
404
500
  flash();
405
- } else if (sub === "status") {
406
- flash();
407
- try {
408
- const local = getLocalVersion();
409
- let latest: string | null = null;
410
- const cached = getCachedUpdate();
411
- if (cached && typeof cached.latest === "string") {
412
- latest = cached.latest;
413
- }
414
- if (latest && compareVersions(latest, local) > 0) {
415
- ctx.ui.notify(
416
- `Update available: ${local} -> ${latest} - run /freeflow update`,
417
- "info",
418
- );
419
- }
420
- } catch {
421
- // swallow — status banner is best-effort
422
- }
423
- } else if (sub === "update") {
424
- if (isLinkedInstall()) {
425
- ctx.ui.notify(
426
- "LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
427
- "info",
428
- );
429
- } else {
430
- try {
431
- ctx.ui.notify("Checking for updates…", "info");
432
- let latest: string | null = null;
433
- try {
434
- latest = await fetchLatestVersion();
435
- } catch {
436
- latest = null;
437
- }
438
- if (!latest) {
439
- const cached = getCachedUpdate();
440
- if (cached && typeof cached.latest === "string") {
441
- latest = cached.latest;
442
- }
443
- }
444
- if (!latest) {
445
- ctx.ui.notify("Could not check latest version (offline?)", "warning");
446
- } else {
447
- const local = getLocalVersion();
448
- const cmp = compareVersions(latest, local);
449
- if (cmp <= 0) {
450
- ctx.ui.notify(`Already on latest (v${local})`, "info");
451
- } else {
452
- ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
453
- let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
454
- if (code !== 0) {
455
- ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
456
- code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
457
- }
458
- if (code === 0) {
459
- ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
460
- } else {
461
- ctx.ui.notify(
462
- `Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
463
- "warning",
464
- );
465
- }
466
- }
467
- }
468
- } catch (e) {
469
- ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
470
- }
471
- }
501
+ ctx.ui.notify("Widget shown", "info");
502
+ } else if (sub === "status") {
503
+ flash();
504
+ try {
505
+ const local = getLocalVersion();
506
+ let latest: string | null = null;
507
+ const cached = getCachedUpdate();
508
+ if (cached && typeof cached.latest === "string") {
509
+ latest = cached.latest;
510
+ }
511
+ if (latest && compareVersions(latest, local) > 0) {
512
+ ctx.ui.notify(
513
+ `Update available: ${local} -> ${latest} - run /freeflow update`,
514
+ "info",
515
+ );
516
+ }
517
+ } catch {
518
+ // swallow — status banner is best-effort
519
+ }
520
+
521
+ const modeLine =
522
+ relayState.mode === "off"
523
+ ? "Mode: off (always direct)"
524
+ : relayState.mode === "on"
525
+ ? "Mode: on (always relay)"
526
+ : "Mode: auto (enabled on session, auto-rolls on 429/5xx)";
527
+ const poolLine = `${relayState.relays.length} relay(s)${
528
+ relayState.relays.length > 0
529
+ ? ` | active: ${shortRelayLabel(relayState.url, relayState.relays)}`
530
+ : ""
531
+ }`;
532
+ const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
533
+ ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}`, "info");
534
+ } else if (sub === "update") {
535
+ if (isLinkedInstall()) {
536
+ ctx.ui.notify(
537
+ "Linked install detected — restart your host app to pick up changes (no update needed).",
538
+ "info",
539
+ );
540
+ } else {
541
+ try {
542
+ ctx.ui.notify("Checking for updates…", "info");
543
+ let latest: string | null = null;
544
+ try {
545
+ latest = await fetchLatestVersion();
546
+ } catch {
547
+ latest = null;
548
+ }
549
+ if (!latest) {
550
+ const cached = getCachedUpdate();
551
+ if (cached && typeof cached.latest === "string") {
552
+ latest = cached.latest;
553
+ }
554
+ }
555
+ if (!latest) {
556
+ ctx.ui.notify("Could not check latest version (offline?)", "warning");
557
+ } else {
558
+ const local = getLocalVersion();
559
+ const cmp = compareVersions(latest, local);
560
+ if (cmp <= 0) {
561
+ ctx.ui.notify(`Already on latest (v${local})`, "info");
562
+ } else {
563
+ ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
564
+ let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
565
+ if (code !== 0) {
566
+ ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
567
+ code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
568
+ }
569
+ if (code === 0) {
570
+ ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
571
+ } else {
572
+ ctx.ui.notify(
573
+ `Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
574
+ "warning",
575
+ );
576
+ }
577
+ }
578
+ }
579
+ } catch (e) {
580
+ ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
581
+ }
582
+ }
472
583
  } else if (sub === "list") {
473
584
  showList();
474
585
  } else if (sub === "add") {
@@ -478,12 +589,16 @@ export function createCommandSpec(
478
589
  const tokens = rest.trim().split(/\s+/);
479
590
  const targetUrl = tokens[0];
480
591
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
481
- const added = ensureRelay(relayState, targetUrl, customLabel);
482
- setRelay(true, added.url, added.label);
592
+ applyRelayState((s) => {
593
+ const added = ensureRelay(s, targetUrl, customLabel);
594
+ s.enabled = true;
595
+ s.url = added.url;
596
+ return s;
597
+ });
483
598
  persist();
484
599
  flash();
485
600
  ctx.ui.notify(
486
- `✓ Added & activated relay [${added.label || shortRelayLabel(added.url)}]: ${added.url}`,
601
+ `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
487
602
  "info",
488
603
  );
489
604
  }
@@ -496,10 +611,16 @@ export function createCommandSpec(
496
611
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
497
612
  const matched = findRelay(relayState, targetToken);
498
613
  if (matched) {
499
- if (customLabel) {
500
- matched.label = customLabel;
501
- }
502
- setRelay(true, matched.url, matched.label);
614
+ applyRelayState((s) => {
615
+ const fresh = s.relays.find((r) => r.url === matched.url);
616
+ if (!fresh) return s;
617
+ s.enabled = true;
618
+ s.url = fresh.url;
619
+ if (customLabel) {
620
+ fresh.label = customLabel;
621
+ }
622
+ return s;
623
+ });
503
624
  persist();
504
625
  flash();
505
626
  } else {
@@ -511,8 +632,12 @@ export function createCommandSpec(
511
632
  } catch {}
512
633
  const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
513
634
  if (looksLikeUrl) {
514
- const added = ensureRelay(relayState, targetToken, customLabel);
515
- setRelay(true, added.url, added.label);
635
+ applyRelayState((s) => {
636
+ const added = ensureRelay(s, targetToken, customLabel);
637
+ s.enabled = true;
638
+ s.url = added.url;
639
+ return s;
640
+ });
516
641
  persist();
517
642
  flash();
518
643
  } else {
@@ -541,7 +666,10 @@ export function createCommandSpec(
541
666
  )
542
667
  )?.trim() || "";
543
668
  }
544
- setRelayLabel(relayState, matched.url, finalLabel);
669
+ applyRelayState((s) => {
670
+ setRelayLabel(s, matched.url, finalLabel);
671
+ return s;
672
+ });
545
673
  persist();
546
674
  flash();
547
675
  ctx.ui.notify(
@@ -610,14 +738,27 @@ export function createCommandSpec(
610
738
  const rawRest = rest.trim();
611
739
  let filterLevel: LogLevel | null = null;
612
740
  let filterReqId: string | null = null;
741
+ let filterText: string | null = null;
742
+ let expectRelay = false;
613
743
  let count = 25;
744
+ const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
745
+ const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
614
746
 
615
747
  if (sub === "trace" && rawRest) {
616
- filterReqId = rawRest.split(/\s+/)[0];
748
+ filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
617
749
  } else if (rawRest) {
618
- const tokens = rawRest.split(/\s+/);
619
- for (const t of tokens) {
750
+ for (const t of rawTokens) {
751
+ if (t === "--follow" || t === "-f") continue;
620
752
  const lower = t.toLowerCase();
753
+ if (expectRelay) {
754
+ filterText = t;
755
+ expectRelay = false;
756
+ continue;
757
+ }
758
+ if (t === "relay") {
759
+ expectRelay = true;
760
+ continue;
761
+ }
621
762
  if (lower in LOG_LEVEL_ORDER) {
622
763
  filterLevel = lower as LogLevel;
623
764
  } else if (/^\d+$/.test(t)) {
@@ -633,7 +774,9 @@ export function createCommandSpec(
633
774
  } else if (lower === "trace" || lower === "req") {
634
775
  continue;
635
776
  } else {
636
- filterReqId = t;
777
+ // Unrecognized token: an invalid level must default to info,
778
+ // not silently become a request-id filter.
779
+ filterLevel = filterLevel ?? "info";
637
780
  }
638
781
  }
639
782
  }
@@ -642,6 +785,7 @@ export function createCommandSpec(
642
785
  filterLevel,
643
786
  filterReqId,
644
787
  count,
788
+ filterText,
645
789
  );
646
790
  if (result.lines.length === 0) {
647
791
  if (result.totalLines === 0) {
@@ -651,18 +795,38 @@ export function createCommandSpec(
651
795
  );
652
796
  } else {
653
797
  ctx.ui.notify(
654
- `No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`,
798
+ `No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} text=${filterText || "any"} count=${count})`,
655
799
  "warning",
656
800
  );
657
801
  }
802
+ if (isFollow) {
803
+ startLogsFollow(
804
+ ctx,
805
+ filterLevel,
806
+ filterReqId,
807
+ count,
808
+ filterText,
809
+ result.totalMatched,
810
+ );
811
+ }
658
812
  return;
659
813
  }
660
814
 
661
- const header = `pi-freeflow logs (last ${result.lines.length}/${result.totalMatched} matched, total ${result.totalLines} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
815
+ const header = `pi-freeflow logs (last ${result.lines.length}/${result.totalMatched} matched, total ${result.totalLines} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}${filterText ? ` text=${filterText}` : ""}):`;
662
816
  ctx.ui.notify(
663
817
  `${header}\n\n${result.lines.join("\n")}`,
664
818
  "info",
665
819
  );
820
+ if (isFollow) {
821
+ startLogsFollow(
822
+ ctx,
823
+ filterLevel,
824
+ filterReqId,
825
+ count,
826
+ filterText,
827
+ result.totalMatched,
828
+ );
829
+ }
666
830
  } catch (e) {
667
831
  ctx.ui.notify(
668
832
  `Could not read log file: ${(e as Error).message}`,
@@ -703,34 +867,61 @@ export function createCommandSpec(
703
867
  );
704
868
  return;
705
869
  }
706
- removeRelay(relayState, matched.url);
870
+ applyRelayState((s) => {
871
+ removeRelay(s, matched.url);
872
+ return s;
873
+ });
707
874
  persist();
708
875
  ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
709
876
  }
877
+ } else if (sub === "test") {
878
+ const target = rest.trim();
879
+ const matched = findRelay(relayState, target);
880
+ if (!matched) {
881
+ ctx.ui.notify(`Relay '${target}' not in saved list`, "warning");
882
+ return;
883
+ }
884
+ ctx.ui.notify(`Testing ${matched.url}…`, "info");
885
+ const probe = await probeRelay(matched.url, matched.auth);
886
+ if (probe.ok) {
887
+ ctx.ui.notify(`✓ ${shortRelayLabel(matched.url, relayState.relays)} ok (HTTP ${probe.status}, ${probe.latencyMs}ms)`, "info");
888
+ } else {
889
+ ctx.ui.notify(`✗ ${shortRelayLabel(matched.url, relayState.relays)} failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
890
+ }
710
891
  } else if (sub === "url") {
711
892
  const input =
712
893
  rest ||
713
894
  (await ctx.ui.input(
714
895
  "Relay URL (empty = default):",
715
- relayState.url || DEFAULT_RELAY_URL,
896
+ relayState.url || "",
716
897
  ));
717
- const cleanInput = (input || "").trim() || DEFAULT_RELAY_URL;
718
- setRelay(
719
- relayState.enabled,
720
- cleanInput,
721
- shortRelayLabel(cleanInput, relayState.relays),
722
- );
898
+ const cleanInput = (input || "").trim() || "";
899
+ applyRelayState((s) => {
900
+ s.url = cleanInput;
901
+ if (cleanInput) {
902
+ ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
903
+ }
904
+ return s;
905
+ });
723
906
  persist();
724
907
  flash();
725
908
  } else if (sub === "deploy") {
726
- const pf = parseDeployPlatform(rest);
727
- if (!pf) {
728
- ctx.ui.notify(
729
- "Unknown platform. Use: vercel | cloudflare | deno",
730
- "warning",
909
+ if (!rest.trim()) {
910
+ const choice = await ctx.ui.select(
911
+ "Deploy relay on",
912
+ Object.keys(DEPLOY_OPTIONS),
731
913
  );
914
+ if (choice) await doDeploy(DEPLOY_OPTIONS[choice]);
732
915
  } else {
733
- await doDeploy(pf);
916
+ const pf = parseDeployPlatform(rest);
917
+ if (!pf) {
918
+ ctx.ui.notify(
919
+ "Unknown platform. Use: vercel | cloudflare | deno",
920
+ "warning",
921
+ );
922
+ } else {
923
+ await doDeploy(pf);
924
+ }
734
925
  }
735
926
  } else {
736
927
  const currentMode = (relayState.mode || "auto").toUpperCase();
@@ -768,20 +959,35 @@ export function createCommandSpec(
768
959
  } else if (choice === "Deploy Deno relay…") {
769
960
  await doDeploy("deno");
770
961
  } else if (choice === "Mode: AUTO (auto-detect on model select)") {
771
- relayState.mode = "auto";
772
- relayState.enabled = true;
773
- setRelay(true, relayState.url || DEFAULT_RELAY_URL);
962
+ applyRelayState((s) => {
963
+ s.mode = "auto";
964
+ s.enabled = true;
965
+ s.url = s.url || "";
966
+ if (s.url) {
967
+ ensureRelay(s, s.url);
968
+ }
969
+ return s;
970
+ });
774
971
  persist();
775
972
  flash();
776
973
  } else if (choice === "Mode: ON (always relay)") {
777
- relayState.mode = "on";
778
- setRelay(true, relayState.url || DEFAULT_RELAY_URL);
974
+ applyRelayState((s) => {
975
+ s.mode = "on";
976
+ s.enabled = true;
977
+ s.url = s.url || "";
978
+ if (s.url) {
979
+ ensureRelay(s, s.url);
980
+ }
981
+ return s;
982
+ });
779
983
  persist();
780
984
  flash();
781
985
  } else if (choice === "Mode: OFF (always direct)") {
782
- relayState.mode = "off";
783
- relayState.enabled = false;
784
- setActiveRelayState(relayState);
986
+ applyRelayState((s) => {
987
+ s.mode = "off";
988
+ s.enabled = false;
989
+ return s;
990
+ });
785
991
  persist();
786
992
  flash();
787
993
  }