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.
- package/dist/attribution-settings-CegtE0TY.js +2347 -0
- package/dist/attribution-settings-CegtE0TY.js.map +1 -0
- package/dist/auth-CP4q2CjO.js +44 -0
- package/dist/auth-CP4q2CjO.js.map +1 -0
- package/dist/browser-ext/background.js +8 -1
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/check-usage-6PB5IUFb.js +43 -0
- package/dist/check-usage-6PB5IUFb.js.map +1 -0
- package/dist/claude-BiZxoNWq.js +593 -0
- package/dist/claude-BiZxoNWq.js.map +1 -0
- package/dist/client-CQMfroGV.js +413 -0
- package/dist/client-CQMfroGV.js.map +1 -0
- package/dist/codex-ST21X3WF.js +85 -0
- package/dist/codex-ST21X3WF.js.map +1 -0
- package/dist/debug-CjmE9sgG.js +81 -0
- package/dist/debug-CjmE9sgG.js.map +1 -0
- package/dist/engine-DNCX0Hfd.js +2 -0
- package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
- package/dist/exec-DTO2oin0.js.map +1 -0
- package/dist/file-log-reporter-IFQcL5IK.js +158 -0
- package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
- package/dist/gate-discovery-CepmbZnc.js +635 -0
- package/dist/gate-discovery-CepmbZnc.js.map +1 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
- package/dist/hook-mcp-client-DBvm9608.js +136 -0
- package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
- package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
- package/dist/internal-plan-review-ipnT3bhj.js +114 -0
- package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
- package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
- package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
- package/dist/internal-session-bind-DwK2iGW0.js +133 -0
- package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
- package/dist/internal-stop-hook-CNPGJm63.js +212 -0
- package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
- package/dist/internal-stop-review-AUcFssrW.js +122 -0
- package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
- package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
- package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
- package/dist/lifecycle-Byw5s1G8.js +134 -0
- package/dist/lifecycle-Byw5s1G8.js.map +1 -0
- package/dist/lifecycle-D80CaWie.js +2 -0
- package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
- package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
- package/dist/lifecycle-DzqCqDlM.js +2 -0
- package/dist/main.js +43 -12884
- package/dist/main.js.map +1 -1
- package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
- package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
- package/dist/models-B6UnGyRo.js +158 -0
- package/dist/models-B6UnGyRo.js.map +1 -0
- package/dist/orchestration-hRU4TdWr.js +39 -0
- package/dist/orchestration-hRU4TdWr.js.map +1 -0
- package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
- package/dist/paths-C1cLZ7Ga.js.map +1 -0
- package/dist/paths-CwKwc2pi.js +2 -0
- package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
- package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
- package/dist/plan-review-hook-C8x0uKPf.js +159 -0
- package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
- package/dist/provision-DSkPa5hY.js +656 -0
- package/dist/provision-DSkPa5hY.js.map +1 -0
- package/dist/serve-sa_7c-yD.js +1511 -0
- package/dist/serve-sa_7c-yD.js.map +1 -0
- package/dist/server-setup-DcO9qvN3.js +4776 -0
- package/dist/server-setup-DcO9qvN3.js.map +1 -0
- package/dist/start-siGxuZhO.js +129 -0
- package/dist/start-siGxuZhO.js.map +1 -0
- package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
- package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
- package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
- package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
- package/dist/token-R2ocFC3M.js +1054 -0
- package/dist/token-R2ocFC3M.js.map +1 -0
- package/dist/version-_Q1WpsQp.js +32 -0
- package/dist/version-_Q1WpsQp.js.map +1 -0
- package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
- package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
- package/package.json +1 -1
- package/dist/engine-BuuHUb4t.js +0 -2
- package/dist/lifecycle-0rXrwjWk.js +0 -2
- package/dist/lifecycle-DHZsVN8j.js.map +0 -1
- package/dist/lifecycle-hzPwXc3I.js +0 -2
- package/dist/paths-BjTMI_xK.js.map +0 -1
- package/dist/paths-D3XgmTcQ.js +0 -2
- package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { t as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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().
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
21626
|
-
|
|
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
|
|
20639
|
+
error: "no text match; screenshot returned no image"
|
|
21638
20640
|
}, true);
|
|
21639
|
-
const visual = await pickElementVisual(shot.
|
|
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]")
|
|
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
|
-
|
|
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
|
-
|
|
24380
|
-
|
|
24381
|
-
|
|
24382
|
-
|
|
24383
|
-
|
|
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
|
-
|
|
24720
|
-
|
|
24721
|
-
|
|
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:
|
|
24724
|
-
|
|
24725
|
-
|
|
24726
|
-
|
|
24727
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
24753
|
-
|
|
24754
|
-
content
|
|
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
|
-
|
|
24758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25692
|
-
|
|
25693
|
-
|
|
25694
|
-
|
|
25695
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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$
|
|
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
|
|
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 {
|
|
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-
|
|
33697
|
+
//# sourceMappingURL=peer-mcp-personas-BDet588n.js.map
|