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 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 it has been idle for a short grace period.
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.10.0",
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 and no request has been proxied recently.
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
- /** Idle grace after the last request before a lease-less daemon exits (ms). */
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 and no request has been proxied recently.
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 initialized AT BIND TIME: a freshly started daemon with
117
- // zero leases must never be GC'd during the parent's readiness-poll window.
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 and idle"),
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, once NO client
6
- * holds a live lease AND no request has been proxied recently, retires itself.
7
- *
8
- * The request-touch (`lastActivityAt`) is the fallback for legacy clients that
9
- * never heartbeated: any proxied request counts as a live user, so the daemon
10
- * is never idle-killed while a session is actually using it.
11
- */
12
-
13
- export interface LeaseOptions {
14
- /** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
15
- ttlMs: number;
16
- /** GC sweep interval (ms). */
17
- gcMs: number;
18
- /** Idle grace after the last proxied request before a lease-less daemon exits (ms). */
19
- graceMs: number;
20
- /** Current in-flight proxied requests — daemon never exits mid-stream. */
21
- getActiveRequests: () => number;
22
- /** Called once when the daemon should retire (close server + exit). */
23
- onIdle: () => void;
24
- }
25
-
26
- const leases = new Map<string, number>();
27
- let lastActivityAt = Date.now();
28
- let gcTimer: ReturnType<typeof setInterval> | null = null;
29
-
30
- /** Register or refresh a client lease. */
31
- export function registerClient(clientId: string): void {
32
- leases.set(clientId, Date.now());
33
- }
34
-
35
- /** Renew an existing client lease. Returns false when the id is unknown (daemon restarted). */
36
- export function renewClient(clientId: string): boolean {
37
- if (leases.has(clientId)) {
38
- leases.set(clientId, Date.now());
39
- return true;
40
- }
41
- return false;
42
- }
43
-
44
- /** Remove a client lease (graceful detach on session end). */
45
- export function unregisterClient(clientId: string): void {
46
- leases.delete(clientId);
47
- }
48
-
49
- /** Number of clients holding a live lease. */
50
- export function getLeaseCount(): number {
51
- return leases.size;
52
- }
53
-
54
- /** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
55
- export function getLeaseSnapshot(): Record<string, number> {
56
- return Object.fromEntries(leases);
57
- }
58
-
59
- /** Record proxy activity — any proxied request counts as a live user. */
60
- export function touchActivity(): void {
61
- lastActivityAt = Date.now();
62
- }
63
-
64
- /** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
65
- export function getLastActivityAt(): number {
66
- return lastActivityAt;
67
- }
68
-
69
- /**
70
- * Start the lease GC sweep. Prunes expired leases and, when no client holds a
71
- * lease, nothing is in flight, and no request has been proxied within the
72
- * grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
73
- * call is a no-op.
74
- */
75
- export function startLeaseGC(opts: LeaseOptions): void {
76
- if (gcTimer !== null) return;
77
- gcTimer = setInterval(() => {
78
- const now = Date.now();
79
- for (const [id, seenAt] of leases) {
80
- if (now - seenAt > opts.ttlMs) {
81
- leases.delete(id);
82
- }
83
- }
84
- if (
85
- leases.size === 0 &&
86
- opts.getActiveRequests() === 0 &&
87
- now - lastActivityAt > opts.graceMs
88
- ) {
89
- stopLeaseGC();
90
- opts.onIdle();
91
- }
92
- }, opts.gcMs);
93
- }
94
-
95
- /** Stop the GC sweep (test teardown / daemon shutdown). */
96
- export function stopLeaseGC(): void {
97
- if (gcTimer !== null) {
98
- clearInterval(gcTimer);
99
- gcTimer = null;
100
- }
101
- }
102
-
103
- /** Test-only: reset all lease state. */
104
- export function _resetLeaseStateForTest(): void {
105
- leases.clear();
106
- lastActivityAt = Date.now();
107
- stopLeaseGC();
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 raw = (process.env.FREEFLOW_LOG_LEVEL || "info").toLowerCase();
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
- return LOG_LEVEL_ORDER.info;
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(500),
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(800),
185
+ signal: AbortSignal.timeout(2500),
186
186
  });
187
187
  if (!res.ok) return null;
188
188
  const data: unknown = await res.json();