pi-freeflow 1.44.0 → 1.45.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
package/src/daemon.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import path from "node:path";
13
+ import fs from "node:fs";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import {
15
16
  DAEMON_GC_MS,
@@ -34,12 +35,26 @@ import {
34
35
  import { getActiveRequests, startClineKeepAlive, startProxy } from "./proxy.ts";
35
36
  import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
36
37
 
38
+ /** True when this module was run as the entry script (not imported by tests). */
39
+ function safeCanonicalPath(p: string): string {
40
+ try {
41
+ return fs.realpathSync.native(path.resolve(p));
42
+ } catch {
43
+ return path.resolve(p);
44
+ }
45
+ }
46
+
37
47
  /** True when this module was run as the entry script (not imported by tests). */
38
48
  export function isDaemonMain(): boolean {
39
49
  try {
40
50
  const entry = process.argv[1];
41
51
  if (!entry) return false;
42
- return path.resolve(entry) === fileURLToPath(import.meta.url);
52
+ const canonicalEntry = safeCanonicalPath(entry);
53
+ const canonicalSelf = safeCanonicalPath(fileURLToPath(import.meta.url));
54
+ if (process.platform === "win32") {
55
+ return canonicalEntry.toLowerCase() === canonicalSelf.toLowerCase();
56
+ }
57
+ return canonicalEntry === canonicalSelf;
43
58
  } catch {
44
59
  return false;
45
60
  }
package/src/deploy.ts CHANGED
@@ -80,6 +80,7 @@ const isPrivateHostname = function(h) {
80
80
  return false
81
81
  }
82
82
  if (host.includes(":")) {
83
+ if (host.startsWith("64:ff9b:") || host.startsWith("2002:")) return true
83
84
  if (host.startsWith("fc") || host.startsWith("fd")) return true
84
85
  if (/^fe[89ab]/.test(host)) return true
85
86
  return false
@@ -121,7 +122,7 @@ async function relayHandler(req) {
121
122
  DENY_HEADERS.forEach((h) => headers.delete(h));
122
123
  headers.delete("x-relay-auth");
123
124
  try {
124
- const init = { method: req.method, headers };
125
+ const init = { method: req.method, headers, redirect: "manual" };
125
126
  if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
126
127
  const response = await fetch(resolved.url, init);
127
128
  return new Response(response.body, { status: response.status, headers: response.headers });
@@ -242,6 +243,7 @@ export async function deployVercelRelay(
242
243
 
243
244
  const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
244
245
  const depId = depJson.id || depJson.uid;
246
+ if (!depId) throw new Error("Vercel did not return a deployment id");
245
247
  const projectId = depJson.projectId || name;
246
248
 
247
249
  // 2. Make the deployment public (disable SSO protection if enabled on team)
@@ -0,0 +1,334 @@
1
+ /**
2
+ * OpenCode Zen free-tier environment block and request body normalization.
3
+ *
4
+ * Enforces the `<env>` system-prompt block required by the OpenCode free-tier gate,
5
+ * normalizes cache keys, Responses bodies, and OpenAI-compat usage options.
6
+ */
7
+
8
+ import {
9
+ clampedEffortFor,
10
+ enforceAutoToolChoice,
11
+ sanitizeReplayItemNames,
12
+ stripCommentaryPhase,
13
+ } from "./fingerprint-tools.ts";
14
+
15
+ /** Complete `<env>` … `</env>` pair; non-greedy so two blocks stay two matches. */
16
+ export const ENV_PAIR_RE = /<env>[\s\S]*?<\/env>/g;
17
+ /** A tag whose partner is missing or was stripped: it can shadow the real pair. */
18
+ export const ENV_TAG_RE = /<\/?env>/g;
19
+ /** The gate's load-bearing key. Any value passes; a different key does not. */
20
+ export const WORKING_DIRECTORY_RE = /Working directory:[ \t]*([^\n<]*)/;
21
+ /** The gate reads the key, not a usable path: this block is a fingerprint. */
22
+ export const FALLBACK_WORKING_DIRECTORY = "(current project)";
23
+ /** Multiple newlines (3 or more) normalized to double newlines. */
24
+ const MULTI_NEWLINE_RE = /\n{3,}/g;
25
+
26
+ /** One mutable piece of system-prompt text: the only place a block can live. */
27
+ export type SystemText = { read(): string; write(text: string): void };
28
+
29
+ /** `container[key]` as a mutable string segment. */
30
+ export function stringSegment(container: Record<string, unknown>, key: string): SystemText {
31
+ return {
32
+ read: (): string => (typeof container[key] === "string" ? (container[key] as string) : ""),
33
+ write: (text: string): void => {
34
+ container[key] = text;
35
+ },
36
+ };
37
+ }
38
+
39
+ /** The text blocks of a parts array (`content[]` / Anthropic `system[]`). */
40
+ export function textBlockSegments(blocks: unknown[]): SystemText[] {
41
+ const segments: SystemText[] = [];
42
+ for (const block of blocks) {
43
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
44
+ const rec = block as Record<string, unknown>;
45
+ if (typeof rec.text !== "string") continue;
46
+ segments.push({
47
+ read: (): string => rec.text as string,
48
+ write: (text: string): void => {
49
+ rec.text = text;
50
+ },
51
+ });
52
+ }
53
+ return segments;
54
+ }
55
+
56
+ /**
57
+ * Text segments of a `content` field: a plain string, or the text blocks of a
58
+ * parts array (Chat Completions spells message content both ways). A parts
59
+ * array with no text block gains one, because there is nowhere else to append.
60
+ */
61
+ export function contentSegments(message: Record<string, unknown>): SystemText[] {
62
+ const content = message.content;
63
+ if (!Array.isArray(content)) {
64
+ if (typeof content !== "string") message.content = "";
65
+ return [stringSegment(message, "content")];
66
+ }
67
+ const segments = textBlockSegments(content);
68
+ if (segments.length > 0) return segments;
69
+ const block: Record<string, unknown> = { type: "text", text: "" };
70
+ content.push(block);
71
+ return [stringSegment(block, "text")];
72
+ }
73
+
74
+ /**
75
+ * Chat Completions system prompt: every `role: "system"` message in order.
76
+ * A body with none gains one at the top.
77
+ */
78
+ export function chatSystemSegments(body: Record<string, unknown>): SystemText[] {
79
+ const existing = body.messages;
80
+ // A non-array messages value carries no system prompt; coerce to an array
81
+ // so the env block still lands (a shape violation either way, but a 429
82
+ // without the block hides the real error).
83
+ if (existing !== undefined && !Array.isArray(existing)) body.messages = [];
84
+ const messages: unknown[] = Array.isArray(body.messages) ? body.messages : ((body.messages = []) as unknown[]);
85
+ const segments: SystemText[] = [];
86
+ for (const message of messages) {
87
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
88
+ const rec = message as Record<string, unknown>;
89
+ if (rec.role !== "system") continue;
90
+ segments.push(...contentSegments(rec));
91
+ }
92
+ if (segments.length > 0) return segments;
93
+ const added: Record<string, unknown> = { role: "system", content: "" };
94
+ messages.unshift(added);
95
+ return [stringSegment(added, "content")];
96
+ }
97
+
98
+ /** Anthropic Messages system prompt: `system` is a string or a text-block array. */
99
+ export function messagesSystemSegments(body: Record<string, unknown>): SystemText[] {
100
+ const system = body.system;
101
+ if (Array.isArray(system)) {
102
+ const segments = textBlockSegments(system);
103
+ if (segments.length > 0) return segments;
104
+ const block: Record<string, unknown> = { type: "text", text: "" };
105
+ system.push(block);
106
+ return [stringSegment(block, "text")];
107
+ }
108
+ if (typeof system !== "string") body.system = "";
109
+ return [stringSegment(body, "system")];
110
+ }
111
+
112
+ /**
113
+ * Responses system prompt: `instructions`, or the `system` / `developer` items
114
+ * a host seats in `input[]` instead. Real OpenCode clients send `instructions`,
115
+ * so a host that sends neither gets one.
116
+ */
117
+ export function responsesSystemSegments(body: Record<string, unknown>): SystemText[] {
118
+ if (typeof body.instructions === "string") return [stringSegment(body, "instructions")];
119
+ const input = body.input;
120
+ if (Array.isArray(input)) {
121
+ const segments: SystemText[] = [];
122
+ for (const item of input) {
123
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
124
+ const rec = item as Record<string, unknown>;
125
+ if (rec.role !== "system" && rec.role !== "developer") continue;
126
+ if (typeof rec.content === "string") {
127
+ segments.push(stringSegment(rec, "content"));
128
+ } else if (Array.isArray(rec.content)) {
129
+ // Array-content system/developer items (Responses text-block parts):
130
+ // edit the text blocks in place rather than ignoring the item.
131
+ segments.push(...textBlockSegments(rec.content));
132
+ } else {
133
+ continue;
134
+ }
135
+ }
136
+ if (segments.length > 0) return segments;
137
+ }
138
+ body.instructions = "";
139
+ return [stringSegment(body, "instructions")];
140
+ }
141
+
142
+ /**
143
+ * Every complete env block in the system text, in wire order. Upstream reads
144
+ * only the FIRST pair, which is why order decides whether a request passes.
145
+ */
146
+ export function envBlocks(segments: SystemText[]): Array<{ segment: number; start: number; end: number; body: string }> {
147
+ const blocks: Array<{ segment: number; start: number; end: number; body: string }> = [];
148
+ segments.forEach((segment, index) => {
149
+ for (const match of segment.read().matchAll(ENV_PAIR_RE)) {
150
+ if (match.index === undefined) continue;
151
+ blocks.push({ segment: index, start: match.index, end: match.index + match[0].length, body: match[0] });
152
+ }
153
+ });
154
+ return blocks;
155
+ }
156
+
157
+ /** Drop every env block and stray tag from `text`, keeping one pair verbatim. */
158
+ export function stripEnvBlocks(text: string, keep?: { start: number; end: number }): string {
159
+ const strip = (part: string): string => part.replace(ENV_PAIR_RE, "").replace(ENV_TAG_RE, "");
160
+ const result =
161
+ keep === undefined
162
+ ? strip(text)
163
+ : `${strip(text.slice(0, keep.start))}${text.slice(keep.start, keep.end)}${strip(text.slice(keep.end))}`;
164
+ return result.replace(MULTI_NEWLINE_RE, "\n\n").trim();
165
+ }
166
+
167
+ /** The `Working directory:` value the caller already stated, if any. */
168
+ export function statedWorkingDirectory(segments: SystemText[]): string | null {
169
+ for (const segment of segments) {
170
+ const value = WORKING_DIRECTORY_RE.exec(segment.read())?.[1]?.trim();
171
+ if (value) return value;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Build the exact 5-line env block the live CLI sends (v1 1.19.4 MITM, SG):
178
+ * Working directory, Workspace root, Is git, Platform, Today's date.
179
+ * The directory values fall back to the fingerprint constant when the caller
180
+ * stated nothing — the gate reads the keys, not usable paths.
181
+ */
182
+ export function buildLiveEnvBlock(workingDirectory: string): string {
183
+ const today = new Date().toISOString().slice(0, 10);
184
+ return [
185
+ "<env>",
186
+ ` Working directory: ${workingDirectory}`,
187
+ ` Workspace root: ${workingDirectory}`,
188
+ " Is git: false",
189
+ ` Platform: ${process.platform}`,
190
+ ` Today's date: ${today}`,
191
+ "</env>",
192
+ ].join("\n");
193
+ }
194
+
195
+ /**
196
+ * Guarantee the free-tier environment gate: exactly one `<env>` … `</env>` pair
197
+ * in the system prompt, with `Working directory:` inside it.
198
+ *
199
+ * Measured 2026-10-24 (issues #117/#118): a body with the block answers 200 and
200
+ * the same body without it answers 429 FreeUsageLimitError, on the same machine
201
+ * within the same minute, with the retry-after countdown still hours away from
202
+ * 00:00 UTC. The countdown is a generic error body, not a quota window, so a
203
+ * host that never emits the block fails on the direct route no matter what
204
+ * else the lane gets right.
205
+ *
206
+ * Two measured traps decide the shape of this function:
207
+ * - Upstream reads only the FIRST pair in the text. A stale or partial copy
208
+ * above the real block shadows it and still 429s, even though the block
209
+ * itself is complete and correct. So a fix must leave exactly one pair, not
210
+ * merely append one.
211
+ * - `Working directory:` is load-bearing. A plausible-looking block carrying
212
+ * `Platform:` or `Shell:` instead is refused; a block carrying only
213
+ * `Working directory:` is accepted. So an existing block without the key is
214
+ * replaced rather than kept and duplicated.
215
+ *
216
+ * A host block that already satisfies both rides through byte-for-byte: the
217
+ * host knows its environment, this module does not. Anything else is folded
218
+ * into the last system message, which upstream accepts on every wire shape.
219
+ */
220
+ export function ensureEnvBlock(body: Record<string, unknown>, pathname: string): void {
221
+ if (!body || typeof body !== "object") return;
222
+ const segments = pathname.endsWith("/responses")
223
+ ? responsesSystemSegments(body)
224
+ : pathname.endsWith("/messages")
225
+ ? messagesSystemSegments(body)
226
+ : chatSystemSegments(body);
227
+ if (segments.length === 0) return;
228
+ const blocks = envBlocks(segments);
229
+ const carrying = blocks.find((block) => WORKING_DIRECTORY_RE.test(block.body));
230
+ // Already the single pair upstream reads, with the key inside: leave it be.
231
+ if (carrying !== undefined && blocks.length === 1) return;
232
+ segments.forEach((segment, index) => {
233
+ segment.write(stripEnvBlocks(segment.read(), carrying !== undefined && carrying.segment === index ? carrying : undefined));
234
+ });
235
+ if (carrying !== undefined) return;
236
+ const last = segments[segments.length - 1];
237
+ const text = last.read();
238
+ last.write(`${text ? `${text}\n\n` : ""}${buildLiveEnvBlock(statedWorkingDirectory(segments) ?? FALLBACK_WORKING_DIRECTORY)}`);
239
+ }
240
+
241
+ /**
242
+ * Normalize the camelCase `promptCacheKey` alias onto `prompt_cache_key` so
243
+ * every session-key reader sees one spelling. A non-empty snake_case value
244
+ * wins; neither spelling is ever stripped on the Zen lane.
245
+ */
246
+ export function normalizePromptCacheKey(body: Record<string, unknown>): void {
247
+ const camelKey = body["promptCacheKey"];
248
+ if (typeof camelKey === "string" && camelKey.length > 0 && (typeof body["prompt_cache_key"] !== "string" || (body["prompt_cache_key"] as string).length === 0)) {
249
+ body["prompt_cache_key"] = camelKey;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
255
+ * sometimes carry Chat Completions leftovers on the responses path and
256
+ * upstream rejects them with 400:
257
+ * - `messages` becomes `input` (copied when `input` is absent, dropped either
258
+ * way — the responses API has no `messages` field).
259
+ * - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
260
+ * when absent, dropped either way).
261
+ * - `temperature` / `top_p` are dropped (Responses rejects them).
262
+ * - `response_format` is dropped (Chat Completions field, Responses rejects it).
263
+ * - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
264
+ * - `tool_choice` is auto-or-absent: `"auto"` rides verbatim and everything
265
+ else is dropped, because Zen supports no other value (see
266
+ enforceAutoToolChoice).
267
+ * - `store` defaults to false (never persisted server-side).
268
+ * - Assistant message items lose `phase: "commentary"`; upstream 400s the whole
269
+ request otherwise (see stripCommentaryPhase).
270
+ * - Reasoning effort is clamped per model: when the model's thinkingLevelMap
271
+ declares the requested effort as null (unsupported), the effort is clamped
272
+ to the highest supported level. Covers all OpenCode models generically
273
+ (muse-spark, mimo, nemotron, etc.), not just a single model family.
274
+ * Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
275
+ * bodies never reach here (the proxy fingerprints Zen bodies exclusively).
276
+ */
277
+ export function normalizeResponsesBody(body: Record<string, unknown>): void {
278
+ if (!body || typeof body !== "object") return;
279
+ if (body.input === undefined && Array.isArray(body.messages)) {
280
+ body.input = body.messages;
281
+ }
282
+ delete body.messages;
283
+ stripCommentaryPhase(body);
284
+ // After input[] exists: a replayed call name is only folded once the array is
285
+ // the one upstream will read.
286
+ sanitizeReplayItemNames(body);
287
+ if (body.max_output_tokens === undefined) {
288
+ if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
289
+ else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
290
+ }
291
+ delete body.max_tokens;
292
+ delete body.max_completion_tokens;
293
+ delete body.temperature;
294
+ delete body.top_p;
295
+ delete body.response_format;
296
+ if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
297
+ if (typeof body.model === "string") {
298
+ const reasoning = body.reasoning;
299
+ if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
300
+ const effort = (reasoning as Record<string, unknown>).effort;
301
+ if (typeof effort === "string") {
302
+ const clamped = clampedEffortFor(body.model as string, effort);
303
+ if (clamped !== null) (reasoning as Record<string, unknown>).effort = clamped;
304
+ }
305
+ }
306
+ for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
307
+ const flat = body[key];
308
+ if (typeof flat === "string") {
309
+ const clamped = clampedEffortFor(body.model as string, flat);
310
+ if (clamped !== null) body[key] = clamped;
311
+ }
312
+ }
313
+ }
314
+ // After the effort clamp: dropping a forced tool call is what triggers the
315
+ // native-effort restore, so it runs once clamping is settled.
316
+ enforceAutoToolChoice(body);
317
+ if (body.store === undefined) body.store = false;
318
+ }
319
+
320
+ /**
321
+ * OpenAI-compat body variant for the default lane: mark the body for usage
322
+ * accounting the way the CLI's openai-compat request does. Merges into an
323
+ * existing stream_options object rather than replacing it. Applied to every
324
+ * chat-lane enforcement (see enforceOpencodeFingerprint).
325
+ */
326
+ export function applyOpenAiCompatVariant(body: Record<string, unknown>): void {
327
+ if (!body || typeof body !== "object") return;
328
+ const existing = body.stream_options;
329
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
330
+ (existing as Record<string, unknown>).include_usage = true;
331
+ return;
332
+ }
333
+ body.stream_options = { include_usage: true };
334
+ }