pi-freeflow 1.5.0 → 1.7.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
@@ -5,16 +5,14 @@
5
5
  */
6
6
 
7
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
8
  import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
12
- import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
9
+ import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
13
10
  import {
14
11
  compareVersions,
15
12
  fetchLatestVersion,
16
13
  getCachedUpdate,
17
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,40 +60,7 @@ import type {
59
60
  */
60
61
  export function updateStatusBar(ui?: ExtensionUIContext): void {
61
62
  if (!ui) return;
62
- const relayState = getActiveRelayState();
63
- if (relayState.hideWidget) {
64
- ui.setStatus("freeflow", undefined);
65
- return;
66
- }
67
- if (relayState.enabled && relayState.relays.length > 0) {
68
- const label = shortRelayLabel(relayState.url);
69
- const idx = Math.max(
70
- 1,
71
- relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
72
- );
73
- const total = relayState.relays.length;
74
- const modeLabel = relayState.mode === "on" ? "ON" : "AUTO (ON)";
75
- ui.setStatus("freeflow", `relay: ${modeLabel} | ${label} ${idx}/${total}`);
76
- } else if (relayState.mode === "off" || !relayState.enabled) {
77
- ui.setStatus("freeflow", "relay: OFF (direct)");
78
- } else {
79
- ui.setStatus("freeflow", undefined);
80
- }
81
- }
82
-
83
- function getLocalVersion(): string {
84
- try {
85
- const thisDir = path.dirname(fileURLToPath(import.meta.url));
86
- const pkgPath = path.join(thisDir, "..", "package.json");
87
- const raw = readFileSync(pkgPath, "utf8");
88
- const pkg = JSON.parse(raw) as { version?: string };
89
- if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
90
- return pkg.version.trim();
91
- }
92
- return "0.0.0";
93
- } catch {
94
- return "0.0.0";
95
- }
63
+ ui.setStatus("freeflow", formatRelayStatusLabel(getActiveRelayState()) ?? undefined);
96
64
  }
97
65
 
98
66
  function spawnWithProgress(
@@ -126,13 +94,55 @@ function spawnWithProgress(
126
94
  });
127
95
  }
128
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;
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?.();
137
+ }
138
+
129
139
  export function createCommandSpec(
130
140
  _pi: ExtensionAPI,
131
141
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
132
142
  ): Omit<RegisteredCommand, "name"> {
133
143
  return {
134
144
  description:
135
- "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> | 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",
136
146
  getArgumentCompletions: (prefix: string) =>
137
147
  [
138
148
  "auto",
@@ -149,6 +159,7 @@ export function createCommandSpec(
149
159
  "label",
150
160
  "rename",
151
161
  "remove",
162
+ "test",
152
163
  "url",
153
164
  "deploy",
154
165
  "deploy vercel",
@@ -168,39 +179,40 @@ export function createCommandSpec(
168
179
  .split(/\s+/);
169
180
  const sub = parts[0] || "";
170
181
  const rest = parts.slice(1).join(" ");
171
- const relayState = getActiveRelayState();
182
+ let relayState = getActiveRelayState();
172
183
 
173
184
  const flash = () => {
174
- const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
175
- const activeIdx = Math.max(
176
- 1,
177
- relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
178
- );
179
- const total = relayState.relays.length || 1;
180
- const modeStr = (relayState.mode || "auto").toUpperCase();
181
- ctx.ui.notify(
182
- `Relay mode: ${modeStr} (${relayState.enabled ? "ON" : "OFF"})${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
183
- "info",
184
- );
185
+ ctx.ui.notify(formatRelayFlash(relayState), "info");
185
186
  };
186
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.
187
191
  const persist = () => {
188
- setActiveRelayState(relayState, true);
189
192
  setStatusUi(ctx.ui);
190
193
  updateStatusBar(ctx.ui);
191
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
+ };
192
202
  const setRelay = (
193
203
  enabled: boolean,
194
204
  url: string,
195
205
  addLabel?: string,
196
206
  ) => {
197
- relayState.enabled = enabled;
198
- const cleanUrl = (url || "").trim() || DEFAULT_RELAY_URL;
199
- relayState.url = cleanUrl;
200
- if (cleanUrl) {
201
- ensureRelay(relayState, cleanUrl, addLabel);
202
- }
203
- 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
+ });
204
216
  };
205
217
 
206
218
  const addRelayInteractive = async () => {
@@ -222,12 +234,16 @@ export function createCommandSpec(
222
234
  )
223
235
  )?.trim() || defaultLabel;
224
236
 
225
- const added = ensureRelay(relayState, inputUrl, inputLabel);
226
- 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
+ });
227
243
  persist();
228
244
  flash();
229
245
  ctx.ui.notify(
230
- `✓ Added & activated relay [${added.label || shortRelayLabel(added.url)}]: ${added.url}`,
246
+ `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
231
247
  "info",
232
248
  );
233
249
  };
@@ -256,7 +272,10 @@ export function createCommandSpec(
256
272
  ctx.ui.notify("Cancelled — short name not changed", "warning");
257
273
  return;
258
274
  }
259
- setRelayLabel(relayState, match.url, newLabel);
275
+ applyRelayState((s) => {
276
+ setRelayLabel(s, match.url, newLabel);
277
+ return s;
278
+ });
260
279
  persist();
261
280
  flash();
262
281
  ctx.ui.notify(
@@ -265,6 +284,12 @@ export function createCommandSpec(
265
284
  );
266
285
  };
267
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
+
268
293
  const parseDeployPlatform = (raw?: string): DeployPlatform | null => {
269
294
  const v = (raw || "").trim().toLowerCase();
270
295
  if (!v || v === "vercel") return "vercel";
@@ -281,6 +306,13 @@ export function createCommandSpec(
281
306
  : platform === "deno"
282
307
  ? "Deno Deploy"
283
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");
284
316
  const token = (
285
317
  await ctx.ui.input(`${label} API token:`, "")
286
318
  )?.trim();
@@ -296,6 +328,17 @@ export function createCommandSpec(
296
328
  )
297
329
  )?.trim() || defaultName;
298
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
+
299
342
  ctx.ui.setStatus("freeflow", `deploying ${platform} relay…`);
300
343
  try {
301
344
  const deployer =
@@ -304,12 +347,26 @@ export function createCommandSpec(
304
347
  : platform === "deno"
305
348
  ? deployDenoRelay
306
349
  : deployVercelRelay;
307
- const url = await deployer(token, name, (m) =>
350
+ const { url, auth } = await deployer(token, name, (m) =>
308
351
  ctx.ui.notify(m, "info"),
309
352
  );
310
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);
311
359
  persist();
312
- 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");
313
370
  } catch (e) {
314
371
  updateStatusBar(ctx.ui);
315
372
  ctx.ui.notify(
@@ -324,17 +381,19 @@ export function createCommandSpec(
324
381
  ctx.ui.notify("No saved relays yet", "warning");
325
382
  return;
326
383
  }
327
- const fmt = (r: KnownRelay, idx: number) => {
328
- const isAct = r.url === relayState.url ? "★ " : " ";
329
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
330
- return `${isAct}[${idx + 1}] ${lbl}→ ${r.url}`;
331
- };
384
+ const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx, relayState.url);
332
385
  const opts = relayState.relays.map(fmt);
333
386
  const choice = await ctx.ui.select("Switch active relay", opts);
334
387
  if (!choice) return;
335
388
  const match = relayState.relays.find((r, idx) => fmt(r, idx) === choice);
336
389
  if (!match) return;
337
- 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
+ });
338
397
  persist();
339
398
  flash();
340
399
  };
@@ -353,8 +412,13 @@ export function createCommandSpec(
353
412
  const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
354
413
  const healthBadge = isCooling
355
414
  ? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
356
- : " ✓";
357
- 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}`;
358
422
  });
359
423
  const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
360
424
  const activeIdx = Math.max(
@@ -377,10 +441,7 @@ export function createCommandSpec(
377
441
  );
378
442
  return;
379
443
  }
380
- const fmt = (r: KnownRelay, idx: number) => {
381
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url, relayState.relays)}] `;
382
- return `[${idx + 1}] ${lbl}→ ${r.url}`;
383
- };
444
+ const fmt = (r: KnownRelay, idx: number) => formatRelayPickerItem(r, idx);
384
445
  const choice = await ctx.ui.select(
385
446
  "Remove relay",
386
447
  removable.map(fmt),
@@ -388,35 +449,53 @@ export function createCommandSpec(
388
449
  if (!choice) return;
389
450
  const match = removable.find((r, idx) => fmt(r, idx) === choice);
390
451
  if (!match) return;
391
- removeRelay(relayState, match.url);
452
+ applyRelayState((s) => {
453
+ removeRelay(s, match.url);
454
+ return s;
455
+ });
392
456
  persist();
393
457
  ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
394
458
  };
395
459
 
396
460
  if (sub === "auto") {
397
- relayState.mode = "auto";
398
- relayState.enabled = true;
399
- 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
+ });
400
470
  persist();
401
471
  flash();
402
472
  } else if (sub === "on") {
403
- relayState.mode = "on";
404
- 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
+ });
405
482
  persist();
406
483
  flash();
407
484
  } else if (sub === "off") {
408
- relayState.mode = "off";
409
- relayState.enabled = false;
410
- setActiveRelayState(relayState);
485
+ applyRelayState((s) => {
486
+ s.mode = "off";
487
+ s.enabled = false;
488
+ return s;
489
+ });
411
490
  persist();
412
491
  flash();
413
492
  } else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
414
- relayState.hideWidget = true;
493
+ applyRelayState((s) => ({ ...s, hideWidget: true }));
415
494
  persist();
416
495
  updateStatusBar(ctx.ui);
417
496
  ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
418
497
  } else if (sub === "show" || (sub === "widget" && rest === "show")) {
419
- relayState.hideWidget = false;
498
+ applyRelayState((s) => ({ ...s, hideWidget: false }));
420
499
  persist();
421
500
  flash();
422
501
  ctx.ui.notify("Widget shown", "info");
@@ -438,10 +517,24 @@ export function createCommandSpec(
438
517
  } catch {
439
518
  // swallow — status banner is best-effort
440
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");
441
534
  } else if (sub === "update") {
442
535
  if (isLinkedInstall()) {
443
536
  ctx.ui.notify(
444
- "LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
537
+ "Linked install detected — restart your host app to pick up changes (no update needed).",
445
538
  "info",
446
539
  );
447
540
  } else {
@@ -496,12 +589,16 @@ export function createCommandSpec(
496
589
  const tokens = rest.trim().split(/\s+/);
497
590
  const targetUrl = tokens[0];
498
591
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
499
- const added = ensureRelay(relayState, targetUrl, customLabel);
500
- 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
+ });
501
598
  persist();
502
599
  flash();
503
600
  ctx.ui.notify(
504
- `✓ Added & activated relay [${added.label || shortRelayLabel(added.url)}]: ${added.url}`,
601
+ `✓ Added & activated relay [${shortRelayLabel(relayState.url, relayState.relays)}]: ${relayState.url}`,
505
602
  "info",
506
603
  );
507
604
  }
@@ -514,10 +611,16 @@ export function createCommandSpec(
514
611
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
515
612
  const matched = findRelay(relayState, targetToken);
516
613
  if (matched) {
517
- if (customLabel) {
518
- matched.label = customLabel;
519
- }
520
- 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
+ });
521
624
  persist();
522
625
  flash();
523
626
  } else {
@@ -529,8 +632,12 @@ export function createCommandSpec(
529
632
  } catch {}
530
633
  const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
531
634
  if (looksLikeUrl) {
532
- const added = ensureRelay(relayState, targetToken, customLabel);
533
- 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
+ });
534
641
  persist();
535
642
  flash();
536
643
  } else {
@@ -559,7 +666,10 @@ export function createCommandSpec(
559
666
  )
560
667
  )?.trim() || "";
561
668
  }
562
- setRelayLabel(relayState, matched.url, finalLabel);
669
+ applyRelayState((s) => {
670
+ setRelayLabel(s, matched.url, finalLabel);
671
+ return s;
672
+ });
563
673
  persist();
564
674
  flash();
565
675
  ctx.ui.notify(
@@ -628,6 +738,8 @@ export function createCommandSpec(
628
738
  const rawRest = rest.trim();
629
739
  let filterLevel: LogLevel | null = null;
630
740
  let filterReqId: string | null = null;
741
+ let filterText: string | null = null;
742
+ let expectRelay = false;
631
743
  let count = 25;
632
744
  const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
633
745
  const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
@@ -638,6 +750,15 @@ export function createCommandSpec(
638
750
  for (const t of rawTokens) {
639
751
  if (t === "--follow" || t === "-f") continue;
640
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
+ }
641
762
  if (lower in LOG_LEVEL_ORDER) {
642
763
  filterLevel = lower as LogLevel;
643
764
  } else if (/^\d+$/.test(t)) {
@@ -653,7 +774,9 @@ export function createCommandSpec(
653
774
  } else if (lower === "trace" || lower === "req") {
654
775
  continue;
655
776
  } else {
656
- filterReqId = t;
777
+ // Unrecognized token: an invalid level must default to info,
778
+ // not silently become a request-id filter.
779
+ filterLevel = filterLevel ?? "info";
657
780
  }
658
781
  }
659
782
  }
@@ -662,6 +785,7 @@ export function createCommandSpec(
662
785
  filterLevel,
663
786
  filterReqId,
664
787
  count,
788
+ filterText,
665
789
  );
666
790
  if (result.lines.length === 0) {
667
791
  if (result.totalLines === 0) {
@@ -671,41 +795,37 @@ export function createCommandSpec(
671
795
  );
672
796
  } else {
673
797
  ctx.ui.notify(
674
- `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})`,
675
799
  "warning",
676
800
  );
677
801
  }
678
802
  if (isFollow) {
679
- const _t = setInterval(() => {
680
- try {
681
- const tail = readRecentLogs(filterLevel, filterReqId, count);
682
- if (tail.lines.length > 0) {
683
- ctx.ui.notify(tail.lines.join("\n"), "info");
684
- }
685
- } catch {}
686
- }, 1000);
687
- // @ts-ignore allow unref to not block process exit in CLI
688
- _t.unref?.();
803
+ startLogsFollow(
804
+ ctx,
805
+ filterLevel,
806
+ filterReqId,
807
+ count,
808
+ filterText,
809
+ result.totalMatched,
810
+ );
689
811
  }
690
812
  return;
691
813
  }
692
814
 
693
- 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}` : ""}):`;
694
816
  ctx.ui.notify(
695
817
  `${header}\n\n${result.lines.join("\n")}`,
696
818
  "info",
697
819
  );
698
820
  if (isFollow) {
699
- const _t2 = setInterval(() => {
700
- try {
701
- const tail = readRecentLogs(filterLevel, filterReqId, count);
702
- if (tail.lines.length > 0) {
703
- ctx.ui.notify(tail.lines.join("\n"), "info");
704
- }
705
- } catch {}
706
- }, 1000);
707
- // @ts-ignore allow unref to not block process exit in CLI
708
- _t2.unref?.();
821
+ startLogsFollow(
822
+ ctx,
823
+ filterLevel,
824
+ filterReqId,
825
+ count,
826
+ filterText,
827
+ result.totalMatched,
828
+ );
709
829
  }
710
830
  } catch (e) {
711
831
  ctx.ui.notify(
@@ -747,34 +867,61 @@ export function createCommandSpec(
747
867
  );
748
868
  return;
749
869
  }
750
- removeRelay(relayState, matched.url);
870
+ applyRelayState((s) => {
871
+ removeRelay(s, matched.url);
872
+ return s;
873
+ });
751
874
  persist();
752
875
  ctx.ui.notify(`Removed: [${shortRelayLabel(matched.url, relayState.relays)}] ${matched.url}`, "info");
753
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
+ }
754
891
  } else if (sub === "url") {
755
892
  const input =
756
893
  rest ||
757
894
  (await ctx.ui.input(
758
895
  "Relay URL (empty = default):",
759
- relayState.url || DEFAULT_RELAY_URL,
896
+ relayState.url || "",
760
897
  ));
761
- const cleanInput = (input || "").trim() || DEFAULT_RELAY_URL;
762
- setRelay(
763
- relayState.enabled,
764
- cleanInput,
765
- shortRelayLabel(cleanInput, relayState.relays),
766
- );
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
+ });
767
906
  persist();
768
907
  flash();
769
908
  } else if (sub === "deploy") {
770
- const pf = parseDeployPlatform(rest);
771
- if (!pf) {
772
- ctx.ui.notify(
773
- "Unknown platform. Use: vercel | cloudflare | deno",
774
- "warning",
909
+ if (!rest.trim()) {
910
+ const choice = await ctx.ui.select(
911
+ "Deploy relay on",
912
+ Object.keys(DEPLOY_OPTIONS),
775
913
  );
914
+ if (choice) await doDeploy(DEPLOY_OPTIONS[choice]);
776
915
  } else {
777
- 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
+ }
778
925
  }
779
926
  } else {
780
927
  const currentMode = (relayState.mode || "auto").toUpperCase();
@@ -812,20 +959,35 @@ export function createCommandSpec(
812
959
  } else if (choice === "Deploy Deno relay…") {
813
960
  await doDeploy("deno");
814
961
  } else if (choice === "Mode: AUTO (auto-detect on model select)") {
815
- relayState.mode = "auto";
816
- relayState.enabled = true;
817
- 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
+ });
818
971
  persist();
819
972
  flash();
820
973
  } else if (choice === "Mode: ON (always relay)") {
821
- relayState.mode = "on";
822
- 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
+ });
823
983
  persist();
824
984
  flash();
825
985
  } else if (choice === "Mode: OFF (always direct)") {
826
- relayState.mode = "off";
827
- relayState.enabled = false;
828
- setActiveRelayState(relayState);
986
+ applyRelayState((s) => {
987
+ s.mode = "off";
988
+ s.enabled = false;
989
+ return s;
990
+ });
829
991
  persist();
830
992
  flash();
831
993
  }