pi-freeflow 1.4.11 → 1.5.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/package.json +11 -3
- package/src/catalog.ts +81 -7
- package/src/commands.ts +172 -128
- package/src/health.ts +106 -0
- package/src/index.ts +57 -1
- package/src/models.ts +58 -64
- package/src/proxy.ts +66 -65
- package/src/relay-state.ts +19 -9
- package/src/relay.ts +23 -12
- package/src/types.ts +172 -165
- package/src/update-checker.ts +12 -18
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -45,11 +45,19 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
|
-
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
48
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
|
|
49
|
+
"changeset": "changeset",
|
|
50
|
+
"docs:dev": "vitepress dev docs",
|
|
51
|
+
"docs:build": "vitepress build docs"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
54
|
+
"@changesets/cli": "^2.27.0",
|
|
51
55
|
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
52
56
|
"@types/node": "^22.13.9",
|
|
53
|
-
"typescript": "^5.8.2"
|
|
57
|
+
"typescript": "^5.8.2",
|
|
58
|
+
"vitepress": "^1.6.4"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"undici": "^8.10.0"
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -34,8 +34,7 @@ import type {
|
|
|
34
34
|
/**
|
|
35
35
|
* Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
|
|
36
36
|
*/
|
|
37
|
-
const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
|
|
38
|
-
|
|
37
|
+
export const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
|
|
39
38
|
/**
|
|
40
39
|
* In-memory cache of currently active/available free models.
|
|
41
40
|
* Initialized with all 21 verified models for 0ms instant availability.
|
|
@@ -170,6 +169,7 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
|
|
|
170
169
|
/**
|
|
171
170
|
* Read cached catalog data from disk if valid and unexpired.
|
|
172
171
|
* Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
|
|
172
|
+
* Preserves etag for conditional If-None-Match requests.
|
|
173
173
|
*/
|
|
174
174
|
export function readCatalogCache(): CatalogCacheData | null {
|
|
175
175
|
try {
|
|
@@ -192,6 +192,7 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
194
|
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
195
|
+
* Persists etag alongside models for subsequent If-None-Match conditional requests.
|
|
195
196
|
*/
|
|
196
197
|
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
197
198
|
try {
|
|
@@ -209,12 +210,13 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
209
210
|
|
|
210
211
|
/**
|
|
211
212
|
* Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
|
|
212
|
-
*
|
|
213
|
+
* Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
|
|
214
|
+
* catalogs; a 304 Not Modified response skips merge and returns the in-memory
|
|
215
|
+
* catalog unchanged. Falls back gracefully to cached or static models if network
|
|
216
|
+
* requests fail.
|
|
213
217
|
*/
|
|
214
218
|
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
215
|
-
// Thin provider:
|
|
216
|
-
// (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
|
|
217
|
-
// We only serve disk cache if fresh, otherwise static 21-model aliveCatalog.
|
|
219
|
+
// Thin provider: serve fresh disk cache instantly when valid
|
|
218
220
|
const disk = readCatalogCache();
|
|
219
221
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
220
222
|
const age = Date.now() - (disk.timestamp ?? 0);
|
|
@@ -222,7 +224,79 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
222
224
|
aliveCatalog = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
223
225
|
return aliveCatalog;
|
|
224
226
|
}
|
|
225
|
-
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resolve etag from fresh or stale cache for conditional request
|
|
230
|
+
let cachedEtag: string | undefined = disk?.etag;
|
|
231
|
+
let staleForEtag: CatalogCacheData | null = disk;
|
|
232
|
+
if (!cachedEtag) {
|
|
233
|
+
try {
|
|
234
|
+
if (fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
235
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
236
|
+
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
237
|
+
cachedEtag = stale.etag;
|
|
238
|
+
staleForEtag = stale;
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Attempt conditional fetch with If-None-Match when we have an etag
|
|
246
|
+
if (cachedEtag || force) {
|
|
247
|
+
try {
|
|
248
|
+
const headers: Record<string, string> = { ...opencodeHeaders() };
|
|
249
|
+
if (cachedEtag) {
|
|
250
|
+
headers["If-None-Match"] = cachedEtag;
|
|
251
|
+
}
|
|
252
|
+
const res = await fetch(`${OPENCODE_API_URL}/models`, { headers });
|
|
253
|
+
if (res.status === 304) {
|
|
254
|
+
// Not modified — skip merge, extend timestamp to avoid tight loop
|
|
255
|
+
if (staleForEtag && Array.isArray(staleForEtag.models)) {
|
|
256
|
+
try {
|
|
257
|
+
writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
return aliveCatalog;
|
|
261
|
+
}
|
|
262
|
+
if (res.ok) {
|
|
263
|
+
const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
|
|
264
|
+
const body: unknown = await res.json();
|
|
265
|
+
let rawList: RawModelItem[] = [];
|
|
266
|
+
if (Array.isArray(body)) {
|
|
267
|
+
rawList = body as RawModelItem[];
|
|
268
|
+
} else if (body !== null && typeof body === "object" && "data" in body) {
|
|
269
|
+
const dataVal = body.data as unknown;
|
|
270
|
+
if (Array.isArray(dataVal)) {
|
|
271
|
+
rawList = dataVal as RawModelItem[];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (rawList.length > 0) {
|
|
275
|
+
const fresh = rawList.map((r) => enrichModelDef(r, "opencode"));
|
|
276
|
+
const merged = mergeCatalog(aliveCatalog, fresh);
|
|
277
|
+
aliveCatalog = merged;
|
|
278
|
+
writeCatalogCache({
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
opencode: fresh.map((m) => m.id),
|
|
281
|
+
kilo: staleForEtag?.kilo ?? [],
|
|
282
|
+
models: merged,
|
|
283
|
+
etag: newEtag ?? cachedEtag,
|
|
284
|
+
});
|
|
285
|
+
return aliveCatalog;
|
|
286
|
+
}
|
|
287
|
+
// Empty payload but 200 — treat as no-op, return current
|
|
288
|
+
if (newEtag && newEtag !== cachedEtag && staleForEtag) {
|
|
289
|
+
writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
|
|
290
|
+
}
|
|
291
|
+
return aliveCatalog;
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Stale cache still better than empty — return it without network (filtered)
|
|
299
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
226
300
|
const filtered = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
227
301
|
if (filtered.length >= 21) {
|
|
228
302
|
aliveCatalog = filtered;
|
package/src/commands.ts
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
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 { 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,
|
|
18
18
|
} from "./update-checker.ts";
|
|
19
19
|
import {
|
|
20
20
|
deployCloudflareWorker,
|
|
@@ -60,6 +60,10 @@ import type {
|
|
|
60
60
|
export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
61
61
|
if (!ui) return;
|
|
62
62
|
const relayState = getActiveRelayState();
|
|
63
|
+
if (relayState.hideWidget) {
|
|
64
|
+
ui.setStatus("freeflow", undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
63
67
|
if (relayState.enabled && relayState.relays.length > 0) {
|
|
64
68
|
const label = shortRelayLabel(relayState.url);
|
|
65
69
|
const idx = Math.max(
|
|
@@ -76,64 +80,68 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
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
|
-
|
|
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
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spawnWithProgress(
|
|
99
|
+
cmd: string,
|
|
100
|
+
args: string[],
|
|
101
|
+
ctx: ExtensionContext,
|
|
102
|
+
): Promise<number> {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
try {
|
|
105
|
+
const child = spawn(cmd, args, {
|
|
106
|
+
shell: process.platform === "win32",
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
});
|
|
109
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
110
|
+
const s = String(d).trim();
|
|
111
|
+
if (s) ctx.ui.notify(s, "info");
|
|
112
|
+
});
|
|
113
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
114
|
+
const s = String(d).trim();
|
|
115
|
+
if (s) ctx.ui.notify(s, "info");
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err: Error) => {
|
|
118
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
119
|
+
resolve(1);
|
|
120
|
+
});
|
|
121
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
122
|
+
} catch (e) {
|
|
123
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
124
|
+
resolve(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
export function createCommandSpec(
|
|
126
130
|
_pi: ExtensionAPI,
|
|
127
131
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
128
132
|
): Omit<RegisteredCommand, "name"> {
|
|
129
133
|
return {
|
|
130
134
|
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",
|
|
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",
|
|
132
136
|
getArgumentCompletions: (prefix: string) =>
|
|
133
137
|
[
|
|
134
138
|
"auto",
|
|
135
139
|
"on",
|
|
136
140
|
"off",
|
|
141
|
+
"hide",
|
|
142
|
+
"show",
|
|
143
|
+
"widget hide",
|
|
144
|
+
"widget show",
|
|
137
145
|
"status",
|
|
138
146
|
"add",
|
|
139
147
|
"list",
|
|
@@ -402,73 +410,83 @@ export function createCommandSpec(
|
|
|
402
410
|
setActiveRelayState(relayState);
|
|
403
411
|
persist();
|
|
404
412
|
flash();
|
|
405
|
-
} else if (sub === "
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
413
|
+
} else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
|
|
414
|
+
relayState.hideWidget = true;
|
|
415
|
+
persist();
|
|
416
|
+
updateStatusBar(ctx.ui);
|
|
417
|
+
ctx.ui.notify("Widget hidden — use /freeflow show or /freeflow widget show to restore", "info");
|
|
418
|
+
} else if (sub === "show" || (sub === "widget" && rest === "show")) {
|
|
419
|
+
relayState.hideWidget = false;
|
|
420
|
+
persist();
|
|
421
|
+
flash();
|
|
422
|
+
ctx.ui.notify("Widget shown", "info");
|
|
423
|
+
} else if (sub === "status") {
|
|
424
|
+
flash();
|
|
425
|
+
try {
|
|
426
|
+
const local = getLocalVersion();
|
|
427
|
+
let latest: string | null = null;
|
|
428
|
+
const cached = getCachedUpdate();
|
|
429
|
+
if (cached && typeof cached.latest === "string") {
|
|
430
|
+
latest = cached.latest;
|
|
431
|
+
}
|
|
432
|
+
if (latest && compareVersions(latest, local) > 0) {
|
|
433
|
+
ctx.ui.notify(
|
|
434
|
+
`Update available: ${local} -> ${latest} - run /freeflow update`,
|
|
435
|
+
"info",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// swallow — status banner is best-effort
|
|
440
|
+
}
|
|
441
|
+
} else if (sub === "update") {
|
|
442
|
+
if (isLinkedInstall()) {
|
|
443
|
+
ctx.ui.notify(
|
|
444
|
+
"LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
|
|
445
|
+
"info",
|
|
446
|
+
);
|
|
447
|
+
} else {
|
|
448
|
+
try {
|
|
449
|
+
ctx.ui.notify("Checking for updates…", "info");
|
|
450
|
+
let latest: string | null = null;
|
|
451
|
+
try {
|
|
452
|
+
latest = await fetchLatestVersion();
|
|
453
|
+
} catch {
|
|
454
|
+
latest = null;
|
|
455
|
+
}
|
|
456
|
+
if (!latest) {
|
|
457
|
+
const cached = getCachedUpdate();
|
|
458
|
+
if (cached && typeof cached.latest === "string") {
|
|
459
|
+
latest = cached.latest;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!latest) {
|
|
463
|
+
ctx.ui.notify("Could not check latest version (offline?)", "warning");
|
|
464
|
+
} else {
|
|
465
|
+
const local = getLocalVersion();
|
|
466
|
+
const cmp = compareVersions(latest, local);
|
|
467
|
+
if (cmp <= 0) {
|
|
468
|
+
ctx.ui.notify(`Already on latest (v${local})`, "info");
|
|
469
|
+
} else {
|
|
470
|
+
ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
|
|
471
|
+
let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
|
|
472
|
+
if (code !== 0) {
|
|
473
|
+
ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
|
|
474
|
+
code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
|
|
475
|
+
}
|
|
476
|
+
if (code === 0) {
|
|
477
|
+
ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
|
|
478
|
+
} else {
|
|
479
|
+
ctx.ui.notify(
|
|
480
|
+
`Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
|
|
481
|
+
"warning",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch (e) {
|
|
487
|
+
ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
472
490
|
} else if (sub === "list") {
|
|
473
491
|
showList();
|
|
474
492
|
} else if (sub === "add") {
|
|
@@ -611,12 +629,14 @@ export function createCommandSpec(
|
|
|
611
629
|
let filterLevel: LogLevel | null = null;
|
|
612
630
|
let filterReqId: string | null = null;
|
|
613
631
|
let count = 25;
|
|
632
|
+
const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
|
|
633
|
+
const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
|
|
614
634
|
|
|
615
635
|
if (sub === "trace" && rawRest) {
|
|
616
|
-
filterReqId =
|
|
636
|
+
filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
|
|
617
637
|
} else if (rawRest) {
|
|
618
|
-
const
|
|
619
|
-
|
|
638
|
+
for (const t of rawTokens) {
|
|
639
|
+
if (t === "--follow" || t === "-f") continue;
|
|
620
640
|
const lower = t.toLowerCase();
|
|
621
641
|
if (lower in LOG_LEVEL_ORDER) {
|
|
622
642
|
filterLevel = lower as LogLevel;
|
|
@@ -655,6 +675,18 @@ export function createCommandSpec(
|
|
|
655
675
|
"warning",
|
|
656
676
|
);
|
|
657
677
|
}
|
|
678
|
+
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?.();
|
|
689
|
+
}
|
|
658
690
|
return;
|
|
659
691
|
}
|
|
660
692
|
|
|
@@ -663,6 +695,18 @@ export function createCommandSpec(
|
|
|
663
695
|
`${header}\n\n${result.lines.join("\n")}`,
|
|
664
696
|
"info",
|
|
665
697
|
);
|
|
698
|
+
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?.();
|
|
709
|
+
}
|
|
666
710
|
} catch (e) {
|
|
667
711
|
ctx.ui.notify(
|
|
668
712
|
`Could not read log file: ${(e as Error).message}`,
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health endpoint for pi-freeflow proxy
|
|
3
|
+
* Loopback-only GET /_health (and alias /health) returning relay health snapshot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type * as http from "node:http";
|
|
7
|
+
import { ALL_MODELS } from "./models.ts";
|
|
8
|
+
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
+
import { PORT } from "./config.ts";
|
|
10
|
+
|
|
11
|
+
export interface HealthRelayInfo {
|
|
12
|
+
url: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
healthy: boolean;
|
|
15
|
+
cooldownUntil: number;
|
|
16
|
+
consecutiveFailures: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HealthData {
|
|
20
|
+
port: number;
|
|
21
|
+
active: string;
|
|
22
|
+
mode: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
relays: HealthRelayInfo[];
|
|
25
|
+
catalog: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collect current health snapshot.
|
|
30
|
+
* @param portOverride - actual listening port (defaults to config PORT)
|
|
31
|
+
*/
|
|
32
|
+
export function getHealthData(portOverride?: number): HealthData {
|
|
33
|
+
const state = getActiveRelayState();
|
|
34
|
+
const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
|
|
35
|
+
const h = getRelayHealth(r.url);
|
|
36
|
+
const healthy = isRelayHealthy(r.url);
|
|
37
|
+
return {
|
|
38
|
+
url: r.url,
|
|
39
|
+
label: r.label,
|
|
40
|
+
healthy,
|
|
41
|
+
cooldownUntil: h?.cooldownUntil ?? 0,
|
|
42
|
+
consecutiveFailures: h?.consecutiveFailures ?? 0,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
port: portOverride ?? PORT,
|
|
47
|
+
active: state.url || "",
|
|
48
|
+
mode: (state.mode as string) ?? "auto",
|
|
49
|
+
enabled: Boolean(state.enabled),
|
|
50
|
+
relays,
|
|
51
|
+
catalog: ALL_MODELS.length,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLoopbackIP(ip: string): boolean {
|
|
56
|
+
if (!ip) return false;
|
|
57
|
+
const clean = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
58
|
+
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getClientIPFromReq(req: http.IncomingMessage): string {
|
|
62
|
+
const addr = (req.socket as unknown as { remoteAddress?: string })?.remoteAddress;
|
|
63
|
+
if (!addr) return "unknown";
|
|
64
|
+
return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle loopback health requests.
|
|
69
|
+
* Returns true if request was a health endpoint (handled, response already sent).
|
|
70
|
+
* Returns false if not a health path (caller should continue).
|
|
71
|
+
*/
|
|
72
|
+
export function handleHealthRequest(
|
|
73
|
+
req: http.IncomingMessage,
|
|
74
|
+
res: http.ServerResponse,
|
|
75
|
+
portOverride?: number,
|
|
76
|
+
): boolean {
|
|
77
|
+
let pathname: string | null = null;
|
|
78
|
+
try {
|
|
79
|
+
pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isHealthPath =
|
|
85
|
+
pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
|
|
86
|
+
if (req.method !== "GET" || !isHealthPath) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const clientIP = getClientIPFromReq(req);
|
|
91
|
+
if (!isLoopbackIP(clientIP)) {
|
|
92
|
+
const body = JSON.stringify({ error: "forbidden" });
|
|
93
|
+
res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
94
|
+
res.end(body);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = getHealthData(portOverride);
|
|
99
|
+
const body = JSON.stringify(data);
|
|
100
|
+
res.writeHead(200, {
|
|
101
|
+
"content-type": "application/json",
|
|
102
|
+
"content-length": Buffer.byteLength(body),
|
|
103
|
+
});
|
|
104
|
+
res.end(body);
|
|
105
|
+
return true;
|
|
106
|
+
}
|