pi-freeflow 1.10.0 → 1.11.1
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 +15 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/client.ts +19 -1
- package/src/config.ts +1 -1
- package/src/daemon.ts +13 -4
- package/src/index.ts +9 -2
- package/src/lease.ts +116 -108
- package/src/logger.ts +10 -2
- package/src/proxy.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Apologies for the daemon disconnect bug introduced in v1.11.0: in multi-agent workflows or when subagents, evaluations, and background tasks completed, the extension prematurely detached from the local proxy daemon, causing the proxy to shut down while your main session remained active. The heartbeat connection now persists throughout your active terminal session.
|
|
8
|
+
- Increased local health check and liveness probe timeouts from 800ms and 500ms to 2500ms and 1500ms, with an automatic probe retry to avoid false-alarm daemon restarts during heavy concurrent streaming.
|
|
9
|
+
- Added unhandled error logging inside the daemon to prevent silent process exits.
|
|
10
|
+
- Fresh installs now log full HTTP lifecycle debug output by default so diagnostic reports contain complete request context; turn it off anytime with `/freeflow debug off`.
|
|
11
|
+
|
|
12
|
+
## 1.11.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- Proxy daemon now stays up while any session uses it, even when idle. It shuts down only after the last session leaves, instead of retiring after a short quiet window.
|
|
17
|
+
|
|
3
18
|
## 1.10.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -250,7 +250,7 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 20
|
|
|
250
250
|
/freeflow logs
|
|
251
251
|
cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
252
252
|
|
|
253
|
-
# debug toggle
|
|
253
|
+
# debug toggle (full debug is on by default for complete error reports; `off` restores info)
|
|
254
254
|
/freeflow debug on
|
|
255
255
|
```
|
|
256
256
|
|
|
@@ -290,7 +290,7 @@ to `1` before starting a session.
|
|
|
290
290
|
Nothing visible to your other sessions. The proxy daemon is a separate background
|
|
291
291
|
process shared by every OMP/Pi session on the machine. Closing one session just
|
|
292
292
|
unregisters it; the daemon keeps serving the rest and retires itself automatically
|
|
293
|
-
once the last client disconnects and
|
|
293
|
+
once the last client disconnects and no client re-attaches within a short grace window.
|
|
294
294
|
To stop it manually, run `/freeflow kill` — the next freeflow use starts it again.
|
|
295
295
|
|
|
296
296
|
**Where's the normalizer?**
|
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.11.1",
|
|
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",
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
6
|
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
7
|
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
-
* once no client holds a live lease
|
|
8
|
+
* once no client holds a live lease for a grace window with nothing in flight
|
|
9
|
+
* (request-idleness alone never retires it).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { randomUUID } from "node:crypto";
|
|
@@ -348,6 +349,14 @@ export async function watchdogCheck(port: number): Promise<void> {
|
|
|
348
349
|
} catch {
|
|
349
350
|
return;
|
|
350
351
|
}
|
|
352
|
+
if (health === null) {
|
|
353
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
354
|
+
try {
|
|
355
|
+
health = await getDaemonHealth(port);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
351
360
|
if (health && (health.activeRequests ?? 0) > 0) {
|
|
352
361
|
trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
|
|
353
362
|
} else {
|
|
@@ -565,6 +574,15 @@ export function getClientId(): string {
|
|
|
565
574
|
return CLIENT_ID;
|
|
566
575
|
}
|
|
567
576
|
|
|
577
|
+
export function hasFallbackServer(): boolean {
|
|
578
|
+
return fallbackServer !== null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Test seam: true when client heartbeat timer is active. */
|
|
582
|
+
export function isHeartbeatActive(): boolean {
|
|
583
|
+
return heartbeatTimer !== null;
|
|
584
|
+
}
|
|
585
|
+
|
|
568
586
|
export function _resetClientForTest(): void {
|
|
569
587
|
stopHeartbeatInternal();
|
|
570
588
|
heartbeatPort = 0;
|
package/src/config.ts
CHANGED
|
@@ -206,7 +206,7 @@ export const DAEMON_TTL_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_TTL_
|
|
|
206
206
|
export const DAEMON_HEARTBEAT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_HEARTBEAT_MS");
|
|
207
207
|
/** Daemon GC sweep interval (ms). */
|
|
208
208
|
export const DAEMON_GC_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GC_MS");
|
|
209
|
-
/**
|
|
209
|
+
/** Zero-lease persistence window before a lease-less daemon exits (ms). */
|
|
210
210
|
export const DAEMON_GRACE_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GRACE_MS");
|
|
211
211
|
/** Max time a client waits for a freshly spawned daemon to answer /_health (ms). */
|
|
212
212
|
export const DAEMON_READY_TIMEOUT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_READY_TIMEOUT_MS");
|
package/src/daemon.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Spawned as a separate OS process by src/client.ts so the local proxy survives
|
|
5
5
|
* the OMP/Pi session that started it. Owns port 28180, serves the proxy plus
|
|
6
6
|
* the client lease/control endpoints, and retires itself once no client holds
|
|
7
|
-
* a live lease
|
|
7
|
+
* a live lease (request-idleness alone never retires it).
|
|
8
8
|
*
|
|
9
9
|
* Run directly: `node --experimental-strip-types src/daemon.ts` (or `bun src/daemon.ts`).
|
|
10
10
|
*/
|
|
@@ -98,6 +98,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
98
98
|
};
|
|
99
99
|
process.on("SIGTERM", () => retire("SIGTERM"));
|
|
100
100
|
process.on("SIGINT", () => retire("SIGINT"));
|
|
101
|
+
process.on("uncaughtException", (err) => {
|
|
102
|
+
log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack });
|
|
103
|
+
});
|
|
104
|
+
process.on("unhandledRejection", (reason) => {
|
|
105
|
+
log("error", "daemon unhandledRejection", { error: String(reason) });
|
|
106
|
+
});
|
|
101
107
|
setShutdownShouldExit(true);
|
|
102
108
|
try {
|
|
103
109
|
const r = await startProxy();
|
|
@@ -113,18 +119,21 @@ export async function runDaemon(): Promise<void> {
|
|
|
113
119
|
process.exit(1);
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
// lastActivityAt is
|
|
117
|
-
//
|
|
122
|
+
// lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
|
|
123
|
+
// retirement no longer reads it — only the zero-lease persistence window
|
|
124
|
+
// gates the GC, so a fresh spawn always survives its re-attach window.
|
|
118
125
|
touchActivity();
|
|
119
126
|
syncRelayStateFromDisk();
|
|
120
127
|
void seedCatalog();
|
|
121
128
|
|
|
129
|
+
// Retire only when zero leases persist past the grace window with nothing
|
|
130
|
+
// in flight — never for request-idleness.
|
|
122
131
|
startLeaseGC({
|
|
123
132
|
ttlMs: DAEMON_TTL_MS,
|
|
124
133
|
gcMs: DAEMON_GC_MS,
|
|
125
134
|
graceMs: DAEMON_GRACE_MS,
|
|
126
135
|
getActiveRequests,
|
|
127
|
-
onIdle: () => retire("no clients
|
|
136
|
+
onIdle: () => retire("no clients"),
|
|
128
137
|
});
|
|
129
138
|
|
|
130
139
|
logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
refreshCatalog,
|
|
17
17
|
setAliveCatalog,
|
|
18
18
|
} from "./catalog.ts";
|
|
19
|
-
import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
|
|
19
|
+
import { ensureDaemon as ensureClientDaemon, getClientPort, hasFallbackServer, stopHeartbeat } from "./client.ts";
|
|
20
20
|
import { createCommandSpec, stopLogsFollow, updateStatusBar } from "./commands.ts";
|
|
21
21
|
import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
22
22
|
import { logInfo, logWarn } from "./logger.ts";
|
|
@@ -328,7 +328,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
328
328
|
}
|
|
329
329
|
});
|
|
330
330
|
pi.on?.("session_shutdown", () => {
|
|
331
|
-
stopHeartbeat();
|
|
332
331
|
stopLogsFollow();
|
|
332
|
+
if (hasFallbackServer()) {
|
|
333
|
+
stopHeartbeat();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
process.once("exit", () => {
|
|
337
|
+
try {
|
|
338
|
+
stopHeartbeat();
|
|
339
|
+
} catch {}
|
|
333
340
|
});
|
|
334
341
|
}
|
package/src/lease.ts
CHANGED
|
@@ -1,108 +1,116 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client lease registry for the detached pi-freeflow proxy daemon.
|
|
3
|
-
*
|
|
4
|
-
* Each OMP/Pi session is a client that registers a lease and renews it with a
|
|
5
|
-
* heartbeat while alive. The daemon drops expired leases and
|
|
6
|
-
* holds a live lease
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Client lease registry for the detached pi-freeflow proxy daemon.
|
|
3
|
+
*
|
|
4
|
+
* Each OMP/Pi session is a client that registers a lease and renews it with a
|
|
5
|
+
* heartbeat while alive. The daemon drops expired leases and retires itself
|
|
6
|
+
* once NO client holds a live lease: the empty state must persist for the
|
|
7
|
+
* grace window (a fresh spawn's clients re-attach within seconds) with
|
|
8
|
+
* nothing in flight. Request-idleness alone NEVER retires the daemon.
|
|
9
|
+
*
|
|
10
|
+
* The request-touch (`lastActivityAt`) is still recorded and surfaced in
|
|
11
|
+
* /_health for observability, but it no longer gates retirement.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface LeaseOptions {
|
|
15
|
+
/** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
|
|
16
|
+
ttlMs: number;
|
|
17
|
+
/** GC sweep interval (ms). */
|
|
18
|
+
gcMs: number;
|
|
19
|
+
/** Zero-lease persistence window (ms): how long leases must stay empty before a lease-less daemon exits. */
|
|
20
|
+
graceMs: number;
|
|
21
|
+
/** Current in-flight proxied requests — daemon never exits mid-stream. */
|
|
22
|
+
getActiveRequests: () => number;
|
|
23
|
+
/** Called once when the daemon should retire (close server + exit). */
|
|
24
|
+
onIdle: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const leases = new Map<string, number>();
|
|
28
|
+
let lastActivityAt = 0;
|
|
29
|
+
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
/** First sweep timestamp at which leases were observed empty; null while any lease exists. */
|
|
31
|
+
let emptySince: number | null = null;
|
|
32
|
+
|
|
33
|
+
/** Register or refresh a client lease. */
|
|
34
|
+
export function registerClient(clientId: string): void {
|
|
35
|
+
leases.set(clientId, Date.now());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Renew an existing client lease. Returns false when the id is unknown (daemon restarted). */
|
|
39
|
+
export function renewClient(clientId: string): boolean {
|
|
40
|
+
if (leases.has(clientId)) {
|
|
41
|
+
leases.set(clientId, Date.now());
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Remove a client lease (graceful detach on session end). */
|
|
48
|
+
export function unregisterClient(clientId: string): void {
|
|
49
|
+
leases.delete(clientId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Number of clients holding a live lease. */
|
|
53
|
+
export function getLeaseCount(): number {
|
|
54
|
+
return leases.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
|
|
58
|
+
export function getLeaseSnapshot(): Record<string, number> {
|
|
59
|
+
return Object.fromEntries(leases);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Record proxy activity — any proxied request counts as a live user. */
|
|
63
|
+
export function touchActivity(): void {
|
|
64
|
+
lastActivityAt = Date.now();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
|
|
68
|
+
export function getLastActivityAt(): number {
|
|
69
|
+
return lastActivityAt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Start the lease GC sweep. Prunes expired leases and, when no client holds a
|
|
74
|
+
* lease, nothing is in flight, and the lease-less state has persisted for the
|
|
75
|
+
* grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
|
|
76
|
+
* call is a no-op.
|
|
77
|
+
*/
|
|
78
|
+
export function startLeaseGC(opts: LeaseOptions): void {
|
|
79
|
+
if (gcTimer !== null) return;
|
|
80
|
+
gcTimer = setInterval(() => {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
for (const [id, seenAt] of leases) {
|
|
83
|
+
if (now - seenAt > opts.ttlMs) {
|
|
84
|
+
leases.delete(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (leases.size > 0) {
|
|
88
|
+
emptySince = null;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (emptySince === null) {
|
|
92
|
+
emptySince = now;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (opts.getActiveRequests() === 0 && now - emptySince >= opts.graceMs) {
|
|
96
|
+
stopLeaseGC();
|
|
97
|
+
opts.onIdle();
|
|
98
|
+
}
|
|
99
|
+
}, opts.gcMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stop the GC sweep (test teardown / daemon shutdown). */
|
|
103
|
+
export function stopLeaseGC(): void {
|
|
104
|
+
if (gcTimer !== null) {
|
|
105
|
+
clearInterval(gcTimer);
|
|
106
|
+
gcTimer = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Test-only: reset all lease state. */
|
|
111
|
+
export function _resetLeaseStateForTest(): void {
|
|
112
|
+
leases.clear();
|
|
113
|
+
lastActivityAt = 0;
|
|
114
|
+
emptySince = null;
|
|
115
|
+
stopLeaseGC();
|
|
116
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -128,7 +128,8 @@ export function getMinLogLevel(): number {
|
|
|
128
128
|
return LOG_LEVEL_ORDER[dbg.level];
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const rawEnv = process.env.FREEFLOW_LOG_LEVEL;
|
|
132
|
+
const raw = (typeof rawEnv === "string" ? rawEnv : "").toLowerCase();
|
|
132
133
|
|
|
133
134
|
if (isEmittedLogLevel(raw)) {
|
|
134
135
|
return LOG_LEVEL_ORDER[raw as LogLevel];
|
|
@@ -142,7 +143,14 @@ export function getMinLogLevel(): number {
|
|
|
142
143
|
return LOG_LEVEL_ORDER.debug;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
// Fresh installs default to full debug so users can attach complete
|
|
147
|
+
// request lifecycles when reporting errors (10MB rotation bounds volume).
|
|
148
|
+
// An explicit persisted off state or env level above still wins, so
|
|
149
|
+
// /freeflow debug off keeps working on every platform.
|
|
150
|
+
if (dbg !== null) {
|
|
151
|
+
return LOG_LEVEL_ORDER.info;
|
|
152
|
+
}
|
|
153
|
+
return LOG_LEVEL_ORDER.debug;
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
export function shouldLog(level: LogLevel): boolean {
|
package/src/proxy.ts
CHANGED
|
@@ -147,7 +147,7 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
147
147
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
148
148
|
try {
|
|
149
149
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
150
|
-
signal: AbortSignal.timeout(
|
|
150
|
+
signal: AbortSignal.timeout(1500),
|
|
151
151
|
});
|
|
152
152
|
const ct = res.headers.get("content-type") || "";
|
|
153
153
|
return res.ok && ct.includes("application/json");
|
|
@@ -182,7 +182,7 @@ export async function getDaemonHealth(
|
|
|
182
182
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
183
183
|
try {
|
|
184
184
|
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
185
|
-
signal: AbortSignal.timeout(
|
|
185
|
+
signal: AbortSignal.timeout(2500),
|
|
186
186
|
});
|
|
187
187
|
if (!res.ok) return null;
|
|
188
188
|
const data: unknown = await res.json();
|