pi-freeflow 1.22.2 → 1.23.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/src/daemon.ts CHANGED
@@ -12,38 +12,37 @@
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import {
15
- DAEMON_GC_MS,
16
- DAEMON_GRACE_MS,
17
- DAEMON_TTL_MS,
18
- HOST,
19
- PKG_VERSION,
20
- PORT,
15
+ DAEMON_GC_MS,
16
+ DAEMON_GRACE_MS,
17
+ DAEMON_TTL_MS,
18
+ HOST,
19
+ PKG_VERSION,
20
+ PORT,
21
21
  } from "./config.ts";
22
22
  import { setShutdownShouldExit } from "./proxy.ts";
23
23
  import {
24
- getAliveCatalog,
25
- readCatalogCache,
26
- refreshCatalog,
27
- setAliveCatalog,
24
+ getAliveCatalog,
25
+ refreshCatalog,
26
+ setAliveCatalog,
28
27
  } from "./catalog.ts";
29
28
  import { log, logInfo, logWarn } from "./logger.ts";
30
29
  import {
31
- startLeaseGC,
32
- stopLeaseGC,
33
- touchActivity,
30
+ startLeaseGC,
31
+ stopLeaseGC,
32
+ touchActivity,
34
33
  } from "./lease.ts";
35
34
  import { getActiveRequests, startProxy } from "./proxy.ts";
36
35
  import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
37
36
 
38
37
  /** True when this module was run as the entry script (not imported by tests). */
39
38
  export function isDaemonMain(): boolean {
40
- try {
41
- const entry = process.argv[1];
42
- if (!entry) return false;
43
- return path.resolve(entry) === fileURLToPath(import.meta.url);
44
- } catch {
45
- return false;
46
- }
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
+ }
47
46
  }
48
47
 
49
48
  /**
@@ -51,11 +50,11 @@ export function isDaemonMain(): boolean {
51
50
  * uses the latest client edits without a daemon restart. Non-fatal on failure.
52
51
  */
53
52
  export function syncRelayStateFromDisk(): void {
54
- try {
55
- setActiveRelayState(loadRelayState(), false);
56
- } catch (e) {
57
- log("warn", "daemon failed to load relay state from disk", { error: String(e) });
58
- }
53
+ try {
54
+ setActiveRelayState(loadRelayState(), false);
55
+ } catch (e) {
56
+ log("warn", "daemon failed to load relay state from disk", { error: String(e) });
57
+ }
59
58
  }
60
59
 
61
60
  /**
@@ -64,21 +63,17 @@ export function syncRelayStateFromDisk(): void {
64
63
  * 24h cache TTL without requiring a daemon restart.
65
64
  */
66
65
  export async function seedCatalog(): Promise<void> {
67
- const cached = readCatalogCache();
68
- if (cached && Array.isArray(cached.models) && cached.models.length > 0) {
69
- setAliveCatalog(cached.models);
70
- }
71
- try {
72
- const fresh = await refreshCatalog(false);
73
- if (fresh.length > 0) {
74
- setAliveCatalog(fresh);
75
- }
76
- } catch (e) {
77
- logWarn("daemon catalog refresh failed; retaining cached catalog", {
78
- error: String(e),
79
- });
80
- }
81
- logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
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`);
82
77
  }
83
78
 
84
79
  /**
@@ -87,63 +82,63 @@ export async function seedCatalog(): Promise<void> {
87
82
  * cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
88
83
  */
89
84
  export async function runDaemon(): Promise<void> {
90
- let server: { close(): void } | null = null;
91
- const retire = (why: string): void => {
92
- logInfo(`daemon retiring: ${why}`);
93
- stopLeaseGC();
94
- try {
95
- server?.close();
96
- } catch {}
97
- process.exit(0);
98
- };
99
- process.on("SIGTERM", () => retire("SIGTERM"));
100
- process.on("SIGINT", () => retire("SIGINT"));
101
- process.on("uncaughtException", (err) => {
102
- log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack, activeRequests: getActiveRequests() });
103
- });
104
- process.on("unhandledRejection", (reason) => {
105
- log("error", "daemon unhandledRejection", { error: String(reason), activeRequests: getActiveRequests() });
106
- });
107
- setShutdownShouldExit(true);
108
- try {
109
- const r = await startProxy();
110
- if (!r.server) {
111
- // Port was taken by another (possibly newer) daemon — the parent
112
- // attaches to the winner; this spawn exits quietly.
113
- logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
114
- process.exit(0);
115
- }
116
- server = r.server;
117
- } catch (e) {
118
- log("error", "daemon failed to bind proxy port", { error: String(e) });
119
- process.exit(1);
120
- }
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
+ try {
104
+ const r = await startProxy();
105
+ if (!r.server) {
106
+ // Port was taken by another (possibly newer) daemon — the parent
107
+ // attaches to the winner; this spawn exits quietly.
108
+ logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
109
+ process.exit(0);
110
+ }
111
+ server = r.server;
112
+ } catch (e) {
113
+ log("error", "daemon failed to bind proxy port", { error: String(e) });
114
+ process.exit(1);
115
+ }
121
116
 
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.
125
- touchActivity();
126
- syncRelayStateFromDisk();
127
- void seedCatalog();
117
+ // lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
118
+ // retirement no longer reads it — only the zero-lease persistence window
119
+ // gates the GC, so a fresh spawn always survives its re-attach window.
120
+ touchActivity();
121
+ syncRelayStateFromDisk();
122
+ void seedCatalog();
128
123
 
129
- // Retire only when zero leases persist past the grace window with nothing
130
- // in flight — never for request-idleness.
131
- startLeaseGC({
132
- ttlMs: DAEMON_TTL_MS,
133
- gcMs: DAEMON_GC_MS,
134
- graceMs: DAEMON_GRACE_MS,
135
- getActiveRequests,
136
- onIdle: () => retire("no clients"),
137
- });
124
+ // Retire only when zero leases persist past the grace window with nothing
125
+ // in flight — never for request-idleness.
126
+ startLeaseGC({
127
+ ttlMs: DAEMON_TTL_MS,
128
+ gcMs: DAEMON_GC_MS,
129
+ graceMs: DAEMON_GRACE_MS,
130
+ getActiveRequests,
131
+ onIdle: () => retire("no clients"),
132
+ });
138
133
 
139
- logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
134
+ logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
140
135
  }
141
136
 
142
137
  // Entry guard: run only when executed as the entry script. When imported by
143
138
  // tests, isDaemonMain() is false and no port is bound.
144
139
  if (isDaemonMain()) {
145
- runDaemon().catch((e) => {
146
- log("error", "daemon crashed", { error: String(e) });
147
- process.exit(1);
148
- });
140
+ runDaemon().catch((e) => {
141
+ log("error", "daemon crashed", { error: String(e) });
142
+ process.exit(1);
143
+ });
149
144
  }
package/src/index.ts CHANGED
@@ -146,17 +146,12 @@ export function buildProviderConfig(
146
146
  }
147
147
  : m.api === "openai-responses"
148
148
  ? { sessionAffinityFormat: "openai-nosession" }
149
- : m.source === "kilo"
149
+ : m.source === "kilo" || m.source === "cline"
150
150
  ? {
151
151
  supportsDeveloperRole: false,
152
152
  supportsReasoningEffort: !!m.thinkingLevelMap,
153
153
  }
154
- : m.source === "cline"
155
- ? {
156
- supportsDeveloperRole: false,
157
- supportsReasoningEffort: !!m.thinkingLevelMap,
158
- }
159
- : {
154
+ : {
160
155
  supportsDeveloperRole: false,
161
156
  supportsReasoningEffort: true,
162
157
  },
package/src/proxy.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  } from "./opencode-fingerprint.ts";
43
43
  import { isDebugEnabled, log } from "./logger.ts";
44
44
  import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
45
- import { refreshClineToken, toApiKey } from "./cline-device-auth.ts";
45
+ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
46
46
  import { rollChat } from "./cline-accounts.ts";
47
47
  import { fetchWithSystemCA } from "./system-ca-fetch.ts";
48
48
  import {
@@ -136,7 +136,7 @@ const CLINE_MODEL_HINT =
136
136
  */
137
137
  export function mapClineError(status: number, data: string): string {
138
138
  // Synthetic pool-exhausted bodies already tell the user exactly what to do
139
- // (add a login / wait out cooldowns) — a rate-limit hint would mislead.
139
+ // (sign in again) — a rate-limit hint would mislead.
140
140
  if (data.includes("cline_pool_exhausted")) return data;
141
141
  const hint = status === 429
142
142
  ? CLINE_RATE_LIMIT_HINT
@@ -158,12 +158,26 @@ export function mapClineError(status: number, data: string): string {
158
158
  }
159
159
  /**
160
160
  * Device-login refresher for the Cline pool: one refresh per stale slot per
161
- * turn. A throw keeps the stale bearer (transient); only a well-formed fresh
162
- * grant replaces it — the pool module owns that decision.
161
+ * turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
162
+ * tokens) is dead — it resolves null so the pool stops trying that slot until
163
+ * the user logs in again. Anything else (network fault, upstream 5xx) throws,
164
+ * which keeps the stale bearer for this turn.
163
165
  */
164
166
  async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
165
- const creds = await refreshClineToken(undefined, refreshToken);
166
- return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
167
+ try {
168
+ const creds = await refreshClineToken(undefined, refreshToken);
169
+ return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
170
+ } catch (e) {
171
+ if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
172
+ log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
173
+ return null;
174
+ }
175
+ if (e instanceof Error && /^Invalid token response|^Token response did not include a refresh token/.test(e.message)) {
176
+ log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
177
+ return null;
178
+ }
179
+ throw e;
180
+ }
167
181
  }
168
182
 
169
183
  /**
@@ -214,10 +228,23 @@ async function handleClineRequest(opts: {
214
228
  }
215
229
  if (!upstreamRes.ok) {
216
230
  log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
217
- const data = mapClineError(upstreamRes.status, await upstreamRes.text());
231
+ const raw = await upstreamRes.text().catch(() => null);
232
+ if (raw === null) {
233
+ log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
234
+ if (!res.headersSent) {
235
+ res.writeHead(502, { "content-type": "application/json" });
236
+ res.end(JSON.stringify({ error: "upstream error" }));
237
+ } else if (!res.writableEnded) {
238
+ res.end();
239
+ }
240
+ return;
241
+ }
242
+ const data = mapClineError(upstreamRes.status, raw);
218
243
  if (!res.headersSent) {
219
244
  res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
220
245
  res.end(data);
246
+ } else if (!res.writableEnded) {
247
+ res.end();
221
248
  }
222
249
  return;
223
250
  }
@@ -237,12 +264,24 @@ async function handleClineRequest(opts: {
237
264
  );
238
265
  return;
239
266
  }
240
- const sseText = await upstreamRes.text();
267
+ const sseText = await upstreamRes.text().catch(() => null);
268
+ if (sseText === null) {
269
+ log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
270
+ if (!res.headersSent) {
271
+ res.writeHead(502, { "content-type": "application/json" });
272
+ res.end(JSON.stringify({ error: "upstream error" }));
273
+ } else if (!res.writableEnded) {
274
+ res.end();
275
+ }
276
+ return;
277
+ }
241
278
  if (!responsesRequest) {
242
279
  const data = convertSseToJson(sseText, pathname);
243
280
  if (!res.headersSent) {
244
281
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
245
282
  res.end(data);
283
+ } else if (!res.writableEnded) {
284
+ res.end();
246
285
  }
247
286
  return;
248
287
  }
@@ -257,12 +296,16 @@ async function handleClineRequest(opts: {
257
296
  "x-accel-buffering": "no",
258
297
  });
259
298
  res.end(chatResponsesSseFromChatCompletion(resp));
299
+ } else if (!res.writableEnded) {
300
+ res.end();
260
301
  }
261
302
  return;
262
303
  }
263
304
  if (!res.headersSent) {
264
305
  res.writeHead(200, { "content-type": "application/json" });
265
306
  res.end(JSON.stringify(resp));
307
+ } else if (!res.writableEnded) {
308
+ res.end();
266
309
  }
267
310
  }
268
311
 
@@ -271,13 +314,15 @@ async function handleClineRequest(opts: {
271
314
  * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
272
315
  * (Kilo traffic leaves via its own branch regardless), and when the model is
273
316
  * missing/empty (e.g. GET /v1/models carries no body — preserve routing).
274
- * Anything else (unknown/paid model ids, non-strings) goes direct upstream.
317
+ * Cline models are direct-only and never relay-eligible. Anything else
318
+ * (unknown/paid model ids, non-strings) goes direct upstream.
275
319
  */
276
320
  export function isRelayEligibleModel(model: unknown): boolean {
277
321
  if (model === undefined || model === null) return true;
278
322
  if (typeof model !== "string") return false;
279
323
  if (model.trim() === "") return true;
280
324
  const canonical = resolveCanonicalModelId(model.trim());
325
+ if (isClineModel(canonical)) return false;
281
326
  return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical);
282
327
  }
283
328
 
@@ -766,7 +811,7 @@ export function startProxy(
766
811
  id: m.id,
767
812
  object: "model",
768
813
  created: 0,
769
- owned_by: m.source === "kilo" ? "kilocode" : "opencode",
814
+ owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
770
815
  })),
771
816
  });
772
817
  res.writeHead(200, {