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
@@ -13,6 +13,7 @@
13
13
  import { request as httpRequest } from "node:http";
14
14
  import { request as httpsRequest } from "node:https";
15
15
  import { execFileSync } from "node:child_process";
16
+ import tls from "node:tls";
16
17
  import { randomBytes } from "node:crypto";
17
18
  import fs from "node:fs";
18
19
  import os from "node:os";
@@ -163,18 +164,26 @@ function windowsBundle(): string[] {
163
164
 
164
165
  let systemCaCache: string[] | null = null;
165
166
 
167
+ /** Test-only: reset the system CA bundle cache. */
168
+ export function _resetSystemCaCacheForTest(): void {
169
+ systemCaCache = null;
170
+ }
171
+
166
172
  /** OS trust bundle: platform store plus NODE_EXTRA_CA_CERTS when set. */
167
- function systemCaBundle(): string[] {
173
+ export function systemCaBundle(): string[] {
168
174
  if (systemCaCache) return systemCaCache;
169
175
  const extra = nodeExtraCa();
170
176
  const platform = process.platform === "win32" ? windowsBundle() : linuxBundle();
171
177
  const seen = new Set<string>();
172
- systemCaCache = [...extra, ...platform].filter((b) => {
178
+ const combined = [...extra, ...platform].filter((b) => {
173
179
  if (seen.has(b)) return false;
174
180
  seen.add(b);
175
181
  return true;
176
182
  });
177
- return systemCaCache;
183
+ if (combined.length > 0) {
184
+ systemCaCache = combined;
185
+ }
186
+ return combined;
178
187
  }
179
188
 
180
189
  function toNodeHeaders(headers: HeadersInit | undefined): Record<string, string> {
@@ -229,9 +238,11 @@ export function requestViaNodeHttp(input: string, init?: RequestInit): Promise<R
229
238
  const req = sender(url, {
230
239
  method: init?.method ?? "GET",
231
240
  headers,
232
- ca: bundle.length > 0 ? bundle : undefined,
241
+ ca: bundle.length > 0 ? [...tls.rootCertificates, ...bundle] : undefined,
233
242
  signal: nodeSignal,
234
243
  }, (res) => {
244
+ res.on("error", reject);
245
+ res.on("aborted", () => reject(new Error("upstream response aborted")));
235
246
  const chunks: Buffer[] = [];
236
247
  res.on("data", (c: Buffer) => chunks.push(c));
237
248
  res.on("end", () => {
package/src/token.ts CHANGED
@@ -88,9 +88,14 @@ export function tokenAuthHeaders(dataDir?: string): Record<string, string> {
88
88
  * accepting unauthenticated traffic.
89
89
  */
90
90
  export function ensureToken(): string {
91
- const existing = readToken();
92
- if (existing) return existing;
93
91
  const file = resolveTokenPath();
92
+ const existing = readToken();
93
+ if (existing) {
94
+ try {
95
+ if (process.platform !== "win32") chmodSync(file, TOKEN_MODE);
96
+ } catch { }
97
+ return existing;
98
+ }
94
99
  const token = randomBytes(TOKEN_BYTES).toString("hex");
95
100
  try {
96
101
  mkdirSync(path.dirname(file), { recursive: true });
package/src/tool-args.ts CHANGED
@@ -180,6 +180,7 @@ function matchesSchema(value: unknown, schema: Record<string, unknown>, depth: n
180
180
  }
181
181
  if (type === "string") return typeof value === "string";
182
182
  if (type === "number") return typeof value === "number" && Number.isFinite(value);
183
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value);
183
184
  if (type === "boolean") return typeof value === "boolean";
184
185
  if (Array.isArray(resolved.enum)) return resolved.enum.some((member) => member === value);
185
186
  return true;
@@ -342,7 +343,13 @@ function adoptDeclaredKeys(
342
343
  for (const key of unknownKeys) {
343
344
  const raw = value[key];
344
345
  const want = Array.isArray(raw) ? "array" : raw !== null && typeof raw === "object" ? "object" : typeof raw;
345
- const candidates = missing.filter((name) => !taken.has(name) && schemaType(declared[name]) === want);
346
+ const candidates = missing.filter((name) => {
347
+ if (taken.has(name)) return false;
348
+ const st = schemaType(declared[name]);
349
+ if (st === want) return true;
350
+ if (st === "integer" && want === "number" && typeof raw === "number" && Number.isInteger(raw)) return true;
351
+ return false;
352
+ });
346
353
  if (candidates.length !== 1) return null;
347
354
  taken.add(candidates[0]);
348
355
  targets.set(key, candidates[0]);
@@ -540,14 +547,14 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
540
547
  // (`{"opts":{"a":1` must not salvage to `{"a":1}`).
541
548
  for (const start of topLevelObjectStarts(argsJson)) {
542
549
  const candidate = argsJson.slice(start);
543
- const i = start;
544
550
  // Two shapes to try, in order: the candidate as written, then the candidate
545
551
  // with its missing closers appended. The closer is only offered at the FIRST
546
552
  // brace and only when something precedes it — that fragment is the evidence
547
553
  // the model was mid-call and lost the tail. A bare `{"op":"init"` stays
548
554
  // refused, and so does an inner `{"phase":…` inside an already-truncated
549
555
  // object, which is why this is not offered at every brace.
550
- const candidates = i === 0 ? [candidate] : [candidate, appendClosers(candidate)];
556
+ const hasPreceding = argsJson.slice(0, start).trim().length > 0;
557
+ const candidates = hasPreceding ? [candidate, appendClosers(candidate)] : [candidate];
551
558
  for (const attempt of candidates) {
552
559
  if (attempt === undefined) continue;
553
560
  try {
@@ -804,239 +804,4 @@ export function translateToolsForPath(tools: unknown[], pathname: string, opts?:
804
804
  }
805
805
  return out;
806
806
  }
807
- /**
808
- * Responses <-> Chat body translation. Cline serves chat completions only, so
809
- * the responses body is reshaped to chat before the Cline branch sends it,
810
- * and the chat answer is translated back. Tool shape conversion reuses
811
- * translateToolsForPath; only the message envelopes are remapped here.
812
- */
813
- /** Extract plain text from a Responses content field (string or parts array). */
814
- function responsesTextOf(content: unknown): string | null {
815
- if (typeof content === "string") return content;
816
- if (!Array.isArray(content)) return null;
817
- let text = "";
818
- let saw = false;
819
- for (const part of content) {
820
- if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
821
- const rec = part as Record<string, unknown>;
822
- if ((rec.type === "text" || rec.type === "input_text" || rec.type === "output_text" || rec.type === "summary_text") && typeof rec.text === "string") {
823
- text += rec.text;
824
- saw = true;
825
- }
826
- }
827
- return saw ? text : null;
828
- }
829
-
830
- /** Chat role for a Responses item role; unknown roles ride as user text. */
831
- function chatRoleFor(role: unknown): string {
832
- if (role === "assistant" || role === "system" || role === "tool") return role;
833
- if (role === "developer") return "system";
834
- return "user";
835
- }
836
-
837
- /**
838
- * Map a Responses `input` (string or item array) to Chat `messages`.
839
- * function_call items become assistant tool_calls, function_call_output
840
- * items become tool messages, reasoning items keep only their summary text.
841
- */
842
- export function responsesInputToChatMessages(input: unknown): Record<string, unknown>[] {
843
- if (typeof input === "string") return input.length > 0 ? [{ role: "user", content: input }] : [];
844
- if (!Array.isArray(input)) return [];
845
- const messages: Record<string, unknown>[] = [];
846
- for (const item of input) {
847
- if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
848
- const rec = item as Record<string, unknown>;
849
- if (rec.type === "function_call") {
850
- const args = typeof rec.arguments === "string" ? rec.arguments : JSON.stringify(rec.arguments ?? {});
851
- const name = typeof rec.name === "string" ? rec.name : "tool";
852
- const id = typeof rec.call_id === "string" ? rec.call_id : typeof rec.id === "string" ? rec.id : "";
853
- messages.push({ role: "assistant", content: null, tool_calls: [{ id, type: "function", function: { name, arguments: args } }] });
854
- continue;
855
- }
856
- if (rec.type === "function_call_output") {
857
- const output = typeof rec.output === "string" ? rec.output : JSON.stringify(rec.output ?? "");
858
- const callId = typeof rec.call_id === "string" ? rec.call_id : typeof rec.id === "string" ? rec.id : "";
859
- messages.push({ role: "tool", tool_call_id: callId, content: output });
860
- continue;
861
- }
862
- if (rec.type === "reasoning") {
863
- const summary = Array.isArray(rec.summary) ? responsesTextOf(rec.summary) : null;
864
- if (summary !== null && summary.length > 0) messages.push({ role: "assistant", content: summary });
865
- continue;
866
- }
867
- const text = responsesTextOf(rec.content);
868
- messages.push({ role: chatRoleFor(rec.role), content: text ?? JSON.stringify(item) });
869
- }
870
- return messages;
871
- }
872
-
873
- /**
874
- * Build a Chat Completions body from a Responses body. Carries model,
875
- * instructions (as the leading system message), input, tools (reshaped to
876
- * chat via translateToolsForPath), tool_choice, and plain sampling scalars.
877
- * Stream policy stays with the caller: this maps shape, never behavior.
878
- *
879
- * Lane-neutral: used by every direct-only lane that serves chat upstream and
880
- * answers a responses-path caller (Cline, OVHcloud AI Endpoints).
881
- */
882
- export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record<string, unknown> {
883
- const out: Record<string, unknown> = {};
884
- if (typeof body.model === "string") out.model = body.model;
885
- const messages: Record<string, unknown>[] = [];
886
- if (typeof body.instructions === "string" && body.instructions.length > 0) {
887
- messages.push({ role: "system", content: body.instructions });
888
- }
889
- messages.push(...responsesInputToChatMessages(body.input));
890
- out.messages = messages;
891
- if (Array.isArray(body.tools)) out.tools = translateToolsForPath(body.tools, "/v1/chat/completions");
892
- if (body.tool_choice !== undefined) out.tool_choice = body.tool_choice;
893
- // `max_output_tokens` is the Responses-native cap and is what a host actually
894
- // sets on this path; without it here the cap silently vanished on the
895
- // chat-only lanes and the model answered at its own default length.
896
- for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "max_output_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
897
- if (body[key] !== undefined) out[key] = body[key];
898
- }
899
- return out;
900
- }
901
-
902
- /** First chat choice message, or null when the completion carries none. */
903
- function firstChatMessage(chat: Record<string, unknown>): Record<string, unknown> | null {
904
- const choices = chat.choices;
905
- if (!Array.isArray(choices) || choices.length === 0) return null;
906
- const first = choices[0];
907
- if (typeof first !== "object" || first === null || Array.isArray(first)) return null;
908
- const message = (first as Record<string, unknown>).message;
909
- if (typeof message !== "object" || message === null || Array.isArray(message)) return null;
910
- return message as Record<string, unknown>;
911
- }
912
-
913
- /**
914
- * Build a Responses object from a Chat Completion object. Text becomes the
915
- * assistant message item, tool_calls become function_call items, usage rides
916
- * verbatim. `finish_reason: "length"` reports incomplete, else completed.
917
- */
918
- export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown>, fallbackModel: string): Record<string, unknown> {
919
- const message = firstChatMessage(chat);
920
- const model = typeof chat.model === "string" && chat.model.length > 0 ? chat.model : fallbackModel;
921
- const id = typeof chat.id === "string" ? chat.id.replace(/^chatcmpl/, "resp") : "resp_cline";
922
- // Both hosts key their text block off the item id — Pi builds `textSignature`
923
- // from item.id, OMP stores itemId — and each synthesizes a fallback when it
924
- // is missing, so the message item carries one derived from the response id,
925
- // the way upstream OpenAI ids share a suffix.
926
- const messageId = `msg_${id.replace(/^resp[_-]?/, "")}`;
927
- const choices = chat.choices;
928
- const first = Array.isArray(choices) && choices.length > 0 && typeof choices[0] === "object" && choices[0] !== null ? (choices[0] as Record<string, unknown>) : {};
929
- const status = first.finish_reason === "length" ? "incomplete" : "completed";
930
- const output: Record<string, unknown>[] = [];
931
- if (message) {
932
- const text = typeof message.content === "string" ? message.content : null;
933
- if (text !== null && text.length > 0) {
934
- output.push({ type: "message", id: messageId, role: "assistant", status, content: [{ type: "output_text", text }] });
935
- }
936
- const toolCalls = message.tool_calls;
937
- if (Array.isArray(toolCalls)) {
938
- for (const tc of toolCalls) {
939
- if (typeof tc !== "object" || tc === null || Array.isArray(tc)) continue;
940
- const rec = tc as Record<string, unknown>;
941
- const fn = typeof rec.function === "object" && rec.function !== null && !Array.isArray(rec.function) ? (rec.function as Record<string, unknown>) : {};
942
- const args = typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {});
943
- const callId = typeof rec.id === "string" && rec.id.length > 0 ? rec.id : `call_cline_${output.length}`;
944
- output.push({ type: "function_call", id: callId, call_id: callId, name: typeof fn.name === "string" ? fn.name : "tool", arguments: args });
945
- }
946
- }
947
- }
948
- const out: Record<string, unknown> = {
949
- id,
950
- object: "response",
951
- created_at: typeof chat.created === "number" ? chat.created : Math.floor(Date.now() / 1000),
952
- model,
953
- output,
954
- status,
955
- };
956
- if (chat.usage !== undefined) out.usage = chat.usage;
957
- return out;
958
- }
959
-
960
- /**
961
- * Wrap a Responses object in the minimal valid Responses SSE sequence
962
- * (created + completed + DONE) so streamed responses-path clients get a
963
- * stream even though the upstream lane only serves chat. Round-trips through
964
- * sseToResponsesJson: the completed event carries the full object.
965
- *
966
- * Every output item is framed the way hosts consume streams: it opens with
967
- * `response.output_item.added` before its deltas and closes with
968
- * `response.output_item.done` after them. A host creates a slot only on
969
- * `added` and DROPS a keyed delta that has no open slot (Pi
970
- * api/openai-responses-shared.ts createSlot/getSlot; OMP
971
- * providers/openai-shared.ts registerOpenItem/lookupOpenItem), so a message
972
- * item emitted as bare `response.output_text.delta` frames left every
973
- * chat-only lane answer blank on the caller side.
974
- */
975
- export function chatResponsesSseFromChatCompletion(resp: Record<string, unknown>): string {
976
- const head = { type: "response.created", response: { id: resp.id, object: "response", model: resp.model } };
977
- let out = `event: response.created\ndata: ${JSON.stringify(head)}\n\n`;
978
- const output = resp.output;
979
- if (Array.isArray(output)) {
980
- for (let idx = 0; idx < output.length; idx++) {
981
- const item = output[idx] as Record<string, unknown>;
982
- if (item && item.type === "message" && Array.isArray(item.content)) {
983
- const parts = item.content as Record<string, unknown>[];
984
- const hasText = parts.some((p) => p && p.type === "output_text" && typeof p.text === "string" && p.text.length > 0);
985
- // An item carrying no text (a pure tool-call turn) stays unopened: opening
986
- // and closing it would leave the host with an empty text block.
987
- if (hasText) {
988
- out += `event: response.output_item.added\ndata: ${JSON.stringify({
989
- type: "response.output_item.added",
990
- output_index: idx,
991
- item: { type: "message", id: item.id, role: typeof item.role === "string" ? item.role : "assistant", status: "in_progress", content: [] },
992
- })}\n\n`;
993
- for (let partIndex = 0; partIndex < parts.length; partIndex++) {
994
- const p = parts[partIndex];
995
- if (p && p.type === "output_text" && typeof p.text === "string" && p.text.length > 0) {
996
- out += `event: response.output_text.delta\ndata: ${JSON.stringify({
997
- type: "response.output_text.delta",
998
- item_id: item.id,
999
- output_index: idx,
1000
- content_index: partIndex,
1001
- delta: p.text,
1002
- })}\n\n`;
1003
- }
1004
- }
1005
- out += `event: response.output_item.done\ndata: ${JSON.stringify({
1006
- type: "response.output_item.done",
1007
- output_index: idx,
1008
- item,
1009
- })}\n\n`;
1010
- }
1011
- } else if (item && item.type === "function_call") {
1012
- out += `event: response.output_item.added\ndata: ${JSON.stringify({
1013
- type: "response.output_item.added",
1014
- output_index: idx,
1015
- item: {
1016
- type: "function_call",
1017
- id: item.id,
1018
- call_id: item.call_id,
1019
- name: item.name,
1020
- arguments: "",
1021
- },
1022
- })}\n\n`;
1023
- if (typeof item.arguments === "string" && item.arguments.length > 0) {
1024
- out += `event: response.function_call_arguments.delta\ndata: ${JSON.stringify({
1025
- type: "response.function_call_arguments.delta",
1026
- output_index: idx,
1027
- delta: item.arguments,
1028
- })}\n\n`;
1029
- }
1030
- out += `event: response.output_item.done\ndata: ${JSON.stringify({
1031
- type: "response.output_item.done",
1032
- output_index: idx,
1033
- item,
1034
- })}\n\n`;
1035
- }
1036
- }
1037
- }
1038
- const done = { type: "response.completed", response: resp };
1039
- out += `event: response.completed\ndata: ${JSON.stringify(done)}\n\n`;
1040
- out += `data: [DONE]\n\n`;
1041
- return out;
1042
- }
807
+ export * from "./lane-translation.ts";
@@ -101,6 +101,8 @@ interface CallAcc {
101
101
  /** Accumulated arguments, bounded; `bytes` is the unbounded payload size. */
102
102
  args: string;
103
103
  bytes: number;
104
+ argsTruncated?: boolean;
105
+ argsValid?: boolean;
104
106
  }
105
107
 
106
108
  interface StreamState {
@@ -189,6 +191,9 @@ function appendArgs(state: StreamState, call: CallAcc, chunk: string): void {
189
191
  MAX_ARGS_CHARS - call.args.length,
190
192
  MAX_TURN_ARGS_CHARS - state.argsChars,
191
193
  );
194
+ if (room <= 0 || chunk.length > room) {
195
+ call.argsTruncated = true;
196
+ }
192
197
  if (room <= 0) return;
193
198
  const take = chunk.length > room ? chunk.slice(0, room) : chunk;
194
199
  call.args += take;
@@ -203,6 +208,10 @@ function setArgs(state: StreamState, call: CallAcc, args: string): void {
203
208
  0,
204
209
  Math.min(MAX_ARGS_CHARS, MAX_TURN_ARGS_CHARS - state.argsChars),
205
210
  );
211
+ if (args.length > room) {
212
+ call.argsTruncated = true;
213
+ call.argsValid = isArgsObject(args);
214
+ }
206
215
  const take = args.length > room ? args.slice(0, room) : args;
207
216
  call.args = take;
208
217
  state.argsChars += take.length;
@@ -261,14 +270,18 @@ function observeChat(evt: Record<string, unknown>, state: StreamState): void {
261
270
 
262
271
  function observeResponses(evt: Record<string, unknown>, state: StreamState): void {
263
272
  const type = typeof evt["type"] === "string" ? (evt["type"] as string) : "";
273
+ const outputIndex = typeof evt["output_index"] === "number" ? (evt["output_index"] as number) : undefined;
264
274
  if (type === "response.output_item.added" || type === "response.output_item.done") {
265
275
  const item = asRecord(evt["item"]);
266
276
  if (!item || item["type"] !== "function_call") return;
267
277
  const id = item["id"] !== undefined ? clip(item["id"], MAX_TEXT) : clip(item["call_id"], MAX_TEXT);
268
- let call = findById(state.calls, id);
278
+ const index = outputIndex ?? state.calls.length;
279
+ let call = findById(state.calls, id) ?? (outputIndex !== undefined ? findByIndex(state.calls, outputIndex, id) : null);
269
280
  if (!call) {
270
- call = pushCall(state, state.calls.length, id);
281
+ call = pushCall(state, index, id);
271
282
  if (!call) return;
283
+ } else if (call.id === "" && id !== "") {
284
+ call.id = id;
272
285
  }
273
286
  const name = clip(item["name"], MAX_TEXT);
274
287
  if (name !== "") call.name = name;
@@ -281,9 +294,9 @@ function observeResponses(evt: Record<string, unknown>, state: StreamState): voi
281
294
  }
282
295
  if (type === "response.function_call_arguments.delta") {
283
296
  const id = clip(evt["item_id"], MAX_TEXT);
284
- let call = findById(state.calls, id);
297
+ let call = findById(state.calls, id) ?? (outputIndex !== undefined ? findByIndex(state.calls, outputIndex, id) : null);
285
298
  if (!call) {
286
- call = pushCall(state, state.calls.length, id);
299
+ call = pushCall(state, outputIndex ?? state.calls.length, id);
287
300
  if (!call) return;
288
301
  }
289
302
  if (typeof evt["delta"] === "string") appendArgs(state, call, evt["delta"] as string);
@@ -291,9 +304,9 @@ function observeResponses(evt: Record<string, unknown>, state: StreamState): voi
291
304
  }
292
305
  if (type === "response.function_call_arguments.done") {
293
306
  const id = clip(evt["item_id"], MAX_TEXT);
294
- let call = findById(state.calls, id);
307
+ let call = findById(state.calls, id) ?? (outputIndex !== undefined ? findByIndex(state.calls, outputIndex, id) : null);
295
308
  if (!call) {
296
- call = pushCall(state, state.calls.length, id);
309
+ call = pushCall(state, outputIndex ?? state.calls.length, id);
297
310
  if (!call) return;
298
311
  }
299
312
  if (typeof evt["arguments"] === "string") setArgs(state, call, evt["arguments"] as string);
@@ -421,7 +434,7 @@ export function createToolcallRecorder(opts: ToolcallRecorderOptions): ToolcallR
421
434
  const calls = state.calls.slice(0, MAX_CALLS).map((call) => ({
422
435
  name: clip(call.name, MAX_NAME),
423
436
  argsBytes: call.bytes,
424
- argsValid: isArgsObject(call.args),
437
+ argsValid: call.argsValid ?? (call.argsTruncated ? true : isArgsObject(call.args)),
425
438
  known: declaredExact.has(call.name),
426
439
  }));
427
440
  // A body that never carried an event stream says nothing about the model:
@@ -67,20 +67,33 @@ export function setCachedUpdate(latest: string): void {
67
67
  }
68
68
  }
69
69
 
70
+ let inFlightUpdateCheck: Promise<void> | null = null;
71
+
70
72
  export function checkForUpdateInBackground(ui?: ExtensionUIContext | null): void {
71
73
  try {
72
74
  if (isLinkedInstall()) return;
73
75
 
74
76
  const cached = getCachedUpdate();
77
+ const local = getLocalVersion();
75
78
  if (
76
79
  cached &&
77
80
  typeof cached.checkedAt === "number" &&
78
81
  Date.now() - cached.checkedAt < UPDATE_CHECK_TTL_MS
79
82
  ) {
83
+ if (local && compareVersions(cached.latest, local) > 0) {
84
+ try {
85
+ if (!getActiveRelayState().hideWidget) {
86
+ const targetUi = ui ?? getStatusUi();
87
+ targetUi?.setStatus?.("freeflow", `update: ${local} → ${cached.latest}`);
88
+ }
89
+ } catch { }
90
+ }
80
91
  return;
81
92
  }
82
93
 
83
- void fetchLatestVersion()
94
+ if (inFlightUpdateCheck !== null) return;
95
+
96
+ inFlightUpdateCheck = fetchLatestVersion()
84
97
  .then((latest) => {
85
98
  if (!latest) return;
86
99
  try {
@@ -119,6 +132,9 @@ export function checkForUpdateInBackground(ui?: ExtensionUIContext | null): void
119
132
  })
120
133
  .catch(() => {
121
134
  // swallow — offline / transient network error
135
+ })
136
+ .finally(() => {
137
+ inFlightUpdateCheck = null;
122
138
  });
123
139
  } catch {
124
140
  // swallow — never throw from background check
@@ -252,8 +252,11 @@ export function runUpdateStep(
252
252
  // unref is best-effort
253
253
  }
254
254
  const emit = (d: Buffer): void => {
255
- const s = String(d).trim();
256
- if (s) opts.onOutput?.(s);
255
+ const text = String(d);
256
+ for (const raw of text.split(/\r?\n/)) {
257
+ const s = raw.trim();
258
+ if (s) opts.onOutput?.(s);
259
+ }
257
260
  };
258
261
  child.stdout?.on("data", emit);
259
262
  child.stderr?.on("data", emit);
package/src/updater.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * spawnSync install (minutes on npm/bun) would freeze the host TUI.
13
13
  */
14
14
 
15
- import { spawn, spawnSync } from "node:child_process";
15
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
16
16
 
17
17
  import { getClientPort } from "./client.ts";
18
18
  import {
@@ -122,9 +122,14 @@ export function probeCommandsAvailable(cmds: string[]): Promise<boolean[]> {
122
122
  resolve(false);
123
123
  return;
124
124
  }
125
- const timer = setTimeout(() => resolve(false), PROBE_TIMEOUT_MS);
125
+ let child: ChildProcess | undefined;
126
+ const timer = setTimeout(() => {
127
+ try { child?.kill(); } catch { }
128
+ resolve(false);
129
+ }, PROBE_TIMEOUT_MS);
130
+ timer.unref?.();
126
131
  try {
127
- const child = spawn(cmd, ["--version"], {
132
+ child = spawn(cmd, ["--version"], {
128
133
  stdio: "ignore",
129
134
  windowsHide: true,
130
135
  shell: process.platform === "win32",
@@ -400,8 +400,28 @@ export function isUnprovenSession(parsedBody: unknown, pathname: string): boolea
400
400
  const key = sessionKeyOf(parsedBody);
401
401
  if (key && isEstablished(key)) return false;
402
402
  if (String(pathname ?? "").includes("responses")) return true;
403
- const messages = (parsedBody as { messages?: unknown }).messages;
404
- if (Array.isArray(messages) && messages.length > 1) return false;
403
+ const messages = typeof parsedBody === "object" && parsedBody !== null && "messages" in parsedBody
404
+ ? (parsedBody as { messages?: unknown }).messages
405
+ : undefined;
406
+ if (Array.isArray(messages)) {
407
+ const hasPriorTurn = messages.some(
408
+ (m) =>
409
+ m !== null &&
410
+ typeof m === "object" &&
411
+ "role" in m &&
412
+ (m.role === "assistant" || m.role === "tool"),
413
+ );
414
+ if (hasPriorTurn) return false;
415
+ const nonSystemCount = messages.filter(
416
+ (m) =>
417
+ m !== null &&
418
+ typeof m === "object" &&
419
+ "role" in m &&
420
+ m.role !== "system" &&
421
+ m.role !== "developer",
422
+ ).length;
423
+ if (nonSystemCount > 1) return false;
424
+ }
405
425
  return true;
406
426
  }
407
427
 
@@ -82,6 +82,7 @@ process.stdout.write(`${JSON.stringify({ status: res.status, headers: out })}\n`
82
82
  // A closed stdout means the bridge is gone; stop quietly instead of crashing
83
83
  // on EPIPE while the body is still streaming.
84
84
  process.stdout.on("error", () => process.exit(0));
85
+ process.stdout.on("close", () => process.exit(0));
85
86
  try {
86
87
  if (res.body) {
87
88
  for await (const c of res.body) {
@@ -13,6 +13,7 @@
13
13
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
+ import os from "node:os";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { Readable } from "node:stream";
18
19
  import { ZEN_TRANSPORT_ENV } from "./config.ts";
@@ -132,21 +133,32 @@ export function probeBunVersion(opts: {
132
133
  */
133
134
  export function findBunOnPath(envPath?: string): string | null {
134
135
  const raw = envPath ?? process.env.PATH ?? process.env.Path ?? "";
135
- if (!raw || typeof raw !== "string") return null;
136
- const extensions = process.platform === "win32"
137
- ? (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"])
138
- : [""];
139
- for (const dir of raw.split(path.delimiter).filter(Boolean)) {
140
- for (const ext of extensions) {
141
- const candidate = ext ? path.join(dir, `bun${ext}`) : path.join(dir, "bun");
142
- try {
143
- const stat = fs.statSync(candidate);
144
- if (!stat.isFile()) continue;
145
- if (process.platform !== "win32") fs.accessSync(candidate, fs.constants.X_OK);
146
- return candidate;
147
- } catch { }
136
+ if (raw && typeof raw === "string") {
137
+ const extensions = process.platform === "win32"
138
+ ? (process.env.PATHEXT ? process.env.PATHEXT.split(";").filter(Boolean) : [".exe", ".cmd", ".bat"])
139
+ : [""];
140
+ for (const dir of raw.split(path.delimiter).filter(Boolean)) {
141
+ for (const ext of extensions) {
142
+ const candidate = ext ? path.join(dir, `bun${ext}`) : path.join(dir, "bun");
143
+ try {
144
+ const stat = fs.statSync(candidate);
145
+ if (!stat.isFile()) continue;
146
+ if (process.platform !== "win32") fs.accessSync(candidate, fs.constants.X_OK);
147
+ return candidate;
148
+ } catch { }
149
+ }
148
150
  }
149
151
  }
152
+ if (envPath === undefined) {
153
+ const fallback = path.join(os.homedir(), ".bun", "bin", process.platform === "win32" ? "bun.exe" : "bun");
154
+ try {
155
+ const stat = fs.statSync(fallback);
156
+ if (stat.isFile()) {
157
+ if (process.platform !== "win32") fs.accessSync(fallback, fs.constants.X_OK);
158
+ return fallback;
159
+ }
160
+ } catch { }
161
+ }
150
162
  return null;
151
163
  }
152
164
 
@@ -252,11 +264,21 @@ export function fetchZenDirectViaSidecar(
252
264
  let stdoutEnded = false;
253
265
  const spawnFn = opts.spawnImpl ?? spawn;
254
266
  try {
255
- child = spawnFn(opts.runtimePath, [...(opts.runtimeArgs ?? []), opts.scriptPath], {
256
- stdio: ["pipe", "pipe", "pipe"],
257
- windowsHide: true,
258
- detached: false,
259
- });
267
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(opts.runtimePath) && opts.spawnImpl === undefined) {
268
+ const quotedCmd = `"${opts.runtimePath}" ${[...(opts.runtimeArgs ?? []), opts.scriptPath].map((a) => `"${a}"`).join(" ")}`;
269
+ child = spawnFn(quotedCmd, [], {
270
+ stdio: ["pipe", "pipe", "pipe"],
271
+ windowsHide: true,
272
+ detached: false,
273
+ shell: true,
274
+ });
275
+ } else {
276
+ child = spawnFn(opts.runtimePath, [...(opts.runtimeArgs ?? []), opts.scriptPath], {
277
+ stdio: ["pipe", "pipe", "pipe"],
278
+ windowsHide: true,
279
+ detached: false,
280
+ });
281
+ }
260
282
  } catch (e) {
261
283
  reject(e instanceof Error ? e : new Error(String(e)));
262
284
  return promise;
@@ -363,6 +385,14 @@ export function fetchZenDirectViaSidecar(
363
385
  // Backpressure: only resume the child pipe when the consumer wants more.
364
386
  if (!stdoutEnded) stdout.resume();
365
387
  },
388
+ destroy(err, cb) {
389
+ finished = true;
390
+ opts.signal?.removeEventListener("abort", onAbort);
391
+ try {
392
+ killZenSidecarTree(proc);
393
+ } catch { }
394
+ cb(err);
395
+ },
366
396
  });
367
397
  const pushBody = (c: Buffer): void => {
368
398
  if (nullBody || stream === null) return;