github-router 0.3.250 → 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/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 -12904
- 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-BvommiSI.js → peer-mcp-personas-BDet588n.js} +163 -2392
- 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-dOYoMQis.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-BvommiSI.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;
|
|
@@ -16605,7 +15252,7 @@ function metaPath(workspace) {
|
|
|
16605
15252
|
/** Read the sidecar metadata for a workspace (null if none yet). */
|
|
16606
15253
|
async function readColbertMeta(workspace) {
|
|
16607
15254
|
try {
|
|
16608
|
-
const raw = await fs.readFile(metaPath(workspace), "utf8");
|
|
15255
|
+
const raw = await fs$1.readFile(metaPath(workspace), "utf8");
|
|
16609
15256
|
const parsed = JSON.parse(raw);
|
|
16610
15257
|
if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed;
|
|
16611
15258
|
return null;
|
|
@@ -16630,14 +15277,14 @@ async function writeColbertMeta(meta) {
|
|
|
16630
15277
|
return next;
|
|
16631
15278
|
}
|
|
16632
15279
|
async function writeColbertMetaUnchained(meta) {
|
|
16633
|
-
await fs.mkdir(PATHS.COLBERT_META_DIR, { recursive: true });
|
|
15280
|
+
await fs$1.mkdir(PATHS.COLBERT_META_DIR, { recursive: true });
|
|
16634
15281
|
const dest = metaPath(meta.workspace);
|
|
16635
15282
|
const tmp = `${dest}.${process$1.pid}.${Math.random().toString(16).slice(2, 10)}.tmp`;
|
|
16636
15283
|
try {
|
|
16637
|
-
await fs.writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
16638
|
-
await fs.rename(tmp, dest);
|
|
15284
|
+
await fs$1.writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
15285
|
+
await fs$1.rename(tmp, dest);
|
|
16639
15286
|
} catch (err) {
|
|
16640
|
-
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
15287
|
+
await fs$1.rm(tmp, { force: true }).catch(() => {});
|
|
16641
15288
|
throw err;
|
|
16642
15289
|
}
|
|
16643
15290
|
}
|
|
@@ -16653,7 +15300,7 @@ async function colbertProjectDir(workspace) {
|
|
|
16653
15300
|
const indicesDir = PATHS.COLBERT_INDICES_DIR;
|
|
16654
15301
|
let names;
|
|
16655
15302
|
try {
|
|
16656
|
-
names = await fs.readdir(indicesDir);
|
|
15303
|
+
names = await fs$1.readdir(indicesDir);
|
|
16657
15304
|
} catch {
|
|
16658
15305
|
return null;
|
|
16659
15306
|
}
|
|
@@ -16663,7 +15310,7 @@ async function colbertProjectDir(workspace) {
|
|
|
16663
15310
|
const dir = path.join(indicesDir, name);
|
|
16664
15311
|
let proj;
|
|
16665
15312
|
try {
|
|
16666
|
-
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"));
|
|
16667
15314
|
} catch {
|
|
16668
15315
|
continue;
|
|
16669
15316
|
}
|
|
@@ -16814,7 +15461,7 @@ function indexDirSignature(workspace) {
|
|
|
16814
15461
|
*/
|
|
16815
15462
|
async function realpathForCompare(p) {
|
|
16816
15463
|
try {
|
|
16817
|
-
return canonicalForCompare(await fs.realpath(p));
|
|
15464
|
+
return canonicalForCompare(await fs$1.realpath(p));
|
|
16818
15465
|
} catch {
|
|
16819
15466
|
return canonicalForCompare(p);
|
|
16820
15467
|
}
|
|
@@ -17129,12 +15776,12 @@ async function runSemanticSearch(opts) {
|
|
|
17129
15776
|
async function quarantineProjectDir(projectDir) {
|
|
17130
15777
|
const quarantine = `${projectDir}.corrupt-${process$1.pid}-${randomBytes(4).toString("hex")}`;
|
|
17131
15778
|
try {
|
|
17132
|
-
await fs.rename(projectDir, quarantine);
|
|
15779
|
+
await fs$1.rename(projectDir, quarantine);
|
|
17133
15780
|
} catch (err) {
|
|
17134
15781
|
consola.debug("colbert: corrupt index quarantine rename failed:", err);
|
|
17135
15782
|
return false;
|
|
17136
15783
|
}
|
|
17137
|
-
const removal = fs.rm(quarantine, {
|
|
15784
|
+
const removal = fs$1.rm(quarantine, {
|
|
17138
15785
|
recursive: true,
|
|
17139
15786
|
force: true
|
|
17140
15787
|
}).catch((err) => {
|
|
@@ -18177,6 +16824,18 @@ function appBrowserMcpDir() {
|
|
|
18177
16824
|
return dir;
|
|
18178
16825
|
}
|
|
18179
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
|
+
/**
|
|
18180
16839
|
* Pick a runtime interpreter for the bridge. The bridge uses Node's
|
|
18181
16840
|
* binary-stdin framing for native messaging which Bun handles
|
|
18182
16841
|
* differently (Bun closes the bridge prematurely as soon as anything
|
|
@@ -18185,6 +16844,7 @@ function appBrowserMcpDir() {
|
|
|
18185
16844
|
* binary) only if node isn't on PATH.
|
|
18186
16845
|
*/
|
|
18187
16846
|
function resolveBridgeInterpreter() {
|
|
16847
|
+
if (cachedBridgeInterpreter !== void 0) return cachedBridgeInterpreter;
|
|
18188
16848
|
const probeCmd = platform() === "win32" ? "where" : "which";
|
|
18189
16849
|
try {
|
|
18190
16850
|
const out = execFileSync(probeCmd, ["node"], {
|
|
@@ -18196,7 +16856,10 @@ function resolveBridgeInterpreter() {
|
|
|
18196
16856
|
timeout: 2e3,
|
|
18197
16857
|
windowsHide: true
|
|
18198
16858
|
}).toString().trim().split(/\r?\n/)[0];
|
|
18199
|
-
if (out)
|
|
16859
|
+
if (out) {
|
|
16860
|
+
cachedBridgeInterpreter = out;
|
|
16861
|
+
return out;
|
|
16862
|
+
}
|
|
18200
16863
|
} catch {}
|
|
18201
16864
|
return process$1.execPath;
|
|
18202
16865
|
}
|
|
@@ -18484,8 +17147,7 @@ async function probeHealth(port, token, timeoutMs = 500) {
|
|
|
18484
17147
|
}
|
|
18485
17148
|
function bridgeBundleExists() {
|
|
18486
17149
|
try {
|
|
18487
|
-
|
|
18488
|
-
return true;
|
|
17150
|
+
return statSync(bridgeBundlePath()).isFile();
|
|
18489
17151
|
} catch {
|
|
18490
17152
|
return false;
|
|
18491
17153
|
}
|
|
@@ -18610,9 +17272,41 @@ let _inFlightReady;
|
|
|
18610
17272
|
* Always 0 in production (only incremented when imported by tests).
|
|
18611
17273
|
*/
|
|
18612
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;
|
|
18613
17298
|
async function ensureBridgeReady() {
|
|
17299
|
+
const cached = _readyCache;
|
|
17300
|
+
if (cached && Date.now() - cached.at < READY_CACHE_TTL_MS) return cached.value;
|
|
18614
17301
|
if (_inFlightReady) return _inFlightReady;
|
|
18615
|
-
_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(() => {
|
|
18616
17310
|
_inFlightReady = void 0;
|
|
18617
17311
|
});
|
|
18618
17312
|
return _inFlightReady;
|
|
@@ -19239,7 +17933,7 @@ function logAudit$1(record) {
|
|
|
19239
17933
|
try {
|
|
19240
17934
|
const fs = await import("node:fs/promises");
|
|
19241
17935
|
const path = await import("node:path");
|
|
19242
|
-
const { PATHS } = await import("./paths-
|
|
17936
|
+
const { PATHS } = await import("./paths-CwKwc2pi.js");
|
|
19243
17937
|
const dir = path.join(PATHS.APP_DIR, "browser-mcp");
|
|
19244
17938
|
await fs.mkdir(dir, { recursive: true });
|
|
19245
17939
|
const line = JSON.stringify({
|
|
@@ -20137,11 +18831,12 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal, retryT
|
|
|
20137
18831
|
}
|
|
20138
18832
|
const claudeModels = state.models?.data.filter((m) => m.id.startsWith("claude")).map((m) => m.id).join(", ") ?? "(models not loaded)";
|
|
20139
18833
|
consola.error(`Copilot rejected model "${payload.model}": ${response.status} ${errorBody} (available Claude models: ${claudeModels})`);
|
|
20140
|
-
|
|
18834
|
+
const reconstructed = new Response(errorBody, {
|
|
20141
18835
|
status: response.status,
|
|
20142
18836
|
statusText: response.statusText,
|
|
20143
18837
|
headers: response.headers
|
|
20144
|
-
})
|
|
18838
|
+
});
|
|
18839
|
+
throw new HTTPError("Failed to create chat completions", reconstructed);
|
|
20145
18840
|
}
|
|
20146
18841
|
if (payload.stream) return events(response);
|
|
20147
18842
|
const cappedResult = await readResponseBodyCapped(response, "/v1/chat/completions", MAX_RESPONSE_BODY_BYTES);
|
|
@@ -26670,11 +25365,12 @@ async function createMessages(body, extraHeaders, callerSignal, retryTransient =
|
|
|
26670
25365
|
errorBody = "(could not read error body)";
|
|
26671
25366
|
}
|
|
26672
25367
|
consola.error(`Copilot /v1/messages error: ${response.status} ${errorBody}`);
|
|
26673
|
-
|
|
25368
|
+
const reconstructed = new Response(errorBody, {
|
|
26674
25369
|
status: response.status,
|
|
26675
25370
|
statusText: response.statusText,
|
|
26676
25371
|
headers: response.headers
|
|
26677
|
-
})
|
|
25372
|
+
});
|
|
25373
|
+
throw new HTTPError("Copilot messages request failed", reconstructed);
|
|
26678
25374
|
}
|
|
26679
25375
|
return response;
|
|
26680
25376
|
}
|
|
@@ -26717,11 +25413,12 @@ async function countTokens(body, extraHeaders, callerSignal, retryTransient = fa
|
|
|
26717
25413
|
errorBody = "(could not read error body)";
|
|
26718
25414
|
}
|
|
26719
25415
|
consola.error(`Copilot count_tokens error: ${response.status} ${errorBody}`);
|
|
26720
|
-
|
|
25416
|
+
const reconstructed = new Response(errorBody, {
|
|
26721
25417
|
status: response.status,
|
|
26722
25418
|
statusText: response.statusText,
|
|
26723
25419
|
headers: response.headers
|
|
26724
|
-
})
|
|
25420
|
+
});
|
|
25421
|
+
throw new HTTPError("Copilot count_tokens request failed", reconstructed);
|
|
26725
25422
|
}
|
|
26726
25423
|
return response;
|
|
26727
25424
|
}
|
|
@@ -28894,7 +27591,7 @@ async function throttleSearch() {
|
|
|
28894
27591
|
const waitMs = 1e3 - (now - searchTimestamps[0]);
|
|
28895
27592
|
if (waitMs > 0) {
|
|
28896
27593
|
consola.debug(`Web search rate limited, waiting ${waitMs}ms`);
|
|
28897
|
-
await sleep$
|
|
27594
|
+
await sleep$2(waitMs);
|
|
28898
27595
|
}
|
|
28899
27596
|
}
|
|
28900
27597
|
searchTimestamps.push(Date.now());
|
|
@@ -30900,7 +29597,7 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30900
29597
|
}
|
|
30901
29598
|
let entries;
|
|
30902
29599
|
try {
|
|
30903
|
-
entries = await fs.readdir(PATHS.WORKER_DIFFS_DIR);
|
|
29600
|
+
entries = await fs$1.readdir(PATHS.WORKER_DIFFS_DIR);
|
|
30904
29601
|
} catch {
|
|
30905
29602
|
return;
|
|
30906
29603
|
}
|
|
@@ -30909,8 +29606,8 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30909
29606
|
if (!WORKER_DIFF_NAME_RE.test(name)) continue;
|
|
30910
29607
|
const full = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
30911
29608
|
try {
|
|
30912
|
-
if (now - (await fs.stat(full)).mtimeMs < AGE_SWEEP_MS$1) continue;
|
|
30913
|
-
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(() => {});
|
|
30914
29611
|
} catch {}
|
|
30915
29612
|
}
|
|
30916
29613
|
}
|
|
@@ -30923,13 +29620,13 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30923
29620
|
*/
|
|
30924
29621
|
async function saveOverflowResult(text) {
|
|
30925
29622
|
await sweepAgedWorkerDiffs({ throttle: true });
|
|
30926
|
-
await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
29623
|
+
await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
30927
29624
|
let lastErr;
|
|
30928
29625
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
30929
29626
|
const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.txt`;
|
|
30930
29627
|
const outPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
30931
29628
|
try {
|
|
30932
|
-
await fs.writeFile(outPath, text, {
|
|
29629
|
+
await fs$1.writeFile(outPath, text, {
|
|
30933
29630
|
mode: 384,
|
|
30934
29631
|
flag: "wx"
|
|
30935
29632
|
});
|
|
@@ -31123,7 +29820,7 @@ async function findRepoRoot(workspaceAbs) {
|
|
|
31123
29820
|
async function sweepAgedWorktrees(parent) {
|
|
31124
29821
|
let entries;
|
|
31125
29822
|
try {
|
|
31126
|
-
entries = await fs.readdir(parent);
|
|
29823
|
+
entries = await fs$1.readdir(parent);
|
|
31127
29824
|
} catch (err) {
|
|
31128
29825
|
if (err.code === "ENOENT") return;
|
|
31129
29826
|
return;
|
|
@@ -31133,7 +29830,7 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31133
29830
|
if (!WORKTREE_DIR_NAME_RE.test(name)) continue;
|
|
31134
29831
|
const full = path.join(parent, name);
|
|
31135
29832
|
try {
|
|
31136
|
-
const ageMs = now - (await fs.stat(full)).mtimeMs;
|
|
29833
|
+
const ageMs = now - (await fs$1.stat(full)).mtimeMs;
|
|
31137
29834
|
if (ageMs < AGE_SWEEP_MTIME_FLOOR_MS) continue;
|
|
31138
29835
|
if (ageMs < AGE_SWEEP_MS) continue;
|
|
31139
29836
|
await execFileP("git", [
|
|
@@ -31142,7 +29839,7 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31142
29839
|
"--force",
|
|
31143
29840
|
full
|
|
31144
29841
|
], { timeout: 1e4 }).catch(() => {});
|
|
31145
|
-
await fs.rm(full, {
|
|
29842
|
+
await fs$1.rm(full, {
|
|
31146
29843
|
recursive: true,
|
|
31147
29844
|
force: true
|
|
31148
29845
|
}).catch(() => {});
|
|
@@ -31161,11 +29858,11 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31161
29858
|
async function createWorktree(workspaceAbs, opts) {
|
|
31162
29859
|
const { repoRoot, gitCommonDir } = await findRepoRoot(workspaceAbs);
|
|
31163
29860
|
const parent = path.join(gitCommonDir, "worker-worktrees");
|
|
31164
|
-
await fs.mkdir(parent, { recursive: true });
|
|
29861
|
+
await fs$1.mkdir(parent, { recursive: true });
|
|
31165
29862
|
await sweepAgedWorktrees(parent);
|
|
31166
29863
|
let existing = [];
|
|
31167
29864
|
try {
|
|
31168
|
-
existing = await fs.readdir(parent);
|
|
29865
|
+
existing = await fs$1.readdir(parent);
|
|
31169
29866
|
} catch {}
|
|
31170
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`);
|
|
31171
29868
|
const suffix = randomBytes(4).toString("hex");
|
|
@@ -31213,9 +29910,9 @@ async function createWorktree(workspaceAbs, opts) {
|
|
|
31213
29910
|
for (const rel of files) {
|
|
31214
29911
|
const src = path.join(repoRoot, rel);
|
|
31215
29912
|
const dst = path.join(dir, rel);
|
|
31216
|
-
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
29913
|
+
await fs$1.mkdir(path.dirname(dst), { recursive: true });
|
|
31217
29914
|
try {
|
|
31218
|
-
await fs.copyFile(src, dst);
|
|
29915
|
+
await fs$1.copyFile(src, dst);
|
|
31219
29916
|
} catch (err) {
|
|
31220
29917
|
if (err.code === "ENOENT") continue;
|
|
31221
29918
|
throw err;
|
|
@@ -31334,13 +30031,13 @@ async function saveOverflowPatch(dir) {
|
|
|
31334
30031
|
"HEAD"
|
|
31335
30032
|
], { maxBuffer: 268435456 });
|
|
31336
30033
|
await sweepAgedWorkerDiffs({ throttle: true });
|
|
31337
|
-
await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
30034
|
+
await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
31338
30035
|
let lastErr;
|
|
31339
30036
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
31340
30037
|
const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.patch`;
|
|
31341
30038
|
const patchPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
31342
30039
|
try {
|
|
31343
|
-
await fs.writeFile(patchPath, patch.stdout, {
|
|
30040
|
+
await fs$1.writeFile(patchPath, patch.stdout, {
|
|
31344
30041
|
mode: 384,
|
|
31345
30042
|
flag: "wx"
|
|
31346
30043
|
});
|
|
@@ -32404,93 +31101,6 @@ function selectChampion(orchestrated, baseline, canonicalGateIds, tiePolicy) {
|
|
|
32404
31101
|
};
|
|
32405
31102
|
}
|
|
32406
31103
|
//#endregion
|
|
32407
|
-
//#region src/lib/orchestration/gate-runner.ts
|
|
32408
|
-
async function runGateChecks(checks, cwd, exec) {
|
|
32409
|
-
const results = await Promise.all(checks.map(async (c) => {
|
|
32410
|
-
try {
|
|
32411
|
-
const r = await exec({
|
|
32412
|
-
command: c.command,
|
|
32413
|
-
cwd
|
|
32414
|
-
});
|
|
32415
|
-
return {
|
|
32416
|
-
id: c.id,
|
|
32417
|
-
passed: r.exitCode === 0
|
|
32418
|
-
};
|
|
32419
|
-
} catch {
|
|
32420
|
-
return {
|
|
32421
|
-
id: c.id,
|
|
32422
|
-
passed: false
|
|
32423
|
-
};
|
|
32424
|
-
}
|
|
32425
|
-
}));
|
|
32426
|
-
const passed = /* @__PURE__ */ new Set();
|
|
32427
|
-
const ran = /* @__PURE__ */ new Set();
|
|
32428
|
-
for (const r of results) {
|
|
32429
|
-
ran.add(r.id);
|
|
32430
|
-
if (r.passed) passed.add(r.id);
|
|
32431
|
-
}
|
|
32432
|
-
return {
|
|
32433
|
-
passed,
|
|
32434
|
-
ran
|
|
32435
|
-
};
|
|
32436
|
-
}
|
|
32437
|
-
//#endregion
|
|
32438
|
-
//#region src/lib/orchestration/gate-registry.ts
|
|
32439
|
-
/**
|
|
32440
|
-
* Built-in sealed gates. Commands follow this repo's TS/Bun conventions (the
|
|
32441
|
-
* `bun run <script>` indirection means a repo without that script simply fails
|
|
32442
|
-
* the check, which the selector treats as not-passed rather than a crash). New
|
|
32443
|
-
* ecosystems get a new sealed id here, never a caller-supplied command.
|
|
32444
|
-
*/
|
|
32445
|
-
const SEALED_GATES = {
|
|
32446
|
-
"default-ci": [
|
|
32447
|
-
{
|
|
32448
|
-
id: "typecheck",
|
|
32449
|
-
command: "bun run typecheck"
|
|
32450
|
-
},
|
|
32451
|
-
{
|
|
32452
|
-
id: "test",
|
|
32453
|
-
command: "bun test"
|
|
32454
|
-
},
|
|
32455
|
-
{
|
|
32456
|
-
id: "lint",
|
|
32457
|
-
command: "bun run lint"
|
|
32458
|
-
}
|
|
32459
|
-
],
|
|
32460
|
-
"typecheck-test": [{
|
|
32461
|
-
id: "typecheck",
|
|
32462
|
-
command: "bun run typecheck"
|
|
32463
|
-
}, {
|
|
32464
|
-
id: "test",
|
|
32465
|
-
command: "bun test"
|
|
32466
|
-
}],
|
|
32467
|
-
"typecheck-only": [{
|
|
32468
|
-
id: "typecheck",
|
|
32469
|
-
command: "bun run typecheck"
|
|
32470
|
-
}]
|
|
32471
|
-
};
|
|
32472
|
-
/** The set of sealed gate ids, used as the kernel's `knownGateIds` so the IR
|
|
32473
|
-
* verifier rejects an executable gate that references an unregistered id. */
|
|
32474
|
-
function sealedGateIds() {
|
|
32475
|
-
return new Set(Object.keys(SEALED_GATES));
|
|
32476
|
-
}
|
|
32477
|
-
/**
|
|
32478
|
-
* Resolve a sealed gate by id. Returns a DEFENSIVE CLONE (fresh objects) so a
|
|
32479
|
-
* caller can never mutate the registry's command set. `undefined` for an
|
|
32480
|
-
* unknown id, which `run_workflow` rejects before executing anything.
|
|
32481
|
-
*/
|
|
32482
|
-
function resolveSealedGate(gateId) {
|
|
32483
|
-
const checks = SEALED_GATES[gateId];
|
|
32484
|
-
if (!checks) return void 0;
|
|
32485
|
-
return {
|
|
32486
|
-
id: gateId,
|
|
32487
|
-
checks: checks.map((c) => ({
|
|
32488
|
-
id: c.id,
|
|
32489
|
-
command: c.command
|
|
32490
|
-
}))
|
|
32491
|
-
};
|
|
32492
|
-
}
|
|
32493
|
-
//#endregion
|
|
32494
31104
|
//#region src/lib/orchestration/verify.ts
|
|
32495
31105
|
/**
|
|
32496
31106
|
* Static verifier for the workflow IR. This is what `decompose`'s output is
|
|
@@ -32982,186 +31592,6 @@ function makeRunner(deps, ctx) {
|
|
|
32982
31592
|
} };
|
|
32983
31593
|
}
|
|
32984
31594
|
//#endregion
|
|
32985
|
-
//#region src/lib/orchestration/gate-immutability.ts
|
|
32986
|
-
/** Patterns that weaken a gate regardless of language (test exclusivity / focus
|
|
32987
|
-
* idioms shared across JS test runners; kept in COMMON since several languages
|
|
32988
|
-
* reuse them). */
|
|
32989
|
-
const COMMON_PATTERNS = [{
|
|
32990
|
-
name: "skipped-test",
|
|
32991
|
-
re: /(\.\s*skip\s*\(|\bxit\s*\(|\bxdescribe\s*\(|\.\s*only\s*\()/
|
|
32992
|
-
}];
|
|
32993
|
-
/** Language-specific weakening patterns, selected by the added line's file type. */
|
|
32994
|
-
const LANG_PATTERNS = {
|
|
32995
|
-
ts: [
|
|
32996
|
-
{
|
|
32997
|
-
name: "ts-suppression",
|
|
32998
|
-
re: /@ts-(ignore|nocheck|expect-error)\b/
|
|
32999
|
-
},
|
|
33000
|
-
{
|
|
33001
|
-
name: "any-cast",
|
|
33002
|
-
re: /\bas\s+any\b|:\s*any\b/
|
|
33003
|
-
},
|
|
33004
|
-
{
|
|
33005
|
-
name: "eslint-disable",
|
|
33006
|
-
re: /eslint-disable\b/
|
|
33007
|
-
}
|
|
33008
|
-
],
|
|
33009
|
-
py: [
|
|
33010
|
-
{
|
|
33011
|
-
name: "py-type-ignore",
|
|
33012
|
-
re: /#\s*type:\s*ignore\b/
|
|
33013
|
-
},
|
|
33014
|
-
{
|
|
33015
|
-
name: "py-noqa",
|
|
33016
|
-
re: /#\s*noqa\b/
|
|
33017
|
-
},
|
|
33018
|
-
{
|
|
33019
|
-
name: "py-skip",
|
|
33020
|
-
re: /@(pytest\.mark\.skip|unittest\.skip)\b/
|
|
33021
|
-
}
|
|
33022
|
-
],
|
|
33023
|
-
go: [{
|
|
33024
|
-
name: "go-skip",
|
|
33025
|
-
re: /\bt\.Skip\s*\(/
|
|
33026
|
-
}, {
|
|
33027
|
-
name: "go-nolint",
|
|
33028
|
-
re: /\/\/\s*nolint\b/
|
|
33029
|
-
}],
|
|
33030
|
-
rust: [{
|
|
33031
|
-
name: "rust-ignore",
|
|
33032
|
-
re: /#\[\s*ignore\b/
|
|
33033
|
-
}, {
|
|
33034
|
-
name: "rust-allow",
|
|
33035
|
-
re: /#\[\s*allow\s*\(/
|
|
33036
|
-
}]
|
|
33037
|
-
};
|
|
33038
|
-
/** Map a file path to a language key for `LANG_PATTERNS` (null → COMMON only). */
|
|
33039
|
-
function langForFile(file) {
|
|
33040
|
-
if (!file) return null;
|
|
33041
|
-
switch (file.slice(file.lastIndexOf(".")).toLowerCase()) {
|
|
33042
|
-
case ".ts":
|
|
33043
|
-
case ".tsx":
|
|
33044
|
-
case ".mts":
|
|
33045
|
-
case ".cts":
|
|
33046
|
-
case ".js":
|
|
33047
|
-
case ".jsx":
|
|
33048
|
-
case ".mjs":
|
|
33049
|
-
case ".cjs": return "ts";
|
|
33050
|
-
case ".py":
|
|
33051
|
-
case ".pyi": return "py";
|
|
33052
|
-
case ".go": return "go";
|
|
33053
|
-
case ".rs": return "rust";
|
|
33054
|
-
default: return null;
|
|
33055
|
-
}
|
|
33056
|
-
}
|
|
33057
|
-
/** Union of COMMON + every language — the pre-header default (no file known yet),
|
|
33058
|
-
* so a header-less diff is matched permissively, exactly as before this became
|
|
33059
|
-
* language-scoped. Once a real file header appears, the set narrows to that
|
|
33060
|
-
* file's language (or COMMON only for an unknown extension → fail open). */
|
|
33061
|
-
const ALL_PATTERNS = [...COMMON_PATTERNS, ...Object.values(LANG_PATTERNS).flat()];
|
|
33062
|
-
/** A `diff --git a/x b/x` or `+++ b/x` header → the current file path. */
|
|
33063
|
-
function fileFromHeader(line) {
|
|
33064
|
-
const git = /^diff --git a\/.+ b\/(.+)$/.exec(line);
|
|
33065
|
-
if (git) return git[1];
|
|
33066
|
-
const plus = /^\+\+\+ b\/(.+)$/.exec(line);
|
|
33067
|
-
if (plus) return plus[1];
|
|
33068
|
-
}
|
|
33069
|
-
function detectGateWeakening(diff) {
|
|
33070
|
-
const findings = [];
|
|
33071
|
-
let file;
|
|
33072
|
-
let patterns = ALL_PATTERNS;
|
|
33073
|
-
for (const raw of diff.split("\n")) {
|
|
33074
|
-
const headerFile = fileFromHeader(raw);
|
|
33075
|
-
if (headerFile !== void 0) {
|
|
33076
|
-
file = headerFile;
|
|
33077
|
-
const lang = langForFile(file);
|
|
33078
|
-
patterns = lang ? [...COMMON_PATTERNS, ...LANG_PATTERNS[lang]] : COMMON_PATTERNS;
|
|
33079
|
-
continue;
|
|
33080
|
-
}
|
|
33081
|
-
if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
|
|
33082
|
-
const added = raw.slice(1);
|
|
33083
|
-
for (const p of patterns) if (p.re.test(added)) findings.push(file === void 0 ? {
|
|
33084
|
-
pattern: p.name,
|
|
33085
|
-
line: added.trim()
|
|
33086
|
-
} : {
|
|
33087
|
-
pattern: p.name,
|
|
33088
|
-
line: added.trim(),
|
|
33089
|
-
file
|
|
33090
|
-
});
|
|
33091
|
-
}
|
|
33092
|
-
return {
|
|
33093
|
-
weakened: findings.length > 0,
|
|
33094
|
-
findings
|
|
33095
|
-
};
|
|
33096
|
-
}
|
|
33097
|
-
//#endregion
|
|
33098
|
-
//#region src/lib/orchestration/stop-gate.ts
|
|
33099
|
-
/**
|
|
33100
|
-
* The Phase-0 structural-gate decision — the cheap, CERTAIN floor win. It blocks
|
|
33101
|
-
* marking work "done" when any canonical gate fails OR the diff weakens a gate.
|
|
33102
|
-
* A non-model check that fires every run and never ships broken or gate-gamed
|
|
33103
|
-
* code; per the floor analysis it is the only component that raises the
|
|
33104
|
-
* worst-case floor BY CONSTRUCTION (the orchestration on top is a conditional
|
|
33105
|
-
* bet).
|
|
33106
|
-
*
|
|
33107
|
-
* This is the logic behind the spawned-session Stop-hook AND the kernel runner's
|
|
33108
|
-
* pre-accept check. The process exec and the diff are inputs, so the decision is
|
|
33109
|
-
* pure + unit-testable; the hook script that runs `git diff` + invokes this is a
|
|
33110
|
-
* thin wrapper.
|
|
33111
|
-
*/
|
|
33112
|
-
async function evaluateStopGate(input) {
|
|
33113
|
-
const gate = await runGateChecks(input.checks, input.cwd, input.exec);
|
|
33114
|
-
const weak = detectGateWeakening(input.diff);
|
|
33115
|
-
const failedChecks = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
|
|
33116
|
-
const block = failedChecks.length > 0 || weak.weakened;
|
|
33117
|
-
const parts = [];
|
|
33118
|
-
if (failedChecks.length > 0) parts.push(`failing gates: ${failedChecks.join(", ")}`);
|
|
33119
|
-
if (weak.weakened) {
|
|
33120
|
-
const pats = [...new Set(weak.findings.map((f) => f.pattern))].join(", ");
|
|
33121
|
-
parts.push(`gate-weakening in the diff: ${pats}`);
|
|
33122
|
-
}
|
|
33123
|
-
return {
|
|
33124
|
-
block,
|
|
33125
|
-
reason: block ? parts.join("; ") : "all canonical gates pass; no gate-weakening in the diff",
|
|
33126
|
-
failedChecks,
|
|
33127
|
-
weakening: weak.findings
|
|
33128
|
-
};
|
|
33129
|
-
}
|
|
33130
|
-
//#endregion
|
|
33131
|
-
//#region src/lib/orchestration/live-exec.ts
|
|
33132
|
-
/**
|
|
33133
|
-
* Live `ExecFn` for the gate runner — runs the kernel's SEALED gate command in a
|
|
33134
|
-
* workspace via the project's Windows-safe exec helper (`runCommandCapture` →
|
|
33135
|
-
* `buildExecInvocation`, which handles cmd.exe quoting on Windows). Only the
|
|
33136
|
-
* kernel's sealed gate commands flow through here (tests/types/lint/build),
|
|
33137
|
-
* never a producer-authored string, so a simple whitespace split into argv is
|
|
33138
|
-
* safe for these fixed commands. A command that can't run (spawn error / killed)
|
|
33139
|
-
* counts as a non-zero exit, never a throw — the gate runner treats it as
|
|
33140
|
-
* not-passed.
|
|
33141
|
-
*
|
|
33142
|
-
* This is the one piece of the gate engine that touches a real subprocess; it is
|
|
33143
|
-
* model-free and cross-platform, so it is exercised directly in CI (unlike the
|
|
33144
|
-
* worker/worktree/model adapters, which need the gated E2E harness).
|
|
33145
|
-
*/
|
|
33146
|
-
/** Per-command wall-clock cap so a hung gate command (watch-mode test, a process
|
|
33147
|
-
* waiting on stdin, a stale lockfile) is tree-killed instead of hanging the
|
|
33148
|
-
* caller forever. Generous (a real typecheck/test/lint can take minutes) but
|
|
33149
|
-
* bounded; override with GH_ROUTER_GATE_CMD_TIMEOUT_MS. A timeout kills the
|
|
33150
|
-
* command (code null) which the gate runner treats as not-passed. */
|
|
33151
|
-
const CMD_TIMEOUT_MS = parseIntEnv(process.env.GH_ROUTER_GATE_CMD_TIMEOUT_MS) ?? 6e5;
|
|
33152
|
-
const liveExec = async ({ command, cwd }) => {
|
|
33153
|
-
const argv = command.trim().split(/\s+/).filter(Boolean);
|
|
33154
|
-
if (argv.length === 0) return { exitCode: 1 };
|
|
33155
|
-
try {
|
|
33156
|
-
return { exitCode: (await runCommandCapture(argv, {
|
|
33157
|
-
cwd,
|
|
33158
|
-
timeoutMs: CMD_TIMEOUT_MS
|
|
33159
|
-
})).code ?? 1 };
|
|
33160
|
-
} catch {
|
|
33161
|
-
return { exitCode: 1 };
|
|
33162
|
-
}
|
|
33163
|
-
};
|
|
33164
|
-
//#endregion
|
|
33165
31595
|
//#region src/lib/orchestration/runner-live.ts
|
|
33166
31596
|
/**
|
|
33167
31597
|
* The LIVE adapter for the kernel's NodeRunner primitives. buildLiveRunner wires
|
|
@@ -33264,665 +31694,6 @@ function buildLiveRunner(ctx, prim) {
|
|
|
33264
31694
|
};
|
|
33265
31695
|
}
|
|
33266
31696
|
//#endregion
|
|
33267
|
-
//#region src/lib/orchestration/stop-gate-policy.ts
|
|
33268
|
-
/**
|
|
33269
|
-
* Pure + thin-IO policy helpers for the consent-gated, top-level-only structural
|
|
33270
|
-
* Stop-gate. Split out of `stop-gate-hook.ts` so the security-critical decisions
|
|
33271
|
-
* (is this a subagent? is this repo trusted? which checks regressed?) are small,
|
|
33272
|
-
* named, and unit-testable.
|
|
33273
|
-
*
|
|
33274
|
-
* Verified against the official Claude Code hooks reference
|
|
33275
|
-
* (https://code.claude.com/docs/en/hooks), 2026-06:
|
|
33276
|
-
* - `Stop` fires for the MAIN agent; subagents fire `SubagentStop`, and a
|
|
33277
|
-
* `Stop`-registered hook is converted to `SubagentStop` in a subagent
|
|
33278
|
-
* context — so the hook command CAN run for a subagent. The payload carries
|
|
33279
|
-
* `agent_id` / `agent_type` ONLY inside a subagent/teammate context, so that
|
|
33280
|
-
* is the reliable top-level-vs-sub discriminator (NOT an env var, which the
|
|
33281
|
-
* proxy cannot set on Claude-spawned subagents).
|
|
33282
|
-
* - `UserPromptSubmit` fires only for human prompts to the main agent.
|
|
33283
|
-
* - `stop_hook_active` is NOT in the current documented payload, so the
|
|
33284
|
-
* per-prompt block budget — not that flag — must be the termination guard.
|
|
33285
|
-
*/
|
|
33286
|
-
/**
|
|
33287
|
-
* True when the hook is firing inside a subagent / teammate context (NOT the
|
|
33288
|
-
* top-level user session). Claude Code adds `agent_id` + `agent_type` to the
|
|
33289
|
-
* payload only there, so their presence is the discriminator. The Stop-gate and
|
|
33290
|
-
* the prompt-steer hook both stand down when this is true, scoping them to the
|
|
33291
|
-
* top-level session.
|
|
33292
|
-
*/
|
|
33293
|
-
function isSubagentContext(payload) {
|
|
33294
|
-
const present = (v) => v !== void 0 && v !== null;
|
|
33295
|
-
return present(payload?.agent_type) || present(payload?.agent_id);
|
|
33296
|
-
}
|
|
33297
|
-
/** Stable trust dir (NOT the per-launch mirror — trust must persist). */
|
|
33298
|
-
function trustDir() {
|
|
33299
|
-
return path.join(PATHS.APP_DIR, "stop-gate", "trust");
|
|
33300
|
-
}
|
|
33301
|
-
/** Resolve the git repo root for `cwd`, falling back to `cwd` when not a repo. */
|
|
33302
|
-
async function repoRoot(cwd) {
|
|
33303
|
-
const top = (await runCommandCapture([
|
|
33304
|
-
"git",
|
|
33305
|
-
"rev-parse",
|
|
33306
|
-
"--show-toplevel"
|
|
33307
|
-
], {
|
|
33308
|
-
cwd,
|
|
33309
|
-
timeoutMs: 5e3
|
|
33310
|
-
}).catch(() => void 0))?.stdout?.trim();
|
|
33311
|
-
return top && top.length > 0 ? top : cwd;
|
|
33312
|
-
}
|
|
33313
|
-
function trustFileFor(root) {
|
|
33314
|
-
const key = createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 32);
|
|
33315
|
-
return path.join(trustDir(), key);
|
|
33316
|
-
}
|
|
33317
|
-
/**
|
|
33318
|
-
* A stable identity for the repo at `root`: the first (root) commit SHA. It
|
|
33319
|
-
* survives normal history growth but differs across distinct repositories, so a
|
|
33320
|
-
* DIFFERENT repo later appearing at the same filesystem path is not silently
|
|
33321
|
-
* trusted (codex review #2). Empty string when unavailable (no git / no commits)
|
|
33322
|
-
* — trust then falls back to path-only, the best we can do.
|
|
33323
|
-
*/
|
|
33324
|
-
async function repoFingerprint(root) {
|
|
33325
|
-
return (await runCommandCapture([
|
|
33326
|
-
"git",
|
|
33327
|
-
"rev-list",
|
|
33328
|
-
"--max-parents=0",
|
|
33329
|
-
"HEAD"
|
|
33330
|
-
], {
|
|
33331
|
-
cwd: root,
|
|
33332
|
-
timeoutMs: 5e3
|
|
33333
|
-
}).catch(() => void 0))?.stdout?.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? "";
|
|
33334
|
-
}
|
|
33335
|
-
/**
|
|
33336
|
-
* True iff the user has consented to run the gate in this repo AND the repo's
|
|
33337
|
-
* identity still matches what was trusted. The trust file stores `root\nfp\n`;
|
|
33338
|
-
* a present fingerprint is verified against the live one (deny on mismatch, and
|
|
33339
|
-
* deny if we pinned one but can't recompute it — fail closed). A legacy file
|
|
33340
|
-
* with no fingerprint is path-only trust.
|
|
33341
|
-
*/
|
|
33342
|
-
async function isRepoTrusted(cwd) {
|
|
33343
|
-
const root = await repoRoot(cwd);
|
|
33344
|
-
let stored;
|
|
33345
|
-
try {
|
|
33346
|
-
stored = await promises.readFile(trustFileFor(root), "utf8");
|
|
33347
|
-
} catch {
|
|
33348
|
-
return false;
|
|
33349
|
-
}
|
|
33350
|
-
const storedFp = (stored.split(/\r?\n/)[1] ?? "").trim();
|
|
33351
|
-
if (storedFp.length === 0) return true;
|
|
33352
|
-
const currentFp = await repoFingerprint(root);
|
|
33353
|
-
if (currentFp.length === 0) return false;
|
|
33354
|
-
return currentFp === storedFp;
|
|
33355
|
-
}
|
|
33356
|
-
/** Record consent for this repo (consent once → automatic thereafter), pinning
|
|
33357
|
-
* the repo's root-commit fingerprint so a later repo swap at the same path is
|
|
33358
|
-
* not auto-trusted. */
|
|
33359
|
-
async function trustRepo(cwd) {
|
|
33360
|
-
const root = await repoRoot(cwd);
|
|
33361
|
-
const fp = await repoFingerprint(root);
|
|
33362
|
-
await promises.mkdir(trustDir(), { recursive: true });
|
|
33363
|
-
await promises.writeFile(trustFileFor(root), `${root}\n${fp}\n`, { mode: 384 });
|
|
33364
|
-
return root;
|
|
33365
|
-
}
|
|
33366
|
-
/**
|
|
33367
|
-
* Repo-aware gate enable: `GH_ROUTER_DISABLE_STOP_GATE` force-off wins;
|
|
33368
|
-
* `GH_ROUTER_ENABLE_STOP_GATE` force-on next; otherwise default to OFF unless the
|
|
33369
|
-
* repo is trusted. This is the load-bearing security gate — the default is OFF,
|
|
33370
|
-
* so an untrusted repo's scripts are never auto-run.
|
|
33371
|
-
*/
|
|
33372
|
-
async function stopGateEnabledForRepo(cwd, env = process.env) {
|
|
33373
|
-
if (parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_GATE) === true) return false;
|
|
33374
|
-
if (parseBoolEnv(env.GH_ROUTER_ENABLE_STOP_GATE) === true) return true;
|
|
33375
|
-
return isRepoTrusted(cwd);
|
|
33376
|
-
}
|
|
33377
|
-
/**
|
|
33378
|
-
* Given the current failed checks and the recorded baseline, return the checks
|
|
33379
|
-
* that REGRESSED (failing now, not failing at baseline). A null baseline (first
|
|
33380
|
-
* eval) yields an empty regression set — nothing is blamed on the agent yet.
|
|
33381
|
-
*/
|
|
33382
|
-
function regressions(currentFailed, baseline) {
|
|
33383
|
-
if (baseline === null) return [];
|
|
33384
|
-
return currentFailed.filter((id) => !baseline.has(id));
|
|
33385
|
-
}
|
|
33386
|
-
/** File-backed `BaselineStore` under `stateDir`, keyed by sha256(session_id). */
|
|
33387
|
-
function fileBaselineStore(stateDir) {
|
|
33388
|
-
const fileFor = (sid) => path.join(stateDir, `baseline-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
33389
|
-
return {
|
|
33390
|
-
async get(sid) {
|
|
33391
|
-
try {
|
|
33392
|
-
const raw = await promises.readFile(fileFor(sid), "utf8");
|
|
33393
|
-
const arr = JSON.parse(raw);
|
|
33394
|
-
if (Array.isArray(arr)) return new Set(arr.filter((x) => typeof x === "string"));
|
|
33395
|
-
return /* @__PURE__ */ new Set();
|
|
33396
|
-
} catch {
|
|
33397
|
-
return null;
|
|
33398
|
-
}
|
|
33399
|
-
},
|
|
33400
|
-
async set(sid, failed) {
|
|
33401
|
-
await promises.mkdir(stateDir, { recursive: true });
|
|
33402
|
-
await promises.writeFile(fileFor(sid), JSON.stringify([...failed]), { mode: 384 });
|
|
33403
|
-
}
|
|
33404
|
-
};
|
|
33405
|
-
}
|
|
33406
|
-
function fileReviewDebounce(stateDir) {
|
|
33407
|
-
const fileFor = (sid) => path.join(stateDir, `review-hash-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
33408
|
-
const readLast = async (sid) => {
|
|
33409
|
-
try {
|
|
33410
|
-
return (await promises.readFile(fileFor(sid), "utf8")).trim();
|
|
33411
|
-
} catch {
|
|
33412
|
-
return "";
|
|
33413
|
-
}
|
|
33414
|
-
};
|
|
33415
|
-
return {
|
|
33416
|
-
async shouldReview(sid, diffHash) {
|
|
33417
|
-
if (diffHash.length === 0) return false;
|
|
33418
|
-
return await readLast(sid) !== diffHash;
|
|
33419
|
-
},
|
|
33420
|
-
async markReviewed(sid, diffHash) {
|
|
33421
|
-
await promises.mkdir(stateDir, { recursive: true });
|
|
33422
|
-
await promises.writeFile(fileFor(sid), diffHash, { mode: 384 });
|
|
33423
|
-
}
|
|
33424
|
-
};
|
|
33425
|
-
}
|
|
33426
|
-
function fileFindingsStore(stateDir) {
|
|
33427
|
-
const fileFor = (sid) => path.join(stateDir, `findings-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
33428
|
-
return {
|
|
33429
|
-
async read(sid) {
|
|
33430
|
-
try {
|
|
33431
|
-
const raw = await promises.readFile(fileFor(sid), "utf8");
|
|
33432
|
-
return raw.length > 0 ? raw : null;
|
|
33433
|
-
} catch {
|
|
33434
|
-
return null;
|
|
33435
|
-
}
|
|
33436
|
-
},
|
|
33437
|
-
async write(sid, findings) {
|
|
33438
|
-
await promises.mkdir(stateDir, { recursive: true });
|
|
33439
|
-
const tmp = `${fileFor(sid)}.${process.pid}.tmp`;
|
|
33440
|
-
await promises.writeFile(tmp, findings, { mode: 384 });
|
|
33441
|
-
await promises.rename(tmp, fileFor(sid));
|
|
33442
|
-
},
|
|
33443
|
-
async clear(sid) {
|
|
33444
|
-
await promises.unlink(fileFor(sid)).catch(() => {});
|
|
33445
|
-
}
|
|
33446
|
-
};
|
|
33447
|
-
}
|
|
33448
|
-
/**
|
|
33449
|
-
* The single canonical state dir for the advisory-review layer (hook V2): the
|
|
33450
|
-
* Stop hook's review debounce, the background review's findings file, and the
|
|
33451
|
-
* UserPromptSubmit hook's last-user-prompt store all live here, keyed by
|
|
33452
|
-
* sha256(session_id). One dir so the three independent subcommand processes
|
|
33453
|
-
* (`internal-stop-hook`, `internal-stop-review`, `internal-prompt-submit`)
|
|
33454
|
-
* agree on where to read/write without threading a path through env. Distinct
|
|
33455
|
-
* from the deterministic gate's `gh-router-stopgate*` dirs (block budget +
|
|
33456
|
-
* baseline) so the advisory layer can be wiped independently.
|
|
33457
|
-
*/
|
|
33458
|
-
function stopReviewStateDir() {
|
|
33459
|
-
return path.join(tmpdir(), "gh-router-stop-review");
|
|
33460
|
-
}
|
|
33461
|
-
function fileLastPromptStore(stateDir) {
|
|
33462
|
-
const fileFor = (sid) => path.join(stateDir, `last-prompt-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
33463
|
-
return {
|
|
33464
|
-
async read(sid) {
|
|
33465
|
-
try {
|
|
33466
|
-
const raw = await promises.readFile(fileFor(sid), "utf8");
|
|
33467
|
-
return raw.length > 0 ? raw : null;
|
|
33468
|
-
} catch {
|
|
33469
|
-
return null;
|
|
33470
|
-
}
|
|
33471
|
-
},
|
|
33472
|
-
async write(sid, prompt) {
|
|
33473
|
-
await promises.mkdir(stateDir, { recursive: true });
|
|
33474
|
-
const tmp = `${fileFor(sid)}.${process.pid}.tmp`;
|
|
33475
|
-
await promises.writeFile(tmp, prompt, { mode: 384 });
|
|
33476
|
-
await promises.rename(tmp, fileFor(sid));
|
|
33477
|
-
}
|
|
33478
|
-
};
|
|
33479
|
-
}
|
|
33480
|
-
//#endregion
|
|
33481
|
-
//#region src/lib/orchestration/stop-gate-hook.ts
|
|
33482
|
-
/**
|
|
33483
|
-
* The Phase-0 structural-gate Stop-hook glue. `runStopGateForLaunch` resolves a
|
|
33484
|
-
* SEALED gate by id and evaluates it against the working-tree diff, returning the
|
|
33485
|
-
* block decision a spawned-session Stop hook (or a manual check) acts on.
|
|
33486
|
-
*
|
|
33487
|
-
* Safety posture (deliberately different from the kernel): a Stop hook that
|
|
33488
|
-
* blocks on a MISCONFIGURATION would wedge the user's session (they could not
|
|
33489
|
-
* stop). So this fails OPEN on config problems (unknown gate id) and blocks ONLY
|
|
33490
|
-
* on a genuine red gate or a gate-weakening diff. The kernel, by contrast, fails
|
|
33491
|
-
* CLOSED to the baseline. Both are correct for their context.
|
|
33492
|
-
*
|
|
33493
|
-
* The sealed gate + diff + exec are inputs, so the decision is pure and
|
|
33494
|
-
* unit-testable; the thin live wrapper (capture `git diff`, spawn the checks,
|
|
33495
|
-
* map the result to an exit code) is the only part that needs a real process.
|
|
33496
|
-
*/
|
|
33497
|
-
async function runStopGateForLaunch(input) {
|
|
33498
|
-
const gate = resolveSealedGate(input.gateId);
|
|
33499
|
-
if (!gate) return {
|
|
33500
|
-
block: false,
|
|
33501
|
-
reason: `stop-gate: unknown gateId "${input.gateId}" (not blocking)`,
|
|
33502
|
-
failedChecks: [],
|
|
33503
|
-
weakening: []
|
|
33504
|
-
};
|
|
33505
|
-
return evaluateStopGate({
|
|
33506
|
-
checks: gate.checks,
|
|
33507
|
-
cwd: input.workspace,
|
|
33508
|
-
exec: input.exec,
|
|
33509
|
-
diff: input.diff
|
|
33510
|
-
});
|
|
33511
|
-
}
|
|
33512
|
-
/**
|
|
33513
|
-
* The launch-stable baseline key for the dynamic gate path: keyed by the
|
|
33514
|
-
* workdir + the descriptor's check set + a per-launch token, NOT the session id
|
|
33515
|
-
* and NOT HEAD. The LAUNCHER generates the token (passed to the child via env),
|
|
33516
|
-
* computes this key, and seeds the baseline (the set of checks already failing at
|
|
33517
|
-
* launch, BEFORE the agent mutates the tree); the runtime hook recomputes the
|
|
33518
|
-
* same key (reading the token from its env) and reads that baseline, so an
|
|
33519
|
-
* agent-introduced failure is a REGRESSION rather than being silently adopted as
|
|
33520
|
-
* the baseline at first stop. The per-launch token isolates concurrent sessions
|
|
33521
|
-
* on the same repo (they don't clobber each other's baseline). HEAD is
|
|
33522
|
-
* deliberately NOT in the key: an agent that COMMITS its work mid-session must
|
|
33523
|
-
* still be judged against the pre-session baseline (a HEAD-keyed baseline would
|
|
33524
|
-
* vanish on the commit — the exact moment it matters). A changed check set (the
|
|
33525
|
-
* agent edited the harness) yields a different key → the launch baseline no
|
|
33526
|
-
* longer matches and the hook degrades to first-stop baselining.
|
|
33527
|
-
*/
|
|
33528
|
-
function launchBaselineKey(workdir, descriptorKey, token) {
|
|
33529
|
-
return JSON.stringify([
|
|
33530
|
-
"launch",
|
|
33531
|
-
path.resolve(workdir),
|
|
33532
|
-
descriptorKey,
|
|
33533
|
-
token ?? ""
|
|
33534
|
-
]);
|
|
33535
|
-
}
|
|
33536
|
-
/**
|
|
33537
|
-
* Run the resolved checks ONCE at launch (pre-mutation) and record the failing
|
|
33538
|
-
* set as the baseline under `launchBaselineKey`. Best-effort: any failure is
|
|
33539
|
-
* swallowed (the hook then degrades to first-stop baselining). The launcher MUST
|
|
33540
|
-
* pass the SAME check set the hook will run (same `includeTests`) so a
|
|
33541
|
-
* pre-existing test failure isn't later miscounted as a regression.
|
|
33542
|
-
*
|
|
33543
|
-
* NOTE: capture is best-effort pre-mutation — the launcher fires it before the
|
|
33544
|
-
* child can edit, and in practice the agent does not mutate until well after a
|
|
33545
|
-
* user prompt, so the checks see the clean tree. If the agent somehow mutates
|
|
33546
|
-
* before this completes, the worst case is the legacy first-stop baseline (no
|
|
33547
|
-
* regression, never a wedge).
|
|
33548
|
-
*/
|
|
33549
|
-
async function captureLaunchBaseline(input) {
|
|
33550
|
-
if (input.checks.length === 0) return;
|
|
33551
|
-
try {
|
|
33552
|
-
const gate = await runGateChecks(input.checks, input.workdir, input.exec);
|
|
33553
|
-
const failed = input.checks.map((c) => c.id).filter((id) => !gate.passed.has(id));
|
|
33554
|
-
await input.baseline.set(launchBaselineKey(input.workdir, input.descriptorKey, input.token), failed);
|
|
33555
|
-
} catch {}
|
|
33556
|
-
}
|
|
33557
|
-
/**
|
|
33558
|
-
* The advisory background review (hook V2) is ON by default whenever the Stop
|
|
33559
|
-
* gate runs; it is the cross-lab accountability layer. Opt out with
|
|
33560
|
-
* `GH_ROUTER_DISABLE_STOP_REVIEW=1` to keep the deterministic gate but drop the
|
|
33561
|
-
* LLM review. (Disabling the whole gate with `GH_ROUTER_DISABLE_STOP_GATE=1`
|
|
33562
|
-
* also drops the review, since the review only ever fires from the gate's green
|
|
33563
|
-
* path.)
|
|
33564
|
-
*/
|
|
33565
|
-
function stopReviewEnabled(env = process.env) {
|
|
33566
|
-
return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_REVIEW) !== true;
|
|
33567
|
-
}
|
|
33568
|
-
/** The sealed gate the Stop hook runs, overridable via `GH_ROUTER_STOP_GATE_ID`
|
|
33569
|
-
* (must be a registered sealed id; the live wrapper falls open on an unknown
|
|
33570
|
-
* id). Defaults to `default-ci`. */
|
|
33571
|
-
function stopGateId(env = process.env) {
|
|
33572
|
-
const v = (env.GH_ROUTER_STOP_GATE_ID ?? "").trim();
|
|
33573
|
-
return v.length > 0 ? v : "default-ci";
|
|
33574
|
-
}
|
|
33575
|
-
/**
|
|
33576
|
-
* PLAN-MODE env fallback. When the session is in plan mode, plan/memory scratch
|
|
33577
|
-
* edits should not trip the gate-WEAKENING scan (a plan/memory markdown file may
|
|
33578
|
-
* legitimately quote `it.skip` / `as any` while describing work). The launcher
|
|
33579
|
-
* sets `GH_ROUTER_STOP_GATE_PLAN_MODE` for a plan-mode session; the payload may
|
|
33580
|
-
* ALSO carry a forward-compat `plan_mode` field (see `decideStopHook`). Either
|
|
33581
|
-
* signal being present activates the scoping — the executable gate STILL runs
|
|
33582
|
-
* and non-plan hunks are STILL scanned.
|
|
33583
|
-
*/
|
|
33584
|
-
function stopGatePlanMode(env = process.env) {
|
|
33585
|
-
return parseBoolEnv(env.GH_ROUTER_STOP_GATE_PLAN_MODE) === true;
|
|
33586
|
-
}
|
|
33587
|
-
/**
|
|
33588
|
-
* Is a unified-diff file path confined to a `plans/` or `memory/` directory?
|
|
33589
|
-
* Windows-safe (normalizes `\` → `/`). Matches only when a DIRECTORY segment of
|
|
33590
|
-
* the path is exactly `plans` or `memory`, so `plans/x.md`, `memory/y.md`, and
|
|
33591
|
-
* `a/plans/z` match while the filenames `docs/plans.md` / `myplans/x` do not.
|
|
33592
|
-
*/
|
|
33593
|
-
function isPlanMemoryDiffPath(p) {
|
|
33594
|
-
const segs = p.replace(/\\/g, "/").split("/");
|
|
33595
|
-
for (let i = 0; i < segs.length - 1; i++) if (segs[i] === "plans" || segs[i] === "memory") return true;
|
|
33596
|
-
return false;
|
|
33597
|
-
}
|
|
33598
|
-
/**
|
|
33599
|
-
* Drop the file sections of a unified `git diff` whose NEW path (the `b/…` side)
|
|
33600
|
-
* is confined to `plans/` or `memory/` — used for the gate-WEAKENING scan when
|
|
33601
|
-
* plan mode is active. Sections for other files (e.g. `src/…`) are preserved
|
|
33602
|
-
* verbatim, so a MIXED diff keeps its non-plan hunks and `src/` weakening is
|
|
33603
|
-
* still caught. The executable gate does not read the diff, so filtering it here
|
|
33604
|
-
* never affects which checks run. A diff with no `diff --git` sections (or an
|
|
33605
|
-
* empty one) is returned unchanged.
|
|
33606
|
-
*/
|
|
33607
|
-
function stripPlanMemoryDiffHunks(diff) {
|
|
33608
|
-
if (diff.length === 0) return diff;
|
|
33609
|
-
const out = [];
|
|
33610
|
-
let dropping = false;
|
|
33611
|
-
for (const line of diff.split("\n")) {
|
|
33612
|
-
if (line.startsWith("diff --git ")) {
|
|
33613
|
-
const bPath = /^diff --git a\/.+ b\/(.+)$/.exec(line)?.[1];
|
|
33614
|
-
dropping = bPath !== void 0 && isPlanMemoryDiffPath(bPath);
|
|
33615
|
-
if (!dropping) out.push(line);
|
|
33616
|
-
continue;
|
|
33617
|
-
}
|
|
33618
|
-
if (!dropping) out.push(line);
|
|
33619
|
-
}
|
|
33620
|
-
return out.join("\n");
|
|
33621
|
-
}
|
|
33622
|
-
/**
|
|
33623
|
-
* C3: whether the structural Stop-gate is disabled for THIS launch. True when the
|
|
33624
|
-
* `GH_ROUTER_DISABLE_STOP_GATE` env is set OR the `--no-stop-gate` launcher flag was
|
|
33625
|
-
* passed (`args["no-stop-gate"] === true`). A DRIVEN session sets the flag so a
|
|
33626
|
-
* blocking Stop hook never hangs its turn-end (~10 min) waiting on the fleet driver.
|
|
33627
|
-
* Pure so it is unit-testable without the live launch path.
|
|
33628
|
-
*/
|
|
33629
|
-
function stopGateDisabled(args, env = process.env) {
|
|
33630
|
-
return parseBoolEnv(env.GH_ROUTER_DISABLE_STOP_GATE) === true || args["no-stop-gate"] === true;
|
|
33631
|
-
}
|
|
33632
|
-
/** True when a settings `Stop` entry already registers `command` (so the merge
|
|
33633
|
-
* is idempotent across re-launches). */
|
|
33634
|
-
function entryHasCommand(entry, command) {
|
|
33635
|
-
if (!entry || typeof entry !== "object") return false;
|
|
33636
|
-
const hooks = entry.hooks;
|
|
33637
|
-
if (!Array.isArray(hooks)) return false;
|
|
33638
|
-
return hooks.some((h) => h && typeof h === "object" && h.command === command);
|
|
33639
|
-
}
|
|
33640
|
-
/**
|
|
33641
|
-
* Idempotently merge a hook running `command` for `event` (default `Stop`) into
|
|
33642
|
-
* an existing Claude Code settings object WITHOUT clobbering other hook events or
|
|
33643
|
-
* other entries. Returns a new object (never mutates the input). Re-running the
|
|
33644
|
-
* launcher with the same command+event does not duplicate the hook.
|
|
33645
|
-
*/
|
|
33646
|
-
function mergeStopHookIntoSettings(existing, command, event = "Stop", timeoutSec, matcher) {
|
|
33647
|
-
const base = existing && typeof existing === "object" ? { ...existing } : {};
|
|
33648
|
-
const hooks = base.hooks && typeof base.hooks === "object" ? { ...base.hooks } : {};
|
|
33649
|
-
const arr = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
|
|
33650
|
-
if (!arr.some((e) => entryHasCommand(e, command))) {
|
|
33651
|
-
const hook = {
|
|
33652
|
-
type: "command",
|
|
33653
|
-
command
|
|
33654
|
-
};
|
|
33655
|
-
if (typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0) hook.timeout = timeoutSec;
|
|
33656
|
-
arr.push(matcher ? {
|
|
33657
|
-
matcher,
|
|
33658
|
-
hooks: [hook]
|
|
33659
|
-
} : { hooks: [hook] });
|
|
33660
|
-
}
|
|
33661
|
-
hooks[event] = arr;
|
|
33662
|
-
base.hooks = hooks;
|
|
33663
|
-
return base;
|
|
33664
|
-
}
|
|
33665
|
-
async function decideStopHook(input) {
|
|
33666
|
-
const maxBlocks = input.maxBlocks ?? 2;
|
|
33667
|
-
let payload = {};
|
|
33668
|
-
let parsed = false;
|
|
33669
|
-
try {
|
|
33670
|
-
const p = JSON.parse(input.stdin);
|
|
33671
|
-
if (p && typeof p === "object") {
|
|
33672
|
-
payload = p;
|
|
33673
|
-
parsed = true;
|
|
33674
|
-
}
|
|
33675
|
-
} catch {}
|
|
33676
|
-
if (!parsed) return { exitCode: 0 };
|
|
33677
|
-
if (isSubagentContext(payload)) return { exitCode: 0 };
|
|
33678
|
-
const sessionId = typeof payload.session_id === "string" && payload.session_id.length > 0 ? payload.session_id : "";
|
|
33679
|
-
if (!sessionId) return { exitCode: 0 };
|
|
33680
|
-
const cwdRaw = typeof payload.cwd === "string" && payload.cwd.length > 0 ? payload.cwd : input.fallbackCwd;
|
|
33681
|
-
let cwd = cwdRaw;
|
|
33682
|
-
try {
|
|
33683
|
-
cwd = await promises.realpath(cwdRaw);
|
|
33684
|
-
} catch {}
|
|
33685
|
-
let enabled;
|
|
33686
|
-
try {
|
|
33687
|
-
enabled = await input.isEnabledForRepo(cwd);
|
|
33688
|
-
} catch {
|
|
33689
|
-
return { exitCode: 0 };
|
|
33690
|
-
}
|
|
33691
|
-
if (!enabled) return { exitCode: 0 };
|
|
33692
|
-
let priorBlocks;
|
|
33693
|
-
try {
|
|
33694
|
-
priorBlocks = await input.budget.count(sessionId);
|
|
33695
|
-
} catch {
|
|
33696
|
-
return { exitCode: 0 };
|
|
33697
|
-
}
|
|
33698
|
-
if (priorBlocks >= maxBlocks) return {
|
|
33699
|
-
exitCode: 0,
|
|
33700
|
-
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.`
|
|
33701
|
-
};
|
|
33702
|
-
let resolvedKey = input.gateId;
|
|
33703
|
-
let dynamicBaselineKey;
|
|
33704
|
-
const planMode = input.planMode === true || payload.plan_mode === true;
|
|
33705
|
-
const scanDiff = (diff) => planMode ? stripPlanMemoryDiffHunks(diff) : diff;
|
|
33706
|
-
const capturedDiff = async (workdir) => {
|
|
33707
|
-
try {
|
|
33708
|
-
return {
|
|
33709
|
-
diff: await input.captureDiff(workdir),
|
|
33710
|
-
captured: true
|
|
33711
|
-
};
|
|
33712
|
-
} catch {
|
|
33713
|
-
return {
|
|
33714
|
-
diff: "",
|
|
33715
|
-
captured: false
|
|
33716
|
-
};
|
|
33717
|
-
}
|
|
33718
|
-
};
|
|
33719
|
-
const runGate = async () => {
|
|
33720
|
-
if (input.resolveChecks) {
|
|
33721
|
-
const resolved = await input.resolveChecks(cwd).catch(() => null);
|
|
33722
|
-
if (!resolved || resolved.checks.length === 0) return null;
|
|
33723
|
-
resolvedKey = resolved.descriptorKey;
|
|
33724
|
-
dynamicBaselineKey = resolved.baselineKey;
|
|
33725
|
-
const workdir = resolved.workdir || cwd;
|
|
33726
|
-
const { diff, captured } = await capturedDiff(workdir);
|
|
33727
|
-
if (captured && diff.length === 0) return {
|
|
33728
|
-
kind: "no-diff",
|
|
33729
|
-
diff
|
|
33730
|
-
};
|
|
33731
|
-
const result = await evaluateStopGate({
|
|
33732
|
-
checks: resolved.checks,
|
|
33733
|
-
cwd: workdir,
|
|
33734
|
-
exec: input.exec,
|
|
33735
|
-
diff: captured ? scanDiff(diff) : ""
|
|
33736
|
-
});
|
|
33737
|
-
return {
|
|
33738
|
-
kind: "evaluated",
|
|
33739
|
-
failedChecks: [...result.failedChecks],
|
|
33740
|
-
weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
|
|
33741
|
-
diff
|
|
33742
|
-
};
|
|
33743
|
-
}
|
|
33744
|
-
const { diff, captured } = await capturedDiff(cwd);
|
|
33745
|
-
if (captured && diff.length === 0) return {
|
|
33746
|
-
kind: "no-diff",
|
|
33747
|
-
diff
|
|
33748
|
-
};
|
|
33749
|
-
const result = await runStopGateForLaunch({
|
|
33750
|
-
workspace: cwd,
|
|
33751
|
-
gateId: input.gateId,
|
|
33752
|
-
exec: input.exec,
|
|
33753
|
-
diff: captured ? scanDiff(diff) : ""
|
|
33754
|
-
});
|
|
33755
|
-
return {
|
|
33756
|
-
kind: "evaluated",
|
|
33757
|
-
failedChecks: [...result.failedChecks],
|
|
33758
|
-
weakeningPatterns: [...new Set(result.weakening.map((w) => w.pattern))],
|
|
33759
|
-
diff
|
|
33760
|
-
};
|
|
33761
|
-
};
|
|
33762
|
-
const timeoutMs = input.timeoutMs ?? 3e5;
|
|
33763
|
-
let timer;
|
|
33764
|
-
const raced = await Promise.race([runGate(), new Promise((resolve) => {
|
|
33765
|
-
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
33766
|
-
})]);
|
|
33767
|
-
if (timer) clearTimeout(timer);
|
|
33768
|
-
if (raced === "timeout") return { exitCode: 0 };
|
|
33769
|
-
if (raced === null) return { exitCode: 0 };
|
|
33770
|
-
if (raced.kind === "no-diff") return { exitCode: 0 };
|
|
33771
|
-
const baselineKey = dynamicBaselineKey ?? JSON.stringify([
|
|
33772
|
-
sessionId,
|
|
33773
|
-
cwd,
|
|
33774
|
-
resolvedKey
|
|
33775
|
-
]);
|
|
33776
|
-
const recorded = await input.baseline.get(baselineKey).catch(() => null);
|
|
33777
|
-
if (recorded === null) await input.baseline.set(baselineKey, raced.failedChecks).catch(() => {});
|
|
33778
|
-
const regressed = regressions(raced.failedChecks, recorded);
|
|
33779
|
-
const weakened = raced.weakeningPatterns.length > 0;
|
|
33780
|
-
if (regressed.length === 0 && !weakened) {
|
|
33781
|
-
await maybeSpawnReview(input, sessionId, cwd, raced.diff);
|
|
33782
|
-
return { exitCode: 0 };
|
|
33783
|
-
}
|
|
33784
|
-
try {
|
|
33785
|
-
await input.budget.record(sessionId);
|
|
33786
|
-
} catch {
|
|
33787
|
-
return { exitCode: 0 };
|
|
33788
|
-
}
|
|
33789
|
-
const parts = [];
|
|
33790
|
-
if (regressed.length > 0) parts.push(`regressed gates: ${regressed.join(", ")}`);
|
|
33791
|
-
if (weakened) parts.push(`gate-weakening in the diff: ${raced.weakeningPatterns.join(", ")}`);
|
|
33792
|
-
return {
|
|
33793
|
-
exitCode: 2,
|
|
33794
|
-
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.`
|
|
33795
|
-
};
|
|
33796
|
-
}
|
|
33797
|
-
/**
|
|
33798
|
-
* The advisory-review side-effect on a GREEN stop: debounce by diff hash, then
|
|
33799
|
-
* fire the detached background reviewer. ADVISORY-ONLY — it returns void, never
|
|
33800
|
-
* throws (every step is swallowed), and the caller does not await its result for
|
|
33801
|
-
* the exit decision. A no-op when the review layer isn't wired (no debounce /
|
|
33802
|
-
* spawn injected, e.g. GH_ROUTER_DISABLE_STOP_REVIEW) or the diff is empty.
|
|
33803
|
-
*
|
|
33804
|
-
* `markReviewed` runs BEFORE the spawn so a crashing spawn still records the
|
|
33805
|
-
* debounce (an identical tree won't re-trigger on the next stop). The review is
|
|
33806
|
-
* gated on the diff CHANGING since the last review — without it, every stop of
|
|
33807
|
-
* an unchanged tree would re-spend a background gpt-5.6-sol review.
|
|
33808
|
-
*
|
|
33809
|
-
* The whole body is bounded by a short timeout (the stores are local temp files
|
|
33810
|
-
* that complete in well under a millisecond in practice, so the timeout never
|
|
33811
|
-
* fires normally — but if the debounce read/write ever stalled, the stop must
|
|
33812
|
-
* still proceed promptly; the advisory layer never delays a clean stop).
|
|
33813
|
-
*/
|
|
33814
|
-
const REVIEW_SIDE_EFFECT_BUDGET_MS = 2e3;
|
|
33815
|
-
async function maybeSpawnReview(input, sessionId, cwd, diff) {
|
|
33816
|
-
if (!input.reviewDebounce || !input.spawnReview) return;
|
|
33817
|
-
if (diff.trim().length === 0) return;
|
|
33818
|
-
let timer;
|
|
33819
|
-
try {
|
|
33820
|
-
const work = (async () => {
|
|
33821
|
-
const diffHash = createHash("sha256").update(diff).digest("hex");
|
|
33822
|
-
if (!await input.reviewDebounce.shouldReview(sessionId, diffHash)) return;
|
|
33823
|
-
await input.reviewDebounce.markReviewed(sessionId, diffHash);
|
|
33824
|
-
input.spawnReview({
|
|
33825
|
-
sessionId,
|
|
33826
|
-
cwd,
|
|
33827
|
-
diff,
|
|
33828
|
-
diffHash
|
|
33829
|
-
});
|
|
33830
|
-
})();
|
|
33831
|
-
await Promise.race([work, new Promise((resolve) => {
|
|
33832
|
-
timer = setTimeout(resolve, REVIEW_SIDE_EFFECT_BUDGET_MS);
|
|
33833
|
-
})]);
|
|
33834
|
-
} catch {} finally {
|
|
33835
|
-
if (timer) clearTimeout(timer);
|
|
33836
|
-
}
|
|
33837
|
-
}
|
|
33838
|
-
/**
|
|
33839
|
-
* A file-backed `BlockBudget` under `stateDir`, keyed by a hash of the session id
|
|
33840
|
-
* (so a session id is never written verbatim to a predictable path). Best-effort:
|
|
33841
|
-
* a read miss counts as 0; `record` increments. A write/read error propagates so
|
|
33842
|
-
* `decideStopHook` stands down (it can't guarantee termination without the
|
|
33843
|
-
* budget).
|
|
33844
|
-
*/
|
|
33845
|
-
function fileBlockBudget(stateDir) {
|
|
33846
|
-
const fileFor = (sid) => path.join(stateDir, `block-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
33847
|
-
const readCount = async (sid) => {
|
|
33848
|
-
try {
|
|
33849
|
-
const raw = await promises.readFile(fileFor(sid), "utf8");
|
|
33850
|
-
const n = Number.parseInt(raw.trim(), 10);
|
|
33851
|
-
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
33852
|
-
} catch {
|
|
33853
|
-
return 0;
|
|
33854
|
-
}
|
|
33855
|
-
};
|
|
33856
|
-
return {
|
|
33857
|
-
count: readCount,
|
|
33858
|
-
async record(sid) {
|
|
33859
|
-
const next = await readCount(sid) + 1;
|
|
33860
|
-
await promises.mkdir(stateDir, { recursive: true });
|
|
33861
|
-
await promises.writeFile(fileFor(sid), String(next), { mode: 384 });
|
|
33862
|
-
},
|
|
33863
|
-
async reset(sid) {
|
|
33864
|
-
await promises.unlink(fileFor(sid)).catch(() => {});
|
|
33865
|
-
}
|
|
33866
|
-
};
|
|
33867
|
-
}
|
|
33868
|
-
/**
|
|
33869
|
-
* Build the shell command string Claude Code runs for the Stop hook. Invokes the
|
|
33870
|
-
* running github-router via its node/bun binary so it works regardless of PATH.
|
|
33871
|
-
* Pure (takes the binary + script paths) so the quoting is unit-testable; the
|
|
33872
|
-
* cross-platform firing is verified by the gated E2E.
|
|
33873
|
-
*/
|
|
33874
|
-
function buildStopHookCommand(execPath, scriptPath) {
|
|
33875
|
-
const q = (s) => `"${s}"`;
|
|
33876
|
-
if (scriptPath && scriptPath !== execPath) return `${q(execPath)} ${q(scriptPath)} internal-stop-hook`;
|
|
33877
|
-
return `${q(execPath)} internal-stop-hook`;
|
|
33878
|
-
}
|
|
33879
|
-
/**
|
|
33880
|
-
* Build the shell command Claude Code runs for the SessionStart/SessionEnd hooks
|
|
33881
|
-
* (registered only when github-router runs inside an ai-or-die Terminal tab). The
|
|
33882
|
-
* sidecar path is baked in as a literal `--out` arg — NOT passed via env — so it
|
|
33883
|
-
* survives `AIORDIE_CLAUDE_BIND` being stripped from the child's environment and a
|
|
33884
|
-
* nested `github-router claude` can't inherit it. Pure (binary + script + out
|
|
33885
|
-
* paths) for unit-testable quoting; the live firing is verified end-to-end.
|
|
33886
|
-
*/
|
|
33887
|
-
function buildSessionBindHookCommand(execPath, scriptPath, outPath) {
|
|
33888
|
-
const q = (s) => `"${s}"`;
|
|
33889
|
-
return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-session-bind --out ${q(outPath)}`;
|
|
33890
|
-
}
|
|
33891
|
-
/** Command for the `internal-artifact-open` hook (no args — token comes from the
|
|
33892
|
-
* mirror creds file, plan from the plans dir; nothing secret in argv). */
|
|
33893
|
-
function buildArtifactOpenHookCommand(execPath, scriptPath) {
|
|
33894
|
-
const q = (s) => `"${s}"`;
|
|
33895
|
-
return `${scriptPath && scriptPath !== execPath ? `${q(execPath)} ${q(scriptPath)}` : q(execPath)} internal-artifact-open`;
|
|
33896
|
-
}
|
|
33897
|
-
/**
|
|
33898
|
-
* Read-merge-atomic-write the Stop hook into a Claude Code `settings.json` file
|
|
33899
|
-
* (the mirrored one). A MISSING file (ENOENT) starts from `{}`; any OTHER read or
|
|
33900
|
-
* parse error THROWS (the caller's try/catch warns and continues) rather than
|
|
33901
|
-
* overwriting a file we couldn't understand with our defaults. Preserves every
|
|
33902
|
-
* other setting, is idempotent, and uses temp+rename so Claude Code's mtime
|
|
33903
|
-
* watcher never sees a half-written file. Returns the merged object.
|
|
33904
|
-
*/
|
|
33905
|
-
async function injectStopHookIntoSettingsFile(settingsPath, command, event = "Stop", timeoutSec, matcher) {
|
|
33906
|
-
let existing = {};
|
|
33907
|
-
let raw;
|
|
33908
|
-
try {
|
|
33909
|
-
raw = await promises.readFile(settingsPath, "utf8");
|
|
33910
|
-
} catch (err) {
|
|
33911
|
-
if (err.code !== "ENOENT") throw err;
|
|
33912
|
-
raw = void 0;
|
|
33913
|
-
}
|
|
33914
|
-
if (raw !== void 0) {
|
|
33915
|
-
const parsed = JSON.parse(raw);
|
|
33916
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
|
|
33917
|
-
else throw new Error(`settings.json at ${settingsPath} is not a JSON object; refusing to overwrite`);
|
|
33918
|
-
}
|
|
33919
|
-
const merged = mergeStopHookIntoSettings(existing, command, event, timeoutSec, matcher);
|
|
33920
|
-
const tmp = `${settingsPath}.${process.pid}.tmp`;
|
|
33921
|
-
await promises.writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
33922
|
-
await promises.rename(tmp, settingsPath);
|
|
33923
|
-
return merged;
|
|
33924
|
-
}
|
|
33925
|
-
//#endregion
|
|
33926
31697
|
//#region src/lib/orchestration/attest.ts
|
|
33927
31698
|
function isNonEmptyString(v) {
|
|
33928
31699
|
return typeof v === "string" && v.length > 0;
|
|
@@ -35921,6 +33692,6 @@ function enumerateInjectedMcpToolNames(groupKeys, opts = {}) {
|
|
|
35921
33692
|
return [...new Set(names)];
|
|
35922
33693
|
}
|
|
35923
33694
|
//#endregion
|
|
35924
|
-
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 };
|
|
35925
33696
|
|
|
35926
|
-
//# sourceMappingURL=peer-mcp-personas-
|
|
33697
|
+
//# sourceMappingURL=peer-mcp-personas-BDet588n.js.map
|