github-router 0.3.250 → 0.3.256
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-jqkXfeVr.js +2347 -0
- package/dist/attribution-settings-jqkXfeVr.js.map +1 -0
- package/dist/auth-CP4q2CjO.js +44 -0
- package/dist/auth-CP4q2CjO.js.map +1 -0
- package/dist/browser-ext/background.js +406 -30
- 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-Cpy7b9gd.js +593 -0
- package/dist/claude-Cpy7b9gd.js.map +1 -0
- package/dist/client-CQMfroGV.js +413 -0
- package/dist/client-CQMfroGV.js.map +1 -0
- package/dist/codex-BJK_QVsI.js +85 -0
- package/dist/codex-BJK_QVsI.js.map +1 -0
- package/dist/debug-CjmE9sgG.js +81 -0
- package/dist/debug-CjmE9sgG.js.map +1 -0
- package/dist/engine-DXn5XmFp.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-Bm1nxHSU.js +635 -0
- package/dist/gate-discovery-Bm1nxHSU.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-BaBkqJyg.js +212 -0
- package/dist/internal-stop-hook-BaBkqJyg.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-BPl6_V2A.js} +288 -2445
- package/dist/peer-mcp-personas-BPl6_V2A.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-DueN4M8G.js +656 -0
- package/dist/provision-DueN4M8G.js.map +1 -0
- package/dist/serve-GN-flJmF.js +1511 -0
- package/dist/serve-GN-flJmF.js.map +1 -0
- package/dist/server-setup-BJL-X7w6.js +4776 -0
- package/dist/server-setup-BJL-X7w6.js.map +1 -0
- package/dist/start-CE0k-weK.js +129 -0
- package/dist/start-CE0k-weK.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) => {
|
|
@@ -17909,13 +16556,27 @@ async function runUnifiedCodeSearch(input, signal) {
|
|
|
17909
16556
|
//#endregion
|
|
17910
16557
|
//#region src/lib/browser-mcp/browser-detect.ts
|
|
17911
16558
|
let cached;
|
|
17912
|
-
function
|
|
17913
|
-
|
|
17914
|
-
|
|
16559
|
+
function probeWindowsRegistry(subkey) {
|
|
16560
|
+
try {
|
|
16561
|
+
execFileSync("reg.exe", [
|
|
16562
|
+
"query",
|
|
16563
|
+
`HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
|
|
16564
|
+
"/ve"
|
|
16565
|
+
], {
|
|
16566
|
+
windowsHide: true,
|
|
16567
|
+
timeout: 3e3,
|
|
16568
|
+
stdio: [
|
|
16569
|
+
"ignore",
|
|
16570
|
+
"pipe",
|
|
16571
|
+
"ignore"
|
|
16572
|
+
]
|
|
16573
|
+
});
|
|
16574
|
+
return true;
|
|
16575
|
+
} catch {
|
|
17915
16576
|
try {
|
|
17916
16577
|
execFileSync("reg.exe", [
|
|
17917
16578
|
"query",
|
|
17918
|
-
`
|
|
16579
|
+
`HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
|
|
17919
16580
|
"/ve"
|
|
17920
16581
|
], {
|
|
17921
16582
|
windowsHide: true,
|
|
@@ -17928,43 +16589,27 @@ function probeWindows() {
|
|
|
17928
16589
|
});
|
|
17929
16590
|
return true;
|
|
17930
16591
|
} catch {
|
|
17931
|
-
|
|
17932
|
-
execFileSync("reg.exe", [
|
|
17933
|
-
"query",
|
|
17934
|
-
`HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
|
|
17935
|
-
"/ve"
|
|
17936
|
-
], {
|
|
17937
|
-
windowsHide: true,
|
|
17938
|
-
timeout: 3e3,
|
|
17939
|
-
stdio: [
|
|
17940
|
-
"ignore",
|
|
17941
|
-
"pipe",
|
|
17942
|
-
"ignore"
|
|
17943
|
-
]
|
|
17944
|
-
});
|
|
17945
|
-
return true;
|
|
17946
|
-
} catch {
|
|
17947
|
-
return false;
|
|
17948
|
-
}
|
|
16592
|
+
return false;
|
|
17949
16593
|
}
|
|
17950
|
-
};
|
|
17951
|
-
if (probe("chrome.exe")) found.push("chrome");
|
|
17952
|
-
if (probe("msedge.exe")) found.push("edge");
|
|
17953
|
-
if (!found.includes("chrome")) {
|
|
17954
|
-
const localApp = process$1.env.LOCALAPPDATA;
|
|
17955
|
-
const pf = process$1.env["PROGRAMFILES"];
|
|
17956
|
-
const pf86 = process$1.env["PROGRAMFILES(X86)"];
|
|
17957
|
-
if ([
|
|
17958
|
-
localApp ? path.join(localApp, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
17959
|
-
pf ? path.join(pf, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
17960
|
-
pf86 ? path.join(pf86, "Google", "Chrome", "Application", "chrome.exe") : void 0
|
|
17961
|
-
].filter((p) => typeof p === "string").some(existsSync)) found.push("chrome");
|
|
17962
|
-
}
|
|
17963
|
-
if (!found.includes("edge")) {
|
|
17964
|
-
const pf86 = process$1.env["PROGRAMFILES(X86)"];
|
|
17965
|
-
const pf = process$1.env["PROGRAMFILES"];
|
|
17966
|
-
if ([pf86 ? path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") : void 0, pf ? path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe") : void 0].filter((p) => typeof p === "string").some(existsSync)) found.push("edge");
|
|
17967
16594
|
}
|
|
16595
|
+
}
|
|
16596
|
+
/** Well-known install locations. Per-user installs land in LocalAppData; system installs in Program Files. */
|
|
16597
|
+
function windowsLiteralPaths(browser) {
|
|
16598
|
+
const localApp = process$1.env.LOCALAPPDATA;
|
|
16599
|
+
const pf = process$1.env["PROGRAMFILES"];
|
|
16600
|
+
const pf86 = process$1.env["PROGRAMFILES(X86)"];
|
|
16601
|
+
return (browser === "chrome" ? [
|
|
16602
|
+
localApp ? path.join(localApp, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
16603
|
+
pf ? path.join(pf, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
16604
|
+
pf86 ? path.join(pf86, "Google", "Chrome", "Application", "chrome.exe") : void 0
|
|
16605
|
+
] : [pf86 ? path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") : void 0, pf ? path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe") : void 0]).filter((p) => typeof p === "string");
|
|
16606
|
+
}
|
|
16607
|
+
function probeWindows() {
|
|
16608
|
+
const found = [];
|
|
16609
|
+
if (windowsLiteralPaths("chrome").some(existsSync)) found.push("chrome");
|
|
16610
|
+
if (windowsLiteralPaths("edge").some(existsSync)) found.push("edge");
|
|
16611
|
+
if (!found.includes("chrome") && probeWindowsRegistry("chrome.exe")) found.push("chrome");
|
|
16612
|
+
if (!found.includes("edge") && probeWindowsRegistry("msedge.exe")) found.push("edge");
|
|
17968
16613
|
return found;
|
|
17969
16614
|
}
|
|
17970
16615
|
function probeMacOS() {
|
|
@@ -18177,6 +16822,18 @@ function appBrowserMcpDir() {
|
|
|
18177
16822
|
return dir;
|
|
18178
16823
|
}
|
|
18179
16824
|
/**
|
|
16825
|
+
* Memo for the interpreter probe below. The probe shells out to
|
|
16826
|
+
* `where`/`which`, which measured ~196ms per spawn on Windows, and it ran on
|
|
16827
|
+
* every browser tool call (twice, before the preflight was deduplicated).
|
|
16828
|
+
*
|
|
16829
|
+
* Only a SUCCESSFUL resolution is cached. `process.execPath` is the
|
|
16830
|
+
* fall-through when the probe fails, and caching that would pin a degraded
|
|
16831
|
+
* interpreter for the life of the process — including across a `node` install
|
|
16832
|
+
* that happens while the proxy is running. The path of an existing `node` on
|
|
16833
|
+
* PATH, by contrast, does not change mid-process in any way that matters here.
|
|
16834
|
+
*/
|
|
16835
|
+
let cachedBridgeInterpreter;
|
|
16836
|
+
/**
|
|
18180
16837
|
* Pick a runtime interpreter for the bridge. The bridge uses Node's
|
|
18181
16838
|
* binary-stdin framing for native messaging which Bun handles
|
|
18182
16839
|
* differently (Bun closes the bridge prematurely as soon as anything
|
|
@@ -18185,6 +16842,7 @@ function appBrowserMcpDir() {
|
|
|
18185
16842
|
* binary) only if node isn't on PATH.
|
|
18186
16843
|
*/
|
|
18187
16844
|
function resolveBridgeInterpreter() {
|
|
16845
|
+
if (cachedBridgeInterpreter !== void 0) return cachedBridgeInterpreter;
|
|
18188
16846
|
const probeCmd = platform() === "win32" ? "where" : "which";
|
|
18189
16847
|
try {
|
|
18190
16848
|
const out = execFileSync(probeCmd, ["node"], {
|
|
@@ -18196,7 +16854,10 @@ function resolveBridgeInterpreter() {
|
|
|
18196
16854
|
timeout: 2e3,
|
|
18197
16855
|
windowsHide: true
|
|
18198
16856
|
}).toString().trim().split(/\r?\n/)[0];
|
|
18199
|
-
if (out)
|
|
16857
|
+
if (out) {
|
|
16858
|
+
cachedBridgeInterpreter = out;
|
|
16859
|
+
return out;
|
|
16860
|
+
}
|
|
18200
16861
|
} catch {}
|
|
18201
16862
|
return process$1.execPath;
|
|
18202
16863
|
}
|
|
@@ -18484,8 +17145,7 @@ async function probeHealth(port, token, timeoutMs = 500) {
|
|
|
18484
17145
|
}
|
|
18485
17146
|
function bridgeBundleExists() {
|
|
18486
17147
|
try {
|
|
18487
|
-
|
|
18488
|
-
return true;
|
|
17148
|
+
return statSync(bridgeBundlePath()).isFile();
|
|
18489
17149
|
} catch {
|
|
18490
17150
|
return false;
|
|
18491
17151
|
}
|
|
@@ -18522,6 +17182,13 @@ function loadExpectedExtensionVersion() {
|
|
|
18522
17182
|
*/
|
|
18523
17183
|
const DEV_VERSION_SENTINEL = "0.0.0";
|
|
18524
17184
|
/**
|
|
17185
|
+
* Stand-in for `version_mismatch.loaded` when the loaded extension is
|
|
17186
|
+
* too old to report a version at all (it predates the `__hello__`
|
|
17187
|
+
* handshake). Kept human-readable because it is rendered verbatim into
|
|
17188
|
+
* the model-facing instructions.
|
|
17189
|
+
*/
|
|
17190
|
+
const UNKNOWN_LOADED_VERSION = "unknown (extension reported no version)";
|
|
17191
|
+
/**
|
|
18525
17192
|
* Track which `(extensionId, expectedVersion)` pairs we've already
|
|
18526
17193
|
* tried to auto-reload in this process. Prevents an infinite reload
|
|
18527
17194
|
* loop if the on-disk version somehow stays ahead of what the browser
|
|
@@ -18530,6 +17197,15 @@ const DEV_VERSION_SENTINEL = "0.0.0";
|
|
|
18530
17197
|
*/
|
|
18531
17198
|
const attemptedReloads = /* @__PURE__ */ new Set();
|
|
18532
17199
|
/**
|
|
17200
|
+
* `auto_installed` for the `extension_outdated` payloads below.
|
|
17201
|
+
*
|
|
17202
|
+
* Reaching those branches means the bridge is running and the extension is
|
|
17203
|
+
* connected, so the native-messaging manifest is provably already correct —
|
|
17204
|
+
* nothing was installed, and claiming otherwise would point the reader at a
|
|
17205
|
+
* step that is not the remedy. The remedy is reloading the extension.
|
|
17206
|
+
*/
|
|
17207
|
+
const NOTHING_AUTO_INSTALLED = Object.freeze([]);
|
|
17208
|
+
/**
|
|
18533
17209
|
* Send POST /reload to the bridge — triggers __reload__ control frame
|
|
18534
17210
|
* over native messaging, which the extension's handler dispatches into
|
|
18535
17211
|
* chrome.runtime.reload(). After this returns, the OLD bridge process
|
|
@@ -18574,7 +17250,10 @@ function buildInstallRequired(reason, autoInstalled, versionMismatch) {
|
|
|
18574
17250
|
const instructions = (() => {
|
|
18575
17251
|
if (reason === "no_supported_browser") return "No Chrome or Edge installation was detected on this host. Install one and restart the github-router proxy.";
|
|
18576
17252
|
if (reason === "bridge_bundle_missing") return "The bridge bundle is missing. Run `bun run build` from the github-router checkout to produce dist/browser-bridge/index.js, then retry.";
|
|
18577
|
-
if (reason === "extension_outdated" && versionMismatch)
|
|
17253
|
+
if (reason === "extension_outdated" && versionMismatch) {
|
|
17254
|
+
if (versionMismatch.loaded === UNKNOWN_LOADED_VERSION) return `Your loaded github-router browser extension did not report a version, so the proxy cannot confirm it is up to date and auto-reload could not fix it (the github-router package shipped version ${versionMismatch.expected}). This usually means the loaded extension predates the auto-reload support and has been silently running stale. Open chrome://extensions (or edge://extensions), find the github-router extension card, and click the reload arrow; if the card is missing, click "Load unpacked" and select the load_unpacked_dir above. If it still reports no version afterwards, restart the github-router proxy so the native bridge is refreshed too.`;
|
|
17255
|
+
return `Your loaded github-router browser extension is version ${versionMismatch.loaded} but the github-router package shipped version ${versionMismatch.expected}. Auto-reload was attempted and did not converge — Chrome likely disabled the extension because the new manifest declares new permissions. Open chrome://extensions (or edge://extensions), find the github-router extension card, click "Enable" if it's disabled, then click the reload arrow. Retry this tool call afterwards.`;
|
|
17256
|
+
}
|
|
18578
17257
|
return "Open chrome://extensions (or edge://extensions), enable Developer Mode, click 'Load unpacked', and select the load_unpacked_dir above. Then retry this tool call. If you just updated the github-router package, an extension already loaded may need to be reloaded — click the reload arrow on its card.";
|
|
18579
17258
|
})();
|
|
18580
17259
|
return {
|
|
@@ -18591,6 +17270,31 @@ function buildInstallRequired(reason, autoInstalled, versionMismatch) {
|
|
|
18591
17270
|
};
|
|
18592
17271
|
}
|
|
18593
17272
|
/**
|
|
17273
|
+
* Is the loaded extension out of date with respect to the shipped one?
|
|
17274
|
+
*
|
|
17275
|
+
* `loaded === undefined` means the extension reported no version. The
|
|
17276
|
+
* bridge has echoed `extension_loaded_version` since the very release
|
|
17277
|
+
* that added the `__hello__` handshake, so on any bridge new enough to
|
|
17278
|
+
* run this code, a CONNECTED extension that reports no version is one
|
|
17279
|
+
* that predates the handshake — the most stale state there is. Treating
|
|
17280
|
+
* that absence as "not checkable" failed open permanently for exactly
|
|
17281
|
+
* the extensions this check exists to catch: one months out of date kept
|
|
17282
|
+
* serving requests while silently ignoring newer arguments, and nothing
|
|
17283
|
+
* ever said so. Absence is a staleness signal.
|
|
17284
|
+
*
|
|
17285
|
+
* The dev sentinel is exempt on both sides: a source checkout loads at
|
|
17286
|
+
* "0.0.0" and is expected to diverge.
|
|
17287
|
+
*
|
|
17288
|
+
* Exported for tests — this predicate is the whole invariant, and it had
|
|
17289
|
+
* no coverage when it silently regressed.
|
|
17290
|
+
*/
|
|
17291
|
+
function isExtensionStale(expected, loaded) {
|
|
17292
|
+
if (typeof expected !== "string" || expected === DEV_VERSION_SENTINEL) return false;
|
|
17293
|
+
if (typeof loaded !== "string") return true;
|
|
17294
|
+
if (loaded === DEV_VERSION_SENTINEL) return false;
|
|
17295
|
+
return loaded !== expected;
|
|
17296
|
+
}
|
|
17297
|
+
/**
|
|
18594
17298
|
* Full pre-flight. Returns either `{install_required: false, port,
|
|
18595
17299
|
* token, pid}` (bridge ready, extension connected) or an
|
|
18596
17300
|
* `install_required` payload the dispatcher hands directly to the
|
|
@@ -18610,9 +17314,41 @@ let _inFlightReady;
|
|
|
18610
17314
|
* Always 0 in production (only incremented when imported by tests).
|
|
18611
17315
|
*/
|
|
18612
17316
|
let __implInvocationsForTests = 0;
|
|
17317
|
+
/**
|
|
17318
|
+
* Short-lived cache of a READY result.
|
|
17319
|
+
*
|
|
17320
|
+
* The single-flight above only collapses CONCURRENT callers — it clears in
|
|
17321
|
+
* `.finally()`, so sequential calls each re-run the full impl. Every browser
|
|
17322
|
+
* tool call runs the check twice (once in `browserPreflight` before slot
|
|
17323
|
+
* acquisition, once in `dispatchBrowserTool`, which is deliberate so internal
|
|
17324
|
+
* compound dispatches stay fail-closed), and each run re-probes health and
|
|
17325
|
+
* re-installs the native-messaging host — spawning `where`/`which` (~196ms on
|
|
17326
|
+
* Windows) and `reg.exe` (~26ms) per detected browser, synchronously.
|
|
17327
|
+
*
|
|
17328
|
+
* Only a READY result is cached, and only briefly:
|
|
17329
|
+
*
|
|
17330
|
+
* - Caching `install_required` would freeze a recoverable state — the user
|
|
17331
|
+
* loading the extension, or installing a browser, would not take effect
|
|
17332
|
+
* until restart. Those paths must stay self-healing.
|
|
17333
|
+
* - The TTL is deliberately short. These checks also REPAIR drift (a deleted
|
|
17334
|
+
* manifest, a cleared registry key), so the window in which that repair is
|
|
17335
|
+
* skipped has to stay small. A few seconds collapses the double-check and
|
|
17336
|
+
* a burst of calls without turning a self-healing probe into a one-shot.
|
|
17337
|
+
*/
|
|
17338
|
+
const READY_CACHE_TTL_MS = 5e3;
|
|
17339
|
+
let _readyCache;
|
|
18613
17340
|
async function ensureBridgeReady() {
|
|
17341
|
+
const cached = _readyCache;
|
|
17342
|
+
if (cached && Date.now() - cached.at < READY_CACHE_TTL_MS) return cached.value;
|
|
18614
17343
|
if (_inFlightReady) return _inFlightReady;
|
|
18615
|
-
_inFlightReady = _ensureBridgeReadyImpl().
|
|
17344
|
+
_inFlightReady = _ensureBridgeReadyImpl().then((result) => {
|
|
17345
|
+
if (!result.install_required) _readyCache = {
|
|
17346
|
+
at: Date.now(),
|
|
17347
|
+
value: result
|
|
17348
|
+
};
|
|
17349
|
+
else _readyCache = void 0;
|
|
17350
|
+
return result;
|
|
17351
|
+
}).finally(() => {
|
|
18616
17352
|
_inFlightReady = void 0;
|
|
18617
17353
|
});
|
|
18618
17354
|
return _inFlightReady;
|
|
@@ -18623,28 +17359,30 @@ async function _ensureBridgeReadyImpl() {
|
|
|
18623
17359
|
const browsers = detectSupportedBrowsers();
|
|
18624
17360
|
if (browsers.length === 0) return buildInstallRequired("no_supported_browser", []);
|
|
18625
17361
|
if (!bridgeBundleExists()) return buildInstallRequired("bridge_bundle_missing", []);
|
|
18626
|
-
const
|
|
17362
|
+
const installNow = () => installNativeHostForAll(browsers).map((r) => `nmh_manifest_${r.browser}`);
|
|
18627
17363
|
const discovery = readBridgeDiscovery();
|
|
18628
|
-
if (!discovery) return buildInstallRequired("bridge_not_running",
|
|
17364
|
+
if (!discovery) return buildInstallRequired("bridge_not_running", installNow());
|
|
18629
17365
|
const health = await probeHealth(discovery.port, discovery.token);
|
|
18630
|
-
if (!health || !health.ok) return buildInstallRequired("bridge_not_running",
|
|
18631
|
-
if (!health.extension_connected) return buildInstallRequired("extension_not_loaded",
|
|
17366
|
+
if (!health || !health.ok) return buildInstallRequired("bridge_not_running", installNow());
|
|
17367
|
+
if (!health.extension_connected) return buildInstallRequired("extension_not_loaded", installNow());
|
|
18632
17368
|
const expectedVersion = loadExpectedExtensionVersion();
|
|
18633
17369
|
const loadedVersion = health.extension_loaded_version;
|
|
18634
|
-
|
|
17370
|
+
const loadedUnknown = typeof loadedVersion !== "string";
|
|
17371
|
+
if (typeof expectedVersion === "string" && isExtensionStale(expectedVersion, loadedVersion)) {
|
|
17372
|
+
const loadedLabel = loadedUnknown ? UNKNOWN_LOADED_VERSION : loadedVersion;
|
|
18635
17373
|
const reloadKey = `${loadStableExtensionId()}::${expectedVersion}`;
|
|
18636
|
-
if (attemptedReloads.has(reloadKey)) return buildInstallRequired("extension_outdated",
|
|
18637
|
-
loaded:
|
|
17374
|
+
if (attemptedReloads.has(reloadKey)) return buildInstallRequired("extension_outdated", NOTHING_AUTO_INSTALLED, {
|
|
17375
|
+
loaded: loadedLabel,
|
|
18638
17376
|
expected: expectedVersion
|
|
18639
17377
|
});
|
|
18640
17378
|
attemptedReloads.add(reloadKey);
|
|
18641
|
-
if (!await postReload(discovery.port, discovery.token)) return buildInstallRequired("extension_outdated",
|
|
18642
|
-
loaded:
|
|
17379
|
+
if (!await postReload(discovery.port, discovery.token)) return buildInstallRequired("extension_outdated", NOTHING_AUTO_INSTALLED, {
|
|
17380
|
+
loaded: loadedLabel,
|
|
18643
17381
|
expected: expectedVersion
|
|
18644
17382
|
});
|
|
18645
17383
|
const newDiscovery = await pollUntilExtensionVersion(expectedVersion, 3e3, 150);
|
|
18646
|
-
if (!newDiscovery) return buildInstallRequired("extension_outdated",
|
|
18647
|
-
loaded:
|
|
17384
|
+
if (!newDiscovery) return buildInstallRequired("extension_outdated", NOTHING_AUTO_INSTALLED, {
|
|
17385
|
+
loaded: loadedLabel,
|
|
18648
17386
|
expected: expectedVersion
|
|
18649
17387
|
});
|
|
18650
17388
|
return {
|
|
@@ -19161,6 +17899,30 @@ async function browserPreflight(tool, args) {
|
|
|
19161
17899
|
if (ready.install_required) return { envelope: installRequiredToolResult(ready) };
|
|
19162
17900
|
return { envelope: void 0 };
|
|
19163
17901
|
}
|
|
17902
|
+
const WINDOW_POLICIES = [
|
|
17903
|
+
"deny",
|
|
17904
|
+
"restore",
|
|
17905
|
+
"foreground"
|
|
17906
|
+
];
|
|
17907
|
+
const DEFAULT_WINDOW_POLICY = "foreground";
|
|
17908
|
+
/**
|
|
17909
|
+
* Resolve the window-remediation policy for pixel capture.
|
|
17910
|
+
*
|
|
17911
|
+
* `captureVisibleTab` cannot produce a frame for a window the browser
|
|
17912
|
+
* is not painting, so capturing against a minimized window requires
|
|
17913
|
+
* making it paintable first. Whether the proxy is allowed to touch the
|
|
17914
|
+
* user's window state is the user's call, not the model's — so it comes
|
|
17915
|
+
* from the environment and is injected here, rather than being an
|
|
17916
|
+
* argument the model chooses. Note this is a usability control, not a
|
|
17917
|
+
* security boundary: the default is the permissive setting, so a model
|
|
17918
|
+
* that simply omits the argument already gets the most intrusive
|
|
17919
|
+
* behavior. The lever that actually restrains the proxy is setting this
|
|
17920
|
+
* env var to `deny`.
|
|
17921
|
+
*/
|
|
17922
|
+
function resolveWindowPolicy() {
|
|
17923
|
+
const raw = process.env.GH_ROUTER_BROWSER_WINDOW_POLICY?.trim().toLowerCase();
|
|
17924
|
+
return WINDOW_POLICIES.includes(raw ?? "") ? raw : DEFAULT_WINDOW_POLICY;
|
|
17925
|
+
}
|
|
19164
17926
|
/**
|
|
19165
17927
|
* Real dispatcher for any browser_* tool. Used by the entries in
|
|
19166
17928
|
* src/lib/browser-mcp/index.ts. Returns the standard MCP tool-result
|
|
@@ -19175,10 +17937,14 @@ async function dispatchBrowserTool(tool, args, signal, opts = {}) {
|
|
|
19175
17937
|
const { defaultMs, maxMs } = pickTimeout(tool);
|
|
19176
17938
|
const callerTimeout = typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? Math.min(opts.timeoutMs, maxMs) : defaultMs;
|
|
19177
17939
|
try {
|
|
17940
|
+
const callArgs = tool === "browser_screenshot" ? {
|
|
17941
|
+
...args,
|
|
17942
|
+
windowPolicy: resolveWindowPolicy()
|
|
17943
|
+
} : args;
|
|
19178
17944
|
const resp = await bridgeCall({
|
|
19179
17945
|
port: ready.port,
|
|
19180
17946
|
token: ready.token
|
|
19181
|
-
}, tool,
|
|
17947
|
+
}, tool, callArgs, callerTimeout, signal);
|
|
19182
17948
|
if (resp.ok) {
|
|
19183
17949
|
logAudit$1({
|
|
19184
17950
|
tool,
|
|
@@ -19239,7 +18005,7 @@ function logAudit$1(record) {
|
|
|
19239
18005
|
try {
|
|
19240
18006
|
const fs = await import("node:fs/promises");
|
|
19241
18007
|
const path = await import("node:path");
|
|
19242
|
-
const { PATHS } = await import("./paths-
|
|
18008
|
+
const { PATHS } = await import("./paths-CwKwc2pi.js");
|
|
19243
18009
|
const dir = path.join(PATHS.APP_DIR, "browser-mcp");
|
|
19244
18010
|
await fs.mkdir(dir, { recursive: true });
|
|
19245
18011
|
const line = JSON.stringify({
|
|
@@ -20137,11 +18903,12 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal, retryT
|
|
|
20137
18903
|
}
|
|
20138
18904
|
const claudeModels = state.models?.data.filter((m) => m.id.startsWith("claude")).map((m) => m.id).join(", ") ?? "(models not loaded)";
|
|
20139
18905
|
consola.error(`Copilot rejected model "${payload.model}": ${response.status} ${errorBody} (available Claude models: ${claudeModels})`);
|
|
20140
|
-
|
|
18906
|
+
const reconstructed = new Response(errorBody, {
|
|
20141
18907
|
status: response.status,
|
|
20142
18908
|
statusText: response.statusText,
|
|
20143
18909
|
headers: response.headers
|
|
20144
|
-
})
|
|
18910
|
+
});
|
|
18911
|
+
throw new HTTPError("Failed to create chat completions", reconstructed);
|
|
20145
18912
|
}
|
|
20146
18913
|
if (payload.stream) return events(response);
|
|
20147
18914
|
const cappedResult = await readResponseBodyCapped(response, "/v1/chat/completions", MAX_RESPONSE_BODY_BYTES);
|
|
@@ -21188,7 +19955,7 @@ const BROWSER_TOOLS = Object.freeze([
|
|
|
21188
19955
|
},
|
|
21189
19956
|
{
|
|
21190
19957
|
toolNameHttp: "browser_screenshot",
|
|
21191
|
-
description: "Captures a screenshot of the visible area of a tab and returns it as an image you can actually look at, plus a small text envelope of capture metadata. It takes a tab id, an optional format (PNG default, JPEG for smaller bytes), and an optional JPEG quality. The tab must be active in its window, so this tool auto-activates the tab if needed and that changes which tab is focused. If a capture is rejected for exceeding a model's image-size limit, the most reliable lever is a smaller browser window; PNG is usually SMALLER than JPEG for ordinary UI and text pages, so switching format is not a dependable way to shrink one. JPEG plus a low quality helps mainly for photographic or dense-colour content. Use screenshot for visual layout, canvas, SVG, maps, or image-only regions; prefer browser_observe when page text and actionable state are enough.",
|
|
19958
|
+
description: "Captures a screenshot of the visible area of a tab and returns it as an image you can actually look at, plus a small text envelope of capture metadata. It takes a tab id, an optional format (PNG default, JPEG for smaller bytes), and an optional JPEG quality. The tab must be active in its window, so this tool auto-activates the tab if needed and that changes which tab is focused. The browser does not paint a minimized window, so a capture needs one that is on screen: by default the tool un-minimizes and raises the target window, which takes focus (the operator can set GH_ROUTER_BROWSER_WINDOW_POLICY=restore to un-minimize without taking focus, or =deny to leave window state untouched and fail instead). If a capture still cannot produce a frame it fails fast with a named code rather than hanging. WINDOW_MINIMIZED and CAPTURE_STALLED mean the window is not visible (minimized, fully covered, on another virtual desktop, or a locked session) and will keep failing until that changes, so make the window visible instead of retrying; TARGET_CHANGED and QUEUE_BUSY mean another tab switch or capture got in the way and a plain retry is the right response. If a capture is rejected for exceeding a model's image-size limit, the most reliable lever is a smaller browser window; PNG is usually SMALLER than JPEG for ordinary UI and text pages, so switching format is not a dependable way to shrink one. JPEG plus a low quality helps mainly for photographic or dense-colour content. Use screenshot for visual layout, canvas, SVG, maps, or image-only regions; prefer browser_observe when page text and actionable state are enough.",
|
|
21192
19959
|
inputSchema: {
|
|
21193
19960
|
type: "object",
|
|
21194
19961
|
required: ["tabId"],
|
|
@@ -26670,11 +25437,12 @@ async function createMessages(body, extraHeaders, callerSignal, retryTransient =
|
|
|
26670
25437
|
errorBody = "(could not read error body)";
|
|
26671
25438
|
}
|
|
26672
25439
|
consola.error(`Copilot /v1/messages error: ${response.status} ${errorBody}`);
|
|
26673
|
-
|
|
25440
|
+
const reconstructed = new Response(errorBody, {
|
|
26674
25441
|
status: response.status,
|
|
26675
25442
|
statusText: response.statusText,
|
|
26676
25443
|
headers: response.headers
|
|
26677
|
-
})
|
|
25444
|
+
});
|
|
25445
|
+
throw new HTTPError("Copilot messages request failed", reconstructed);
|
|
26678
25446
|
}
|
|
26679
25447
|
return response;
|
|
26680
25448
|
}
|
|
@@ -26717,11 +25485,12 @@ async function countTokens(body, extraHeaders, callerSignal, retryTransient = fa
|
|
|
26717
25485
|
errorBody = "(could not read error body)";
|
|
26718
25486
|
}
|
|
26719
25487
|
consola.error(`Copilot count_tokens error: ${response.status} ${errorBody}`);
|
|
26720
|
-
|
|
25488
|
+
const reconstructed = new Response(errorBody, {
|
|
26721
25489
|
status: response.status,
|
|
26722
25490
|
statusText: response.statusText,
|
|
26723
25491
|
headers: response.headers
|
|
26724
|
-
})
|
|
25492
|
+
});
|
|
25493
|
+
throw new HTTPError("Copilot count_tokens request failed", reconstructed);
|
|
26725
25494
|
}
|
|
26726
25495
|
return response;
|
|
26727
25496
|
}
|
|
@@ -28894,7 +27663,7 @@ async function throttleSearch() {
|
|
|
28894
27663
|
const waitMs = 1e3 - (now - searchTimestamps[0]);
|
|
28895
27664
|
if (waitMs > 0) {
|
|
28896
27665
|
consola.debug(`Web search rate limited, waiting ${waitMs}ms`);
|
|
28897
|
-
await sleep$
|
|
27666
|
+
await sleep$2(waitMs);
|
|
28898
27667
|
}
|
|
28899
27668
|
}
|
|
28900
27669
|
searchTimestamps.push(Date.now());
|
|
@@ -30900,7 +29669,7 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30900
29669
|
}
|
|
30901
29670
|
let entries;
|
|
30902
29671
|
try {
|
|
30903
|
-
entries = await fs.readdir(PATHS.WORKER_DIFFS_DIR);
|
|
29672
|
+
entries = await fs$1.readdir(PATHS.WORKER_DIFFS_DIR);
|
|
30904
29673
|
} catch {
|
|
30905
29674
|
return;
|
|
30906
29675
|
}
|
|
@@ -30909,8 +29678,8 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30909
29678
|
if (!WORKER_DIFF_NAME_RE.test(name)) continue;
|
|
30910
29679
|
const full = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
30911
29680
|
try {
|
|
30912
|
-
if (now - (await fs.stat(full)).mtimeMs < AGE_SWEEP_MS$1) continue;
|
|
30913
|
-
await fs.rm(full, { force: true }).catch(() => {});
|
|
29681
|
+
if (now - (await fs$1.stat(full)).mtimeMs < AGE_SWEEP_MS$1) continue;
|
|
29682
|
+
await fs$1.rm(full, { force: true }).catch(() => {});
|
|
30914
29683
|
} catch {}
|
|
30915
29684
|
}
|
|
30916
29685
|
}
|
|
@@ -30923,13 +29692,13 @@ async function sweepAgedWorkerDiffs(opts = {}) {
|
|
|
30923
29692
|
*/
|
|
30924
29693
|
async function saveOverflowResult(text) {
|
|
30925
29694
|
await sweepAgedWorkerDiffs({ throttle: true });
|
|
30926
|
-
await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
29695
|
+
await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
30927
29696
|
let lastErr;
|
|
30928
29697
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
30929
29698
|
const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.txt`;
|
|
30930
29699
|
const outPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
30931
29700
|
try {
|
|
30932
|
-
await fs.writeFile(outPath, text, {
|
|
29701
|
+
await fs$1.writeFile(outPath, text, {
|
|
30933
29702
|
mode: 384,
|
|
30934
29703
|
flag: "wx"
|
|
30935
29704
|
});
|
|
@@ -31123,7 +29892,7 @@ async function findRepoRoot(workspaceAbs) {
|
|
|
31123
29892
|
async function sweepAgedWorktrees(parent) {
|
|
31124
29893
|
let entries;
|
|
31125
29894
|
try {
|
|
31126
|
-
entries = await fs.readdir(parent);
|
|
29895
|
+
entries = await fs$1.readdir(parent);
|
|
31127
29896
|
} catch (err) {
|
|
31128
29897
|
if (err.code === "ENOENT") return;
|
|
31129
29898
|
return;
|
|
@@ -31133,7 +29902,7 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31133
29902
|
if (!WORKTREE_DIR_NAME_RE.test(name)) continue;
|
|
31134
29903
|
const full = path.join(parent, name);
|
|
31135
29904
|
try {
|
|
31136
|
-
const ageMs = now - (await fs.stat(full)).mtimeMs;
|
|
29905
|
+
const ageMs = now - (await fs$1.stat(full)).mtimeMs;
|
|
31137
29906
|
if (ageMs < AGE_SWEEP_MTIME_FLOOR_MS) continue;
|
|
31138
29907
|
if (ageMs < AGE_SWEEP_MS) continue;
|
|
31139
29908
|
await execFileP("git", [
|
|
@@ -31142,7 +29911,7 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31142
29911
|
"--force",
|
|
31143
29912
|
full
|
|
31144
29913
|
], { timeout: 1e4 }).catch(() => {});
|
|
31145
|
-
await fs.rm(full, {
|
|
29914
|
+
await fs$1.rm(full, {
|
|
31146
29915
|
recursive: true,
|
|
31147
29916
|
force: true
|
|
31148
29917
|
}).catch(() => {});
|
|
@@ -31161,11 +29930,11 @@ async function sweepAgedWorktrees(parent) {
|
|
|
31161
29930
|
async function createWorktree(workspaceAbs, opts) {
|
|
31162
29931
|
const { repoRoot, gitCommonDir } = await findRepoRoot(workspaceAbs);
|
|
31163
29932
|
const parent = path.join(gitCommonDir, "worker-worktrees");
|
|
31164
|
-
await fs.mkdir(parent, { recursive: true });
|
|
29933
|
+
await fs$1.mkdir(parent, { recursive: true });
|
|
31165
29934
|
await sweepAgedWorktrees(parent);
|
|
31166
29935
|
let existing = [];
|
|
31167
29936
|
try {
|
|
31168
|
-
existing = await fs.readdir(parent);
|
|
29937
|
+
existing = await fs$1.readdir(parent);
|
|
31169
29938
|
} catch {}
|
|
31170
29939
|
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
29940
|
const suffix = randomBytes(4).toString("hex");
|
|
@@ -31213,9 +29982,9 @@ async function createWorktree(workspaceAbs, opts) {
|
|
|
31213
29982
|
for (const rel of files) {
|
|
31214
29983
|
const src = path.join(repoRoot, rel);
|
|
31215
29984
|
const dst = path.join(dir, rel);
|
|
31216
|
-
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
29985
|
+
await fs$1.mkdir(path.dirname(dst), { recursive: true });
|
|
31217
29986
|
try {
|
|
31218
|
-
await fs.copyFile(src, dst);
|
|
29987
|
+
await fs$1.copyFile(src, dst);
|
|
31219
29988
|
} catch (err) {
|
|
31220
29989
|
if (err.code === "ENOENT") continue;
|
|
31221
29990
|
throw err;
|
|
@@ -31334,13 +30103,13 @@ async function saveOverflowPatch(dir) {
|
|
|
31334
30103
|
"HEAD"
|
|
31335
30104
|
], { maxBuffer: 268435456 });
|
|
31336
30105
|
await sweepAgedWorkerDiffs({ throttle: true });
|
|
31337
|
-
await fs.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
30106
|
+
await fs$1.mkdir(PATHS.WORKER_DIFFS_DIR, { recursive: true });
|
|
31338
30107
|
let lastErr;
|
|
31339
30108
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
31340
30109
|
const name = `${process$1.pid}-${randomBytes(4).toString("hex")}.patch`;
|
|
31341
30110
|
const patchPath = path.join(PATHS.WORKER_DIFFS_DIR, name);
|
|
31342
30111
|
try {
|
|
31343
|
-
await fs.writeFile(patchPath, patch.stdout, {
|
|
30112
|
+
await fs$1.writeFile(patchPath, patch.stdout, {
|
|
31344
30113
|
mode: 384,
|
|
31345
30114
|
flag: "wx"
|
|
31346
30115
|
});
|
|
@@ -32404,93 +31173,6 @@ function selectChampion(orchestrated, baseline, canonicalGateIds, tiePolicy) {
|
|
|
32404
31173
|
};
|
|
32405
31174
|
}
|
|
32406
31175
|
//#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
31176
|
//#region src/lib/orchestration/verify.ts
|
|
32495
31177
|
/**
|
|
32496
31178
|
* Static verifier for the workflow IR. This is what `decompose`'s output is
|
|
@@ -32982,186 +31664,6 @@ function makeRunner(deps, ctx) {
|
|
|
32982
31664
|
} };
|
|
32983
31665
|
}
|
|
32984
31666
|
//#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
31667
|
//#region src/lib/orchestration/runner-live.ts
|
|
33166
31668
|
/**
|
|
33167
31669
|
* The LIVE adapter for the kernel's NodeRunner primitives. buildLiveRunner wires
|
|
@@ -33264,665 +31766,6 @@ function buildLiveRunner(ctx, prim) {
|
|
|
33264
31766
|
};
|
|
33265
31767
|
}
|
|
33266
31768
|
//#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
31769
|
//#region src/lib/orchestration/attest.ts
|
|
33927
31770
|
function isNonEmptyString(v) {
|
|
33928
31771
|
return typeof v === "string" && v.length > 0;
|
|
@@ -35921,6 +33764,6 @@ function enumerateInjectedMcpToolNames(groupKeys, opts = {}) {
|
|
|
35921
33764
|
return [...new Set(names)];
|
|
35922
33765
|
}
|
|
35923
33766
|
//#endregion
|
|
35924
|
-
export {
|
|
33767
|
+
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
33768
|
|
|
35926
|
-
//# sourceMappingURL=peer-mcp-personas-
|
|
33769
|
+
//# sourceMappingURL=peer-mcp-personas-BPl6_V2A.js.map
|