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.
@@ -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 winner = await new Promise<HedgedWin>((resolve, reject) => {
406
- let failures = 0;
407
- let firstError: unknown = null;
408
- const onError = (err: unknown) => {
409
- failures += 1;
410
- if (firstError === null) firstError = err;
411
- if (failures >= 2) reject(firstError);
412
- };
413
- fetchA.then(
414
- (response) => resolve({ url: primaryUrl, response }),
415
- onError,
416
- );
417
- fetchB.then(
418
- (response) => resolve({ url: nextUrl, response }),
419
- onError,
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
- // Both candidates are consumed by this race: skip the next
431
- // index so the loser is never retried in this request.
432
- hedgeSkipNext = true;
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
- markRelayFailure(nextUrl, 0, (hedgeErr as Error)?.message || String(hedgeErr));
442
- // Both legs of this race were abandoned; the outer catch
443
- // reports the primary one, this reports the hedge leg.
444
- reportAbandoned(
445
- (hedgeErr as Error)?.name === "AbortError" ? "header-timeout" : "network-error",
446
- );
447
- hedgeSkipNext = true;
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
  }
@@ -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>;
@@ -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) state.audit?.repair("arguments-buffered", toolName);
659
- call.buffered = true;
660
- call.args = "";
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 = fnRec.arguments;
665
+ call.args += fnRec.arguments;
663
666
  delete fnRec.arguments;
664
667
  changed = true;
665
668
  }
@@ -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
- if (checkTerminalEvent(tail)) {
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
@@ -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 {
@@ -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;