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/CHANGELOG.md +17 -0
- package/README.md +13 -14
- package/package.json +2 -1
- package/src/catalog.ts +23 -7
- package/src/client.ts +102 -21
- package/src/cline-accounts.ts +41 -5
- package/src/commands.ts +89 -61
- package/src/daemon.ts +146 -144
- package/src/index.ts +2 -2
- package/src/logger.ts +491 -367
- package/src/models.ts +143 -43
- package/src/opencode-fingerprint.ts +2 -1
- package/src/proxy.ts +5 -0
- package/src/relay-state.ts +1 -1
- package/src/relay.ts +32 -0
- package/src/stream-pipe.ts +188 -45
- package/src/tool-args.ts +51 -14
- package/src/tool-translation.ts +48 -20
- package/src/updater.ts +158 -155
- package/src/upstream-health.ts +32 -1
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:
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
-
|
|
349
|
+
const follower = createLogFollower({
|
|
350
|
+
level: filterLevel,
|
|
351
|
+
reqId: filterReqId,
|
|
352
|
+
text: filterText,
|
|
353
|
+
});
|
|
348
354
|
logsFollowTimer = setInterval(() => {
|
|
349
355
|
try {
|
|
350
|
-
const
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
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
|
|
1295
|
+
code = await updateStepRunner(plan.cmd, plan.args, ctx);
|
|
1275
1296
|
}
|
|
1276
1297
|
}
|
|
1277
1298
|
if (code === 0) {
|
|
1278
|
-
|
|
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
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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: ${
|
|
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
|
-
|
|
1854
|
-
s
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
276
|
+
`freeflow ready: ${ALL_MODELS.length} free models via local proxy ${HOST}:${actualPort}. ${hint}`,
|
|
277
277
|
"info",
|
|
278
278
|
);
|
|
279
279
|
}
|