github-router 0.3.249 → 0.3.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/attribution-settings-CegtE0TY.js +2347 -0
  2. package/dist/attribution-settings-CegtE0TY.js.map +1 -0
  3. package/dist/auth-CP4q2CjO.js +44 -0
  4. package/dist/auth-CP4q2CjO.js.map +1 -0
  5. package/dist/browser-ext/background.js +8 -1
  6. package/dist/browser-ext/manifest.json +1 -1
  7. package/dist/check-usage-6PB5IUFb.js +43 -0
  8. package/dist/check-usage-6PB5IUFb.js.map +1 -0
  9. package/dist/claude-BiZxoNWq.js +593 -0
  10. package/dist/claude-BiZxoNWq.js.map +1 -0
  11. package/dist/client-CQMfroGV.js +413 -0
  12. package/dist/client-CQMfroGV.js.map +1 -0
  13. package/dist/codex-ST21X3WF.js +85 -0
  14. package/dist/codex-ST21X3WF.js.map +1 -0
  15. package/dist/debug-CjmE9sgG.js +81 -0
  16. package/dist/debug-CjmE9sgG.js.map +1 -0
  17. package/dist/engine-DNCX0Hfd.js +2 -0
  18. package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
  19. package/dist/exec-DTO2oin0.js.map +1 -0
  20. package/dist/file-log-reporter-IFQcL5IK.js +158 -0
  21. package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
  22. package/dist/gate-discovery-CepmbZnc.js +635 -0
  23. package/dist/gate-discovery-CepmbZnc.js.map +1 -0
  24. package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
  25. package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
  26. package/dist/hook-mcp-client-DBvm9608.js +136 -0
  27. package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
  28. package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
  29. package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
  30. package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
  31. package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
  32. package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
  33. package/dist/internal-plan-review-ipnT3bhj.js +114 -0
  34. package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
  35. package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
  36. package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
  37. package/dist/internal-session-bind-DwK2iGW0.js +133 -0
  38. package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
  39. package/dist/internal-stop-hook-CNPGJm63.js +212 -0
  40. package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
  41. package/dist/internal-stop-review-AUcFssrW.js +122 -0
  42. package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
  43. package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
  44. package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
  45. package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
  46. package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
  47. package/dist/lifecycle-Byw5s1G8.js +134 -0
  48. package/dist/lifecycle-Byw5s1G8.js.map +1 -0
  49. package/dist/lifecycle-D80CaWie.js +2 -0
  50. package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
  51. package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
  52. package/dist/lifecycle-DzqCqDlM.js +2 -0
  53. package/dist/main.js +43 -12884
  54. package/dist/main.js.map +1 -1
  55. package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
  56. package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
  57. package/dist/models-B6UnGyRo.js +158 -0
  58. package/dist/models-B6UnGyRo.js.map +1 -0
  59. package/dist/orchestration-hRU4TdWr.js +39 -0
  60. package/dist/orchestration-hRU4TdWr.js.map +1 -0
  61. package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
  62. package/dist/paths-C1cLZ7Ga.js.map +1 -0
  63. package/dist/paths-CwKwc2pi.js +2 -0
  64. package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
  65. package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
  66. package/dist/plan-review-hook-C8x0uKPf.js +159 -0
  67. package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
  68. package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
  69. package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
  70. package/dist/provision-DSkPa5hY.js +656 -0
  71. package/dist/provision-DSkPa5hY.js.map +1 -0
  72. package/dist/serve-sa_7c-yD.js +1511 -0
  73. package/dist/serve-sa_7c-yD.js.map +1 -0
  74. package/dist/server-setup-DcO9qvN3.js +4776 -0
  75. package/dist/server-setup-DcO9qvN3.js.map +1 -0
  76. package/dist/start-siGxuZhO.js +129 -0
  77. package/dist/start-siGxuZhO.js.map +1 -0
  78. package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
  79. package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
  80. package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
  81. package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
  82. package/dist/token-R2ocFC3M.js +1054 -0
  83. package/dist/token-R2ocFC3M.js.map +1 -0
  84. package/dist/version-_Q1WpsQp.js +32 -0
  85. package/dist/version-_Q1WpsQp.js.map +1 -0
  86. package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
  87. package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
  88. package/package.json +1 -1
  89. package/dist/engine-BuuHUb4t.js +0 -2
  90. package/dist/lifecycle-0rXrwjWk.js +0 -2
  91. package/dist/lifecycle-DHZsVN8j.js.map +0 -1
  92. package/dist/lifecycle-hzPwXc3I.js +0 -2
  93. package/dist/paths-BjTMI_xK.js.map +0 -1
  94. package/dist/paths-D3XgmTcQ.js +0 -2
  95. package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
@@ -1,19 +1,26 @@
1
- import { t as PATHS } from "./paths-BjTMI_xK.js";
2
- import { d as resolveExecutable, f as runCommandCapture, l as parseBoolEnv, m as runManagedExeCapture, n as isPidAlive, o as trackChild, r as registerColbertExitHandlers, t as getColbertInstanceUuid, u as parseIntEnv } from "./lifecycle-DHZsVN8j.js";
3
- import { i as registerExitHandlers$1, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-CUf1UDrH.js";
1
+ import { t as getPackageVersion } from "./version-_Q1WpsQp.js";
2
+ import { t as PATHS } from "./paths-C1cLZ7Ga.js";
3
+ import { C as copilotHeaders, E as githubAgentHeaders, O as state, S as copilotBaseUrl, T as githubAgentGraphQLHeaders, _ as withTransientRetry, b as GITHUB_API_BASE_URL, d as resolveModel, f as sleep$2, g as fetchWithTransientRetry, h as isAllowedCopilotHost, i as tryRefreshAndRetry, v as HTTPError, w as copilotVersion, x as GITHUB_GRAPHQL_URL } from "./token-R2ocFC3M.js";
4
+ import { a as resolveExecutable, c as runManagedExeCapture, i as parseIntEnv, r as parseBoolEnv } from "./exec-DTO2oin0.js";
5
+ import { i as registerExitHandlers$1, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-DD88ka3f.js";
6
+ import { t as MCP_WORKSPACE_HEADER } from "./mcp-workspace-header-CGJbNeHb.js";
7
+ import { n as ArtifactError, r as applyInsecureTls, t as ArtifactClient } from "./client-CQMfroGV.js";
8
+ import { n as isPidAlive, o as trackChild, r as registerColbertExitHandlers, t as getColbertInstanceUuid } from "./lifecycle-Byw5s1G8.js";
9
+ import { h as runGateChecks, m as sealedGateIds, p as resolveSealedGate } from "./stop-gate-hook-DQc2KV7B.js";
10
+ import { t as liveExec } from "./orchestration-hRU4TdWr.js";
4
11
  import { createRequire } from "node:module";
5
12
  import consola from "consola";
6
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
- import fs, { chmod, mkdir, open, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
8
- import * as os$1 from "node:os";
9
- import os, { homedir, platform, tmpdir } from "node:os";
13
+ import { chmodSync, closeSync, constants, cpSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
10
14
  import * as path$1 from "node:path";
11
15
  import path, { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
18
+ import fs$1, { chmod, mkdir, open, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
19
+ import * as os$1 from "node:os";
20
+ import os, { homedir, platform } from "node:os";
12
21
  import process$1 from "node:process";
13
22
  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";
15
- import { fileURLToPath } from "node:url";
16
- import { Agent, ProxyAgent } from "undici";
23
+ import { ProxyAgent } from "undici";
17
24
  import { z } from "zod";
18
25
  import { AsyncLocalStorage } from "node:async_hooks";
19
26
  import { performance } from "node:perf_hooks";
@@ -33,914 +40,6 @@ import "diff";
33
40
  //#region \0rolldown/runtime.js
34
41
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
35
42
  //#endregion
36
- //#region src/lib/state.ts
37
- const state = {
38
- accountType: "enterprise",
39
- manualApprove: false,
40
- rateLimitWait: false,
41
- showToken: false,
42
- extendedBetas: false,
43
- browseEnabled: false,
44
- fleetEnabled: false,
45
- agentsEnabled: false,
46
- powerBrowseEnabled: false,
47
- humanlikeForce: "auto",
48
- sessionId: randomUUID(),
49
- machineId: randomBytes(32).toString("hex")
50
- };
51
- //#endregion
52
- //#region src/lib/api-config.ts
53
- const standardHeaders = () => ({
54
- "content-type": "application/json",
55
- accept: "application/json"
56
- });
57
- const DEFAULT_COPILOT_VERSION = "0.43.2026033101";
58
- function copilotVersion(state) {
59
- return state.copilotVersion ?? DEFAULT_COPILOT_VERSION;
60
- }
61
- const API_VERSION = "2026-01-09";
62
- const copilotBaseUrl = (state) => state.copilotApiUrl ?? "https://api.githubcopilot.com";
63
- const copilotHeaders = (state, vision = false, integrationId = "vscode-chat") => {
64
- const version = copilotVersion(state);
65
- const headers = {
66
- Authorization: `Bearer ${state.copilotToken}`,
67
- "content-type": standardHeaders()["content-type"],
68
- "copilot-integration-id": integrationId,
69
- "editor-version": `vscode/${state.vsCodeVersion}`,
70
- "editor-plugin-version": `copilot-chat/${version}`,
71
- "user-agent": `GitHubCopilotChat/${version}`,
72
- "openai-intent": "conversation-panel",
73
- "x-interaction-type": "conversation-panel",
74
- "x-github-api-version": API_VERSION,
75
- "x-request-id": randomUUID(),
76
- "x-vscode-user-agent-library-version": "electron-fetch",
77
- "VScode-SessionId": state.sessionId,
78
- "VScode-MachineId": state.machineId
79
- };
80
- if (vision) headers["copilot-vision-request"] = "true";
81
- return headers;
82
- };
83
- const GITHUB_API_BASE_URL = process.env.GITHUB_API_URL ?? "https://api.github.com";
84
- const githubHeaders = (state) => ({
85
- ...standardHeaders(),
86
- authorization: `token ${state.githubToken}`,
87
- "editor-version": `vscode/${state.vsCodeVersion}`,
88
- "editor-plugin-version": `copilot-chat/${copilotVersion(state)}`,
89
- "user-agent": `GitHubCopilotChat/${copilotVersion(state)}`,
90
- "x-github-api-version": API_VERSION,
91
- "x-vscode-user-agent-library-version": "electron-fetch"
92
- });
93
- const GITHUB_BASE_URL = "https://github.com";
94
- const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98";
95
- const GITHUB_APP_SCOPES = ["read:user"].join(" ");
96
- const GITHUB_AGENT_CLIENT_ID = "178c6fc778ccc68e1d6a";
97
- const GITHUB_AGENT_SCOPES = [
98
- "repo",
99
- "workflow",
100
- "read:org"
101
- ].join(" ");
102
- const GITHUB_GRAPHQL_URL = process.env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql";
103
- const GITHUB_REST_API_VERSION = "2022-11-28";
104
- const githubAgentHeaders = (state) => ({
105
- ...standardHeaders(),
106
- authorization: `token ${state.githubAgentToken}`,
107
- "x-github-api-version": GITHUB_REST_API_VERSION,
108
- "user-agent": "github-router-first-mate"
109
- });
110
- const githubAgentGraphQLHeaders = (state, features) => ({
111
- ...githubAgentHeaders(state),
112
- ...features ? { "GraphQL-Features": features } : {}
113
- });
114
- //#endregion
115
- //#region src/lib/error.ts
116
- var HTTPError = class extends Error {
117
- response;
118
- constructor(message, response) {
119
- super(message);
120
- this.response = response;
121
- }
122
- };
123
- async function forwardError(c, error) {
124
- consola.error(`Error occurred at ${c.req.path}:`, error);
125
- if (error instanceof HTTPError) {
126
- const errorText = await error.response.text().catch(() => "");
127
- let errorJson;
128
- try {
129
- errorJson = JSON.parse(errorText);
130
- } catch {
131
- errorJson = void 0;
132
- }
133
- if (isContextOverflow(error.response.status, errorJson, errorText)) {
134
- const upstream = resolveErrorMessage(errorJson, errorText);
135
- consola.error("HTTP error (mapped to overflow):", errorJson ?? errorText);
136
- return c.json({
137
- type: "error",
138
- error: {
139
- type: "invalid_request_error",
140
- message: `prompt is too long: ${upstream}`
141
- }
142
- }, 400);
143
- }
144
- const responseStatus = error.response.status === 401 ? 503 : error.response.status;
145
- if (isAnthropicError(errorJson)) {
146
- consola.error("HTTP error:", errorJson);
147
- return c.json(errorJson, responseStatus);
148
- }
149
- const message = resolveErrorMessage(errorJson, errorText);
150
- consola.error("HTTP error:", errorJson ?? errorText);
151
- return c.json({
152
- type: "error",
153
- error: {
154
- type: resolveErrorType(responseStatus),
155
- message
156
- }
157
- }, responseStatus);
158
- }
159
- return c.json({
160
- type: "error",
161
- error: {
162
- type: "api_error",
163
- message: error instanceof Error ? error.message : String(error)
164
- }
165
- }, 500);
166
- }
167
- function resolveErrorMessage(errorJson, fallback) {
168
- if (typeof errorJson !== "object" || errorJson === null) return fallback;
169
- const errorRecord = errorJson;
170
- if (errorRecord.message !== void 0) return String(errorRecord.message);
171
- if (typeof errorRecord.error === "object" && errorRecord.error !== null) {
172
- const nestedRecord = errorRecord.error;
173
- if (nestedRecord.message !== void 0) return String(nestedRecord.message);
174
- }
175
- return fallback;
176
- }
177
- /**
178
- * Check if a parsed JSON body is already in Anthropic error format:
179
- * { type: "error", error: { type: "...", message: "..." } }
180
- */
181
- function isAnthropicError(json) {
182
- if (typeof json !== "object" || json === null) return false;
183
- const record = json;
184
- if (record.type !== "error") return false;
185
- if (typeof record.error !== "object" || record.error === null) return false;
186
- const inner = record.error;
187
- return typeof inner.type === "string" && typeof inner.message === "string";
188
- }
189
- const CONTEXT_OVERFLOW_SUBSTRINGS = [
190
- "prompt is too long",
191
- "context_length_exceeded",
192
- "context length exceeded",
193
- "input is too long",
194
- "maximum context length",
195
- "too many tokens"
196
- ];
197
- /**
198
- * Detect upstream context-overflow errors so we can remap them to a 400
199
- * "prompt is too long" shape that triggers Claude Code self-compaction.
200
- *
201
- * Always remaps 413 (treated as a hard payload-size signal regardless of
202
- * body wording). Remaps 400 only when the error text contains one of the
203
- * known overflow substrings — a regular 400 (e.g. "model not found") must
204
- * NOT remap.
205
- */
206
- function isContextOverflow(status, errorJson, errorText) {
207
- if (status === 413) return true;
208
- if (status !== 400) return false;
209
- const haystack = (errorText + " " + (typeof errorJson === "object" && errorJson !== null ? JSON.stringify(errorJson) : "")).toLowerCase();
210
- return CONTEXT_OVERFLOW_SUBSTRINGS.some((s) => haystack.includes(s));
211
- }
212
- /**
213
- * Map HTTP status to Anthropic error type.
214
- *
215
- * Note: a 401 from upstream is remapped to 503 in `forwardError` BEFORE
216
- * this function is called (no-401 invariant — see comment there). The
217
- * 401 → "authentication_error" mapping below is preserved for
218
- * defensive coverage in case any code path calls `resolveErrorType`
219
- * directly with an unsanitized status.
220
- */
221
- function resolveErrorType(status) {
222
- if (status === 400) return "invalid_request_error";
223
- if (status === 401) return "authentication_error";
224
- if (status === 403) return "permission_error";
225
- if (status === 404) return "not_found_error";
226
- if (status === 429) return "rate_limit_error";
227
- if (status === 503) return "overloaded_error";
228
- if (status === 529) return "overloaded_error";
229
- return "api_error";
230
- }
231
- //#endregion
232
- //#region src/lib/upstream-retry.ts
233
- /**
234
- * Shared transient-failure retry for upstream calls (`src/lib/upstream-retry.ts`).
235
- *
236
- * Retries ONLY on transient conditions — never on success, never on a
237
- * deterministic 4xx (400/401/403/404…), so a malformed request or auth
238
- * failure fails fast instead of being hammered. 401 is intentionally NOT
239
- * retried here: it stays with the existing token-refresh path so the
240
- * 401→503 forwardError invariant is preserved.
241
- *
242
- * Retryable:
243
- * - HTTP 429 + 5xx (500/502/503/504) — the "upstream is sick" class.
244
- * - network errors (ECONNRESET / "fetch failed" / "terminated" / EPIPE …).
245
- * - upstream TIMEOUT aborts: an AbortError thrown while the CALLER'S
246
- * `signal` is NOT aborted (a user cancel aborts the caller's signal
247
- * and is rethrown immediately, never retried).
248
- *
249
- * Exponential backoff + FULL jitter, capped, honoring `Retry-After`. The
250
- * inter-attempt sleep is abortable so a user cancel during backoff
251
- * returns promptly. Bounded `attempts` keep a single call from holding an
252
- * inflight slot indefinitely (robust AND fast).
253
- *
254
- * Streaming note: the user-facing passthrough and the worker loop must
255
- * only retry in the PRE-FIRST-BYTE window — a retry after bytes have
256
- * streamed would duplicate output. Callers there pass a `doFetch` that
257
- * has not yet handed its body to the consumer.
258
- */
259
- const DEFAULT_RETRY_STATUSES = [
260
- 429,
261
- 500,
262
- 502,
263
- 503,
264
- 504
265
- ];
266
- function parseRetryAfter(headerValue) {
267
- if (!headerValue) return void 0;
268
- const secs = Number(headerValue);
269
- if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
270
- const dateMs = Date.parse(headerValue);
271
- if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
272
- }
273
- /**
274
- * A thrown fetch error that is worth retrying — network resets, broken
275
- * pipes, DNS hiccups, and timeout aborts. NOTE: the caller must already
276
- * have ruled out a user cancel (caller signal aborted) before calling
277
- * this, since a timeout abort and a user-cancel abort look identical.
278
- */
279
- function isTransientNetworkError(err) {
280
- const e = err;
281
- if (!e) return false;
282
- if (e.name === "AbortError" || e.name === "TimeoutError") return true;
283
- const msg = (e.message ?? "").toLowerCase();
284
- if (msg.includes("terminated") || msg.includes("fetch failed") || msg.includes("network") || msg.includes("socket") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("econnrefused") || msg.includes("epipe") || msg.includes("enotfound")) return true;
285
- const code = e.code ?? e.cause?.code;
286
- return code !== void 0 && [
287
- "ECONNRESET",
288
- "ETIMEDOUT",
289
- "ECONNREFUSED",
290
- "EPIPE",
291
- "ENOTFOUND"
292
- ].includes(code);
293
- }
294
- /** Sleep that resolves early (does not reject) when `signal` aborts — the
295
- * retry loop re-checks `signal.aborted` at the top and throws there. */
296
- function abortableSleep(ms, signal) {
297
- if (ms <= 0) return Promise.resolve();
298
- return new Promise((resolve) => {
299
- let settled = false;
300
- const done = () => {
301
- if (settled) return;
302
- settled = true;
303
- clearTimeout(timer);
304
- signal?.removeEventListener("abort", done);
305
- resolve();
306
- };
307
- const timer = setTimeout(done, ms);
308
- if (signal) {
309
- if (signal.aborted) {
310
- done();
311
- return;
312
- }
313
- signal.addEventListener("abort", done, { once: true });
314
- }
315
- });
316
- }
317
- /**
318
- * Run `doFetch` with bounded transient-failure retries. `doFetch` is a
319
- * thunk so each attempt issues a FRESH request (bodies can't be replayed
320
- * from a consumed stream). Returns the final `Response` — which may still
321
- * carry a retryable status if all attempts are exhausted (the caller
322
- * handles that as it would a single-shot failure).
323
- */
324
- async function fetchWithTransientRetry(doFetch, opts = {}) {
325
- const attempts = Math.max(1, opts.attempts ?? 3);
326
- const retryStatuses = opts.retryStatuses ?? DEFAULT_RETRY_STATUSES;
327
- const baseDelayMs = opts.baseDelayMs ?? 250;
328
- const maxDelayMs = opts.maxDelayMs ?? 4e3;
329
- const { signal, label } = opts;
330
- for (let attempt = 1;; attempt++) {
331
- if (signal?.aborted) throw new DOMException("This operation was aborted", "AbortError");
332
- let res;
333
- let caught;
334
- try {
335
- res = await doFetch(attempt);
336
- } catch (err) {
337
- caught = err;
338
- }
339
- if (res && !retryStatuses.includes(res.status)) return res;
340
- if (caught !== void 0) {
341
- if (signal?.aborted) throw caught;
342
- if (!isTransientNetworkError(caught)) throw caught;
343
- }
344
- if (attempt >= attempts) {
345
- if (res) return res;
346
- throw caught;
347
- }
348
- const retryAfterMs = res ? parseRetryAfter(res.headers.get("retry-after")) : void 0;
349
- if (res?.body) try {
350
- await res.body.cancel();
351
- } catch {}
352
- const expCap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
353
- const delay = Math.min(maxDelayMs, retryAfterMs ?? Math.round(Math.random() * expCap));
354
- if (label) {
355
- const why = res ? `HTTP ${res.status}` : caught?.name ?? "error";
356
- consola.debug(`[upstream-retry] ${label}: attempt ${attempt}/${attempts} failed (${why}); retrying in ${delay}ms`);
357
- }
358
- await abortableSleep(delay, signal);
359
- }
360
- }
361
- /** Extract an HTTP status from a thrown error (HTTPError carries
362
- * `.response.status`; others may carry `.status`/`.statusCode`; last
363
- * resort parses `"HTTP <code>"` from the message). */
364
- function errorStatus(err) {
365
- const e = err;
366
- for (const v of [
367
- e?.status,
368
- e?.statusCode,
369
- e?.response?.status
370
- ]) if (typeof v === "number") return v;
371
- const m = /\bHTTP (\d{3})\b/.exec(e?.message ?? "");
372
- return m ? Number(m[1]) : void 0;
373
- }
374
- /**
375
- * Generic transient-retry for a non-`Response`-returning call (e.g. the
376
- * Copilot service clients, which throw `HTTPError` on non-OK and throw on
377
- * network errors). Retries `fn()` when it throws a transient error — an
378
- * `HTTPError`-like with a retryable status (429/5xx) OR a transient
379
- * network/timeout error — using the same backoff + abort semantics as
380
- * `fetchWithTransientRetry`. Never retries a deterministic 4xx (incl.
381
- * 401), a non-transient throw, or a user cancel.
382
- */
383
- async function withTransientRetry(fn, opts = {}) {
384
- const attempts = Math.max(1, opts.attempts ?? 3);
385
- const retryStatuses = opts.retryStatuses ?? DEFAULT_RETRY_STATUSES;
386
- const baseDelayMs = opts.baseDelayMs ?? 250;
387
- const maxDelayMs = opts.maxDelayMs ?? 4e3;
388
- const { signal, label } = opts;
389
- for (let attempt = 1;; attempt++) {
390
- if (signal?.aborted) throw new DOMException("This operation was aborted", "AbortError");
391
- try {
392
- return await fn(attempt);
393
- } catch (err) {
394
- if (signal?.aborted) throw err;
395
- const status = errorStatus(err);
396
- if (!(status !== void 0 && retryStatuses.includes(status) || isTransientNetworkError(err)) || attempt >= attempts) throw err;
397
- const retryAfterMs = parseRetryAfter(err?.response?.headers?.get?.("retry-after") ?? null);
398
- const expCap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
399
- const delay = Math.min(maxDelayMs, retryAfterMs ?? Math.round(Math.random() * expCap));
400
- if (label) consola.debug(`[upstream-retry] ${label}: attempt ${attempt}/${attempts} threw (${status !== void 0 ? `HTTP ${status}` : err?.name ?? "error"}); retrying in ${delay}ms`);
401
- await abortableSleep(delay, signal);
402
- }
403
- }
404
- }
405
- //#endregion
406
- //#region src/services/github/get-copilot-token.ts
407
- /**
408
- * Allowlist of hosts the router will trust as the Copilot API base URL.
409
- * Anything else returned in `endpoints.api` (e.g. via a tampered or
410
- * misconfigured token-exchange response) is rejected — otherwise a
411
- * malicious value would receive the long-lived GitHub PAT we send to
412
- * `/mcp` for web search (see `src/services/copilot/web-search.ts`).
413
- */
414
- const COPILOT_HOST_ALLOWLIST = [
415
- "api.githubcopilot.com",
416
- "api.individual.githubcopilot.com",
417
- "api.business.githubcopilot.com",
418
- "api.enterprise.githubcopilot.com"
419
- ];
420
- /**
421
- * True iff `rawUrl` is an HTTPS URL whose host is a trusted Copilot API host.
422
- * Exported so every path that sends a long-lived token to a discovered
423
- * Copilot host (web search, first-mate CAPI session logs) shares one allowlist.
424
- */
425
- function isAllowedCopilotHost(rawUrl) {
426
- let parsed;
427
- try {
428
- parsed = new URL(rawUrl);
429
- } catch {
430
- return false;
431
- }
432
- if (parsed.protocol !== "https:") return false;
433
- return COPILOT_HOST_ALLOWLIST.includes(parsed.hostname);
434
- }
435
- const getCopilotToken = async () => {
436
- const response = await fetchWithTransientRetry(() => fetch(`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, { headers: githubHeaders(state) }), { label: "/copilot_internal/v2/token" });
437
- if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
438
- const data = await response.json();
439
- if (data.endpoints?.api) if (isAllowedCopilotHost(data.endpoints.api)) state.copilotApiUrl = data.endpoints.api;
440
- else consola.warn(`Refusing to honor Copilot API endpoint "${data.endpoints.api}" from the token-exchange response — not in allowlist (${COPILOT_HOST_ALLOWLIST.join(", ")}). ` + (state.copilotApiUrl ? `Keeping existing override "${state.copilotApiUrl}".` : `Falling back to the default api.githubcopilot.com.`));
441
- return data;
442
- };
443
- //#endregion
444
- //#region src/services/github/get-device-code.ts
445
- const DEFAULT_DEVICE_APP = {
446
- clientId: GITHUB_CLIENT_ID,
447
- scope: GITHUB_APP_SCOPES
448
- };
449
- async function getDeviceCode(app = DEFAULT_DEVICE_APP) {
450
- const response = await fetchWithTransientRetry(() => fetch(`${GITHUB_BASE_URL}/login/device/code`, {
451
- method: "POST",
452
- headers: standardHeaders(),
453
- body: JSON.stringify({
454
- client_id: app.clientId,
455
- scope: app.scope
456
- })
457
- }), { label: "/login/device/code" });
458
- if (!response.ok) throw new HTTPError("Failed to get device code", response);
459
- return await response.json();
460
- }
461
- //#endregion
462
- //#region src/services/github/get-user.ts
463
- async function getGitHubUser() {
464
- const response = await fetchWithTransientRetry(() => fetch(`${GITHUB_API_BASE_URL}/user`, { headers: {
465
- authorization: `token ${state.githubToken}`,
466
- ...standardHeaders()
467
- } }), { label: "/user" });
468
- if (!response.ok) throw new HTTPError("Failed to get GitHub user", response);
469
- return await response.json();
470
- }
471
- //#endregion
472
- //#region src/services/copilot/get-models.ts
473
- const getModels = async () => {
474
- const response = await fetchWithTransientRetry(() => tryRefreshAndRetry(() => fetch(`${copilotBaseUrl(state)}/models`, { headers: copilotHeaders(state) }), "/models"), { label: "/models" });
475
- if (!response.ok) throw new HTTPError("Failed to get models", response);
476
- return await response.json();
477
- };
478
- //#endregion
479
- //#region src/services/get-copilot-version.ts
480
- const FALLBACK$1 = "0.43.2026033101";
481
- async function getCopilotChatVersion() {
482
- const controller = new AbortController();
483
- const timeout = setTimeout(() => {
484
- controller.abort();
485
- }, 5e3);
486
- try {
487
- const response = await fetch("https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", {
488
- method: "POST",
489
- headers: {
490
- "Content-Type": "application/json",
491
- Accept: "application/json;api-version=7.1-preview.1"
492
- },
493
- body: JSON.stringify({
494
- filters: [{ criteria: [{
495
- filterType: 7,
496
- value: "GitHub.copilot-chat"
497
- }] }],
498
- flags: 914
499
- }),
500
- signal: controller.signal
501
- });
502
- if (!response.ok) return FALLBACK$1;
503
- return (await response.json())?.results?.[0]?.extensions?.[0]?.versions?.[0]?.version ?? FALLBACK$1;
504
- } catch {
505
- return FALLBACK$1;
506
- } finally {
507
- clearTimeout(timeout);
508
- }
509
- }
510
- //#endregion
511
- //#region src/services/get-vscode-version.ts
512
- const FALLBACK = "1.104.3";
513
- async function getVSCodeVersion() {
514
- const controller = new AbortController();
515
- const timeout = setTimeout(() => {
516
- controller.abort();
517
- }, 5e3);
518
- try {
519
- const match = (await (await fetch("https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=visual-studio-code-bin", { signal: controller.signal })).text()).match(/pkgver=([0-9.]+)/);
520
- if (match) return match[1];
521
- return FALLBACK;
522
- } catch {
523
- return FALLBACK;
524
- } finally {
525
- clearTimeout(timeout);
526
- }
527
- }
528
- await getVSCodeVersion();
529
- //#endregion
530
- //#region src/lib/utils.ts
531
- const sleep$3 = (ms) => new Promise((resolve) => {
532
- setTimeout(resolve, ms);
533
- });
534
- const isNullish = (value) => value === null || value === void 0;
535
- /**
536
- * Beta prefixes VS Code Copilot Chat v0.43 actually sends.
537
- * Default mode — makes proxy traffic indistinguishable from VS Code.
538
- */
539
- const VSCODE_BETA_PREFIXES = [
540
- "interleaved-thinking-",
541
- "context-management-",
542
- "advanced-tool-use-"
543
- ];
544
- /**
545
- * Extended beta prefixes for Claude CLI compatibility.
546
- * Enabled via --extended-betas flag. Includes all betas confirmed
547
- * to work with the Copilot API.
548
- *
549
- * Notably absent (Copilot 400s on these — verified live):
550
- * context-1m-, skills-, files-api-, code-execution-, output-128k-,
551
- * advisor-tool- (see EXPLICITLY_STRIPPED_BETA_PREFIXES below).
552
- * 1M context is unlocked by selecting `claude-opus-4.7-1m-internal`
553
- * as the model id, not via a beta header.
554
- *
555
- * Empirical verification (2026-05-11 against api.enterprise.githubcopilot.com):
556
- * task-budgets-2026-03-13 → 200 ACCEPTED (cost-ceiling leverage)
557
- * token-efficient-tools-2026-03-28 → 200 ACCEPTED (per-tool token saving)
558
- * summarize-connector-text-2026-03-13 → 200 (Anthropic-internal feature flag,
559
- * won't fire for non-ant users; allowlisted defensively for ant edge case)
560
- * afk-mode-2026-01-31 → 200 (Anthropic-internal feature flag)
561
- * cli-internal-2026-02-09 → 200 (USER_TYPE=ant only)
562
- * oauth-2025-04-20 → 200 (Files-API path; Files-API itself
563
- * is not supportable via Copilot, but the header alone is harmless)
564
- * prompt-caching-scope-2026-01-05 → 200 even with body cache_control.scope
565
- * stripped (already covered by `prompt-caching-` prefix above)
566
- */
567
- const EXTENDED_BETA_PREFIXES = [
568
- ...VSCODE_BETA_PREFIXES,
569
- "claude-code-",
570
- "effort-",
571
- "prompt-caching-",
572
- "computer-use-",
573
- "pdfs-",
574
- "max-tokens-",
575
- "token-counting-",
576
- "compact-",
577
- "structured-outputs-",
578
- "fast-mode-",
579
- "mcp-client-",
580
- "mcp-servers-",
581
- "redact-thinking-",
582
- "web-search-",
583
- "task-budgets-",
584
- "token-efficient-tools-",
585
- "summarize-connector-text-",
586
- "afk-mode-",
587
- "cli-internal-",
588
- "oauth-"
589
- ];
590
- /**
591
- * Beta prefixes the proxy explicitly STRIPS even from the extended
592
- * allowlist (and even if a future leverage mode broadens the allowlist
593
- * further). Defensive layer: today's allowlist-only filter would already
594
- * drop these because they're not in any allowlist, but keeping an
595
- * explicit deny-list catches future changes that broaden allow rules
596
- * (e.g. a hypothetical pattern-based mode that lets `claude-*` through).
597
- *
598
- * Empirical (2026-05-11): Copilot returns HTTP 400
599
- * `unsupported beta header(s): advisor-tool-2026-03-01`
600
- * on every request that includes `advisor-tool-`. Stripping it is the
601
- * difference between a working request (no ADVISOR semantics) and a
602
- * fully-failed request. Document upstream limitation in CLAUDE.md.
603
- */
604
- const EXPLICITLY_STRIPPED_BETA_PREFIXES = ["advisor-tool-"];
605
- /**
606
- * Filter an `anthropic-beta` header value, keeping only beta flags
607
- * in the active whitelist AND not in the explicit-strip list.
608
- * Uses extended prefixes when --extended-betas is enabled, VS Code-only
609
- * prefixes otherwise. Returns the filtered comma-separated string,
610
- * or undefined if nothing remains.
611
- */
612
- function filterBetaHeader(value) {
613
- const prefixes = state.extendedBetas ? EXTENDED_BETA_PREFIXES : VSCODE_BETA_PREFIXES;
614
- return value.split(",").map((v) => v.trim()).filter((v) => v && prefixes.some((prefix) => v.startsWith(prefix)) && !EXPLICITLY_STRIPPED_BETA_PREFIXES.some((p) => v.startsWith(p))).join(",") || void 0;
615
- }
616
- /**
617
- * Normalize a model ID for fuzzy comparison: lowercase, replace dots with
618
- * dashes, insert dash at letter→digit boundaries, and collapse repeated
619
- * dashes. E.g. "gpt5.3-codex" → "gpt-5-3-codex", "GPT-5.3-Codex" → "gpt-5-3-codex".
620
- */
621
- function normalizeModelId$1(id) {
622
- return id.toLowerCase().replace(/\./g, "-").replace(/([a-z])(\d)/g, "$1-$2").replace(/-{2,}/g, "-");
623
- }
624
- /**
625
- * Resolve a model name to the best available variant in the Copilot model list.
626
- *
627
- * Resolution cascade:
628
- * 0. `[1m]` literal-bracket suffix: strip, delegate, warn if downgraded.
629
- * Bracketed slug must never reach Copilot (400s on it). See cc-backup
630
- * `src/utils/context.ts:35-40` for Claude Code's 1M unlock mechanism.
631
- * 1. Exact match
632
- * 2. Case-insensitive match
633
- * 3. Family preference (opus→1m, codex→highest version)
634
- * 4. Normalized match (dots→dashes, letter-digit boundaries)
635
- * 5. Anthropic dated-slug retry: if the input matches `claude-...-YYYYMMDD`,
636
- * strip the date and re-run the cascade once. Family-guarded so non-claude
637
- * 8-digit suffixes can't be mis-stripped; runs after Steps 1-4 so explicit
638
- * version pinning (a dated catalog id matched at Step 1) always wins.
639
- * 6. Return as-is with a warning
640
- */
641
- /**
642
- * `[1m]`-downgrade warnings we've already emitted this process. `resolveModel`
643
- * is called many times per launch (env setup, model validation, the banner,
644
- * and — under `serve` — on every model-picker `/models` poll), so the downgrade
645
- * notice is deduped to once per model id to avoid log spam.
646
- */
647
- const warnedOneMDowngrade = /* @__PURE__ */ new Set();
648
- function resolveModel(modelId) {
649
- const models = state.models?.data;
650
- if (!models) return modelId;
651
- const oneMMatch = modelId.match(/^(.*)\[1m\]$/i);
652
- if (oneMMatch) {
653
- const stripped = oneMMatch[1];
654
- const resolved = resolveModel(stripped);
655
- if (!(/-1m(?:$|-)/.test(resolved) || (models.find((m) => m.id === resolved)?.capabilities?.limits?.max_context_window_tokens ?? 0) >= 1e6) && !warnedOneMDowngrade.has(modelId)) {
656
- warnedOneMDowngrade.add(modelId);
657
- consola.warn(`Model "${modelId}" requested 1M context but no 1M backend is in Copilot's catalog for this tier/family; downgrading upstream to "${resolved}" (200K). Claude Code's local context accounting will still assume 1M — expect premature auto-compact. Drop the [1m] suffix (or unset CLAUDE_CODE_DISABLE_1M_CONTEXT if you set it) to silence.`);
658
- }
659
- return resolved;
660
- }
661
- if (models.some((m) => m.id === modelId)) return modelId;
662
- const lower = modelId.toLowerCase();
663
- const ciMatch = models.find((m) => m.id.toLowerCase() === lower);
664
- if (ciMatch) return ciMatch.id;
665
- if (lower.includes("opus")) {
666
- const oneMs = models.filter((m) => m.id.includes("opus") && /-1m(?:$|-)/.test(m.id));
667
- const versionMatch = lower.match(/opus-(\d+)(?:[.-](\d+))?/);
668
- const requestedVersion = versionMatch ? versionMatch[2] ? `${versionMatch[1]}.${versionMatch[2]}` : versionMatch[1] : void 0;
669
- const oneM = (requestedVersion ? oneMs.find((m) => m.id.includes(`opus-${requestedVersion}-`)) : void 0) ?? (requestedVersion ? void 0 : oneMs[0]);
670
- if (oneM) return oneM.id;
671
- }
672
- if (lower.includes("codex")) {
673
- const codexModels = models.filter((m) => m.id.includes("codex") && !m.id.includes("mini"));
674
- if (codexModels.length > 0) {
675
- codexModels.sort((a, b) => b.id.localeCompare(a.id));
676
- return codexModels[0].id;
677
- }
678
- }
679
- const normalized = normalizeModelId$1(modelId);
680
- const normMatch = models.find((m) => normalizeModelId$1(m.id) === normalized);
681
- if (normMatch) return normMatch.id;
682
- const dateStripped = modelId.replace(/^(claude-[\w.-]+)-20\d{6}$/i, "$1");
683
- if (dateStripped !== modelId) {
684
- const retried = resolveModel(dateStripped);
685
- if (retried !== dateStripped || models.some((m) => m.id === dateStripped)) {
686
- consola.info(`Resolved Anthropic dated slug "${modelId}" → "${retried}" (stripped -YYYYMMDD; pass an explicit catalog id to pin a snapshot)`);
687
- return retried;
688
- }
689
- }
690
- if (lower.startsWith("claude-")) {
691
- const matchSonnet = /(?:^|-)sonnet(?:-|$)/.test(lower);
692
- const matchHaiku = /(?:^|-)haiku(?:-|$)/.test(lower);
693
- if (matchSonnet || matchHaiku) {
694
- const family = matchSonnet ? "sonnet" : "haiku";
695
- const familyMembers = models.filter((m) => new RegExp(`(?:^|-)${family}(?:-|$|\\.)`).test(m.id));
696
- if (familyMembers.length > 0) {
697
- familyMembers.sort((a, b) => b.id.localeCompare(a.id, void 0, { numeric: true }));
698
- const best = familyMembers[0].id;
699
- consola.info(`Model "${modelId}" not in Copilot catalog; falling back to highest available "${best}" (legacy ${family} slug). Pin a current catalog id to silence.`);
700
- return best;
701
- }
702
- }
703
- }
704
- consola.warn(`Model "${modelId}" not found in Copilot model list. Available: ${models.map((m) => m.id).join(", ")}`);
705
- return modelId;
706
- }
707
- /**
708
- * Resolve a codex model ID, falling back to the best available codex model.
709
- * Used by the codex subcommand for model selection.
710
- */
711
- function resolveCodexModel(modelId) {
712
- const resolved = resolveModel(modelId);
713
- const models = state.models?.data;
714
- if (!models) return resolved;
715
- if (models.some((m) => m.id === resolved)) return resolved;
716
- const candidates = models.filter((m) => {
717
- const endpoints = m.supported_endpoints ?? [];
718
- if (m.id.includes("mini") || m.id.includes("nano")) return false;
719
- return endpoints.length === 0 || endpoints.includes("/responses");
720
- });
721
- if (candidates.length > 0) {
722
- candidates.sort((a, b) => {
723
- const aCodex = a.id.includes("codex") ? 1 : 0;
724
- const bCodex = b.id.includes("codex") ? 1 : 0;
725
- if (aCodex !== bCodex) return bCodex - aCodex;
726
- return b.id.localeCompare(a.id);
727
- });
728
- const best = candidates[0].id;
729
- consola.warn(`Model "${modelId}" not available, using "${best}" instead`);
730
- return best;
731
- }
732
- return resolved;
733
- }
734
- async function cacheModels() {
735
- const models = await getModels();
736
- state.models = models;
737
- }
738
- const cacheVSCodeVersion = async () => {
739
- const response = await getVSCodeVersion();
740
- state.vsCodeVersion = response;
741
- consola.info(`Using VSCode version: ${response}`);
742
- };
743
- const cacheCopilotVersion = async () => {
744
- const version = await getCopilotChatVersion();
745
- state.copilotVersion = version;
746
- consola.info(`Using Copilot Chat version: ${version}`);
747
- };
748
- //#endregion
749
- //#region src/services/github/poll-access-token.ts
750
- async function pollAccessToken(deviceCode, clientId = GITHUB_CLIENT_ID) {
751
- const sleepDuration = (deviceCode.interval + 1) * 1e3;
752
- consola.debug(`Polling access token with interval of ${sleepDuration}ms`);
753
- const expiresAt = Date.now() + deviceCode.expires_in * 1e3;
754
- while (Date.now() < expiresAt) {
755
- const response = await fetch(`${GITHUB_BASE_URL}/login/oauth/access_token`, {
756
- method: "POST",
757
- headers: standardHeaders(),
758
- body: JSON.stringify({
759
- client_id: clientId,
760
- device_code: deviceCode.device_code,
761
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
762
- })
763
- });
764
- if (!response.ok) {
765
- consola.error("Failed to poll access token:", await response.text());
766
- if (Date.now() >= expiresAt) break;
767
- await sleep$3(sleepDuration);
768
- continue;
769
- }
770
- const json = await response.json();
771
- consola.debug("Polling access token response:", json);
772
- const { access_token } = json;
773
- if (access_token) return access_token;
774
- if (Date.now() >= expiresAt) break;
775
- await sleep$3(sleepDuration);
776
- }
777
- throw new Error("Device code expired. Please run auth again.");
778
- }
779
- //#endregion
780
- //#region src/lib/token.ts
781
- const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8");
782
- const writeGithubToken = (token) => fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token);
783
- const readGithubAgentToken = () => fs.readFile(PATHS.GITHUB_AGENT_TOKEN_PATH, "utf8");
784
- const writeGithubAgentToken = (token) => fs.writeFile(PATHS.GITHUB_AGENT_TOKEN_PATH, token, { mode: 384 });
785
- const setupCopilotToken = async () => {
786
- const { token, refresh_in } = await getCopilotToken();
787
- state.copilotToken = token;
788
- consola.debug("GitHub Copilot Token fetched successfully!");
789
- if (state.showToken) consola.info("Copilot token:", token);
790
- const refreshInterval = Math.max((refresh_in - 60) * 1e3, 1e3);
791
- setInterval(() => {
792
- refreshCopilotToken("interval");
793
- }, refreshInterval);
794
- };
795
- let inflightRefresh;
796
- let lastRefreshSuccess = 0;
797
- let lastRefreshFailure = 0;
798
- const REFRESH_SUCCESS_COOLDOWN_MS = 3e4;
799
- const REFRESH_FAILURE_COOLDOWN_MS = 5e3;
800
- async function refreshCopilotToken(reason) {
801
- if (inflightRefresh) return inflightRefresh;
802
- if (reason === "401-retry") {
803
- const now = Date.now();
804
- if (now - lastRefreshSuccess < REFRESH_SUCCESS_COOLDOWN_MS) {
805
- consola.debug(`refreshCopilotToken(${reason}) skipped: prior success within ${REFRESH_SUCCESS_COOLDOWN_MS}ms`);
806
- return;
807
- }
808
- if (now - lastRefreshFailure < REFRESH_FAILURE_COOLDOWN_MS) {
809
- consola.debug(`refreshCopilotToken(${reason}) skipped: prior failure within ${REFRESH_FAILURE_COOLDOWN_MS}ms`);
810
- return;
811
- }
812
- }
813
- inflightRefresh = (async () => {
814
- consola.debug(`Refreshing Copilot token (reason=${reason})`);
815
- try {
816
- const { token } = await getCopilotToken();
817
- state.copilotToken = token;
818
- lastRefreshSuccess = Date.now();
819
- consola.debug("Copilot token refreshed");
820
- if (state.showToken) consola.info("Refreshed Copilot token:", token);
821
- } catch (error) {
822
- lastRefreshFailure = Date.now();
823
- consola.error(`Failed to refresh Copilot token (reason=${reason}):`, error);
824
- } finally {
825
- inflightRefresh = void 0;
826
- }
827
- })();
828
- return inflightRefresh;
829
- }
830
- /**
831
- * Try `request()`. If it returns a 401, refresh the Copilot token (subject
832
- * to the single-flight + refresh-storm-protection of `refreshCopilotToken`)
833
- * and retry once. After one retry, propagate whatever the second attempt
834
- * returned — the caller's existing 401-handling path is preserved.
835
- *
836
- * The `request` callback is responsible for capturing `state.copilotToken`
837
- * locally before any await; this helper does NOT re-build the request
838
- * itself, just re-invokes the callback after a refresh.
839
- */
840
- async function tryRefreshAndRetry(request, routePath) {
841
- const first = await request();
842
- if (first.status !== 401) return first;
843
- consola.warn(`${routePath}: upstream returned 401, attempting one token refresh + retry`);
844
- await refreshCopilotToken("401-retry");
845
- return request();
846
- }
847
- async function setupGitHubToken(options) {
848
- try {
849
- const githubToken = await readGithubToken();
850
- if (githubToken && !options?.force) {
851
- state.githubToken = githubToken;
852
- if (state.showToken) consola.info("GitHub token:", githubToken);
853
- await logUser();
854
- return;
855
- }
856
- consola.info("Not logged in, getting new access token");
857
- const response = await getDeviceCode();
858
- consola.debug("Device code response:", response);
859
- consola.info(`Please enter the code "${response.user_code}" in ${response.verification_uri}`);
860
- const token = await pollAccessToken(response);
861
- await writeGithubToken(token);
862
- state.githubToken = token;
863
- if (state.showToken) consola.info("GitHub token:", token);
864
- await logUser();
865
- } catch (error) {
866
- if (error instanceof HTTPError) {
867
- consola.error("Failed to get GitHub token:", await error.response.json());
868
- throw error;
869
- }
870
- consola.error("Failed to get GitHub token:", error);
871
- throw error;
872
- }
873
- }
874
- async function logUser() {
875
- const user = await getGitHubUser();
876
- consola.info(`Logged in as ${user.login}`);
877
- }
878
- /**
879
- * Set up the SECOND, write-capable GitHub token used by the first-mate
880
- * agent-orchestration surface (`--agents`). Mirrors `setupGitHubToken`
881
- * but authenticates against the GitHub CLI's OAuth client
882
- * (`GITHUB_AGENT_CLIENT_ID`) requesting `repo workflow read:org`, and
883
- * stores the result apart at `PATHS.GITHUB_AGENT_TOKEN_PATH`. The Copilot
884
- * App token (`state.githubToken`) is left completely untouched — this is
885
- * a distinct identity for a distinct capability.
886
- *
887
- * Long-lived (device-flow user token) → no refresh loop; a later 401 is
888
- * surfaced to the caller as a revoked grant to re-run the login. Called
889
- * once from `setupAndServe` when `state.agentsEnabled` is true.
890
- */
891
- async function setupGitHubAgentToken(options) {
892
- try {
893
- const existing = (await readGithubAgentToken().catch(() => "")).trim();
894
- if (existing && !options?.force) {
895
- state.githubAgentToken = existing;
896
- if (state.showToken) consola.info("GitHub agent token:", existing);
897
- await warnIfAgentScopesInsufficient();
898
- return;
899
- }
900
- consola.info("Agent mode (--agents): a second GitHub login is required for a write-capable token (repo, workflow, read:org).");
901
- const response = await getDeviceCode({
902
- clientId: GITHUB_AGENT_CLIENT_ID,
903
- scope: GITHUB_AGENT_SCOPES
904
- });
905
- consola.debug("Agent device code response:", response);
906
- consola.info(`Please enter the code "${response.user_code}" in ${response.verification_uri} to authorize github-router's cloud-agent orchestration to act on your repositories.`);
907
- const token = await pollAccessToken(response, GITHUB_AGENT_CLIENT_ID);
908
- await writeGithubAgentToken(token);
909
- state.githubAgentToken = token;
910
- if (state.showToken) consola.info("GitHub agent token:", token);
911
- await warnIfAgentScopesInsufficient();
912
- } catch (error) {
913
- if (error instanceof HTTPError) {
914
- consola.error("Failed to get GitHub agent token:", await error.response.json());
915
- throw error;
916
- }
917
- consola.error("Failed to get GitHub agent token:", error);
918
- throw error;
919
- }
920
- }
921
- /**
922
- * Best-effort check that the agent token actually carries the scopes we
923
- * asked for. The GitHub CLI OAuth client is a classic OAuth App, so the
924
- * granted scopes are echoed in the `x-oauth-scopes` response header on
925
- * any authenticated call. Warn loudly (not fatal) if `repo`/`workflow`
926
- * are missing so the failure is diagnosable at login rather than at the
927
- * first write 403.
928
- */
929
- async function warnIfAgentScopesInsufficient() {
930
- try {
931
- const res = await fetch(`${GITHUB_API_BASE_URL}/user`, { headers: githubAgentHeaders(state) });
932
- if (res.status === 401) {
933
- consola.warn("GitHub agent token was rejected (401) — the grant may have been revoked. Re-run with --agents to log in again.");
934
- return;
935
- }
936
- const scopes = (res.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
937
- const missing = ["repo", "workflow"].filter((s) => !scopes.includes(s));
938
- if (missing.length > 0) consola.warn(`GitHub agent token is missing scope(s): ${missing.join(", ")}. The first-mate surface needs 'repo' + 'workflow' to create issues, assign cloud agents, and dispatch workflows. Re-run the agent login and grant the requested scopes.`);
939
- } catch (err) {
940
- consola.debug("Agent token scope check skipped:", err);
941
- }
942
- }
943
- //#endregion
944
43
  //#region src/lib/update-lock.ts
945
44
  /**
946
45
  * Cross-process install lock for the auto-update / self-update paths.
@@ -996,32 +95,6 @@ async function tryCreateLock(p) {
996
95
  }
997
96
  }
998
97
  //#endregion
999
- //#region src/lib/version.ts
1000
- /**
1001
- * Read this binary's published version from package.json at runtime.
1002
- *
1003
- * Done at runtime (not baked at build time) because release.yml builds
1004
- * BEFORE `npm version patch` bumps the version — a build-time inline
1005
- * would always ship the pre-bump value. The npm tarball ships package.json
1006
- * alongside `dist/`, so a sibling-up lookup from import.meta.url resolves
1007
- * cleanly in both dev (`src/lib/`) and bundled (`dist/`) layouts.
1008
- *
1009
- * Returns `"unknown"` if package.json can't be located or parsed —
1010
- * never throws, so the CLI never fails to start over version reporting.
1011
- */
1012
- function getPackageVersion() {
1013
- try {
1014
- const here = dirname(fileURLToPath(import.meta.url));
1015
- const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
1016
- for (const path of candidates) try {
1017
- const raw = readFileSync(path, "utf8");
1018
- const parsed = JSON.parse(raw);
1019
- if (typeof parsed.version === "string" && (parsed.name === "github-router" || parsed.name === "@animeshkundu/github-router")) return parsed.version;
1020
- } catch {}
1021
- } catch {}
1022
- return "unknown";
1023
- }
1024
- //#endregion
1025
98
  //#region src/lib/port.ts
1026
99
  const DEFAULT_PORT = 8787;
1027
100
  const DEFAULT_CLAUDE_MODEL_FALLBACKS = [
@@ -1183,432 +256,6 @@ function collapsePathKeys(env) {
1183
256
  return env;
1184
257
  }
1185
258
  //#endregion
1186
- //#region src/lib/mcp-workspace-header.ts
1187
- /** Header the per-session MCP headersHelper emits, carrying the calling Claude
1188
- * session's working directory. The /mcp handler uses it as the default
1189
- * `workspace` for repo-scoped tools so a machine-wide `serve` targets the
1190
- * active repo, not the proxy launch cwd. */
1191
- const MCP_WORKSPACE_HEADER = "X-GH-Workspace";
1192
- /** stdout JSON the headersHelper prints; Claude merges it into the connection
1193
- * headers. `cwd` is the helper's own process cwd = the session's project dir. */
1194
- function buildWorkspaceHeaderJson(cwd) {
1195
- return JSON.stringify({ [MCP_WORKSPACE_HEADER]: cwd });
1196
- }
1197
- /** Command string Claude runs as the headersHelper. Mirrors
1198
- * buildPromptSubmitHookCommand's quoting (src/lib/orchestration/prompt-submit-hook.ts). */
1199
- function buildWorkspaceHeaderHelperCommand(execPath, scriptPath) {
1200
- const q = (s) => `"${s}"`;
1201
- return scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)} internal-workspace-header` : `${q(execPath)} internal-workspace-header`;
1202
- }
1203
- //#endregion
1204
- //#region src/lib/insecure-tls.ts
1205
- /**
1206
- * Runtime-aware "skip TLS verification" for a self-signed direct-HTTPS instance.
1207
- * The mechanism differs by runtime and getting it wrong is silent: the BUILT
1208
- * proxy runs under Node (bin shebang `#!/usr/bin/env node`), whose fetch (undici)
1209
- * IGNORES a per-request `tls` option — only a `dispatcher` relaxes verification.
1210
- * `bun run dev` and the test suite run under Bun, whose fetch honors `tls`. So we
1211
- * detect the runtime and attach the field it actually understands.
1212
- *
1213
- * Shared by the fleet control-plane client and the artifact-review client — both
1214
- * talk to a self-signed ai-or-die instance on loopback. Per-request scope (not a
1215
- * global TLS posture change) keeps Copilot upstream verification untouched.
1216
- */
1217
- const IS_BUN$1 = typeof globalThis.Bun !== "undefined";
1218
- let sharedInsecureDispatcher;
1219
- function insecureDispatcher() {
1220
- return sharedInsecureDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } });
1221
- }
1222
- /**
1223
- * Attach the runtime-correct TLS-verification-off mechanism to a fetch init for a
1224
- * single self-signed direct-HTTPS instance: Bun → `tls`, Node → an undici
1225
- * `dispatcher`. Exported so BOTH runtime branches are unit-testable under one
1226
- * interpreter (the untested Node branch is exactly what shipped broken).
1227
- */
1228
- function applyInsecureTls(init, isBun = IS_BUN$1) {
1229
- if (isBun) init.tls = { rejectUnauthorized: false };
1230
- else init.dispatcher = insecureDispatcher();
1231
- }
1232
- //#endregion
1233
- //#region src/lib/artifact/client.ts
1234
- var ArtifactError = class extends Error {
1235
- code;
1236
- retryable;
1237
- status;
1238
- detail;
1239
- constructor(args) {
1240
- super(args.message);
1241
- this.name = "ArtifactError";
1242
- this.code = args.code;
1243
- this.retryable = args.retryable;
1244
- this.status = args.status;
1245
- this.detail = args.detail;
1246
- }
1247
- };
1248
- const AWAIT_DEFAULT_TIMEOUT_MS = 25e3;
1249
- const AWAIT_ABORT_MARGIN_MS = 5e3;
1250
- const DEFAULT_RETRY_BASE_MS = 250;
1251
- const TRANSIENT_RETRIES = 2;
1252
- const DEFAULT_RETRYABLE_CODES = /* @__PURE__ */ new Set(["UNREACHABLE", "TIMEOUT"]);
1253
- const AWAIT_RETRYABLE_CODES = /* @__PURE__ */ new Set(["UNREACHABLE"]);
1254
- var ArtifactClient = class {
1255
- baseUrl;
1256
- token;
1257
- sessionId;
1258
- fetchFn;
1259
- insecureTLS;
1260
- retryBaseMs;
1261
- constructor(options) {
1262
- this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1263
- this.token = options.token;
1264
- this.sessionId = options.sessionId;
1265
- this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
1266
- this.insecureTLS = options.insecureTLS ?? false;
1267
- this.retryBaseMs = options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
1268
- }
1269
- /**
1270
- * Open (or replace) the review for this session. `mode` is advisory metadata
1271
- * forwarded to the server (forward-compat: ignored if unknown); the actual
1272
- * interactivity comes from the served HTML's `data-aod-*` markup. Retried on a
1273
- * transient failure with a STABLE idempotency key so a retry-after-success does
1274
- * not double-open.
1275
- */
1276
- open(file, opts = {}) {
1277
- const idempotencyKey = opts.idempotencyKey ?? randomUUID();
1278
- const body = { file };
1279
- if (opts.mode) body.mode = opts.mode;
1280
- return this.withRetry(() => this.requestOnce({
1281
- method: "POST",
1282
- pathname: this.path("/open"),
1283
- body,
1284
- signal: opts.signal,
1285
- idempotencyKey
1286
- }), TRANSIENT_RETRIES, opts.signal);
1287
- }
1288
- /**
1289
- * Replace the current review's content. Exactly one of `file` | `html` (the
1290
- * caller-facing tool enforces that). `html` is written by the SERVER to the
1291
- * review's existing sandboxed file, so `html` with no existing review is an
1292
- * INVALID_REQUEST from the server. Retried with a stable idempotency key.
1293
- */
1294
- update(opts) {
1295
- const idempotencyKey = opts.idempotencyKey ?? randomUUID();
1296
- const body = {};
1297
- if (opts.file !== void 0) body.file = opts.file;
1298
- if (opts.html !== void 0) body.html = opts.html;
1299
- return this.withRetry(() => this.requestOnce({
1300
- method: "POST",
1301
- pathname: this.path("/update"),
1302
- body,
1303
- signal: opts.signal,
1304
- idempotencyKey,
1305
- allowEmptyJson: true
1306
- }), TRANSIENT_RETRIES, opts.signal);
1307
- }
1308
- /** Force a reload from disk (no content change). Single-shot (idempotent, cheap). */
1309
- refresh(signal) {
1310
- return this.requestOnce({
1311
- method: "POST",
1312
- pathname: this.path("/refresh"),
1313
- signal,
1314
- allowEmptyJson: true
1315
- });
1316
- }
1317
- /** Hide the panel UI while keeping the review alive. Single-shot; server idempotent. */
1318
- dismiss(signal) {
1319
- return this.requestOnce({
1320
- method: "POST",
1321
- pathname: this.path("/dismiss"),
1322
- signal,
1323
- allowEmptyJson: true
1324
- });
1325
- }
1326
- /**
1327
- * Typed drain (contract v2.2 §1/§2). Long-holds up to the server cap, returns
1328
- * events with `id > cursor` plus the new high-water `cursor`. Idempotent by
1329
- * (cursor, event.id): re-calling with the same cursor replays the same window
1330
- * from the server's bounded buffer, so a lost cursor (compaction) is
1331
- * recoverable. Retried on a transient network failure (the cursor makes retry
1332
- * safe — no double-consumption).
1333
- */
1334
- awaitEvents(opts = {}) {
1335
- const serverTimeoutMs = typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : AWAIT_DEFAULT_TIMEOUT_MS;
1336
- const clientTimeoutMs = serverTimeoutMs + AWAIT_ABORT_MARGIN_MS;
1337
- return this.withRetry(() => this.requestOnce({
1338
- method: "GET",
1339
- pathname: this.path("/await"),
1340
- query: {
1341
- cursor: opts.cursor,
1342
- timeoutMs: String(serverTimeoutMs)
1343
- },
1344
- signal: opts.signal,
1345
- timeoutMsHint: clientTimeoutMs
1346
- }), TRANSIENT_RETRIES, opts.signal, AWAIT_RETRYABLE_CODES);
1347
- }
1348
- /** Free-text agent->human reply. SINGLE-SHOT (not retried): a retry-after-success
1349
- * would render a duplicate chat bubble server-side. */
1350
- agentReply(text, signal) {
1351
- return this.requestOnce({
1352
- method: "POST",
1353
- pathname: this.path("/agent-reply"),
1354
- body: { text },
1355
- signal,
1356
- allowEmptyJson: true
1357
- });
1358
- }
1359
- /**
1360
- * End the review. Retried with a stable idempotency key; a NOT_FOUND (the
1361
- * review is already ended — whether by a prior call or by a retry landing after
1362
- * the first attempt succeeded) is mapped to a successful `{ ok, status:"ended" }`
1363
- * rather than surfaced as a 404 (contract v2.2 §1.1).
1364
- */
1365
- async end(signal) {
1366
- const idempotencyKey = randomUUID();
1367
- try {
1368
- return await this.withRetry(() => this.requestOnce({
1369
- method: "POST",
1370
- pathname: this.path("/end"),
1371
- signal,
1372
- idempotencyKey,
1373
- allowEmptyJson: true
1374
- }), TRANSIENT_RETRIES, signal);
1375
- } catch (err) {
1376
- if (err instanceof ArtifactError && err.code === "NOT_FOUND") return {
1377
- ok: true,
1378
- status: "ended"
1379
- };
1380
- throw err;
1381
- }
1382
- }
1383
- /**
1384
- * FROZEN legacy long-poll (contract v2.2 §1). Old payload shape,
1385
- * comment-equivalent only. New agents use `awaitEvents`. Single request per
1386
- * call (the caller-facing tool owns the bounded re-poll budget).
1387
- */
1388
- poll(timeoutMsHint, signal) {
1389
- return this.requestOnce({
1390
- method: "GET",
1391
- pathname: this.path("/poll"),
1392
- signal,
1393
- timeoutMsHint
1394
- });
1395
- }
1396
- path(suffix) {
1397
- return `/api/artifact/${encodeURIComponent(this.sessionId)}${suffix}`;
1398
- }
1399
- async withRetry(fn, retries, signal, retryableCodes = DEFAULT_RETRYABLE_CODES) {
1400
- let attempt = 0;
1401
- for (;;) try {
1402
- return await fn();
1403
- } catch (err) {
1404
- attempt += 1;
1405
- if (!(err instanceof ArtifactError && err.retryable && retryableCodes.has(err.code)) || attempt > retries || signal?.aborted) throw err;
1406
- const base = this.retryBaseMs;
1407
- await sleep$2(base <= 0 ? 0 : Math.round(base * 2 ** (attempt - 1) * (.5 + Math.random() * .5)), signal);
1408
- }
1409
- }
1410
- async requestOnce(o) {
1411
- let url;
1412
- try {
1413
- url = new URL(o.pathname, `${this.baseUrl}/`);
1414
- } catch (err) {
1415
- throw new ArtifactError({
1416
- code: "UNREACHABLE",
1417
- message: "artifact API base URL is invalid",
1418
- retryable: false,
1419
- detail: err
1420
- });
1421
- }
1422
- if (o.query) {
1423
- for (const [key, value] of Object.entries(o.query)) if (value !== void 0) url.searchParams.set(key, value);
1424
- }
1425
- const timeout = combineSignalAndTimeout(o.signal, o.timeoutMsHint);
1426
- try {
1427
- const headers = { Authorization: `Bearer ${this.token}` };
1428
- if (o.body !== void 0) headers["Content-Type"] = "application/json";
1429
- if (o.idempotencyKey) headers["Idempotency-Key"] = o.idempotencyKey;
1430
- const init = {
1431
- method: o.method,
1432
- headers,
1433
- body: o.body === void 0 ? void 0 : JSON.stringify(o.body),
1434
- redirect: "error",
1435
- signal: timeout.signal
1436
- };
1437
- if (this.insecureTLS) applyInsecureTls(init);
1438
- const response = await this.fetchFn(url.toString(), init);
1439
- if (!response.ok) throw await mapHttpError$1(response);
1440
- let text;
1441
- try {
1442
- text = await response.text();
1443
- } catch (err) {
1444
- if (isAbortLike$2(err)) throw mapNetworkError$1(err);
1445
- throw new ArtifactError({
1446
- code: "INVALID_RESPONSE",
1447
- message: "artifact API response body could not be read",
1448
- retryable: false,
1449
- detail: err
1450
- });
1451
- }
1452
- if (!text && o.allowEmptyJson) return {};
1453
- try {
1454
- return JSON.parse(text);
1455
- } catch (err) {
1456
- throw new ArtifactError({
1457
- code: "INVALID_RESPONSE",
1458
- message: "artifact API returned a non-JSON response",
1459
- retryable: false,
1460
- detail: err
1461
- });
1462
- }
1463
- } catch (err) {
1464
- if (err instanceof ArtifactError) throw err;
1465
- throw mapNetworkError$1(err);
1466
- } finally {
1467
- timeout.cleanup();
1468
- }
1469
- }
1470
- };
1471
- function sleep$2(ms, signal) {
1472
- if (ms <= 0) return Promise.resolve();
1473
- return new Promise((resolve, reject) => {
1474
- const onAbort = () => {
1475
- clearTimeout(timer);
1476
- reject(new DOMException("retry backoff aborted", "AbortError"));
1477
- };
1478
- const timer = setTimeout(() => {
1479
- signal?.removeEventListener("abort", onAbort);
1480
- resolve();
1481
- }, ms);
1482
- if (signal?.aborted) {
1483
- onAbort();
1484
- return;
1485
- }
1486
- signal?.addEventListener("abort", onAbort, { once: true });
1487
- });
1488
- }
1489
- function combineSignalAndTimeout(signal, timeoutMsHint) {
1490
- const timeoutMs = typeof timeoutMsHint === "number" && Number.isFinite(timeoutMsHint) && timeoutMsHint > 0 ? timeoutMsHint : void 0;
1491
- if (timeoutMs === void 0) return {
1492
- signal,
1493
- cleanup: () => {}
1494
- };
1495
- const controller = new AbortController();
1496
- const abortFromCaller = () => {
1497
- try {
1498
- controller.abort(signal?.reason);
1499
- } catch {
1500
- controller.abort();
1501
- }
1502
- };
1503
- if (signal?.aborted) abortFromCaller();
1504
- signal?.addEventListener("abort", abortFromCaller, { once: true });
1505
- const timer = setTimeout(() => {
1506
- try {
1507
- controller.abort(new DOMException("artifact API request timed out", "TimeoutError"));
1508
- } catch {
1509
- controller.abort();
1510
- }
1511
- }, timeoutMs);
1512
- return {
1513
- signal: controller.signal,
1514
- cleanup: () => {
1515
- clearTimeout(timer);
1516
- signal?.removeEventListener("abort", abortFromCaller);
1517
- }
1518
- };
1519
- }
1520
- async function mapHttpError$1(response) {
1521
- const detail = await readErrorDetail$1(response);
1522
- const upstreamMessage = detailToMessage$1(detail);
1523
- const suffix = upstreamMessage ? `: ${upstreamMessage}` : "";
1524
- if (response.status === 401 || response.status === 403) return new ArtifactError({
1525
- code: "AUTH_FAILED",
1526
- message: `artifact API authentication failed (${response.status})${suffix}`,
1527
- retryable: false,
1528
- status: response.status,
1529
- detail
1530
- });
1531
- if (response.status === 404) return new ArtifactError({
1532
- code: "NOT_FOUND",
1533
- message: `artifact session or resource not found (404)${suffix}`,
1534
- retryable: false,
1535
- status: response.status,
1536
- detail
1537
- });
1538
- if (response.status === 408 || response.status === 504) return new ArtifactError({
1539
- code: "TIMEOUT",
1540
- message: `artifact API request timed out (${response.status})${suffix}`,
1541
- retryable: true,
1542
- status: response.status,
1543
- detail
1544
- });
1545
- if (response.status === 400 && detailToCode(detail)?.toUpperCase() === "INVALID_REQUEST") return new ArtifactError({
1546
- code: "INVALID_REQUEST",
1547
- message: `artifact API rejected the request (400)${suffix}`,
1548
- retryable: false,
1549
- status: response.status,
1550
- detail
1551
- });
1552
- return new ArtifactError({
1553
- code: "UPSTREAM_ERROR",
1554
- message: `artifact API returned HTTP ${response.status}${suffix}`,
1555
- retryable: response.status === 429 || response.status >= 500,
1556
- status: response.status,
1557
- detail
1558
- });
1559
- }
1560
- function mapNetworkError$1(err) {
1561
- if (isAbortLike$2(err)) return new ArtifactError({
1562
- code: "TIMEOUT",
1563
- message: "artifact API request timed out or was aborted",
1564
- retryable: true,
1565
- detail: err
1566
- });
1567
- return new ArtifactError({
1568
- code: "UNREACHABLE",
1569
- message: `artifact API unreachable: ${err instanceof Error ? err.message : String(err)}`,
1570
- retryable: true,
1571
- detail: err
1572
- });
1573
- }
1574
- async function readErrorDetail$1(response) {
1575
- const text = await response.text().catch(() => "");
1576
- if (!text) return void 0;
1577
- try {
1578
- return JSON.parse(text);
1579
- } catch {
1580
- return text;
1581
- }
1582
- }
1583
- function detailToMessage$1(detail) {
1584
- if (typeof detail === "string") return detail;
1585
- if (typeof detail !== "object" || detail === null) return void 0;
1586
- const record = detail;
1587
- const error = record.error;
1588
- if (typeof error === "string") return error;
1589
- if (typeof error === "object" && error !== null) {
1590
- const errorRecord = error;
1591
- if (typeof errorRecord.message === "string") return errorRecord.message;
1592
- if (typeof errorRecord.code === "string") return errorRecord.code;
1593
- }
1594
- if (typeof record.message === "string") return record.message;
1595
- }
1596
- /** Extract a machine `code` from an upstream error body (`{error:{code}}` or
1597
- * `{code}`) so a tagged 400 can be classified (INVALID_REQUEST). */
1598
- function detailToCode(detail) {
1599
- if (typeof detail !== "object" || detail === null) return void 0;
1600
- const record = detail;
1601
- const error = record.error;
1602
- if (typeof error === "object" && error !== null) {
1603
- const code = error.code;
1604
- if (typeof code === "string") return code;
1605
- }
1606
- if (typeof record.code === "string") return record.code;
1607
- }
1608
- function isAbortLike$2(err) {
1609
- return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
1610
- }
1611
- //#endregion
1612
259
  //#region src/lib/artifact/tools.ts
1613
260
  const ARTIFACT_GROUP = "peers";
1614
261
  const ARTIFACT_POLL_TOOL_BUDGET_MS = 5e4;
@@ -2632,13 +1279,13 @@ function defaultFleetConfigPath() {
2632
1279
  async function loadFleetRegistryConfig(configPath = defaultFleetConfigPath()) {
2633
1280
  let stat;
2634
1281
  try {
2635
- stat = await fs.stat(configPath);
1282
+ stat = await fs$1.stat(configPath);
2636
1283
  } catch (err) {
2637
1284
  if (isNodeErrorCode(err, "ENOENT")) return { instances: [] };
2638
1285
  throw err;
2639
1286
  }
2640
1287
  if (process.platform !== "win32" && (stat.mode & 63) !== 0) console.warn(`[fleet] Registry file ${configPath} is group/other-readable; it contains bearer / tunnel credentials. Consider chmod 600.`);
2641
- const raw = await fs.readFile(configPath, "utf8");
1288
+ const raw = await fs$1.readFile(configPath, "utf8");
2642
1289
  if (raw.trim() === "") return { instances: [] };
2643
1290
  const parsed = JSON.parse(raw);
2644
1291
  if (!isObject(parsed)) throw new FleetRegistryError("INVALID_CONFIG", "fleet registry must be a JSON object");
@@ -2891,7 +1538,7 @@ function nonEmptyString(raw) {
2891
1538
  * validation; never throws — a missing/unreadable/oversized/malformed file
2892
1539
  * yields `[]` (discovery is best-effort and must never break static fleet use).
2893
1540
  */
2894
- async function readMeshPeers(readFileFn = (p) => fs.readFile(p, "utf8")) {
1541
+ async function readMeshPeers(readFileFn = (p) => fs$1.readFile(p, "utf8")) {
2895
1542
  if (meshDiscoveryDisabled()) return [];
2896
1543
  let raw;
2897
1544
  try {
@@ -2978,7 +1625,7 @@ function validEgressToken(raw) {
2978
1625
  */
2979
1626
  async function readMeshEgress(options = {}) {
2980
1627
  if (meshDiscoveryDisabled()) return void 0;
2981
- const readFileFn = options.readFileFn ?? ((p) => fs.readFile(p, "utf8"));
1628
+ const readFileFn = options.readFileFn ?? ((p) => fs$1.readFile(p, "utf8"));
2982
1629
  const now = options.now ?? (() => Date.now());
2983
1630
  const pidAlive = options.pidAlive ?? defaultPidAlive;
2984
1631
  let raw;
@@ -5559,7 +4206,7 @@ function isLease(value) {
5559
4206
  async function readLeaseFile(file) {
5560
4207
  let raw;
5561
4208
  try {
5562
- raw = await fs.readFile(file, "utf8");
4209
+ raw = await fs$1.readFile(file, "utf8");
5563
4210
  } catch (err) {
5564
4211
  if (err.code === "ENOENT") return void 0;
5565
4212
  throw err;
@@ -5572,40 +4219,40 @@ async function readLeaseFile(file) {
5572
4219
  }
5573
4220
  }
5574
4221
  async function writeLeaseAtomic(file, lease) {
5575
- await fs.mkdir(path.dirname(file), { recursive: true });
4222
+ await fs$1.mkdir(path.dirname(file), { recursive: true });
5576
4223
  const tmp = `${file}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
5577
4224
  try {
5578
- await fs.writeFile(tmp, `${JSON.stringify(lease, null, 2)}\n`, { mode: 384 });
5579
- await fs.rename(tmp, file);
4225
+ await fs$1.writeFile(tmp, `${JSON.stringify(lease, null, 2)}\n`, { mode: 384 });
4226
+ await fs$1.rename(tmp, file);
5580
4227
  } catch (err) {
5581
- await fs.unlink(tmp).catch(() => {});
4228
+ await fs$1.unlink(tmp).catch(() => {});
5582
4229
  throw err;
5583
4230
  }
5584
4231
  }
5585
4232
  const LEASE_LOCK_MAX_WAIT_MS = 1e4;
5586
4233
  async function withLeaseLock(file, fn, hooks) {
5587
4234
  const lockPath = `${file}.lock`;
5588
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
4235
+ await fs$1.mkdir(path.dirname(lockPath), { recursive: true });
5589
4236
  const ownerToken = `${process.pid}-${randomBytes(8).toString("hex")}`;
5590
4237
  const staging = `${lockPath}.acq.${ownerToken}`;
5591
- await fs.writeFile(staging, ownerToken, { mode: 384 });
4238
+ await fs$1.writeFile(staging, ownerToken, { mode: 384 });
5592
4239
  const start = Date.now();
5593
4240
  try {
5594
4241
  for (;;) try {
5595
- await fs.link(staging, lockPath);
4242
+ await fs$1.link(staging, lockPath);
5596
4243
  break;
5597
4244
  } catch (err) {
5598
4245
  if (err.code !== "EEXIST") throw err;
5599
4246
  let observed;
5600
4247
  try {
5601
- observed = (await fs.readFile(lockPath, "utf8")).trim();
5602
- const st = await fs.stat(lockPath);
4248
+ observed = (await fs$1.readFile(lockPath, "utf8")).trim();
4249
+ const st = await fs$1.stat(lockPath);
5603
4250
  if (Date.now() - st.mtimeMs > 1e4) {
5604
4251
  if (hooks?.onObservedStale) await hooks.onObservedStale();
5605
4252
  const breaker = `${lockPath}.stale.${ownerToken}`;
5606
4253
  let moved = false;
5607
4254
  try {
5608
- await fs.rename(lockPath, breaker);
4255
+ await fs$1.rename(lockPath, breaker);
5609
4256
  moved = true;
5610
4257
  } catch (e) {
5611
4258
  if (e.code !== "ENOENT") throw e;
@@ -5613,13 +4260,13 @@ async function withLeaseLock(file, fn, hooks) {
5613
4260
  if (moved) {
5614
4261
  let movedToken;
5615
4262
  try {
5616
- movedToken = (await fs.readFile(breaker, "utf8")).trim();
4263
+ movedToken = (await fs$1.readFile(breaker, "utf8")).trim();
5617
4264
  } catch {
5618
4265
  movedToken = void 0;
5619
4266
  }
5620
- if (movedToken === observed) await fs.unlink(breaker).catch(() => {});
5621
- else await fs.rename(breaker, lockPath).catch(async () => {
5622
- await fs.unlink(breaker).catch(() => {});
4267
+ if (movedToken === observed) await fs$1.unlink(breaker).catch(() => {});
4268
+ else await fs$1.rename(breaker, lockPath).catch(async () => {
4269
+ await fs$1.unlink(breaker).catch(() => {});
5623
4270
  });
5624
4271
  }
5625
4272
  continue;
@@ -5629,11 +4276,11 @@ async function withLeaseLock(file, fn, hooks) {
5629
4276
  await new Promise((r) => setTimeout(r, 15));
5630
4277
  }
5631
4278
  } finally {
5632
- await fs.unlink(staging).catch(() => {});
4279
+ await fs$1.unlink(staging).catch(() => {});
5633
4280
  }
5634
4281
  const verifyOwner = async () => {
5635
4282
  try {
5636
- return (await fs.readFile(lockPath, "utf8")).trim() === ownerToken;
4283
+ return (await fs$1.readFile(lockPath, "utf8")).trim() === ownerToken;
5637
4284
  } catch {
5638
4285
  return false;
5639
4286
  }
@@ -5641,7 +4288,7 @@ async function withLeaseLock(file, fn, hooks) {
5641
4288
  try {
5642
4289
  return await fn();
5643
4290
  } finally {
5644
- if (await verifyOwner()) await fs.unlink(lockPath).catch(() => {});
4291
+ if (await verifyOwner()) await fs$1.unlink(lockPath).catch(() => {});
5645
4292
  }
5646
4293
  }
5647
4294
  /**
@@ -5798,15 +4445,15 @@ function sleep$1(ms) {
5798
4445
  return new Promise((resolve) => setTimeout(resolve, ms));
5799
4446
  }
5800
4447
  async function writeJsonSecure(target, value) {
5801
- await fs.mkdir(path.dirname(target), { recursive: true });
4448
+ await fs$1.mkdir(path.dirname(target), { recursive: true });
5802
4449
  const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
5803
4450
  try {
5804
- await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
5805
- await fs.chmod(tmp, 384).catch(() => {});
5806
- await fs.rename(tmp, target);
5807
- await fs.chmod(target, 384).catch(() => {});
4451
+ await fs$1.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
4452
+ await fs$1.chmod(tmp, 384).catch(() => {});
4453
+ await fs$1.rename(tmp, target);
4454
+ await fs$1.chmod(target, 384).catch(() => {});
5808
4455
  } catch (err) {
5809
- await fs.unlink(tmp).catch(() => {});
4456
+ await fs$1.unlink(tmp).catch(() => {});
5810
4457
  throw err;
5811
4458
  }
5812
4459
  }
@@ -5815,11 +4462,11 @@ const LOCK_RETRY_MS = 25;
5815
4462
  const LOCK_MAX_WAIT_MS = 1e4;
5816
4463
  async function withFileLock(target, fn) {
5817
4464
  const lockPath = `${target}.lock`;
5818
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
4465
+ await fs$1.mkdir(path.dirname(lockPath), { recursive: true });
5819
4466
  const ownerToken = `${process.pid}-${randomBytes(8).toString("hex")}`;
5820
4467
  const start = Date.now();
5821
4468
  for (;;) try {
5822
- const fh = await fs.open(lockPath, "wx");
4469
+ const fh = await fs$1.open(lockPath, "wx");
5823
4470
  await fh.writeFile(ownerToken);
5824
4471
  await fh.close();
5825
4472
  break;
@@ -5828,14 +4475,14 @@ async function withFileLock(target, fn) {
5828
4475
  if (!(code === "EEXIST" || code === "EPERM" && process.platform === "win32")) throw err;
5829
4476
  let observedToken;
5830
4477
  try {
5831
- observedToken = (await fs.readFile(lockPath, "utf8")).trim();
5832
- const st = await fs.stat(lockPath);
4478
+ observedToken = (await fs$1.readFile(lockPath, "utf8")).trim();
4479
+ const st = await fs$1.stat(lockPath);
5833
4480
  if (Date.now() - st.mtimeMs > LOCK_TTL_MS) {
5834
4481
  const stealPath = `${lockPath}.steal.${ownerToken}`;
5835
4482
  try {
5836
- await fs.rename(lockPath, stealPath);
5837
- if ((await fs.readFile(stealPath, "utf8").catch(() => "")).trim() === observedToken) await fs.unlink(stealPath).catch(() => {});
5838
- else await fs.rename(stealPath, lockPath).catch(() => fs.unlink(stealPath).catch(() => {}));
4483
+ await fs$1.rename(lockPath, stealPath);
4484
+ if ((await fs$1.readFile(stealPath, "utf8").catch(() => "")).trim() === observedToken) await fs$1.unlink(stealPath).catch(() => {});
4485
+ else await fs$1.rename(stealPath, lockPath).catch(() => fs$1.unlink(stealPath).catch(() => {}));
5839
4486
  } catch {}
5840
4487
  continue;
5841
4488
  }
@@ -5845,7 +4492,7 @@ async function withFileLock(target, fn) {
5845
4492
  }
5846
4493
  const verifyOwner = async () => {
5847
4494
  try {
5848
- return (await fs.readFile(lockPath, "utf8")).trim() === ownerToken;
4495
+ return (await fs$1.readFile(lockPath, "utf8")).trim() === ownerToken;
5849
4496
  } catch {
5850
4497
  return false;
5851
4498
  }
@@ -5853,7 +4500,7 @@ async function withFileLock(target, fn) {
5853
4500
  try {
5854
4501
  return await fn(verifyOwner);
5855
4502
  } finally {
5856
- if (await verifyOwner()) await fs.unlink(lockPath).catch(() => {});
4503
+ if (await verifyOwner()) await fs$1.unlink(lockPath).catch(() => {});
5857
4504
  }
5858
4505
  }
5859
4506
  const writeChains = /* @__PURE__ */ new Map();
@@ -5870,7 +4517,7 @@ const OCC_MAX_ATTEMPTS = 16;
5870
4517
  const OCC_BACKOFF_MS = 10;
5871
4518
  async function readRaw(file) {
5872
4519
  try {
5873
- return await fs.readFile(file, "utf8");
4520
+ return await fs$1.readFile(file, "utf8");
5874
4521
  } catch (err) {
5875
4522
  if (err.code === "ENOENT") return void 0;
5876
4523
  throw err;
@@ -6013,7 +4660,7 @@ function parseDecisions(raw) {
6013
4660
  async function readDecisionsFile() {
6014
4661
  let raw;
6015
4662
  try {
6016
- raw = await fs.readFile(decisionsPath(), "utf8");
4663
+ raw = await fs$1.readFile(decisionsPath(), "utf8");
6017
4664
  } catch (err) {
6018
4665
  if (err.code !== "ENOENT") consola.debug("first-mate decisions read skipped:", err);
6019
4666
  raw = void 0;
@@ -6617,7 +5264,7 @@ function parseLedgerRaw(raw) {
6617
5264
  async function readRepoLedgerWithRev(repo) {
6618
5265
  let raw;
6619
5266
  try {
6620
- raw = await fs.readFile(repoLedgerPath(repo), "utf8");
5267
+ raw = await fs$1.readFile(repoLedgerPath(repo), "utf8");
6621
5268
  } catch (err) {
6622
5269
  if (err.code !== "ENOENT") consola.debug("first-mate ledger read skipped:", err);
6623
5270
  return {
@@ -6751,7 +5398,7 @@ function repoKey(repo) {
6751
5398
  async function readMissions() {
6752
5399
  let raw;
6753
5400
  try {
6754
- raw = await fs.readFile(registryPath(), "utf8");
5401
+ raw = await fs$1.readFile(registryPath(), "utf8");
6755
5402
  } catch (err) {
6756
5403
  if (err.code !== "ENOENT") consola.debug("first-mate registry read skipped:", err);
6757
5404
  raw = void 0;
@@ -7109,7 +5756,7 @@ var Outbox = class {
7109
5756
  async read() {
7110
5757
  let raw;
7111
5758
  try {
7112
- raw = await fs.readFile(this.file, "utf8");
5759
+ raw = await fs$1.readFile(this.file, "utf8");
7113
5760
  } catch (err) {
7114
5761
  if (err.code === "ENOENT") return [];
7115
5762
  throw err;
@@ -7124,17 +5771,17 @@ var Outbox = class {
7124
5771
  }
7125
5772
  }
7126
5773
  async write(entries) {
7127
- await fs.mkdir(path.dirname(this.file), { recursive: true });
5774
+ await fs$1.mkdir(path.dirname(this.file), { recursive: true });
7128
5775
  const tmp = `${this.file}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
7129
5776
  const value = {
7130
5777
  version: OUTBOX_VERSION,
7131
5778
  entries
7132
5779
  };
7133
5780
  try {
7134
- await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
7135
- await fs.rename(tmp, this.file);
5781
+ await fs$1.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
5782
+ await fs$1.rename(tmp, this.file);
7136
5783
  } catch (err) {
7137
- await fs.unlink(tmp).catch(() => {});
5784
+ await fs$1.unlink(tmp).catch(() => {});
7138
5785
  throw err;
7139
5786
  }
7140
5787
  }
@@ -7462,9 +6109,9 @@ function sanitizeSegment(value) {
7462
6109
  }
7463
6110
  async function writeDecisionPacketHtml(packetId, html) {
7464
6111
  const dir = path.join(PATHS.FIRST_MATE_DIR, "packets");
7465
- await fs.mkdir(dir, { recursive: true });
6112
+ await fs$1.mkdir(dir, { recursive: true });
7466
6113
  const target = path.join(dir, `${sanitizeSegment(packetId)}.html`);
7467
- await fs.writeFile(target, html, { mode: 384 });
6114
+ await fs$1.writeFile(target, html, { mode: 384 });
7468
6115
  return target;
7469
6116
  }
7470
6117
  function agentRepo(repo) {
@@ -11204,7 +9851,7 @@ const WIN_RENAME_TRANSIENT = /* @__PURE__ */ new Set([
11204
9851
  */
11205
9852
  async function claimByRename(from, to) {
11206
9853
  for (let attempt = 0;; attempt++) try {
11207
- await fs.rename(from, to);
9854
+ await fs$1.rename(from, to);
11208
9855
  return true;
11209
9856
  } catch (err) {
11210
9857
  const code = err.code;
@@ -11241,8 +9888,8 @@ var AnswerInbox = class {
11241
9888
  }))];
11242
9889
  if (lines.length === 0) return 0;
11243
9890
  const run = this.chain.then(async () => {
11244
- await fs.mkdir(path.dirname(this.file), { recursive: true });
11245
- await fs.appendFile(this.file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { mode: 384 });
9891
+ await fs$1.mkdir(path.dirname(this.file), { recursive: true });
9892
+ await fs$1.appendFile(this.file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { mode: 384 });
11246
9893
  });
11247
9894
  this.chain = run.then(() => void 0, () => void 0);
11248
9895
  await run;
@@ -11283,7 +9930,7 @@ var AnswerInbox = class {
11283
9930
  const claimed = [];
11284
9931
  let siblings;
11285
9932
  try {
11286
- siblings = await fs.readdir(dir);
9933
+ siblings = await fs$1.readdir(dir);
11287
9934
  } catch {
11288
9935
  siblings = [];
11289
9936
  }
@@ -11294,7 +9941,7 @@ var AnswerInbox = class {
11294
9941
  const claim = `${orphan}.claim.${process.pid}.${randomBytes(4).toString("hex")}`;
11295
9942
  if (!await claimByRename(orphan, claim)) continue;
11296
9943
  try {
11297
- this.mergeLines(await fs.readFile(claim, "utf8"), out);
9944
+ this.mergeLines(await fs$1.readFile(claim, "utf8"), out);
11298
9945
  claimed.push(claim);
11299
9946
  } catch (err) {
11300
9947
  if (err.code !== "ENOENT") consola.warn(`first-mate: deferring unreadable answer claim ${claim} for retry:`, err);
@@ -11302,7 +9949,7 @@ var AnswerInbox = class {
11302
9949
  }
11303
9950
  const target = `${this.file}.draining.${process.pid}.${randomBytes(4).toString("hex")}`;
11304
9951
  if (await claimByRename(this.file, target)) try {
11305
- this.mergeLines(await fs.readFile(target, "utf8"), out);
9952
+ this.mergeLines(await fs$1.readFile(target, "utf8"), out);
11306
9953
  claimed.push(target);
11307
9954
  } catch (err) {
11308
9955
  if (err.code !== "ENOENT") consola.warn(`first-mate: deferring unreadable inbox claim ${target} for retry:`, err);
@@ -11310,7 +9957,7 @@ var AnswerInbox = class {
11310
9957
  for (const p of claimed) this.inflight.add(p);
11311
9958
  const ack = async () => {
11312
9959
  for (const p of claimed) {
11313
- await fs.unlink(p).catch(() => {});
9960
+ await fs$1.unlink(p).catch(() => {});
11314
9961
  this.inflight.delete(p);
11315
9962
  }
11316
9963
  };
@@ -11441,8 +10088,8 @@ var Tier1Shadow = class {
11441
10088
  this.judge = opts.judge ?? defaultTier1Judge;
11442
10089
  }
11443
10090
  async append(record) {
11444
- await fs.mkdir(path.dirname(this.file), { recursive: true });
11445
- await fs.appendFile(this.file, `${JSON.stringify(record)}\n`, { mode: 384 });
10091
+ await fs$1.mkdir(path.dirname(this.file), { recursive: true });
10092
+ await fs$1.appendFile(this.file, `${JSON.stringify(record)}\n`, { mode: 384 });
11446
10093
  }
11447
10094
  /**
11448
10095
  * Run the Tier1 judge for one request and log its would-be verdict. Returns
@@ -11719,7 +10366,7 @@ function parseStrategies(raw) {
11719
10366
  async function readStrategiesFile() {
11720
10367
  let raw;
11721
10368
  try {
11722
- raw = await fs.readFile(strategiesPath(), "utf8");
10369
+ raw = await fs$1.readFile(strategiesPath(), "utf8");
11723
10370
  } catch (err) {
11724
10371
  if (err.code !== "ENOENT") consola.debug("first-mate strategies read skipped:", err);
11725
10372
  raw = void 0;
@@ -14245,6 +12892,9 @@ function isSensitivePath(absPath, workspaceAbs) {
14245
12892
  */
14246
12893
  function rejectWindowsHostilePath(raw) {
14247
12894
  if (/^[\\/]{2}/.test(raw)) return "rejected: UNC or device path";
12895
+ if (IS_WINDOWS) {
12896
+ if ((/^[A-Za-z]:/.test(raw) ? raw.slice(2) : raw).includes(":")) return "rejected: alternate data stream";
12897
+ }
14248
12898
  if (/^[A-Za-z]:(?![\\/])/.test(raw)) return "rejected: drive-relative path";
14249
12899
  return null;
14250
12900
  }
@@ -16602,7 +15252,7 @@ function metaPath(workspace) {
16602
15252
  /** Read the sidecar metadata for a workspace (null if none yet). */
16603
15253
  async function readColbertMeta(workspace) {
16604
15254
  try {
16605
- const raw = await fs.readFile(metaPath(workspace), "utf8");
15255
+ const raw = await fs$1.readFile(metaPath(workspace), "utf8");
16606
15256
  const parsed = JSON.parse(raw);
16607
15257
  if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed;
16608
15258
  return null;
@@ -16627,14 +15277,14 @@ async function writeColbertMeta(meta) {
16627
15277
  return next;
16628
15278
  }
16629
15279
  async function writeColbertMetaUnchained(meta) {
16630
- await fs.mkdir(PATHS.COLBERT_META_DIR, { recursive: true });
15280
+ await fs$1.mkdir(PATHS.COLBERT_META_DIR, { recursive: true });
16631
15281
  const dest = metaPath(meta.workspace);
16632
15282
  const tmp = `${dest}.${process$1.pid}.${Math.random().toString(16).slice(2, 10)}.tmp`;
16633
15283
  try {
16634
- await fs.writeFile(tmp, JSON.stringify(meta, null, 2));
16635
- await fs.rename(tmp, dest);
15284
+ await fs$1.writeFile(tmp, JSON.stringify(meta, null, 2));
15285
+ await fs$1.rename(tmp, dest);
16636
15286
  } catch (err) {
16637
- await fs.rm(tmp, { force: true }).catch(() => {});
15287
+ await fs$1.rm(tmp, { force: true }).catch(() => {});
16638
15288
  throw err;
16639
15289
  }
16640
15290
  }
@@ -16650,7 +15300,7 @@ async function colbertProjectDir(workspace) {
16650
15300
  const indicesDir = PATHS.COLBERT_INDICES_DIR;
16651
15301
  let names;
16652
15302
  try {
16653
- names = await fs.readdir(indicesDir);
15303
+ names = await fs$1.readdir(indicesDir);
16654
15304
  } catch {
16655
15305
  return null;
16656
15306
  }
@@ -16660,7 +15310,7 @@ async function colbertProjectDir(workspace) {
16660
15310
  const dir = path.join(indicesDir, name);
16661
15311
  let proj;
16662
15312
  try {
16663
- proj = JSON.parse(await fs.readFile(path.join(dir, "project.json"), "utf8"));
15313
+ proj = JSON.parse(await fs$1.readFile(path.join(dir, "project.json"), "utf8"));
16664
15314
  } catch {
16665
15315
  continue;
16666
15316
  }
@@ -16811,7 +15461,7 @@ function indexDirSignature(workspace) {
16811
15461
  */
16812
15462
  async function realpathForCompare(p) {
16813
15463
  try {
16814
- return canonicalForCompare(await fs.realpath(p));
15464
+ return canonicalForCompare(await fs$1.realpath(p));
16815
15465
  } catch {
16816
15466
  return canonicalForCompare(p);
16817
15467
  }
@@ -17126,12 +15776,12 @@ async function runSemanticSearch(opts) {
17126
15776
  async function quarantineProjectDir(projectDir) {
17127
15777
  const quarantine = `${projectDir}.corrupt-${process$1.pid}-${randomBytes(4).toString("hex")}`;
17128
15778
  try {
17129
- await fs.rename(projectDir, quarantine);
15779
+ await fs$1.rename(projectDir, quarantine);
17130
15780
  } catch (err) {
17131
15781
  consola.debug("colbert: corrupt index quarantine rename failed:", err);
17132
15782
  return false;
17133
15783
  }
17134
- const removal = fs.rm(quarantine, {
15784
+ const removal = fs$1.rm(quarantine, {
17135
15785
  recursive: true,
17136
15786
  force: true
17137
15787
  }).catch((err) => {
@@ -18174,6 +16824,18 @@ function appBrowserMcpDir() {
18174
16824
  return dir;
18175
16825
  }
18176
16826
  /**
16827
+ * Memo for the interpreter probe below. The probe shells out to
16828
+ * `where`/`which`, which measured ~196ms per spawn on Windows, and it ran on
16829
+ * every browser tool call (twice, before the preflight was deduplicated).
16830
+ *
16831
+ * Only a SUCCESSFUL resolution is cached. `process.execPath` is the
16832
+ * fall-through when the probe fails, and caching that would pin a degraded
16833
+ * interpreter for the life of the process — including across a `node` install
16834
+ * that happens while the proxy is running. The path of an existing `node` on
16835
+ * PATH, by contrast, does not change mid-process in any way that matters here.
16836
+ */
16837
+ let cachedBridgeInterpreter;
16838
+ /**
18177
16839
  * Pick a runtime interpreter for the bridge. The bridge uses Node's
18178
16840
  * binary-stdin framing for native messaging which Bun handles
18179
16841
  * differently (Bun closes the bridge prematurely as soon as anything
@@ -18182,6 +16844,7 @@ function appBrowserMcpDir() {
18182
16844
  * binary) only if node isn't on PATH.
18183
16845
  */
18184
16846
  function resolveBridgeInterpreter() {
16847
+ if (cachedBridgeInterpreter !== void 0) return cachedBridgeInterpreter;
18185
16848
  const probeCmd = platform() === "win32" ? "where" : "which";
18186
16849
  try {
18187
16850
  const out = execFileSync(probeCmd, ["node"], {
@@ -18193,7 +16856,10 @@ function resolveBridgeInterpreter() {
18193
16856
  timeout: 2e3,
18194
16857
  windowsHide: true
18195
16858
  }).toString().trim().split(/\r?\n/)[0];
18196
- if (out) return out;
16859
+ if (out) {
16860
+ cachedBridgeInterpreter = out;
16861
+ return out;
16862
+ }
18197
16863
  } catch {}
18198
16864
  return process$1.execPath;
18199
16865
  }
@@ -18481,8 +17147,7 @@ async function probeHealth(port, token, timeoutMs = 500) {
18481
17147
  }
18482
17148
  function bridgeBundleExists() {
18483
17149
  try {
18484
- readFileSync(bridgeBundlePath());
18485
- return true;
17150
+ return statSync(bridgeBundlePath()).isFile();
18486
17151
  } catch {
18487
17152
  return false;
18488
17153
  }
@@ -18607,9 +17272,41 @@ let _inFlightReady;
18607
17272
  * Always 0 in production (only incremented when imported by tests).
18608
17273
  */
18609
17274
  let __implInvocationsForTests = 0;
17275
+ /**
17276
+ * Short-lived cache of a READY result.
17277
+ *
17278
+ * The single-flight above only collapses CONCURRENT callers — it clears in
17279
+ * `.finally()`, so sequential calls each re-run the full impl. Every browser
17280
+ * tool call runs the check twice (once in `browserPreflight` before slot
17281
+ * acquisition, once in `dispatchBrowserTool`, which is deliberate so internal
17282
+ * compound dispatches stay fail-closed), and each run re-probes health and
17283
+ * re-installs the native-messaging host — spawning `where`/`which` (~196ms on
17284
+ * Windows) and `reg.exe` (~26ms) per detected browser, synchronously.
17285
+ *
17286
+ * Only a READY result is cached, and only briefly:
17287
+ *
17288
+ * - Caching `install_required` would freeze a recoverable state — the user
17289
+ * loading the extension, or installing a browser, would not take effect
17290
+ * until restart. Those paths must stay self-healing.
17291
+ * - The TTL is deliberately short. These checks also REPAIR drift (a deleted
17292
+ * manifest, a cleared registry key), so the window in which that repair is
17293
+ * skipped has to stay small. A few seconds collapses the double-check and
17294
+ * a burst of calls without turning a self-healing probe into a one-shot.
17295
+ */
17296
+ const READY_CACHE_TTL_MS = 5e3;
17297
+ let _readyCache;
18610
17298
  async function ensureBridgeReady() {
17299
+ const cached = _readyCache;
17300
+ if (cached && Date.now() - cached.at < READY_CACHE_TTL_MS) return cached.value;
18611
17301
  if (_inFlightReady) return _inFlightReady;
18612
- _inFlightReady = _ensureBridgeReadyImpl().finally(() => {
17302
+ _inFlightReady = _ensureBridgeReadyImpl().then((result) => {
17303
+ if (!result.install_required) _readyCache = {
17304
+ at: Date.now(),
17305
+ value: result
17306
+ };
17307
+ else _readyCache = void 0;
17308
+ return result;
17309
+ }).finally(() => {
18613
17310
  _inFlightReady = void 0;
18614
17311
  });
18615
17312
  return _inFlightReady;
@@ -18750,6 +17447,103 @@ function preflightUrlPolicy(toolName, args) {
18750
17447
  return checkUrlPolicy(args.url);
18751
17448
  }
18752
17449
  //#endregion
17450
+ //#region src/lib/attachments.ts
17451
+ /**
17452
+ * Build a text + image tool result.
17453
+ *
17454
+ * TEXT FIRST, IMAGE SECOND — and this ordering is load-bearing for a reason
17455
+ * worth recording honestly: it is test-debt accommodation, not a design
17456
+ * argument. 75 assertions across the suite read `result.content[0].text`, and
17457
+ * putting the image first would break every one of them for no gain. Nothing
17458
+ * in the MCP spec or in any known consumer is sensitive to block order, so the
17459
+ * cheap accommodation wins. If that ever stops being true, codemod the
17460
+ * assertions rather than contorting the wire shape.
17461
+ */
17462
+ function mcpTextAndImage(text, image) {
17463
+ return { content: [{
17464
+ type: "text",
17465
+ text
17466
+ }, {
17467
+ type: "image",
17468
+ data: image.data,
17469
+ mimeType: image.mimeType
17470
+ }] };
17471
+ }
17472
+ /**
17473
+ * Media types that appear in some Copilot model's
17474
+ * `capabilities.limits.vision.supported_media_types`. This is the UNION across
17475
+ * models, not a per-model allowlist — an individual model accepts a subset
17476
+ * (gemini takes heic/heif but not gif; the gpt and claude lanes take gif but
17477
+ * not heic). Per-model narrowing is the outbound validator's job; this set only
17478
+ * answers "is this an image shape any Copilot model could take at all".
17479
+ */
17480
+ const SUPPORTED_IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
17481
+ "image/jpeg",
17482
+ "image/png",
17483
+ "image/webp",
17484
+ "image/gif",
17485
+ "image/heic",
17486
+ "image/heif"
17487
+ ]);
17488
+ function startsWith(bytes, offset, sig) {
17489
+ if (bytes.length < offset + sig.length) return false;
17490
+ for (const [i, element] of sig.entries()) if (bytes[offset + i] !== element) return false;
17491
+ return true;
17492
+ }
17493
+ function startsWithAscii(bytes, offset, ascii) {
17494
+ return startsWith(bytes, offset, [...ascii].map((c) => c.codePointAt(0) ?? 0));
17495
+ }
17496
+ const PNG_SIGNATURE = [
17497
+ 137,
17498
+ 80,
17499
+ 78,
17500
+ 71,
17501
+ 13,
17502
+ 10,
17503
+ 26,
17504
+ 10
17505
+ ];
17506
+ /**
17507
+ * Identify an image by its leading bytes, returning a media type from
17508
+ * `SUPPORTED_IMAGE_MIME_TYPES` or `undefined` when the bytes are not a
17509
+ * supported image.
17510
+ *
17511
+ * `undefined` is a deliberate two-in-one answer: "not an image" and "an image
17512
+ * kind nothing upstream accepts" are the same outcome for every caller here,
17513
+ * because both mean the bytes must not be sent as an image block.
17514
+ *
17515
+ * Truncated and malformed inputs return `undefined` rather than throwing — a
17516
+ * caller reading an arbitrary file off disk must not be able to crash a tool
17517
+ * dispatch with a 3-byte file.
17518
+ */
17519
+ function detectImageMimeType(bytes) {
17520
+ if (startsWith(bytes, 0, [
17521
+ 255,
17522
+ 216,
17523
+ 255
17524
+ ])) return bytes[3] === 247 ? void 0 : "image/jpeg";
17525
+ 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;
17526
+ if (startsWithAscii(bytes, 0, "GIF8")) return "image/gif";
17527
+ if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WEBP")) return "image/webp";
17528
+ if (startsWithAscii(bytes, 4, "ftyp")) {
17529
+ const brand = new TextDecoder().decode(bytes.subarray(8, 12));
17530
+ if (brand === "heic" || brand === "heix" || brand === "hevc" || brand === "hevx") return "image/heic";
17531
+ if (brand === "mif1" || brand === "msf1" || brand === "heim" || brand === "heis") return "image/heif";
17532
+ }
17533
+ }
17534
+ /** Decode base64 for size-validation purposes; see the notes inside. */
17535
+ function decodeBase64Strict(input) {
17536
+ const compact = input.replaceAll(/[\s]/g, "");
17537
+ if (compact.length === 0) return null;
17538
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
17539
+ const remainder = compact.length % 4;
17540
+ if (remainder === 1) return null;
17541
+ const padded = remainder === 0 ? compact : compact + "=".repeat(4 - remainder);
17542
+ const buf = Buffer.from(padded, "base64");
17543
+ if (buf.toString("base64") !== padded) return null;
17544
+ return new Uint8Array(buf);
17545
+ }
17546
+ //#endregion
18753
17547
  //#region src/lib/browser-mcp/dispatch.ts
18754
17548
  /**
18755
17549
  * Tools whose dispatch counts as a mutating user action for pacing
@@ -18969,6 +17763,52 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
18969
17763
  });
18970
17764
  });
18971
17765
  }
17766
+ /**
17767
+ * Turn a bridge envelope that carries captured pixels into a real MCP image
17768
+ * block, or return `null` when it doesn't (the overwhelmingly common case —
17769
+ * every non-screenshot tool).
17770
+ *
17771
+ * The extension returns `{contentType, dataBase64}` for `browser_screenshot`.
17772
+ * That used to be `JSON.stringify`'d into a text block, which meant the calling
17773
+ * model received base64 characters it could not interpret, at roughly 130x the
17774
+ * token cost of the same image sent natively (base64 tokenizes at ~1.46
17775
+ * chars/token under o200k, so a 200 KB PNG is ~187k tokens — enough to be
17776
+ * rejected outright by a 200k-context model).
17777
+ *
17778
+ * Two deliberate choices:
17779
+ *
17780
+ * - The media type is SNIFFED from the bytes, not taken from the envelope's
17781
+ * `contentType`. A declared type is an assertion; the payload is what
17782
+ * upstream will actually try to decode. The declared value is preserved in
17783
+ * the text block so a mismatch stays debuggable.
17784
+ * - The text block keeps every envelope field EXCEPT `dataBase64`, and gains
17785
+ * a decoded `bytes` count. Callers that parsed the JSON metadata keep
17786
+ * working; the payload simply stops being duplicated in a form nothing
17787
+ * could read.
17788
+ *
17789
+ * Bytes that don't decode strictly, or that aren't a supported image, fall
17790
+ * through to `null` and take the plain-text path — a malformed capture must
17791
+ * not become an image block that upstream then rejects.
17792
+ */
17793
+ function imageEnvelope(data) {
17794
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return null;
17795
+ const record = data;
17796
+ const encoded = record.dataBase64;
17797
+ if (typeof encoded !== "string" || encoded.length === 0) return null;
17798
+ const bytes = decodeBase64Strict(encoded);
17799
+ if (!bytes) return null;
17800
+ const mimeType = detectImageMimeType(bytes);
17801
+ if (!mimeType) return null;
17802
+ const meta = {
17803
+ bytes: bytes.length,
17804
+ mimeType
17805
+ };
17806
+ for (const [key, value] of Object.entries(record)) if (key !== "dataBase64") meta[key] = value;
17807
+ return mcpTextAndImage(JSON.stringify(meta, null, 2), {
17808
+ data: encoded,
17809
+ mimeType
17810
+ });
17811
+ }
18972
17812
  function blockedUrlEnvelope(reason) {
18973
17813
  return {
18974
17814
  content: [{
@@ -19034,7 +17874,6 @@ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
19034
17874
  token: ready.token
19035
17875
  }, tool, args, callerTimeout, signal);
19036
17876
  if (resp.ok) {
19037
- const text = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2);
19038
17877
  logAudit$1({
19039
17878
  tool,
19040
17879
  argsBytes: argsByteSize(args),
@@ -19042,9 +17881,9 @@ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
19042
17881
  profile: typeof args.profile === "string" ? args.profile : "isolated",
19043
17882
  result: "ok"
19044
17883
  });
19045
- return { content: [{
17884
+ return imageEnvelope(resp.data) ?? { content: [{
19046
17885
  type: "text",
19047
- text
17886
+ text: typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2)
19048
17887
  }] };
19049
17888
  }
19050
17889
  logAudit$1({
@@ -19094,7 +17933,7 @@ function logAudit$1(record) {
19094
17933
  try {
19095
17934
  const fs = await import("node:fs/promises");
19096
17935
  const path = await import("node:path");
19097
- const { PATHS } = await import("./paths-D3XgmTcQ.js");
17936
+ const { PATHS } = await import("./paths-CwKwc2pi.js");
19098
17937
  const dir = path.join(PATHS.APP_DIR, "browser-mcp");
19099
17938
  await fs.mkdir(dir, { recursive: true });
19100
17939
  const line = JSON.stringify({
@@ -19674,6 +18513,169 @@ function currentInFlight() {
19674
18513
  return inFlight$2;
19675
18514
  }
19676
18515
  //#endregion
18516
+ //#region src/lib/vision-preflight.ts
18517
+ /**
18518
+ * Outbound vision preflight.
18519
+ *
18520
+ * Converts what would otherwise be an opaque upstream `400` into a local,
18521
+ * actionable rejection — the same trade `predictedWindowOverflow` in
18522
+ * `src/routes/mcp/handler.ts` already makes for prompt tokens, and the reason
18523
+ * this file exists at all: `capabilities.limits.vision` advertises three fields
18524
+ * (`max_prompt_images`, `max_prompt_image_size`, `supported_media_types`) and
18525
+ * until now the proxy read exactly one of them, in the `models` pretty-printer.
18526
+ *
18527
+ * WHY ONE CHOKEPOINT, NOT PER-ADAPTER CHECKS
18528
+ *
18529
+ * Images reach the wire through more paths than is obvious: a top-level user
18530
+ * block, a block nested inside a `tool_result`, the synthetic follow-up user
18531
+ * message the shim emits because a tool-output item cannot carry images, images
18532
+ * already present in replayed conversation history, and peer-critic
18533
+ * attachments. Validating in each adapter means each adapter has to
18534
+ * rediscover every one of those shapes, and the ones it forgets fail silently.
18535
+ * So validation runs ONCE, on the fully assembled payload, immediately before
18536
+ * transport serialization — `createResponses` and `createChatCompletions` — and
18537
+ * is therefore total by construction.
18538
+ *
18539
+ * POLICY ON MISSING METADATA
18540
+ *
18541
+ * Deliberately NOT fail-open. "We don't know the limit" is not "there is no
18542
+ * limit"; treating it as the latter reproduces the exact opaque-400 this
18543
+ * module exists to remove. The three cases are distinguished:
18544
+ *
18545
+ * - model absent from the catalog entirely → ALLOW. We have no basis for a
18546
+ * judgement, upstream remains authoritative, and blocking would break
18547
+ * custom catalogs and offline tests. This mirrors `modelSupportsEndpoint`.
18548
+ * - model present, `supports.vision` not true → REJECT, naming the model.
18549
+ * - model present, vision supported, limits absent → CONSERVATIVE FLOOR
18550
+ * (`FLOOR_MAX_IMAGES` / `FLOOR_MAX_IMAGE_BYTES`), and the message says so,
18551
+ * so the caller can tell a real limit from an assumed one.
18552
+ *
18553
+ * Error strings are returned to callers and end up in front of a model, so they
18554
+ * name the model, the numbers, and the fix — never a stack, a local path, or a
18555
+ * raw catalog object.
18556
+ */
18557
+ /**
18558
+ * Floor applied when a model advertises vision but publishes no limits. Every
18559
+ * vision-capable model in the live catalog reports at least 1 image and a
18560
+ * 3 MiB ceiling, so this is the observed minimum rather than a guess.
18561
+ */
18562
+ const FLOOR_MAX_IMAGES = 1;
18563
+ const FLOOR_MAX_IMAGE_BYTES = 3145728;
18564
+ /**
18565
+ * Split a `data:<mime>;base64,<payload>` URI. Returns `null` for any other URL
18566
+ * shape (including a plain remote `https://` reference, which the caller
18567
+ * handles separately because its bytes are not ours to inspect).
18568
+ */
18569
+ function parseDataUrl(url) {
18570
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(url);
18571
+ if (!match) return null;
18572
+ return {
18573
+ mimeType: match[1],
18574
+ base64: match[2]
18575
+ };
18576
+ }
18577
+ function reject(message) {
18578
+ return {
18579
+ ok: false,
18580
+ message
18581
+ };
18582
+ }
18583
+ /**
18584
+ * Validate every image on an outbound request against the resolved model's
18585
+ * advertised vision capability.
18586
+ *
18587
+ * Remote-URL images are counted toward the cardinality limit but not size- or
18588
+ * type-checked: the bytes live on someone else's server, so any local claim
18589
+ * about them would be fiction. Upstream stays authoritative for those.
18590
+ */
18591
+ function checkOutboundImages(modelId, images) {
18592
+ if (images.length === 0) return { ok: true };
18593
+ const model = state.models?.data.find((m) => m.id === modelId);
18594
+ if (!model) return { ok: true };
18595
+ if ((model.capabilities?.supports)?.vision !== true) return reject(`Model ${modelId} does not support image input. Send text only, or select a vision-capable model.`);
18596
+ const limits = model.capabilities?.limits?.vision;
18597
+ const maxImages = limits?.max_prompt_images ?? FLOOR_MAX_IMAGES;
18598
+ const maxBytes = limits?.max_prompt_image_size ?? FLOOR_MAX_IMAGE_BYTES;
18599
+ const assumed = limits?.max_prompt_images === void 0 ? " (assumed; the model publishes no image limits)" : "";
18600
+ 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.`);
18601
+ const allowedTypes = limits?.supported_media_types;
18602
+ for (const [index, image] of images.entries()) {
18603
+ if (image.url !== void 0 && image.base64 === void 0) continue;
18604
+ const position = `image ${index + 1} of ${images.length}`;
18605
+ 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.`);
18606
+ if (image.base64 === void 0) continue;
18607
+ const bytes = decodeBase64Strict(image.base64);
18608
+ if (!bytes) return reject(`${position} is not valid base64 and cannot be sent.`);
18609
+ 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.`);
18610
+ const actual = detectImageMimeType(bytes);
18611
+ if (!actual) return reject(`${position} is declared ${image.declaredMimeType} but its bytes are not a supported image (jpeg, png, webp, gif, heic, heif).`);
18612
+ if (actual !== image.declaredMimeType) return reject(`${position} is declared ${image.declaredMimeType} but its bytes are ${actual}. Send the correct media type.`);
18613
+ if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(actual)) return reject(`${position} is ${actual}, which model ${modelId} does not accept. Supported: ${allowedTypes.join(", ")}.`);
18614
+ }
18615
+ return { ok: true };
18616
+ }
18617
+ /** Collect the images on an assembled Copilot `/responses` payload. */
18618
+ function imagesInResponsesPayload(input) {
18619
+ const images = [];
18620
+ if (!Array.isArray(input)) return images;
18621
+ for (const item of input) {
18622
+ if (item === null || typeof item !== "object") continue;
18623
+ const content = item.content;
18624
+ if (!Array.isArray(content)) continue;
18625
+ for (const part of content) {
18626
+ if (part === null || typeof part !== "object") continue;
18627
+ const p = part;
18628
+ if (p.type !== "input_image" || typeof p.image_url !== "string") continue;
18629
+ images.push(fromUrl(p.image_url));
18630
+ }
18631
+ }
18632
+ return images;
18633
+ }
18634
+ /** Collect the images on an assembled Copilot `/chat/completions` payload. */
18635
+ function imagesInChatPayload(messages) {
18636
+ const images = [];
18637
+ if (!Array.isArray(messages)) return images;
18638
+ for (const message of messages) {
18639
+ if (message === null || typeof message !== "object") continue;
18640
+ const content = message.content;
18641
+ if (!Array.isArray(content)) continue;
18642
+ for (const part of content) {
18643
+ if (part === null || typeof part !== "object") continue;
18644
+ const p = part;
18645
+ if (p.type !== "image_url") continue;
18646
+ const url = p.image_url?.url;
18647
+ if (typeof url !== "string") continue;
18648
+ images.push(fromUrl(url));
18649
+ }
18650
+ }
18651
+ return images;
18652
+ }
18653
+ function fromUrl(url) {
18654
+ const parsed = parseDataUrl(url);
18655
+ return parsed ? {
18656
+ base64: parsed.base64,
18657
+ declaredMimeType: parsed.mimeType
18658
+ } : { url };
18659
+ }
18660
+ /**
18661
+ * Run the preflight and throw a client-visible 400 when it fails.
18662
+ *
18663
+ * Shaped as an `HTTPError` carrying a synthetic `Response` so it flows through
18664
+ * the existing `forwardError` path and reaches the client as a normal
18665
+ * Anthropic-format `invalid_request_error` — the same envelope an upstream
18666
+ * rejection would produce, except it names the actual problem and no upstream
18667
+ * request was made. Tests assert that second property: a preflight failure must
18668
+ * cost zero upstream calls.
18669
+ */
18670
+ function assertOutboundImagesOk(modelId, images) {
18671
+ const verdict = checkOutboundImages(modelId, images);
18672
+ if (verdict.ok) return;
18673
+ throw new HTTPError(verdict.message, new Response(JSON.stringify({ error: { message: verdict.message } }), {
18674
+ status: 400,
18675
+ headers: { "content-type": "application/json" }
18676
+ }));
18677
+ }
18678
+ //#endregion
19677
18679
  //#region src/lib/diagnose-response.ts
19678
18680
  const PREVIEW_LIMIT = 200;
19679
18681
  async function parseJsonOrDiagnose(response, routePath) {
@@ -19795,6 +18797,7 @@ async function readResponseBodyCapped(response, routePath, capBytes = MAX_RESPON
19795
18797
  const createChatCompletions = async (payload, modelHeaders, callerSignal, retryTransient = false) => {
19796
18798
  if (!state.copilotToken) throw new Error("Copilot token not found");
19797
18799
  const enableVision = payload.messages.some((x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"));
18800
+ if (enableVision) assertOutboundImagesOk(payload.model, imagesInChatPayload(payload.messages));
19798
18801
  const isAgentCall = payload.messages.some((msg) => ["assistant", "tool"].includes(msg.role));
19799
18802
  const url = `${copilotBaseUrl(state)}/chat/completions`;
19800
18803
  const doFetch = () => {
@@ -19828,11 +18831,12 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal, retryT
19828
18831
  }
19829
18832
  const claudeModels = state.models?.data.filter((m) => m.id.startsWith("claude")).map((m) => m.id).join(", ") ?? "(models not loaded)";
19830
18833
  consola.error(`Copilot rejected model "${payload.model}": ${response.status} ${errorBody} (available Claude models: ${claudeModels})`);
19831
- throw new HTTPError("Failed to create chat completions", new Response(errorBody, {
18834
+ const reconstructed = new Response(errorBody, {
19832
18835
  status: response.status,
19833
18836
  statusText: response.statusText,
19834
18837
  headers: response.headers
19835
- }));
18838
+ });
18839
+ throw new HTTPError("Failed to create chat completions", reconstructed);
19836
18840
  }
19837
18841
  if (payload.stream) return events(response);
19838
18842
  const cappedResult = await readResponseBodyCapped(response, "/v1/chat/completions", MAX_RESPONSE_BODY_BYTES);
@@ -19857,6 +18861,7 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal, retryT
19857
18861
  const createResponses = async (payload, modelHeaders, callerSignal, retryTransient = false) => {
19858
18862
  if (!state.copilotToken) throw new Error("Copilot token not found");
19859
18863
  const enableVision = detectVision(payload.input);
18864
+ if (enableVision) assertOutboundImagesOk(payload.model, imagesInResponsesPayload(payload.input));
19860
18865
  const isAgentCall = detectAgentCall(payload.input);
19861
18866
  const url = `${copilotBaseUrl(state)}/responses`;
19862
18867
  const doFetch = () => {
@@ -20878,7 +19883,7 @@ const BROWSER_TOOLS = Object.freeze([
20878
19883
  },
20879
19884
  {
20880
19885
  toolNameHttp: "browser_screenshot",
20881
- 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.",
19886
+ 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.",
20882
19887
  inputSchema: {
20883
19888
  type: "object",
20884
19889
  required: ["tabId"],
@@ -20892,6 +19897,12 @@ const BROWSER_TOOLS = Object.freeze([
20892
19897
  type: "string",
20893
19898
  enum: ["png", "jpeg"],
20894
19899
  description: "Image format for the returned screenshot. Default 'png'; use 'jpeg' when smaller image bytes are preferable."
19900
+ },
19901
+ quality: {
19902
+ type: "number",
19903
+ minimum: 1,
19904
+ maximum: 100,
19905
+ 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."
20895
19906
  }
20896
19907
  }
20897
19908
  },
@@ -21622,21 +20633,12 @@ async function runAtomicIntentStep(tabId, intent, value, signal) {
21622
20633
  error: "no text match; screenshot for visual fallback failed",
21623
20634
  picked
21624
20635
  }, true);
21625
- const shotText = shotEnv.content?.[0]?.text;
21626
- let shot = {};
21627
- try {
21628
- shot = shotText ? JSON.parse(shotText) : {};
21629
- } catch {
21630
- return toolEnvelope({
21631
- ok: false,
21632
- error: "no text match; screenshot envelope unparseable"
21633
- }, true);
21634
- }
21635
- if (!shot.contentType || !shot.dataBase64) return toolEnvelope({
20636
+ const shot = shotEnv.content.find((b) => b.type === "image");
20637
+ if (!shot) return toolEnvelope({
21636
20638
  ok: false,
21637
- error: "no text match; screenshot envelope missing fields"
20639
+ error: "no text match; screenshot returned no image"
21638
20640
  }, true);
21639
- const visual = await pickElementVisual(shot.dataBase64, shot.contentType, intent, surfaces, signal);
20641
+ const visual = await pickElementVisual(shot.data, shot.mimeType, intent, surfaces, signal);
21640
20642
  if (visual.confidence < .5) return toolEnvelope({
21641
20643
  ok: false,
21642
20644
  error: "no element matched intent (text + visual)",
@@ -24232,10 +23234,14 @@ async function runChatAttempt(stream, payload, opts, signal, state) {
24232
23234
  let nextContentIndex = 0;
24233
23235
  let activeTextIndex = null;
24234
23236
  const toolPiIndexByOAI = /* @__PURE__ */ new Map();
23237
+ let sawDone = false;
24235
23238
  for await (const evt of sseStream) {
24236
23239
  const data = evt?.data;
24237
23240
  if (data == null) continue;
24238
- if (data === "[DONE]") break;
23241
+ if (data === "[DONE]") {
23242
+ sawDone = true;
23243
+ break;
23244
+ }
24239
23245
  let chunk;
24240
23246
  try {
24241
23247
  chunk = JSON.parse(data);
@@ -24328,12 +23334,26 @@ async function runChatAttempt(stream, payload, opts, signal, state) {
24328
23334
  if (choice.finish_reason) accum.finishReason = choice.finish_reason;
24329
23335
  }
24330
23336
  if (!state.active) return;
23337
+ if (!sawDone && accum.finishReason == null) {
23338
+ if (activeTextIndex != null) stream.push({
23339
+ type: "text_end",
23340
+ contentIndex: activeTextIndex,
23341
+ content: joinTextChunks(accum, activeTextIndex),
23342
+ partial: buildPartial(resolved, accum)
23343
+ });
23344
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("chat stream ended without a [DONE] sentinel (truncated)"));
23345
+ return;
23346
+ }
24331
23347
  if (activeTextIndex != null) stream.push({
24332
23348
  type: "text_end",
24333
23349
  contentIndex: activeTextIndex,
24334
23350
  content: joinTextChunks(accum, activeTextIndex),
24335
23351
  partial: buildPartial(resolved, accum)
24336
23352
  });
23353
+ if (accum.finishReason === "content_filter") {
23354
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("upstream content filter blocked the response"));
23355
+ return;
23356
+ }
24337
23357
  for (const block of accum.blocks) {
24338
23358
  if (block.kind !== "tool") continue;
24339
23359
  const entry = accum.toolByIndex.get(block.contentIndex);
@@ -24359,10 +23379,7 @@ function buildPayload(context, resolved) {
24359
23379
  role: "system",
24360
23380
  content: context.systemPrompt
24361
23381
  });
24362
- for (const m of context.messages) {
24363
- const oai = translateMessage(m);
24364
- if (oai) messages.push(oai);
24365
- }
23382
+ messages.push(...translateMessages(context.messages));
24366
23383
  const tools = translateTools(context.tools);
24367
23384
  const payload = {
24368
23385
  model: resolved.modelId,
@@ -24376,11 +23393,55 @@ function buildPayload(context, resolved) {
24376
23393
  if (resolved.thinking !== "off") payload.reasoning_effort = resolved.thinking;
24377
23394
  return payload;
24378
23395
  }
24379
- function translateMessage(m) {
24380
- if (m.role === "user") return translateUser(m);
24381
- if (m.role === "assistant") return translateAssistant(m);
24382
- if (m.role === "toolResult") return translateToolResult(m);
24383
- return null;
23396
+ /**
23397
+ * Pi messages wire messages, with tool-result images hoisted to the END of
23398
+ * each contiguous run of tool results.
23399
+ *
23400
+ * A tool-output wire item cannot carry an image, so images have to ride in a
23401
+ * separate user message. The obvious implementation — fan each `toolResult` out
23402
+ * to `[tool, user]` independently — is WRONG for parallel tool calls: it yields
23403
+ * `tool A, user A, tool B, user B`, and every provider requires the tool
23404
+ * messages answering one assistant turn to be CONTIGUOUS. The interjected user
23405
+ * message orphans the following tool message and the request is rejected.
23406
+ *
23407
+ * So images accumulate across the run and flush once, after the last tool
23408
+ * message of that run: `tool A, tool B, user(imgA, imgB)`.
23409
+ *
23410
+ * This runs at REQUEST-ASSEMBLY time over `context.messages` and never writes
23411
+ * back to worker state, so the synthetic message is ephemeral — an image is not
23412
+ * appended to the transcript and re-sent on every later turn.
23413
+ */
23414
+ function translateMessages(messages) {
23415
+ const out = [];
23416
+ let pending = [];
23417
+ const flushImages = () => {
23418
+ if (pending.length === 0) return;
23419
+ out.push({
23420
+ role: "user",
23421
+ content: pending.map((img) => ({
23422
+ type: "image_url",
23423
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` }
23424
+ }))
23425
+ });
23426
+ pending = [];
23427
+ };
23428
+ for (const m of messages) {
23429
+ if (m.role === "toolResult") {
23430
+ const images = imagePartsOf(m.content);
23431
+ out.push({
23432
+ role: "tool",
23433
+ tool_call_id: m.toolCallId,
23434
+ content: toolResultText(m, images.length > 0)
23435
+ });
23436
+ pending.push(...images);
23437
+ continue;
23438
+ }
23439
+ flushImages();
23440
+ if (m.role === "user") out.push(translateUser(m));
23441
+ else if (m.role === "assistant") out.push(translateAssistant(m));
23442
+ }
23443
+ flushImages();
23444
+ return out;
24384
23445
  }
24385
23446
  function translateUser(m) {
24386
23447
  if (typeof m.content === "string") return {
@@ -24423,13 +23484,6 @@ function translateAssistant(m) {
24423
23484
  if (toolCalls.length > 0) out.tool_calls = toolCalls;
24424
23485
  return out;
24425
23486
  }
24426
- function translateToolResult(m) {
24427
- return {
24428
- role: "tool",
24429
- tool_call_id: m.toolCallId,
24430
- content: joinTextParts(m.content)
24431
- };
24432
- }
24433
23487
  function translateTools(tools) {
24434
23488
  if (!tools || tools.length === 0) return void 0;
24435
23489
  return tools.map((t) => ({
@@ -24441,6 +23495,37 @@ function translateTools(tools) {
24441
23495
  }
24442
23496
  }));
24443
23497
  }
23498
+ /** The image parts of a Pi tool result, in wire order. */
23499
+ function imagePartsOf(parts) {
23500
+ const out = [];
23501
+ for (const p of parts) {
23502
+ if (p.type !== "image") continue;
23503
+ if (typeof p.data !== "string" || p.data.length === 0) continue;
23504
+ out.push({
23505
+ data: p.data,
23506
+ mimeType: p.mimeType ?? "image/png"
23507
+ });
23508
+ }
23509
+ return out;
23510
+ }
23511
+ /**
23512
+ * The text of a tool result, with two pieces of information restored that were
23513
+ * previously thrown away:
23514
+ *
23515
+ * - `isError`. Pi records it on the message, but the wire tool-output item has
23516
+ * no error flag, so without a marker the model could not tell a failed call
23517
+ * from a successful one. The Anthropic shim has always prefixed
23518
+ * `[tool error]`; this matches it.
23519
+ * - a pointer to the follow-up image message, when the tool returned only
23520
+ * images. An empty tool output is confusing on its own; naming what follows
23521
+ * is not.
23522
+ */
23523
+ function toolResultText(m, hasImages) {
23524
+ let text = joinTextParts(m.content);
23525
+ if (hasImages && text.length === 0) text = "[image result below]";
23526
+ if (m.isError === true) text = text.length > 0 ? `[tool error] ${text}` : "[tool error]";
23527
+ return text;
23528
+ }
24444
23529
  function joinTextParts(parts) {
24445
23530
  let s = "";
24446
23531
  for (const p of parts) if (p.type === "text" && typeof p.text === "string") s += p.text;
@@ -24513,6 +23598,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24513
23598
  });
24514
23599
  activeTextIndex = null;
24515
23600
  };
23601
+ let sawTerminal = false;
24516
23602
  for await (const evt of sseStream) {
24517
23603
  const data = evt?.data;
24518
23604
  if (data == null) continue;
@@ -24658,6 +23744,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24658
23744
  }
24659
23745
  case "response.completed":
24660
23746
  case "response.incomplete":
23747
+ sawTerminal = true;
24661
23748
  accum.usage = mapResponsesUsage(ev.response?.usage);
24662
23749
  if (ev.type === "response.incomplete" && ev.response?.incomplete_details?.reason === "max_output_tokens") accum.finishReason = "length";
24663
23750
  if (opts.onChunk && accum.usage) try {
@@ -24679,6 +23766,11 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24679
23766
  }
24680
23767
  }
24681
23768
  if (!state.active) return;
23769
+ if (!sawTerminal) {
23770
+ closeActiveText();
23771
+ pushTerminalError(stream, resolved, /* @__PURE__ */ new Error("responses stream ended without a terminal event (truncated)"));
23772
+ return;
23773
+ }
24682
23774
  closeActiveText();
24683
23775
  for (const block of accum.blocks) {
24684
23776
  if (block.kind !== "tool") continue;
@@ -24702,11 +23794,7 @@ async function runResponsesAttempt(stream, payload, opts, signal, state) {
24702
23794
  });
24703
23795
  }
24704
23796
  function buildResponsesPayload(context, resolved) {
24705
- const messages = [];
24706
- for (const m of context.messages) {
24707
- const neutral = piMessageToNeutral(m);
24708
- if (neutral) messages.push(neutral);
24709
- }
23797
+ const messages = piMessagesToNeutral(context.messages);
24710
23798
  return assembleResponsesPayload({
24711
23799
  model: resolved.modelId,
24712
23800
  instructions: context.systemPrompt || void 0,
@@ -24716,50 +23804,82 @@ function buildResponsesPayload(context, resolved) {
24716
23804
  stream: true
24717
23805
  });
24718
23806
  }
24719
- function piMessageToNeutral(m) {
24720
- if (m.role === "user") {
24721
- if (typeof m.content === "string") return {
23807
+ /**
23808
+ * Pi messages neutral messages, with tool-result images hoisted to the end of
23809
+ * each contiguous run of tool results.
23810
+ *
23811
+ * Exact twin of `translateMessages` on the chat path — see that doc comment for
23812
+ * why the grouping is load-bearing rather than cosmetic. A per-message fan-out
23813
+ * interleaves a user message between parallel `function_call_output` items,
23814
+ * which at best is a shape no client emits and at worst is rejected outright.
23815
+ */
23816
+ function piMessagesToNeutral(messages) {
23817
+ const out = [];
23818
+ let pending = [];
23819
+ const flushImages = () => {
23820
+ if (pending.length === 0) return;
23821
+ out.push({
24722
23822
  role: "user",
24723
- content: m.content
24724
- };
24725
- const parts = [];
24726
- for (const c of m.content) if (c.type === "text") parts.push({
24727
- type: "text",
24728
- text: c.text
24729
- });
24730
- else if (c.type === "image") parts.push({
24731
- type: "image",
24732
- mimeType: c.mimeType,
24733
- data: c.data
23823
+ content: pending.map((img) => ({
23824
+ type: "image",
23825
+ mimeType: img.mimeType,
23826
+ data: img.data
23827
+ }))
24734
23828
  });
24735
- return {
23829
+ pending = [];
23830
+ };
23831
+ for (const m of messages) {
23832
+ if (m.role === "toolResult") {
23833
+ const images = imagePartsOf(m.content);
23834
+ out.push({
23835
+ role: "toolResult",
23836
+ toolCallId: m.toolCallId,
23837
+ output: toolResultText(m, images.length > 0)
23838
+ });
23839
+ pending.push(...images);
23840
+ continue;
23841
+ }
23842
+ flushImages();
23843
+ if (m.role === "user") if (typeof m.content === "string") out.push({
24736
23844
  role: "user",
24737
- content: parts
24738
- };
24739
- }
24740
- if (m.role === "assistant") {
24741
- const parts = [];
24742
- for (const c of m.content) if (c.type === "text") parts.push({
24743
- type: "text",
24744
- text: c.text
24745
- });
24746
- else if (c.type === "toolCall") parts.push({
24747
- type: "toolCall",
24748
- id: c.id,
24749
- name: c.name,
24750
- arguments: c.arguments
23845
+ content: m.content
24751
23846
  });
24752
- return {
24753
- role: "assistant",
24754
- content: parts
24755
- };
23847
+ else {
23848
+ const parts = [];
23849
+ for (const c of m.content) if (c.type === "text") parts.push({
23850
+ type: "text",
23851
+ text: c.text
23852
+ });
23853
+ else if (c.type === "image") parts.push({
23854
+ type: "image",
23855
+ mimeType: c.mimeType,
23856
+ data: c.data
23857
+ });
23858
+ out.push({
23859
+ role: "user",
23860
+ content: parts
23861
+ });
23862
+ }
23863
+ else if (m.role === "assistant") {
23864
+ const parts = [];
23865
+ for (const c of m.content) if (c.type === "text") parts.push({
23866
+ type: "text",
23867
+ text: c.text
23868
+ });
23869
+ else if (c.type === "toolCall") parts.push({
23870
+ type: "toolCall",
23871
+ id: c.id,
23872
+ name: c.name,
23873
+ arguments: c.arguments
23874
+ });
23875
+ out.push({
23876
+ role: "assistant",
23877
+ content: parts
23878
+ });
23879
+ }
24756
23880
  }
24757
- if (m.role === "toolResult") return {
24758
- role: "toolResult",
24759
- toolCallId: m.toolCallId,
24760
- output: joinTextParts(m.content)
24761
- };
24762
- return null;
23881
+ flushImages();
23882
+ return out;
24763
23883
  }
24764
23884
  function piToolsToNeutral(tools) {
24765
23885
  if (!tools || tools.length === 0) return void 0;
@@ -25094,7 +24214,37 @@ function argsRecord(params) {
25094
24214
  * `tools.ts` uses for `peer_review`.
25095
24215
  */
25096
24216
  function joinEnvelopeText(env) {
25097
- return (env.content ?? []).map((c) => c.text).join("\n");
24217
+ return (env.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
24218
+ }
24219
+ /**
24220
+ * Wrap a text payload PLUS a captured image in Pi's tool-result shape.
24221
+ *
24222
+ * The browse worker's own system prompt tells it to "use screenshot to SEE the
24223
+ * page", but every result was flattened to text — so the pixels never reached
24224
+ * it and it received base64 characters instead. Pi's tool-result content
24225
+ * supports image blocks natively, and the engine's `afterToolCall` cap
24226
+ * (`tool-output-cap.ts`) already preserves them and exempts them from the text
24227
+ * byte cap. Nothing was ever producing one.
24228
+ *
24229
+ * Text first, image second — same ordering rule as the MCP surface
24230
+ * (`mcpTextAndImage`), so a consumer reading the first block still finds text.
24231
+ */
24232
+ function imageResult$1(text, image) {
24233
+ return {
24234
+ content: [{
24235
+ type: "text",
24236
+ text
24237
+ }, {
24238
+ type: "image",
24239
+ data: image.data,
24240
+ mimeType: image.mimeType
24241
+ }],
24242
+ details: {}
24243
+ };
24244
+ }
24245
+ /** The first image block on a dispatch envelope, if the tool captured one. */
24246
+ function envelopeImage(env) {
24247
+ return (env.content ?? []).find((c) => c.type === "image");
25098
24248
  }
25099
24249
  /**
25100
24250
  * How a tool interacts with a session's owned tabs:
@@ -25390,7 +24540,8 @@ function makeBrowserTool(meta, parameters, dispatch, sessionId) {
25390
24540
  if (typeof tabId === "number") recordSessionTab(sessionId, tabId);
25391
24541
  } else if (policy === "closes") for (const tabId of toNumberArray(args.tabIds)) releaseSessionTab(sessionId, tabId);
25392
24542
  }
25393
- return textResult$1(text);
24543
+ const image = envelopeImage(env);
24544
+ return image ? imageResult$1(text, image) : textResult$1(text);
25394
24545
  }
25395
24546
  };
25396
24547
  if (meta.executionMode) tool.executionMode = meta.executionMode;
@@ -25660,6 +24811,62 @@ function truncateModelText(text, capBytes) {
25660
24811
  return head + notice + tail;
25661
24812
  }
25662
24813
  /**
24814
+ * Per-result image budget.
24815
+ *
24816
+ * Images are cheap in TOKENS (a vision image costs ~1.5k regardless of byte
24817
+ * size) but not in BYTES: three 3 MiB captures is ~12 MiB of base64 on the wire
24818
+ * and in memory, and they accumulate across turns. The text cap does not bound
24819
+ * them — and should not, since counting base64 against a text budget would
24820
+ * evict real text to make room for an image the model reads for free.
24821
+ *
24822
+ * `MAX_IMAGES_PER_RESULT` is the ceiling of the most permissive model in the
24823
+ * live catalog (gemini's 10). `MAX_IMAGE_BYTES_PER_RESULT` is the published
24824
+ * 3 MiB per-image ceiling times a small factor, so a legitimate multi-image
24825
+ * result passes and a runaway one does not.
24826
+ */
24827
+ const MAX_IMAGES_PER_RESULT = 10;
24828
+ const MAX_IMAGE_BYTES_PER_RESULT = 12582912;
24829
+ /** Decoded byte size of a base64 payload, without allocating the buffer. */
24830
+ function base64Bytes(data) {
24831
+ if (typeof data !== "string" || data.length === 0) return 0;
24832
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
24833
+ return Math.floor(data.length * 3 / 4) - padding;
24834
+ }
24835
+ /**
24836
+ * Trim an image list to the budget, returning the survivors plus a note naming
24837
+ * what was dropped. Dropping silently is not an option: a model told to compare
24838
+ * five screenshots, shown three, and given no indication, will reason
24839
+ * confidently about a set it never saw.
24840
+ */
24841
+ function capImages(images) {
24842
+ const kept = [];
24843
+ let bytes = 0;
24844
+ let dropped = 0;
24845
+ for (const img of images) {
24846
+ if (kept.length >= MAX_IMAGES_PER_RESULT) {
24847
+ dropped++;
24848
+ continue;
24849
+ }
24850
+ const size = base64Bytes(img.data);
24851
+ if (bytes + size > MAX_IMAGE_BYTES_PER_RESULT) {
24852
+ dropped++;
24853
+ continue;
24854
+ }
24855
+ bytes += size;
24856
+ kept.push(img);
24857
+ }
24858
+ if (dropped === 0) return {
24859
+ kept,
24860
+ note: void 0
24861
+ };
24862
+ return {
24863
+ kept,
24864
+ 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....]`
24865
+ };
24866
+ }
24867
+ /** Literal newline, kept out of the template above for readability. */
24868
+ const NEWLINE = "\n";
24869
+ /**
25663
24870
  * Cap a tool result's TEXT content to `capBytes`, preserving any non-text
25664
24871
  * (image) blocks. Returns the replacement content array, or `undefined` when
25665
24872
  * the result is already under the cap (caller leaves it untouched).
@@ -25680,20 +24887,180 @@ function capToolResultText(content, capBytes) {
25680
24887
  let textBytes = 0;
25681
24888
  const texts = [];
25682
24889
  const images = [];
24890
+ const other = [];
25683
24891
  for (const block of content) {
25684
24892
  if (!block || typeof block !== "object") continue;
25685
24893
  const b = block;
25686
24894
  if (b.type === "text" && typeof b.text === "string") {
25687
24895
  texts.push(b.text);
25688
24896
  textBytes += Buffer.byteLength(b.text, "utf8");
25689
- } else images.push(block);
24897
+ } else if (b.type === "image") images.push(block);
24898
+ else other.push(block);
25690
24899
  }
25691
- if (textBytes <= capBytes) return void 0;
25692
- const capped = truncateModelText(texts.join("\n"), capBytes);
25693
- return [...images, {
25694
- type: "text",
25695
- text: capped
25696
- }];
24900
+ const { kept, note } = capImages(images);
24901
+ if (textBytes <= capBytes && note === void 0) return void 0;
24902
+ const joined = texts.join(NEWLINE);
24903
+ const capped = textBytes > capBytes ? truncateModelText(joined, capBytes) : joined;
24904
+ return [
24905
+ ...kept,
24906
+ ...other,
24907
+ {
24908
+ type: "text",
24909
+ text: capped + (note ?? "")
24910
+ }
24911
+ ];
24912
+ }
24913
+ //#endregion
24914
+ //#region src/lib/peer-attachments.ts
24915
+ /**
24916
+ * Server-side image loading for peer-critic attachments (`imagePaths`).
24917
+ *
24918
+ * WHY PATHS AND NOT BASE64
24919
+ *
24920
+ * The obvious schema would take base64 directly. It would also mean megabytes
24921
+ * of payload crossing the MCP boundary and sitting in the CALLER's context — the
24922
+ * exact cost the browser-screenshot fix exists to remove — and it would trip the
24923
+ * `predictedTooLong` pre-flight, which sizes the brief before a slot is
24924
+ * acquired. Reading the bytes here instead keeps the caller's context clean and
24925
+ * the wire small: the caller sends a path, the proxy sends the pixels.
24926
+ *
24927
+ * The proxy and the caller are the same machine in this product (the MCP server
24928
+ * is loopback-only and the CLI is spawned by it), so a local path is meaningful
24929
+ * on both sides.
24930
+ *
24931
+ * THREAT MODEL
24932
+ *
24933
+ * This is a second file-reading path, so it must not become a way around the
24934
+ * first one's rules. Every path goes through `confineToWorkspaceResult` — the
24935
+ * SAME chokepoint the worker's `read`/`glob`/`grep` tools use — which enforces
24936
+ * workspace confinement, `realpathSync.native()` (so a symlink or junction is
24937
+ * resolved to its true target before the prefix check), and syntactic rejection
24938
+ * of UNC, device, and drive-relative Windows paths. `isSensitivePath` then
24939
+ * applies the credential-shaped denylist (`.env*`, `*.pem`, `id_rsa*`, `.ssh/`,
24940
+ * `.git/` interior, `.netrc`, …).
24941
+ *
24942
+ * On top of that, content identification adds a second barrier: a file is only
24943
+ * ever sent if its leading BYTES are a supported image, so a `.env` renamed to
24944
+ * `shot.png` is refused even if it somehow passed confinement.
24945
+ *
24946
+ * That barrier is header-only, and worth stating honestly: a file beginning
24947
+ * with a valid PNG signature and carrying arbitrary data afterwards would pass.
24948
+ * Constructing one requires write access, and a caller with write access
24949
+ * already has `bash` and could exfiltrate directly — so this stops the
24950
+ * realistic case (a model pointing at a credential file by mistake or via
24951
+ * prompt injection), not a determined adversary. Confinement and the denylist
24952
+ * are the primary controls; this is defence in depth.
24953
+ */
24954
+ /**
24955
+ * Pre-encode size ceiling. Every vision-capable model in the live catalog
24956
+ * publishes `max_prompt_image_size: 3145728`, so anything larger cannot be sent
24957
+ * to any of them. Checking BEFORE `readFile` means an oversized file is never
24958
+ * loaded into memory, let alone base64-expanded by a third.
24959
+ *
24960
+ * The per-model check still runs later at the transport boundary
24961
+ * (`assertOutboundImagesOk`); this is the cheap guard, not the authority.
24962
+ */
24963
+ const MAX_ATTACHMENT_BYTES = 3145728;
24964
+ const MAX_TOTAL_ATTACHMENT_BYTES = 12582912;
24965
+ /**
24966
+ * Open flags. `O_NOFOLLOW` is POSIX-only and simply absent on Windows, where
24967
+ * `fsConstants.O_NOFOLLOW` is `undefined` — OR-ing that in would produce `NaN`
24968
+ * and break every open, so it is added only when the platform defines it.
24969
+ */
24970
+ const READ_FLAGS = typeof constants.O_NOFOLLOW === "number" ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
24971
+ /**
24972
+ * Resolve, validate, and base64-encode each path.
24973
+ *
24974
+ * Fails on the FIRST bad path rather than silently skipping it: a caller that
24975
+ * attached four screenshots and got three has been quietly misled about what the
24976
+ * reviewer actually saw.
24977
+ *
24978
+ * Error strings reach a model, so they say what was wrong and what to do, and
24979
+ * never echo the resolved absolute path (the confinement helper deliberately
24980
+ * keeps its own messages path-free for the same reason).
24981
+ */
24982
+ async function loadPeerImages(paths, workspace) {
24983
+ if (paths.length > 10) return {
24984
+ ok: false,
24985
+ error: `imagePaths: ${paths.length} paths exceeds the 10-image ceiling (the most any Copilot model accepts). Send fewer.`
24986
+ };
24987
+ let workspaceAbs;
24988
+ try {
24989
+ workspaceAbs = realpathSync.native(workspace);
24990
+ } catch {
24991
+ workspaceAbs = workspace;
24992
+ }
24993
+ const images = [];
24994
+ let totalBytes = 0;
24995
+ for (const [index, raw] of paths.entries()) {
24996
+ const position = `imagePaths[${index}]`;
24997
+ if (typeof raw !== "string" || raw.length === 0) return {
24998
+ ok: false,
24999
+ error: `${position}: must be a non-empty string.`
25000
+ };
25001
+ const confined = confineToWorkspaceResult(raw, workspaceAbs);
25002
+ if (!confined.ok) return {
25003
+ ok: false,
25004
+ error: `${position}: ${confined.error}`
25005
+ };
25006
+ if (isSensitivePath(confined.abs, workspaceAbs)) return {
25007
+ ok: false,
25008
+ error: `${position}: rejected: sensitive path`
25009
+ };
25010
+ let handle;
25011
+ let buf;
25012
+ try {
25013
+ handle = await open(confined.abs, READ_FLAGS);
25014
+ const info = await handle.stat();
25015
+ if (!info.isFile()) return {
25016
+ ok: false,
25017
+ error: `${position}: rejected: not a regular file`
25018
+ };
25019
+ if (info.size > 3145728) return {
25020
+ ok: false,
25021
+ 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.`
25022
+ };
25023
+ totalBytes += info.size;
25024
+ if (totalBytes > 12582912) return {
25025
+ ok: false,
25026
+ error: `${position}: the attachments total more than ${MAX_TOTAL_ATTACHMENT_BYTES} bytes. Send fewer or smaller images.`
25027
+ };
25028
+ const capacity = Math.min(info.size, MAX_ATTACHMENT_BYTES);
25029
+ const scratch = Buffer.alloc(Math.min(capacity + 1, 3145729));
25030
+ let filled = 0;
25031
+ for (;;) {
25032
+ const { bytesRead } = await handle.read(scratch, filled, scratch.length - filled, filled);
25033
+ if (bytesRead === 0) break;
25034
+ filled += bytesRead;
25035
+ if (filled >= scratch.length) break;
25036
+ }
25037
+ if (filled > capacity) return {
25038
+ ok: false,
25039
+ error: `${position}: the file grew while it was being read. Retry once it has stopped changing.`
25040
+ };
25041
+ buf = scratch.subarray(0, filled);
25042
+ } catch {
25043
+ return {
25044
+ ok: false,
25045
+ error: `${position}: file not found or unreadable`
25046
+ };
25047
+ } finally {
25048
+ await handle?.close().catch(() => {});
25049
+ }
25050
+ const mimeType = detectImageMimeType(buf);
25051
+ if (!mimeType) return {
25052
+ ok: false,
25053
+ error: `${position}: not a supported image. Content is identified by its bytes, not its extension. Supported: ${[...SUPPORTED_IMAGE_MIME_TYPES].join(", ")}.`
25054
+ };
25055
+ images.push({
25056
+ data: buf.toString("base64"),
25057
+ mimeType
25058
+ });
25059
+ }
25060
+ return {
25061
+ ok: true,
25062
+ images
25063
+ };
25697
25064
  }
25698
25065
  //#endregion
25699
25066
  //#region src/lib/tokenizer.ts
@@ -25924,9 +25291,15 @@ const getTokenCount = async (payload, model) => {
25924
25291
  * - anthropic-version (VS Code's Anthropic SDK sends this)
25925
25292
  * - X-Interaction-Id (VS Code sends a session-scoped UUID)
25926
25293
  *
25927
- * We intentionally omit copilot-vision-request VS Code only sends it when
25294
+ * We intentionally omit copilot-vision-request. VS Code only sends it when
25928
25295
  * images are present, and the native /v1/messages endpoint handles vision
25929
- * without requiring the header.
25296
+ * without it — VERIFIED live (2026-08-03) rather than assumed: the same
25297
+ * base64 image sent to claude-opus-5 with the header omitted and with it set
25298
+ * both returned 200 AND the model named the image's colour in each case, so
25299
+ * the pixels genuinely reach it either way. Probe `passthrough_image_claude`
25300
+ * in scripts/probe-copilot-compat.sh keeps that verified; if Copilot ever
25301
+ * starts gating vision on the header, that probe fails rather than images
25302
+ * silently degrading on the lead model's own path.
25930
25303
  *
25931
25304
  * extraHeaders allows callers to forward client-supplied beta headers
25932
25305
  * (anthropic-beta) so Copilot enables extended features.
@@ -25992,11 +25365,12 @@ async function createMessages(body, extraHeaders, callerSignal, retryTransient =
25992
25365
  errorBody = "(could not read error body)";
25993
25366
  }
25994
25367
  consola.error(`Copilot /v1/messages error: ${response.status} ${errorBody}`);
25995
- throw new HTTPError("Copilot messages request failed", new Response(errorBody, {
25368
+ const reconstructed = new Response(errorBody, {
25996
25369
  status: response.status,
25997
25370
  statusText: response.statusText,
25998
25371
  headers: response.headers
25999
- }));
25372
+ });
25373
+ throw new HTTPError("Copilot messages request failed", reconstructed);
26000
25374
  }
26001
25375
  return response;
26002
25376
  }
@@ -26039,11 +25413,12 @@ async function countTokens(body, extraHeaders, callerSignal, retryTransient = fa
26039
25413
  errorBody = "(could not read error body)";
26040
25414
  }
26041
25415
  consola.error(`Copilot count_tokens error: ${response.status} ${errorBody}`);
26042
- throw new HTTPError("Copilot count_tokens request failed", new Response(errorBody, {
25416
+ const reconstructed = new Response(errorBody, {
26043
25417
  status: response.status,
26044
25418
  statusText: response.statusText,
26045
25419
  headers: response.headers
26046
- }));
25420
+ });
25421
+ throw new HTTPError("Copilot count_tokens request failed", reconstructed);
26047
25422
  }
26048
25423
  return response;
26049
25424
  }
@@ -26543,6 +25918,11 @@ function toolEntries(scope) {
26543
25918
  type: "string",
26544
25919
  description: "Optional additional context (extra file content, prior decisions). Concatenated to the brief before sending."
26545
25920
  },
25921
+ imagePaths: {
25922
+ type: "array",
25923
+ items: { type: "string" },
25924
+ 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."
25925
+ },
26546
25926
  effort: {
26547
25927
  type: "string",
26548
25928
  enum: [...p.allowedEfforts],
@@ -26802,7 +26182,10 @@ async function dispatchModelCall(args) {
26802
26182
  content: [{
26803
26183
  type: "input_text",
26804
26184
  text: args.userText
26805
- }]
26185
+ }, ...(args.images ?? []).map((img) => ({
26186
+ type: "input_image",
26187
+ image_url: `data:${img.mimeType};base64,${img.data}`
26188
+ }))]
26806
26189
  }],
26807
26190
  stream: false,
26808
26191
  reasoning: { effort: args.effort }
@@ -26820,7 +26203,20 @@ async function dispatchModelCall(args) {
26820
26203
  system: args.instructions,
26821
26204
  thinking: { type: "adaptive" },
26822
26205
  output_config: { effort: args.effort },
26823
- messages: [{
26206
+ messages: [args.images && args.images.length > 0 ? {
26207
+ role: "user",
26208
+ content: [{
26209
+ type: "text",
26210
+ text: args.userText
26211
+ }, ...args.images.map((img) => ({
26212
+ type: "image",
26213
+ source: {
26214
+ type: "base64",
26215
+ media_type: img.mimeType,
26216
+ data: img.data
26217
+ }
26218
+ }))]
26219
+ } : {
26824
26220
  role: "user",
26825
26221
  content: args.userText
26826
26222
  }]
@@ -26835,7 +26231,16 @@ async function dispatchModelCall(args) {
26835
26231
  messages: [{
26836
26232
  role: "system",
26837
26233
  content: args.instructions
26838
- }, {
26234
+ }, args.images && args.images.length > 0 ? {
26235
+ role: "user",
26236
+ content: [{
26237
+ type: "text",
26238
+ text: args.userText
26239
+ }, ...args.images.map((img) => ({
26240
+ type: "image_url",
26241
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` }
26242
+ }))]
26243
+ } : {
26839
26244
  role: "user",
26840
26245
  content: args.userText
26841
26246
  }],
@@ -26847,7 +26252,7 @@ async function dispatchModelCall(args) {
26847
26252
  label: resolvedModel
26848
26253
  }));
26849
26254
  }
26850
- async function callPersona(persona, prompt, context, effort, signal) {
26255
+ async function callPersona(persona, prompt, context, effort, signal, images) {
26851
26256
  const userText = buildUserText(prompt, context);
26852
26257
  const text = await dispatchModelCall({
26853
26258
  model: persona.model,
@@ -26855,6 +26260,7 @@ async function callPersona(persona, prompt, context, effort, signal) {
26855
26260
  instructions: persona.baseInstructions,
26856
26261
  userText,
26857
26262
  effort,
26263
+ images,
26858
26264
  signal
26859
26265
  });
26860
26266
  if (!text) return toolError(`persona ${persona.agentName}: empty assistant output`);
@@ -26903,6 +26309,7 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26903
26309
  let personaPrompt;
26904
26310
  let personaContext;
26905
26311
  let personaEffort;
26312
+ let personaImages;
26906
26313
  if (persona) {
26907
26314
  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)}`);
26908
26315
  const requestedEffort = args.effort;
@@ -26910,6 +26317,12 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26910
26317
  if (!prompt) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: arguments.prompt is required`);
26911
26318
  personaPrompt = prompt;
26912
26319
  personaContext = typeof args.context === "string" ? args.context : void 0;
26320
+ if (args.imagePaths !== void 0) {
26321
+ 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");
26322
+ const loaded = await loadPeerImages(args.imagePaths, process.cwd());
26323
+ if (!loaded.ok) return rpcError(body.id, RPC_INVALID_PARAMS, `tools/call: ${loaded.error}`);
26324
+ personaImages = loaded.images;
26325
+ }
26913
26326
  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("|")}.`);
26914
26327
  personaEffort = requestedEffort ?? persona.defaultEffort;
26915
26328
  }
@@ -26945,7 +26358,7 @@ async function handleToolsCall(body, scope, sessionWorkspace) {
26945
26358
  const telemetryModel = persona ? persona.model : "(non-persona)";
26946
26359
  try {
26947
26360
  if (nonPersonaTool) applySessionWorkspace(args, sessionWorkspace, nonPersonaTool);
26948
- const result = persona ? await callPersona(persona, personaPrompt, personaContext, personaEffort, aborter?.signal) : await nonPersonaTool.handler(args, aborter?.signal);
26361
+ const result = persona ? await callPersona(persona, personaPrompt, personaContext, personaEffort, aborter?.signal, personaImages) : await nonPersonaTool.handler(args, aborter?.signal);
26949
26362
  logTelemetry({
26950
26363
  name: telemetryName,
26951
26364
  model: telemetryModel,
@@ -28178,7 +27591,7 @@ async function throttleSearch() {
28178
27591
  const waitMs = 1e3 - (now - searchTimestamps[0]);
28179
27592
  if (waitMs > 0) {
28180
27593
  consola.debug(`Web search rate limited, waiting ${waitMs}ms`);
28181
- await sleep$3(waitMs);
27594
+ await sleep$2(waitMs);
28182
27595
  }
28183
27596
  }
28184
27597
  searchTimestamps.push(Date.now());
@@ -29230,18 +28643,49 @@ const READ_PARAMS = Type$1.Object({
29230
28643
  description: "Max lines to return."
29231
28644
  }))
29232
28645
  });
28646
+ /**
28647
+ * A file is an image if its BYTES say so. Extensions are a caller assertion and
28648
+ * are not consulted: a `.png` holding HTML must not be shipped as an image, and
28649
+ * a screenshot saved without a suffix should still work.
28650
+ */
28651
+ function imageResult(data, mimeType) {
28652
+ return {
28653
+ content: [{
28654
+ type: "text",
28655
+ text: `[${mimeType} image, ${data.length} base64 chars]`
28656
+ }, {
28657
+ type: "image",
28658
+ data,
28659
+ mimeType
28660
+ }],
28661
+ details: {}
28662
+ };
28663
+ }
28664
+ /**
28665
+ * Non-image binary detection. A NUL byte in the first few KiB is the classic
28666
+ * heuristic (it is what `grep`, `git`, and `file` all effectively use) and it is
28667
+ * enough here: the goal is not to classify the file, only to avoid handing the
28668
+ * model a wall of U+FFFD replacement characters and calling it text.
28669
+ */
28670
+ function looksBinary(buf) {
28671
+ return buf.subarray(0, Math.min(buf.length, 8192)).includes(0);
28672
+ }
29233
28673
  function readTool(workspace) {
29234
28674
  return {
29235
28675
  name: "read",
29236
28676
  label: "Read file",
29237
- 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.",
28677
+ 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.",
29238
28678
  parameters: READ_PARAMS,
29239
28679
  async execute(_toolCallId, params, signal) {
29240
28680
  const abs = resolvePathOrThrow(params.path, workspace);
29241
28681
  const st = await stat(abs);
29242
28682
  if (!st.isFile()) throw new Error("rejected: not a regular file");
29243
28683
  if (st.size > READ_MAX_BYTES) throw new Error(`rejected: file >${READ_MAX_BYTES} bytes (10 MiB) — got ${st.size}`);
29244
- const text = (await readFile(abs, { signal })).toString("utf8");
28684
+ const buf = await readFile(abs, { signal });
28685
+ const imageMime = detectImageMimeType(buf);
28686
+ if (imageMime) return imageResult(buf.toString("base64"), imageMime);
28687
+ 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.`);
28688
+ const text = buf.toString("utf8");
29245
28689
  if (params.offset === void 0 && params.limit === void 0) return textResult(text);
29246
28690
  const lines = text.split(/\r?\n/);
29247
28691
  const start = params.offset ?? 0;
@@ -30153,7 +29597,7 @@ async function sweepAgedWorkerDiffs(opts = {}) {
30153
29597
  }
30154
29598
  let entries;
30155
29599
  try {
30156
- entries = await fs.readdir(PATHS.WORKER_DIFFS_DIR);
29600
+ entries = await fs$1.readdir(PATHS.WORKER_DIFFS_DIR);
30157
29601
  } catch {
30158
29602
  return;
30159
29603
  }
@@ -30162,8 +29606,8 @@ async function sweepAgedWorkerDiffs(opts = {}) {
30162
29606
  if (!WORKER_DIFF_NAME_RE.test(name)) continue;
30163
29607
  const full = path.join(PATHS.WORKER_DIFFS_DIR, name);
30164
29608
  try {
30165
- if (now - (await fs.stat(full)).mtimeMs < AGE_SWEEP_MS$1) continue;
30166
- await fs.rm(full, { force: true }).catch(() => {});
29609
+ if (now - (await fs$1.stat(full)).mtimeMs < AGE_SWEEP_MS$1) continue;
29610
+ await fs$1.rm(full, { force: true }).catch(() => {});
30167
29611
  } catch {}
30168
29612
  }
30169
29613
  }
@@ -30176,13 +29620,13 @@ async function sweepAgedWorkerDiffs(opts = {}) {
30176
29620
  */
30177
29621
  async function saveOverflowResult(text) {
30178
29622
  await sweepAgedWorkerDiffs({ throttle: true });
30179
- await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
29623
+ await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
30180
29624
  let lastErr;
30181
29625
  for (let attempt = 0; attempt < 5; attempt++) {
30182
29626
  const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.txt`;
30183
29627
  const outPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
30184
29628
  try {
30185
- await fs.writeFile(outPath, text, {
29629
+ await fs$1.writeFile(outPath, text, {
30186
29630
  mode: 384,
30187
29631
  flag: "wx"
30188
29632
  });
@@ -30376,7 +29820,7 @@ async function findRepoRoot(workspaceAbs) {
30376
29820
  async function sweepAgedWorktrees(parent) {
30377
29821
  let entries;
30378
29822
  try {
30379
- entries = await fs.readdir(parent);
29823
+ entries = await fs$1.readdir(parent);
30380
29824
  } catch (err) {
30381
29825
  if (err.code === "ENOENT") return;
30382
29826
  return;
@@ -30386,7 +29830,7 @@ async function sweepAgedWorktrees(parent) {
30386
29830
  if (!WORKTREE_DIR_NAME_RE.test(name)) continue;
30387
29831
  const full = path.join(parent, name);
30388
29832
  try {
30389
- const ageMs = now - (await fs.stat(full)).mtimeMs;
29833
+ const ageMs = now - (await fs$1.stat(full)).mtimeMs;
30390
29834
  if (ageMs < AGE_SWEEP_MTIME_FLOOR_MS) continue;
30391
29835
  if (ageMs < AGE_SWEEP_MS) continue;
30392
29836
  await execFileP("git", [
@@ -30395,7 +29839,7 @@ async function sweepAgedWorktrees(parent) {
30395
29839
  "--force",
30396
29840
  full
30397
29841
  ], { timeout: 1e4 }).catch(() => {});
30398
- await fs.rm(full, {
29842
+ await fs$1.rm(full, {
30399
29843
  recursive: true,
30400
29844
  force: true
30401
29845
  }).catch(() => {});
@@ -30414,11 +29858,11 @@ async function sweepAgedWorktrees(parent) {
30414
29858
  async function createWorktree(workspaceAbs, opts) {
30415
29859
  const { repoRoot, gitCommonDir } = await findRepoRoot(workspaceAbs);
30416
29860
  const parent = path.join(gitCommonDir, "worker-worktrees");
30417
- await fs.mkdir(parent, { recursive: true });
29861
+ await fs$1.mkdir(parent, { recursive: true });
30418
29862
  await sweepAgedWorktrees(parent);
30419
29863
  let existing = [];
30420
29864
  try {
30421
- existing = await fs.readdir(parent);
29865
+ existing = await fs$1.readdir(parent);
30422
29866
  } catch {}
30423
29867
  if (existing.filter((n) => WORKTREE_DIR_NAME_RE.test(n)).length >= QUOTA_PER_REPO) throw new Error(`worker-agent worktree: per-repo quota exceeded (>=${QUOTA_PER_REPO} entries under ${parent}); abort, investigate, then prune manually or wait for the age sweep`);
30424
29868
  const suffix = randomBytes(4).toString("hex");
@@ -30466,9 +29910,9 @@ async function createWorktree(workspaceAbs, opts) {
30466
29910
  for (const rel of files) {
30467
29911
  const src = path.join(repoRoot, rel);
30468
29912
  const dst = path.join(dir, rel);
30469
- await fs.mkdir(path.dirname(dst), { recursive: true });
29913
+ await fs$1.mkdir(path.dirname(dst), { recursive: true });
30470
29914
  try {
30471
- await fs.copyFile(src, dst);
29915
+ await fs$1.copyFile(src, dst);
30472
29916
  } catch (err) {
30473
29917
  if (err.code === "ENOENT") continue;
30474
29918
  throw err;
@@ -30587,13 +30031,13 @@ async function saveOverflowPatch(dir) {
30587
30031
  "HEAD"
30588
30032
  ], { maxBuffer: 268435456 });
30589
30033
  await sweepAgedWorkerDiffs({ throttle: true });
30590
- await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
30034
+ await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
30591
30035
  let lastErr;
30592
30036
  for (let attempt = 0; attempt < 5; attempt++) {
30593
30037
  const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.patch`;
30594
30038
  const patchPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
30595
30039
  try {
30596
- await fs.writeFile(patchPath, patch.stdout, {
30040
+ await fs$1.writeFile(patchPath, patch.stdout, {
30597
30041
  mode: 384,
30598
30042
  flag: "wx"
30599
30043
  });
@@ -31657,93 +31101,6 @@ function selectChampion(orchestrated, baseline, canonicalGateIds, tiePolicy) {
31657
31101
  };
31658
31102
  }
31659
31103
  //#endregion
31660
- //#region src/lib/orchestration/gate-runner.ts
31661
- async function runGateChecks(checks, cwd, exec) {
31662
- const results = await Promise.all(checks.map(async (c) => {
31663
- try {
31664
- const r = await exec({
31665
- command: c.command,
31666
- cwd
31667
- });
31668
- return {
31669
- id: c.id,
31670
- passed: r.exitCode === 0
31671
- };
31672
- } catch {
31673
- return {
31674
- id: c.id,
31675
- passed: false
31676
- };
31677
- }
31678
- }));
31679
- const passed = /* @__PURE__ */ new Set();
31680
- const ran = /* @__PURE__ */ new Set();
31681
- for (const r of results) {
31682
- ran.add(r.id);
31683
- if (r.passed) passed.add(r.id);
31684
- }
31685
- return {
31686
- passed,
31687
- ran
31688
- };
31689
- }
31690
- //#endregion
31691
- //#region src/lib/orchestration/gate-registry.ts
31692
- /**
31693
- * Built-in sealed gates. Commands follow this repo's TS/Bun conventions (the
31694
- * `bun run <script>` indirection means a repo without that script simply fails
31695
- * the check, which the selector treats as not-passed rather than a crash). New
31696
- * ecosystems get a new sealed id here, never a caller-supplied command.
31697
- */
31698
- const SEALED_GATES = {
31699
- "default-ci": [
31700
- {
31701
- id: "typecheck",
31702
- command: "bun run typecheck"
31703
- },
31704
- {
31705
- id: "test",
31706
- command: "bun test"
31707
- },
31708
- {
31709
- id: "lint",
31710
- command: "bun run lint"
31711
- }
31712
- ],
31713
- "typecheck-test": [{
31714
- id: "typecheck",
31715
- command: "bun run typecheck"
31716
- }, {
31717
- id: "test",
31718
- command: "bun test"
31719
- }],
31720
- "typecheck-only": [{
31721
- id: "typecheck",
31722
- command: "bun run typecheck"
31723
- }]
31724
- };
31725
- /** The set of sealed gate ids, used as the kernel's `knownGateIds` so the IR
31726
- * verifier rejects an executable gate that references an unregistered id. */
31727
- function sealedGateIds() {
31728
- return new Set(Object.keys(SEALED_GATES));
31729
- }
31730
- /**
31731
- * Resolve a sealed gate by id. Returns a DEFENSIVE CLONE (fresh objects) so a
31732
- * caller can never mutate the registry's command set. `undefined` for an
31733
- * unknown id, which `run_workflow` rejects before executing anything.
31734
- */
31735
- function resolveSealedGate(gateId) {
31736
- const checks = SEALED_GATES[gateId];
31737
- if (!checks) return void 0;
31738
- return {
31739
- id: gateId,
31740
- checks: checks.map((c) => ({
31741
- id: c.id,
31742
- command: c.command
31743
- }))
31744
- };
31745
- }
31746
- //#endregion
31747
31104
  //#region src/lib/orchestration/verify.ts
31748
31105
  /**
31749
31106
  * Static verifier for the workflow IR. This is what `decompose`'s output is
@@ -32235,186 +31592,6 @@ function makeRunner(deps, ctx) {
32235
31592
  } };
32236
31593
  }
32237
31594
  //#endregion
32238
- //#region src/lib/orchestration/gate-immutability.ts
32239
- /** Patterns that weaken a gate regardless of language (test exclusivity / focus
32240
- * idioms shared across JS test runners; kept in COMMON since several languages
32241
- * reuse them). */
32242
- const COMMON_PATTERNS = [{
32243
- name: "skipped-test",
32244
- re: /(\.\s*skip\s*\(|\bxit\s*\(|\bxdescribe\s*\(|\.\s*only\s*\()/
32245
- }];
32246
- /** Language-specific weakening patterns, selected by the added line's file type. */
32247
- const LANG_PATTERNS = {
32248
- ts: [
32249
- {
32250
- name: "ts-suppression",
32251
- re: /@ts-(ignore|nocheck|expect-error)\b/
32252
- },
32253
- {
32254
- name: "any-cast",
32255
- re: /\bas\s+any\b|:\s*any\b/
32256
- },
32257
- {
32258
- name: "eslint-disable",
32259
- re: /eslint-disable\b/
32260
- }
32261
- ],
32262
- py: [
32263
- {
32264
- name: "py-type-ignore",
32265
- re: /#\s*type:\s*ignore\b/
32266
- },
32267
- {
32268
- name: "py-noqa",
32269
- re: /#\s*noqa\b/
32270
- },
32271
- {
32272
- name: "py-skip",
32273
- re: /@(pytest\.mark\.skip|unittest\.skip)\b/
32274
- }
32275
- ],
32276
- go: [{
32277
- name: "go-skip",
32278
- re: /\bt\.Skip\s*\(/
32279
- }, {
32280
- name: "go-nolint",
32281
- re: /\/\/\s*nolint\b/
32282
- }],
32283
- rust: [{
32284
- name: "rust-ignore",
32285
- re: /#\[\s*ignore\b/
32286
- }, {
32287
- name: "rust-allow",
32288
- re: /#\[\s*allow\s*\(/
32289
- }]
32290
- };
32291
- /** Map a file path to a language key for `LANG_PATTERNS` (null → COMMON only). */
32292
- function langForFile(file) {
32293
- if (!file) return null;
32294
- switch (file.slice(file.lastIndexOf(".")).toLowerCase()) {
32295
- case ".ts":
32296
- case ".tsx":
32297
- case ".mts":
32298
- case ".cts":
32299
- case ".js":
32300
- case ".jsx":
32301
- case ".mjs":
32302
- case ".cjs": return "ts";
32303
- case ".py":
32304
- case ".pyi": return "py";
32305
- case ".go": return "go";
32306
- case ".rs": return "rust";
32307
- default: return null;
32308
- }
32309
- }
32310
- /** Union of COMMON + every language — the pre-header default (no file known yet),
32311
- * so a header-less diff is matched permissively, exactly as before this became
32312
- * language-scoped. Once a real file header appears, the set narrows to that
32313
- * file's language (or COMMON only for an unknown extension → fail open). */
32314
- const ALL_PATTERNS = [...COMMON_PATTERNS, ...Object.values(LANG_PATTERNS).flat()];
32315
- /** A `diff --git a/x b/x` or `+++ b/x` header → the current file path. */
32316
- function fileFromHeader(line) {
32317
- const git = /^diff --git a\/.+ b\/(.+)$/.exec(line);
32318
- if (git) return git[1];
32319
- const plus = /^\+\+\+ b\/(.+)$/.exec(line);
32320
- if (plus) return plus[1];
32321
- }
32322
- function detectGateWeakening(diff) {
32323
- const findings = [];
32324
- let file;
32325
- let patterns = ALL_PATTERNS;
32326
- for (const raw of diff.split("\n")) {
32327
- const headerFile = fileFromHeader(raw);
32328
- if (headerFile !== void 0) {
32329
- file = headerFile;
32330
- const lang = langForFile(file);
32331
- patterns = lang ? [...COMMON_PATTERNS, ...LANG_PATTERNS[lang]] : COMMON_PATTERNS;
32332
- continue;
32333
- }
32334
- if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
32335
- const added = raw.slice(1);
32336
- for (const p of patterns) if (p.re.test(added)) findings.push(file === void 0 ? {
32337
- pattern: p.name,
32338
- line: added.trim()
32339
- } : {
32340
- pattern: p.name,
32341
- line: added.trim(),
32342
- file
32343
- });
32344
- }
32345
- return {
32346
- weakened: findings.length > 0,
32347
- findings
32348
- };
32349
- }
32350
- //#endregion
32351
- //#region src/lib/orchestration/stop-gate.ts
32352
- /**
32353
- * The Phase-0 structural-gate decision — the cheap, CERTAIN floor win. It blocks
32354
- * marking work "done" when any canonical gate fails OR the diff weakens a gate.
32355
- * A non-model check that fires every run and never ships broken or gate-gamed
32356
- * code; per the floor analysis it is the only component that raises the
32357
- * worst-case floor BY CONSTRUCTION (the orchestration on top is a conditional
32358
- * bet).
32359
- *
32360
- * This is the logic behind the spawned-session Stop-hook AND the kernel runner's
32361
- * pre-accept check. The process exec and the diff are inputs, so the decision is
32362
- * pure + unit-testable; the hook script that runs `git diff` + invokes this is a
32363
- * thin wrapper.
32364
- */
32365
- async function evaluateStopGate(input) {
32366
- const gate = await runGateChecks(input.checks, input.cwd, input.exec);
32367
- const weak = detectGateWeakening(input.diff);
32368
- const failedChecks = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
32369
- const block = failedChecks.length > 0 || weak.weakened;
32370
- const parts = [];
32371
- if (failedChecks.length > 0) parts.push(`failing gates: ${failedChecks.join(", ")}`);
32372
- if (weak.weakened) {
32373
- const pats = [...new Set(weak.findings.map((f) => f.pattern))].join(", ");
32374
- parts.push(`gate-weakening in the diff: ${pats}`);
32375
- }
32376
- return {
32377
- block,
32378
- reason: block ? parts.join("; ") : "all canonical gates pass; no gate-weakening in the diff",
32379
- failedChecks,
32380
- weakening: weak.findings
32381
- };
32382
- }
32383
- //#endregion
32384
- //#region src/lib/orchestration/live-exec.ts
32385
- /**
32386
- * Live `ExecFn` for the gate runner — runs the kernel's SEALED gate command in a
32387
- * workspace via the project's Windows-safe exec helper (`runCommandCapture` →
32388
- * `buildExecInvocation`, which handles cmd.exe quoting on Windows). Only the
32389
- * kernel's sealed gate commands flow through here (tests/types/lint/build),
32390
- * never a producer-authored string, so a simple whitespace split into argv is
32391
- * safe for these fixed commands. A command that can't run (spawn error / killed)
32392
- * counts as a non-zero exit, never a throw — the gate runner treats it as
32393
- * not-passed.
32394
- *
32395
- * This is the one piece of the gate engine that touches a real subprocess; it is
32396
- * model-free and cross-platform, so it is exercised directly in CI (unlike the
32397
- * worker/worktree/model adapters, which need the gated E2E harness).
32398
- */
32399
- /** Per-command wall-clock cap so a hung gate command (watch-mode test, a process
32400
- * waiting on stdin, a stale lockfile) is tree-killed instead of hanging the
32401
- * caller forever. Generous (a real typecheck/test/lint can take minutes) but
32402
- * bounded; override with GH_ROUTER_GATE_CMD_TIMEOUT_MS. A timeout kills the
32403
- * command (code null) which the gate runner treats as not-passed. */
32404
- const CMD_TIMEOUT_MS = parseIntEnv(process.env.GH_ROUTER_GATE_CMD_TIMEOUT_MS) ?? 6e5;
32405
- const liveExec = async ({ command, cwd }) => {
32406
- const argv = command.trim().split(/\s+/).filter(Boolean);
32407
- if (argv.length === 0) return { exitCode: 1 };
32408
- try {
32409
- return { exitCode: (await runCommandCapture(argv, {
32410
- cwd,
32411
- timeoutMs: CMD_TIMEOUT_MS
32412
- })).code ?? 1 };
32413
- } catch {
32414
- return { exitCode: 1 };
32415
- }
32416
- };
32417
- //#endregion
32418
31595
  //#region src/lib/orchestration/runner-live.ts
32419
31596
  /**
32420
31597
  * The LIVE adapter for the kernel's NodeRunner primitives. buildLiveRunner wires
@@ -32517,665 +31694,6 @@ function buildLiveRunner(ctx, prim) {
32517
31694
  };
32518
31695
  }
32519
31696
  //#endregion
32520
- //#region src/lib/orchestration/stop-gate-policy.ts
32521
- /**
32522
- * Pure + thin-IO policy helpers for the consent-gated, top-level-only structural
32523
- * Stop-gate. Split out of `stop-gate-hook.ts` so the security-critical decisions
32524
- * (is this a subagent? is this repo trusted? which checks regressed?) are small,
32525
- * named, and unit-testable.
32526
- *
32527
- * Verified against the official Claude Code hooks reference
32528
- * (https://code.claude.com/docs/en/hooks), 2026-06:
32529
- * - `Stop` fires for the MAIN agent; subagents fire `SubagentStop`, and a
32530
- * `Stop`-registered hook is converted to `SubagentStop` in a subagent
32531
- * context — so the hook command CAN run for a subagent. The payload carries
32532
- * `agent_id` / `agent_type` ONLY inside a subagent/teammate context, so that
32533
- * is the reliable top-level-vs-sub discriminator (NOT an env var, which the
32534
- * proxy cannot set on Claude-spawned subagents).
32535
- * - `UserPromptSubmit` fires only for human prompts to the main agent.
32536
- * - `stop_hook_active` is NOT in the current documented payload, so the
32537
- * per-prompt block budget — not that flag — must be the termination guard.
32538
- */
32539
- /**
32540
- * True when the hook is firing inside a subagent / teammate context (NOT the
32541
- * top-level user session). Claude Code adds `agent_id` + `agent_type` to the
32542
- * payload only there, so their presence is the discriminator. The Stop-gate and
32543
- * the prompt-steer hook both stand down when this is true, scoping them to the
32544
- * top-level session.
32545
- */
32546
- function isSubagentContext(payload) {
32547
- const present = (v) => v !== void 0 && v !== null;
32548
- return present(payload?.agent_type) || present(payload?.agent_id);
32549
- }
32550
- /** Stable trust dir (NOT the per-launch mirror — trust must persist). */
32551
- function trustDir() {
32552
- return path.join(PATHS.APP_DIR, "stop-gate", "trust");
32553
- }
32554
- /** Resolve the git repo root for `cwd`, falling back to `cwd` when not a repo. */
32555
- async function repoRoot(cwd) {
32556
- const top = (await runCommandCapture([
32557
- "git",
32558
- "rev-parse",
32559
- "--show-toplevel"
32560
- ], {
32561
- cwd,
32562
- timeoutMs: 5e3
32563
- }).catch(() => void 0))?.stdout?.trim();
32564
- return top && top.length > 0 ? top : cwd;
32565
- }
32566
- function trustFileFor(root) {
32567
- const key = createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 32);
32568
- return path.join(trustDir(), key);
32569
- }
32570
- /**
32571
- * A stable identity for the repo at `root`: the first (root) commit SHA. It
32572
- * survives normal history growth but differs across distinct repositories, so a
32573
- * DIFFERENT repo later appearing at the same filesystem path is not silently
32574
- * trusted (codex review #2). Empty string when unavailable (no git / no commits)
32575
- * — trust then falls back to path-only, the best we can do.
32576
- */
32577
- async function repoFingerprint(root) {
32578
- return (await runCommandCapture([
32579
- "git",
32580
- "rev-list",
32581
- "--max-parents=0",
32582
- "HEAD"
32583
- ], {
32584
- cwd: root,
32585
- timeoutMs: 5e3
32586
- }).catch(() => void 0))?.stdout?.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? "";
32587
- }
32588
- /**
32589
- * True iff the user has consented to run the gate in this repo AND the repo's
32590
- * identity still matches what was trusted. The trust file stores `root\nfp\n`;
32591
- * a present fingerprint is verified against the live one (deny on mismatch, and
32592
- * deny if we pinned one but can't recompute it — fail closed). A legacy file
32593
- * with no fingerprint is path-only trust.
32594
- */
32595
- async function isRepoTrusted(cwd) {
32596
- const root = await repoRoot(cwd);
32597
- let stored;
32598
- try {
32599
- stored = await promises.readFile(trustFileFor(root), "utf8");
32600
- } catch {
32601
- return false;
32602
- }
32603
- const storedFp = (stored.split(/\r?\n/)[1] ?? "").trim();
32604
- if (storedFp.length === 0) return true;
32605
- const currentFp = await repoFingerprint(root);
32606
- if (currentFp.length === 0) return false;
32607
- return currentFp === storedFp;
32608
- }
32609
- /** Record consent for this repo (consent once → automatic thereafter), pinning
32610
- * the repo's root-commit fingerprint so a later repo swap at the same path is
32611
- * not auto-trusted. */
32612
- async function trustRepo(cwd) {
32613
- const root = await repoRoot(cwd);
32614
- const fp = await repoFingerprint(root);
32615
- await promises.mkdir(trustDir(), { recursive: true });
32616
- await promises.writeFile(trustFileFor(root), `${root}\n${fp}\n`, { mode: 384 });
32617
- return root;
32618
- }
32619
- /**
32620
- * Repo-aware gate enable: `GH_ROUTER_DISABLE_STOP_GATE` force-off wins;
32621
- * `GH_ROUTER_ENABLE_STOP_GATE` force-on next; otherwise default to OFF unless the
32622
- * repo is trusted. This is the load-bearing security gate — the default is OFF,
32623
- * so an untrusted repo's scripts are never auto-run.
32624
- */
32625
- async function stopGateEnabledForRepo(cwd, env = process.env) {
32626
- if (parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_GATE) === true) return false;
32627
- if (parseBoolEnv(env.GH_ROUTER_ENABLE_STOP_GATE) === true) return true;
32628
- return isRepoTrusted(cwd);
32629
- }
32630
- /**
32631
- * Given the current failed checks and the recorded baseline, return the checks
32632
- * that REGRESSED (failing now, not failing at baseline). A null baseline (first
32633
- * eval) yields an empty regression set — nothing is blamed on the agent yet.
32634
- */
32635
- function regressions(currentFailed, baseline) {
32636
- if (baseline === null) return [];
32637
- return currentFailed.filter((id) => !baseline.has(id));
32638
- }
32639
- /** File-backed `BaselineStore` under `stateDir`, keyed by sha256(session_id). */
32640
- function fileBaselineStore(stateDir) {
32641
- const fileFor = (sid) => path.join(stateDir, `baseline-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
32642
- return {
32643
- async get(sid) {
32644
- try {
32645
- const raw = await promises.readFile(fileFor(sid), "utf8");
32646
- const arr = JSON.parse(raw);
32647
- if (Array.isArray(arr)) return new Set(arr.filter((x) => typeof x === "string"));
32648
- return /* @__PURE__ */ new Set();
32649
- } catch {
32650
- return null;
32651
- }
32652
- },
32653
- async set(sid, failed) {
32654
- await promises.mkdir(stateDir, { recursive: true });
32655
- await promises.writeFile(fileFor(sid), JSON.stringify([...failed]), { mode: 384 });
32656
- }
32657
- };
32658
- }
32659
- function fileReviewDebounce(stateDir) {
32660
- const fileFor = (sid) => path.join(stateDir, `review-hash-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
32661
- const readLast = async (sid) => {
32662
- try {
32663
- return (await promises.readFile(fileFor(sid), "utf8")).trim();
32664
- } catch {
32665
- return "";
32666
- }
32667
- };
32668
- return {
32669
- async shouldReview(sid, diffHash) {
32670
- if (diffHash.length === 0) return false;
32671
- return await readLast(sid) !== diffHash;
32672
- },
32673
- async markReviewed(sid, diffHash) {
32674
- await promises.mkdir(stateDir, { recursive: true });
32675
- await promises.writeFile(fileFor(sid), diffHash, { mode: 384 });
32676
- }
32677
- };
32678
- }
32679
- function fileFindingsStore(stateDir) {
32680
- const fileFor = (sid) => path.join(stateDir, `findings-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
32681
- return {
32682
- async read(sid) {
32683
- try {
32684
- const raw = await promises.readFile(fileFor(sid), "utf8");
32685
- return raw.length > 0 ? raw : null;
32686
- } catch {
32687
- return null;
32688
- }
32689
- },
32690
- async write(sid, findings) {
32691
- await promises.mkdir(stateDir, { recursive: true });
32692
- const tmp = `${fileFor(sid)}.${process.pid}.tmp`;
32693
- await promises.writeFile(tmp, findings, { mode: 384 });
32694
- await promises.rename(tmp, fileFor(sid));
32695
- },
32696
- async clear(sid) {
32697
- await promises.unlink(fileFor(sid)).catch(() => {});
32698
- }
32699
- };
32700
- }
32701
- /**
32702
- * The single canonical state dir for the advisory-review layer (hook V2): the
32703
- * Stop hook's review debounce, the background review's findings file, and the
32704
- * UserPromptSubmit hook's last-user-prompt store all live here, keyed by
32705
- * sha256(session_id). One dir so the three independent subcommand processes
32706
- * (`internal-stop-hook`, `internal-stop-review`, `internal-prompt-submit`)
32707
- * agree on where to read/write without threading a path through env. Distinct
32708
- * from the deterministic gate's `gh-router-stopgate*` dirs (block budget +
32709
- * baseline) so the advisory layer can be wiped independently.
32710
- */
32711
- function stopReviewStateDir() {
32712
- return path.join(tmpdir(), "gh-router-stop-review");
32713
- }
32714
- function fileLastPromptStore(stateDir) {
32715
- const fileFor = (sid) => path.join(stateDir, `last-prompt-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
32716
- return {
32717
- async read(sid) {
32718
- try {
32719
- const raw = await promises.readFile(fileFor(sid), "utf8");
32720
- return raw.length > 0 ? raw : null;
32721
- } catch {
32722
- return null;
32723
- }
32724
- },
32725
- async write(sid, prompt) {
32726
- await promises.mkdir(stateDir, { recursive: true });
32727
- const tmp = `${fileFor(sid)}.${process.pid}.tmp`;
32728
- await promises.writeFile(tmp, prompt, { mode: 384 });
32729
- await promises.rename(tmp, fileFor(sid));
32730
- }
32731
- };
32732
- }
32733
- //#endregion
32734
- //#region src/lib/orchestration/stop-gate-hook.ts
32735
- /**
32736
- * The Phase-0 structural-gate Stop-hook glue. `runStopGateForLaunch` resolves a
32737
- * SEALED gate by id and evaluates it against the working-tree diff, returning the
32738
- * block decision a spawned-session Stop hook (or a manual check) acts on.
32739
- *
32740
- * Safety posture (deliberately different from the kernel): a Stop hook that
32741
- * blocks on a MISCONFIGURATION would wedge the user's session (they could not
32742
- * stop). So this fails OPEN on config problems (unknown gate id) and blocks ONLY
32743
- * on a genuine red gate or a gate-weakening diff. The kernel, by contrast, fails
32744
- * CLOSED to the baseline. Both are correct for their context.
32745
- *
32746
- * The sealed gate + diff + exec are inputs, so the decision is pure and
32747
- * unit-testable; the thin live wrapper (capture `git diff`, spawn the checks,
32748
- * map the result to an exit code) is the only part that needs a real process.
32749
- */
32750
- async function runStopGateForLaunch(input) {
32751
- const gate = resolveSealedGate(input.gateId);
32752
- if (!gate) return {
32753
- block: false,
32754
- reason: `stop-gate: unknown gateId "${input.gateId}" (not blocking)`,
32755
- failedChecks: [],
32756
- weakening: []
32757
- };
32758
- return evaluateStopGate({
32759
- checks: gate.checks,
32760
- cwd: input.workspace,
32761
- exec: input.exec,
32762
- diff: input.diff
32763
- });
32764
- }
32765
- /**
32766
- * The launch-stable baseline key for the dynamic gate path: keyed by the
32767
- * workdir + the descriptor's check set + a per-launch token, NOT the session id
32768
- * and NOT HEAD. The LAUNCHER generates the token (passed to the child via env),
32769
- * computes this key, and seeds the baseline (the set of checks already failing at
32770
- * launch, BEFORE the agent mutates the tree); the runtime hook recomputes the
32771
- * same key (reading the token from its env) and reads that baseline, so an
32772
- * agent-introduced failure is a REGRESSION rather than being silently adopted as
32773
- * the baseline at first stop. The per-launch token isolates concurrent sessions
32774
- * on the same repo (they don't clobber each other's baseline). HEAD is
32775
- * deliberately NOT in the key: an agent that COMMITS its work mid-session must
32776
- * still be judged against the pre-session baseline (a HEAD-keyed baseline would
32777
- * vanish on the commit — the exact moment it matters). A changed check set (the
32778
- * agent edited the harness) yields a different key → the launch baseline no
32779
- * longer matches and the hook degrades to first-stop baselining.
32780
- */
32781
- function launchBaselineKey(workdir, descriptorKey, token) {
32782
- return JSON.stringify([
32783
- "launch",
32784
- path.resolve(workdir),
32785
- descriptorKey,
32786
- token ?? ""
32787
- ]);
32788
- }
32789
- /**
32790
- * Run the resolved checks ONCE at launch (pre-mutation) and record the failing
32791
- * set as the baseline under `launchBaselineKey`. Best-effort: any failure is
32792
- * swallowed (the hook then degrades to first-stop baselining). The launcher MUST
32793
- * pass the SAME check set the hook will run (same `includeTests`) so a
32794
- * pre-existing test failure isn't later miscounted as a regression.
32795
- *
32796
- * NOTE: capture is best-effort pre-mutation — the launcher fires it before the
32797
- * child can edit, and in practice the agent does not mutate until well after a
32798
- * user prompt, so the checks see the clean tree. If the agent somehow mutates
32799
- * before this completes, the worst case is the legacy first-stop baseline (no
32800
- * regression, never a wedge).
32801
- */
32802
- async function captureLaunchBaseline(input) {
32803
- if (input.checks.length === 0) return;
32804
- try {
32805
- const gate = await runGateChecks(input.checks, input.workdir, input.exec);
32806
- const failed = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
32807
- await input.baseline.set(launchBaselineKey(input.workdir, input.descriptorKey, input.token), failed);
32808
- } catch {}
32809
- }
32810
- /**
32811
- * The advisory background review (hook V2) is ON by default whenever the Stop
32812
- * gate runs; it is the cross-lab accountability layer. Opt out with
32813
- * `GH_ROUTER_DISABLE_STOP_REVIEW=1` to keep the deterministic gate but drop the
32814
- * LLM review. (Disabling the whole gate with `GH_ROUTER_DISABLE_STOP_GATE=1`
32815
- * also drops the review, since the review only ever fires from the gate's green
32816
- * path.)
32817
- */
32818
- function stopReviewEnabled(env = process.env) {
32819
- return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_REVIEW) !== true;
32820
- }
32821
- /** The sealed gate the Stop hook runs, overridable via `GH_ROUTER_STOP_GATE_ID`
32822
- * (must be a registered sealed id; the live wrapper falls open on an unknown
32823
- * id). Defaults to `default-ci`. */
32824
- function stopGateId(env = process.env) {
32825
- const v = (env.GH_ROUTER_STOP_GATE_ID ?? "").trim();
32826
- return v.length > 0 ? v : "default-ci";
32827
- }
32828
- /**
32829
- * PLAN-MODE env fallback. When the session is in plan mode, plan/memory scratch
32830
- * edits should not trip the gate-WEAKENING scan (a plan/memory markdown file may
32831
- * legitimately quote `it.skip` / `as any` while describing work). The launcher
32832
- * sets `GH_ROUTER_STOP_GATE_PLAN_MODE` for a plan-mode session; the payload may
32833
- * ALSO carry a forward-compat `plan_mode` field (see `decideStopHook`). Either
32834
- * signal being present activates the scoping — the executable gate STILL runs
32835
- * and non-plan hunks are STILL scanned.
32836
- */
32837
- function stopGatePlanMode(env = process.env) {
32838
- return parseBoolEnv(env.GH_ROUTER_STOP_GATE_PLAN_MODE) === true;
32839
- }
32840
- /**
32841
- * Is a unified-diff file path confined to a `plans/` or `memory/` directory?
32842
- * Windows-safe (normalizes `\` → `/`). Matches only when a DIRECTORY segment of
32843
- * the path is exactly `plans` or `memory`, so `plans/x.md`, `memory/y.md`, and
32844
- * `a/plans/z` match while the filenames `docs/plans.md` / `myplans/x` do not.
32845
- */
32846
- function isPlanMemoryDiffPath(p) {
32847
- const segs = p.replace(/\\/g, "/").split("/");
32848
- for (let i = 0; i < segs.length - 1; i++) if (segs[i] === "plans" || segs[i] === "memory") return true;
32849
- return false;
32850
- }
32851
- /**
32852
- * Drop the file sections of a unified `git diff` whose NEW path (the `b/…` side)
32853
- * is confined to `plans/` or `memory/` — used for the gate-WEAKENING scan when
32854
- * plan mode is active. Sections for other files (e.g. `src/…`) are preserved
32855
- * verbatim, so a MIXED diff keeps its non-plan hunks and `src/` weakening is
32856
- * still caught. The executable gate does not read the diff, so filtering it here
32857
- * never affects which checks run. A diff with no `diff --git` sections (or an
32858
- * empty one) is returned unchanged.
32859
- */
32860
- function stripPlanMemoryDiffHunks(diff) {
32861
- if (diff.length === 0) return diff;
32862
- const out = [];
32863
- let dropping = false;
32864
- for (const line of diff.split("\n")) {
32865
- if (line.startsWith("diff --git ")) {
32866
- const bPath = /^diff --git a\/.+ b\/(.+)$/.exec(line)?.[1];
32867
- dropping = bPath !== void 0 && isPlanMemoryDiffPath(bPath);
32868
- if (!dropping) out.push(line);
32869
- continue;
32870
- }
32871
- if (!dropping) out.push(line);
32872
- }
32873
- return out.join("\n");
32874
- }
32875
- /**
32876
- * C3: whether the structural Stop-gate is disabled for THIS launch. True when the
32877
- * `GH_ROUTER_DISABLE_STOP_GATE` env is set OR the `--no-stop-gate` launcher flag was
32878
- * passed (`args["no-stop-gate"] === true`). A DRIVEN session sets the flag so a
32879
- * blocking Stop hook never hangs its turn-end (~10 min) waiting on the fleet driver.
32880
- * Pure so it is unit-testable without the live launch path.
32881
- */
32882
- function stopGateDisabled(args, env = process.env) {
32883
- return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_GATE) === true || args["no-stop-gate"] === true;
32884
- }
32885
- /** True when a settings `Stop` entry already registers `command` (so the merge
32886
- * is idempotent across re-launches). */
32887
- function entryHasCommand(entry, command) {
32888
- if (!entry || typeof entry !== "object") return false;
32889
- const hooks = entry.hooks;
32890
- if (!Array.isArray(hooks)) return false;
32891
- return hooks.some((h) => h && typeof h === "object" && h.command === command);
32892
- }
32893
- /**
32894
- * Idempotently merge a hook running `command` for `event` (default `Stop`) into
32895
- * an existing Claude Code settings object WITHOUT clobbering other hook events or
32896
- * other entries. Returns a new object (never mutates the input). Re-running the
32897
- * launcher with the same command+event does not duplicate the hook.
32898
- */
32899
- function mergeStopHookIntoSettings(existing, command, event = "Stop", timeoutSec, matcher) {
32900
- const base = existing && typeof existing === "object" ? { ...existing } : {};
32901
- const hooks = base.hooks && typeof base.hooks === "object" ? { ...base.hooks } : {};
32902
- const arr = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
32903
- if (!arr.some((e) => entryHasCommand(e, command))) {
32904
- const hook = {
32905
- type: "command",
32906
- command
32907
- };
32908
- if (typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0) hook.timeout = timeoutSec;
32909
- arr.push(matcher ? {
32910
- matcher,
32911
- hooks: [hook]
32912
- } : { hooks: [hook] });
32913
- }
32914
- hooks[event] = arr;
32915
- base.hooks = hooks;
32916
- return base;
32917
- }
32918
- async function decideStopHook(input) {
32919
- const maxBlocks = input.maxBlocks ?? 2;
32920
- let payload = {};
32921
- let parsed = false;
32922
- try {
32923
- const p = JSON.parse(input.stdin);
32924
- if (p && typeof p === "object") {
32925
- payload = p;
32926
- parsed = true;
32927
- }
32928
- } catch {}
32929
- if (!parsed) return { exitCode: 0 };
32930
- if (isSubagentContext(payload)) return { exitCode: 0 };
32931
- const sessionId = typeof payload.session_id === "string" && payload.session_id.length > 0 ? payload.session_id : "";
32932
- if (!sessionId) return { exitCode: 0 };
32933
- const cwdRaw = typeof payload.cwd === "string" && payload.cwd.length > 0 ? payload.cwd : input.fallbackCwd;
32934
- let cwd = cwdRaw;
32935
- try {
32936
- cwd = await promises.realpath(cwdRaw);
32937
- } catch {}
32938
- let enabled;
32939
- try {
32940
- enabled = await input.isEnabledForRepo(cwd);
32941
- } catch {
32942
- return { exitCode: 0 };
32943
- }
32944
- if (!enabled) return { exitCode: 0 };
32945
- let priorBlocks;
32946
- try {
32947
- priorBlocks = await input.budget.count(sessionId);
32948
- } catch {
32949
- return { exitCode: 0 };
32950
- }
32951
- if (priorBlocks >= maxBlocks) return {
32952
- exitCode: 0,
32953
- stderr: `structural gate: reached the ${maxBlocks}-block limit for this prompt; allowing the stop WITHOUT re-running the checks. If the failures from the last block are unfixed, they are shipping — run the checks manually to verify.`
32954
- };
32955
- let resolvedKey = input.gateId;
32956
- let dynamicBaselineKey;
32957
- const planMode = input.planMode === true || payload.plan_mode === true;
32958
- const scanDiff = (diff) => planMode ? stripPlanMemoryDiffHunks(diff) : diff;
32959
- const capturedDiff = async (workdir) => {
32960
- try {
32961
- return {
32962
- diff: await input.captureDiff(workdir),
32963
- captured: true
32964
- };
32965
- } catch {
32966
- return {
32967
- diff: "",
32968
- captured: false
32969
- };
32970
- }
32971
- };
32972
- const runGate = async () => {
32973
- if (input.resolveChecks) {
32974
- const resolved = await input.resolveChecks(cwd).catch(() => null);
32975
- if (!resolved || resolved.checks.length === 0) return null;
32976
- resolvedKey = resolved.descriptorKey;
32977
- dynamicBaselineKey = resolved.baselineKey;
32978
- const workdir = resolved.workdir || cwd;
32979
- const { diff, captured } = await capturedDiff(workdir);
32980
- if (captured && diff.length === 0) return {
32981
- kind: "no-diff",
32982
- diff
32983
- };
32984
- const result = await evaluateStopGate({
32985
- checks: resolved.checks,
32986
- cwd: workdir,
32987
- exec: input.exec,
32988
- diff: captured ? scanDiff(diff) : ""
32989
- });
32990
- return {
32991
- kind: "evaluated",
32992
- failedChecks: [...result.failedChecks],
32993
- weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
32994
- diff
32995
- };
32996
- }
32997
- const { diff, captured } = await capturedDiff(cwd);
32998
- if (captured && diff.length === 0) return {
32999
- kind: "no-diff",
33000
- diff
33001
- };
33002
- const result = await runStopGateForLaunch({
33003
- workspace: cwd,
33004
- gateId: input.gateId,
33005
- exec: input.exec,
33006
- diff: captured ? scanDiff(diff) : ""
33007
- });
33008
- return {
33009
- kind: "evaluated",
33010
- failedChecks: [...result.failedChecks],
33011
- weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
33012
- diff
33013
- };
33014
- };
33015
- const timeoutMs = input.timeoutMs ?? 3e5;
33016
- let timer;
33017
- const raced = await Promise.race([runGate(), new Promise((resolve) => {
33018
- timer = setTimeout(() => resolve("timeout"), timeoutMs);
33019
- })]);
33020
- if (timer) clearTimeout(timer);
33021
- if (raced === "timeout") return { exitCode: 0 };
33022
- if (raced === null) return { exitCode: 0 };
33023
- if (raced.kind === "no-diff") return { exitCode: 0 };
33024
- const baselineKey = dynamicBaselineKey ?? JSON.stringify([
33025
- sessionId,
33026
- cwd,
33027
- resolvedKey
33028
- ]);
33029
- const recorded = await input.baseline.get(baselineKey).catch(() => null);
33030
- if (recorded === null) await input.baseline.set(baselineKey, raced.failedChecks).catch(() => {});
33031
- const regressed = regressions(raced.failedChecks, recorded);
33032
- const weakened = raced.weakeningPatterns.length > 0;
33033
- if (regressed.length === 0 && !weakened) {
33034
- await maybeSpawnReview(input, sessionId, cwd, raced.diff);
33035
- return { exitCode: 0 };
33036
- }
33037
- try {
33038
- await input.budget.record(sessionId);
33039
- } catch {
33040
- return { exitCode: 0 };
33041
- }
33042
- const parts = [];
33043
- if (regressed.length > 0) parts.push(`regressed gates: ${regressed.join(", ")}`);
33044
- if (weakened) parts.push(`gate-weakening in the diff: ${raced.weakeningPatterns.join(", ")}`);
33045
- return {
33046
- exitCode: 2,
33047
- stderr: `structural gate failed (block ${priorBlocks + 1}/${maxBlocks}): ${parts.join("; ")}. Fix the failing checks and revert any gate-weakening (no new .skip / as any / lint-disable) before finishing.`
33048
- };
33049
- }
33050
- /**
33051
- * The advisory-review side-effect on a GREEN stop: debounce by diff hash, then
33052
- * fire the detached background reviewer. ADVISORY-ONLY — it returns void, never
33053
- * throws (every step is swallowed), and the caller does not await its result for
33054
- * the exit decision. A no-op when the review layer isn't wired (no debounce /
33055
- * spawn injected, e.g. GH_ROUTER_DISABLE_STOP_REVIEW) or the diff is empty.
33056
- *
33057
- * `markReviewed` runs BEFORE the spawn so a crashing spawn still records the
33058
- * debounce (an identical tree won't re-trigger on the next stop). The review is
33059
- * gated on the diff CHANGING since the last review — without it, every stop of
33060
- * an unchanged tree would re-spend a background gpt-5.6-sol review.
33061
- *
33062
- * The whole body is bounded by a short timeout (the stores are local temp files
33063
- * that complete in well under a millisecond in practice, so the timeout never
33064
- * fires normally — but if the debounce read/write ever stalled, the stop must
33065
- * still proceed promptly; the advisory layer never delays a clean stop).
33066
- */
33067
- const REVIEW_SIDE_EFFECT_BUDGET_MS = 2e3;
33068
- async function maybeSpawnReview(input, sessionId, cwd, diff) {
33069
- if (!input.reviewDebounce || !input.spawnReview) return;
33070
- if (diff.trim().length === 0) return;
33071
- let timer;
33072
- try {
33073
- const work = (async () => {
33074
- const diffHash = createHash("sha256").update(diff).digest("hex");
33075
- if (!await input.reviewDebounce.shouldReview(sessionId, diffHash)) return;
33076
- await input.reviewDebounce.markReviewed(sessionId, diffHash);
33077
- input.spawnReview({
33078
- sessionId,
33079
- cwd,
33080
- diff,
33081
- diffHash
33082
- });
33083
- })();
33084
- await Promise.race([work, new Promise((resolve) => {
33085
- timer = setTimeout(resolve, REVIEW_SIDE_EFFECT_BUDGET_MS);
33086
- })]);
33087
- } catch {} finally {
33088
- if (timer) clearTimeout(timer);
33089
- }
33090
- }
33091
- /**
33092
- * A file-backed `BlockBudget` under `stateDir`, keyed by a hash of the session id
33093
- * (so a session id is never written verbatim to a predictable path). Best-effort:
33094
- * a read miss counts as 0; `record` increments. A write/read error propagates so
33095
- * `decideStopHook` stands down (it can't guarantee termination without the
33096
- * budget).
33097
- */
33098
- function fileBlockBudget(stateDir) {
33099
- const fileFor = (sid) => path.join(stateDir, `block-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
33100
- const readCount = async (sid) => {
33101
- try {
33102
- const raw = await promises.readFile(fileFor(sid), "utf8");
33103
- const n = Number.parseInt(raw.trim(), 10);
33104
- return Number.isFinite(n) && n > 0 ? n : 0;
33105
- } catch {
33106
- return 0;
33107
- }
33108
- };
33109
- return {
33110
- count: readCount,
33111
- async record(sid) {
33112
- const next = await readCount(sid) + 1;
33113
- await promises.mkdir(stateDir, { recursive: true });
33114
- await promises.writeFile(fileFor(sid), String(next), { mode: 384 });
33115
- },
33116
- async reset(sid) {
33117
- await promises.unlink(fileFor(sid)).catch(() => {});
33118
- }
33119
- };
33120
- }
33121
- /**
33122
- * Build the shell command string Claude Code runs for the Stop hook. Invokes the
33123
- * running github-router via its node/bun binary so it works regardless of PATH.
33124
- * Pure (takes the binary + script paths) so the quoting is unit-testable; the
33125
- * cross-platform firing is verified by the gated E2E.
33126
- */
33127
- function buildStopHookCommand(execPath, scriptPath) {
33128
- const q = (s) => `"${s}"`;
33129
- if (scriptPath && scriptPath !== execPath) return `${q(execPath)} ${q(scriptPath)} internal-stop-hook`;
33130
- return `${q(execPath)} internal-stop-hook`;
33131
- }
33132
- /**
33133
- * Build the shell command Claude Code runs for the SessionStart/SessionEnd hooks
33134
- * (registered only when github-router runs inside an ai-or-die Terminal tab). The
33135
- * sidecar path is baked in as a literal `--out` arg — NOT passed via env — so it
33136
- * survives `AIORDIE_CLAUDE_BIND` being stripped from the child's environment and a
33137
- * nested `github-router claude` can't inherit it. Pure (binary + script + out
33138
- * paths) for unit-testable quoting; the live firing is verified end-to-end.
33139
- */
33140
- function buildSessionBindHookCommand(execPath, scriptPath, outPath) {
33141
- const q = (s) => `"${s}"`;
33142
- return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-session-bind --out ${q(outPath)}`;
33143
- }
33144
- /** Command for the `internal-artifact-open` hook (no args — token comes from the
33145
- * mirror creds file, plan from the plans dir; nothing secret in argv). */
33146
- function buildArtifactOpenHookCommand(execPath, scriptPath) {
33147
- const q = (s) => `"${s}"`;
33148
- return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-artifact-open`;
33149
- }
33150
- /**
33151
- * Read-merge-atomic-write the Stop hook into a Claude Code `settings.json` file
33152
- * (the mirrored one). A MISSING file (ENOENT) starts from `{}`; any OTHER read or
33153
- * parse error THROWS (the caller's try/catch warns and continues) rather than
33154
- * overwriting a file we couldn't understand with our defaults. Preserves every
33155
- * other setting, is idempotent, and uses temp+rename so Claude Code's mtime
33156
- * watcher never sees a half-written file. Returns the merged object.
33157
- */
33158
- async function injectStopHookIntoSettingsFile(settingsPath, command, event = "Stop", timeoutSec, matcher) {
33159
- let existing = {};
33160
- let raw;
33161
- try {
33162
- raw = await promises.readFile(settingsPath, "utf8");
33163
- } catch (err) {
33164
- if (err.code !== "ENOENT") throw err;
33165
- raw = void 0;
33166
- }
33167
- if (raw !== void 0) {
33168
- const parsed = JSON.parse(raw);
33169
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
33170
- else throw new Error(`settings.json at ${settingsPath} is not a JSON object; refusing to overwrite`);
33171
- }
33172
- const merged = mergeStopHookIntoSettings(existing, command, event, timeoutSec, matcher);
33173
- const tmp = `${settingsPath}.${process.pid}.tmp`;
33174
- await promises.writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
33175
- await promises.rename(tmp, settingsPath);
33176
- return merged;
33177
- }
33178
- //#endregion
33179
31697
  //#region src/lib/orchestration/attest.ts
33180
31698
  function isNonEmptyString(v) {
33181
31699
  return typeof v === "string" && v.length > 0;
@@ -35174,6 +33692,6 @@ function enumerateInjectedMcpToolNames(groupKeys, opts = {}) {
35174
33692
  return [...new Set(names)];
35175
33693
  }
35176
33694
  //#endregion
35177
- 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 };
33695
+ export { scoutModel as $, ADVISOR_INTERNAL_TOOL_NAME as A, UPSTREAM_FETCH_TIMEOUT_MS as At, relayAnthropicStream as B, buildToolbeltAwareness as C, shouldUseInsecureTls as Ct, TOOLBELT_TOOLS$1 as D, DEFAULT_CODEX_MODEL as Dt, vscodeRipgrepPath as E, DEFAULT_CLAUDE_MODEL_FALLBACKS as Et, buildAnthropicErrorEvent as F, brainstormModel as G, handleMcpPost as H, buildOpenAIErrorEvent as I, browserToolsEnabled as J, browseAgentEnabled as K, isControllerClosedError as L, buildAdvisorStream as M, generateRandomPort as Mt, injectAdvisorTool as N, pickClaudeDefault as Nt, assetFor as O, DEFAULT_CODEX_MODEL_FALLBACKS as Ot, isAdvisorRequested as P, withInstallLock as Pt, reviewerModel as Q, logStreamError as R, availableToolCommands as S, DEFINITION_OF_GREATNESS as St, toolbeltSkipSet as T, toolbeltPathOverride as Tt, agentToolsEnabled as U, handleMcpDelete as V, artifactToolsEnabled as W, geminiAvailable as X, fleetToolsEnabled as Y, nativeSubagentModel as Z, appendPlanReminder as _, provisionAndIndexColbert as _t, buildPeerAwarenessSnippet as a, createMessages as at, runWorkerAgent as b, warmTreeSitterPool as bt, personasFor as c, resolveMcpToolTimeoutMs as ct, EXPLORE_DEFAULT_MODEL as d, createChatCompletions as dt, scribeModel as et, EXPLORE_DEFAULT_THINKING as f, MAX_RESPONSE_BODY_BYTES as ft, TEST_DEFAULT_MODEL as g, hasSupportedBrowserInstalled as gt, REVIEW_DEFAULT_MODEL as h, provisionBrowserAssets as ht, buildAgentPrompt as i, countTokens as it, ADVISOR_TOOL_INSTRUCTIONS as j, UPSTREAM_INACTIVITY_TIMEOUT_MS as jt, searchWeb as k, DEFAULT_PORT as kt, BROWSE_DEFAULT_MODEL as l, pickEndpoint as lt, PLAN_DEFAULT_MODEL as m, parseJsonOrDiagnose as mt, MCP_GROUPS as n, workerToolsEnabled as nt, buildPeerAwarenessSummary as o, getTokenCount as ot, IMPLEMENT_DEFAULT_MODEL as p, readResponseBodyCapped as pt, browserCompoundToolsEnabled as q, assertMcpToolSurfaceConsistent as r, shimDefaultsToXhigh as rt, enumerateInjectedMcpToolNames as s, assembleResponsesPayload as st, GROUP_META as t, standInToolEnabled as tt, DEFAULT_MODEL as u, createResponses as ut, resolveModeDefaults as v, extractTarGzMember as vt, toolbeltEnabled as w, collapsePathKeys as wt, buildEnv as x, CONDENSED_OPERATING_SEQUENCE as xt, resolveWorkerRunOpts as y, extractZipMember as yt, readIteratorWithTimeout as z };
35178
33696
 
35179
- //# sourceMappingURL=peer-mcp-personas-DJzLpfDJ.js.map
33697
+ //# sourceMappingURL=peer-mcp-personas-BDet588n.js.map