pi-freeflow 1.38.0 → 1.38.2

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
@@ -28,6 +28,7 @@ import {
28
28
  type DeployPlatform,
29
29
  } from "./deploy.ts";
30
30
  import {
31
+ createLogFollower,
31
32
  LOG_LEVEL_ORDER,
32
33
  getMinLogLevel,
33
34
  isDebugEnabled,
@@ -323,9 +324,12 @@ function spawnWithProgress(
323
324
  }
324
325
 
325
326
  /**
326
- * Follow-mode log poller: tracks the count of matched lines already printed
327
- * and notifies only lines beyond it, so repeated ticks never re-notify the
328
- * same tail. One poller at a time — a re-run clears the previous interval.
327
+ * Follow-mode log poller: one stateful byte-offset follower over the live log
328
+ * file (createLogFollower), so each tick reads only the bytes appended since
329
+ * the previous tick instead of re-reading the log file and every rotated
330
+ * archive. Existing content is the one-shot tail's job — it is printed before
331
+ * this poller starts, and the follower never re-reports it. One poller at a
332
+ * time: a re-run clears the previous interval.
329
333
  * Stop with stopLogsFollow (session shutdown / non-follow commands).
330
334
  */
331
335
  let logsFollowTimer: NodeJS.Timeout | null = null;
@@ -339,29 +343,20 @@ function startLogsFollow(
339
343
  ctx: ExtensionContext,
340
344
  filterLevel: LogLevel | null,
341
345
  filterReqId: string | null,
342
- count: number,
343
346
  filterText: string | null,
344
- baselineMatched: number,
345
347
  ): void {
346
348
  stopLogsFollow();
347
- let lastPrinted = baselineMatched;
349
+ const follower = createLogFollower({
350
+ level: filterLevel,
351
+ reqId: filterReqId,
352
+ text: filterText,
353
+ });
348
354
  logsFollowTimer = setInterval(() => {
349
355
  try {
350
- const tail = readRecentLogs(filterLevel, filterReqId, count, filterText);
351
- if (tail.totalMatched < lastPrinted) {
352
- // Log rotated/rewritten below the baseline — re-baseline silently.
353
- lastPrinted = tail.totalMatched;
354
- return;
355
- }
356
- const newCount = tail.totalMatched - lastPrinted;
357
- if (newCount <= 0) return;
358
- const newLines = tail.lines.slice(
359
- tail.lines.length - Math.min(newCount, tail.lines.length),
360
- ).slice(-50);
356
+ const newLines = follower.next();
361
357
  if (newLines.length > 0) {
362
358
  ctx.ui.notify(newLines.join("\n"), "info");
363
359
  }
364
- lastPrinted = tail.totalMatched;
365
360
  } catch { }
366
361
  }, 1000);
367
362
  // @ts-ignore allow unref to not block process exit in CLI
@@ -514,13 +509,38 @@ export function validateDeployProjectName(
514
509
  }
515
510
  return { ok: true, name: clean };
516
511
  }
512
+ /**
513
+ * /freeflow update restarts the daemon the session is actually attached to: a
514
+ * walked port must not keep serving the old build after a successful update.
515
+ * Three indirections keep that contract testable without module mocking — the
516
+ * update-step runner, the attached-port lookup and the killer. Production never
517
+ * calls the setter; the defaults are the real spawnWithProgress, the real
518
+ * getClientPort() || PORT and the real killPortHolder.
519
+ */
520
+ let updateStepRunner = spawnWithProgress;
521
+ let attachedPortResolver = (): number => getClientPort() || PORT;
522
+ let attachedPortKiller = (port: number): Promise<unknown> => killPortHolder(port);
523
+
524
+ /** Test seam for the /freeflow update restart path (null restores the real deps). */
525
+ export function _setUpdateRestartDepsForTest(
526
+ opts: {
527
+ runUpdateStep?: (cmd: string, args: string[], ctx: ExtensionContext) => Promise<number>;
528
+ resolveAttachedPort?: () => number;
529
+ killAttachedPort?: (port: number) => Promise<unknown>;
530
+ } | null,
531
+ ): void {
532
+ updateStepRunner = opts?.runUpdateStep ?? spawnWithProgress;
533
+ attachedPortResolver = opts?.resolveAttachedPort ?? ((): number => getClientPort() || PORT);
534
+ attachedPortKiller = opts?.killAttachedPort ?? ((port: number): Promise<unknown> => killPortHolder(port));
535
+ }
536
+
517
537
  export function createCommandSpec(
518
538
  _pi: ExtensionAPI,
519
539
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
520
540
  ): Omit<RegisteredCommand, "name"> {
521
541
  return {
522
542
  description:
523
- "Relay egress: auto | spread | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update [auto on|off] | autoupdate [on|off] | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
543
+ "Relay egress: auto | spread | on | off | hide | show | widget hide/show | status | kill|stop|shutdown | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | url [value] | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | trace [reqId] | debug on|off|level <level> | refresh | models|reload | update [auto on|off] | autoupdate [on|off] | deploy cloudflare | deploy deno | deploy vercel | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot] | cline signout|switch",
524
544
  getArgumentCompletions: (prefix: string) =>
525
545
  [
526
546
  "auto",
@@ -531,6 +551,9 @@ export function createCommandSpec(
531
551
  "widget hide",
532
552
  "widget show",
533
553
  "status",
554
+ "kill",
555
+ "stop",
556
+ "shutdown",
534
557
  "add",
535
558
  "list",
536
559
  "use",
@@ -559,10 +582,19 @@ export function createCommandSpec(
559
582
  "cline login --key",
560
583
  "cline accounts",
561
584
  "cline logout",
585
+ "cline signout",
586
+ "cline switch",
562
587
  "refresh",
563
588
  "models",
589
+ "reload",
564
590
  "logs",
565
591
  "debug",
592
+ "debug on",
593
+ "debug off",
594
+ "debug level debug",
595
+ "debug level info",
596
+ "debug level warn",
597
+ "debug level error",
566
598
  "trace",
567
599
  ]
568
600
  .filter((s) => s.startsWith(prefix))
@@ -593,22 +625,6 @@ export function createCommandSpec(
593
625
  relayState = withRelayState(updater);
594
626
  return relayState;
595
627
  };
596
- const setRelay = (
597
- enabled: boolean,
598
- url: string,
599
- addLabel?: string,
600
- ) => {
601
- applyRelayState((s) => {
602
- s.enabled = enabled;
603
- const cleanUrl = (url || "").trim() || "";
604
- s.url = cleanUrl;
605
- if (cleanUrl) {
606
- ensureRelay(s, cleanUrl, addLabel);
607
- }
608
- return s;
609
- });
610
- };
611
-
612
628
  const addRelayInteractive = async () => {
613
629
  const inputUrl = (
614
630
  await ctx.ui.input(
@@ -628,12 +644,17 @@ export function createCommandSpec(
628
644
  )
629
645
  )?.trim() || defaultLabel;
630
646
 
631
- applyRelayState((s) => {
632
- const added = ensureRelay(s, inputUrl, inputLabel);
633
- s.enabled = true;
634
- s.url = added.url;
635
- return s;
636
- });
647
+ try {
648
+ applyRelayState((s) => {
649
+ const added = ensureRelay(s, inputUrl, inputLabel);
650
+ s.enabled = true;
651
+ s.url = added.url;
652
+ return s;
653
+ });
654
+ } catch (e) {
655
+ ctx.ui.notify((e as Error).message, "warning");
656
+ return;
657
+ }
637
658
  persist();
638
659
  flash();
639
660
  ctx.ui.notify(
@@ -1255,7 +1276,7 @@ export function createCommandSpec(
1255
1276
  piAvailable: isCommandAvailable("pi"),
1256
1277
  })) {
1257
1278
  if (lastStep !== "") ctx.ui.notify(`${lastStep} exited ${code} — trying ${step.manual}…`, "info");
1258
- code = await spawnWithProgress(step.cmd, step.args, ctx);
1279
+ code = await updateStepRunner(step.cmd, step.args, ctx);
1259
1280
  manual = step.manual;
1260
1281
  lastStep = step.manual;
1261
1282
  if (code === 0) break;
@@ -1271,11 +1292,13 @@ export function createCommandSpec(
1271
1292
  "info",
1272
1293
  );
1273
1294
  manual = plan.manual;
1274
- code = await spawnWithProgress(plan.cmd, plan.args, ctx);
1295
+ code = await updateStepRunner(plan.cmd, plan.args, ctx);
1275
1296
  }
1276
1297
  }
1277
1298
  if (code === 0) {
1278
- try { await killPortHolder(PORT); } catch { }
1299
+ // Restart the daemon that is actually attached — a walked port must
1300
+ // not leave the old build serving after a successful update.
1301
+ try { await attachedPortKiller(attachedPortResolver()); } catch { }
1279
1302
  ctx.ui.notify(`Updated to ${latest} and restarted proxy daemon`, "info");
1280
1303
  } else {
1281
1304
  ctx.ui.notify(
@@ -1442,13 +1465,17 @@ export function createCommandSpec(
1442
1465
  }
1443
1466
  } else {
1444
1467
  const st = loadDebugState();
1445
- const cur = st?.debug
1446
- ? "debug (ON)"
1447
- : st?.level ||
1448
- process.env.FREEFLOW_LOG_LEVEL ||
1449
- "info";
1468
+ const minLevel = getMinLogLevel();
1469
+ // Report the level the logger actually filters at, not the raw fields: a
1470
+ // fresh install (no state file) and FREEFLOW_DEBUG=1 both run at debug
1471
+ // without either being persisted anywhere.
1472
+ const effective =
1473
+ (Object.keys(LOG_LEVEL_ORDER) as LogLevel[]).find(
1474
+ (level) => LOG_LEVEL_ORDER[level] === minLevel,
1475
+ ) ?? "info";
1476
+ const cur = st?.debug ? "debug (ON)" : effective;
1450
1477
  ctx.ui.notify(
1451
- `Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`,
1478
+ `Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${minLevel} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`,
1452
1479
  "info",
1453
1480
  );
1454
1481
  }
@@ -1523,9 +1550,7 @@ export function createCommandSpec(
1523
1550
  ctx,
1524
1551
  filterLevel,
1525
1552
  filterReqId,
1526
- count,
1527
1553
  filterText,
1528
- result.totalMatched,
1529
1554
  );
1530
1555
  }
1531
1556
  return;
@@ -1541,9 +1566,7 @@ export function createCommandSpec(
1541
1566
  ctx,
1542
1567
  filterLevel,
1543
1568
  filterReqId,
1544
- count,
1545
1569
  filterText,
1546
- result.totalMatched,
1547
1570
  );
1548
1571
  }
1549
1572
  } catch (e) {
@@ -1850,13 +1873,18 @@ export function createCommandSpec(
1850
1873
  relayState.url || "",
1851
1874
  ));
1852
1875
  const cleanInput = (input || "").trim() || "";
1853
- applyRelayState((s) => {
1854
- s.url = cleanInput;
1855
- if (cleanInput) {
1856
- ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
1857
- }
1858
- return s;
1859
- });
1876
+ try {
1877
+ applyRelayState((s) => {
1878
+ s.url = cleanInput;
1879
+ if (cleanInput) {
1880
+ ensureRelay(s, cleanInput, shortRelayLabel(cleanInput, s.relays));
1881
+ }
1882
+ return s;
1883
+ });
1884
+ } catch (e) {
1885
+ ctx.ui.notify((e as Error).message, "warning");
1886
+ return;
1887
+ }
1860
1888
  persist();
1861
1889
  flash();
1862
1890
  } else if (sub === "deploy") {
package/src/daemon.ts CHANGED
@@ -1,144 +1,146 @@
1
- /**
2
- * Detached proxy daemon entry for pi-freeflow.
3
- *
4
- * Spawned as a separate OS process by src/client.ts so the local proxy survives
5
- * the OMP/Pi session that started it. Owns port 28180, serves the proxy plus
6
- * the client lease/control endpoints, and retires itself once no client holds
7
- * a live lease (request-idleness alone never retires it).
8
- *
9
- * Run directly: `node --experimental-strip-types src/daemon.ts` (or `bun src/daemon.ts`).
10
- */
11
-
12
- import path from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import {
15
- DAEMON_GC_MS,
16
- DAEMON_GRACE_MS,
17
- DAEMON_TTL_MS,
18
- HOST,
19
- PKG_VERSION,
20
- PORT,
21
- } from "./config.ts";
22
- import { setShutdownShouldExit } from "./proxy.ts";
23
- import {
24
- getAliveCatalog,
25
- refreshCatalog,
26
- setAliveCatalog,
27
- } from "./catalog.ts";
28
- import { log, logInfo, logWarn } from "./logger.ts";
29
- import {
30
- startLeaseGC,
31
- stopLeaseGC,
32
- touchActivity,
33
- } from "./lease.ts";
34
- import { getActiveRequests, startProxy } from "./proxy.ts";
35
- import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
36
-
37
- /** True when this module was run as the entry script (not imported by tests). */
38
- export function isDaemonMain(): boolean {
39
- try {
40
- const entry = process.argv[1];
41
- if (!entry) return false;
42
- return path.resolve(entry) === fileURLToPath(import.meta.url);
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
- /**
49
- * Load the on-disk relay state into this process's in-memory cache so routing
50
- * uses the latest client edits without a daemon restart. Non-fatal on failure.
51
- */
52
- export function syncRelayStateFromDisk(): void {
53
- try {
54
- setActiveRelayState(loadRelayState(), false);
55
- } catch (e) {
56
- log("warn", "daemon failed to load relay state from disk", { error: String(e) });
57
- }
58
- }
59
-
60
- /**
61
- * Serve the static+disk catalog and refresh it in the background, mirroring
62
- * what the host session did in-process. Keeps /v1/models correct after the
63
- * 24h cache TTL without requiring a daemon restart.
64
- */
65
- export async function seedCatalog(): Promise<void> {
66
- try {
67
- const fresh = await refreshCatalog(false);
68
- if (fresh.length > 0) {
69
- setAliveCatalog(fresh);
70
- }
71
- } catch (e) {
72
- logWarn("daemon catalog refresh failed; retaining static catalog", {
73
- error: String(e),
74
- });
75
- }
76
- logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
77
- }
78
-
79
- /**
80
- * Boot the detached proxy daemon. Binds the port, seeds catalog + relay state,
81
- * starts the lease GC, and installs signal handlers so the daemon exits
82
- * cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
83
- */
84
- export async function runDaemon(): Promise<void> {
85
- let server: { close(): void } | null = null;
86
- const retire = (why: string): void => {
87
- logInfo(`daemon retiring: ${why}`);
88
- stopLeaseGC();
89
- try {
90
- server?.close();
91
- } catch { }
92
- process.exit(0);
93
- };
94
- process.on("SIGTERM", () => retire("SIGTERM"));
95
- process.on("SIGINT", () => retire("SIGINT"));
96
- process.on("uncaughtException", (err) => {
97
- log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack, activeRequests: getActiveRequests() });
98
- });
99
- process.on("unhandledRejection", (reason) => {
100
- log("error", "daemon unhandledRejection", { error: String(reason), activeRequests: getActiveRequests() });
101
- });
102
- setShutdownShouldExit(true);
103
- try {
104
- const r = await startProxy();
105
- if (!r.server) {
106
- // Port was taken by another (possibly newer) daemon — the parent
107
- // attaches to the winner; this spawn exits quietly.
108
- logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
109
- process.exit(0);
110
- }
111
- server = r.server;
112
- } catch (e) {
113
- log("error", "daemon failed to bind proxy port", { error: String(e) });
114
- process.exit(1);
115
- }
116
-
117
- // lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
118
- // retirement no longer reads it — only the zero-lease persistence window
119
- // gates the GC, so a fresh spawn always survives its re-attach window.
120
- touchActivity();
121
- syncRelayStateFromDisk();
122
- void seedCatalog();
123
-
124
- // Retire only when zero leases persist past the grace window with nothing
125
- // in flight — never for request-idleness.
126
- startLeaseGC({
127
- ttlMs: DAEMON_TTL_MS,
128
- gcMs: DAEMON_GC_MS,
129
- graceMs: DAEMON_GRACE_MS,
130
- getActiveRequests,
131
- onIdle: () => retire("no clients"),
132
- });
133
-
134
- logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
135
- }
136
-
137
- // Entry guard: run only when executed as the entry script. When imported by
138
- // tests, isDaemonMain() is false and no port is bound.
139
- if (isDaemonMain()) {
140
- runDaemon().catch((e) => {
141
- log("error", "daemon crashed", { error: String(e) });
142
- process.exit(1);
143
- });
144
- }
1
+ /**
2
+ * Detached proxy daemon entry for pi-freeflow.
3
+ *
4
+ * Spawned as a separate OS process by src/client.ts so the local proxy survives
5
+ * the OMP/Pi session that started it. Owns port 28180, serves the proxy plus
6
+ * the client lease/control endpoints, and retires itself once no client holds
7
+ * a live lease (request-idleness alone never retires it).
8
+ *
9
+ * Run directly: `node --experimental-strip-types src/daemon.ts` (or `bun src/daemon.ts`).
10
+ */
11
+
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ DAEMON_GC_MS,
16
+ DAEMON_GRACE_MS,
17
+ DAEMON_TTL_MS,
18
+ HOST,
19
+ PKG_VERSION,
20
+ PORT,
21
+ } from "./config.ts";
22
+ import { setShutdownShouldExit } from "./proxy.ts";
23
+ import {
24
+ getAliveCatalog,
25
+ refreshCatalog,
26
+ setAliveCatalog,
27
+ } from "./catalog.ts";
28
+ import { log, logInfo, logWarn } from "./logger.ts";
29
+ import {
30
+ startLeaseGC,
31
+ stopLeaseGC,
32
+ touchActivity,
33
+ } from "./lease.ts";
34
+ import { getActiveRequests, startProxy } from "./proxy.ts";
35
+ import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
36
+
37
+ /** True when this module was run as the entry script (not imported by tests). */
38
+ export function isDaemonMain(): boolean {
39
+ try {
40
+ const entry = process.argv[1];
41
+ if (!entry) return false;
42
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Load the on-disk relay state into this process's in-memory cache so routing
50
+ * uses the latest client edits without a daemon restart. Non-fatal on failure.
51
+ */
52
+ export function syncRelayStateFromDisk(): void {
53
+ try {
54
+ setActiveRelayState(loadRelayState(), false);
55
+ } catch (e) {
56
+ log("warn", "daemon failed to load relay state from disk", { error: String(e) });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Serve the static+disk catalog and refresh it in the background, mirroring
62
+ * what the host session did in-process. Keeps /v1/models correct after the
63
+ * 24h cache TTL without requiring a daemon restart.
64
+ */
65
+ export async function seedCatalog(): Promise<void> {
66
+ try {
67
+ const fresh = await refreshCatalog(false);
68
+ if (fresh.length > 0) {
69
+ setAliveCatalog(fresh);
70
+ }
71
+ } catch (e) {
72
+ logWarn("daemon catalog refresh failed; retaining static catalog", {
73
+ error: String(e),
74
+ });
75
+ }
76
+ logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
77
+ }
78
+
79
+ /**
80
+ * Boot the detached proxy daemon. Binds the port, seeds catalog + relay state,
81
+ * starts the lease GC, and installs signal handlers so the daemon exits
82
+ * cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
83
+ */
84
+ export async function runDaemon(): Promise<void> {
85
+ let server: { close(): void } | null = null;
86
+ const retire = (why: string): void => {
87
+ logInfo(`daemon retiring: ${why}`);
88
+ stopLeaseGC();
89
+ try {
90
+ server?.close();
91
+ } catch { }
92
+ process.exit(0);
93
+ };
94
+ process.on("SIGTERM", () => retire("SIGTERM"));
95
+ process.on("SIGINT", () => retire("SIGINT"));
96
+ process.on("uncaughtException", (err) => {
97
+ log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack, activeRequests: getActiveRequests() });
98
+ });
99
+ process.on("unhandledRejection", (reason) => {
100
+ log("error", "daemon unhandledRejection", { error: String(reason), activeRequests: getActiveRequests() });
101
+ });
102
+ setShutdownShouldExit(true);
103
+ let boundPort = PORT;
104
+ try {
105
+ const r = await startProxy();
106
+ if (!r.server) {
107
+ // Port was taken by another (possibly newer) daemon — the parent
108
+ // attaches to the winner; this spawn exits quietly.
109
+ logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
110
+ process.exit(0);
111
+ }
112
+ server = r.server;
113
+ boundPort = r.port;
114
+ } catch (e) {
115
+ log("error", "daemon failed to bind proxy port", { error: String(e) });
116
+ process.exit(1);
117
+ }
118
+
119
+ // lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
120
+ // retirement no longer reads it — only the zero-lease persistence window
121
+ // gates the GC, so a fresh spawn always survives its re-attach window.
122
+ touchActivity();
123
+ syncRelayStateFromDisk();
124
+ void seedCatalog();
125
+
126
+ // Retire only when zero leases persist past the grace window with nothing
127
+ // in flight — never for request-idleness.
128
+ startLeaseGC({
129
+ ttlMs: DAEMON_TTL_MS,
130
+ gcMs: DAEMON_GC_MS,
131
+ graceMs: DAEMON_GRACE_MS,
132
+ getActiveRequests,
133
+ onIdle: () => retire("no clients"),
134
+ });
135
+
136
+ logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${boundPort}`);
137
+ }
138
+
139
+ // Entry guard: run only when executed as the entry script. When imported by
140
+ // tests, isDaemonMain() is false and no port is bound.
141
+ if (isDaemonMain()) {
142
+ runDaemon().catch((e) => {
143
+ log("error", "daemon crashed", { error: String(e) });
144
+ process.exit(1);
145
+ });
146
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 35 free models (11 OpenCode Zen + 14 KiloCode Gateway + 2 Cline + 8 OVH) with:
4
+ * Provides access to the free-model catalog (OpenCode Zen, KiloCode Gateway, Cline, OVH) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
@@ -273,7 +273,7 @@ export default async function(pi: ExtensionAPI): Promise<void> {
273
273
  ? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
274
274
  : "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
275
275
  ctx.ui?.notify?.(
276
- `freeflow ready: ${ALL_MODELS.length} free models via local proxy 127.0.0.1:28180. ${hint}`,
276
+ `freeflow ready: ${ALL_MODELS.length} free models via local proxy ${HOST}:${actualPort}. ${hint}`,
277
277
  "info",
278
278
  );
279
279
  }