pi-freeflow 1.11.0 → 1.11.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 549b66e: Oversized requests no longer fail at the relay hop: when a relay answers 413 payload limit, the proxy transparently tries the next relay and then the direct route, keeping the stream alive. Long sessions that outgrow the relay payload cap now complete instead of surfacing a function payload error.
8
+
9
+ ## 1.11.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+ - 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.
15
+ - Added unhandled error logging inside the daemon to prevent silent process exits.
16
+ - 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`.
17
+
3
18
  ## 1.11.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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.11.0",
4
+ "version": "1.11.2",
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
@@ -349,6 +349,14 @@ export async function watchdogCheck(port: number): Promise<void> {
349
349
  } catch {
350
350
  return;
351
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
+ }
352
360
  if (health && (health.activeRequests ?? 0) > 0) {
353
361
  trackBusyEdge(health.activeRequests ?? 0, health.lastBytesAt ?? 0);
354
362
  } else {
@@ -566,6 +574,15 @@ export function getClientId(): string {
566
574
  return CLIENT_ID;
567
575
  }
568
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
+
569
586
  export function _resetClientForTest(): void {
570
587
  stopHeartbeatInternal();
571
588
  heartbeatPort = 0;
package/src/daemon.ts CHANGED
@@ -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();
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,116 +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 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 = Date.now();
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 = Date.now();
114
- emptySince = null;
115
- stopLeaseGC();
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 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();
package/src/relay.ts CHANGED
@@ -155,6 +155,28 @@ export async function relayFetch(
155
155
  }
156
156
  break;
157
157
  }
158
+ // Relay payload cap hit (413: request exceeds host payload limit):
159
+ // Not a relay health signal, so no failure marking — try the next
160
+ // relay (a different host may accept it), else the direct fallback.
161
+ if (res.status === 413) {
162
+ lastResponse?.body?.cancel().catch(() => {});
163
+ lastResponse = res;
164
+ log(
165
+ "warn",
166
+ `relay ${targetUrl} hit HTTP 413 payload limit in ${elapsed}s — trying next path`,
167
+ { upstream: url, sizeKB: bodySizeKB },
168
+ rid,
169
+ );
170
+ const now = Date.now();
171
+ if (now - lastRollNotify > ROLL_NOTIFY_MS) {
172
+ lastRollNotify = now;
173
+ const ui = getStatusUi();
174
+ if (ui?.notify) {
175
+ ui.notify(`relay ${shortRelayLabel(targetUrl)} hit payload limit — trying next path`, "warning");
176
+ }
177
+ }
178
+ continue;
179
+ }
158
180
 
159
181
  // Relay host infrastructure 404 (e.g. Vercel DEPLOYMENT_NOT_FOUND or non-JSON 404):
160
182
  // When a relay URL is deleted, misconfigured, or has no deployment, Vercel/Cloudflare