pi-freeflow 1.44.1 → 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 +23 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +4 -1
- package/src/client-staging.ts +53 -3
- package/src/client.ts +37 -19
- package/src/cline-accounts.ts +7 -4
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +176 -126
- package/src/cline-roll.ts +71 -38
- package/src/commands-relay.ts +2 -2
- package/src/commands-startup.ts +4 -1
- package/src/commands.ts +28 -6
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-tools.ts +1 -1
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +22 -3
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +5 -2
- package/src/opencode-identity.ts +10 -3
- package/src/proxy-lanes.ts +15 -3
- package/src/proxy-ports.ts +2 -2
- package/src/proxy.ts +29 -14
- package/src/relay-state.ts +9 -4
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +1 -0
- package/src/stream-cloak.ts +7 -4
- package/src/stream-pipe.ts +12 -2
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- 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/relay-state.ts
CHANGED
|
@@ -46,6 +46,7 @@ function isPrivateRelayHostname(hostname: string): boolean {
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
if (host.includes(":")) {
|
|
49
|
+
if (host.startsWith("64:ff9b:") || host.startsWith("2002:")) return true;
|
|
49
50
|
if (host.startsWith("fc") || host.startsWith("fd")) return true; // ULA fc00::/7
|
|
50
51
|
if (/^fe[89ab]/.test(host)) return true; // link-local fe80::/10
|
|
51
52
|
return false;
|
|
@@ -611,7 +612,8 @@ const RELAY_STATE_LOCK_FILE = `${RELAY_STATE_FILE}.lock`;
|
|
|
611
612
|
/** A lock older than this is presumed abandoned (crashed holder) and taken over. */
|
|
612
613
|
const LOCK_STALE_MS = 30_000;
|
|
613
614
|
|
|
614
|
-
function tryAcquireRelayLock(): boolean {
|
|
615
|
+
export function tryAcquireRelayLock(depth = 0): boolean {
|
|
616
|
+
if (depth >= 3) return false;
|
|
615
617
|
try {
|
|
616
618
|
fs.writeFileSync(RELAY_STATE_LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
617
619
|
return true;
|
|
@@ -630,9 +632,12 @@ function tryAcquireRelayLock(): boolean {
|
|
|
630
632
|
stale = Date.now() - fs.statSync(RELAY_STATE_LOCK_FILE).mtimeMs > LOCK_STALE_MS;
|
|
631
633
|
} catch {
|
|
632
634
|
// The lock vanished between the failed create and the stat — retry once.
|
|
633
|
-
return tryAcquireRelayLock();
|
|
635
|
+
return tryAcquireRelayLock(depth + 1);
|
|
636
|
+
}
|
|
637
|
+
if (!stale) {
|
|
638
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
639
|
+
return tryAcquireRelayLock(depth + 1);
|
|
634
640
|
}
|
|
635
|
-
if (!stale) return false;
|
|
636
641
|
try {
|
|
637
642
|
fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true });
|
|
638
643
|
} catch {
|
|
@@ -640,7 +645,7 @@ function tryAcquireRelayLock(): boolean {
|
|
|
640
645
|
// the proxy request path.
|
|
641
646
|
return false;
|
|
642
647
|
}
|
|
643
|
-
return tryAcquireRelayLock();
|
|
648
|
+
return tryAcquireRelayLock(depth + 1);
|
|
644
649
|
}
|
|
645
650
|
}
|
|
646
651
|
|
package/src/relay.ts
CHANGED
|
@@ -401,50 +401,97 @@ export async function relayFetch(
|
|
|
401
401
|
fetchA.catch(() => {});
|
|
402
402
|
fetchB.catch(() => {});
|
|
403
403
|
interface HedgedWin { url: string; response: Response; }
|
|
404
|
+
let hedgeBAbortedByWinner = false;
|
|
405
|
+
let bProducedOutcome = false;
|
|
406
|
+
const isCallerAborted = () => Boolean(opts.signal?.aborted);
|
|
407
|
+
const isNonWinning = (status: number): boolean =>
|
|
408
|
+
isRetriableStatus(status) || status === 403 || status === 401 || status === 402;
|
|
404
409
|
try {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
410
|
+
const { promise: winnerPromise, resolve, reject } = Promise.withResolvers<HedgedWin>();
|
|
411
|
+
let firstBad: HedgedWin | null = null;
|
|
412
|
+
let firstError: unknown = null;
|
|
413
|
+
let settledCount = 0;
|
|
414
|
+
const onResponse = (legUrl: string, response: Response) => {
|
|
415
|
+
settledCount += 1;
|
|
416
|
+
if (legUrl === nextUrl) {
|
|
417
|
+
bProducedOutcome = true;
|
|
418
|
+
}
|
|
419
|
+
if (!isNonWinning(response.status)) {
|
|
420
|
+
if (firstBad) {
|
|
421
|
+
firstBad.response.body?.cancel().catch(() => {});
|
|
422
|
+
markRelayFailure(firstBad.url, firstBad.response.status);
|
|
423
|
+
}
|
|
424
|
+
resolve({ url: legUrl, response });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!firstBad) {
|
|
428
|
+
if (settledCount >= 2) {
|
|
429
|
+
resolve({ url: legUrl, response });
|
|
430
|
+
} else {
|
|
431
|
+
firstBad = { url: legUrl, response };
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (legUrl === nextUrl) {
|
|
436
|
+
markRelayFailure(nextUrl, response.status);
|
|
437
|
+
response.body?.cancel().catch(() => {});
|
|
438
|
+
} else {
|
|
439
|
+
markRelayFailure(primaryUrl, response.status);
|
|
440
|
+
response.body?.cancel().catch(() => {});
|
|
441
|
+
}
|
|
442
|
+
resolve(firstBad);
|
|
443
|
+
};
|
|
444
|
+
const onError = (legUrl: string, err: unknown) => {
|
|
445
|
+
settledCount += 1;
|
|
446
|
+
if (legUrl === nextUrl && !hedgeBAbortedByWinner) {
|
|
447
|
+
bProducedOutcome = true;
|
|
448
|
+
}
|
|
449
|
+
if (firstBad) {
|
|
450
|
+
if (legUrl === nextUrl && !hedgeBAbortedByWinner && !isCallerAborted()) {
|
|
451
|
+
markRelayFailure(nextUrl, 0, (err as Error)?.message || String(err));
|
|
452
|
+
}
|
|
453
|
+
resolve(firstBad);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (firstError === null) {
|
|
457
|
+
firstError = err;
|
|
458
|
+
}
|
|
459
|
+
if (settledCount >= 2) {
|
|
460
|
+
reject(firstError);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
fetchA.then(
|
|
464
|
+
(r) => onResponse(primaryUrl, r),
|
|
465
|
+
(e) => onError(primaryUrl, e),
|
|
466
|
+
);
|
|
467
|
+
fetchB.then(
|
|
468
|
+
(r) => onResponse(nextUrl, r),
|
|
469
|
+
(e) => onError(nextUrl, e),
|
|
470
|
+
);
|
|
471
|
+
const winner = await winnerPromise;
|
|
422
472
|
if (winner.url === nextUrl) {
|
|
423
473
|
hedgeCtlA.abort();
|
|
424
474
|
targetUrl = nextUrl;
|
|
425
475
|
attemptStart = hedgeStart;
|
|
426
476
|
} else {
|
|
477
|
+
hedgeBAbortedByWinner = true;
|
|
427
478
|
hedgeCtlB.abort();
|
|
428
479
|
}
|
|
429
480
|
res = winner.response;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
481
|
+
if (bProducedOutcome) {
|
|
482
|
+
hedgeSkipNext = true;
|
|
483
|
+
}
|
|
433
484
|
} catch (hedgeErr) {
|
|
434
|
-
// Both hedged attempts failed without usable headers. Caller
|
|
435
|
-
// abort must propagate unmarked (the outer catch rethrows);
|
|
436
|
-
// otherwise record the next relay here and let the outer
|
|
437
|
-
// catch record the primary.
|
|
438
485
|
if ((hedgeErr as Error)?.name === "AbortError" && opts.signal?.aborted) {
|
|
439
486
|
throw hedgeErr;
|
|
440
487
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
488
|
+
if (bProducedOutcome) {
|
|
489
|
+
markRelayFailure(nextUrl, 0, (hedgeErr as Error)?.message || String(hedgeErr));
|
|
490
|
+
reportAbandoned(
|
|
491
|
+
(hedgeErr as Error)?.name === "AbortError" ? "header-timeout" : "network-error",
|
|
492
|
+
);
|
|
493
|
+
hedgeSkipNext = true;
|
|
494
|
+
}
|
|
448
495
|
throw hedgeErr;
|
|
449
496
|
}
|
|
450
497
|
}
|
package/src/sse-aggregator.ts
CHANGED
|
@@ -349,6 +349,7 @@ export function sseToResponsesJson(
|
|
|
349
349
|
const ev = events[i];
|
|
350
350
|
try {
|
|
351
351
|
const parsed = JSON.parse(ev.data);
|
|
352
|
+
if (parsed?.type === "response.created" || parsed?.type === "response.in_progress") continue;
|
|
352
353
|
if (parsed?.response && typeof parsed.response === "object") {
|
|
353
354
|
cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
|
|
354
355
|
return parsed.response as Record<string, unknown>;
|
package/src/stream-cloak.ts
CHANGED
|
@@ -222,6 +222,7 @@ export function shouldBypassStreamCloak(cloak?: StreamCloakOptions): boolean {
|
|
|
222
222
|
if (cloak.injected !== undefined && cloak.injected.length > 0) return false;
|
|
223
223
|
if (cloak.caseRestore && Object.keys(cloak.caseRestore).length > 0) return false;
|
|
224
224
|
if (cloak.findGlob?.renamedFindToGlob === true) return false;
|
|
225
|
+
if (cloak.findGlob?.renamed && Object.keys(cloak.findGlob.renamed).length > 0) return false;
|
|
225
226
|
// Tool-less callers must still see tool calls stripped when the
|
|
226
227
|
// injected record exists (legacy direct calls keep everything).
|
|
227
228
|
if (!cloak.callerHadTools && cloak.injected !== undefined) return false;
|
|
@@ -655,11 +656,13 @@ export function rewriteChatBlock(
|
|
|
655
656
|
// Hold this call's arguments: the packet is only judgeable once whole,
|
|
656
657
|
// so it is re-emitted repaired on the terminal finish_reason chunk.
|
|
657
658
|
// Reset-then-absorb mirrors the old per-index buffer exactly.
|
|
658
|
-
if (!call.buffered)
|
|
659
|
-
|
|
660
|
-
|
|
659
|
+
if (!call.buffered) {
|
|
660
|
+
state.audit?.repair("arguments-buffered", toolName);
|
|
661
|
+
call.buffered = true;
|
|
662
|
+
call.args = "";
|
|
663
|
+
}
|
|
661
664
|
if (fnRec && typeof fnRec.arguments === "string" && fnRec.arguments !== "") {
|
|
662
|
-
call.args
|
|
665
|
+
call.args += fnRec.arguments;
|
|
663
666
|
delete fnRec.arguments;
|
|
664
667
|
changed = true;
|
|
665
668
|
}
|
package/src/stream-pipe.ts
CHANGED
|
@@ -619,6 +619,7 @@ export function pipeUpstreamStream(
|
|
|
619
619
|
sseDecoder = new StringDecoder("utf8");
|
|
620
620
|
terminalScanCarry = Buffer.alloc(0);
|
|
621
621
|
watch(next);
|
|
622
|
+
scheduleStallCut(next);
|
|
622
623
|
try {
|
|
623
624
|
if (!cut.destroyed) cut.destroy();
|
|
624
625
|
} catch { }
|
|
@@ -939,6 +940,10 @@ export function pipeUpstreamStream(
|
|
|
939
940
|
ensureTerminalEvent(false, errorMsg || "stream interrupted", false);
|
|
940
941
|
} else {
|
|
941
942
|
if (await tryRefire()) return;
|
|
943
|
+
if (cloakState && cloak) {
|
|
944
|
+
const flushed = flushBufferedToolArgs(cloakState, cloak);
|
|
945
|
+
if (flushed !== null) forwardText(flushed);
|
|
946
|
+
}
|
|
942
947
|
sseBuffer = "";
|
|
943
948
|
terminalScanCarry = Buffer.alloc(0);
|
|
944
949
|
ensureTerminalEvent(!isSubstantial(totalChunks, totalBytes), errorMsg, true);
|
|
@@ -990,12 +995,17 @@ export function pipeUpstreamStream(
|
|
|
990
995
|
if (sseBuffer.trim() !== "") {
|
|
991
996
|
const tail = sseBuffer;
|
|
992
997
|
sseBuffer = "";
|
|
993
|
-
|
|
998
|
+
const isCutTerminal =
|
|
999
|
+
(tail.includes("response.completed") ||
|
|
1000
|
+
tail.includes("response.incomplete") ||
|
|
1001
|
+
tail.includes("response.failed")) &&
|
|
1002
|
+
!endsOnFinishedSseLine(tail);
|
|
1003
|
+
if (!isCutTerminal && checkTerminalEvent(tail)) {
|
|
994
1004
|
if (cloakState && cloak) {
|
|
995
1005
|
const rewritten = rewriteSseBlock(tail, cloakState, cloak);
|
|
996
1006
|
if (rewritten !== null) forwardText(`${rewritten}\n\n`);
|
|
997
1007
|
} else {
|
|
998
|
-
forwardText(tail);
|
|
1008
|
+
forwardText(tail.endsWith("\n") ? `${tail}\n` : `${tail}\n\n`);
|
|
999
1009
|
}
|
|
1000
1010
|
} else if (endsOnFinishedSseLine(tail)) {
|
|
1001
1011
|
// A complete final event that merely lost its trailing blank line: forward
|
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/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;
|