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.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
package/src/system-ca-fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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 {
|
package/src/tool-translation.ts
CHANGED
|
@@ -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";
|
package/src/toolcall-audit.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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:
|
package/src/update-checker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/update-core.ts
CHANGED
|
@@ -252,8 +252,11 @@ export function runUpdateStep(
|
|
|
252
252
|
// unref is best-effort
|
|
253
253
|
}
|
|
254
254
|
const emit = (d: Buffer): void => {
|
|
255
|
-
const
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
child = spawn(cmd, ["--version"], {
|
|
128
133
|
stdio: "ignore",
|
|
129
134
|
windowsHide: true,
|
|
130
135
|
shell: process.platform === "win32",
|
package/src/upstream-health.ts
CHANGED
|
@@ -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 =
|
|
404
|
-
|
|
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
|
|
package/src/zen-sidecar.ts
CHANGED
|
@@ -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) {
|
package/src/zen-transport.ts
CHANGED
|
@@ -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 (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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;
|