github-router 0.3.248 → 0.3.250

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.
@@ -11,7 +11,7 @@ import * as path$1 from "node:path";
11
11
  import path, { dirname, join } from "node:path";
12
12
  import process$1 from "node:process";
13
13
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
14
- import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, promises, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
14
+ import { chmodSync, closeSync, constants, cpSync, existsSync, mkdirSync, openSync, promises, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { Agent, ProxyAgent } from "undici";
17
17
  import { z } from "zod";
@@ -14245,6 +14245,9 @@ function isSensitivePath(absPath, workspaceAbs) {
14245
14245
  */
14246
14246
  function rejectWindowsHostilePath(raw) {
14247
14247
  if (/^[\\/]{2}/.test(raw)) return "rejected: UNC or device path";
14248
+ if (IS_WINDOWS) {
14249
+ if ((/^[A-Za-z]:/.test(raw) ? raw.slice(2) : raw).includes(":")) return "rejected: alternate data stream";
14250
+ }
14248
14251
  if (/^[A-Za-z]:(?![\\/])/.test(raw)) return "rejected: drive-relative path";
14249
14252
  return null;
14250
14253
  }
@@ -18750,6 +18753,103 @@ function preflightUrlPolicy(toolName, args) {
18750
18753
  return checkUrlPolicy(args.url);
18751
18754
  }
18752
18755
  //#endregion
18756
+ //#region src/lib/attachments.ts
18757
+ /**
18758
+ * Build a text + image tool result.
18759
+ *
18760
+ * TEXT FIRST, IMAGE SECOND — and this ordering is load-bearing for a reason
18761
+ * worth recording honestly: it is test-debt accommodation, not a design
18762
+ * argument. 75 assertions across the suite read `result.content[0].text`, and
18763
+ * putting the image first would break every one of them for no gain. Nothing
18764
+ * in the MCP spec or in any known consumer is sensitive to block order, so the
18765
+ * cheap accommodation wins. If that ever stops being true, codemod the
18766
+ * assertions rather than contorting the wire shape.
18767
+ */
18768
+ function mcpTextAndImage(text, image) {
18769
+ return { content: [{
18770
+ type: "text",
18771
+ text
18772
+ }, {
18773
+ type: "image",
18774
+ data: image.data,
18775
+ mimeType: image.mimeType
18776
+ }] };
18777
+ }
18778
+ /**
18779
+ * Media types that appear in some Copilot model's
18780
+ * `capabilities.limits.vision.supported_media_types`. This is the UNION across
18781
+ * models, not a per-model allowlist — an individual model accepts a subset
18782
+ * (gemini takes heic/heif but not gif; the gpt and claude lanes take gif but
18783
+ * not heic). Per-model narrowing is the outbound validator's job; this set only
18784
+ * answers "is this an image shape any Copilot model could take at all".
18785
+ */
18786
+ const SUPPORTED_IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
18787
+ "image/jpeg",
18788
+ "image/png",
18789
+ "image/webp",
18790
+ "image/gif",
18791
+ "image/heic",
18792
+ "image/heif"
18793
+ ]);
18794
+ function startsWith(bytes, offset, sig) {
18795
+ if (bytes.length < offset + sig.length) return false;
18796
+ for (const [i, element] of sig.entries()) if (bytes[offset + i] !== element) return false;
18797
+ return true;
18798
+ }
18799
+ function startsWithAscii(bytes, offset, ascii) {
18800
+ return startsWith(bytes, offset, [...ascii].map((c) => c.codePointAt(0) ?? 0));
18801
+ }
18802
+ const PNG_SIGNATURE = [
18803
+ 137,
18804
+ 80,
18805
+ 78,
18806
+ 71,
18807
+ 13,
18808
+ 10,
18809
+ 26,
18810
+ 10
18811
+ ];
18812
+ /**
18813
+ * Identify an image by its leading bytes, returning a media type from
18814
+ * `SUPPORTED_IMAGE_MIME_TYPES` or `undefined` when the bytes are not a
18815
+ * supported image.
18816
+ *
18817
+ * `undefined` is a deliberate two-in-one answer: "not an image" and "an image
18818
+ * kind nothing upstream accepts" are the same outcome for every caller here,
18819
+ * because both mean the bytes must not be sent as an image block.
18820
+ *
18821
+ * Truncated and malformed inputs return `undefined` rather than throwing — a
18822
+ * caller reading an arbitrary file off disk must not be able to crash a tool
18823
+ * dispatch with a 3-byte file.
18824
+ */
18825
+ function detectImageMimeType(bytes) {
18826
+ if (startsWith(bytes, 0, [
18827
+ 255,
18828
+ 216,
18829
+ 255
18830
+ ])) return bytes[3] === 247 ? void 0 : "image/jpeg";
18831
+ if (startsWith(bytes, 0, PNG_SIGNATURE)) return bytes.length >= 16 && new DataView(bytes.buffer, bytes.byteOffset).getUint32(PNG_SIGNATURE.length) === 13 && startsWithAscii(bytes, 12, "IHDR") ? "image/png" : void 0;
18832
+ if (startsWithAscii(bytes, 0, "GIF8")) return "image/gif";
18833
+ if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WEBP")) return "image/webp";
18834
+ if (startsWithAscii(bytes, 4, "ftyp")) {
18835
+ const brand = new TextDecoder().decode(bytes.subarray(8, 12));
18836
+ if (brand === "heic" || brand === "heix" || brand === "hevc" || brand === "hevx") return "image/heic";
18837
+ if (brand === "mif1" || brand === "msf1" || brand === "heim" || brand === "heis") return "image/heif";
18838
+ }
18839
+ }
18840
+ /** Decode base64 for size-validation purposes; see the notes inside. */
18841
+ function decodeBase64Strict(input) {
18842
+ const compact = input.replaceAll(/[\s]/g, "");
18843
+ if (compact.length === 0) return null;
18844
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
18845
+ const remainder = compact.length % 4;
18846
+ if (remainder === 1) return null;
18847
+ const padded = remainder === 0 ? compact : compact + "=".repeat(4 - remainder);
18848
+ const buf = Buffer.from(padded, "base64");
18849
+ if (buf.toString("base64") !== padded) return null;
18850
+ return new Uint8Array(buf);
18851
+ }
18852
+ //#endregion
18753
18853
  //#region src/lib/browser-mcp/dispatch.ts
18754
18854
  /**
18755
18855
  * Tools whose dispatch counts as a mutating user action for pacing
@@ -18969,6 +19069,52 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
18969
19069
  });
18970
19070
  });
18971
19071
  }
19072
+ /**
19073
+ * Turn a bridge envelope that carries captured pixels into a real MCP image
19074
+ * block, or return `null` when it doesn't (the overwhelmingly common case —
19075
+ * every non-screenshot tool).
19076
+ *
19077
+ * The extension returns `{contentType, dataBase64}` for `browser_screenshot`.
19078
+ * That used to be `JSON.stringify`'d into a text block, which meant the calling
19079
+ * model received base64 characters it could not interpret, at roughly 130x the
19080
+ * token cost of the same image sent natively (base64 tokenizes at ~1.46
19081
+ * chars/token under o200k, so a 200 KB PNG is ~187k tokens — enough to be
19082
+ * rejected outright by a 200k-context model).
19083
+ *
19084
+ * Two deliberate choices:
19085
+ *
19086
+ * - The media type is SNIFFED from the bytes, not taken from the envelope's
19087
+ * `contentType`. A declared type is an assertion; the payload is what
19088
+ * upstream will actually try to decode. The declared value is preserved in
19089
+ * the text block so a mismatch stays debuggable.
19090
+ * - The text block keeps every envelope field EXCEPT `dataBase64`, and gains
19091
+ * a decoded `bytes` count. Callers that parsed the JSON metadata keep
19092
+ * working; the payload simply stops being duplicated in a form nothing
19093
+ * could read.
19094
+ *
19095
+ * Bytes that don't decode strictly, or that aren't a supported image, fall
19096
+ * through to `null` and take the plain-text path — a malformed capture must
19097
+ * not become an image block that upstream then rejects.
19098
+ */
19099
+ function imageEnvelope(data) {
19100
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return null;
19101
+ const record = data;
19102
+ const encoded = record.dataBase64;
19103
+ if (typeof encoded !== "string" || encoded.length === 0) return null;
19104
+ const bytes = decodeBase64Strict(encoded);
19105
+ if (!bytes) return null;
19106
+ const mimeType = detectImageMimeType(bytes);
19107
+ if (!mimeType) return null;
19108
+ const meta = {
19109
+ bytes: bytes.length,
19110
+ mimeType
19111
+ };
19112
+ for (const [key, value] of Object.entries(record)) if (key !== "dataBase64") meta[key] = value;
19113
+ return mcpTextAndImage(JSON.stringify(meta, null, 2), {
19114
+ data: encoded,
19115
+ mimeType
19116
+ });
19117
+ }
18972
19118
  function blockedUrlEnvelope(reason) {
18973
19119
  return {
18974
19120
  content: [{
@@ -19034,7 +19180,6 @@ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
19034
19180
  token: ready.token
19035
19181
  }, tool, args, callerTimeout, signal);
19036
19182
  if (resp.ok) {
19037
- const text = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2);
19038
19183
  logAudit$1({
19039
19184
  tool,
19040
19185
  argsBytes: argsByteSize(args),
@@ -19042,9 +19187,9 @@ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
19042
19187
  profile: typeof args.profile === "string" ? args.profile : "isolated",
19043
19188
  result: "ok"
19044
19189
  });
19045
- return { content: [{
19190
+ return imageEnvelope(resp.data) ?? { content: [{
19046
19191
  type: "text",
19047
- text
19192
+ text: typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2)
19048
19193
  }] };
19049
19194
  }
19050
19195
  logAudit$1({
@@ -19674,6 +19819,169 @@ function currentInFlight() {
19674
19819
  return inFlight$2;
19675
19820
  }
19676
19821
  //#endregion
19822
+ //#region src/lib/vision-preflight.ts
19823
+ /**
19824
+ * Outbound vision preflight.
19825
+ *
19826
+ * Converts what would otherwise be an opaque upstream `400` into a local,
19827
+ * actionable rejection — the same trade `predictedWindowOverflow` in
19828
+ * `src/routes/mcp/handler.ts` already makes for prompt tokens, and the reason
19829
+ * this file exists at all: `capabilities.limits.vision` advertises three fields
19830
+ * (`max_prompt_images`, `max_prompt_image_size`, `supported_media_types`) and
19831
+ * until now the proxy read exactly one of them, in the `models` pretty-printer.
19832
+ *
19833
+ * WHY ONE CHOKEPOINT, NOT PER-ADAPTER CHECKS
19834
+ *
19835
+ * Images reach the wire through more paths than is obvious: a top-level user
19836
+ * block, a block nested inside a `tool_result`, the synthetic follow-up user
19837
+ * message the shim emits because a tool-output item cannot carry images, images
19838
+ * already present in replayed conversation history, and peer-critic
19839
+ * attachments. Validating in each adapter means each adapter has to
19840
+ * rediscover every one of those shapes, and the ones it forgets fail silently.
19841
+ * So validation runs ONCE, on the fully assembled payload, immediately before
19842
+ * transport serialization — `createResponses` and `createChatCompletions` — and
19843
+ * is therefore total by construction.
19844
+ *
19845
+ * POLICY ON MISSING METADATA
19846
+ *
19847
+ * Deliberately NOT fail-open. "We don't know the limit" is not "there is no
19848
+ * limit"; treating it as the latter reproduces the exact opaque-400 this
19849
+ * module exists to remove. The three cases are distinguished:
19850
+ *
19851
+ * - model absent from the catalog entirely → ALLOW. We have no basis for a
19852
+ * judgement, upstream remains authoritative, and blocking would break
19853
+ * custom catalogs and offline tests. This mirrors `modelSupportsEndpoint`.
19854
+ * - model present, `supports.vision` not true → REJECT, naming the model.
19855
+ * - model present, vision supported, limits absent → CONSERVATIVE FLOOR
19856
+ * (`FLOOR_MAX_IMAGES` / `FLOOR_MAX_IMAGE_BYTES`), and the message says so,
19857
+ * so the caller can tell a real limit from an assumed one.
19858
+ *
19859
+ * Error strings are returned to callers and end up in front of a model, so they
19860
+ * name the model, the numbers, and the fix — never a stack, a local path, or a
19861
+ * raw catalog object.
19862
+ */
19863
+ /**
19864
+ * Floor applied when a model advertises vision but publishes no limits. Every
19865
+ * vision-capable model in the live catalog reports at least 1 image and a
19866
+ * 3 MiB ceiling, so this is the observed minimum rather than a guess.
19867
+ */
19868
+ const FLOOR_MAX_IMAGES = 1;
19869
+ const FLOOR_MAX_IMAGE_BYTES = 3145728;
19870
+ /**
19871
+ * Split a `data:<mime>;base64,<payload>` URI. Returns `null` for any other URL
19872
+ * shape (including a plain remote `https://` reference, which the caller
19873
+ * handles separately because its bytes are not ours to inspect).
19874
+ */
19875
+ function parseDataUrl(url) {
19876
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(url);
19877
+ if (!match) return null;
19878
+ return {
19879
+ mimeType: match[1],
19880
+ base64: match[2]
19881
+ };
19882
+ }
19883
+ function reject(message) {
19884
+ return {
19885
+ ok: false,
19886
+ message
19887
+ };
19888
+ }
19889
+ /**
19890
+ * Validate every image on an outbound request against the resolved model's
19891
+ * advertised vision capability.
19892
+ *
19893
+ * Remote-URL images are counted toward the cardinality limit but not size- or
19894
+ * type-checked: the bytes live on someone else's server, so any local claim
19895
+ * about them would be fiction. Upstream stays authoritative for those.
19896
+ */
19897
+ function checkOutboundImages(modelId, images) {
19898
+ if (images.length === 0) return { ok: true };
19899
+ const model = state.models?.data.find((m) => m.id === modelId);
19900
+ if (!model) return { ok: true };
19901
+ if ((model.capabilities?.supports)?.vision !== true) return reject(`Model ${modelId} does not support image input. Send text only, or select a vision-capable model.`);
19902
+ const limits = model.capabilities?.limits?.vision;
19903
+ const maxImages = limits?.max_prompt_images ?? FLOOR_MAX_IMAGES;
19904
+ const maxBytes = limits?.max_prompt_image_size ?? FLOOR_MAX_IMAGE_BYTES;
19905
+ const assumed = limits?.max_prompt_images === void 0 ? " (assumed; the model publishes no image limits)" : "";
19906
+ if (images.length > maxImages) return reject(`Model ${modelId} accepts at most ${maxImages} image(s) per request${assumed}, but this request carries ${images.length}. Send fewer images, or use a model with a higher image limit.`);
19907
+ const allowedTypes = limits?.supported_media_types;
19908
+ for (const [index, image] of images.entries()) {
19909
+ if (image.url !== void 0 && image.base64 === void 0) continue;
19910
+ const position = `image ${index + 1} of ${images.length}`;
19911
+ if (image.declaredMimeType === void 0 || image.declaredMimeType.length === 0) return reject(`${position} has no media type. Anthropic's \`source.media_type\` (or a \`data:<mime>;base64,\` prefix) is required — it used to be defaulted, which silently asserted a type the model may not accept.`);
19912
+ if (image.base64 === void 0) continue;
19913
+ const bytes = decodeBase64Strict(image.base64);
19914
+ if (!bytes) return reject(`${position} is not valid base64 and cannot be sent.`);
19915
+ if (bytes.length > maxBytes) return reject(`${position} is ${bytes.length} bytes, over model ${modelId}'s ${maxBytes}-byte limit${assumed}. Re-capture at a smaller scale or lower quality.`);
19916
+ const actual = detectImageMimeType(bytes);
19917
+ if (!actual) return reject(`${position} is declared ${image.declaredMimeType} but its bytes are not a supported image (jpeg, png, webp, gif, heic, heif).`);
19918
+ if (actual !== image.declaredMimeType) return reject(`${position} is declared ${image.declaredMimeType} but its bytes are ${actual}. Send the correct media type.`);
19919
+ if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(actual)) return reject(`${position} is ${actual}, which model ${modelId} does not accept. Supported: ${allowedTypes.join(", ")}.`);
19920
+ }
19921
+ return { ok: true };
19922
+ }
19923
+ /** Collect the images on an assembled Copilot `/responses` payload. */
19924
+ function imagesInResponsesPayload(input) {
19925
+ const images = [];
19926
+ if (!Array.isArray(input)) return images;
19927
+ for (const item of input) {
19928
+ if (item === null || typeof item !== "object") continue;
19929
+ const content = item.content;
19930
+ if (!Array.isArray(content)) continue;
19931
+ for (const part of content) {
19932
+ if (part === null || typeof part !== "object") continue;
19933
+ const p = part;
19934
+ if (p.type !== "input_image" || typeof p.image_url !== "string") continue;
19935
+ images.push(fromUrl(p.image_url));
19936
+ }
19937
+ }
19938
+ return images;
19939
+ }
19940
+ /** Collect the images on an assembled Copilot `/chat/completions` payload. */
19941
+ function imagesInChatPayload(messages) {
19942
+ const images = [];
19943
+ if (!Array.isArray(messages)) return images;
19944
+ for (const message of messages) {
19945
+ if (message === null || typeof message !== "object") continue;
19946
+ const content = message.content;
19947
+ if (!Array.isArray(content)) continue;
19948
+ for (const part of content) {
19949
+ if (part === null || typeof part !== "object") continue;
19950
+ const p = part;
19951
+ if (p.type !== "image_url") continue;
19952
+ const url = p.image_url?.url;
19953
+ if (typeof url !== "string") continue;
19954
+ images.push(fromUrl(url));
19955
+ }
19956
+ }
19957
+ return images;
19958
+ }
19959
+ function fromUrl(url) {
19960
+ const parsed = parseDataUrl(url);
19961
+ return parsed ? {
19962
+ base64: parsed.base64,
19963
+ declaredMimeType: parsed.mimeType
19964
+ } : { url };
19965
+ }
19966
+ /**
19967
+ * Run the preflight and throw a client-visible 400 when it fails.
19968
+ *
19969
+ * Shaped as an `HTTPError` carrying a synthetic `Response` so it flows through
19970
+ * the existing `forwardError` path and reaches the client as a normal
19971
+ * Anthropic-format `invalid_request_error` — the same envelope an upstream
19972
+ * rejection would produce, except it names the actual problem and no upstream
19973
+ * request was made. Tests assert that second property: a preflight failure must
19974
+ * cost zero upstream calls.
19975
+ */
19976
+ function assertOutboundImagesOk(modelId, images) {
19977
+ const verdict = checkOutboundImages(modelId, images);
19978
+ if (verdict.ok) return;
19979
+ throw new HTTPError(verdict.message, new Response(JSON.stringify({ error: { message: verdict.message } }), {
19980
+ status: 400,
19981
+ headers: { "content-type": "application/json" }
19982
+ }));
19983
+ }
19984
+ //#endregion
19677
19985
  //#region src/lib/diagnose-response.ts
19678
19986
  const PREVIEW_LIMIT = 200;
19679
19987
  async function parseJsonOrDiagnose(response, routePath) {
@@ -19795,6 +20103,7 @@ async function readResponseBodyCapped(response, routePath, capBytes = MAX_RESPON
19795
20103
  const createChatCompletions = async (payload, modelHeaders, callerSignal, retryTransient = false) => {
19796
20104
  if (!state.copilotToken) throw new Error("Copilot token not found");
19797
20105
  const enableVision = payload.messages.some((x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"));
20106
+ if (enableVision) assertOutboundImagesOk(payload.model, imagesInChatPayload(payload.messages));
19798
20107
  const isAgentCall = payload.messages.some((msg) => ["assistant", "tool"].includes(msg.role));
19799
20108
  const url = `${copilotBaseUrl(state)}/chat/completions`;
19800
20109
  const doFetch = () => {
@@ -19857,6 +20166,7 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal, retryT
19857
20166
  const createResponses = async (payload, modelHeaders, callerSignal, retryTransient = false) => {
19858
20167
  if (!state.copilotToken) throw new Error("Copilot token not found");
19859
20168
  const enableVision = detectVision(payload.input);
20169
+ if (enableVision) assertOutboundImagesOk(payload.model, imagesInResponsesPayload(payload.input));
19860
20170
  const isAgentCall = detectAgentCall(payload.input);
19861
20171
  const url = `${copilotBaseUrl(state)}/responses`;
19862
20172
  const doFetch = () => {
@@ -19919,6 +20229,20 @@ function detectAgentCall(input) {
19919
20229
  //#endregion
19920
20230
  //#region src/services/copilot/endpoint.ts
19921
20231
  /**
20232
+ * Catalog spellings that mean each of our two clients. Copilot is not
20233
+ * self-consistent about the `/v1` prefix — the live catalog advertises
20234
+ * `/v1/messages` prefixed but `/chat/completions` bare, and this repo's own
20235
+ * fixtures carry both forms — so an exact-match on the bare spelling alone
20236
+ * silently misses a real shape. `src/lib/model-validation.ts` already
20237
+ * normalizes the same way (`ENDPOINT_ALIASES`); this keeps the two agreeing.
20238
+ *
20239
+ * Matching is EXACT against this set, never a suffix/`includes` test: a
20240
+ * `ws:/responses` (websocket transport) entry is NOT the `/responses` HTTP
20241
+ * client and must keep resolving to "serves neither".
20242
+ */
20243
+ const CHAT_ENDPOINTS = /* @__PURE__ */ new Set(["/chat/completions", "/v1/chat/completions"]);
20244
+ const RESPONSES_ENDPOINTS = /* @__PURE__ */ new Set(["/responses", "/v1/responses"]);
20245
+ /**
19922
20246
  * Decide which endpoint to call for a model from its catalog
19923
20247
  * `supported_endpoints`. Prefers `/chat/completions` when available (the
19924
20248
  * simpler, more widely-supported shape) and falls back to `/responses` for
@@ -19934,19 +20258,37 @@ function detectAgentCall(input) {
19934
20258
  function pickEndpoint(model) {
19935
20259
  const eps = model.supported_endpoints;
19936
20260
  if (!eps || eps.length === 0) return "chat";
19937
- if (eps.includes("/chat/completions")) return "chat";
19938
- if (eps.includes("/responses")) return "responses";
20261
+ if (eps.some((e) => CHAT_ENDPOINTS.has(e))) return "chat";
20262
+ if (eps.some((e) => RESPONSES_ENDPOINTS.has(e))) return "responses";
19939
20263
  }
19940
20264
  /**
19941
- * `pickEndpoint` by model id against the live catalog. Returns "chat" when
19942
- * the id isn't in the catalog (unknown models default to the chat shape,
19943
- * matching the field-absent rule above) — callers that need a hard
19944
- * presence check should look the model up themselves.
20265
+ * `pickEndpoint` by model id against the live catalog, WITHOUT collapsing
20266
+ * "absent from the catalog" into "serves neither of our endpoints".
20267
+ *
20268
+ * This function deliberately has no default. The predecessor
20269
+ * (`endpointForModelId`) returned `pickEndpoint(found) ?? "chat"`, which
20270
+ * coerced both cases to "chat" — defensible for an unknown id, silently wrong
20271
+ * for a catalog model serving only, say, `/v1/messages`: the caller would drive
20272
+ * it through the chat client and get an opaque upstream 400 with no local
20273
+ * signal about the real cause. `src/lib/browser-mcp/compressor.ts` already
20274
+ * treats that case correctly (`if (!endpoint) continue`); this makes the same
20275
+ * distinction available to callers that resolve by id.
20276
+ *
20277
+ * Callers that legitimately want the chat default for an unknown id can still
20278
+ * have it — they just have to write it, per case, on purpose.
19945
20279
  */
19946
- function endpointForModelId(id) {
20280
+ function resolveEndpointForModelId(id) {
19947
20281
  const found = state.models?.data?.find((m) => m.id === id);
19948
- if (!found) return "chat";
19949
- return pickEndpoint(found) ?? "chat";
20282
+ if (!found) return { kind: "unknown-model" };
20283
+ const endpoint = pickEndpoint(found);
20284
+ if (endpoint) return {
20285
+ kind: "endpoint",
20286
+ endpoint
20287
+ };
20288
+ return {
20289
+ kind: "unreachable",
20290
+ endpoints: found.supported_endpoints ?? []
20291
+ };
19950
20292
  }
19951
20293
  //#endregion
19952
20294
  //#region src/lib/browser-mcp/compressor.ts
@@ -20846,7 +21188,7 @@ const BROWSER_TOOLS = Object.freeze([
20846
21188
  },
20847
21189
  {
20848
21190
  toolNameHttp: "browser_screenshot",
20849
- description: "Captures a screenshot of the visible area of a tab, as PNG by default or JPEG when requested. It takes a tab id and optional format, then returns base64-encoded image bytes plus contentType. The tab must be active in its window, so this tool auto-activates the tab if needed and that changes which tab is focused. Use screenshot for visual layout, canvas, SVG, maps, or image-only regions; prefer browser_observe when page text and actionable state are enough.",
21191
+ description: "Captures a screenshot of the visible area of a tab and returns it as an image you can actually look at, plus a small text envelope of capture metadata. It takes a tab id, an optional format (PNG default, JPEG for smaller bytes), and an optional JPEG quality. The tab must be active in its window, so this tool auto-activates the tab if needed and that changes which tab is focused. If a capture is rejected for exceeding a model's image-size limit, the most reliable lever is a smaller browser window; PNG is usually SMALLER than JPEG for ordinary UI and text pages, so switching format is not a dependable way to shrink one. JPEG plus a low quality helps mainly for photographic or dense-colour content. Use screenshot for visual layout, canvas, SVG, maps, or image-only regions; prefer browser_observe when page text and actionable state are enough.",
20850
21192
  inputSchema: {
20851
21193
  type: "object",
20852
21194
  required: ["tabId"],
@@ -20860,6 +21202,12 @@ const BROWSER_TOOLS = Object.freeze([
20860
21202
  type: "string",
20861
21203
  enum: ["png", "jpeg"],
20862
21204
  description: "Image format for the returned screenshot. Default 'png'; use 'jpeg' when smaller image bytes are preferable."
21205
+ },
21206
+ quality: {
21207
+ type: "number",
21208
+ minimum: 1,
21209
+ maximum: 100,
21210
+ description: "JPEG quality 1-100 (ignored for PNG). Only meaningful with format='jpeg'. Note JPEG is often LARGER than PNG for flat UI screenshots — measured on a plain documentation page, PNG 35553 bytes vs JPEG q30 40599 bytes at the same viewport — so reach for it for photographic content, not as a general size lever."
20863
21211
  }
20864
21212
  }
20865
21213
  },
@@ -21590,21 +21938,12 @@ async function runAtomicIntentStep(tabId, intent, value, signal) {
21590
21938
  error: "no text match; screenshot for visual fallback failed",
21591
21939
  picked
21592
21940
  }, true);
21593
- const shotText = shotEnv.content?.[0]?.text;
21594
- let shot = {};
21595
- try {
21596
- shot = shotText ? JSON.parse(shotText) : {};
21597
- } catch {
21598
- return toolEnvelope({
21599
- ok: false,
21600
- error: "no text match; screenshot envelope unparseable"
21601
- }, true);
21602
- }
21603
- if (!shot.contentType || !shot.dataBase64) return toolEnvelope({
21941
+ const shot = shotEnv.content.find((b) => b.type === "image");
21942
+ if (!shot) return toolEnvelope({
21604
21943
  ok: false,
21605
- error: "no text match; screenshot envelope missing fields"
21944
+ error: "no text match; screenshot returned no image"
21606
21945
  }, true);
21607
- const visual = await pickElementVisual(shot.dataBase64, shot.contentType, intent, surfaces, signal);
21946
+ const visual = await pickElementVisual(shot.data, shot.mimeType, intent, surfaces, signal);
21608
21947
  if (visual.confidence < .5) return toolEnvelope({
21609
21948
  ok: false,
21610
21949
  error: "no element matched intent (text + visual)",
@@ -24169,7 +24508,12 @@ async function runStreamLoop(stream, context, opts, options) {
24169
24508
  return;
24170
24509
  }
24171
24510
  }
24172
- if (endpointForModelId(resolved.modelId) === "responses") {
24511
+ const resolution = resolveEndpointForModelId(resolved.modelId);
24512
+ if (resolution.kind === "unreachable") {
24513
+ pushUndrivableModelDiagnostic(stream, resolved, resolution.endpoints);
24514
+ return;
24515
+ }
24516
+ if (resolution.kind === "endpoint" && resolution.endpoint === "responses") {
24173
24517
  await runResponsesStreamLoop(stream, context, opts, options);
24174
24518
  return;
24175
24519
  }
@@ -24195,10 +24539,14 @@ async function runChatAttempt(stream, payload, opts, signal, state) {
24195
24539
  let nextContentIndex = 0;
24196
24540
  let activeTextIndex = null;
24197
24541
  const toolPiIndexByOAI = /* @__PURE__ */ new Map();
24542
+ let sawDone = false;
24198
24543
  for await (const evt of sseStream) {
24199
24544
  const data = evt?.data;
24200
24545
  if (data == null) continue;
24201
- if (data === "[DONE]") break;
24546
+ if (data === "[DONE]") {
24547
+ sawDone = true;
24548
+ break;
24549
+ }
24202
24550
  let chunk;
24203
24551
  try {
24204
24552
  chunk = JSON.parse(data);
@@ -24291,12 +24639,26 @@ async function runChatAttempt(stream, payload, opts, signal, state) {
24291
24639
  if (choice.finish_reason) accum.finishReason = choice.finish_reason;
24292
24640
  }
24293
24641
  if (!state.active) return;
24642
+ if (!sawDone && accum.finishReason == null) {
24643
+ if (activeTextIndex != null) stream.push({
24644
+ type: "text_end",
24645
+ contentIndex: activeTextIndex,
24646
+ content: joinTextChunks(accum, activeTextIndex),
24647
+ partial: buildPartial(resolved, accum)
24648
+ });
24649
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("chat stream ended without a [DONE] sentinel (truncated)"));
24650
+ return;
24651
+ }
24294
24652
  if (activeTextIndex != null) stream.push({
24295
24653
  type: "text_end",
24296
24654
  contentIndex: activeTextIndex,
24297
24655
  content: joinTextChunks(accum, activeTextIndex),
24298
24656
  partial: buildPartial(resolved, accum)
24299
24657
  });
24658
+ if (accum.finishReason === "content_filter") {
24659
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("upstream content filter blocked the response"));
24660
+ return;
24661
+ }
24300
24662
  for (const block of accum.blocks) {
24301
24663
  if (block.kind !== "tool") continue;
24302
24664
  const entry = accum.toolByIndex.get(block.contentIndex);
@@ -24322,10 +24684,7 @@ function buildPayload(context, resolved) {
24322
24684
  role: "system",
24323
24685
  content: context.systemPrompt
24324
24686
  });
24325
- for (const m of context.messages) {
24326
- const oai = translateMessage(m);
24327
- if (oai) messages.push(oai);
24328
- }
24687
+ messages.push(...translateMessages(context.messages));
24329
24688
  const tools = translateTools(context.tools);
24330
24689
  const payload = {
24331
24690
  model: resolved.modelId,
@@ -24339,11 +24698,55 @@ function buildPayload(context, resolved) {
24339
24698
  if (resolved.thinking !== "off") payload.reasoning_effort = resolved.thinking;
24340
24699
  return payload;
24341
24700
  }
24342
- function translateMessage(m) {
24343
- if (m.role === "user") return translateUser(m);
24344
- if (m.role === "assistant") return translateAssistant(m);
24345
- if (m.role === "toolResult") return translateToolResult(m);
24346
- return null;
24701
+ /**
24702
+ * Pi messages wire messages, with tool-result images hoisted to the END of
24703
+ * each contiguous run of tool results.
24704
+ *
24705
+ * A tool-output wire item cannot carry an image, so images have to ride in a
24706
+ * separate user message. The obvious implementation — fan each `toolResult` out
24707
+ * to `[tool, user]` independently — is WRONG for parallel tool calls: it yields
24708
+ * `tool A, user A, tool B, user B`, and every provider requires the tool
24709
+ * messages answering one assistant turn to be CONTIGUOUS. The interjected user
24710
+ * message orphans the following tool message and the request is rejected.
24711
+ *
24712
+ * So images accumulate across the run and flush once, after the last tool
24713
+ * message of that run: `tool A, tool B, user(imgA, imgB)`.
24714
+ *
24715
+ * This runs at REQUEST-ASSEMBLY time over `context.messages` and never writes
24716
+ * back to worker state, so the synthetic message is ephemeral — an image is not
24717
+ * appended to the transcript and re-sent on every later turn.
24718
+ */
24719
+ function translateMessages(messages) {
24720
+ const out = [];
24721
+ let pending = [];
24722
+ const flushImages = () => {
24723
+ if (pending.length === 0) return;
24724
+ out.push({
24725
+ role: "user",
24726
+ content: pending.map((img) => ({
24727
+ type: "image_url",
24728
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` }
24729
+ }))
24730
+ });
24731
+ pending = [];
24732
+ };
24733
+ for (const m of messages) {
24734
+ if (m.role === "toolResult") {
24735
+ const images = imagePartsOf(m.content);
24736
+ out.push({
24737
+ role: "tool",
24738
+ tool_call_id: m.toolCallId,
24739
+ content: toolResultText(m, images.length > 0)
24740
+ });
24741
+ pending.push(...images);
24742
+ continue;
24743
+ }
24744
+ flushImages();
24745
+ if (m.role === "user") out.push(translateUser(m));
24746
+ else if (m.role === "assistant") out.push(translateAssistant(m));
24747
+ }
24748
+ flushImages();
24749
+ return out;
24347
24750
  }
24348
24751
  function translateUser(m) {
24349
24752
  if (typeof m.content === "string") return {
@@ -24386,13 +24789,6 @@ function translateAssistant(m) {
24386
24789
  if (toolCalls.length > 0) out.tool_calls = toolCalls;
24387
24790
  return out;
24388
24791
  }
24389
- function translateToolResult(m) {
24390
- return {
24391
- role: "tool",
24392
- tool_call_id: m.toolCallId,
24393
- content: joinTextParts(m.content)
24394
- };
24395
- }
24396
24792
  function translateTools(tools) {
24397
24793
  if (!tools || tools.length === 0) return void 0;
24398
24794
  return tools.map((t) => ({
@@ -24404,6 +24800,37 @@ function translateTools(tools) {
24404
24800
  }
24405
24801
  }));
24406
24802
  }
24803
+ /** The image parts of a Pi tool result, in wire order. */
24804
+ function imagePartsOf(parts) {
24805
+ const out = [];
24806
+ for (const p of parts) {
24807
+ if (p.type !== "image") continue;
24808
+ if (typeof p.data !== "string" || p.data.length === 0) continue;
24809
+ out.push({
24810
+ data: p.data,
24811
+ mimeType: p.mimeType ?? "image/png"
24812
+ });
24813
+ }
24814
+ return out;
24815
+ }
24816
+ /**
24817
+ * The text of a tool result, with two pieces of information restored that were
24818
+ * previously thrown away:
24819
+ *
24820
+ * - `isError`. Pi records it on the message, but the wire tool-output item has
24821
+ * no error flag, so without a marker the model could not tell a failed call
24822
+ * from a successful one. The Anthropic shim has always prefixed
24823
+ * `[tool error]`; this matches it.
24824
+ * - a pointer to the follow-up image message, when the tool returned only
24825
+ * images. An empty tool output is confusing on its own; naming what follows
24826
+ * is not.
24827
+ */
24828
+ function toolResultText(m, hasImages) {
24829
+ let text = joinTextParts(m.content);
24830
+ if (hasImages && text.length === 0) text = "[image result below]";
24831
+ if (m.isError === true) text = text.length > 0 ? `[tool error] ${text}` : "[tool error]";
24832
+ return text;
24833
+ }
24407
24834
  function joinTextParts(parts) {
24408
24835
  let s = "";
24409
24836
  for (const p of parts) if (p.type === "text" && typeof p.text === "string") s += p.text;
@@ -24476,6 +24903,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24476
24903
  });
24477
24904
  activeTextIndex = null;
24478
24905
  };
24906
+ let sawTerminal = false;
24479
24907
  for await (const evt of sseStream) {
24480
24908
  const data = evt?.data;
24481
24909
  if (data == null) continue;
@@ -24621,6 +25049,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24621
25049
  }
24622
25050
  case "response.completed":
24623
25051
  case "response.incomplete":
25052
+ sawTerminal = true;
24624
25053
  accum.usage = mapResponsesUsage(ev.response?.usage);
24625
25054
  if (ev.type === "response.incomplete" && ev.response?.incomplete_details?.reason === "max_output_tokens") accum.finishReason = "length";
24626
25055
  if (opts.onChunk && accum.usage) try {
@@ -24642,6 +25071,11 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24642
25071
  }
24643
25072
  }
24644
25073
  if (!state.active) return;
25074
+ if (!sawTerminal) {
25075
+ closeActiveText();
25076
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("responses stream ended without a terminal event (truncated)"));
25077
+ return;
25078
+ }
24645
25079
  closeActiveText();
24646
25080
  for (const block of accum.blocks) {
24647
25081
  if (block.kind !== "tool") continue;
@@ -24665,11 +25099,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24665
25099
  });
24666
25100
  }
24667
25101
  function buildResponsesPayload(context, resolved) {
24668
- const messages = [];
24669
- for (const m of context.messages) {
24670
- const neutral = piMessageToNeutral(m);
24671
- if (neutral) messages.push(neutral);
24672
- }
25102
+ const messages = piMessagesToNeutral(context.messages);
24673
25103
  return assembleResponsesPayload({
24674
25104
  model: resolved.modelId,
24675
25105
  instructions: context.systemPrompt || void 0,
@@ -24679,50 +25109,82 @@ function buildResponsesPayload(context, resolved) {
24679
25109
  stream: true
24680
25110
  });
24681
25111
  }
24682
- function piMessageToNeutral(m) {
24683
- if (m.role === "user") {
24684
- if (typeof m.content === "string") return {
25112
+ /**
25113
+ * Pi messages neutral messages, with tool-result images hoisted to the end of
25114
+ * each contiguous run of tool results.
25115
+ *
25116
+ * Exact twin of `translateMessages` on the chat path — see that doc comment for
25117
+ * why the grouping is load-bearing rather than cosmetic. A per-message fan-out
25118
+ * interleaves a user message between parallel `function_call_output` items,
25119
+ * which at best is a shape no client emits and at worst is rejected outright.
25120
+ */
25121
+ function piMessagesToNeutral(messages) {
25122
+ const out = [];
25123
+ let pending = [];
25124
+ const flushImages = () => {
25125
+ if (pending.length === 0) return;
25126
+ out.push({
24685
25127
  role: "user",
24686
- content: m.content
24687
- };
24688
- const parts = [];
24689
- for (const c of m.content) if (c.type === "text") parts.push({
24690
- type: "text",
24691
- text: c.text
25128
+ content: pending.map((img) => ({
25129
+ type: "image",
25130
+ mimeType: img.mimeType,
25131
+ data: img.data
25132
+ }))
24692
25133
  });
24693
- else if (c.type === "image") parts.push({
24694
- type: "image",
24695
- mimeType: c.mimeType,
24696
- data: c.data
24697
- });
24698
- return {
25134
+ pending = [];
25135
+ };
25136
+ for (const m of messages) {
25137
+ if (m.role === "toolResult") {
25138
+ const images = imagePartsOf(m.content);
25139
+ out.push({
25140
+ role: "toolResult",
25141
+ toolCallId: m.toolCallId,
25142
+ output: toolResultText(m, images.length > 0)
25143
+ });
25144
+ pending.push(...images);
25145
+ continue;
25146
+ }
25147
+ flushImages();
25148
+ if (m.role === "user") if (typeof m.content === "string") out.push({
24699
25149
  role: "user",
24700
- content: parts
24701
- };
24702
- }
24703
- if (m.role === "assistant") {
24704
- const parts = [];
24705
- for (const c of m.content) if (c.type === "text") parts.push({
24706
- type: "text",
24707
- text: c.text
24708
- });
24709
- else if (c.type === "toolCall") parts.push({
24710
- type: "toolCall",
24711
- id: c.id,
24712
- name: c.name,
24713
- arguments: c.arguments
25150
+ content: m.content
24714
25151
  });
24715
- return {
24716
- role: "assistant",
24717
- content: parts
24718
- };
25152
+ else {
25153
+ const parts = [];
25154
+ for (const c of m.content) if (c.type === "text") parts.push({
25155
+ type: "text",
25156
+ text: c.text
25157
+ });
25158
+ else if (c.type === "image") parts.push({
25159
+ type: "image",
25160
+ mimeType: c.mimeType,
25161
+ data: c.data
25162
+ });
25163
+ out.push({
25164
+ role: "user",
25165
+ content: parts
25166
+ });
25167
+ }
25168
+ else if (m.role === "assistant") {
25169
+ const parts = [];
25170
+ for (const c of m.content) if (c.type === "text") parts.push({
25171
+ type: "text",
25172
+ text: c.text
25173
+ });
25174
+ else if (c.type === "toolCall") parts.push({
25175
+ type: "toolCall",
25176
+ id: c.id,
25177
+ name: c.name,
25178
+ arguments: c.arguments
25179
+ });
25180
+ out.push({
25181
+ role: "assistant",
25182
+ content: parts
25183
+ });
25184
+ }
24719
25185
  }
24720
- if (m.role === "toolResult") return {
24721
- role: "toolResult",
24722
- toolCallId: m.toolCallId,
24723
- output: joinTextParts(m.content)
24724
- };
24725
- return null;
25186
+ flushImages();
25187
+ return out;
24726
25188
  }
24727
25189
  function piToolsToNeutral(tools) {
24728
25190
  if (!tools || tools.length === 0) return void 0;
@@ -24993,6 +25455,33 @@ function pushBackstopDiagnostic(stream, resolved, assembledTokens, limitTokens)
24993
25455
  error: final
24994
25456
  });
24995
25457
  }
25458
+ /**
25459
+ * Terminal diagnostic for a model the worker cannot drive AT ALL: it is in the
25460
+ * live catalog, but its `supported_endpoints` name neither `/chat/completions`
25461
+ * nor `/responses` — our only two clients. Fails here, locally, naming the
25462
+ * model and what it actually serves, instead of coercing it onto the chat
25463
+ * client and surfacing an opaque upstream `unsupported_api_for_model` 400.
25464
+ * Carried as assistant TEXT so the engine surfaces it as an `isError` result
25465
+ * (same shape as the request-boundary backstop).
25466
+ */
25467
+ function pushUndrivableModelDiagnostic(stream, resolved, endpoints) {
25468
+ const served = endpoints.length > 0 ? endpoints.join(", ") : "(none advertised)";
25469
+ const text = `Cannot run: ${resolved.modelId} is in the Copilot catalog but serves neither /chat/completions nor /responses — the only two APIs a worker can drive. Its catalog supported_endpoints are: ${served}. Pick a different model for this call, or change the mode default via worker_defaults (a zero-arg worker_defaults call lists the models a worker can be pointed at).`;
25470
+ const final = {
25471
+ ...makeBaseMessage(resolved),
25472
+ content: [{
25473
+ type: "text",
25474
+ text
25475
+ }],
25476
+ stopReason: "error",
25477
+ errorMessage: `model ${resolved.modelId} serves no worker-drivable endpoint (${served})`
25478
+ };
25479
+ stream.push({
25480
+ type: "error",
25481
+ reason: "error",
25482
+ error: final
25483
+ });
25484
+ }
24996
25485
  function describeError(err) {
24997
25486
  if (err instanceof HTTPError) return `${err.message} (status ${err.response.status})`;
24998
25487
  if (err instanceof Error) return err.message;
@@ -25030,7 +25519,37 @@ function argsRecord(params) {
25030
25519
  * `tools.ts` uses for `peer_review`.
25031
25520
  */
25032
25521
  function joinEnvelopeText(env) {
25033
- return (env.content ?? []).map((c) => c.text).join("\n");
25522
+ return (env.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
25523
+ }
25524
+ /**
25525
+ * Wrap a text payload PLUS a captured image in Pi's tool-result shape.
25526
+ *
25527
+ * The browse worker's own system prompt tells it to "use screenshot to SEE the
25528
+ * page", but every result was flattened to text — so the pixels never reached
25529
+ * it and it received base64 characters instead. Pi's tool-result content
25530
+ * supports image blocks natively, and the engine's `afterToolCall` cap
25531
+ * (`tool-output-cap.ts`) already preserves them and exempts them from the text
25532
+ * byte cap. Nothing was ever producing one.
25533
+ *
25534
+ * Text first, image second — same ordering rule as the MCP surface
25535
+ * (`mcpTextAndImage`), so a consumer reading the first block still finds text.
25536
+ */
25537
+ function imageResult$1(text, image) {
25538
+ return {
25539
+ content: [{
25540
+ type: "text",
25541
+ text
25542
+ }, {
25543
+ type: "image",
25544
+ data: image.data,
25545
+ mimeType: image.mimeType
25546
+ }],
25547
+ details: {}
25548
+ };
25549
+ }
25550
+ /** The first image block on a dispatch envelope, if the tool captured one. */
25551
+ function envelopeImage(env) {
25552
+ return (env.content ?? []).find((c) => c.type === "image");
25034
25553
  }
25035
25554
  /**
25036
25555
  * How a tool interacts with a session's owned tabs:
@@ -25326,7 +25845,8 @@ function makeBrowserTool(meta, parameters, dispatch, sessionId) {
25326
25845
  if (typeof tabId === "number") recordSessionTab(sessionId, tabId);
25327
25846
  } else if (policy === "closes") for (const tabId of toNumberArray(args.tabIds)) releaseSessionTab(sessionId, tabId);
25328
25847
  }
25329
- return textResult$1(text);
25848
+ const image = envelopeImage(env);
25849
+ return image ? imageResult$1(text, image) : textResult$1(text);
25330
25850
  }
25331
25851
  };
25332
25852
  if (meta.executionMode) tool.executionMode = meta.executionMode;
@@ -25596,6 +26116,62 @@ function truncateModelText(text, capBytes) {
25596
26116
  return head + notice + tail;
25597
26117
  }
25598
26118
  /**
26119
+ * Per-result image budget.
26120
+ *
26121
+ * Images are cheap in TOKENS (a vision image costs ~1.5k regardless of byte
26122
+ * size) but not in BYTES: three 3 MiB captures is ~12 MiB of base64 on the wire
26123
+ * and in memory, and they accumulate across turns. The text cap does not bound
26124
+ * them — and should not, since counting base64 against a text budget would
26125
+ * evict real text to make room for an image the model reads for free.
26126
+ *
26127
+ * `MAX_IMAGES_PER_RESULT` is the ceiling of the most permissive model in the
26128
+ * live catalog (gemini's 10). `MAX_IMAGE_BYTES_PER_RESULT` is the published
26129
+ * 3 MiB per-image ceiling times a small factor, so a legitimate multi-image
26130
+ * result passes and a runaway one does not.
26131
+ */
26132
+ const MAX_IMAGES_PER_RESULT = 10;
26133
+ const MAX_IMAGE_BYTES_PER_RESULT = 12582912;
26134
+ /** Decoded byte size of a base64 payload, without allocating the buffer. */
26135
+ function base64Bytes(data) {
26136
+ if (typeof data !== "string" || data.length === 0) return 0;
26137
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
26138
+ return Math.floor(data.length * 3 / 4) - padding;
26139
+ }
26140
+ /**
26141
+ * Trim an image list to the budget, returning the survivors plus a note naming
26142
+ * what was dropped. Dropping silently is not an option: a model told to compare
26143
+ * five screenshots, shown three, and given no indication, will reason
26144
+ * confidently about a set it never saw.
26145
+ */
26146
+ function capImages(images) {
26147
+ const kept = [];
26148
+ let bytes = 0;
26149
+ let dropped = 0;
26150
+ for (const img of images) {
26151
+ if (kept.length >= MAX_IMAGES_PER_RESULT) {
26152
+ dropped++;
26153
+ continue;
26154
+ }
26155
+ const size = base64Bytes(img.data);
26156
+ if (bytes + size > MAX_IMAGE_BYTES_PER_RESULT) {
26157
+ dropped++;
26158
+ continue;
26159
+ }
26160
+ bytes += size;
26161
+ kept.push(img);
26162
+ }
26163
+ if (dropped === 0) return {
26164
+ kept,
26165
+ note: void 0
26166
+ };
26167
+ return {
26168
+ kept,
26169
+ note: NEWLINE + NEWLINE + `[...${dropped} of ${images.length} image(s) dropped: over the per-result budget of ${MAX_IMAGES_PER_RESULT} images / ${Math.round(MAX_IMAGE_BYTES_PER_RESULT / 1048576)} MiB. Request fewer images at a time, or capture at a lower quality....]`
26170
+ };
26171
+ }
26172
+ /** Literal newline, kept out of the template above for readability. */
26173
+ const NEWLINE = "\n";
26174
+ /**
25599
26175
  * Cap a tool result's TEXT content to `capBytes`, preserving any non-text
25600
26176
  * (image) blocks. Returns the replacement content array, or `undefined` when
25601
26177
  * the result is already under the cap (caller leaves it untouched).
@@ -25616,20 +26192,180 @@ function capToolResultText(content, capBytes) {
25616
26192
  let textBytes = 0;
25617
26193
  const texts = [];
25618
26194
  const images = [];
26195
+ const other = [];
25619
26196
  for (const block of content) {
25620
26197
  if (!block || typeof block !== "object") continue;
25621
26198
  const b = block;
25622
26199
  if (b.type === "text" && typeof b.text === "string") {
25623
26200
  texts.push(b.text);
25624
26201
  textBytes += Buffer.byteLength(b.text, "utf8");
25625
- } else images.push(block);
26202
+ } else if (b.type === "image") images.push(block);
26203
+ else other.push(block);
25626
26204
  }
25627
- if (textBytes <= capBytes) return void 0;
25628
- const capped = truncateModelText(texts.join("\n"), capBytes);
25629
- return [...images, {
25630
- type: "text",
25631
- text: capped
25632
- }];
26205
+ const { kept, note } = capImages(images);
26206
+ if (textBytes <= capBytes && note === void 0) return void 0;
26207
+ const joined = texts.join(NEWLINE);
26208
+ const capped = textBytes > capBytes ? truncateModelText(joined, capBytes) : joined;
26209
+ return [
26210
+ ...kept,
26211
+ ...other,
26212
+ {
26213
+ type: "text",
26214
+ text: capped + (note ?? "")
26215
+ }
26216
+ ];
26217
+ }
26218
+ //#endregion
26219
+ //#region src/lib/peer-attachments.ts
26220
+ /**
26221
+ * Server-side image loading for peer-critic attachments (`imagePaths`).
26222
+ *
26223
+ * WHY PATHS AND NOT BASE64
26224
+ *
26225
+ * The obvious schema would take base64 directly. It would also mean megabytes
26226
+ * of payload crossing the MCP boundary and sitting in the CALLER's context — the
26227
+ * exact cost the browser-screenshot fix exists to remove — and it would trip the
26228
+ * `predictedTooLong` pre-flight, which sizes the brief before a slot is
26229
+ * acquired. Reading the bytes here instead keeps the caller's context clean and
26230
+ * the wire small: the caller sends a path, the proxy sends the pixels.
26231
+ *
26232
+ * The proxy and the caller are the same machine in this product (the MCP server
26233
+ * is loopback-only and the CLI is spawned by it), so a local path is meaningful
26234
+ * on both sides.
26235
+ *
26236
+ * THREAT MODEL
26237
+ *
26238
+ * This is a second file-reading path, so it must not become a way around the
26239
+ * first one's rules. Every path goes through `confineToWorkspaceResult` — the
26240
+ * SAME chokepoint the worker's `read`/`glob`/`grep` tools use — which enforces
26241
+ * workspace confinement, `realpathSync.native()` (so a symlink or junction is
26242
+ * resolved to its true target before the prefix check), and syntactic rejection
26243
+ * of UNC, device, and drive-relative Windows paths. `isSensitivePath` then
26244
+ * applies the credential-shaped denylist (`.env*`, `*.pem`, `id_rsa*`, `.ssh/`,
26245
+ * `.git/` interior, `.netrc`, …).
26246
+ *
26247
+ * On top of that, content identification adds a second barrier: a file is only
26248
+ * ever sent if its leading BYTES are a supported image, so a `.env` renamed to
26249
+ * `shot.png` is refused even if it somehow passed confinement.
26250
+ *
26251
+ * That barrier is header-only, and worth stating honestly: a file beginning
26252
+ * with a valid PNG signature and carrying arbitrary data afterwards would pass.
26253
+ * Constructing one requires write access, and a caller with write access
26254
+ * already has `bash` and could exfiltrate directly — so this stops the
26255
+ * realistic case (a model pointing at a credential file by mistake or via
26256
+ * prompt injection), not a determined adversary. Confinement and the denylist
26257
+ * are the primary controls; this is defence in depth.
26258
+ */
26259
+ /**
26260
+ * Pre-encode size ceiling. Every vision-capable model in the live catalog
26261
+ * publishes `max_prompt_image_size: 3145728`, so anything larger cannot be sent
26262
+ * to any of them. Checking BEFORE `readFile` means an oversized file is never
26263
+ * loaded into memory, let alone base64-expanded by a third.
26264
+ *
26265
+ * The per-model check still runs later at the transport boundary
26266
+ * (`assertOutboundImagesOk`); this is the cheap guard, not the authority.
26267
+ */
26268
+ const MAX_ATTACHMENT_BYTES = 3145728;
26269
+ const MAX_TOTAL_ATTACHMENT_BYTES = 12582912;
26270
+ /**
26271
+ * Open flags. `O_NOFOLLOW` is POSIX-only and simply absent on Windows, where
26272
+ * `fsConstants.O_NOFOLLOW` is `undefined` — OR-ing that in would produce `NaN`
26273
+ * and break every open, so it is added only when the platform defines it.
26274
+ */
26275
+ const READ_FLAGS = typeof constants.O_NOFOLLOW === "number" ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
26276
+ /**
26277
+ * Resolve, validate, and base64-encode each path.
26278
+ *
26279
+ * Fails on the FIRST bad path rather than silently skipping it: a caller that
26280
+ * attached four screenshots and got three has been quietly misled about what the
26281
+ * reviewer actually saw.
26282
+ *
26283
+ * Error strings reach a model, so they say what was wrong and what to do, and
26284
+ * never echo the resolved absolute path (the confinement helper deliberately
26285
+ * keeps its own messages path-free for the same reason).
26286
+ */
26287
+ async function loadPeerImages(paths, workspace) {
26288
+ if (paths.length > 10) return {
26289
+ ok: false,
26290
+ error: `imagePaths: ${paths.length} paths exceeds the 10-image ceiling (the most any Copilot model accepts). Send fewer.`
26291
+ };
26292
+ let workspaceAbs;
26293
+ try {
26294
+ workspaceAbs = realpathSync.native(workspace);
26295
+ } catch {
26296
+ workspaceAbs = workspace;
26297
+ }
26298
+ const images = [];
26299
+ let totalBytes = 0;
26300
+ for (const [index, raw] of paths.entries()) {
26301
+ const position = `imagePaths[${index}]`;
26302
+ if (typeof raw !== "string" || raw.length === 0) return {
26303
+ ok: false,
26304
+ error: `${position}: must be a non-empty string.`
26305
+ };
26306
+ const confined = confineToWorkspaceResult(raw, workspaceAbs);
26307
+ if (!confined.ok) return {
26308
+ ok: false,
26309
+ error: `${position}: ${confined.error}`
26310
+ };
26311
+ if (isSensitivePath(confined.abs, workspaceAbs)) return {
26312
+ ok: false,
26313
+ error: `${position}: rejected: sensitive path`
26314
+ };
26315
+ let handle;
26316
+ let buf;
26317
+ try {
26318
+ handle = await open(confined.abs, READ_FLAGS);
26319
+ const info = await handle.stat();
26320
+ if (!info.isFile()) return {
26321
+ ok: false,
26322
+ error: `${position}: rejected: not a regular file`
26323
+ };
26324
+ if (info.size > 3145728) return {
26325
+ ok: false,
26326
+ error: `${position}: ${info.size} bytes exceeds the ${MAX_ATTACHMENT_BYTES}-byte limit every vision model publishes. Re-capture at a smaller scale or lower quality.`
26327
+ };
26328
+ totalBytes += info.size;
26329
+ if (totalBytes > 12582912) return {
26330
+ ok: false,
26331
+ error: `${position}: the attachments total more than ${MAX_TOTAL_ATTACHMENT_BYTES} bytes. Send fewer or smaller images.`
26332
+ };
26333
+ const capacity = Math.min(info.size, MAX_ATTACHMENT_BYTES);
26334
+ const scratch = Buffer.alloc(Math.min(capacity + 1, 3145729));
26335
+ let filled = 0;
26336
+ for (;;) {
26337
+ const { bytesRead } = await handle.read(scratch, filled, scratch.length - filled, filled);
26338
+ if (bytesRead === 0) break;
26339
+ filled += bytesRead;
26340
+ if (filled >= scratch.length) break;
26341
+ }
26342
+ if (filled > capacity) return {
26343
+ ok: false,
26344
+ error: `${position}: the file grew while it was being read. Retry once it has stopped changing.`
26345
+ };
26346
+ buf = scratch.subarray(0, filled);
26347
+ } catch {
26348
+ return {
26349
+ ok: false,
26350
+ error: `${position}: file not found or unreadable`
26351
+ };
26352
+ } finally {
26353
+ await handle?.close().catch(() => {});
26354
+ }
26355
+ const mimeType = detectImageMimeType(buf);
26356
+ if (!mimeType) return {
26357
+ ok: false,
26358
+ error: `${position}: not a supported image. Content is identified by its bytes, not its extension. Supported: ${[...SUPPORTED_IMAGE_MIME_TYPES].join(", ")}.`
26359
+ };
26360
+ images.push({
26361
+ data: buf.toString("base64"),
26362
+ mimeType
26363
+ });
26364
+ }
26365
+ return {
26366
+ ok: true,
26367
+ images
26368
+ };
25633
26369
  }
25634
26370
  //#endregion
25635
26371
  //#region src/lib/tokenizer.ts
@@ -25860,9 +26596,15 @@ const getTokenCount = async (payload, model) => {
25860
26596
  * - anthropic-version (VS Code's Anthropic SDK sends this)
25861
26597
  * - X-Interaction-Id (VS Code sends a session-scoped UUID)
25862
26598
  *
25863
- * We intentionally omit copilot-vision-request VS Code only sends it when
26599
+ * We intentionally omit copilot-vision-request. VS Code only sends it when
25864
26600
  * images are present, and the native /v1/messages endpoint handles vision
25865
- * without requiring the header.
26601
+ * without it — VERIFIED live (2026-08-03) rather than assumed: the same
26602
+ * base64 image sent to claude-opus-5 with the header omitted and with it set
26603
+ * both returned 200 AND the model named the image's colour in each case, so
26604
+ * the pixels genuinely reach it either way. Probe `passthrough_image_claude`
26605
+ * in scripts/probe-copilot-compat.sh keeps that verified; if Copilot ever
26606
+ * starts gating vision on the header, that probe fails rather than images
26607
+ * silently degrading on the lead model's own path.
25866
26608
  *
25867
26609
  * extraHeaders allows callers to forward client-supplied beta headers
25868
26610
  * (anthropic-beta) so Copilot enables extended features.
@@ -26479,6 +27221,11 @@ function toolEntries(scope) {
26479
27221
  type: "string",
26480
27222
  description: "Optional additional context (extra file content, prior decisions). Concatenated to the brief before sending."
26481
27223
  },
27224
+ imagePaths: {
27225
+ type: "array",
27226
+ items: { type: "string" },
27227
+ description: "Optional paths to image files (screenshots, diagrams, charts) INSIDE the workspace for the persona to LOOK AT. The proxy reads and encodes them, so no image data passes through your context. Content is identified by bytes, not extension; jpeg/png/webp/gif/heic/heif, 3 MiB each. Note paths are confined to the proxy's working directory. Note the per-model ceiling: the gemini-backed personas accept 10 images, the gpt- and opus-backed ones accept 1."
27228
+ },
26482
27229
  effort: {
26483
27230
  type: "string",
26484
27231
  enum: [...p.allowedEfforts],
@@ -26738,7 +27485,10 @@ async function dispatchModelCall(args) {
26738
27485
  content: [{
26739
27486
  type: "input_text",
26740
27487
  text: args.userText
26741
- }]
27488
+ }, ...(args.images ?? []).map((img) => ({
27489
+ type: "input_image",
27490
+ image_url: `data:${img.mimeType};base64,${img.data}`
27491
+ }))]
26742
27492
  }],
26743
27493
  stream: false,
26744
27494
  reasoning: { effort: args.effort }
@@ -26756,7 +27506,20 @@ async function dispatchModelCall(args) {
26756
27506
  system: args.instructions,
26757
27507
  thinking: { type: "adaptive" },
26758
27508
  output_config: { effort: args.effort },
26759
- messages: [{
27509
+ messages: [args.images && args.images.length > 0 ? {
27510
+ role: "user",
27511
+ content: [{
27512
+ type: "text",
27513
+ text: args.userText
27514
+ }, ...args.images.map((img) => ({
27515
+ type: "image",
27516
+ source: {
27517
+ type: "base64",
27518
+ media_type: img.mimeType,
27519
+ data: img.data
27520
+ }
27521
+ }))]
27522
+ } : {
26760
27523
  role: "user",
26761
27524
  content: args.userText
26762
27525
  }]
@@ -26771,7 +27534,16 @@ async function dispatchModelCall(args) {
26771
27534
  messages: [{
26772
27535
  role: "system",
26773
27536
  content: args.instructions
26774
- }, {
27537
+ }, args.images && args.images.length > 0 ? {
27538
+ role: "user",
27539
+ content: [{
27540
+ type: "text",
27541
+ text: args.userText
27542
+ }, ...args.images.map((img) => ({
27543
+ type: "image_url",
27544
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` }
27545
+ }))]
27546
+ } : {
26775
27547
  role: "user",
26776
27548
  content: args.userText
26777
27549
  }],
@@ -26783,7 +27555,7 @@ async function dispatchModelCall(args) {
26783
27555
  label: resolvedModel
26784
27556
  }));
26785
27557
  }
26786
- async function callPersona(persona, prompt, context, effort, signal) {
27558
+ async function callPersona(persona, prompt, context, effort, signal, images) {
26787
27559
  const userText = buildUserText(prompt, context);
26788
27560
  const text = await dispatchModelCall({
26789
27561
  model: persona.model,
@@ -26791,6 +27563,7 @@ async function callPersona(persona, prompt, context, effort, signal) {
26791
27563
  instructions: persona.baseInstructions,
26792
27564
  userText,
26793
27565
  effort,
27566
+ images,
26794
27567
  signal
26795
27568
  });
26796
27569
  if (!text) return toolError(`persona ${persona.agentName}: empty assistant output`);
@@ -26839,6 +27612,7 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26839
27612
  let personaPrompt;
26840
27613
  let personaContext;
26841
27614
  let personaEffort;
27615
+ let personaImages;
26842
27616
  if (persona) {
26843
27617
  if (args.effort !== void 0 && !isEffort(args.effort)) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: arguments.effort must be one of ${EFFORT_LEVELS.join("|")}; got ${JSON.stringify(args.effort)}`);
26844
27618
  const requestedEffort = args.effort;
@@ -26846,6 +27620,12 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26846
27620
  if (!prompt) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: arguments.prompt is required`);
26847
27621
  personaPrompt = prompt;
26848
27622
  personaContext = typeof args.context === "string" ? args.context : void 0;
27623
+ if (args.imagePaths !== void 0) {
27624
+ if (!Array.isArray(args.imagePaths) || args.imagePaths.some((v) => typeof v !== "string")) return rpcError(body.id, RPC_INVALID_PARAMS, "tools/call: arguments.imagePaths must be an array of strings");
27625
+ const loaded = await loadPeerImages(args.imagePaths, process.cwd());
27626
+ if (!loaded.ok) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: ${loaded.error}`);
27627
+ personaImages = loaded.images;
27628
+ }
26849
27629
  if (requestedEffort !== void 0 && !persona.allowedEfforts.includes(requestedEffort)) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: persona "${persona.toolNameHttp}" does not accept effort="${requestedEffort}". Allowed: ${persona.allowedEfforts.join("|")}.`);
26850
27630
  personaEffort = requestedEffort ?? persona.defaultEffort;
26851
27631
  }
@@ -26881,7 +27661,7 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26881
27661
  const telemetryModel = persona ? persona.model : "(non-persona)";
26882
27662
  try {
26883
27663
  if (nonPersonaTool) applySessionWorkspace(args, sessionWorkspace, nonPersonaTool);
26884
- const result = persona ? await callPersona(persona, personaPrompt, personaContext, personaEffort, aborter?.signal) : await nonPersonaTool.handler(args, aborter?.signal);
27664
+ const result = persona ? await callPersona(persona, personaPrompt, personaContext, personaEffort, aborter?.signal, personaImages) : await nonPersonaTool.handler(args, aborter?.signal);
26885
27665
  logTelemetry({
26886
27666
  name: telemetryName,
26887
27667
  model: telemetryModel,
@@ -29166,18 +29946,49 @@ const READ_PARAMS = Type$1.Object({
29166
29946
  description: "Max lines to return."
29167
29947
  }))
29168
29948
  });
29949
+ /**
29950
+ * A file is an image if its BYTES say so. Extensions are a caller assertion and
29951
+ * are not consulted: a `.png` holding HTML must not be shipped as an image, and
29952
+ * a screenshot saved without a suffix should still work.
29953
+ */
29954
+ function imageResult(data, mimeType) {
29955
+ return {
29956
+ content: [{
29957
+ type: "text",
29958
+ text: `[${mimeType} image, ${data.length} base64 chars]`
29959
+ }, {
29960
+ type: "image",
29961
+ data,
29962
+ mimeType
29963
+ }],
29964
+ details: {}
29965
+ };
29966
+ }
29967
+ /**
29968
+ * Non-image binary detection. A NUL byte in the first few KiB is the classic
29969
+ * heuristic (it is what `grep`, `git`, and `file` all effectively use) and it is
29970
+ * enough here: the goal is not to classify the file, only to avoid handing the
29971
+ * model a wall of U+FFFD replacement characters and calling it text.
29972
+ */
29973
+ function looksBinary(buf) {
29974
+ return buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
29975
+ }
29169
29976
  function readTool(workspace) {
29170
29977
  return {
29171
29978
  name: "read",
29172
29979
  label: "Read file",
29173
- description: "Read a file from the worker's workspace. Returns UTF-8 text. Files larger than 10 MiB are refused; use offset/limit to page.",
29980
+ description: "Read a file from the worker's workspace. Returns UTF-8 text, or the image itself for a jpeg/png/webp/gif/heic/heif file (detected by content, not extension) so you can actually SEE it. Other binary files are refused rather than returned as mojibake. Files larger than 10 MiB are refused; use offset/limit to page.",
29174
29981
  parameters: READ_PARAMS,
29175
29982
  async execute(_toolCallId, params, signal) {
29176
29983
  const abs = resolvePathOrThrow(params.path, workspace);
29177
29984
  const st = await stat(abs);
29178
29985
  if (!st.isFile()) throw new Error("rejected: not a regular file");
29179
29986
  if (st.size > READ_MAX_BYTES) throw new Error(`rejected: file >${READ_MAX_BYTES} bytes (10 MiB) — got ${st.size}`);
29180
- const text = (await readFile(abs, { signal })).toString("utf8");
29987
+ const buf = await readFile(abs, { signal });
29988
+ const imageMime = detectImageMimeType(buf);
29989
+ if (imageMime) return imageResult(buf.toString("base64"), imageMime);
29990
+ if (looksBinary(buf)) throw new Error(`rejected: binary file (${st.size} bytes), not a supported image. Supported image types are jpeg, png, webp, gif, heic, heif. Use \`bash\` with a suitable CLI to inspect or convert it.`);
29991
+ const text = buf.toString("utf8");
29181
29992
  if (params.offset === void 0 && params.limit === void 0) return textResult(text);
29182
29993
  const lines = text.split(/\r?\n/);
29183
29994
  const start = params.offset ?? 0;
@@ -30775,6 +31586,36 @@ function extractAssistantText(content) {
30775
31586
  for (const part of content) if (part.type === "text") out += part.text;
30776
31587
  return out;
30777
31588
  }
31589
+ /**
31590
+ * Banner that prefixes text salvaged from an EARLIER assistant turn when the
31591
+ * run ends without a usable final answer.
31592
+ *
31593
+ * It is deliberately loud. Recovered text is partial work — the model was
31594
+ * mid-investigation when it went quiet — and returning it bare would let the
31595
+ * caller read an interim note as a conclusion. That is a different bug from
31596
+ * the one this recovery fixes, and a worse one: silently wrong beats loudly
31597
+ * missing only for the model that produced it.
31598
+ */
31599
+ const RECOVERED_TEXT_BANNER = "[recovered from an earlier turn — this run ended without a final answer, so the text below is partial work in progress, NOT a conclusion. Treat it as leads to verify, not as the worker's answer.]";
31600
+ /**
31601
+ * Compose the recovered-text block for a run that is ending with nothing
31602
+ * usable in its final turn.
31603
+ *
31604
+ * Returns `""` in the two cases where recovery would be noise: the run DID
31605
+ * produce live text (nothing was lost), or no turn ever produced any.
31606
+ *
31607
+ * Scope note: `highWater` is the LAST non-empty assistant text, not a
31608
+ * concatenation of every non-empty turn. Accumulating them would turn a
31609
+ * recovered result into a transcript dump — mostly interim narration — where
31610
+ * the last turn is both the most complete and the one the model was building
31611
+ * toward when it stalled.
31612
+ */
31613
+ function recoveredBlock(liveText, highWater) {
31614
+ if (liveText.trim()) return "";
31615
+ const recovered = highWater.trim();
31616
+ if (!recovered) return "";
31617
+ return `${RECOVERED_TEXT_BANNER}\n\n${recovered}`;
31618
+ }
30778
31619
  const MAX_EMPTY_OUTPUT_NUDGES = 3;
30779
31620
  const EMPTY_OUTPUT_NUDGES = [
30780
31621
  "Summarize your findings so far.",
@@ -30951,6 +31792,7 @@ async function runWorkerAgentOnce(opts) {
30951
31792
  const abortHandler = () => agent.abort();
30952
31793
  if (opts.signal) opts.signal.addEventListener("abort", abortHandler, { once: true });
30953
31794
  let finalText = "";
31795
+ let lastNonEmptyText = "";
30954
31796
  let lastStopReason = null;
30955
31797
  let nudgeCount = 0;
30956
31798
  const maxEmptyOutputNudges = resolveMaxEmptyOutputNudges();
@@ -30977,6 +31819,7 @@ async function runWorkerAgentOnce(opts) {
30977
31819
  const content = msg.content;
30978
31820
  if (!Array.isArray(content)) return;
30979
31821
  finalText = extractAssistantText(content);
31822
+ if (finalText.trim()) lastNonEmptyText = finalText;
30980
31823
  const sr = msg.stopReason;
30981
31824
  if (typeof sr === "string") lastStopReason = sr;
30982
31825
  });
@@ -30999,30 +31842,41 @@ async function runWorkerAgentOnce(opts) {
30999
31842
  try {
31000
31843
  await ws.remove();
31001
31844
  } catch {}
31002
- const text = isBrowse ? terminalText ?? finalText : diff ? `${finalText}\n\n${diff}` : finalText;
31845
+ const liveAnswer = isBrowse ? terminalText ?? finalText : finalText;
31846
+ const recovered = recoveredBlock(liveAnswer, lastNonEmptyText);
31847
+ const text = isBrowse ? liveAnswer : diff ? `${finalText}\n\n${diff}` : finalText;
31003
31848
  if (lastStopReason === "error" || lastStopReason === "aborted") {
31004
- const diag = (terminalText ?? finalText).trim();
31849
+ const diag = liveAnswer.trim();
31005
31850
  let diagnostic;
31006
31851
  if (lastStopReason === "aborted") diagnostic = wallClockExpired ? "[halted: wallclock]" : "[halted: cancelled]";
31007
31852
  else diagnostic = diag || "Worker run failed before producing an answer — the model's input likely overflowed (a large tool result), or the upstream errored. Retry with a narrower task: target a specific section / file / element rather than reading everything at once.";
31008
31853
  return {
31009
31854
  text: lastStopReason === "aborted" ? [
31010
31855
  diag,
31856
+ recovered,
31011
31857
  diff,
31012
31858
  diagnostic
31013
- ].filter(Boolean).join("\n\n") : [diagnostic, diff].filter(Boolean).join("\n\n"),
31859
+ ].filter(Boolean).join("\n\n") : [
31860
+ diagnostic,
31861
+ recovered,
31862
+ diff
31863
+ ].filter(Boolean).join("\n\n"),
31014
31864
  isError: true
31015
31865
  };
31016
31866
  }
31017
31867
  if (budget.hardStopReason) return {
31018
- text: [text, `[halted: ${budget.hardStopReason}]`].filter(Boolean).join("\n\n"),
31868
+ text: [
31869
+ text,
31870
+ recovered,
31871
+ `[halted: ${budget.hardStopReason}]`
31872
+ ].filter(Boolean).join("\n\n"),
31019
31873
  isError: true
31020
31874
  };
31021
31875
  if (!text.trim()) return {
31022
- text: `${NO_OUTPUT_PREFIX} after ${nudgeCount} nudges (stopReason=${lastStopReason ?? "unknown"}, turns=${budget.turns}, elapsed=${budget.elapsedMs}ms)]; retry with a different model via worker_defaults, or narrow/split the task.`,
31876
+ text: [`${NO_OUTPUT_PREFIX} after ${nudgeCount} nudges (stopReason=${lastStopReason ?? "unknown"}, turns=${budget.turns}, elapsed=${budget.elapsedMs}ms)]; retry with a different model via worker_defaults, or narrow/split the task.`, recovered].filter(Boolean).join("\n\n"),
31023
31877
  isError: true
31024
31878
  };
31025
- return { text };
31879
+ return { text: [text, recovered].filter(Boolean).join("\n\n") };
31026
31880
  } catch (err) {
31027
31881
  let diff = "";
31028
31882
  try {
@@ -31035,7 +31889,12 @@ async function runWorkerAgentOnce(opts) {
31035
31889
  } catch {}
31036
31890
  const haltOrErr = err instanceof Error ? err.message : String(err);
31037
31891
  const parts = [];
31038
- if (finalText) parts.push(finalText);
31892
+ const liveAnswer = isBrowse ? terminalText ?? finalText : finalText;
31893
+ if (liveAnswer.trim()) parts.push(liveAnswer);
31894
+ else {
31895
+ const recovered = recoveredBlock(liveAnswer, lastNonEmptyText);
31896
+ if (recovered) parts.push(recovered);
31897
+ }
31039
31898
  if (diff) parts.push(diff);
31040
31899
  parts.push(haltOrErr);
31041
31900
  return {
@@ -35064,4 +35923,4 @@ function enumerateInjectedMcpToolNames(groupKeys, opts = {}) {
35064
35923
  //#endregion
35065
35924
  export { searchWeb as $, DEFAULT_CLAUDE_MODEL_FALLBACKS as $t, trustRepo as A, state as An, getTokenCount as At, TEST_DEFAULT_MODEL as B, hasSupportedBrowserInstalled as Bt, fileLastPromptStore as C, fetchWithTransientRetry as Cn, scoutModel as Ct, repoRoot as D, copilotBaseUrl as Dn, shimDefaultsToXhigh as Dt, repoFingerprint as E, GITHUB_API_BASE_URL as En, workerToolsEnabled as Et, EXPLORE_DEFAULT_MODEL as F, createChatCompletions as Ft, buildEnv as G, CONDENSED_OPERATING_SEQUENCE as Gt, resolveModeDefaults as H, extractTarGzMember as Ht, EXPLORE_DEFAULT_THINKING as I, MAX_RESPONSE_BODY_BYTES as It, toolbeltEnabled as J, ArtifactClient as Jt, availableToolCommands as K, DEFINITION_OF_GREATNESS as Kt, IMPLEMENT_DEFAULT_MODEL as L, readResponseBodyCapped as Lt, resolveSealedGate as M, resolveMcpToolTimeoutMs as Mt, BROWSE_DEFAULT_MODEL as N, pickEndpoint as Nt, stopGateEnabledForRepo as O, copilotHeaders as On, countTokens as Ot, DEFAULT_MODEL as P, createResponses as Pt, assetFor as Q, toolbeltPathOverride as Qt, PLAN_DEFAULT_MODEL as R, parseJsonOrDiagnose as Rt, fileFindingsStore as S, getGitHubUser as Sn, reviewerModel as St, isSubagentContext as T, forwardError as Tn, standInToolEnabled as Tt, resolveWorkerRunOpts as U, extractZipMember as Ut, appendPlanReminder as V, provisionAndIndexColbert as Vt, runWorkerAgent as W, warmTreeSitterPool as Wt, vscodeRipgrepPath as X, buildWorkspaceHeaderJson as Xt, toolbeltSkipSet as Y, buildWorkspaceHeaderHelperCommand as Yt, TOOLBELT_TOOLS$1 as Z, collapsePathKeys as Zt, stopGateDisabled as _, isNullish as _n, browserCompoundToolsEnabled as _t, buildPeerAwarenessSnippet as a, generateRandomPort as an, buildAnthropicErrorEvent as at, stopReviewEnabled as b, sleep$3 as bn, geminiAvailable as bt, personasFor as c, withInstallLock as cn, logStreamError as ct, buildStopHookCommand as d, setupGitHubToken as dn, handleMcpDelete as dt, DEFAULT_CODEX_MODEL as en, ADVISOR_INTERNAL_TOOL_NAME as et, captureLaunchBaseline as f, tryRefreshAndRetry as fn, handleMcpPost as ft, launchBaselineKey as g, filterBetaHeader as gn, browseAgentEnabled as gt, injectStopHookIntoSettingsFile as h, cacheVSCodeVersion as hn, brainstormModel as ht, buildAgentPrompt as i, UPSTREAM_INACTIVITY_TIMEOUT_MS as in, isAdvisorRequested as it, liveExec as j, assembleResponsesPayload as jt, stopReviewStateDir as k, githubHeaders as kn, createMessages as kt, buildArtifactOpenHookCommand as l, setupCopilotToken as ln, readIteratorWithTimeout as lt, fileBlockBudget as m, cacheModels as mn, artifactToolsEnabled as mt, MCP_GROUPS as n, DEFAULT_PORT as nn, buildAdvisorStream as nt, buildPeerAwarenessSummary as o, pickClaudeDefault as on, buildOpenAIErrorEvent as ot, decideStopHook as p, cacheCopilotVersion as pn, agentToolsEnabled as pt, buildToolbeltAwareness as q, shouldUseInsecureTls as qt, assertMcpToolSurfaceConsistent as r, UPSTREAM_FETCH_TIMEOUT_MS as rn, injectAdvisorTool as rt, enumerateInjectedMcpToolNames as s, getPackageVersion as sn, isControllerClosedError as st, GROUP_META as t, DEFAULT_CODEX_MODEL_FALLBACKS as tn, ADVISOR_TOOL_INSTRUCTIONS as tt, buildSessionBindHookCommand as u, setupGitHubAgentToken as un, relayAnthropicStream as ut, stopGateId as v, resolveCodexModel as vn, browserToolsEnabled as vt, fileReviewDebounce as w, HTTPError as wn, scribeModel as wt, fileBaselineStore as x, getModels as xn, nativeSubagentModel as xt, stopGatePlanMode as y, resolveModel as yn, fleetToolsEnabled as yt, REVIEW_DEFAULT_MODEL as z, provisionBrowserAssets as zt };
35066
35925
 
35067
- //# sourceMappingURL=peer-mcp-personas-HWhxZUvf.js.map
35926
+ //# sourceMappingURL=peer-mcp-personas-BvommiSI.js.map