github-router 0.3.240 → 0.3.241
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/browser-bridge/index.js +357 -369
- package/dist/browser-ext/background.js +2 -2
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/browser-ext/snapshot-cdp.js +1 -1
- package/dist/engine-DL15-epb.js +2 -0
- package/dist/lib/tree-sitter-pool/worker.js +36 -31
- package/dist/lifecycle-0rXrwjWk.js +2 -0
- package/dist/{lifecycle-Cuuj40CW.js → lifecycle-CUf1UDrH.js} +37 -17
- package/dist/lifecycle-CUf1UDrH.js.map +1 -0
- package/dist/{lifecycle-C-9_vhyM.js → lifecycle-DHZsVN8j.js} +39 -16
- package/dist/lifecycle-DHZsVN8j.js.map +1 -0
- package/dist/lifecycle-hzPwXc3I.js +2 -0
- package/dist/main.js +840 -408
- package/dist/main.js.map +1 -1
- package/dist/{paths-Srxs-aR-.js → paths-BjTMI_xK.js} +155 -54
- package/dist/{paths-Srxs-aR-.js.map → paths-BjTMI_xK.js.map} +1 -1
- package/dist/paths-D3XgmTcQ.js +2 -0
- package/dist/{peer-mcp-personas-CDja7arT.js → peer-mcp-personas-r_UmLBkJ.js} +1850 -1206
- package/dist/peer-mcp-personas-r_UmLBkJ.js.map +1 -0
- package/package.json +21 -21
- package/dist/engine-5VxiBAdY.js +0 -6
- package/dist/lifecycle-C-9_vhyM.js.map +0 -1
- package/dist/lifecycle-Cuuj40CW.js.map +0 -1
- package/dist/lifecycle-DF2ygqz8.js +0 -4
- package/dist/lifecycle-wIIt0wGo.js +0 -4
- package/dist/paths-C75DZ_Y3.js +0 -3
- package/dist/peer-mcp-personas-CDja7arT.js.map +0 -1
package/dist/main.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as searchWeb, $t as DEFAULT_CLAUDE_MODEL_FALLBACKS, A as trustRepo, An as state, At as getTokenCount, Bt as hasSupportedBrowserInstalled, C as fileLastPromptStore, Cn as fetchWithTransientRetry, Ct as scoutModel, D as repoRoot, Dn as copilotBaseUrl, Dt as shimDefaultsToXhigh, E as repoFingerprint, En as GITHUB_API_BASE_URL, Et as workerToolsEnabled, Ft as createChatCompletions, G as buildEnv, Gt as CONDENSED_OPERATING_SEQUENCE, Ht as extractTarGzMember, It as MAX_RESPONSE_BODY_BYTES, J as toolbeltEnabled, Jt as ArtifactClient, K as availableToolCommands, Kt as DEFINITION_OF_GREATNESS, Lt as readResponseBodyCapped, M as resolveSealedGate, Mt as resolveMcpToolTimeoutMs, Nt as pickEndpoint, O as stopGateEnabledForRepo, On as copilotHeaders, Ot as countTokens, Pt as createResponses, Q as assetFor, Qt as toolbeltPathOverride, Rt as parseJsonOrDiagnose, S as fileFindingsStore, Sn as getGitHubUser, St as reviewerModel, T as isSubagentContext, Tn as forwardError, Tt as standInToolEnabled, Ut as extractZipMember, Vt as provisionAndIndexColbert, Wt as warmTreeSitterPool, X as vscodeRipgrepPath, Xt as buildWorkspaceHeaderJson, Y as toolbeltSkipSet, Yt as buildWorkspaceHeaderHelperCommand, Z as TOOLBELT_TOOLS, Zt as collapsePathKeys, _ as stopGateDisabled, _n as isNullish, _t as browserCompoundToolsEnabled, a as buildPeerAwarenessSnippet, an as generateRandomPort, at as buildAnthropicErrorEvent, b as stopReviewEnabled, bn as sleep$1, bt as geminiAvailable, c as personasFor, cn as withInstallLock, ct as logStreamError, d as buildStopHookCommand, dn as setupGitHubToken, dt as handleMcpDelete, en as DEFAULT_CODEX_MODEL, et as ADVISOR_INTERNAL_TOOL_NAME, f as captureLaunchBaseline, fn as tryRefreshAndRetry, ft as handleMcpPost, g as launchBaselineKey, gn as filterBetaHeader, gt as browseAgentEnabled, h as injectStopHookIntoSettingsFile, hn as cacheVSCodeVersion, ht as brainstormModel, i as buildAgentPrompt, in as UPSTREAM_INACTIVITY_TIMEOUT_MS, it as isAdvisorRequested, j as liveExec, jt as assembleResponsesPayload, k as stopReviewStateDir, kn as githubHeaders, kt as createMessages, l as buildArtifactOpenHookCommand, ln as setupCopilotToken, lt as readIteratorWithTimeout, m as fileBlockBudget, mn as cacheModels, mt as artifactToolsEnabled, n as MCP_GROUPS,
|
|
3
|
-
import { a as isUnderClaudeConfigMirror, d as writeRuntimeFileSecure, i as ensurePaths, o as removeOwnClaudeConfigMirror, r as ensureClaudeConfigMirror, t as PATHS, u as writeArtifactCredsToMirror } from "./paths-
|
|
4
|
-
import { c as killManagedTree, d as resolveExecutable, f as runCommandCapture, l as parseBoolEnv, p as runCommandVoid, s as killChildProcessTree, u as parseIntEnv } from "./lifecycle-
|
|
5
|
-
import { a as sweepRegistry } from "./lifecycle-
|
|
2
|
+
import { $ as searchWeb, $t as DEFAULT_CLAUDE_MODEL_FALLBACKS, A as trustRepo, An as state, At as getTokenCount, Bt as hasSupportedBrowserInstalled, C as fileLastPromptStore, Cn as fetchWithTransientRetry, Ct as scoutModel, D as repoRoot, Dn as copilotBaseUrl, Dt as shimDefaultsToXhigh, E as repoFingerprint, En as GITHUB_API_BASE_URL, Et as workerToolsEnabled, Ft as createChatCompletions, G as buildEnv, Gt as CONDENSED_OPERATING_SEQUENCE, Ht as extractTarGzMember, It as MAX_RESPONSE_BODY_BYTES, J as toolbeltEnabled, Jt as ArtifactClient, K as availableToolCommands, Kt as DEFINITION_OF_GREATNESS, Lt as readResponseBodyCapped, M as resolveSealedGate, Mt as resolveMcpToolTimeoutMs, Nt as pickEndpoint, O as stopGateEnabledForRepo, On as copilotHeaders, Ot as countTokens, Pt as createResponses, Q as assetFor, Qt as toolbeltPathOverride, Rt as parseJsonOrDiagnose, S as fileFindingsStore, Sn as getGitHubUser, St as reviewerModel, T as isSubagentContext, Tn as forwardError, Tt as standInToolEnabled, Ut as extractZipMember, Vt as provisionAndIndexColbert, Wt as warmTreeSitterPool, X as vscodeRipgrepPath, Xt as buildWorkspaceHeaderJson, Y as toolbeltSkipSet, Yt as buildWorkspaceHeaderHelperCommand, Z as TOOLBELT_TOOLS, Zt as collapsePathKeys, _ as stopGateDisabled, _n as isNullish, _t as browserCompoundToolsEnabled, a as buildPeerAwarenessSnippet, an as generateRandomPort, at as buildAnthropicErrorEvent, b as stopReviewEnabled, bn as sleep$1, bt as geminiAvailable, c as personasFor, cn as withInstallLock, ct as logStreamError, d as buildStopHookCommand, dn as setupGitHubToken, dt as handleMcpDelete, en as DEFAULT_CODEX_MODEL, et as ADVISOR_INTERNAL_TOOL_NAME, f as captureLaunchBaseline, fn as tryRefreshAndRetry, ft as handleMcpPost, g as launchBaselineKey, gn as filterBetaHeader, gt as browseAgentEnabled, h as injectStopHookIntoSettingsFile, hn as cacheVSCodeVersion, ht as brainstormModel, i as buildAgentPrompt, in as UPSTREAM_INACTIVITY_TIMEOUT_MS, it as isAdvisorRequested, j as liveExec, jt as assembleResponsesPayload, k as stopReviewStateDir, kn as githubHeaders, kt as createMessages, l as buildArtifactOpenHookCommand, ln as setupCopilotToken, lt as readIteratorWithTimeout, m as fileBlockBudget, mn as cacheModels, mt as artifactToolsEnabled, n as MCP_GROUPS, nt as buildAdvisorStream, o as buildPeerAwarenessSummary, on as pickClaudeDefault, ot as buildOpenAIErrorEvent, p as decideStopHook, pn as cacheCopilotVersion, pt as agentToolsEnabled, q as buildToolbeltAwareness, qt as shouldUseInsecureTls, r as assertMcpToolSurfaceConsistent, rn as UPSTREAM_FETCH_TIMEOUT_MS, rt as injectAdvisorTool, s as enumerateInjectedMcpToolNames, sn as getPackageVersion$1, st as isControllerClosedError, t as GROUP_META, tn as DEFAULT_CODEX_MODEL_FALLBACKS, tt as ADVISOR_TOOL_INSTRUCTIONS, u as buildSessionBindHookCommand, un as setupGitHubAgentToken, ut as relayAnthropicStream, v as stopGateId, vn as resolveCodexModel, vt as browserToolsEnabled, w as fileReviewDebounce, wn as HTTPError, wt as scribeModel, x as fileBaselineStore, xn as getModels, xt as nativeSubagentModel, y as stopGatePlanMode, yn as resolveModel, yt as fleetToolsEnabled, zt as provisionBrowserAssets } from "./peer-mcp-personas-r_UmLBkJ.js";
|
|
3
|
+
import { a as isUnderClaudeConfigMirror, d as writeRuntimeFileSecure, i as ensurePaths, o as removeOwnClaudeConfigMirror, r as ensureClaudeConfigMirror, t as PATHS, u as writeArtifactCredsToMirror } from "./paths-BjTMI_xK.js";
|
|
4
|
+
import { c as killManagedTree, d as resolveExecutable, f as runCommandCapture, l as parseBoolEnv, p as runCommandVoid, s as killChildProcessTree, u as parseIntEnv } from "./lifecycle-DHZsVN8j.js";
|
|
5
|
+
import { a as sweepRegistry } from "./lifecycle-CUf1UDrH.js";
|
|
6
6
|
import { defineCommand, runMain } from "citty";
|
|
7
7
|
import consola from "consola";
|
|
8
8
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
9
|
import fs, { chmod, copyFile, link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
10
10
|
import os, { homedir, tmpdir } from "node:os";
|
|
11
|
-
import * as nodePath
|
|
12
|
-
import
|
|
11
|
+
import * as nodePath from "node:path";
|
|
12
|
+
import path from "node:path";
|
|
13
13
|
import process$1 from "node:process";
|
|
14
14
|
import { execFileSync, spawn } from "node:child_process";
|
|
15
15
|
import * as fs$2 from "node:fs";
|
|
@@ -24,7 +24,6 @@ import { cors } from "hono/cors";
|
|
|
24
24
|
import { Marked } from "marked";
|
|
25
25
|
import http from "node:http";
|
|
26
26
|
import clipboard from "clipboardy";
|
|
27
|
-
|
|
28
27
|
//#region src/auth.ts
|
|
29
28
|
async function runAuth(options) {
|
|
30
29
|
if (options.verbose) {
|
|
@@ -61,7 +60,6 @@ const auth = defineCommand({
|
|
|
61
60
|
});
|
|
62
61
|
}
|
|
63
62
|
});
|
|
64
|
-
|
|
65
63
|
//#endregion
|
|
66
64
|
//#region src/services/github/get-copilot-usage.ts
|
|
67
65
|
const getCopilotUsage = async () => {
|
|
@@ -69,7 +67,6 @@ const getCopilotUsage = async () => {
|
|
|
69
67
|
if (!response.ok) throw new HTTPError("Failed to get Copilot usage", response);
|
|
70
68
|
return await response.json();
|
|
71
69
|
};
|
|
72
|
-
|
|
73
70
|
//#endregion
|
|
74
71
|
//#region src/check-usage.ts
|
|
75
72
|
const checkUsage = defineCommand({
|
|
@@ -87,13 +84,13 @@ const checkUsage = defineCommand({
|
|
|
87
84
|
const premiumUsed = premiumTotal - premium.remaining;
|
|
88
85
|
const premiumPercentUsed = premiumTotal > 0 ? premiumUsed / premiumTotal * 100 : 0;
|
|
89
86
|
const premiumPercentRemaining = premium.percent_remaining;
|
|
90
|
-
function summarizeQuota(name
|
|
91
|
-
if (!snap) return `${name
|
|
87
|
+
function summarizeQuota(name, snap) {
|
|
88
|
+
if (!snap) return `${name}: N/A`;
|
|
92
89
|
const total = snap.entitlement;
|
|
93
90
|
const used = total - snap.remaining;
|
|
94
91
|
const percentUsed = total > 0 ? used / total * 100 : 0;
|
|
95
92
|
const percentRemaining = snap.percent_remaining;
|
|
96
|
-
return `${name
|
|
93
|
+
return `${name}: ${used}/${total} used (${percentUsed.toFixed(1)}% used, ${percentRemaining.toFixed(1)}% remaining)`;
|
|
97
94
|
}
|
|
98
95
|
const premiumLine = `Premium: ${premiumUsed}/${premiumTotal} used (${premiumPercentUsed.toFixed(1)}% used, ${premiumPercentRemaining.toFixed(1)}% remaining)`;
|
|
99
96
|
const chatLine = summarizeQuota("Chat", usage.quota_snapshots.chat);
|
|
@@ -105,7 +102,6 @@ const checkUsage = defineCommand({
|
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
104
|
});
|
|
108
|
-
|
|
109
105
|
//#endregion
|
|
110
106
|
//#region src/lib/claude-version-check.ts
|
|
111
107
|
const NPM_PACKAGE$1 = "@anthropic-ai/claude-code";
|
|
@@ -115,7 +111,7 @@ const CLAUDE_VERSION_TIMEOUT_MS = 3e3;
|
|
|
115
111
|
const NPM_INSTALL_TIMEOUT_MS = 12e4;
|
|
116
112
|
/** Path to the throttle cache. Created on demand. */
|
|
117
113
|
function cacheFilePath$1() {
|
|
118
|
-
return
|
|
114
|
+
return path.join(os.homedir(), ".local", "share", "github-router", "last-update-check");
|
|
119
115
|
}
|
|
120
116
|
/**
|
|
121
117
|
* Read the throttle cache. Returns null on missing/corrupt file —
|
|
@@ -133,7 +129,7 @@ async function readCache$1() {
|
|
|
133
129
|
}
|
|
134
130
|
async function writeCache$1(cache) {
|
|
135
131
|
try {
|
|
136
|
-
await fs.mkdir(
|
|
132
|
+
await fs.mkdir(path.dirname(cacheFilePath$1()), { recursive: true });
|
|
137
133
|
await fs.writeFile(cacheFilePath$1(), JSON.stringify(cache), { mode: 384 });
|
|
138
134
|
} catch (err) {
|
|
139
135
|
consola.debug("Failed to write claude version-check cache:", err);
|
|
@@ -309,14 +305,29 @@ async function updateClaude(latestVersion, deps = {}) {
|
|
|
309
305
|
consola.success(`${NPM_PACKAGE$1} updated to ${latestVersion}`);
|
|
310
306
|
})) consola.debug("Claude Code update already in progress in another process; skipping.");
|
|
311
307
|
}
|
|
312
|
-
|
|
313
308
|
//#endregion
|
|
314
309
|
//#region src/lib/self-update.ts
|
|
310
|
+
/**
|
|
311
|
+
* Self-update for the `github-router` proxy.
|
|
312
|
+
*
|
|
313
|
+
* The proxy is a *running* process, so updating its own global install
|
|
314
|
+
* in place (`npm install -g github-router@latest`) deletes/overwrites
|
|
315
|
+
* files the OS holds open — on Windows that fails deterministically
|
|
316
|
+
* (EPERM/EBUSY) and can corrupt the install. So we never install in
|
|
317
|
+
* place: when a newer version exists we spawn a **detached updater that
|
|
318
|
+
* waits for this process to exit**, then installs. The update takes
|
|
319
|
+
* effect on the next launch — no re-exec, no restart prompt.
|
|
320
|
+
*
|
|
321
|
+
* Best-effort throughout: every failure is swallowed to debug/warn and
|
|
322
|
+
* never blocks startup. The startup probe is bounded and the install is
|
|
323
|
+
* off the critical path (detached), so even a blackholed network can't
|
|
324
|
+
* delay the proxy from serving.
|
|
325
|
+
*/
|
|
315
326
|
const NPM_PACKAGE = "github-router";
|
|
316
327
|
const THROTTLE_HOURS = 1;
|
|
317
328
|
const NPM_VIEW_TIMEOUT_MS = 5e3;
|
|
318
329
|
function cacheFilePath() {
|
|
319
|
-
return
|
|
330
|
+
return path.join(os.homedir(), ".local", "share", "github-router", "last-self-update-check");
|
|
320
331
|
}
|
|
321
332
|
async function readCache() {
|
|
322
333
|
try {
|
|
@@ -329,7 +340,7 @@ async function readCache() {
|
|
|
329
340
|
}
|
|
330
341
|
async function writeCache(cache) {
|
|
331
342
|
try {
|
|
332
|
-
await fs.mkdir(
|
|
343
|
+
await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
|
|
333
344
|
await fs.writeFile(cacheFilePath(), JSON.stringify(cache), { mode: 384 });
|
|
334
345
|
} catch (err) {
|
|
335
346
|
consola.debug("Failed to write self-update cache:", err);
|
|
@@ -406,7 +417,7 @@ async function runSelfUpdate(opts) {
|
|
|
406
417
|
try {
|
|
407
418
|
const cache = await readCache();
|
|
408
419
|
if (!opts.force && !shouldCheckNow(cache)) return;
|
|
409
|
-
const installed = getPackageVersion();
|
|
420
|
+
const installed = getPackageVersion$1();
|
|
410
421
|
if (installed === "unknown") return;
|
|
411
422
|
const npmPath = resolveExecutable("npm");
|
|
412
423
|
if (!npmPath) return;
|
|
@@ -424,10 +435,40 @@ async function runSelfUpdate(opts) {
|
|
|
424
435
|
consola.debug("Self-update check failed:", err);
|
|
425
436
|
}
|
|
426
437
|
}
|
|
427
|
-
|
|
428
438
|
//#endregion
|
|
429
439
|
//#region src/lib/process-guard/index.ts
|
|
430
440
|
/**
|
|
441
|
+
* Crash-safe process guard (POSIX only).
|
|
442
|
+
*
|
|
443
|
+
* If the proxy dies WITHOUT running its own shutdown handlers (hard crash,
|
|
444
|
+
* SIGKILL, OOM), nothing in `cleanup()` runs to reap the launched CLI's
|
|
445
|
+
* process tree. This guard is the last-resort net for that case.
|
|
446
|
+
*
|
|
447
|
+
* Platform split:
|
|
448
|
+
* - **Windows: no guard needed.** Node assigns each child to a Job
|
|
449
|
+
* Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so when the proxy
|
|
450
|
+
* dies (any cause) the OS tears down the whole descendant tree —
|
|
451
|
+
* verified: force-killing a Node parent kills child AND grandchild.
|
|
452
|
+
* `startProcessGuard` is a no-op on win32.
|
|
453
|
+
* - **POSIX: a detached `node -e` reaper.** There is no job-object
|
|
454
|
+
* equivalent (a killed parent's children reparent to init), so we
|
|
455
|
+
* spawn a detached watchdog that learns of proxy death via an inherited
|
|
456
|
+
* stdin PIPE EOF (the proxy holds the write end; the OS closes it on
|
|
457
|
+
* proxy death) and reaps the CLI's process group.
|
|
458
|
+
*
|
|
459
|
+
* Never kill the WRONG process: if the child's PID/PGID was recycled after
|
|
460
|
+
* the child exited, a blind `kill(-pid)` could hit an innocent group. The
|
|
461
|
+
* reaper snapshots the child's process start-time at startup and, on proxy
|
|
462
|
+
* death, kills ONLY if the live PID's start-time still matches. A mismatch
|
|
463
|
+
* / unreadable probe → skip (fail-safe). We never trade an orphan-leak for
|
|
464
|
+
* killing the user's work.
|
|
465
|
+
*
|
|
466
|
+
* The reaper is detached + unref'd so it survives the proxy's SIGKILL, and
|
|
467
|
+
* self-exits once it has acted (or after a 24h cap) so it can never become
|
|
468
|
+
* the orphan it exists to prevent. Opt out with
|
|
469
|
+
* `GH_ROUTER_DISABLE_PROCESS_GUARD=1`.
|
|
470
|
+
*/
|
|
471
|
+
/**
|
|
431
472
|
* Live reaper children, held so the GC can't collect the `ChildProcess`
|
|
432
473
|
* (and with it the stdin write-end that is the proxy-death signal) while
|
|
433
474
|
* the proxy runs. Entries self-remove on the reaper's exit. We do NOT
|
|
@@ -538,7 +579,6 @@ function startProcessGuard(child) {
|
|
|
538
579
|
if (process$1.platform === "win32") return;
|
|
539
580
|
spawnNodeReaper(pid, true);
|
|
540
581
|
}
|
|
541
|
-
|
|
542
582
|
//#endregion
|
|
543
583
|
//#region src/lib/stripped-env-keys.ts
|
|
544
584
|
/**
|
|
@@ -589,7 +629,6 @@ const STRIPPED_AUTH_ROUTING_ENV_KEYS = [
|
|
|
589
629
|
"AIORDIE_TOKEN",
|
|
590
630
|
"AIORDIE_INSECURE_TLS"
|
|
591
631
|
];
|
|
592
|
-
|
|
593
632
|
//#endregion
|
|
594
633
|
//#region src/lib/launch.ts
|
|
595
634
|
/**
|
|
@@ -616,9 +655,9 @@ function sanitizeParentEnv(parent) {
|
|
|
616
655
|
for (const key of STRIPPED_PARENT_ENV_KEYS) delete sanitized[key];
|
|
617
656
|
return sanitized;
|
|
618
657
|
}
|
|
619
|
-
function commandExists(name
|
|
658
|
+
function commandExists(name) {
|
|
620
659
|
try {
|
|
621
|
-
execFileSync(process$1.platform === "win32" ? "where.exe" : "which", [name
|
|
660
|
+
execFileSync(process$1.platform === "win32" ? "where.exe" : "which", [name], { stdio: "ignore" });
|
|
622
661
|
return true;
|
|
623
662
|
} catch {
|
|
624
663
|
return false;
|
|
@@ -637,7 +676,7 @@ function commandExists(name$1) {
|
|
|
637
676
|
* installed fails with a spurious "not found on PATH".
|
|
638
677
|
*/
|
|
639
678
|
function isExecutableAvailable(executable) {
|
|
640
|
-
if (
|
|
679
|
+
if (path.isAbsolute(executable)) return existsSync(executable);
|
|
641
680
|
return commandExists(executable);
|
|
642
681
|
}
|
|
643
682
|
/**
|
|
@@ -683,10 +722,10 @@ function getCodexVersion() {
|
|
|
683
722
|
};
|
|
684
723
|
const major = Number.parseInt(m[1], 10);
|
|
685
724
|
const minor = Number.parseInt(m[2], 10);
|
|
686
|
-
const version
|
|
725
|
+
const version = `${m[1]}.${m[2]}.${m[3]}`;
|
|
687
726
|
return {
|
|
688
727
|
ok: major > 0 || major === 0 && minor >= 129,
|
|
689
|
-
version
|
|
728
|
+
version
|
|
690
729
|
};
|
|
691
730
|
}
|
|
692
731
|
/**
|
|
@@ -704,7 +743,7 @@ function getCodexVersion() {
|
|
|
704
743
|
function buildCodexCmd(target) {
|
|
705
744
|
const cmd = ["codex"];
|
|
706
745
|
if (target.serverUrl) cmd.push(...buildCodexProviderConfigFlags(target.serverUrl));
|
|
707
|
-
cmd.push("--sandbox", "workspace-write", "--ask-for-approval", "on-request", "-m", target.model ??
|
|
746
|
+
cmd.push("--sandbox", "workspace-write", "--ask-for-approval", "on-request", "-m", target.model ?? "gpt-5.6-sol", ...target.extraArgs);
|
|
708
747
|
return cmd;
|
|
709
748
|
}
|
|
710
749
|
function buildLaunchCommand(target) {
|
|
@@ -733,10 +772,10 @@ function buildLaunchCommand(target) {
|
|
|
733
772
|
* intermediary to orphan its node/MCP grandchildren on a kill.
|
|
734
773
|
*/
|
|
735
774
|
function windowsLaunchNeedsShell(executable) {
|
|
736
|
-
const ext =
|
|
775
|
+
const ext = path.extname(executable).toLowerCase();
|
|
737
776
|
return ext === ".cmd" || ext === ".bat";
|
|
738
777
|
}
|
|
739
|
-
function launchChild(target, server
|
|
778
|
+
function launchChild(target, server, options = {}) {
|
|
740
779
|
const { cmd, env } = buildLaunchCommand(target);
|
|
741
780
|
const executable = cmd[0];
|
|
742
781
|
if (!isExecutableAvailable(executable)) {
|
|
@@ -747,12 +786,14 @@ function launchChild(target, server$1, options = {}) {
|
|
|
747
786
|
}
|
|
748
787
|
let child;
|
|
749
788
|
try {
|
|
750
|
-
if (process$1.platform === "win32") if (windowsLaunchNeedsShell(cmd[0]))
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
789
|
+
if (process$1.platform === "win32") if (windowsLaunchNeedsShell(cmd[0])) {
|
|
790
|
+
const quoted = cmd.map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
|
|
791
|
+
child = spawn(quoted, [], {
|
|
792
|
+
env,
|
|
793
|
+
stdio: "inherit",
|
|
794
|
+
shell: true
|
|
795
|
+
});
|
|
796
|
+
} else child = spawn(cmd[0], cmd.slice(1), {
|
|
756
797
|
env,
|
|
757
798
|
stdio: "inherit"
|
|
758
799
|
});
|
|
@@ -765,7 +806,7 @@ function launchChild(target, server$1, options = {}) {
|
|
|
765
806
|
const msg = `Failed to launch ${executable}: ${error instanceof Error ? error.message : String(error)}`;
|
|
766
807
|
consola.error(msg);
|
|
767
808
|
process$1.stderr.write(msg + "\n");
|
|
768
|
-
server
|
|
809
|
+
server.close(true).catch(() => {});
|
|
769
810
|
if (options.onShutdown) Promise.resolve(options.onShutdown()).catch(() => {});
|
|
770
811
|
process$1.exit(1);
|
|
771
812
|
}
|
|
@@ -780,7 +821,7 @@ function launchChild(target, server$1, options = {}) {
|
|
|
780
821
|
killChildProcessTree(child, { detachedGroup: process$1.platform !== "win32" });
|
|
781
822
|
} catch {}
|
|
782
823
|
try {
|
|
783
|
-
await server
|
|
824
|
+
await server.close(true);
|
|
784
825
|
} catch {}
|
|
785
826
|
if (options.onShutdown) try {
|
|
786
827
|
await options.onShutdown();
|
|
@@ -830,7 +871,6 @@ function launchChild(target, server$1, options = {}) {
|
|
|
830
871
|
cleanup().then(() => exit(1)).catch(() => exit(1));
|
|
831
872
|
});
|
|
832
873
|
}
|
|
833
|
-
|
|
834
874
|
//#endregion
|
|
835
875
|
//#region src/lib/worker-dispatch.ts
|
|
836
876
|
/**
|
|
@@ -883,8 +923,7 @@ const ALL_WORKER_DISPATCH_MODES = [...CORE_WORKER_MODES, BROWSE_WORKER_MODE];
|
|
|
883
923
|
function dispatcherAgentName(mode) {
|
|
884
924
|
return `worker-${mode}`;
|
|
885
925
|
}
|
|
886
|
-
|
|
887
|
-
const ALL_DISPATCHER_AGENT_NAMES = ALL_WORKER_DISPATCH_MODES.map(dispatcherAgentName);
|
|
926
|
+
ALL_WORKER_DISPATCH_MODES.map(dispatcherAgentName);
|
|
888
927
|
/** The MCP tool name the workers server exposes for a mode under the resolved key. */
|
|
889
928
|
function workerToolName(workersKey, mode) {
|
|
890
929
|
return `mcp__${workersKey}__${mode}`;
|
|
@@ -1051,7 +1090,6 @@ function parseModesCsv(csv) {
|
|
|
1051
1090
|
const out = csv.split(",").map((s) => s.trim()).filter((s) => known.has(s));
|
|
1052
1091
|
return out.length > 0 ? out : [...CORE_WORKER_MODES];
|
|
1053
1092
|
}
|
|
1054
|
-
|
|
1055
1093
|
//#endregion
|
|
1056
1094
|
//#region src/lib/codex-mcp-config.ts
|
|
1057
1095
|
/** The `peers` server is always enabled, so its resolved key always exists;
|
|
@@ -1345,10 +1383,10 @@ function buildPeerAgentDefinitions(opts) {
|
|
|
1345
1383
|
...peersMcp
|
|
1346
1384
|
};
|
|
1347
1385
|
const nativeModel = nonEmptyModel(opts.nativeSubagentModel);
|
|
1348
|
-
const reviewerModel
|
|
1349
|
-
const brainstormModel
|
|
1350
|
-
const scoutModel
|
|
1351
|
-
const scribeModel
|
|
1386
|
+
const reviewerModel = nonEmptyModel(opts.reviewerModel);
|
|
1387
|
+
const brainstormModel = nonEmptyModel(opts.brainstormModel);
|
|
1388
|
+
const scoutModel = nonEmptyModel(opts.scoutModel);
|
|
1389
|
+
const scribeModel = nonEmptyModel(opts.scribeModel);
|
|
1352
1390
|
const modelField = nativeModel ? { model: nativeModel } : {};
|
|
1353
1391
|
const searchKey = opts.groupKeys.search ?? GROUP_META.search.preferredKey;
|
|
1354
1392
|
const searchMcp = opts.serverUrl ? { mcpServers: { [searchKey]: httpEntryFor(opts.serverUrl, "search", opts.nonce, opts.workspaceHeaderCmd) } } : {};
|
|
@@ -1358,28 +1396,28 @@ function buildPeerAgentDefinitions(opts) {
|
|
|
1358
1396
|
...modelField
|
|
1359
1397
|
};
|
|
1360
1398
|
out.reviewer = {
|
|
1361
|
-
description: reviewerModel
|
|
1399
|
+
description: reviewerModel ? `Feedback subagent running ${reviewerModel}, a DIFFERENT lab from both the lead and the implementer, so its blind spots are decorrelated from whoever produced the work. Use proactively when something already exists and you want it assessed: a diff, a plan, a document, a failing test. Unlike the stateless peer critics, it reads the repo and can RUN things, so prefer it whenever the assessment needs execution or repo context (reproduce a failure, run the suite, bisect); prefer a peer critic when you already hold the artifact and want a fresh-context opinion on it. Model is overridable at spawn.` : `Feedback subagent (native tools, runs on the lead's model in its own context). Use proactively when something already exists and you want it assessed: a diff, a plan, a document, a failing test. Unlike the stateless peer critics, it reads the repo and can RUN things, so prefer it whenever the assessment needs execution or repo context; prefer a peer critic when you already hold the artifact and want a fresh-context opinion on it. Model is overridable at spawn.`,
|
|
1362
1400
|
prompt: "You are a feedback subagent. Your job is to tell the caller what is actually true about the artifact you are given — code, a plan, a document, a failure report — and what is wrong with it. Verify against the ACTUAL code by reading it; never assume. Do whatever the assessment requires: reproduce a failure end to end as close to how a real user hits it as you can, form hypotheses and test them against the code and runtime, and isolate the true root cause rather than a symptom. Where the change warrants it, author tests that try to BREAK the implementation (edge cases, error paths, and the acceptance criteria as executable checks), run them, and report which pass and which fail; do NOT modify production code just to make tests pass. " + fileToolSteer("builds") + " Do the work yourself — do not spawn further subagents. Report severity-ranked findings with `file:line` citations, the evidence behind each, and end with a clear go/no-go.",
|
|
1363
|
-
...reviewerModel
|
|
1401
|
+
...reviewerModel ? { model: reviewerModel } : {}
|
|
1364
1402
|
};
|
|
1365
1403
|
out.brainstorm = {
|
|
1366
|
-
description: brainstormModel
|
|
1404
|
+
description: brainstormModel ? `Divergent-options subagent running ${brainstormModel} (third lab, for approaches the lead would not generate). Use proactively BEFORE an approach is chosen. Pass the decision, the constraints, what you have already ruled out, and the cost of being wrong; pass your current leading approach too if you have one, and it will try to beat it rather than restate it. Read-only; it proposes, then hands off to implementer. Model is overridable at spawn.` : `Divergent-options subagent (runs on the lead's model in its own context). Use proactively BEFORE an approach is chosen. Pass the decision, the constraints, what you have already ruled out, and the cost of being wrong; pass your current leading approach too if you have one, and it will try to beat it rather than restate it. Read-only; it proposes, then hands off to implementer. Model is overridable at spawn.`,
|
|
1367
1405
|
prompt: "You are a divergent-options subagent: the lead's sounding board while an approach is still open. Your job is to surface the option the lead would not have reached on its own. If the caller states its current leading approach, your job is to try to beat it, and you close with one verdict: `replace`, `retain`, or `insufficient evidence`. `replace` requires naming a concrete alternative that dominates it and the evidence that decides between them. `retain` is a real answer and a useful one: say it plainly when the approach survives a genuine attempt to beat it. Manufactured disagreement is as useless as agreement; do neither. If the caller states no leading approach, generate independently and let the lead compare. Return 3 to 5 approaches that differ in MECHANISM, not in phrasing. For each: how it works, what it costs, what would have to be true for it to be the right answer, and the failure mode that would kill it. Near-duplicate options are the failure mode to avoid — if only one real approach exists, say so plainly and explain why the alternatives are dead, because one honest option beats four padded ones. Screen EVERY candidate against this repository and this environment before you rank them, then verify the one you are about to recommend can actually run here: read the code path it depends on, the guard that would refuse it, the artifact it assumes exists. A recommendation that cannot execute is worse than no recommendation. If checking kills your front-runner, rerank and say so. Ground every option in what the repository actually contains, and prefer reusing what is already there over inventing something new. " + readOnlyToolSteer() + " Do the work yourself — do not spawn further subagents.",
|
|
1368
1406
|
tools: readOnlyToolAllowlist(searchKey),
|
|
1369
|
-
...brainstormModel
|
|
1407
|
+
...brainstormModel ? { model: brainstormModel } : {},
|
|
1370
1408
|
...searchMcp
|
|
1371
1409
|
};
|
|
1372
|
-
if (scoutModel
|
|
1373
|
-
description: `Read-only exploration subagent running ${scoutModel
|
|
1410
|
+
if (scoutModel) out.scout = {
|
|
1411
|
+
description: `Read-only exploration subagent running ${scoutModel} (fast and cheap, so repository lookups do not run at the lead's model rates). Use proactively to find or understand something in the codebase: it sweeps widely and returns conclusions with file:line references rather than file dumps. Model is overridable at spawn.`,
|
|
1374
1412
|
prompt: "You are a read-only exploration subagent. Answer the question by investigating the repository: cast a wide net, then narrow. Return the conclusion, not the raw material — cite `file:line` for anything load-bearing and quote only the lines that matter. If the answer is that something does not exist, say so explicitly and describe where you looked. " + readOnlyToolSteer() + " Do the work yourself — do not spawn further subagents.",
|
|
1375
1413
|
tools: readOnlyToolAllowlist(searchKey),
|
|
1376
|
-
model: scoutModel
|
|
1414
|
+
model: scoutModel,
|
|
1377
1415
|
...searchMcp
|
|
1378
1416
|
};
|
|
1379
1417
|
out.scribe = {
|
|
1380
|
-
description: scribeModel
|
|
1418
|
+
description: scribeModel ? `Documentation subagent running ${scribeModel}. Use proactively for prose that trails the code: docs, ADRs, CLAUDE.md sections, changelog entries, and README updates that have gone stale. Keeps low-glamour upkeep off the lead's context. Model is overridable at spawn.` : `Documentation subagent (runs on the lead's model in its own context). Use proactively for prose that trails the code: docs, ADRs, CLAUDE.md sections, changelog entries, and README updates that have gone stale. Model is overridable at spawn.`,
|
|
1381
1419
|
prompt: "You are a documentation subagent. Write and maintain the prose that trails the code: docs, ADRs, CLAUDE.md sections, changelog entries, README rows. Read the code before describing it — every claim you write must be checkable against the repository as it is now, not as a summary said it was. Match the surrounding document's voice, structure, and level of detail. Prefer updating an existing document over adding a new one, and delete what has become false rather than layering a correction on top of it. " + fileToolSteer("builds") + " Do the work yourself — do not spawn further subagents. Report which documents changed and any claim you could not verify.",
|
|
1382
|
-
...scribeModel
|
|
1420
|
+
...scribeModel ? { model: scribeModel } : {}
|
|
1383
1421
|
};
|
|
1384
1422
|
if (opts.workerToolsAvailable) {
|
|
1385
1423
|
const workersKey = workersKeyOf(opts.groupKeys);
|
|
@@ -1406,7 +1444,7 @@ function buildPeerAgentDefinitions(opts) {
|
|
|
1406
1444
|
* sweep is scoped to peer-* names only via the persona-name allowlist.
|
|
1407
1445
|
*/
|
|
1408
1446
|
function defaultAgentsDir() {
|
|
1409
|
-
return
|
|
1447
|
+
return path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
|
|
1410
1448
|
}
|
|
1411
1449
|
/**
|
|
1412
1450
|
* YAML frontmatter string-escape — sufficient for our use case where
|
|
@@ -1506,16 +1544,16 @@ function buildAgentMd(spec) {
|
|
|
1506
1544
|
* Returns the file paths plus a cleanup() that unlinks them.
|
|
1507
1545
|
*/
|
|
1508
1546
|
async function writePeerAgentMdFiles(agents, opts) {
|
|
1509
|
-
for (const name
|
|
1547
|
+
for (const name of Object.keys(agents)) if (!VALID_AGENT_NAME.test(name)) throw new Error(`writePeerAgentMdFiles: invalid agent name ${JSON.stringify(name)} — must match ${VALID_AGENT_NAME.source}`);
|
|
1510
1548
|
const dir = opts.agentsDir ?? defaultAgentsDir();
|
|
1511
1549
|
await fs.mkdir(dir, { recursive: true });
|
|
1512
1550
|
const paths = [];
|
|
1513
1551
|
try {
|
|
1514
|
-
for (const [name
|
|
1515
|
-
const filePath =
|
|
1552
|
+
for (const [name, def] of Object.entries(agents)) {
|
|
1553
|
+
const filePath = path.join(dir, `peer-${opts.fileSuffix}-${name}.md`);
|
|
1516
1554
|
await fs.unlink(filePath).catch(() => {});
|
|
1517
1555
|
await writeRuntimeFileSecure(filePath, buildAgentMd({
|
|
1518
|
-
name
|
|
1556
|
+
name,
|
|
1519
1557
|
description: def.description,
|
|
1520
1558
|
prompt: def.prompt,
|
|
1521
1559
|
model: def.model,
|
|
@@ -1574,7 +1612,7 @@ async function readMcpServersSnapshot(target) {
|
|
|
1574
1612
|
*/
|
|
1575
1613
|
async function resolveGroupKeysFromMirror(enabledGroups, claudeConfigDir) {
|
|
1576
1614
|
const dir = claudeConfigDir ?? PATHS.CLAUDE_CONFIG_DIR;
|
|
1577
|
-
const existing = await readMcpServersSnapshot(
|
|
1615
|
+
const existing = await readMcpServersSnapshot(path.join(dir, ".claude.json"));
|
|
1578
1616
|
const keys = {};
|
|
1579
1617
|
for (const group of enabledGroups) {
|
|
1580
1618
|
const bare = GROUP_META[group].preferredKey;
|
|
@@ -1622,7 +1660,7 @@ async function resolveGroupKeysFromMirror(enabledGroups, claudeConfigDir) {
|
|
|
1622
1660
|
*/
|
|
1623
1661
|
async function injectPeerMcpIntoMirror(serverUrl, opts) {
|
|
1624
1662
|
const dir = opts.claudeConfigDir ?? PATHS.CLAUDE_CONFIG_DIR;
|
|
1625
|
-
const target =
|
|
1663
|
+
const target = path.join(dir, ".claude.json");
|
|
1626
1664
|
let existing = {};
|
|
1627
1665
|
try {
|
|
1628
1666
|
const raw = await fs.readFile(target, "utf8");
|
|
@@ -1652,7 +1690,7 @@ async function injectPeerMcpIntoMirror(serverUrl, opts) {
|
|
|
1652
1690
|
workspaceHeaderCmd: buildWorkspaceHeaderHelperCommand(process.execPath, process.argv[1])
|
|
1653
1691
|
});
|
|
1654
1692
|
const conflicts = [];
|
|
1655
|
-
for (const name
|
|
1693
|
+
for (const name of Object.keys(peerConfig.mcpServers)) if (mcpServers[name] !== void 0) conflicts.push(name);
|
|
1656
1694
|
if (conflicts.length > 0) {
|
|
1657
1695
|
consola.warn(`injectPeerMcpIntoMirror: your ~/.claude/.claude.json already has mcpServers entries named [${conflicts.join(", ")}]; refusing to overwrite. Subagents will not see those tools — only the parent session via --mcp-config fallback. To resolve, rename the user-side server(s) (e.g. via \`claude mcp remove\`) and relaunch.`);
|
|
1658
1696
|
return {
|
|
@@ -1661,7 +1699,7 @@ async function injectPeerMcpIntoMirror(serverUrl, opts) {
|
|
|
1661
1699
|
conflictingServers: conflicts
|
|
1662
1700
|
};
|
|
1663
1701
|
}
|
|
1664
|
-
for (const [name
|
|
1702
|
+
for (const [name, entry] of Object.entries(peerConfig.mcpServers)) mcpServers[name] = entry;
|
|
1665
1703
|
existing.mcpServers = mcpServers;
|
|
1666
1704
|
const desiredJson = JSON.stringify(existing, null, 2) + "\n";
|
|
1667
1705
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -1700,8 +1738,8 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
|
|
|
1700
1738
|
await fs.mkdir(runtimeDir, { recursive: true });
|
|
1701
1739
|
if (process.platform !== "win32") await fs.chmod(runtimeDir, 448).catch(() => {});
|
|
1702
1740
|
const fileSuffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
1703
|
-
const mcpConfigPath =
|
|
1704
|
-
const agentsPath =
|
|
1741
|
+
const mcpConfigPath = path.join(runtimeDir, `peer-mcp-${fileSuffix}.json`);
|
|
1742
|
+
const agentsPath = path.join(runtimeDir, `peer-agents-${fileSuffix}.json`);
|
|
1705
1743
|
const mcpConfig = buildPeerMcpConfig(serverUrl, {
|
|
1706
1744
|
codexCli: opts.codexCli,
|
|
1707
1745
|
geminiAvailable: opts.geminiAvailable,
|
|
@@ -1757,15 +1795,14 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
|
|
|
1757
1795
|
cleanup
|
|
1758
1796
|
};
|
|
1759
1797
|
}
|
|
1760
|
-
|
|
1761
1798
|
//#endregion
|
|
1762
1799
|
//#region src/lib/file-log-reporter.ts
|
|
1763
|
-
const MAX_LOG_BYTES =
|
|
1800
|
+
const MAX_LOG_BYTES = 1048576;
|
|
1764
1801
|
const DEDUP_MAX = 1e3;
|
|
1765
1802
|
const ARG_MAX_LEN = 2048;
|
|
1766
1803
|
const DEDUP_KEY_MAX_LEN = 200;
|
|
1767
1804
|
const CREDENTIAL_RE = /\b(eyJ[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]+){0,2}|gh[opsu]_[A-Za-z0-9_]{20,}|Bearer\s+\S{20,})\b/g;
|
|
1768
|
-
const ALLOWED_TYPES = new Set([
|
|
1805
|
+
const ALLOWED_TYPES = /* @__PURE__ */ new Set([
|
|
1769
1806
|
"fatal",
|
|
1770
1807
|
"error",
|
|
1771
1808
|
"warn"
|
|
@@ -1909,7 +1946,6 @@ function enableFileLogging() {
|
|
|
1909
1946
|
consola.options.stdout = nullStream;
|
|
1910
1947
|
consola.options.stderr = nullStream;
|
|
1911
1948
|
}
|
|
1912
|
-
|
|
1913
1949
|
//#endregion
|
|
1914
1950
|
//#region src/lib/mcp-permissions-settings.ts
|
|
1915
1951
|
/**
|
|
@@ -2121,7 +2157,6 @@ async function configureServeDefaultPermissionMode(settingsPath) {
|
|
|
2121
2157
|
}, "servemode");
|
|
2122
2158
|
return { written: true };
|
|
2123
2159
|
}
|
|
2124
|
-
|
|
2125
2160
|
//#endregion
|
|
2126
2161
|
//#region src/lib/model-validation.ts
|
|
2127
2162
|
const ENDPOINT_ALIASES = {
|
|
@@ -2169,10 +2204,34 @@ function listModelsForEndpoint(path) {
|
|
|
2169
2204
|
return supported.includes(endpoint);
|
|
2170
2205
|
}).map((m) => m.id);
|
|
2171
2206
|
}
|
|
2172
|
-
|
|
2173
2207
|
//#endregion
|
|
2174
2208
|
//#region src/lib/orchestration/hook-mcp-client.ts
|
|
2175
2209
|
/**
|
|
2210
|
+
* Tiny MCP-over-HTTP + inference client for the advisory-review hooks (hook V2).
|
|
2211
|
+
*
|
|
2212
|
+
* The hook subcommands (`internal-stop-review`, `internal-prompt-submit`) run as
|
|
2213
|
+
* SEPARATE short-lived processes spawned by Claude Code — they have no in-process
|
|
2214
|
+
* proxy `state`. They reach the running github-router proxy over loopback HTTP:
|
|
2215
|
+
*
|
|
2216
|
+
* - `callMcpTool` POSTs a JSON-RPC `tools/call` to `${serverUrl}/mcp/<group>`
|
|
2217
|
+
* with the per-launch `Authorization: Bearer <nonce>` (the same nonce the
|
|
2218
|
+
* proxy minted in `writePeerMcpRuntimeFiles` and validates in the /mcp
|
|
2219
|
+
* handler). Used for `workers/review` (the background reviewer) and
|
|
2220
|
+
* `search/code` (the prompt-hook grounding search). Forces the JSON response
|
|
2221
|
+
* path (`Accept: application/json`) — the SSE path's 60s-ceiling workaround
|
|
2222
|
+
* is a Claude-Code-MCP-client concern; our own fetch has no such cap, so the
|
|
2223
|
+
* simpler single-body JSON path is correct here.
|
|
2224
|
+
*
|
|
2225
|
+
* - `callInference` POSTs to `${serverUrl}/v1/responses` (no nonce — the /v1/*
|
|
2226
|
+
* passthrough authenticates upstream with the proxy's own Copilot token) for
|
|
2227
|
+
* the single gpt-5.6-sol scope/goal call the prompt hook makes.
|
|
2228
|
+
*
|
|
2229
|
+
* Everything here is best-effort and caller-fail-open: a transport error,
|
|
2230
|
+
* non-2xx, timeout, or malformed body throws, and every caller wraps the call so
|
|
2231
|
+
* the hook degrades gracefully (no findings / the regex-heuristic goal) rather
|
|
2232
|
+
* than disrupting the session.
|
|
2233
|
+
*/
|
|
2234
|
+
/**
|
|
2176
2235
|
* Read the proxy URL + nonce the launcher injected into the spawned child env
|
|
2177
2236
|
* (`GH_ROUTER_HOOK_MCP_URL` / `GH_ROUTER_HOOK_NONCE`). Returns undefined when
|
|
2178
2237
|
* either is absent — the hook then skips its LLM layer and falls back to its
|
|
@@ -2277,22 +2336,15 @@ async function postJson(url, payload, opts) {
|
|
|
2277
2336
|
if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort);
|
|
2278
2337
|
}
|
|
2279
2338
|
}
|
|
2280
|
-
|
|
2281
|
-
//#endregion
|
|
2282
|
-
//#region src/lib/orchestration/plan-review-hook.ts
|
|
2283
|
-
/** Minimum finalized-plan size worth spending a model review on. */
|
|
2284
|
-
const PLAN_REVIEW_MIN_CHARS = 220;
|
|
2285
|
-
/** Hard wall-clock for the advisory plan critic. */
|
|
2286
|
-
const PLAN_REVIEW_TIMEOUT_MS = 25e3;
|
|
2287
2339
|
/** Cap the plan embedded in the critic brief so a huge plan file cannot dominate. */
|
|
2288
|
-
const MAX_REVIEWED_PLAN_CHARS =
|
|
2340
|
+
const MAX_REVIEWED_PLAN_CHARS = 81920;
|
|
2289
2341
|
/** Default-on advisory plan review. Opt out with GH_ROUTER_DISABLE_PLAN_REVIEW=1. */
|
|
2290
2342
|
function planReviewEnabled(env = process.env) {
|
|
2291
2343
|
return parseBoolEnv(env.GH_ROUTER_DISABLE_PLAN_REVIEW) !== true;
|
|
2292
2344
|
}
|
|
2293
2345
|
/** File-backed per-session debounce for finalized plans, separate from Stop diff hashes. */
|
|
2294
2346
|
function filePlanReviewDebounce(stateDir) {
|
|
2295
|
-
const fileFor = (sid) =>
|
|
2347
|
+
const fileFor = (sid) => path.join(stateDir, `plan-review-hash-${createHash("sha256").update(sid).digest("hex").slice(0, 32)}`);
|
|
2296
2348
|
const readLast = async (sid) => {
|
|
2297
2349
|
try {
|
|
2298
2350
|
return (await promises.readFile(fileFor(sid), "utf8")).trim();
|
|
@@ -2317,12 +2369,12 @@ function parseObject(raw) {
|
|
|
2317
2369
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2318
2370
|
} catch {}
|
|
2319
2371
|
}
|
|
2320
|
-
async function resolvePlanText(toolInput, readFile
|
|
2372
|
+
async function resolvePlanText(toolInput, readFile) {
|
|
2321
2373
|
if (!toolInput || typeof toolInput !== "object") return "";
|
|
2322
2374
|
const ti = toolInput;
|
|
2323
2375
|
const planFilePath = typeof ti.planFilePath === "string" && ti.planFilePath.trim().length > 0 ? ti.planFilePath.trim() : "";
|
|
2324
2376
|
if (planFilePath.length > 0) try {
|
|
2325
|
-
const fromFile = await readFile
|
|
2377
|
+
const fromFile = await readFile(planFilePath);
|
|
2326
2378
|
if (fromFile.trim().length > 0) return fromFile;
|
|
2327
2379
|
} catch {}
|
|
2328
2380
|
return typeof ti.plan === "string" ? ti.plan : "";
|
|
@@ -2355,7 +2407,7 @@ async function decidePlanReviewHook(input) {
|
|
|
2355
2407
|
};
|
|
2356
2408
|
const cwd = typeof payload.cwd === "string" && payload.cwd.length > 0 ? payload.cwd : input.fallbackCwd;
|
|
2357
2409
|
const trimmed = (await resolvePlanText(payload.tool_input, input.readFile ?? ((filePath) => promises.readFile(filePath, "utf8")))).trim();
|
|
2358
|
-
if (trimmed.length < (input.minChars ??
|
|
2410
|
+
if (trimmed.length < (input.minChars ?? 220)) return {
|
|
2359
2411
|
kind: "skip",
|
|
2360
2412
|
reason: "trivial-plan"
|
|
2361
2413
|
};
|
|
@@ -2397,7 +2449,7 @@ function framePlanFindings(text) {
|
|
|
2397
2449
|
}
|
|
2398
2450
|
/** Run the bounded critic call and write material findings to the shared findings store. */
|
|
2399
2451
|
async function runPlanReview(input) {
|
|
2400
|
-
const timeoutMs = input.timeoutMs ??
|
|
2452
|
+
const timeoutMs = input.timeoutMs ?? 25e3;
|
|
2401
2453
|
let timer;
|
|
2402
2454
|
const controller = new AbortController();
|
|
2403
2455
|
try {
|
|
@@ -2433,12 +2485,39 @@ function buildPlanReviewHookCommand(execPath, scriptPath) {
|
|
|
2433
2485
|
if (scriptPath && scriptPath !== execPath) return `${q(execPath)} ${q(scriptPath)} internal-plan-review`;
|
|
2434
2486
|
return `${q(execPath)} internal-plan-review`;
|
|
2435
2487
|
}
|
|
2436
|
-
|
|
2437
2488
|
//#endregion
|
|
2438
2489
|
//#region src/lib/orchestration/harness-parse.ts
|
|
2490
|
+
/**
|
|
2491
|
+
* Deterministic, language-agnostic harness PARSER for the structural Stop-gate.
|
|
2492
|
+
*
|
|
2493
|
+
* The gate runs a repo's own checks (typecheck/lint/test/build) when the agent
|
|
2494
|
+
* tries to finish. Historically it only auto-enabled for Bun/TS repos via a
|
|
2495
|
+
* hard-coded sealed gate. This module generalizes detection by PARSING the
|
|
2496
|
+
* project's own authoritative config — `package.json` scripts, CI `run:` steps,
|
|
2497
|
+
* Make/just/task targets, and language manifests — and emitting the canonical
|
|
2498
|
+
* check commands it finds.
|
|
2499
|
+
*
|
|
2500
|
+
* Design properties (a cross-lab panel chose a parser over a model for these):
|
|
2501
|
+
* - EVIDENCE-PINNED BY CONSTRUCTION: every emitted command is either lifted
|
|
2502
|
+
* verbatim from a parsed source file (a package.json script, a CI `run:`
|
|
2503
|
+
* line, a Make target invocation) or is a fixed canonical manifest command
|
|
2504
|
+
* for a detected ecosystem (`cargo check`, `go vet ./...`). The parser never
|
|
2505
|
+
* invents a command, so it has no hallucination / prompt-injection surface.
|
|
2506
|
+
* - PURE + DETERMINISTIC: a function of the repo's files + the live PATH. The
|
|
2507
|
+
* runtime Stop hook re-derives it at each stop (no cache), so it always
|
|
2508
|
+
* reflects the current tree and fails open for free when a tool vanished.
|
|
2509
|
+
* - SHELL-SAFE: `liveExec` runs `command.trim().split(/\s+/)` (naive argv
|
|
2510
|
+
* split, no shell), so every emitted command is validated to be plain
|
|
2511
|
+
* space-separated tokens with no shell operators and no `%` (cmd.exe quoting
|
|
2512
|
+
* throws on `%`). See `isSafeCommand`.
|
|
2513
|
+
*
|
|
2514
|
+
* This is consumed ONLY by the local Stop hook. The sealed-gate kernel
|
|
2515
|
+
* (`run_workflow`) is never fed a parsed command — `GateDescriptor.kind` is
|
|
2516
|
+
* `"parsed"`, never a sealed id, and `sealedGateIds()` is unchanged.
|
|
2517
|
+
*/
|
|
2439
2518
|
/** The fast static checks that are always-on; `test` is opt-in (it runs project
|
|
2440
2519
|
* code and can be slow on every stop). */
|
|
2441
|
-
const STATIC_IDS = new Set(["typecheck", "lint"]);
|
|
2520
|
+
const STATIC_IDS = /* @__PURE__ */ new Set(["typecheck", "lint"]);
|
|
2442
2521
|
/** Reject anything that is not a plain, shell-free, `%`-free argv line. This is
|
|
2443
2522
|
* the load-bearing guard for `liveExec`'s naive whitespace split. */
|
|
2444
2523
|
function isSafeCommand(command) {
|
|
@@ -2486,7 +2565,7 @@ async function readTextFile(file) {
|
|
|
2486
2565
|
}
|
|
2487
2566
|
/** package.json `scripts` as a plain string→string map (best-effort). */
|
|
2488
2567
|
async function readScripts(root) {
|
|
2489
|
-
const pkg = await readJsonFile(
|
|
2568
|
+
const pkg = await readJsonFile(path.join(root, "package.json"));
|
|
2490
2569
|
const scripts = pkg && typeof pkg === "object" ? pkg.scripts : void 0;
|
|
2491
2570
|
const out = {};
|
|
2492
2571
|
if (scripts && typeof scripts === "object") {
|
|
@@ -2496,31 +2575,31 @@ async function readScripts(root) {
|
|
|
2496
2575
|
}
|
|
2497
2576
|
/** Pick the JS package runner from the lockfile present at the root. */
|
|
2498
2577
|
function nodeRunner(root) {
|
|
2499
|
-
if (existsSync(
|
|
2500
|
-
if (existsSync(
|
|
2501
|
-
if (existsSync(
|
|
2578
|
+
if (existsSync(path.join(root, "bun.lockb")) || existsSync(path.join(root, "bun.lock"))) return "bun";
|
|
2579
|
+
if (existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
2580
|
+
if (existsSync(path.join(root, "yarn.lock"))) return "yarn";
|
|
2502
2581
|
return "npm";
|
|
2503
2582
|
}
|
|
2504
2583
|
/** Map a package.json script NAME to a canonical id (read-only checks only — a
|
|
2505
2584
|
* `:fix`/`:write` variant mutates the tree and is never a gate). */
|
|
2506
|
-
function scriptNameToId(name
|
|
2507
|
-
if (/:(fix|write)$/i.test(name
|
|
2508
|
-
if (/^(typecheck|type-check|check-types|tsc|types)$/i.test(name
|
|
2509
|
-
if (/^(lint|lint:check|eslint)$/i.test(name
|
|
2510
|
-
if (/^test$/i.test(name
|
|
2585
|
+
function scriptNameToId(name) {
|
|
2586
|
+
if (/:(fix|write)$/i.test(name)) return null;
|
|
2587
|
+
if (/^(typecheck|type-check|check-types|tsc|types)$/i.test(name)) return "typecheck";
|
|
2588
|
+
if (/^(lint|lint:check|eslint)$/i.test(name)) return "lint";
|
|
2589
|
+
if (/^test$/i.test(name)) return "test";
|
|
2511
2590
|
return null;
|
|
2512
2591
|
}
|
|
2513
2592
|
async function collectNodeChecks(root, includeTests) {
|
|
2514
|
-
if (!existsSync(
|
|
2593
|
+
if (!existsSync(path.join(root, "package.json"))) return [];
|
|
2515
2594
|
const runner = nodeRunner(root);
|
|
2516
2595
|
if (resolveExecutable(runner, { env: process.env }) === null) return [];
|
|
2517
2596
|
const scripts = await readScripts(root);
|
|
2518
2597
|
const out = [];
|
|
2519
|
-
for (const [name
|
|
2520
|
-
const id = scriptNameToId(name
|
|
2598
|
+
for (const [name] of Object.entries(scripts)) {
|
|
2599
|
+
const id = scriptNameToId(name);
|
|
2521
2600
|
if (!id) continue;
|
|
2522
2601
|
if (id === "test" && !includeTests) continue;
|
|
2523
|
-
const command = `${runner} run ${name
|
|
2602
|
+
const command = `${runner} run ${name}`;
|
|
2524
2603
|
if (isSafeCommand(command)) out.push({
|
|
2525
2604
|
id,
|
|
2526
2605
|
command,
|
|
@@ -2538,11 +2617,11 @@ async function collectNodeChecks(root, includeTests) {
|
|
|
2538
2617
|
*/
|
|
2539
2618
|
async function collectCiChecks(root) {
|
|
2540
2619
|
const files = [];
|
|
2541
|
-
const wfDir =
|
|
2620
|
+
const wfDir = path.join(root, ".github", "workflows");
|
|
2542
2621
|
try {
|
|
2543
|
-
for (const name
|
|
2622
|
+
for (const name of await promises.readdir(wfDir)) if (/\.ya?ml$/i.test(name)) files.push(path.join(wfDir, name));
|
|
2544
2623
|
} catch {}
|
|
2545
|
-
const gitlab =
|
|
2624
|
+
const gitlab = path.join(root, ".gitlab-ci.yml");
|
|
2546
2625
|
if (existsSync(gitlab)) files.push(gitlab);
|
|
2547
2626
|
if (files.length === 0) return [];
|
|
2548
2627
|
let parseYaml;
|
|
@@ -2588,7 +2667,7 @@ async function collectCiChecks(root) {
|
|
|
2588
2667
|
const text = await readTextFile(f);
|
|
2589
2668
|
if (text === void 0) continue;
|
|
2590
2669
|
try {
|
|
2591
|
-
walk(parseYaml(text),
|
|
2670
|
+
walk(parseYaml(text), path.relative(root, f) || path.basename(f));
|
|
2592
2671
|
} catch {}
|
|
2593
2672
|
}
|
|
2594
2673
|
return out;
|
|
@@ -2603,7 +2682,7 @@ async function collectTaskChecks(root, includeTests) {
|
|
|
2603
2682
|
["justfile", "just"],
|
|
2604
2683
|
[".justfile", "just"]
|
|
2605
2684
|
]) {
|
|
2606
|
-
const text = await readTextFile(
|
|
2685
|
+
const text = await readTextFile(path.join(root, file));
|
|
2607
2686
|
if (text === void 0) continue;
|
|
2608
2687
|
if (resolveExecutable(tool, { env: process.env }) === null) continue;
|
|
2609
2688
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -2619,20 +2698,20 @@ async function collectTaskChecks(root, includeTests) {
|
|
|
2619
2698
|
});
|
|
2620
2699
|
}
|
|
2621
2700
|
}
|
|
2622
|
-
const taskfile = ["Taskfile.yml", "Taskfile.yaml"].map((n) =>
|
|
2701
|
+
const taskfile = ["Taskfile.yml", "Taskfile.yaml"].map((n) => path.join(root, n)).find((p) => existsSync(p));
|
|
2623
2702
|
if (taskfile && resolveExecutable("task", { env: process.env }) !== null) {
|
|
2624
2703
|
const text = await readTextFile(taskfile);
|
|
2625
2704
|
if (text !== void 0) try {
|
|
2626
2705
|
const { parse } = await import("yaml");
|
|
2627
2706
|
const doc = parse(text);
|
|
2628
|
-
for (const name
|
|
2629
|
-
const id = wantId(name
|
|
2707
|
+
for (const name of Object.keys(doc?.tasks ?? {})) {
|
|
2708
|
+
const id = wantId(name);
|
|
2630
2709
|
if (!id || id === "test" && !includeTests) continue;
|
|
2631
|
-
const command = `task ${name
|
|
2710
|
+
const command = `task ${name}`;
|
|
2632
2711
|
if (isSafeCommand(command)) out.push({
|
|
2633
2712
|
id,
|
|
2634
2713
|
command,
|
|
2635
|
-
source:
|
|
2714
|
+
source: path.basename(taskfile)
|
|
2636
2715
|
});
|
|
2637
2716
|
}
|
|
2638
2717
|
} catch {}
|
|
@@ -2640,7 +2719,7 @@ async function collectTaskChecks(root, includeTests) {
|
|
|
2640
2719
|
return out;
|
|
2641
2720
|
}
|
|
2642
2721
|
async function collectRustChecks(root, includeTests) {
|
|
2643
|
-
if (!existsSync(
|
|
2722
|
+
if (!existsSync(path.join(root, "Cargo.toml"))) return [];
|
|
2644
2723
|
if (resolveExecutable("cargo", { env: process.env }) === null) return [];
|
|
2645
2724
|
const out = [{
|
|
2646
2725
|
id: "typecheck",
|
|
@@ -2660,7 +2739,7 @@ async function collectRustChecks(root, includeTests) {
|
|
|
2660
2739
|
return out;
|
|
2661
2740
|
}
|
|
2662
2741
|
async function collectGoChecks(root, includeTests) {
|
|
2663
|
-
if (!existsSync(
|
|
2742
|
+
if (!existsSync(path.join(root, "go.mod"))) return [];
|
|
2664
2743
|
if (resolveExecutable("go", { env: process.env }) === null) return [];
|
|
2665
2744
|
const out = [{
|
|
2666
2745
|
id: "typecheck",
|
|
@@ -2682,11 +2761,11 @@ async function collectPythonChecks(root, includeTests) {
|
|
|
2682
2761
|
"tox.ini",
|
|
2683
2762
|
"ruff.toml",
|
|
2684
2763
|
"mypy.ini"
|
|
2685
|
-
].filter((f) => existsSync(
|
|
2764
|
+
].filter((f) => existsSync(path.join(root, f)));
|
|
2686
2765
|
if (present.length === 0) return [];
|
|
2687
2766
|
const evidence = present[0];
|
|
2688
2767
|
const out = [];
|
|
2689
|
-
const configText = await readTextFile(
|
|
2768
|
+
const configText = await readTextFile(path.join(root, present[0])) ?? "";
|
|
2690
2769
|
const mentions = (tool) => present.some((f) => f.startsWith(tool)) || configText.includes(tool);
|
|
2691
2770
|
if (mentions("mypy") && resolveExecutable("mypy", { env: process.env }) !== null) out.push({
|
|
2692
2771
|
id: "typecheck",
|
|
@@ -2698,7 +2777,7 @@ async function collectPythonChecks(root, includeTests) {
|
|
|
2698
2777
|
command: "ruff check .",
|
|
2699
2778
|
source: evidence
|
|
2700
2779
|
});
|
|
2701
|
-
if (includeTests && (mentions("pytest") || existsSync(
|
|
2780
|
+
if (includeTests && (mentions("pytest") || existsSync(path.join(root, "pytest.ini")))) {
|
|
2702
2781
|
if (resolveExecutable("pytest", { env: process.env }) !== null) out.push({
|
|
2703
2782
|
id: "test",
|
|
2704
2783
|
command: "pytest -q",
|
|
@@ -2798,9 +2877,29 @@ function descriptorHash(d) {
|
|
|
2798
2877
|
const canon = [...d.checks].map((c) => `${c.id}${c.command.trim().replace(/\s+/g, " ")}`).sort().join("");
|
|
2799
2878
|
return `${d.kind}:${createHash("sha256").update(canon).digest("hex").slice(0, 32)}`;
|
|
2800
2879
|
}
|
|
2801
|
-
|
|
2802
2880
|
//#endregion
|
|
2803
2881
|
//#region src/lib/orchestration/gate-discovery.ts
|
|
2882
|
+
/**
|
|
2883
|
+
* Evidence-pinned model FALLBACK for the structural Stop-gate — the last resort
|
|
2884
|
+
* when the deterministic parser (`harness-parse`) finds no runnable checks.
|
|
2885
|
+
*
|
|
2886
|
+
* A read-only worker reads the repo's own config/docs and proposes the canonical
|
|
2887
|
+
* check commands. Two guards keep this safe despite being model-authored:
|
|
2888
|
+
* 1. SANITIZE — `sanitizeDiscoveredCheck` rejects shell metacharacters (so a
|
|
2889
|
+
* command can never chain/redirect/expand), destructive/stateful verbs,
|
|
2890
|
+
* interactive/watch shapes, and an executable that isn't on PATH. What
|
|
2891
|
+
* survives is a single plain argv line, safe for `liveExec`'s naive split.
|
|
2892
|
+
* 2. EVIDENCE-PIN — a surviving command must appear (whitespace-normalized)
|
|
2893
|
+
* VERBATIM in one of the collected source files. The model cannot invent a
|
|
2894
|
+
* command or be prompt-injected into emitting one that isn't already a real
|
|
2895
|
+
* command in the repo (and a real command in a user-trusted repo is the
|
|
2896
|
+
* same authority the existing gate already runs).
|
|
2897
|
+
*
|
|
2898
|
+
* Discovery runs ONCE at launch and the result is cached per (repoFingerprint,
|
|
2899
|
+
* sourcesHash) in a human-readable record. The runtime Stop hook only READS the
|
|
2900
|
+
* cached record (no model call at stop). This is consumed ONLY by the local Stop
|
|
2901
|
+
* hook; the sealed-gate kernel never sees a discovered command.
|
|
2902
|
+
*/
|
|
2804
2903
|
/** The allowlist of files the discovery worker is steered to read — config +
|
|
2805
2904
|
* docs that legitimately describe how to check a project. Secret files are
|
|
2806
2905
|
* additionally blocked at the worker IO layer (`.env*`/`*.pem`/`id_*`/…). */
|
|
@@ -2829,11 +2928,11 @@ const SIGNAL_FILES = [
|
|
|
2829
2928
|
"composer.json"
|
|
2830
2929
|
];
|
|
2831
2930
|
const SIGNAL_DIRS = [".github/workflows"];
|
|
2832
|
-
const MAX_SIGNAL_BYTES =
|
|
2931
|
+
const MAX_SIGNAL_BYTES = 65536;
|
|
2833
2932
|
/** Global caps so a repo with many workflow files can't inflate discovery /
|
|
2834
2933
|
* hashing cost: at most this many files and this much total text. */
|
|
2835
2934
|
const MAX_SIGNAL_FILES = 40;
|
|
2836
|
-
const MAX_TOTAL_SIGNAL_BYTES =
|
|
2935
|
+
const MAX_TOTAL_SIGNAL_BYTES = 524288;
|
|
2837
2936
|
/** Words that mark a command as destructive, stateful, or non-terminating — a
|
|
2838
2937
|
* "check" must never do any of these. (Shell operators are already rejected by
|
|
2839
2938
|
* `isSafeCommand`, so chaining can't smuggle them past this word scan.) */
|
|
@@ -2850,7 +2949,8 @@ function sanitizeDiscoveredCheck(command) {
|
|
|
2850
2949
|
const norm = normalizeWs(command);
|
|
2851
2950
|
if (DENY_WORD.test(norm)) return false;
|
|
2852
2951
|
if (isMutatingCommand(norm)) return false;
|
|
2853
|
-
|
|
2952
|
+
const first = norm.split(" ")[0] ?? "";
|
|
2953
|
+
return resolveExecutable(first, { env: process.env }) !== null;
|
|
2854
2954
|
}
|
|
2855
2955
|
function normalizeWs(s) {
|
|
2856
2956
|
return s.replace(/\s+/g, " ").trim();
|
|
@@ -2872,13 +2972,13 @@ async function collectSignals(root) {
|
|
|
2872
2972
|
} catch {}
|
|
2873
2973
|
};
|
|
2874
2974
|
for (const f of SIGNAL_FILES) {
|
|
2875
|
-
const abs =
|
|
2975
|
+
const abs = path.join(root, f);
|
|
2876
2976
|
if (existsSync(abs)) await readCapped(abs, f);
|
|
2877
2977
|
}
|
|
2878
2978
|
for (const d of SIGNAL_DIRS) {
|
|
2879
|
-
const dir =
|
|
2979
|
+
const dir = path.join(root, d);
|
|
2880
2980
|
try {
|
|
2881
|
-
for (const name
|
|
2981
|
+
for (const name of await promises.readdir(dir)) if (/\.(ya?ml)$/i.test(name)) await readCapped(path.join(dir, name), path.join(d, name));
|
|
2882
2982
|
} catch {}
|
|
2883
2983
|
}
|
|
2884
2984
|
return {
|
|
@@ -2893,10 +2993,10 @@ async function sourcesHash(root) {
|
|
|
2893
2993
|
return createHash("sha256").update(files.sort().join("\n")).update("\0").update(text).digest("hex");
|
|
2894
2994
|
}
|
|
2895
2995
|
function discoveredDir() {
|
|
2896
|
-
return
|
|
2996
|
+
return path.join(PATHS.APP_DIR, "stop-gate", "discovered");
|
|
2897
2997
|
}
|
|
2898
2998
|
function recordPathFor(root) {
|
|
2899
|
-
return
|
|
2999
|
+
return path.join(discoveredDir(), createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 32));
|
|
2900
3000
|
}
|
|
2901
3001
|
/** Read the cached discovered record for `root`, verifying it still matches the
|
|
2902
3002
|
* live repo identity AND the live sources hash. Any mismatch / unreadable /
|
|
@@ -2931,16 +3031,16 @@ const DISCOVERY_PROMPT = (files) => `You are configuring an automated pre-finish
|
|
|
2931
3031
|
function extractJson(text) {
|
|
2932
3032
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
2933
3033
|
const body = fenced ? fenced[1] : text;
|
|
2934
|
-
const start
|
|
3034
|
+
const start = body.indexOf("{");
|
|
2935
3035
|
const end = body.lastIndexOf("}");
|
|
2936
|
-
if (start
|
|
3036
|
+
if (start < 0 || end <= start) return void 0;
|
|
2937
3037
|
try {
|
|
2938
|
-
return JSON.parse(body.slice(start
|
|
3038
|
+
return JSON.parse(body.slice(start, end + 1));
|
|
2939
3039
|
} catch {
|
|
2940
3040
|
return;
|
|
2941
3041
|
}
|
|
2942
3042
|
}
|
|
2943
|
-
const VALID_IDS = new Set([
|
|
3043
|
+
const VALID_IDS = /* @__PURE__ */ new Set([
|
|
2944
3044
|
"typecheck",
|
|
2945
3045
|
"lint",
|
|
2946
3046
|
"test"
|
|
@@ -2987,7 +3087,7 @@ async function discoverGateCommands(cwd, opts) {
|
|
|
2987
3087
|
if (files.length === 0) return null;
|
|
2988
3088
|
let result;
|
|
2989
3089
|
try {
|
|
2990
|
-
const { runWorkerAgent } = await import("./engine-
|
|
3090
|
+
const { runWorkerAgent } = await import("./engine-DL15-epb.js");
|
|
2991
3091
|
result = await runWorkerAgent({
|
|
2992
3092
|
mode: "explore",
|
|
2993
3093
|
workspace: root,
|
|
@@ -3010,10 +3110,25 @@ async function discoverGateCommands(cwd, opts) {
|
|
|
3010
3110
|
evidence: files
|
|
3011
3111
|
};
|
|
3012
3112
|
}
|
|
3013
|
-
|
|
3014
3113
|
//#endregion
|
|
3015
3114
|
//#region src/lib/orchestration/prompt-submit-hook.ts
|
|
3016
3115
|
/**
|
|
3116
|
+
* Pure decision logic for the `UserPromptSubmit` hook (the front-end of the
|
|
3117
|
+
* floor-raising surface). It serves TWO duties on the one event:
|
|
3118
|
+
*
|
|
3119
|
+
* 1. Per-prompt budget reset — clears the Stop-gate's block counter for this
|
|
3120
|
+
* session so `maxBlocks` is "per user prompt", not "per session lifetime"
|
|
3121
|
+
* (the per-prompt scoping the Stop-gate relies on).
|
|
3122
|
+
* 2. Goal steer — for a NON-trivial prompt, inject an advisory GOAL directive
|
|
3123
|
+
* (additive context, never blocking) pointing the model at `/gh-research`
|
|
3124
|
+
* then `/gh-orchestrate`. It raises the prior; it does not force execution.
|
|
3125
|
+
*
|
|
3126
|
+
* Top-level only: like the Stop-gate, it stands down in a subagent/teammate
|
|
3127
|
+
* context (the payload carries `agent_type`/`agent_id` there) so it never
|
|
3128
|
+
* recurses into spawned workers. Fail-open and never blocking: any error or a
|
|
3129
|
+
* trivial prompt yields an empty injection.
|
|
3130
|
+
*/
|
|
3131
|
+
/**
|
|
3017
3132
|
* The advisory goal injected for a non-trivial prompt. Uses the skills' slash
|
|
3018
3133
|
* invocation form. The model still decides whether to follow it; the Stop-gate
|
|
3019
3134
|
* backstops correctness at the output end.
|
|
@@ -3057,7 +3172,7 @@ const PROMPT_SEARCH_TIP = "TIP (advisory): when this task needs code context, se
|
|
|
3057
3172
|
* user-derived (not invented) advisory note grounded in the search results. */
|
|
3058
3173
|
const PROMPT_SCOPE_SYSTEM = "You are a scoping assistant for a coding agent about to act on a user's request. You are given the user's request and the results of a lexical + semantic code search over the relevant repository. Produce a SHORT advisory note (<= 120 words), plain text only:\n1. SCOPE: one line — is this trivial, focused (one area), or large/cross-cutting — grounded in what the search surfaced (reference the most relevant file(s) by name).\n2. GOAL: restate the user's OWN ask as a single measurable objective, in THEIR terms. Do NOT invent new requirements or acceptance criteria beyond what they asked.\n3. Only if the task is large/cross-cutting, add a final line: \"Consider /gh-research first to saturate understanding, then /gh-orchestrate to compose a floor-raising workflow.\" Omit it for a focused or trivial task.\nThis is advisory — the agent decides whether to follow it. Be concrete and concise; no preamble.";
|
|
3059
3174
|
/** Max chars of each search-result blob fed into the scope inference. */
|
|
3060
|
-
const SEARCH_CONTEXT_CAP =
|
|
3175
|
+
const SEARCH_CONTEXT_CAP = 6144;
|
|
3061
3176
|
/** Wrap the prior-turn review findings in an explicitly NON-AUTHORITATIVE frame. */
|
|
3062
3177
|
function framePendingFindings(findings) {
|
|
3063
3178
|
return "ADVISORY — independent review of your PREVIOUS change (NON-AUTHORITATIVE): an independent gpt-5.6-sol reviewer flagged the following. Evaluate each on its merits — fix the real ones, and ignore any wrong one with a one-line reason. You are NOT obligated to act on these.\n" + findings.trim();
|
|
@@ -3144,9 +3259,22 @@ function buildPromptSubmitHookCommand(execPath, scriptPath) {
|
|
|
3144
3259
|
if (scriptPath && scriptPath !== execPath) return `${q(execPath)} ${q(scriptPath)} internal-prompt-submit`;
|
|
3145
3260
|
return `${q(execPath)} internal-prompt-submit`;
|
|
3146
3261
|
}
|
|
3147
|
-
|
|
3148
3262
|
//#endregion
|
|
3149
3263
|
//#region src/lib/first-mate/operator-shaping.ts
|
|
3264
|
+
/**
|
|
3265
|
+
* Operator-mode steering for github-router `--agents` sessions.
|
|
3266
|
+
*
|
|
3267
|
+
* In first-mate/operator mode the spawned Claude is the cloud-agent operator, not
|
|
3268
|
+
* the product implementer. Product implementation is steered to GitHub cloud
|
|
3269
|
+
* agents by first-mate guidance and the gh-first-mate skill. This module no
|
|
3270
|
+
* longer hard-blocks local file writes or shell commands: Edit/Write/NotebookEdit
|
|
3271
|
+
* and Bash remain available as escape hatches when needed.
|
|
3272
|
+
*
|
|
3273
|
+
* The only enforced main-operator boundary is that local worker/orchestrate MCP
|
|
3274
|
+
* tools stay subagent-only. The main operator should invoke the worker-* Agent
|
|
3275
|
+
* subagents or delegate to GitHub cloud agents rather than calling
|
|
3276
|
+
* `mcp__workers__*` / `mcp__orchestrate__*` directly.
|
|
3277
|
+
*/
|
|
3150
3278
|
/** MCP prefixes denied to the main operator; these remain subagent-only. */
|
|
3151
3279
|
const OPERATOR_DENIED_MCP_PREFIXES = ["mcp__workers__", "mcp__orchestrate__"];
|
|
3152
3280
|
/**
|
|
@@ -3193,9 +3321,19 @@ function operatorPreToolUse(toolName, operatorMode, input) {
|
|
|
3193
3321
|
};
|
|
3194
3322
|
return { block: false };
|
|
3195
3323
|
}
|
|
3196
|
-
|
|
3197
3324
|
//#endregion
|
|
3198
3325
|
//#region src/internal-first-mate-guard.ts
|
|
3326
|
+
/**
|
|
3327
|
+
* The internal `internal-first-mate-guard` subcommand: a PreToolUse hook that
|
|
3328
|
+
* keeps local worker/orchestrate MCP tools subagent-only in cloud-agent OPERATOR
|
|
3329
|
+
* mode. It is injected into the spawned Claude session's settings ONLY in
|
|
3330
|
+
* `--agents`/operator mode, with a matcher scoped to those MCP tool prefixes.
|
|
3331
|
+
*
|
|
3332
|
+
* Reads the PreToolUse payload from stdin (`{tool_name, tool_input, ...}`) and,
|
|
3333
|
+
* if the tool is denied in operator mode, blocks via exit code 2 with a reason
|
|
3334
|
+
* on stderr (the repo's hook convention). Bash and file-authoring tools are not
|
|
3335
|
+
* matched by this guard and are not blocked.
|
|
3336
|
+
*/
|
|
3199
3337
|
function readStdinSync() {
|
|
3200
3338
|
try {
|
|
3201
3339
|
return readFileSync(0, "utf8");
|
|
@@ -3211,7 +3349,7 @@ const internalFirstMateGuard = defineCommand({
|
|
|
3211
3349
|
run() {
|
|
3212
3350
|
let toolName = "";
|
|
3213
3351
|
let hookInput;
|
|
3214
|
-
let parsed
|
|
3352
|
+
let parsed;
|
|
3215
3353
|
try {
|
|
3216
3354
|
const payload = JSON.parse(readStdinSync());
|
|
3217
3355
|
if (typeof payload.tool_name === "string") toolName = payload.tool_name;
|
|
@@ -3241,7 +3379,6 @@ const FIRST_MATE_GUARD_MATCHER = "mcp__workers__.*|mcp__orchestrate__.*";
|
|
|
3241
3379
|
function buildFirstMateGuardHookCommand(execPath, entry) {
|
|
3242
3380
|
return `${JSON.stringify(execPath)} ${JSON.stringify(entry)} internal-first-mate-guard`;
|
|
3243
3381
|
}
|
|
3244
|
-
|
|
3245
3382
|
//#endregion
|
|
3246
3383
|
//#region src/lib/injected-skills/first-mate-conduct-skill.ts
|
|
3247
3384
|
const FIRST_MATE_CONDUCT_SKILL = {
|
|
@@ -3294,7 +3431,6 @@ The ledger is durable memory for unit state; the strategy store is durable memor
|
|
|
3294
3431
|
${DEFINITION_OF_GREATNESS}
|
|
3295
3432
|
`
|
|
3296
3433
|
};
|
|
3297
|
-
|
|
3298
3434
|
//#endregion
|
|
3299
3435
|
//#region src/lib/injected-skills/first-mate-operate-skill.ts
|
|
3300
3436
|
const FIRST_MATE_OPERATE_SKILL = {
|
|
@@ -3396,14 +3532,12 @@ Hard authority limits: launching to real external channels, any spend or paid ac
|
|
|
3396
3532
|
Report the current phase, its checkpoint and whether it is met with reproducible evidence, the active mission(s) and their phase-appropriate acceptance criteria, and the next decision or escalation.
|
|
3397
3533
|
`
|
|
3398
3534
|
};
|
|
3399
|
-
|
|
3400
3535
|
//#endregion
|
|
3401
3536
|
//#region src/lib/injected-skills/first-mate-setup-skill.ts
|
|
3402
3537
|
const FIRST_MATE_SETUP_SKILL = {
|
|
3403
3538
|
name: "gh-first-mate-scaffold",
|
|
3404
3539
|
md: "---\nname: gh-first-mate-scaffold\ndescription: Scaffolds a repo-geared agentic-dev foundation through first-mate: seeds guidance files, role agents, ADRs, changelog, learnings, PR template, test instructions, Copilot setup, and CI through a scaffold branch and PR. Use before the first build wave on an owned repository.\nuser-invocable: true\n---\n\n# gh-first-mate-scaffold\n\nInvoke the `scaffold_repo` MCP tool (`mcp__first-mate__scaffold_repo`) before the first build wave on an owned repository. The goal is not generic TODO stubs; it is a repo-geared foundation that GitHub agents, local agents, reviewers, and CI can read.\n\n## What it seeds\n\n- `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` / `.github/copilot-instructions.md` — identical guidance with overview, detected stack, commands, hard DoD gate, primary OS, conventions, structure, decisions/memory, handoff, testing, and gotchas.\n- `.github/agents/{ceo,cto,cpo,planner,implementer,reviewer,researcher,tester}.md` mirrored into `.claude/agents/` — C-suite operator hats plus execution roles, each with frontmatter, cold-start contract, method, quality bar, output contract, and self-reminder.\n- `docs/playbook/README.md` — the autonomous DISCOVER → NICHE → POSITION → SCOPE → BUILD → LAUNCH → MEASURE → ITERATE → GROW protocol, externally verifiable phase gates, OODA / Build-Measure-Learn governance, and authority limits.\n- `docs/adrs/0000-template.md` plus `docs/adr/0001-record-architecture-decisions.md` — Nygard-style decision record foundation.\n- `LEARNINGS.md`, `CHANGELOG.md`, `docs/history/0000-template.md`, `docs/plans/README.md`, and `docs/research/README.md` — durable memory, history, plans, and research conventions.\n- `.github/pull_request_template.md` — summary, type, failure-modes-considered-and-tested, and DoD checklist.\n- `.github/instructions/tests.instructions.md` — path-scoped test guidance filled from detected framework/dir/glob where possible.\n- `.github/workflows/copilot-setup-steps.yml` and starter `.github/workflows/ci.yml` — detected toolchain setup with stable quality-gate job names.\n\nIt does not seed factory-protocol or `docs/factory/` files. Orchestration remains outside the product repo in first-mate.\n\n## Usage\n\n```\nmcp__first-mate__scaffold_repo({ repo: \"owner/repo\" })\nmcp__first-mate__scaffold_repo({ repo: \"owner/repo\", mode: \"enhance\" })\nmcp__first-mate__scaffold_repo({\n repo: \"owner/repo\",\n mode: \"add-missing-only\",\n detection_overrides: { primary_os: \"windows-latest\", test_command: \"npm test\" }\n})\n```\n\nModes:\n\n- `add-missing-only` (default): seed absent files and skip present files.\n- `enhance`: for guidance files, product playbook, ADR index, changelog, and learnings, append only missing `##` sections; never rewrite existing prose. Other present files are skipped.\n- `overwrite-approved`: replace existing files only when explicitly approved.\n\nAlways inspect the returned per-file report and PR. A no-op result means the repo already has the foundation or has no missing enhanceable sections.\n"
|
|
3405
3540
|
};
|
|
3406
|
-
|
|
3407
3541
|
//#endregion
|
|
3408
3542
|
//#region src/lib/injected-skills/first-mate-skill.ts
|
|
3409
3543
|
const FIRST_MATE_SKILL = {
|
|
@@ -3565,7 +3699,6 @@ When reporting progress, return:
|
|
|
3565
3699
|
- Next wake: the heartbeat state — armed and next check in nextWakeSeconds seconds, or idle (disarmed).
|
|
3566
3700
|
`
|
|
3567
3701
|
};
|
|
3568
|
-
|
|
3569
3702
|
//#endregion
|
|
3570
3703
|
//#region src/lib/injected-skills/floor-keeper-skill.ts
|
|
3571
3704
|
const FLOOR_KEEPER_SKILL = {
|
|
@@ -3716,7 +3849,6 @@ Return a compact final checkpoint:
|
|
|
3716
3849
|
- Do not bury cap-hit or unknown states in a green-sounding summary.
|
|
3717
3850
|
`
|
|
3718
3851
|
};
|
|
3719
|
-
|
|
3720
3852
|
//#endregion
|
|
3721
3853
|
//#region src/lib/injected-skills/orchestrate-skill.ts
|
|
3722
3854
|
const ORCHESTRATE_SKILL = {
|
|
@@ -3855,7 +3987,6 @@ Return:
|
|
|
3855
3987
|
- Final residual risks and next action.
|
|
3856
3988
|
`
|
|
3857
3989
|
};
|
|
3858
|
-
|
|
3859
3990
|
//#endregion
|
|
3860
3991
|
//#region src/lib/injected-skills/research-skill.ts
|
|
3861
3992
|
const RESEARCH_SKILL = {
|
|
@@ -3967,7 +4098,6 @@ Return a compact brief, not the whole research dump:
|
|
|
3967
4098
|
- Do not paste the entire persisted brief into later turns unless the user asks.
|
|
3968
4099
|
`
|
|
3969
4100
|
};
|
|
3970
|
-
|
|
3971
4101
|
//#endregion
|
|
3972
4102
|
//#region src/lib/injected-skills/worker-skill.ts
|
|
3973
4103
|
/**
|
|
@@ -4028,7 +4158,6 @@ The dispatcher calls the worker once and relays its result verbatim.
|
|
|
4028
4158
|
for long worker fan-out.
|
|
4029
4159
|
`
|
|
4030
4160
|
};
|
|
4031
|
-
|
|
4032
4161
|
//#endregion
|
|
4033
4162
|
//#region src/lib/injected-skills/artifact-review-skill.ts
|
|
4034
4163
|
const ARTIFACT_REVIEW_SKILL = {
|
|
@@ -4102,7 +4231,6 @@ To let the human act on the artifact (not just comment), emit declarative contro
|
|
|
4102
4231
|
- The panel is a review surface, not an approver: outward/irreversible actions still need explicit user confirmation.
|
|
4103
4232
|
`
|
|
4104
4233
|
};
|
|
4105
|
-
|
|
4106
4234
|
//#endregion
|
|
4107
4235
|
//#region src/lib/claude-md-injection.ts
|
|
4108
4236
|
/**
|
|
@@ -4189,7 +4317,7 @@ const OPERATING_DEFAULTS_DIGEST = "## Operating defaults (the user's explicit di
|
|
|
4189
4317
|
* the awareness via `--append-system-prompt`, so skipping here only
|
|
4190
4318
|
* loses descendant-reach.
|
|
4191
4319
|
*/
|
|
4192
|
-
const MAX_CLAUDE_MD_BYTES =
|
|
4320
|
+
const MAX_CLAUDE_MD_BYTES = 1048576;
|
|
4193
4321
|
/**
|
|
4194
4322
|
* Bounded retry budget for the temp → rename step on Windows where
|
|
4195
4323
|
* `fs.rename` can transiently fail with EBUSY / EPERM / EACCES when
|
|
@@ -4326,7 +4454,7 @@ async function isUnderClaudeConfigMirrorRealpath(target) {
|
|
|
4326
4454
|
consola.warn(`${ERROR_CODE$1}: realpath failed on mirror root ${mirrorRoot}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4327
4455
|
return false;
|
|
4328
4456
|
}
|
|
4329
|
-
const targetParent =
|
|
4457
|
+
const targetParent = path.dirname(target);
|
|
4330
4458
|
let resolvedTargetParent;
|
|
4331
4459
|
try {
|
|
4332
4460
|
resolvedTargetParent = await fs.realpath(targetParent);
|
|
@@ -4335,7 +4463,7 @@ async function isUnderClaudeConfigMirrorRealpath(target) {
|
|
|
4335
4463
|
return false;
|
|
4336
4464
|
}
|
|
4337
4465
|
if (resolvedTargetParent === resolvedRoot) return true;
|
|
4338
|
-
return resolvedTargetParent.startsWith(resolvedRoot +
|
|
4466
|
+
return resolvedTargetParent.startsWith(resolvedRoot + path.sep);
|
|
4339
4467
|
}
|
|
4340
4468
|
/**
|
|
4341
4469
|
* Try `fs.rename(temp, target)` with bounded retry + verify-on-fail.
|
|
@@ -4385,13 +4513,13 @@ async function injectMarkerBlock(opts) {
|
|
|
4385
4513
|
consola.warn(`${ERROR_CODE$1}: refusing to inject ${label} snippet that contains marker literal; this would corrupt idempotency on the next launch`);
|
|
4386
4514
|
return;
|
|
4387
4515
|
}
|
|
4388
|
-
const target =
|
|
4516
|
+
const target = path.join(PATHS.CLAUDE_CONFIG_DIR, "CLAUDE.md");
|
|
4389
4517
|
if (!await isUnderClaudeConfigMirrorRealpath(target)) {
|
|
4390
4518
|
consola.warn(`${ERROR_CODE$1}: refusing to write outside resolved mirror dir (target=${target}, mirror=${PATHS.CLAUDE_CONFIG_DIR}) [${label}]`);
|
|
4391
4519
|
return;
|
|
4392
4520
|
}
|
|
4393
|
-
let existingContent
|
|
4394
|
-
let targetExists
|
|
4521
|
+
let existingContent;
|
|
4522
|
+
let targetExists;
|
|
4395
4523
|
try {
|
|
4396
4524
|
const linkStat = await fs.lstat(target);
|
|
4397
4525
|
if (linkStat.isSymbolicLink()) {
|
|
@@ -4568,9 +4696,25 @@ async function prependArtifactPanelDirectiveToMirroredClaudeMd(peersKey = "peers
|
|
|
4568
4696
|
label: "artifact-panel-directive"
|
|
4569
4697
|
});
|
|
4570
4698
|
}
|
|
4571
|
-
|
|
4572
4699
|
//#endregion
|
|
4573
4700
|
//#region src/lib/injected-skills/write.ts
|
|
4701
|
+
/**
|
|
4702
|
+
* Writer for injected skills: materializes a `SKILL.md` into the per-launch
|
|
4703
|
+
* `CLAUDE_CONFIG_DIR` mirror so the spawned Claude Code session discovers it as a
|
|
4704
|
+
* user-scope skill (`<CLAUDE_CONFIG_DIR>/skills/<name>/SKILL.md`, where the folder
|
|
4705
|
+
* name MUST equal the frontmatter `name` — the loader enforces this).
|
|
4706
|
+
*
|
|
4707
|
+
* Safety mirrors `claude-md-injection.ts`:
|
|
4708
|
+
* - mirror-only write guard (symlink-resolving) so we never touch the user's
|
|
4709
|
+
* real `~/.claude/skills/`;
|
|
4710
|
+
* - ATOMIC temp+rename so a concurrent child process can never read a
|
|
4711
|
+
* half-written `SKILL.md` (Claude Code watches the skills dir);
|
|
4712
|
+
* - warn-and-continue on every failure — an injected skill is an enhancement,
|
|
4713
|
+
* never a launch blocker.
|
|
4714
|
+
*
|
|
4715
|
+
* No per-skill stale sweep is needed: the whole per-launch mirror dir is GC'd by
|
|
4716
|
+
* `removeOwnClaudeConfigMirror` / `sweepStaleClaudeConfigMirrors`.
|
|
4717
|
+
*/
|
|
4574
4718
|
/** Grep-able prefix on every warn path (mirrors the CLAUDE_MD_WRITE convention). */
|
|
4575
4719
|
const ERROR_CODE = "INJECTED_SKILL_WRITE";
|
|
4576
4720
|
/**
|
|
@@ -4586,13 +4730,13 @@ const VALID_SKILL_NAME = /^[a-z][a-z0-9-]*$/;
|
|
|
4586
4730
|
* then the body). Idempotent across launches (overwrite); the per-launch mirror
|
|
4587
4731
|
* dir is disposable.
|
|
4588
4732
|
*/
|
|
4589
|
-
async function writeInjectedSkill(name
|
|
4590
|
-
if (!VALID_SKILL_NAME.test(name
|
|
4591
|
-
consola.warn(`${ERROR_CODE}: invalid skill name "${name
|
|
4733
|
+
async function writeInjectedSkill(name, md) {
|
|
4734
|
+
if (!VALID_SKILL_NAME.test(name)) {
|
|
4735
|
+
consola.warn(`${ERROR_CODE}: invalid skill name "${name}" (need lowercase kebab); skipping`);
|
|
4592
4736
|
return { written: false };
|
|
4593
4737
|
}
|
|
4594
|
-
const dir =
|
|
4595
|
-
const target =
|
|
4738
|
+
const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, "skills", name);
|
|
4739
|
+
const target = path.join(dir, "SKILL.md");
|
|
4596
4740
|
try {
|
|
4597
4741
|
await fs.mkdir(dir, { recursive: true });
|
|
4598
4742
|
} catch (err) {
|
|
@@ -4621,9 +4765,15 @@ async function writeInjectedSkill(name$1, md) {
|
|
|
4621
4765
|
path: target
|
|
4622
4766
|
};
|
|
4623
4767
|
}
|
|
4624
|
-
|
|
4625
4768
|
//#endregion
|
|
4626
4769
|
//#region src/lib/injected-skills/index.ts
|
|
4770
|
+
/**
|
|
4771
|
+
* Injected-skill registry: the floor-raising / controller skills the `claude`
|
|
4772
|
+
* launcher materializes into the per-launch `CLAUDE_CONFIG_DIR` mirror so the
|
|
4773
|
+
* spawned Claude Code session discovers them (`/gh-research`,
|
|
4774
|
+
* `/gh-orchestrate`, `/gh-floor-keeper`, `/gh-first-mate`). See
|
|
4775
|
+
* `docs/floor-raising-agent-surface.md`.
|
|
4776
|
+
*/
|
|
4627
4777
|
/** All injected skills, in dependency order (research underpins the others). */
|
|
4628
4778
|
const INJECTED_SKILLS = [
|
|
4629
4779
|
RESEARCH_SKILL,
|
|
@@ -4635,7 +4785,6 @@ const INJECTED_SKILLS = [
|
|
|
4635
4785
|
FIRST_MATE_OPERATE_SKILL,
|
|
4636
4786
|
FIRST_MATE_CONDUCT_SKILL
|
|
4637
4787
|
];
|
|
4638
|
-
|
|
4639
4788
|
//#endregion
|
|
4640
4789
|
//#region src/lib/attribution-settings.ts
|
|
4641
4790
|
/**
|
|
@@ -4700,11 +4849,30 @@ async function injectAttributionSuppressionIntoSettingsFile(settingsPath) {
|
|
|
4700
4849
|
await fs.rename(tmp, settingsPath);
|
|
4701
4850
|
return { written: true };
|
|
4702
4851
|
}
|
|
4703
|
-
|
|
4704
4852
|
//#endregion
|
|
4705
4853
|
//#region src/lib/toolbelt/provision.ts
|
|
4854
|
+
/**
|
|
4855
|
+
* Toolbelt provisioner: materialize curated CLI tools into the durable
|
|
4856
|
+
* router `bin/` dir so the spawned agent can call them on PATH.
|
|
4857
|
+
*
|
|
4858
|
+
* Properties (all load-bearing):
|
|
4859
|
+
* - **Best-effort.** Every tool is isolated in try/catch; a failure
|
|
4860
|
+
* skips that one tool and never throws to the launcher.
|
|
4861
|
+
* - **Gap-fill.** A tool already on the user's PATH is NOT
|
|
4862
|
+
* materialized (and a stale copy is removed) so we never shadow the
|
|
4863
|
+
* user's own `jq`/`yq`/etc.
|
|
4864
|
+
* - **Verified.** The downloaded archive's SHA256 must match the
|
|
4865
|
+
* hardcoded manifest digest BEFORE extraction.
|
|
4866
|
+
* - **Safe extraction.** Only the expected regular-file binary is
|
|
4867
|
+
* pulled out; symlink/hardlink/device entries are rejected.
|
|
4868
|
+
* - **Concurrency-safe.** A cross-process lock serializes writers;
|
|
4869
|
+
* unique temp names + atomic rename avoid half-written binaries.
|
|
4870
|
+
* - **Integrity-pruned.** Unexpected files in `bin/` are removed each
|
|
4871
|
+
* launch so a prompt-injected agent can't plant a `git.cmd` that
|
|
4872
|
+
* shadows real tools in future sessions.
|
|
4873
|
+
*/
|
|
4706
4874
|
/** Per-download cap (bytes) — these binaries are a few MB at most. */
|
|
4707
|
-
const MAX_DOWNLOAD_BYTES =
|
|
4875
|
+
const MAX_DOWNLOAD_BYTES = 67108864;
|
|
4708
4876
|
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
4709
4877
|
const EXE_EXT = process$1.platform === "win32" ? ".exe" : "";
|
|
4710
4878
|
/**
|
|
@@ -4751,13 +4919,13 @@ async function pruneUnexpected(binDir) {
|
|
|
4751
4919
|
} catch {
|
|
4752
4920
|
return;
|
|
4753
4921
|
}
|
|
4754
|
-
for (const name
|
|
4755
|
-
if (name
|
|
4756
|
-
if (!expected.has(name
|
|
4922
|
+
for (const name of entries) {
|
|
4923
|
+
if (name.endsWith(".tmp")) continue;
|
|
4924
|
+
if (!expected.has(name)) await rm(path.join(binDir, name), { force: true }).catch(() => {});
|
|
4757
4925
|
}
|
|
4758
4926
|
}
|
|
4759
4927
|
async function provisionRg(binDir, skip) {
|
|
4760
|
-
const dest =
|
|
4928
|
+
const dest = path.join(binDir, "rg" + EXE_EXT);
|
|
4761
4929
|
if (skip.has("rg") || resolveExecutable("rg")) {
|
|
4762
4930
|
await removeBin(dest);
|
|
4763
4931
|
return;
|
|
@@ -4775,7 +4943,7 @@ async function provisionRg(binDir, skip) {
|
|
|
4775
4943
|
await commit(tmp, dest);
|
|
4776
4944
|
}
|
|
4777
4945
|
async function provisionTool(spec, binDir, skip) {
|
|
4778
|
-
const dest =
|
|
4946
|
+
const dest = path.join(binDir, spec.binBasename + EXE_EXT);
|
|
4779
4947
|
const sidecar = `${dest}.sha256`;
|
|
4780
4948
|
const asset = assetFor(spec);
|
|
4781
4949
|
if (skip.has(spec.command) || !asset) {
|
|
@@ -4843,7 +5011,7 @@ async function commit(tmp, dest) {
|
|
|
4843
5011
|
}
|
|
4844
5012
|
async function ensureAliases(spec, binDir, dest) {
|
|
4845
5013
|
for (const alias of spec.aliases ?? []) {
|
|
4846
|
-
const ap =
|
|
5014
|
+
const ap = path.join(binDir, alias + EXE_EXT);
|
|
4847
5015
|
if (existsSync(ap)) continue;
|
|
4848
5016
|
const tmp = tempName(ap);
|
|
4849
5017
|
try {
|
|
@@ -4856,8 +5024,8 @@ async function ensureAliases(spec, binDir, dest) {
|
|
|
4856
5024
|
}
|
|
4857
5025
|
}
|
|
4858
5026
|
async function removeTool(spec, binDir) {
|
|
4859
|
-
await removeBin(
|
|
4860
|
-
for (const alias of spec.aliases ?? []) await removeBin(
|
|
5027
|
+
await removeBin(path.join(binDir, spec.binBasename + EXE_EXT));
|
|
5028
|
+
for (const alias of spec.aliases ?? []) await removeBin(path.join(binDir, alias + EXE_EXT));
|
|
4861
5029
|
}
|
|
4862
5030
|
async function removeBin(dest) {
|
|
4863
5031
|
await rm(dest, { force: true }).catch(() => {});
|
|
@@ -4887,10 +5055,14 @@ async function exposedCommands(binDir) {
|
|
|
4887
5055
|
for (const spec of TOOLBELT_TOOLS) if (present(spec.binBasename)) out.push(spec.command);
|
|
4888
5056
|
return out;
|
|
4889
5057
|
}
|
|
4890
|
-
|
|
4891
5058
|
//#endregion
|
|
4892
5059
|
//#region src/lib/keep-awake/flags.ts
|
|
4893
5060
|
/**
|
|
5061
|
+
* Env-flag parsing for the Windows keep-awake feature. Pure +
|
|
5062
|
+
* unit-testable (no spawn, no platform branch — the platform gate lives
|
|
5063
|
+
* in `keepAwakeEnabled()`).
|
|
5064
|
+
*/
|
|
5065
|
+
/**
|
|
4894
5066
|
* True unless the operator opted out via `GH_ROUTER_DISABLE_KEEP_AWAKE`.
|
|
4895
5067
|
* Keep-awake is ON BY DEFAULT (the win32-only platform gate is applied
|
|
4896
5068
|
* separately in `keepAwakeEnabled()`). Mirrors the colbert opt-out idiom
|
|
@@ -4907,11 +5079,26 @@ function keepAwakeOptedIn() {
|
|
|
4907
5079
|
function keepDisplayOn() {
|
|
4908
5080
|
return parseBoolEnv(process$1.env.GH_ROUTER_KEEP_DISPLAY_ON) === true;
|
|
4909
5081
|
}
|
|
4910
|
-
|
|
4911
5082
|
//#endregion
|
|
4912
5083
|
//#region src/lib/keep-awake/helper.ts
|
|
5084
|
+
/**
|
|
5085
|
+
* The persistent PowerShell helper that holds a Win32
|
|
5086
|
+
* `SetThreadExecutionState` assertion for the proxy's lifetime.
|
|
5087
|
+
*
|
|
5088
|
+
* `SetThreadExecutionState` is THREAD-scoped: a one-shot process that
|
|
5089
|
+
* sets it and exits releases the assertion immediately. So a long-lived
|
|
5090
|
+
* helper holds it on its own main thread and blocks on stdin; when the
|
|
5091
|
+
* proxy dies (clean exit, signal, SIGKILL/taskkill, OOM) the stdin pipe
|
|
5092
|
+
* closes, the helper hits EOF, clears the assertion, and exits — and
|
|
5093
|
+
* Windows releases the thread's assertion on process death regardless.
|
|
5094
|
+
* The helper therefore cannot outlive the proxy, so no orphan sweep is
|
|
5095
|
+
* needed (unlike colbert/worktrees, which leave on-disk artifacts).
|
|
5096
|
+
*
|
|
5097
|
+
* Best-effort: every failure path (no powershell, Constrained Language
|
|
5098
|
+
* Mode blocking `Add-Type`, spawn error) degrades to a clean no-op. We
|
|
5099
|
+
* NEVER attempt to install, enable, or bypass language-mode/WDAC policy.
|
|
5100
|
+
*/
|
|
4913
5101
|
const ES_CONTINUOUS = 2147483648;
|
|
4914
|
-
const ES_SYSTEM_REQUIRED = 1;
|
|
4915
5102
|
const ES_DISPLAY_REQUIRED = 2;
|
|
4916
5103
|
/** Default time to wait for the helper's `OK` readiness line.
|
|
4917
5104
|
*
|
|
@@ -4941,7 +5128,7 @@ const DEFAULT_READY_TIMEOUT_MS = 2e4;
|
|
|
4941
5128
|
* 32-bit value so the hex literal handed to PowerShell is positive.
|
|
4942
5129
|
*/
|
|
4943
5130
|
function executionStateFlags(displayRequired) {
|
|
4944
|
-
let flags =
|
|
5131
|
+
let flags = -2147483647;
|
|
4945
5132
|
if (displayRequired) flags |= ES_DISPLAY_REQUIRED;
|
|
4946
5133
|
return flags >>> 0;
|
|
4947
5134
|
}
|
|
@@ -5057,17 +5244,37 @@ function killHelper(handle) {
|
|
|
5057
5244
|
killManagedTree(child);
|
|
5058
5245
|
} catch {}
|
|
5059
5246
|
}
|
|
5060
|
-
|
|
5061
5247
|
//#endregion
|
|
5062
5248
|
//#region src/lib/keep-awake/index.ts
|
|
5063
5249
|
/**
|
|
5250
|
+
* Windows keep-awake — public entry points.
|
|
5251
|
+
*
|
|
5252
|
+
* `startKeepAwake()` is the fire-and-forget call the `start` / `claude` /
|
|
5253
|
+
* `codex` launchers invoke after `setupAndServe` (alongside
|
|
5254
|
+
* `provisionAndIndexColbert()` / `runSelfUpdate()`). On Windows, by
|
|
5255
|
+
* default, it spawns a persistent PowerShell helper that holds a
|
|
5256
|
+
* `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`
|
|
5257
|
+
* assertion so the machine doesn't idle-sleep mid-session, and registers
|
|
5258
|
+
* SIGINT/SIGTERM/exit handlers to release it.
|
|
5259
|
+
*
|
|
5260
|
+
* Best-effort and model-agnostic: non-win32 is a total no-op; any
|
|
5261
|
+
* failure (no powershell, Constrained Language Mode blocking `Add-Type`)
|
|
5262
|
+
* degrades to a clean debug-logged no-op. We NEVER install, enable, or
|
|
5263
|
+
* bypass language-mode/WDAC policy, and never block or crash launch.
|
|
5264
|
+
*
|
|
5265
|
+
* Crash safety: the helper blocks on stdin, so if the proxy is hard-
|
|
5266
|
+
* killed the pipe closes, the helper hits EOF and exits, and Windows
|
|
5267
|
+
* releases the assertion on process death — no orphan is possible, so no
|
|
5268
|
+
* boot-time sweep is needed.
|
|
5269
|
+
*/
|
|
5270
|
+
/**
|
|
5064
5271
|
* True iff keep-awake should run THIS launch: win32 AND not opted out.
|
|
5065
5272
|
* Non-win32 short-circuits before anything else (no spawn, no flags read
|
|
5066
5273
|
* beyond the opt-out, no handler registration). `platform` is injectable
|
|
5067
5274
|
* for tests; production callers use the default `process.platform`.
|
|
5068
5275
|
*/
|
|
5069
|
-
function keepAwakeEnabled(platform
|
|
5070
|
-
return platform
|
|
5276
|
+
function keepAwakeEnabled(platform = process$1.platform) {
|
|
5277
|
+
return platform === "win32" && keepAwakeOptedIn();
|
|
5071
5278
|
}
|
|
5072
5279
|
let _handle = null;
|
|
5073
5280
|
let _started = false;
|
|
@@ -5149,7 +5356,6 @@ function startKeepAwake() {
|
|
|
5149
5356
|
async function stopKeepAwake() {
|
|
5150
5357
|
releaseSync();
|
|
5151
5358
|
}
|
|
5152
|
-
|
|
5153
5359
|
//#endregion
|
|
5154
5360
|
//#region src/lib/first-mate/scheduler/autospawn.ts
|
|
5155
5361
|
/**
|
|
@@ -5215,7 +5421,7 @@ function nodeDaemonSpawn(cmd, spawnOpts, spawnImpl = spawn) {
|
|
|
5215
5421
|
function maybeSpawnDaemon(opts) {
|
|
5216
5422
|
const env = opts.env ?? process.env;
|
|
5217
5423
|
if (!shouldAutoSpawnDaemon(env, opts.agentsEnabled)) return void 0;
|
|
5218
|
-
const script =
|
|
5424
|
+
const script = path.join(opts.repoRoot ?? process.cwd(), "scripts/first-mate-daemon.ts");
|
|
5219
5425
|
if (opts.spawn === void 0 && !existsSync(script)) {
|
|
5220
5426
|
consola.debug(`first-mate daemon entry not found (${script}); auto-spawn skipped.`);
|
|
5221
5427
|
return;
|
|
@@ -5284,7 +5490,6 @@ function wireDaemonTeardown(handle, opts = {}) {
|
|
|
5284
5490
|
kill();
|
|
5285
5491
|
});
|
|
5286
5492
|
}
|
|
5287
|
-
|
|
5288
5493
|
//#endregion
|
|
5289
5494
|
//#region src/lib/proxy.ts
|
|
5290
5495
|
function initProxyFromEnv() {
|
|
@@ -5330,26 +5535,23 @@ function initProxyFromEnv() {
|
|
|
5330
5535
|
consola.debug("Proxy setup skipped:", err);
|
|
5331
5536
|
}
|
|
5332
5537
|
}
|
|
5333
|
-
|
|
5334
5538
|
//#endregion
|
|
5335
5539
|
//#region package.json
|
|
5336
5540
|
var name = "github-router";
|
|
5337
|
-
var version$1 = "0.3.
|
|
5338
|
-
|
|
5541
|
+
var version$1 = "0.3.241";
|
|
5339
5542
|
//#endregion
|
|
5340
5543
|
//#region src/lib/approval.ts
|
|
5341
5544
|
const awaitApproval = async () => {
|
|
5342
5545
|
if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected by user", Response.json({ message: "Request rejected by user" }, { status: 403 }));
|
|
5343
5546
|
};
|
|
5344
|
-
|
|
5345
5547
|
//#endregion
|
|
5346
5548
|
//#region src/lib/rate-limit.ts
|
|
5347
5549
|
const RATE_LIMIT_QUEUE_TIMEOUT_MS = 5e3;
|
|
5348
5550
|
let rateLimitChain = Promise.resolve();
|
|
5349
|
-
async function checkRateLimit(state
|
|
5350
|
-
if (state
|
|
5551
|
+
async function checkRateLimit(state) {
|
|
5552
|
+
if (state.rateLimitSeconds === void 0) return;
|
|
5351
5553
|
const ticket = { aborted: false };
|
|
5352
|
-
const myTurn = rateLimitChain.then(() => doCheck(state
|
|
5554
|
+
const myTurn = rateLimitChain.then(() => doCheck(state, ticket));
|
|
5353
5555
|
rateLimitChain = myTurn.catch(() => {});
|
|
5354
5556
|
return Promise.race([myTurn, sleep$1(RATE_LIMIT_QUEUE_TIMEOUT_MS).then(() => {
|
|
5355
5557
|
ticket.aborted = true;
|
|
@@ -5362,21 +5564,21 @@ async function checkRateLimit(state$1) {
|
|
|
5362
5564
|
}, { status: 429 }));
|
|
5363
5565
|
})]);
|
|
5364
5566
|
}
|
|
5365
|
-
async function doCheck(state
|
|
5366
|
-
if (state
|
|
5567
|
+
async function doCheck(state, ticket) {
|
|
5568
|
+
if (state.rateLimitSeconds === void 0) return;
|
|
5367
5569
|
if (ticket.aborted) return;
|
|
5368
5570
|
const now = Date.now();
|
|
5369
|
-
if (!state
|
|
5370
|
-
state
|
|
5571
|
+
if (!state.lastRequestTimestamp) {
|
|
5572
|
+
state.lastRequestTimestamp = now;
|
|
5371
5573
|
return;
|
|
5372
5574
|
}
|
|
5373
|
-
const elapsedSeconds = (now - state
|
|
5374
|
-
if (elapsedSeconds > state
|
|
5375
|
-
state
|
|
5575
|
+
const elapsedSeconds = (now - state.lastRequestTimestamp) / 1e3;
|
|
5576
|
+
if (elapsedSeconds > state.rateLimitSeconds) {
|
|
5577
|
+
state.lastRequestTimestamp = now;
|
|
5376
5578
|
return;
|
|
5377
5579
|
}
|
|
5378
|
-
const waitTimeSeconds = Math.ceil(state
|
|
5379
|
-
if (!state
|
|
5580
|
+
const waitTimeSeconds = Math.ceil(state.rateLimitSeconds - elapsedSeconds);
|
|
5581
|
+
if (!state.rateLimitWait) {
|
|
5380
5582
|
consola.warn(`Rate limit exceeded. Need to wait ${waitTimeSeconds} more seconds.`);
|
|
5381
5583
|
throw new HTTPError("Rate limit exceeded", Response.json({ message: "Rate limit exceeded" }, { status: 429 }));
|
|
5382
5584
|
}
|
|
@@ -5384,10 +5586,9 @@ async function doCheck(state$1, ticket) {
|
|
|
5384
5586
|
consola.warn(`Rate limit reached. Waiting ${waitTimeSeconds} seconds before proceeding...`);
|
|
5385
5587
|
await sleep$1(waitTimeMs);
|
|
5386
5588
|
if (ticket.aborted) return;
|
|
5387
|
-
state
|
|
5589
|
+
state.lastRequestTimestamp = Date.now();
|
|
5388
5590
|
consola.info("Rate limit wait completed, proceeding with request");
|
|
5389
5591
|
}
|
|
5390
|
-
|
|
5391
5592
|
//#endregion
|
|
5392
5593
|
//#region src/lib/request-log.ts
|
|
5393
5594
|
/**
|
|
@@ -5495,7 +5696,6 @@ function collectToolFieldKeys(body) {
|
|
|
5495
5696
|
for (const tool of tools) if (tool && typeof tool === "object" && !Array.isArray(tool)) for (const k of Object.keys(tool)) seen.add(k);
|
|
5496
5697
|
return [...seen].sort();
|
|
5497
5698
|
}
|
|
5498
|
-
|
|
5499
5699
|
//#endregion
|
|
5500
5700
|
//#region src/routes/chat-completions/handler.ts
|
|
5501
5701
|
const ENCODER$1 = new TextEncoder();
|
|
@@ -5684,7 +5884,6 @@ function extractUserQuery$2(messages) {
|
|
|
5684
5884
|
}
|
|
5685
5885
|
}
|
|
5686
5886
|
}
|
|
5687
|
-
|
|
5688
5887
|
//#endregion
|
|
5689
5888
|
//#region src/routes/chat-completions/route.ts
|
|
5690
5889
|
const completionRoutes = new Hono();
|
|
@@ -5695,7 +5894,6 @@ completionRoutes.post("/", async (c) => {
|
|
|
5695
5894
|
return await forwardError(c, error);
|
|
5696
5895
|
}
|
|
5697
5896
|
});
|
|
5698
|
-
|
|
5699
5897
|
//#endregion
|
|
5700
5898
|
//#region src/services/copilot/create-embeddings.ts
|
|
5701
5899
|
const createEmbeddings = async (payload) => {
|
|
@@ -5708,19 +5906,18 @@ const createEmbeddings = async (payload) => {
|
|
|
5708
5906
|
if (!response.ok) throw new HTTPError("Failed to create embeddings", response);
|
|
5709
5907
|
return await response.json();
|
|
5710
5908
|
};
|
|
5711
|
-
|
|
5712
5909
|
//#endregion
|
|
5713
5910
|
//#region src/routes/embeddings/route.ts
|
|
5714
5911
|
const embeddingRoutes = new Hono();
|
|
5715
5912
|
embeddingRoutes.post("/", async (c) => {
|
|
5716
5913
|
try {
|
|
5717
|
-
const
|
|
5914
|
+
const payload = await c.req.json();
|
|
5915
|
+
const response = await createEmbeddings(payload);
|
|
5718
5916
|
return c.json(response);
|
|
5719
5917
|
} catch (error) {
|
|
5720
5918
|
return await forwardError(c, error);
|
|
5721
5919
|
}
|
|
5722
5920
|
});
|
|
5723
|
-
|
|
5724
5921
|
//#endregion
|
|
5725
5922
|
//#region src/routes/mcp/route.ts
|
|
5726
5923
|
const mcpRoutes = new Hono();
|
|
@@ -5752,10 +5949,43 @@ mcpRoutes.delete("/:group", (c) => {
|
|
|
5752
5949
|
return c.body(null, 500);
|
|
5753
5950
|
}
|
|
5754
5951
|
});
|
|
5755
|
-
|
|
5756
5952
|
//#endregion
|
|
5757
5953
|
//#region src/lib/sanitize-anthropic-body.ts
|
|
5758
5954
|
/**
|
|
5955
|
+
* Inbound /v1/messages body sanitizer.
|
|
5956
|
+
*
|
|
5957
|
+
* Today this only handles ADVISOR-related corruption — the
|
|
5958
|
+
* proxy-generated `server_tool_use{name:"advisor"}` and paired
|
|
5959
|
+
* `advisor_tool_result` blocks may travel through Claude Code's
|
|
5960
|
+
* persisted conversation state with a malformed `id`/`tool_use_id`
|
|
5961
|
+
* (e.g., a leftover `toolu_*` value from before the round-5 fix).
|
|
5962
|
+
* Every replay of that history through `/v1/messages` would 400 at
|
|
5963
|
+
* Copilot's spec validator without rewriting them on inbound. This
|
|
5964
|
+
* module performs the rewrite.
|
|
5965
|
+
*
|
|
5966
|
+
* **Out of scope** (deliberate, per gemini-critic round 6 — the "ID
|
|
5967
|
+
* round-trip trap"): generic `tool_use.id` and `tool_result.tool_use_id`
|
|
5968
|
+
* are stateful references between requests; rewriting them statelessly
|
|
5969
|
+
* would break Claude Code's client-side tool tracker. Only advisor
|
|
5970
|
+
* blocks are touched here, because both sides of an advisor pair
|
|
5971
|
+
* (`server_tool_use` + `advisor_tool_result`) are proxy-generated and
|
|
5972
|
+
* round-trip together in the same request body — so a per-request
|
|
5973
|
+
* deterministic transformation preserves pairing without cross-request
|
|
5974
|
+
* state.
|
|
5975
|
+
*
|
|
5976
|
+
* **Round-7 holistic fix**: Copilot rejects `server_tool_use{name:
|
|
5977
|
+
* "advisor"}` outright (spec validator says only `web_search`,
|
|
5978
|
+
* `tool_search_tool_regex`, `tool_search_tool_bm25` are allowed), so
|
|
5979
|
+
* fixing the id format alone isn't enough. The sanitizer also
|
|
5980
|
+
* **translates** historical advisor pairs to the
|
|
5981
|
+
* `tool_use{__anthropic_advisor}` + `tool_result` shape Copilot
|
|
5982
|
+
* accepts (per user direction "Option C"). Multi-turn split is
|
|
5983
|
+
* required because `tool_result` must appear in a `user` role per
|
|
5984
|
+
* Anthropic spec. The `__anthropic_advisor` tool definition is
|
|
5985
|
+
* re-injected into `tools[]` so the `tool_use.name` reference
|
|
5986
|
+
* resolves.
|
|
5987
|
+
*/
|
|
5988
|
+
/**
|
|
5759
5989
|
* Convert a `srvtoolu_*` id to the matching `toolu_*` id used in the
|
|
5760
5990
|
* Copilot-replay shape (`tool_use.id` must match `^toolu_*$`). For
|
|
5761
5991
|
* any other input shape, fall back to a synthesized `toolu_advisor_N`
|
|
@@ -5798,7 +6028,7 @@ function splitAssistantTurnAtAdvisorPairs(originalContent, syntheticIndexRef) {
|
|
|
5798
6028
|
while (i < originalContent.length) {
|
|
5799
6029
|
const block = originalContent[i];
|
|
5800
6030
|
const b = typeof block === "object" && block !== null ? block : null;
|
|
5801
|
-
if (b && b.type === "server_tool_use" && b.name ===
|
|
6031
|
+
if (b && b.type === "server_tool_use" && b.name === "__anthropic_advisor".replace(/^__anthropic_/, "")) {
|
|
5802
6032
|
const stuId = typeof b.id === "string" ? b.id : "";
|
|
5803
6033
|
const nextBlock = originalContent[i + 1];
|
|
5804
6034
|
const next = typeof nextBlock === "object" && nextBlock !== null ? nextBlock : null;
|
|
@@ -5902,8 +6132,8 @@ function sanitizeAnthropicBody(rawBody) {
|
|
|
5902
6132
|
if (!content.some((b) => {
|
|
5903
6133
|
if (typeof b !== "object" || b === null) return false;
|
|
5904
6134
|
const type = b.type;
|
|
5905
|
-
const name
|
|
5906
|
-
return type === "server_tool_use" && name
|
|
6135
|
+
const name = b.name;
|
|
6136
|
+
return type === "server_tool_use" && name === "advisor" || type === "advisor_tool_result";
|
|
5907
6137
|
})) {
|
|
5908
6138
|
rebuilt.push(msg);
|
|
5909
6139
|
continue;
|
|
@@ -5920,7 +6150,7 @@ function sanitizeAnthropicBody(rawBody) {
|
|
|
5920
6150
|
const existingTools = Array.isArray(parsed.tools) ? parsed.tools : [];
|
|
5921
6151
|
if (!existingTools.some((t) => {
|
|
5922
6152
|
if (typeof t !== "object" || t === null) return false;
|
|
5923
|
-
return t.name ===
|
|
6153
|
+
return t.name === "__anthropic_advisor";
|
|
5924
6154
|
})) parsed.tools = [...existingTools, {
|
|
5925
6155
|
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
5926
6156
|
description: ADVISOR_TOOL_INSTRUCTIONS,
|
|
@@ -5935,9 +6165,24 @@ function sanitizeAnthropicBody(rawBody) {
|
|
|
5935
6165
|
if (!mutated) return rawBody;
|
|
5936
6166
|
return JSON.stringify(parsed);
|
|
5937
6167
|
}
|
|
5938
|
-
|
|
5939
6168
|
//#endregion
|
|
5940
6169
|
//#region src/lib/anthropic-translate/anthropic-sse.ts
|
|
6170
|
+
/**
|
|
6171
|
+
* Anthropic Messages SSE egress: frame builders + a Bun-safe ReadableStream
|
|
6172
|
+
* adapter that serializes a generator of Anthropic stream events to wire bytes.
|
|
6173
|
+
*
|
|
6174
|
+
* The synthesizer (see `responses-egress.ts`) is source-specific and yields
|
|
6175
|
+
* plain event objects; these builders + adapter own the Anthropic wire shape
|
|
6176
|
+
* and the stream lifecycle so the synthesizer stays a pure state machine.
|
|
6177
|
+
*
|
|
6178
|
+
* Stream-lifecycle contract (repo mandate for any synthesized SSE surface):
|
|
6179
|
+
* the adapter is pull-based (backpressure-respecting), guards every
|
|
6180
|
+
* `controller.enqueue`/`close` against the consumer-cancel race
|
|
6181
|
+
* (`isControllerClosedError`), and on a mid-stream upstream error emits a
|
|
6182
|
+
* terminal Anthropic `event: error` frame before closing — mirroring
|
|
6183
|
+
* `relayAnthropicStream`. Verified against Bun's
|
|
6184
|
+
* `TypeError: Invalid state: Controller is already closed`.
|
|
6185
|
+
*/
|
|
5941
6186
|
function makeMessageId() {
|
|
5942
6187
|
return `msg_${randomUUID().replace(/-/g, "")}`;
|
|
5943
6188
|
}
|
|
@@ -6067,11 +6312,11 @@ function anthropicSseStreamFromEvents(events, opts) {
|
|
|
6067
6312
|
safeClose(controller);
|
|
6068
6313
|
return;
|
|
6069
6314
|
}
|
|
6070
|
-
const name
|
|
6315
|
+
const name = err instanceof Error ? err.name : "Error";
|
|
6071
6316
|
const message = err instanceof Error ? err.message : String(err);
|
|
6072
|
-
consola.error(`Anthropic-translate stream interrupted at ${opts.routePath}: ${name
|
|
6317
|
+
consola.error(`Anthropic-translate stream interrupted at ${opts.routePath}: ${name}: ${JSON.stringify(message)}`);
|
|
6073
6318
|
try {
|
|
6074
|
-
controller.enqueue(enc.encode(buildAnthropicErrorEvent(name
|
|
6319
|
+
controller.enqueue(enc.encode(buildAnthropicErrorEvent(name, message)));
|
|
6075
6320
|
} catch (enqueueError) {
|
|
6076
6321
|
if (!isControllerClosedError(enqueueError)) consola.warn(`Could not deliver error event to consumer at ${opts.routePath}: ${enqueueError instanceof Error ? enqueueError.message : String(enqueueError)}`);
|
|
6077
6322
|
}
|
|
@@ -6105,7 +6350,6 @@ function anthropicSseStreamFromEvents(events, opts) {
|
|
|
6105
6350
|
}
|
|
6106
6351
|
});
|
|
6107
6352
|
}
|
|
6108
|
-
|
|
6109
6353
|
//#endregion
|
|
6110
6354
|
//#region src/lib/reasoning-effort.ts
|
|
6111
6355
|
/**
|
|
@@ -6160,7 +6404,6 @@ function clampEffort(bucketed, supported) {
|
|
|
6160
6404
|
}
|
|
6161
6405
|
return best ?? bucketed;
|
|
6162
6406
|
}
|
|
6163
|
-
|
|
6164
6407
|
//#endregion
|
|
6165
6408
|
//#region src/lib/anthropic-translate/anthropic-request.ts
|
|
6166
6409
|
/**
|
|
@@ -6502,7 +6745,6 @@ function parsedToResponsesPayload(parsed) {
|
|
|
6502
6745
|
stream: parsed.stream
|
|
6503
6746
|
});
|
|
6504
6747
|
}
|
|
6505
|
-
|
|
6506
6748
|
//#endregion
|
|
6507
6749
|
//#region src/lib/anthropic-translate/chat-request.ts
|
|
6508
6750
|
/** Build the `data:` URI (base64) or return the verbatim URL for an image part. */
|
|
@@ -6652,9 +6894,32 @@ function parsedToChatPayload(parsed) {
|
|
|
6652
6894
|
if (parsed.parallelToolCalls === false) payload.parallel_tool_calls = false;
|
|
6653
6895
|
return payload;
|
|
6654
6896
|
}
|
|
6655
|
-
|
|
6656
6897
|
//#endregion
|
|
6657
6898
|
//#region src/lib/anthropic-translate/chat-egress.ts
|
|
6899
|
+
/**
|
|
6900
|
+
* Copilot `/chat/completions` → Anthropic Messages egress (the Gemini path).
|
|
6901
|
+
*
|
|
6902
|
+
* - `chatResponseToAnthropicMessage` maps a non-streaming chat/completions
|
|
6903
|
+
* object to an Anthropic Messages object (text + tool_use blocks, usage,
|
|
6904
|
+
* stop_reason).
|
|
6905
|
+
* - `synthAnthropicFromChat` is the streaming state machine: it consumes
|
|
6906
|
+
* Copilot's chat SSE (`choices[].delta`) and yields Anthropic stream events
|
|
6907
|
+
* (message_start → content_block_* → message_delta → message_stop), reusing
|
|
6908
|
+
* the shared `anthropic-sse.ts` frame builders.
|
|
6909
|
+
*
|
|
6910
|
+
* Chat streaming differs from the Responses path in one load-bearing way: there
|
|
6911
|
+
* is NO authoritative per-tool `.done` event carrying the full arguments — a
|
|
6912
|
+
* tool call's `id`/`name` arrive early (first delta for its array `index`) and
|
|
6913
|
+
* its `arguments` stream incrementally across later deltas keyed by that same
|
|
6914
|
+
* array `index`. So each tool is BUFFERED per OpenAI array index and its
|
|
6915
|
+
* Anthropic block is emitted ATOMICALLY at end-of-stream (content_block_start →
|
|
6916
|
+
* one input_json_delta with the full assembled args → content_block_stop). This
|
|
6917
|
+
* carries the Phase 1 C1 correctness lesson to the chat path: parallel/multiple
|
|
6918
|
+
* tools keep DISTINCT block indices and never lose their args to a clobbered
|
|
6919
|
+
* shared pointer. An open TEXT block is closed before any tool block opens (a
|
|
6920
|
+
* tool_use must not nest inside a text block on the wire). The Anthropic block
|
|
6921
|
+
* index is assigned at emit time so indices stay monotonic on the wire.
|
|
6922
|
+
*/
|
|
6658
6923
|
/** Synthesize a matchable Anthropic tool_use id when the upstream id is absent. */
|
|
6659
6924
|
function makeToolUseId$1() {
|
|
6660
6925
|
return `toolu_${randomUUID().replace(/-/g, "")}`;
|
|
@@ -6802,8 +7067,8 @@ async function* synthAnthropicFromChat(upstream, opts) {
|
|
|
6802
7067
|
toolByIndex.set(tcd.index, entry);
|
|
6803
7068
|
}
|
|
6804
7069
|
if (typeof tcd.id === "string" && tcd.id.length > 0) entry.id = tcd.id;
|
|
6805
|
-
const name
|
|
6806
|
-
if (typeof name
|
|
7070
|
+
const name = tcd.function?.name;
|
|
7071
|
+
if (typeof name === "string" && name.length > 0) entry.name = name;
|
|
6807
7072
|
const argDelta = tcd.function?.arguments;
|
|
6808
7073
|
if (typeof argDelta === "string" && argDelta.length > 0) entry.args += argDelta;
|
|
6809
7074
|
}
|
|
@@ -6811,10 +7076,7 @@ async function* synthAnthropicFromChat(upstream, opts) {
|
|
|
6811
7076
|
if (choice.finish_reason != null) finishReason = choice.finish_reason;
|
|
6812
7077
|
}
|
|
6813
7078
|
if (!sawDone) throw new Error("chat stream ended without a [DONE] sentinel (truncated)");
|
|
6814
|
-
if (activeTextIndex != null)
|
|
6815
|
-
yield makeContentBlockStop(activeTextIndex);
|
|
6816
|
-
activeTextIndex = null;
|
|
6817
|
-
}
|
|
7079
|
+
if (activeTextIndex != null) yield makeContentBlockStop(activeTextIndex);
|
|
6818
7080
|
let sawTool = false;
|
|
6819
7081
|
const orderedTools = [...toolByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
|
6820
7082
|
for (const [, entry] of orderedTools) {
|
|
@@ -6837,10 +7099,38 @@ async function* synthAnthropicFromChat(upstream, opts) {
|
|
|
6837
7099
|
});
|
|
6838
7100
|
yield makeMessageStop();
|
|
6839
7101
|
}
|
|
6840
|
-
|
|
6841
7102
|
//#endregion
|
|
6842
7103
|
//#region src/lib/anthropic-translate/responses-egress.ts
|
|
6843
7104
|
/**
|
|
7105
|
+
* OpenAI `/responses` → Anthropic Messages egress.
|
|
7106
|
+
*
|
|
7107
|
+
* - `responsesResponseToAnthropicMessage` maps a non-streaming Responses object
|
|
7108
|
+
* to an Anthropic Messages object (content blocks, tool_use, usage,
|
|
7109
|
+
* stop_reason).
|
|
7110
|
+
* - `synthAnthropicFromResponses` is the streaming state machine: it consumes
|
|
7111
|
+
* Copilot's `/responses` SSE and yields Anthropic stream events
|
|
7112
|
+
* (message_start → content_block_* → message_delta → message_stop).
|
|
7113
|
+
*
|
|
7114
|
+
* The decode follows our own empirically-verified `/responses` handling
|
|
7115
|
+
* (`src/lib/worker-agent/stream-fn.ts`): the tool/reasoning block key is the
|
|
7116
|
+
* STABLE `output_index`, never the per-event `item_id` — Copilot re-encrypts
|
|
7117
|
+
* `item_id` on every event, so an id key makes every delta lookup miss and the
|
|
7118
|
+
* tool args drop to `{}`.
|
|
7119
|
+
*
|
|
7120
|
+
* Block lifecycle (mirrors stream-fn.ts): every output item is tracked
|
|
7121
|
+
* independently by its `output_index` key. Parallel/interleaved tool calls
|
|
7122
|
+
* never force-close a sibling — Responses emits ALL `output_item.added` for
|
|
7123
|
+
* parallel tools BEFORE the first `function_call_arguments.delta`, so opening (and
|
|
7124
|
+
* emitting) a tool at `added` time would ship it with empty args and drop the
|
|
7125
|
+
* later deltas. Instead a tool's args are BUFFERED and its Anthropic block is
|
|
7126
|
+
* emitted ATOMICALLY (content_block_start → single input_json_delta with the
|
|
7127
|
+
* full assembled args → content_block_stop) at that tool's OWN
|
|
7128
|
+
* `output_item.done` (or flushed at end-of-stream if the item was left
|
|
7129
|
+
* dangling). Only an open TEXT or THINKING block is closed on a type switch —
|
|
7130
|
+
* never a sibling tool. The Anthropic block index is assigned at emit time, so
|
|
7131
|
+
* indices stay monotonic on the wire regardless of item interleaving.
|
|
7132
|
+
*/
|
|
7133
|
+
/**
|
|
6844
7134
|
* Stable map key for a `/responses` output item: prefer `output_index`
|
|
6845
7135
|
* (constant per item), fall back to the opaque id only when absent. Namespaced
|
|
6846
7136
|
* so a numeric index and a string id can never collide.
|
|
@@ -6972,11 +7262,11 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
6972
7262
|
if (existing != null && current?.kind === "text" && current.index === existing.index) return existing;
|
|
6973
7263
|
closeCurrent();
|
|
6974
7264
|
const index = nextIndex++;
|
|
6975
|
-
const state
|
|
7265
|
+
const state = {
|
|
6976
7266
|
index,
|
|
6977
7267
|
emitted: ""
|
|
6978
7268
|
};
|
|
6979
|
-
textByKey.set(key, state
|
|
7269
|
+
textByKey.set(key, state);
|
|
6980
7270
|
current = {
|
|
6981
7271
|
index,
|
|
6982
7272
|
kind: "text"
|
|
@@ -6985,7 +7275,7 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
6985
7275
|
type: "text",
|
|
6986
7276
|
text: ""
|
|
6987
7277
|
}));
|
|
6988
|
-
return state
|
|
7278
|
+
return state;
|
|
6989
7279
|
};
|
|
6990
7280
|
const ensureThinking = (key) => {
|
|
6991
7281
|
if (current?.kind === "thinking" && thinkingByKey.get(key) === current.index) return current.index;
|
|
@@ -7035,9 +7325,9 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
7035
7325
|
case "response.output_text.delta": {
|
|
7036
7326
|
const d = ev.delta;
|
|
7037
7327
|
if (typeof d !== "string" || d.length === 0) break;
|
|
7038
|
-
const state
|
|
7039
|
-
state
|
|
7040
|
-
q.push(makeTextDelta(state
|
|
7328
|
+
const state = ensureTextState(responsesKey(ev.output_index, ev.item_id) ?? "text");
|
|
7329
|
+
state.emitted += d;
|
|
7330
|
+
q.push(makeTextDelta(state.index, d));
|
|
7041
7331
|
break;
|
|
7042
7332
|
}
|
|
7043
7333
|
case "response.output_text.done": {
|
|
@@ -7046,9 +7336,9 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
7046
7336
|
const existing = textByKey.get(key);
|
|
7047
7337
|
if (existing == null) {
|
|
7048
7338
|
if (fullText.length > 0) {
|
|
7049
|
-
const state
|
|
7050
|
-
state
|
|
7051
|
-
q.push(makeTextDelta(state
|
|
7339
|
+
const state = ensureTextState(key);
|
|
7340
|
+
state.emitted = fullText;
|
|
7341
|
+
q.push(makeTextDelta(state.index, fullText));
|
|
7052
7342
|
closeCurrent();
|
|
7053
7343
|
}
|
|
7054
7344
|
} else if (currentKind() === "text" && currentIndex() === existing.index) {
|
|
@@ -7139,7 +7429,6 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
7139
7429
|
break;
|
|
7140
7430
|
}
|
|
7141
7431
|
case "response.failed": throw new Error(ev.response?.error?.message ?? "response.failed");
|
|
7142
|
-
default: break;
|
|
7143
7432
|
}
|
|
7144
7433
|
for (const e of q) yield e;
|
|
7145
7434
|
q.length = 0;
|
|
@@ -7158,10 +7447,33 @@ async function* synthAnthropicFromResponses(upstream, opts) {
|
|
|
7158
7447
|
for (const e of q) yield e;
|
|
7159
7448
|
q.length = 0;
|
|
7160
7449
|
}
|
|
7161
|
-
|
|
7162
7450
|
//#endregion
|
|
7163
7451
|
//#region src/lib/anthropic-translate/classifier.ts
|
|
7164
7452
|
/**
|
|
7453
|
+
* Routing classifier for `POST /v1/messages`.
|
|
7454
|
+
*
|
|
7455
|
+
* Claude Code speaks the Anthropic Messages wire format. Copilot only serves
|
|
7456
|
+
* Claude models on its native `/v1/messages` endpoint; a gpt/gemini request
|
|
7457
|
+
* sent there 400s. This classifier decides, from the RESOLVED model id and its
|
|
7458
|
+
* catalog metadata, whether a request stays on the native passthrough
|
|
7459
|
+
* (`createMessages`) or is diverted to the translation shim.
|
|
7460
|
+
*
|
|
7461
|
+
* Non-regression is the whole point — every Claude model (opus / sonnet / haiku /
|
|
7462
|
+
* any `claude-*` or Anthropic-vendored id) MUST return "claude-passthrough" so
|
|
7463
|
+
* its bytes reach `createMessages` unchanged — even if future catalog metadata
|
|
7464
|
+
* were to (wrongly) advertise a `/responses` endpoint for it. The Claude check
|
|
7465
|
+
* is therefore keyed off identity (id / vendor / family), NOT the endpoint.
|
|
7466
|
+
*
|
|
7467
|
+
* Non-Claude models are diverted to the translation shim by the endpoint the
|
|
7468
|
+
* catalog says they serve: `/responses` models (gpt-5.5, gpt-5.3-codex, …) take
|
|
7469
|
+
* the Responses path (`responses-shim`), `/chat/completions` models (gemini,
|
|
7470
|
+
* and any chat-default model) take the chat path (`chat-shim`). The decision is
|
|
7471
|
+
* derived from `pickEndpoint` (catalog `supported_endpoints`), never a
|
|
7472
|
+
* hardcoded slug list, so it generalizes. Copilot only serves Claude models on
|
|
7473
|
+
* its native `/v1/messages`, so diverting every non-Claude model to a shim is
|
|
7474
|
+
* correct — a non-Claude request sent to `/v1/messages` would 400.
|
|
7475
|
+
*/
|
|
7476
|
+
/**
|
|
7165
7477
|
* Match "claude"/"anthropic" as a delimiter-bounded segment anywhere in a model
|
|
7166
7478
|
* id: at the start, or after a `/ _ . : -` path/version delimiter, and followed
|
|
7167
7479
|
* by end-of-string, another such delimiter, or a digit. This catches catalog
|
|
@@ -7214,7 +7526,6 @@ function classifyMessagesRoute(modelId, model, originalModelId) {
|
|
|
7214
7526
|
if (endpoint === "chat") return "chat-shim";
|
|
7215
7527
|
return "claude-passthrough";
|
|
7216
7528
|
}
|
|
7217
|
-
|
|
7218
7529
|
//#endregion
|
|
7219
7530
|
//#region src/lib/anthropic-translate/index.ts
|
|
7220
7531
|
const STREAM_HEADERS = {
|
|
@@ -7338,7 +7649,6 @@ async function handleNonClaudeChat(c, opts) {
|
|
|
7338
7649
|
}, opts.model, opts.startTime);
|
|
7339
7650
|
return c.json(anthropic, 200);
|
|
7340
7651
|
}
|
|
7341
|
-
|
|
7342
7652
|
//#endregion
|
|
7343
7653
|
//#region src/routes/messages/handler.ts
|
|
7344
7654
|
const isWebSearchTool$1 = (tool) => !!tool && typeof tool === "object" && (typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search");
|
|
@@ -7431,7 +7741,7 @@ function stripAdvisorTool(rawBody) {
|
|
|
7431
7741
|
const original = body.tools;
|
|
7432
7742
|
const tools = original.filter((tool) => {
|
|
7433
7743
|
if (typeof tool !== "object" || tool === null) return true;
|
|
7434
|
-
if (tool.name ===
|
|
7744
|
+
if (tool.name === "__anthropic_advisor") return false;
|
|
7435
7745
|
const type = tool.type;
|
|
7436
7746
|
return typeof type !== "string" || !type.startsWith("advisor_");
|
|
7437
7747
|
});
|
|
@@ -7500,7 +7810,8 @@ async function handleCompletion(c) {
|
|
|
7500
7810
|
}
|
|
7501
7811
|
if (state.manualApprove) await awaitApproval();
|
|
7502
7812
|
const betaHeaders = extractBetaHeaders(c);
|
|
7503
|
-
const
|
|
7813
|
+
const incomingBeta = c.req.header("anthropic-beta");
|
|
7814
|
+
const advisorEnabled = isAdvisorRequested(incomingBeta);
|
|
7504
7815
|
let finalBody = await processWebSearch(rawBody);
|
|
7505
7816
|
finalBody = sanitizeAnthropicBody(finalBody);
|
|
7506
7817
|
if (advisorEnabled) {
|
|
@@ -7812,7 +8123,7 @@ function stripAnthropicOnlyFields$1(body) {
|
|
|
7812
8123
|
if (body.output_config !== void 0) {
|
|
7813
8124
|
if (body.output_config && typeof body.output_config === "object") {
|
|
7814
8125
|
const oc = body.output_config;
|
|
7815
|
-
const PROXY_OWNED_FIELDS = new Set(["effort"]);
|
|
8126
|
+
const PROXY_OWNED_FIELDS = /* @__PURE__ */ new Set(["effort"]);
|
|
7816
8127
|
const schema = oc.schema;
|
|
7817
8128
|
const ocType = oc.type;
|
|
7818
8129
|
let strippedAny = false;
|
|
@@ -7869,7 +8180,6 @@ function appendStructuredOutputInstruction(body, schema, ocType) {
|
|
|
7869
8180
|
}];
|
|
7870
8181
|
else body.system = instruction.trimStart();
|
|
7871
8182
|
}
|
|
7872
|
-
|
|
7873
8183
|
//#endregion
|
|
7874
8184
|
//#region src/routes/messages/count-tokens-handler.ts
|
|
7875
8185
|
const isWebSearchTool = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
|
|
@@ -8005,7 +8315,7 @@ function stripAnthropicOnlyFields(body) {
|
|
|
8005
8315
|
if (body.output_config !== void 0) {
|
|
8006
8316
|
if (body.output_config && typeof body.output_config === "object") {
|
|
8007
8317
|
const oc = body.output_config;
|
|
8008
|
-
const PROXY_OWNED_FIELDS = new Set(["effort"]);
|
|
8318
|
+
const PROXY_OWNED_FIELDS = /* @__PURE__ */ new Set(["effort"]);
|
|
8009
8319
|
let strippedAny = false;
|
|
8010
8320
|
for (const key of Object.keys(oc)) if (!PROXY_OWNED_FIELDS.has(key)) {
|
|
8011
8321
|
delete oc[key];
|
|
@@ -8039,7 +8349,6 @@ function stripAnthropicOnlyFields(body) {
|
|
|
8039
8349
|
}
|
|
8040
8350
|
return stripped;
|
|
8041
8351
|
}
|
|
8042
|
-
|
|
8043
8352
|
//#endregion
|
|
8044
8353
|
//#region src/routes/messages/route.ts
|
|
8045
8354
|
const messageRoutes = new Hono();
|
|
@@ -8057,15 +8366,14 @@ messageRoutes.post("/count_tokens", async (c) => {
|
|
|
8057
8366
|
return await forwardError(c, error);
|
|
8058
8367
|
}
|
|
8059
8368
|
});
|
|
8060
|
-
|
|
8061
8369
|
//#endregion
|
|
8062
8370
|
//#region src/routes/models/route.ts
|
|
8063
8371
|
const modelRoutes = new Hono();
|
|
8064
8372
|
modelRoutes.get("/", async (c) => {
|
|
8065
8373
|
try {
|
|
8066
8374
|
if (!state.models) await cacheModels();
|
|
8067
|
-
const models
|
|
8068
|
-
const { requestHeaders
|
|
8375
|
+
const models = state.models?.data.map((model) => {
|
|
8376
|
+
const { requestHeaders, ...rest } = model;
|
|
8069
8377
|
return {
|
|
8070
8378
|
...rest,
|
|
8071
8379
|
object: "model",
|
|
@@ -8078,14 +8386,13 @@ modelRoutes.get("/", async (c) => {
|
|
|
8078
8386
|
});
|
|
8079
8387
|
return c.json({
|
|
8080
8388
|
object: "list",
|
|
8081
|
-
data: models
|
|
8389
|
+
data: models,
|
|
8082
8390
|
has_more: false
|
|
8083
8391
|
});
|
|
8084
8392
|
} catch (error) {
|
|
8085
8393
|
return await forwardError(c, error);
|
|
8086
8394
|
}
|
|
8087
8395
|
});
|
|
8088
|
-
|
|
8089
8396
|
//#endregion
|
|
8090
8397
|
//#region src/routes/responses/handler.ts
|
|
8091
8398
|
const ENCODER = new TextEncoder();
|
|
@@ -8379,7 +8686,6 @@ async function syntheticCompact(c, body, startTime) {
|
|
|
8379
8686
|
}
|
|
8380
8687
|
});
|
|
8381
8688
|
}
|
|
8382
|
-
|
|
8383
8689
|
//#endregion
|
|
8384
8690
|
//#region src/routes/responses/route.ts
|
|
8385
8691
|
const responsesRoutes = new Hono();
|
|
@@ -8397,7 +8703,6 @@ responsesRoutes.post("/compact", async (c) => {
|
|
|
8397
8703
|
return await forwardError(c, error);
|
|
8398
8704
|
}
|
|
8399
8705
|
});
|
|
8400
|
-
|
|
8401
8706
|
//#endregion
|
|
8402
8707
|
//#region src/routes/search/route.ts
|
|
8403
8708
|
const searchRoutes = new Hono();
|
|
@@ -8411,7 +8716,6 @@ searchRoutes.post("/", async (c) => {
|
|
|
8411
8716
|
return await forwardError(c, error);
|
|
8412
8717
|
}
|
|
8413
8718
|
});
|
|
8414
|
-
|
|
8415
8719
|
//#endregion
|
|
8416
8720
|
//#region src/routes/token/route.ts
|
|
8417
8721
|
const tokenRoute = new Hono();
|
|
@@ -8422,7 +8726,6 @@ tokenRoute.get("/", (c) => {
|
|
|
8422
8726
|
} }, 403);
|
|
8423
8727
|
return c.json({ token: state.copilotToken });
|
|
8424
8728
|
});
|
|
8425
|
-
|
|
8426
8729
|
//#endregion
|
|
8427
8730
|
//#region src/routes/usage/route.ts
|
|
8428
8731
|
const usageRoute = new Hono();
|
|
@@ -8435,7 +8738,6 @@ usageRoute.get("/", async (c) => {
|
|
|
8435
8738
|
return await forwardError(c, error);
|
|
8436
8739
|
}
|
|
8437
8740
|
});
|
|
8438
|
-
|
|
8439
8741
|
//#endregion
|
|
8440
8742
|
//#region src/server.ts
|
|
8441
8743
|
assertMcpToolSurfaceConsistent();
|
|
@@ -8477,7 +8779,6 @@ server.notFound((c) => c.json({
|
|
|
8477
8779
|
message: `${c.req.method} ${c.req.path} not found`
|
|
8478
8780
|
}
|
|
8479
8781
|
}, 404));
|
|
8480
|
-
|
|
8481
8782
|
//#endregion
|
|
8482
8783
|
//#region src/lib/server-setup.ts
|
|
8483
8784
|
const MAX_PORT_RETRIES = 10;
|
|
@@ -8646,7 +8947,7 @@ const sharedServerArgs = {
|
|
|
8646
8947
|
description: "Update github-router itself to the latest npm version on launch (throttled once/hour). Best-effort and non-blocking: the proxy serves immediately and a detached updater applies the new version after this process exits (it takes effect on the NEXT launch; the running process keeps its current build). Disable with --no-self-update or GH_ROUTER_NO_SELF_UPDATE=1. Skipped silently if npm/network unavailable."
|
|
8647
8948
|
}
|
|
8648
8949
|
};
|
|
8649
|
-
const allowedAccountTypes = new Set([
|
|
8950
|
+
const allowedAccountTypes = /* @__PURE__ */ new Set([
|
|
8650
8951
|
"individual",
|
|
8651
8952
|
"business",
|
|
8652
8953
|
"enterprise"
|
|
@@ -8782,17 +9083,17 @@ function nativeSelectableModelsInCatalog() {
|
|
|
8782
9083
|
* reject, dropping the rows). Rename-over-existing is atomic on POSIX and
|
|
8783
9084
|
* Windows (libuv MoveFileEx REPLACE_EXISTING).
|
|
8784
9085
|
*/
|
|
8785
|
-
function seedGatewayModelCache(serverUrl, models
|
|
8786
|
-
if (models
|
|
8787
|
-
const cacheDir = nodePath
|
|
8788
|
-
const target = nodePath
|
|
8789
|
-
const tmp = nodePath
|
|
9086
|
+
function seedGatewayModelCache(serverUrl, models, configDir = PATHS.CLAUDE_CONFIG_DIR) {
|
|
9087
|
+
if (models.length === 0) return false;
|
|
9088
|
+
const cacheDir = nodePath.join(configDir, "cache");
|
|
9089
|
+
const target = nodePath.join(cacheDir, "gateway-models.json");
|
|
9090
|
+
const tmp = nodePath.join(cacheDir, `gateway-models.json.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
8790
9091
|
try {
|
|
8791
9092
|
fs$2.mkdirSync(cacheDir, { recursive: true });
|
|
8792
9093
|
const payload = {
|
|
8793
9094
|
baseUrl: serverUrl,
|
|
8794
9095
|
fetchedAt: Date.now(),
|
|
8795
|
-
models: models
|
|
9096
|
+
models: models.map((m) => ({
|
|
8796
9097
|
id: m.id,
|
|
8797
9098
|
display_name: m.display_name
|
|
8798
9099
|
}))
|
|
@@ -8817,7 +9118,7 @@ function seedGatewayModelCache(serverUrl, models$1, configDir = PATHS.CLAUDE_CON
|
|
|
8817
9118
|
*/
|
|
8818
9119
|
function clearGatewayModelCache(configDir = PATHS.CLAUDE_CONFIG_DIR) {
|
|
8819
9120
|
try {
|
|
8820
|
-
fs$2.rmSync(nodePath
|
|
9121
|
+
fs$2.rmSync(nodePath.join(configDir, "cache", "gateway-models.json"), { force: true });
|
|
8821
9122
|
} catch {}
|
|
8822
9123
|
}
|
|
8823
9124
|
/**
|
|
@@ -8912,11 +9213,10 @@ function getCodexEnvVars(serverUrl) {
|
|
|
8912
9213
|
if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
|
|
8913
9214
|
return vars;
|
|
8914
9215
|
}
|
|
8915
|
-
|
|
8916
9216
|
//#endregion
|
|
8917
9217
|
//#region src/claude.ts
|
|
8918
|
-
function isFirstMateSkillName(name
|
|
8919
|
-
return name
|
|
9218
|
+
function isFirstMateSkillName(name) {
|
|
9219
|
+
return name === "gh-first-mate" || name === "gh-first-mate-scaffold" || name === "gh-first-mate-operate" || name === "gh-first-mate-conduct";
|
|
8920
9220
|
}
|
|
8921
9221
|
const claudeArgs = {
|
|
8922
9222
|
...sharedServerArgs,
|
|
@@ -8987,10 +9287,10 @@ const claudeArgs = {
|
|
|
8987
9287
|
function collectChildPassthroughArgs(rawArgs, argsDef) {
|
|
8988
9288
|
const known = /* @__PURE__ */ new Set();
|
|
8989
9289
|
const stringTyped = /* @__PURE__ */ new Set();
|
|
8990
|
-
for (const [name
|
|
9290
|
+
for (const [name, def] of Object.entries(argsDef)) {
|
|
8991
9291
|
const rawAlias = "alias" in def ? def.alias : void 0;
|
|
8992
9292
|
const aliases = rawAlias === void 0 ? [] : Array.isArray(rawAlias) ? rawAlias : [rawAlias];
|
|
8993
|
-
for (const n of [name
|
|
9293
|
+
for (const n of [name, ...aliases]) {
|
|
8994
9294
|
known.add(n);
|
|
8995
9295
|
if (def.type === "string") stringTyped.add(n);
|
|
8996
9296
|
}
|
|
@@ -9051,7 +9351,7 @@ const claude = defineCommand({
|
|
|
9051
9351
|
} catch (err) {
|
|
9052
9352
|
consola.debug("Claude version check failed:", err);
|
|
9053
9353
|
}
|
|
9054
|
-
let server
|
|
9354
|
+
let server;
|
|
9055
9355
|
let serverUrl;
|
|
9056
9356
|
try {
|
|
9057
9357
|
const result = await setupAndServe({
|
|
@@ -9059,7 +9359,7 @@ const claude = defineCommand({
|
|
|
9059
9359
|
port: parsed.port,
|
|
9060
9360
|
silent: true
|
|
9061
9361
|
});
|
|
9062
|
-
server
|
|
9362
|
+
server = result.server;
|
|
9063
9363
|
serverUrl = result.serverUrl;
|
|
9064
9364
|
} catch (error) {
|
|
9065
9365
|
consola.error("Failed to start server:", error instanceof Error ? error.message : error);
|
|
@@ -9175,12 +9475,14 @@ const claude = defineCommand({
|
|
|
9175
9475
|
let skillsWritten = 0;
|
|
9176
9476
|
for (const s of skillsToWrite) if ((await writeInjectedSkill(s.name, s.md).catch(() => ({ written: false }))).written) skillsWritten++;
|
|
9177
9477
|
try {
|
|
9178
|
-
|
|
9478
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9479
|
+
const cmd = buildPromptSubmitHookCommand(process$1.execPath, process$1.argv[1]);
|
|
9480
|
+
await injectStopHookIntoSettingsFile(settingsPath, cmd, "UserPromptSubmit", 45);
|
|
9179
9481
|
} catch (err) {
|
|
9180
9482
|
consola.warn(`Could not register the UserPromptSubmit hook: ${String(err)}`);
|
|
9181
9483
|
}
|
|
9182
9484
|
try {
|
|
9183
|
-
const res = await injectAllowRules(
|
|
9485
|
+
const res = await injectAllowRules(path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json"), planModeAllowRules(injected.ok ? injected.serversAdded : [...Object.values(groupKeys).filter((k) => Boolean(k)), ...backend === "cli" ? ["codex-cli"] : []]));
|
|
9184
9486
|
if (res.added.length) consola.debug(`Auto-approved for plan mode: ${res.added.join(", ")}`);
|
|
9185
9487
|
} catch (err) {
|
|
9186
9488
|
consola.warn(`Could not auto-approve injected tools: ${String(err)}`);
|
|
@@ -9188,10 +9490,12 @@ const claude = defineCommand({
|
|
|
9188
9490
|
if (!injected.ok) consola.warn("Workers non-blocking guard NOT registered: subagent MCP injection fell back to parent-only (--mcp-config), so worker-* dispatchers cannot reach the workers server. Raw (blocking) worker tools remain usable on the main thread this session.");
|
|
9189
9491
|
else if (process$1.env.GH_ROUTER_DISABLE_WORKER_GUARD === "1") consola.info("Workers non-blocking guard disabled via GH_ROUTER_DISABLE_WORKER_GUARD=1 — raw mcp__workers__* is callable on the main thread (blocking); the worker-* background agents remain the steered, non-blocking default.");
|
|
9190
9492
|
else try {
|
|
9191
|
-
const settingsPath =
|
|
9493
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9192
9494
|
const workersKey = workersKeyOf(groupKeys);
|
|
9193
9495
|
const modes = activeDispatchModes({ browse: browseAgentEnabled() });
|
|
9194
|
-
|
|
9496
|
+
const cmd = buildWorkerGuardHookCommand(process$1.execPath, process$1.argv[1], workersKey, modes);
|
|
9497
|
+
const matcher = guardToolMatcher(workersKey, modes);
|
|
9498
|
+
await injectStopHookIntoSettingsFile(settingsPath, cmd, "PreToolUse", 10, matcher);
|
|
9195
9499
|
} catch (err) {
|
|
9196
9500
|
consola.warn(`Could not register the workers PreToolUse guard hook: ${String(err)}`);
|
|
9197
9501
|
}
|
|
@@ -9203,7 +9507,9 @@ const claude = defineCommand({
|
|
|
9203
9507
|
if (agentToolsEnabled()) {
|
|
9204
9508
|
let shapingInstalled = false;
|
|
9205
9509
|
try {
|
|
9206
|
-
|
|
9510
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9511
|
+
const guardCmd = buildFirstMateGuardHookCommand(process$1.execPath, process$1.argv[1]);
|
|
9512
|
+
await injectStopHookIntoSettingsFile(settingsPath, guardCmd, "PreToolUse", void 0, FIRST_MATE_GUARD_MATCHER);
|
|
9207
9513
|
shapingInstalled = true;
|
|
9208
9514
|
} catch (err) {
|
|
9209
9515
|
consola.error(`Could not register the operator worker/orchestrate guard hook: ${String(err)}`);
|
|
@@ -9212,7 +9518,7 @@ const claude = defineCommand({
|
|
|
9212
9518
|
}
|
|
9213
9519
|
const aiordieSidecar = (process$1.env.AIORDIE_CLAUDE_BIND ?? "").trim();
|
|
9214
9520
|
if (aiordieSidecar.length > 0 && !aiordieSidecar.includes("\"")) try {
|
|
9215
|
-
const settingsPath =
|
|
9521
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9216
9522
|
const command = buildSessionBindHookCommand(process$1.execPath, process$1.argv[1], aiordieSidecar);
|
|
9217
9523
|
await injectStopHookIntoSettingsFile(settingsPath, command, "SessionStart");
|
|
9218
9524
|
await injectStopHookIntoSettingsFile(settingsPath, command, "SessionEnd");
|
|
@@ -9228,13 +9534,17 @@ const claude = defineCommand({
|
|
|
9228
9534
|
}
|
|
9229
9535
|
try {
|
|
9230
9536
|
await writeArtifactCredsToMirror(shouldUseInsecureTls(process$1.env.AIORDIE_BASE_URL ?? ""));
|
|
9231
|
-
|
|
9537
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9538
|
+
const cmd = buildArtifactOpenHookCommand(process$1.execPath, process$1.argv[1]);
|
|
9539
|
+
await injectStopHookIntoSettingsFile(settingsPath, cmd, "PostToolUse", void 0, "ExitPlanMode");
|
|
9232
9540
|
} catch (err) {
|
|
9233
9541
|
consola.warn(`Could not register the artifact auto-open hook: ${String(err)}`);
|
|
9234
9542
|
}
|
|
9235
9543
|
}
|
|
9236
9544
|
if (hookMcpRuntimeFromEnv(envVars) && planReviewEnabled()) try {
|
|
9237
|
-
|
|
9545
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9546
|
+
const command = buildPlanReviewHookCommand(process$1.execPath, process$1.argv[1]);
|
|
9547
|
+
await injectStopHookIntoSettingsFile(settingsPath, command, "PostToolUse", void 0, "ExitPlanMode");
|
|
9238
9548
|
} catch (err) {
|
|
9239
9549
|
consola.warn(`Could not register the advisory plan-review hook: ${String(err)}`);
|
|
9240
9550
|
}
|
|
@@ -9281,7 +9591,7 @@ const claude = defineCommand({
|
|
|
9281
9591
|
if (gateEnabled && descriptor) {
|
|
9282
9592
|
const armed = descriptor;
|
|
9283
9593
|
try {
|
|
9284
|
-
const settingsPath =
|
|
9594
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
9285
9595
|
const command = buildStopHookCommand(process$1.execPath, process$1.argv[1]);
|
|
9286
9596
|
if (armed.kind === "sealed") envVars.GH_ROUTER_STOP_GATE_ID = armed.gateId;
|
|
9287
9597
|
else if (armed.kind === "parsed") envVars.GH_ROUTER_STOP_GATE_PARSED = "1";
|
|
@@ -9300,7 +9610,7 @@ const claude = defineCommand({
|
|
|
9300
9610
|
exec: liveExec,
|
|
9301
9611
|
descriptorKey,
|
|
9302
9612
|
token,
|
|
9303
|
-
baseline: fileBaselineStore(
|
|
9613
|
+
baseline: fileBaselineStore(path.join(tmpdir(), "gh-router-stopgate-baseline"))
|
|
9304
9614
|
});
|
|
9305
9615
|
})().catch(() => {});
|
|
9306
9616
|
}
|
|
@@ -9381,7 +9691,7 @@ const claude = defineCommand({
|
|
|
9381
9691
|
consola.warn(`Operating-defaults CLAUDE.md prepend failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
9382
9692
|
}
|
|
9383
9693
|
if (process$1.env.GH_ROUTER_DISABLE_NO_ATTRIBUTION !== "1") try {
|
|
9384
|
-
await injectAttributionSuppressionIntoSettingsFile(
|
|
9694
|
+
await injectAttributionSuppressionIntoSettingsFile(path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json"));
|
|
9385
9695
|
} catch (err) {
|
|
9386
9696
|
consola.warn(`No-attribution settings backstop skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
9387
9697
|
}
|
|
@@ -9390,10 +9700,9 @@ const claude = defineCommand({
|
|
|
9390
9700
|
envVars,
|
|
9391
9701
|
extraArgs,
|
|
9392
9702
|
model: chosenSlug
|
|
9393
|
-
}, server
|
|
9703
|
+
}, server, { onShutdown });
|
|
9394
9704
|
}
|
|
9395
9705
|
});
|
|
9396
|
-
|
|
9397
9706
|
//#endregion
|
|
9398
9707
|
//#region src/codex.ts
|
|
9399
9708
|
const codex = defineCommand({
|
|
@@ -9415,7 +9724,7 @@ const codex = defineCommand({
|
|
|
9415
9724
|
process$1.exit(1);
|
|
9416
9725
|
}
|
|
9417
9726
|
const parsed = parseSharedArgs(args);
|
|
9418
|
-
let server
|
|
9727
|
+
let server;
|
|
9419
9728
|
let serverUrl;
|
|
9420
9729
|
try {
|
|
9421
9730
|
const result = await setupAndServe({
|
|
@@ -9423,7 +9732,7 @@ const codex = defineCommand({
|
|
|
9423
9732
|
port: parsed.port,
|
|
9424
9733
|
silent: true
|
|
9425
9734
|
});
|
|
9426
|
-
server
|
|
9735
|
+
server = result.server;
|
|
9427
9736
|
serverUrl = result.serverUrl;
|
|
9428
9737
|
} catch (error) {
|
|
9429
9738
|
consola.error("Failed to start server:", error instanceof Error ? error.message : error);
|
|
@@ -9436,7 +9745,7 @@ const codex = defineCommand({
|
|
|
9436
9745
|
startKeepAwake();
|
|
9437
9746
|
if ((state.browseEnabled || process$1.env.GH_ROUTER_ENABLE_BROWSE === "1") && hasSupportedBrowserInstalled()) provisionBrowserAssets().catch((err) => consola.debug("Browser extension provisioning failed:", err));
|
|
9438
9747
|
const usingDefault = !args.model;
|
|
9439
|
-
const requestedModel = args.model ??
|
|
9748
|
+
const requestedModel = args.model ?? "gpt-5.6-sol";
|
|
9440
9749
|
enableFileLogging();
|
|
9441
9750
|
let codexModel = resolveCodexModel(requestedModel);
|
|
9442
9751
|
if (codexModel !== requestedModel) consola.info(`Model "${requestedModel}" resolved to "${codexModel}"`);
|
|
@@ -9463,13 +9772,12 @@ const codex = defineCommand({
|
|
|
9463
9772
|
extraArgs: args._ ?? [],
|
|
9464
9773
|
model: codexModel,
|
|
9465
9774
|
serverUrl
|
|
9466
|
-
}, server
|
|
9775
|
+
}, server);
|
|
9467
9776
|
}
|
|
9468
9777
|
});
|
|
9469
|
-
|
|
9470
9778
|
//#endregion
|
|
9471
9779
|
//#region src/debug.ts
|
|
9472
|
-
async function getPackageVersion
|
|
9780
|
+
async function getPackageVersion() {
|
|
9473
9781
|
try {
|
|
9474
9782
|
const packageJsonPath = new URL("../package.json", import.meta.url).pathname;
|
|
9475
9783
|
return JSON.parse(await fs.readFile(packageJsonPath)).version;
|
|
@@ -9495,9 +9803,9 @@ async function checkTokenExists() {
|
|
|
9495
9803
|
}
|
|
9496
9804
|
}
|
|
9497
9805
|
async function getDebugInfo() {
|
|
9498
|
-
const [version
|
|
9806
|
+
const [version, tokenExists] = await Promise.all([getPackageVersion(), checkTokenExists()]);
|
|
9499
9807
|
return {
|
|
9500
|
-
version
|
|
9808
|
+
version,
|
|
9501
9809
|
runtime: getRuntimeInfo(),
|
|
9502
9810
|
paths: {
|
|
9503
9811
|
APP_DIR: PATHS.APP_DIR,
|
|
@@ -9540,10 +9848,27 @@ const debug = defineCommand({
|
|
|
9540
9848
|
return runDebug({ json: args.json });
|
|
9541
9849
|
}
|
|
9542
9850
|
});
|
|
9543
|
-
|
|
9544
9851
|
//#endregion
|
|
9545
9852
|
//#region src/internal-prompt-submit.ts
|
|
9546
9853
|
/**
|
|
9854
|
+
* The internal `internal-prompt-submit` subcommand: the executable a spawned
|
|
9855
|
+
* Claude Code session's `UserPromptSubmit` hook invokes (registered into the
|
|
9856
|
+
* mirrored settings.json by the launcher). It serves the front-end of the
|
|
9857
|
+
* floor-raising surface on one event:
|
|
9858
|
+
* 1. resets the Stop-gate's per-session block budget (making `maxBlocks`
|
|
9859
|
+
* per-prompt);
|
|
9860
|
+
* 2. stashes the prompt + surfaces the prior turn's advisory review findings;
|
|
9861
|
+
* 3. for a non-trivial prompt, injects a GROUNDED, user-derived scope/goal
|
|
9862
|
+
* note (one gpt-5.6-sol call over the prompt + a parallel lexical+semantic code
|
|
9863
|
+
* search) — or, when the proxy URL/nonce isn't wired or anything errors,
|
|
9864
|
+
* falls open to the v1 regex goal.
|
|
9865
|
+
*
|
|
9866
|
+
* ALWAYS exits 0 (never blocks the prompt): the steer is additive context, and a
|
|
9867
|
+
* UserPromptSubmit hook that exit-2'd would refuse the user's prompt. The pure
|
|
9868
|
+
* v1 path (`decidePromptSubmit`) is the fail-open fallback; V2
|
|
9869
|
+
* (`decidePromptSubmitV2`) layers the grounded enrichment on top via injected IO.
|
|
9870
|
+
*/
|
|
9871
|
+
/**
|
|
9547
9872
|
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async
|
|
9548
9873
|
* stdin read leaves an in-flight libuv FS request that, on Windows, races the
|
|
9549
9874
|
* process teardown and trips a `uv_async_send` assertion; a synchronous read has
|
|
@@ -9626,15 +9951,25 @@ const internalPromptSubmit = defineCommand({
|
|
|
9626
9951
|
stdin,
|
|
9627
9952
|
steerEnabled
|
|
9628
9953
|
});
|
|
9629
|
-
if (decision.resetSession) await fileBlockBudget(
|
|
9954
|
+
if (decision.resetSession) await fileBlockBudget(path.join(tmpdir(), "gh-router-stopgate")).reset(decision.resetSession).catch(() => {});
|
|
9630
9955
|
if (decision.inject.length > 0) await new Promise((resolve) => process.stdout.write(`${decision.inject}\n`, () => resolve()));
|
|
9631
9956
|
} catch {}
|
|
9632
9957
|
process.exitCode = 0;
|
|
9633
9958
|
}
|
|
9634
9959
|
});
|
|
9635
|
-
|
|
9636
9960
|
//#endregion
|
|
9637
9961
|
//#region src/internal-plan-review.ts
|
|
9962
|
+
/**
|
|
9963
|
+
* The internal `internal-plan-review` subcommand: a PostToolUse(ExitPlanMode)
|
|
9964
|
+
* hook that spawns a bounded advisory critic review when the main agent finalizes
|
|
9965
|
+
* a substantive plan.
|
|
9966
|
+
*
|
|
9967
|
+
* It never blocks ExitPlanMode: the hook process exits 0 quickly after deciding
|
|
9968
|
+
* whether to spawn a detached reviewer. The detached child calls a cross-lab plan
|
|
9969
|
+
* critic and writes material findings to the SAME per-session findings store used
|
|
9970
|
+
* by the Stop review, so the next UserPromptSubmit surfaces them with the existing
|
|
9971
|
+
* non-authoritative findings path.
|
|
9972
|
+
*/
|
|
9638
9973
|
function readStdin$4() {
|
|
9639
9974
|
try {
|
|
9640
9975
|
if (process.stdin.isTTY) return "";
|
|
@@ -9669,7 +10004,7 @@ function spawnDetachedPlanReview(ctx) {
|
|
|
9669
10004
|
try {
|
|
9670
10005
|
const dir = stopReviewStateDir();
|
|
9671
10006
|
mkdirSync(dir, { recursive: true });
|
|
9672
|
-
payloadPath =
|
|
10007
|
+
payloadPath = path.join(dir, `plan-review-${process.pid}-${randomBytes(4).toString("hex")}.json`);
|
|
9673
10008
|
writeFileSync(payloadPath, JSON.stringify(ctx), { mode: 384 });
|
|
9674
10009
|
const scriptArgs = process.argv[1] && process.argv[1] !== process.execPath ? [process.argv[1]] : [];
|
|
9675
10010
|
const child = spawn(process.execPath, [...scriptArgs, "internal-plan-review"], {
|
|
@@ -9725,10 +10060,30 @@ const internalPlanReview = defineCommand({
|
|
|
9725
10060
|
process.exitCode = 0;
|
|
9726
10061
|
}
|
|
9727
10062
|
});
|
|
9728
|
-
|
|
9729
10063
|
//#endregion
|
|
9730
10064
|
//#region src/internal-session-bind.ts
|
|
9731
10065
|
/**
|
|
10066
|
+
* The internal `internal-session-bind` subcommand: the executable a spawned Claude
|
|
10067
|
+
* Code session's SessionStart / SessionEnd hooks invoke (registered into the
|
|
10068
|
+
* mirrored settings.json by the launcher when `AIORDIE_CLAUDE_BIND` is set —
|
|
10069
|
+
* i.e. when github-router runs inside an ai-or-die Terminal tab).
|
|
10070
|
+
*
|
|
10071
|
+
* It reads Claude Code's hook payload from stdin and ATOMICALLY writes a tiny
|
|
10072
|
+
* "sidecar" JSON file (path passed via `--out`) describing the CURRENTLY active
|
|
10073
|
+
* claude session: its sessionId and the absolute transcript path. ai-or-die owns
|
|
10074
|
+
* the sidecar path and watches it, so it can bind that browser tab's session
|
|
10075
|
+
* summariser to the exact transcript — surviving in-session `/resume`, `/clear`,
|
|
10076
|
+
* `/compact`, and exit→relaunch, without guessing from the filesystem.
|
|
10077
|
+
*
|
|
10078
|
+
* Side-effect only: writes the file and exits 0. It NEVER writes to stdout (a
|
|
10079
|
+
* SessionStart hook's stdout is injected into the model's context) and NEVER
|
|
10080
|
+
* blocks (any error is swallowed → exit 0).
|
|
10081
|
+
*
|
|
10082
|
+
* Top-level filter: a SessionStart fired by a subagent/teammate carries
|
|
10083
|
+
* `agent_id`/`agent_type`; those are skipped so only the tab's top-level session
|
|
10084
|
+
* drives the binding (mirrors the Stop / UserPromptSubmit hooks).
|
|
10085
|
+
*/
|
|
10086
|
+
/**
|
|
9732
10087
|
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async
|
|
9733
10088
|
* stdin read leaves an in-flight libuv FS request that, on Windows, races process
|
|
9734
10089
|
* teardown and trips a `uv_async_send` assertion; a synchronous read has no such
|
|
@@ -9759,11 +10114,11 @@ function realTranscriptPath(tp) {
|
|
|
9759
10114
|
const missing = [];
|
|
9760
10115
|
let cur = tp;
|
|
9761
10116
|
for (let i = 0; i < 64; i++) {
|
|
9762
|
-
missing.unshift(
|
|
9763
|
-
const parent =
|
|
10117
|
+
missing.unshift(path.basename(cur));
|
|
10118
|
+
const parent = path.dirname(cur);
|
|
9764
10119
|
if (parent === cur) break;
|
|
9765
10120
|
try {
|
|
9766
|
-
return
|
|
10121
|
+
return path.join(realpathSync.native(parent), ...missing);
|
|
9767
10122
|
} catch {
|
|
9768
10123
|
cur = parent;
|
|
9769
10124
|
}
|
|
@@ -9804,7 +10159,7 @@ function decodeSessionBind(stdin) {
|
|
|
9804
10159
|
/** Atomically write the sidecar (temp + rename) so the reader never sees a partial file. */
|
|
9805
10160
|
function writeSidecar(out, record) {
|
|
9806
10161
|
try {
|
|
9807
|
-
mkdirSync(
|
|
10162
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
9808
10163
|
} catch {}
|
|
9809
10164
|
const tmp = `${out}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
9810
10165
|
writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
|
|
@@ -9830,7 +10185,6 @@ const internalSessionBind = defineCommand({
|
|
|
9830
10185
|
process.exitCode = 0;
|
|
9831
10186
|
}
|
|
9832
10187
|
});
|
|
9833
|
-
|
|
9834
10188
|
//#endregion
|
|
9835
10189
|
//#region src/internal-workspace-header.ts
|
|
9836
10190
|
function workspaceHeaderJsonForCwd(cwd = process.cwd()) {
|
|
@@ -9850,9 +10204,22 @@ const internalWorkspaceHeader = defineCommand({
|
|
|
9850
10204
|
process.exitCode = 0;
|
|
9851
10205
|
}
|
|
9852
10206
|
});
|
|
9853
|
-
|
|
9854
10207
|
//#endregion
|
|
9855
10208
|
//#region src/lib/artifact/plan-html.ts
|
|
10209
|
+
/**
|
|
10210
|
+
* Render a plan's markdown into a self-contained, styled HTML document for the
|
|
10211
|
+
* ai-or-die Artifact panel. HTML is the canonical review artifact (lavish's
|
|
10212
|
+
* model); converting here means the hands-off auto-open shows a formatted,
|
|
10213
|
+
* annotatable page instead of raw markdown.
|
|
10214
|
+
*
|
|
10215
|
+
* Security: the iframe ai-or-die serves this into runs with
|
|
10216
|
+
* `allow-scripts allow-same-origin`, so a `<script>` smuggled into the plan
|
|
10217
|
+
* markdown could read the per-session asset token. Two defenses:
|
|
10218
|
+
* 1. marked is configured to ESCAPE raw HTML tokens (render as visible text).
|
|
10219
|
+
* 2. `link`/`image` hrefs with script-bearing schemes (javascript:, vbscript:,
|
|
10220
|
+
* non-image data:) are neutralised before rendering.
|
|
10221
|
+
* No separate sanitizer/dep needed.
|
|
10222
|
+
*/
|
|
9856
10223
|
function escapeHtml(s) {
|
|
9857
10224
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
9858
10225
|
}
|
|
@@ -9937,7 +10304,7 @@ function renderMarkdownBody(source) {
|
|
|
9937
10304
|
let line = 1;
|
|
9938
10305
|
let out = "";
|
|
9939
10306
|
for (const tok of tokens) {
|
|
9940
|
-
let html
|
|
10307
|
+
let html;
|
|
9941
10308
|
try {
|
|
9942
10309
|
html = m.parser([tok]);
|
|
9943
10310
|
} catch {
|
|
@@ -10010,9 +10377,30 @@ ${body}
|
|
|
10010
10377
|
</html>
|
|
10011
10378
|
`;
|
|
10012
10379
|
}
|
|
10013
|
-
|
|
10014
10380
|
//#endregion
|
|
10015
10381
|
//#region src/internal-artifact-open.ts
|
|
10382
|
+
/**
|
|
10383
|
+
* The internal `internal-artifact-open` subcommand: invoked by a spawned Claude
|
|
10384
|
+
* Code session's `PostToolUse` hook (matcher `ExitPlanMode`), registered into the
|
|
10385
|
+
* mirrored settings.json by the launcher when running inside an ai-or-die tab
|
|
10386
|
+
* (`AIORDIE_SESSION_ID` set). It auto-opens the just-finalized plan in the
|
|
10387
|
+
* ai-or-die Artifact review panel so the human reviews it WITHOUT the model
|
|
10388
|
+
* having to call artifact_open itself.
|
|
10389
|
+
*
|
|
10390
|
+
* Auth: AIORDIE_TOKEN is stripped from the child env, so the token can't come via
|
|
10391
|
+
* env or argv (argv leaks to `ps`). The launcher writes a mode-600
|
|
10392
|
+
* `.aiordie-artifact.json` into CLAUDE_CONFIG_DIR; this hook reads it.
|
|
10393
|
+
*
|
|
10394
|
+
* Plan: the EXACT plan for THIS session comes from the ExitPlanMode payload —
|
|
10395
|
+
* `tool_input.planFilePath` (per-session; never the globally-newest file in the
|
|
10396
|
+
* shared plans dir) with `tool_input.plan` as the markdown fallback. The plan
|
|
10397
|
+
* markdown is rendered to a self-contained styled HTML file (HTML is the
|
|
10398
|
+
* canonical artifact, lavish's model — formatted + annotatable, not raw md), and
|
|
10399
|
+
* THAT `.html` is opened.
|
|
10400
|
+
*
|
|
10401
|
+
* Side-effect only: never writes stdout, never throws, always exits 0. Skips
|
|
10402
|
+
* subagent payloads (agent_id/agent_type) like the other hooks.
|
|
10403
|
+
*/
|
|
10016
10404
|
function readStdin$2() {
|
|
10017
10405
|
try {
|
|
10018
10406
|
if (process.stdin.isTTY) return "";
|
|
@@ -10022,8 +10410,8 @@ function readStdin$2() {
|
|
|
10022
10410
|
}
|
|
10023
10411
|
}
|
|
10024
10412
|
function credsPath() {
|
|
10025
|
-
const dir = process.env.CLAUDE_CONFIG_DIR ||
|
|
10026
|
-
return
|
|
10413
|
+
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
|
|
10414
|
+
return path.join(dir, ".aiordie-artifact.json");
|
|
10027
10415
|
}
|
|
10028
10416
|
function isSubagent(payload) {
|
|
10029
10417
|
try {
|
|
@@ -10068,7 +10456,7 @@ function resolvePlanMarkdown(p) {
|
|
|
10068
10456
|
const md = readFileSync(p.planFilePath, "utf8");
|
|
10069
10457
|
if (md.trim()) return {
|
|
10070
10458
|
markdown: md,
|
|
10071
|
-
title:
|
|
10459
|
+
title: path.basename(p.planFilePath, path.extname(p.planFilePath)),
|
|
10072
10460
|
nearPath: p.planFilePath
|
|
10073
10461
|
};
|
|
10074
10462
|
} catch {}
|
|
@@ -10086,8 +10474,8 @@ function writePlanHtml(html, nearPath) {
|
|
|
10086
10474
|
let target;
|
|
10087
10475
|
if (nearPath) target = `${nearPath.replace(/\.(md|markdown)$/i, "")}.aiordie.html`;
|
|
10088
10476
|
else {
|
|
10089
|
-
const dir =
|
|
10090
|
-
target =
|
|
10477
|
+
const dir = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude"), "plans");
|
|
10478
|
+
target = path.join(dir, `aiordie-plan-${process.pid}.html`);
|
|
10091
10479
|
}
|
|
10092
10480
|
try {
|
|
10093
10481
|
writeFileSync(target, html, "utf8");
|
|
@@ -10125,10 +10513,23 @@ const internalArtifactOpen = defineCommand({
|
|
|
10125
10513
|
} catch {}
|
|
10126
10514
|
}
|
|
10127
10515
|
});
|
|
10128
|
-
|
|
10129
10516
|
//#endregion
|
|
10130
10517
|
//#region src/internal-stop-hook.ts
|
|
10131
10518
|
/**
|
|
10519
|
+
* The internal `internal-stop-hook` subcommand: the executable a spawned Claude
|
|
10520
|
+
* Code session's Stop hook invokes (registered into the mirrored settings.json by
|
|
10521
|
+
* the launcher when `GH_ROUTER_ENABLE_STOP_GATE` is set). It reads Claude Code's
|
|
10522
|
+
* hook payload from stdin, runs the SEALED structural gate over the session's
|
|
10523
|
+
* working-tree diff, and maps the result to the hook exit contract: exit 2 (with
|
|
10524
|
+
* the reason on stderr) blocks the stop so the model fixes the failure; exit 0
|
|
10525
|
+
* allows it. The `stop_hook_active` loop guard (in `decideStopHook`) guarantees
|
|
10526
|
+
* it can never wedge the session.
|
|
10527
|
+
*
|
|
10528
|
+
* All decision logic lives in `decideStopHook` (pure, unit-tested); this wrapper
|
|
10529
|
+
* only does stdin read + the live `git diff` capture + the exit. The live firing
|
|
10530
|
+
* is verified by the gated E2E (it needs a real spawned session).
|
|
10531
|
+
*/
|
|
10532
|
+
/**
|
|
10132
10533
|
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async
|
|
10133
10534
|
* stdin read leaves an in-flight libuv FS request that, on Windows, races the
|
|
10134
10535
|
* process teardown and trips a `uv_async_send` assertion; a synchronous read has
|
|
@@ -10145,7 +10546,7 @@ function readStdin$1() {
|
|
|
10145
10546
|
}
|
|
10146
10547
|
/** Max diff bytes scanned for gate-weakening: a hard cap so a huge generated diff
|
|
10147
10548
|
* (e.g. a lockfile) can never OOM or stall the hook. */
|
|
10148
|
-
const MAX_DIFF_BYTES =
|
|
10549
|
+
const MAX_DIFF_BYTES = 2097152;
|
|
10149
10550
|
/** Capture the working-tree diff WITHOUT mutating the user's index (no
|
|
10150
10551
|
* `git add -N`): `git diff HEAD` covers modified tracked files, which is where
|
|
10151
10552
|
* gate-weakening edits live. A git failure rejects, so the decision layer can run
|
|
@@ -10184,7 +10585,7 @@ function spawnStopReview(ctx, extras) {
|
|
|
10184
10585
|
try {
|
|
10185
10586
|
const dir = stopReviewStateDir();
|
|
10186
10587
|
mkdirSync(dir, { recursive: true });
|
|
10187
|
-
payloadPath =
|
|
10588
|
+
payloadPath = path.join(dir, `payload-${process.pid}-${randomBytes(4).toString("hex")}.json`);
|
|
10188
10589
|
writeFileSync(payloadPath, JSON.stringify({
|
|
10189
10590
|
session_id: ctx.sessionId,
|
|
10190
10591
|
cwd: ctx.cwd,
|
|
@@ -10246,11 +10647,12 @@ function buildResolveChecks(mode) {
|
|
|
10246
10647
|
if (checks.length === 0) return null;
|
|
10247
10648
|
const descriptorKey = descriptorHash(descriptor);
|
|
10248
10649
|
const workdir = descriptor.workdir || root;
|
|
10650
|
+
const token = process.env.GH_ROUTER_STOP_GATE_BASELINE_TOKEN || void 0;
|
|
10249
10651
|
return {
|
|
10250
10652
|
checks,
|
|
10251
10653
|
workdir,
|
|
10252
10654
|
descriptorKey,
|
|
10253
|
-
baselineKey: launchBaselineKey(workdir, descriptorKey,
|
|
10655
|
+
baselineKey: launchBaselineKey(workdir, descriptorKey, token)
|
|
10254
10656
|
};
|
|
10255
10657
|
};
|
|
10256
10658
|
}
|
|
@@ -10288,8 +10690,8 @@ const internalStopHook = defineCommand({
|
|
|
10288
10690
|
exec: liveExec,
|
|
10289
10691
|
captureDiff,
|
|
10290
10692
|
fallbackCwd: process.cwd(),
|
|
10291
|
-
budget: fileBlockBudget(
|
|
10292
|
-
baseline: fileBaselineStore(
|
|
10693
|
+
budget: fileBlockBudget(path.join(tmpdir(), "gh-router-stopgate")),
|
|
10694
|
+
baseline: fileBaselineStore(path.join(tmpdir(), "gh-router-stopgate-baseline")),
|
|
10293
10695
|
isEnabledForRepo: (cwd) => stopGateEnabledForRepo(cwd),
|
|
10294
10696
|
resolveChecks: mode ? buildResolveChecks(mode) : void 0,
|
|
10295
10697
|
timeoutMs: timeoutEnv,
|
|
@@ -10308,10 +10710,29 @@ const internalStopHook = defineCommand({
|
|
|
10308
10710
|
process.exitCode = decision.exitCode;
|
|
10309
10711
|
}
|
|
10310
10712
|
});
|
|
10311
|
-
|
|
10312
10713
|
//#endregion
|
|
10313
10714
|
//#region src/internal-stop-review.ts
|
|
10314
10715
|
/**
|
|
10716
|
+
* The internal `internal-stop-review` subcommand: the DETACHED background process
|
|
10717
|
+
* the Stop hook spawns (fire-and-forget, unref'd) after a GREEN deterministic
|
|
10718
|
+
* gate. It runs the advisory cross-lab review described in
|
|
10719
|
+
* `docs/hook-v2-design.md` — ADVISORY ONLY, it never blocks anything (the Stop
|
|
10720
|
+
* hook already returned exit 0 before this process even started).
|
|
10721
|
+
*
|
|
10722
|
+
* It reads a JSON payload from stdin (`{session_id, cwd, diff, prompt,
|
|
10723
|
+
* transcript_path}`), calls the read-only `workers/review` MCP tool over loopback
|
|
10724
|
+
* HTTP (gpt-5.6-sol, the live working tree, an accountability brief: judge the diff
|
|
10725
|
+
* against the user's actual ask for wrong-spec / vacuous-tests / incompleteness),
|
|
10726
|
+
* and writes the findings to the per-session findings file. The next
|
|
10727
|
+
* `UserPromptSubmit` surfaces them to Claude NON-authoritatively, and the user
|
|
10728
|
+
* sees them as an accountability log.
|
|
10729
|
+
*
|
|
10730
|
+
* Everything is best-effort: a missing proxy URL/nonce, an unavailable model, a
|
|
10731
|
+
* review error, or an empty result all end the process with NO findings written
|
|
10732
|
+
* and exit 0. It NEVER authors or runs tests (that path was rejected in design as
|
|
10733
|
+
* non-monotone — see the handoff).
|
|
10734
|
+
*/
|
|
10735
|
+
/**
|
|
10315
10736
|
* Read the JSON payload. The Stop hook writes it to a temp file (synchronously,
|
|
10316
10737
|
* before spawning) and passes the path via `GH_ROUTER_STOP_REVIEW_PAYLOAD` — this
|
|
10317
10738
|
* avoids the stdin-flush-before-parent-exit race a pipe would have for a large
|
|
@@ -10340,12 +10761,12 @@ async function readPayload() {
|
|
|
10340
10761
|
/** Embed at most this many diff bytes in the review brief; the reviewer reads the
|
|
10341
10762
|
* live tree itself for anything beyond it, so a giant diff never blows the model
|
|
10342
10763
|
* window. The Stop hook already caps the captured diff at 2 MiB. */
|
|
10343
|
-
const MAX_EMBEDDED_DIFF_BYTES =
|
|
10764
|
+
const MAX_EMBEDDED_DIFF_BYTES = 204800;
|
|
10344
10765
|
/** Wall-clock the stop-gate reviewer may take. This is INDEPENDENT of the
|
|
10345
10766
|
* autonomous worker's wall-clock cap (`DEFAULT_MAX_WALLCLOCK_MS`, now 6h) —
|
|
10346
10767
|
* it bounds this one detached review request. Nothing waits on this process,
|
|
10347
10768
|
* so the bound only stops a hung request from lingering forever. */
|
|
10348
|
-
const REVIEW_TIMEOUT_MS =
|
|
10769
|
+
const REVIEW_TIMEOUT_MS = 21e5;
|
|
10349
10770
|
function buildReviewBrief(payload) {
|
|
10350
10771
|
const diff = payload.diff.length > MAX_EMBEDDED_DIFF_BYTES ? `${payload.diff.slice(0, MAX_EMBEDDED_DIFF_BYTES)}\n\n[diff truncated at ${MAX_EMBEDDED_DIFF_BYTES} bytes — read the files directly for the rest]` : payload.diff;
|
|
10351
10772
|
return `You are an INDEPENDENT accountability reviewer. A coding agent just finished a turn and its working-tree diff passed the deterministic checks (typecheck/test/lint). Your job is to judge whether the change ACTUALLY does what the user asked — passing checks does not prove that.
|
|
@@ -10403,10 +10824,28 @@ const internalStopReview = defineCommand({
|
|
|
10403
10824
|
} catch {}
|
|
10404
10825
|
}
|
|
10405
10826
|
});
|
|
10406
|
-
|
|
10407
10827
|
//#endregion
|
|
10408
10828
|
//#region src/internal-worker-guard.ts
|
|
10409
10829
|
/**
|
|
10830
|
+
* The internal `internal-worker-guard` subcommand: the executable a spawned
|
|
10831
|
+
* Claude Code session's `PreToolUse` hook invokes for the workers tools
|
|
10832
|
+
* (registered into the mirrored settings.json by the launcher, with a matcher
|
|
10833
|
+
* scoped to `^mcp__<workersKey>__(explore|…)$`).
|
|
10834
|
+
*
|
|
10835
|
+
* It enforces the "workers are non-blocking" invariant: the MAIN agent (and any
|
|
10836
|
+
* non-dispatcher subagent) is DENIED a raw `mcp__<workersKey>__<mode>` call and
|
|
10837
|
+
* redirected to the matching background `worker-<mode>` dispatcher subagent; the
|
|
10838
|
+
* dispatcher's own call (identified by `agent_type` in the payload) is ALLOWED.
|
|
10839
|
+
* All the logic lives in the pure `decideWorkerGuard` (worker-dispatch.ts); this
|
|
10840
|
+
* entry is just stdin → decide → stdout.
|
|
10841
|
+
*
|
|
10842
|
+
* Output contract (Claude Code PreToolUse):
|
|
10843
|
+
* - ALLOW → print nothing, exit 0 (normal permission flow proceeds).
|
|
10844
|
+
* - DENY → print the `{hookSpecificOutput:{permissionDecision:"deny",…}}`
|
|
10845
|
+
* JSON to stdout, exit 0 (the JSON, not the exit code, blocks it).
|
|
10846
|
+
* Fail CLOSED: an unparseable payload for a matched worker tool is denied.
|
|
10847
|
+
*/
|
|
10848
|
+
/**
|
|
10410
10849
|
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`) — same
|
|
10411
10850
|
* rationale as `internal-prompt-submit`: an async stdin read leaves an in-flight
|
|
10412
10851
|
* libuv FS request that races Windows process teardown. Hooks always receive
|
|
@@ -10453,7 +10892,6 @@ const internalWorkerGuard = defineCommand({
|
|
|
10453
10892
|
process.exitCode = 0;
|
|
10454
10893
|
}
|
|
10455
10894
|
});
|
|
10456
|
-
|
|
10457
10895
|
//#endregion
|
|
10458
10896
|
//#region src/models.ts
|
|
10459
10897
|
const models = defineCommand({
|
|
@@ -10513,9 +10951,9 @@ const models = defineCommand({
|
|
|
10513
10951
|
process.stdout.write(lines.join("\n"));
|
|
10514
10952
|
}
|
|
10515
10953
|
});
|
|
10516
|
-
function filterModels(models
|
|
10954
|
+
function filterModels(models, pattern) {
|
|
10517
10955
|
const needle = pattern.toLowerCase();
|
|
10518
|
-
return models
|
|
10956
|
+
return models.filter((m) => {
|
|
10519
10957
|
return [
|
|
10520
10958
|
m.id,
|
|
10521
10959
|
m.name,
|
|
@@ -10526,9 +10964,9 @@ function filterModels(models$1, pattern) {
|
|
|
10526
10964
|
].join(" ").toLowerCase().includes(needle);
|
|
10527
10965
|
});
|
|
10528
10966
|
}
|
|
10529
|
-
function groupByVendor(models
|
|
10967
|
+
function groupByVendor(models) {
|
|
10530
10968
|
const map = /* @__PURE__ */ new Map();
|
|
10531
|
-
for (const m of models
|
|
10969
|
+
for (const m of models) {
|
|
10532
10970
|
const key = m.vendor || "(unknown vendor)";
|
|
10533
10971
|
const bucket = map.get(key);
|
|
10534
10972
|
if (bucket) bucket.push(m);
|
|
@@ -10605,12 +11043,9 @@ function formatTokens(n) {
|
|
|
10605
11043
|
if (n >= 1e3 && n % 1e3 === 0) return `${n / 1e3}k`;
|
|
10606
11044
|
return `${n}`;
|
|
10607
11045
|
}
|
|
10608
|
-
|
|
10609
11046
|
//#endregion
|
|
10610
11047
|
//#region src/lib/serve/cloudcli.ts
|
|
10611
11048
|
const CLOUDCLI_PACKAGE = "@cloudcli-ai/cloudcli";
|
|
10612
|
-
/** Pinned, tested known-good version. Never `@latest` (see docs/serve-control-plane.md). */
|
|
10613
|
-
const CLOUDCLI_PINNED_VERSION = "1.36.1";
|
|
10614
11049
|
/**
|
|
10615
11050
|
* Build the env handed to the CloudCLI child. Starts from `buildEnv()` — the
|
|
10616
11051
|
* vetted secret-stripping ALLOWLIST used for worker bash (drops GITHUB_TOKEN,
|
|
@@ -10626,10 +11061,10 @@ function composeCloudCliChildEnv(anthropicVars) {
|
|
|
10626
11061
|
...anthropicVars
|
|
10627
11062
|
};
|
|
10628
11063
|
}
|
|
10629
|
-
const SERVER_ENTRY_REL =
|
|
11064
|
+
const SERVER_ENTRY_REL = path.join("dist-server", "server", "index.js");
|
|
10630
11065
|
/** Where the package's server entry lives under a given install root. */
|
|
10631
11066
|
function serverEntryUnderRoot(root) {
|
|
10632
|
-
return
|
|
11067
|
+
return path.join(root, "node_modules", CLOUDCLI_PACKAGE, SERVER_ENTRY_REL);
|
|
10633
11068
|
}
|
|
10634
11069
|
async function exists(p) {
|
|
10635
11070
|
try {
|
|
@@ -10641,7 +11076,7 @@ async function exists(p) {
|
|
|
10641
11076
|
}
|
|
10642
11077
|
async function readVersion(pkgRoot) {
|
|
10643
11078
|
try {
|
|
10644
|
-
const raw = await fs.readFile(
|
|
11079
|
+
const raw = await fs.readFile(path.join(pkgRoot, "node_modules", CLOUDCLI_PACKAGE, "package.json"), "utf8");
|
|
10645
11080
|
return JSON.parse(raw).version ?? null;
|
|
10646
11081
|
} catch {
|
|
10647
11082
|
return null;
|
|
@@ -10655,9 +11090,9 @@ async function readVersion(pkgRoot) {
|
|
|
10655
11090
|
* Never installs globally, never `@latest`.
|
|
10656
11091
|
*/
|
|
10657
11092
|
async function resolveCloudCli(opts) {
|
|
10658
|
-
const version
|
|
11093
|
+
const version = opts.version ?? "1.36.1";
|
|
10659
11094
|
if (opts.cliPath) {
|
|
10660
|
-
const direct =
|
|
11095
|
+
const direct = path.join(opts.cliPath, SERVER_ENTRY_REL);
|
|
10661
11096
|
const underRoot = serverEntryUnderRoot(opts.cliPath);
|
|
10662
11097
|
if (await exists(direct)) return {
|
|
10663
11098
|
serverEntry: direct,
|
|
@@ -10675,42 +11110,42 @@ async function resolveCloudCli(opts) {
|
|
|
10675
11110
|
serverEntry: entry,
|
|
10676
11111
|
version: await readVersion(root)
|
|
10677
11112
|
};
|
|
10678
|
-
if (opts.noInstall) throw new Error(`CloudCLI is not installed and --no-install was given. Install it with:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version
|
|
10679
|
-
await installCloudCli(root, version
|
|
11113
|
+
if (opts.noInstall) throw new Error(`CloudCLI is not installed and --no-install was given. Install it with:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version}`);
|
|
11114
|
+
await installCloudCli(root, version);
|
|
10680
11115
|
if (!await exists(entry)) throw new Error(`CloudCLI install completed but the server entry is missing (${entry}).`);
|
|
10681
11116
|
return {
|
|
10682
11117
|
serverEntry: entry,
|
|
10683
11118
|
version: await readVersion(root)
|
|
10684
11119
|
};
|
|
10685
11120
|
}
|
|
10686
|
-
async function installCloudCli(root, version
|
|
11121
|
+
async function installCloudCli(root, version) {
|
|
10687
11122
|
const npm = resolveExecutable("npm");
|
|
10688
11123
|
if (!npm) throw new Error("npm not found on PATH — cannot install CloudCLI. Install Node.js/npm, or pass --cloudcli-path to an existing install.");
|
|
10689
11124
|
await fs.mkdir(root, {
|
|
10690
11125
|
recursive: true,
|
|
10691
11126
|
mode: 448
|
|
10692
11127
|
});
|
|
10693
|
-
const pkgJson =
|
|
11128
|
+
const pkgJson = path.join(root, "package.json");
|
|
10694
11129
|
if (!await exists(pkgJson)) await fs.writeFile(pkgJson, JSON.stringify({
|
|
10695
11130
|
name: "gh-router-cloudcli-host",
|
|
10696
11131
|
private: true
|
|
10697
11132
|
}, null, 2));
|
|
10698
|
-
consola.info(`Installing ${CLOUDCLI_PACKAGE}@${version
|
|
11133
|
+
consola.info(`Installing ${CLOUDCLI_PACKAGE}@${version} (one-time, ~a minute; builds native deps)…`);
|
|
10699
11134
|
const { code, stderr } = await runCommandCapture([
|
|
10700
11135
|
npm,
|
|
10701
11136
|
"install",
|
|
10702
|
-
`${CLOUDCLI_PACKAGE}@${version
|
|
11137
|
+
`${CLOUDCLI_PACKAGE}@${version}`,
|
|
10703
11138
|
"--prefix",
|
|
10704
11139
|
root,
|
|
10705
11140
|
"--no-audit",
|
|
10706
11141
|
"--no-fund",
|
|
10707
11142
|
"--loglevel=error"
|
|
10708
|
-
], { timeoutMs:
|
|
10709
|
-
if (code !== 0) throw new Error(`CloudCLI install failed (npm exit ${code}). This often means no network, a corporate proxy, or missing Windows build tools for native deps.\nInstall manually then re-run with --cloudcli-path:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version
|
|
11143
|
+
], { timeoutMs: 6e5 });
|
|
11144
|
+
if (code !== 0) throw new Error(`CloudCLI install failed (npm exit ${code}). This often means no network, a corporate proxy, or missing Windows build tools for native deps.\nInstall manually then re-run with --cloudcli-path:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version}\n${stderr.slice(0, 400)}`);
|
|
10710
11145
|
}
|
|
10711
11146
|
/** Spawn CloudCLI on a loopback port with the given (already-filtered) env. */
|
|
10712
11147
|
function spawnCloudCli(opts) {
|
|
10713
|
-
const databasePath =
|
|
11148
|
+
const databasePath = path.join(PATHS.CLOUDCLI_HOME, "auth.db");
|
|
10714
11149
|
const node = resolveExecutable("node") ?? process.execPath;
|
|
10715
11150
|
const logPath = process.env.GH_ROUTER_CLOUDCLI_LOG;
|
|
10716
11151
|
let stdio = "ignore";
|
|
@@ -10776,7 +11211,7 @@ async function waitForCloudCliReady(port, timeoutMs = 6e4) {
|
|
|
10776
11211
|
}
|
|
10777
11212
|
throw new Error(`CloudCLI did not become ready within ${timeoutMs}ms`);
|
|
10778
11213
|
}
|
|
10779
|
-
const secretFile = () =>
|
|
11214
|
+
const secretFile = () => path.join(PATHS.CLOUDCLI_HOME, ".serve-secret.json");
|
|
10780
11215
|
async function loadPassword(username) {
|
|
10781
11216
|
try {
|
|
10782
11217
|
const raw = await fs.readFile(secretFile(), "utf8");
|
|
@@ -10803,21 +11238,21 @@ async function persistPassword(username, password) {
|
|
|
10803
11238
|
async function mintJwt(port, username, needsSetup) {
|
|
10804
11239
|
const existing = await loadPassword(username);
|
|
10805
11240
|
if (needsSetup) {
|
|
10806
|
-
const password
|
|
10807
|
-
const r
|
|
11241
|
+
const password = existing ?? randomBytes(24).toString("base64url");
|
|
11242
|
+
const r = await req(port, "POST", "/api/auth/register", {
|
|
10808
11243
|
username,
|
|
10809
|
-
password
|
|
11244
|
+
password
|
|
10810
11245
|
});
|
|
10811
|
-
if (r
|
|
10812
|
-
const token = JSON.parse(r
|
|
11246
|
+
if (r.status === 200) {
|
|
11247
|
+
const token = JSON.parse(r.body).token;
|
|
10813
11248
|
if (token) {
|
|
10814
|
-
await persistPassword(username, password
|
|
11249
|
+
await persistPassword(username, password);
|
|
10815
11250
|
return token;
|
|
10816
11251
|
}
|
|
10817
11252
|
}
|
|
10818
11253
|
}
|
|
10819
11254
|
const password = existing;
|
|
10820
|
-
if (!password) throw new Error(`CloudCLI already has a user but no stored password for "${username}". Delete ${
|
|
11255
|
+
if (!password) throw new Error(`CloudCLI already has a user but no stored password for "${username}". Delete ${path.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
|
|
10821
11256
|
const r = await req(port, "POST", "/api/auth/login", {
|
|
10822
11257
|
username,
|
|
10823
11258
|
password
|
|
@@ -10826,9 +11261,8 @@ async function mintJwt(port, username, needsSetup) {
|
|
|
10826
11261
|
const token = JSON.parse(r.body).token;
|
|
10827
11262
|
if (token) return token;
|
|
10828
11263
|
}
|
|
10829
|
-
throw new Error(`Could not obtain a CloudCLI session token (status ${r.status}). If a stale isolated DB exists, delete ${
|
|
11264
|
+
throw new Error(`Could not obtain a CloudCLI session token (status ${r.status}). If a stale isolated DB exists, delete ${path.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
|
|
10830
11265
|
}
|
|
10831
|
-
|
|
10832
11266
|
//#endregion
|
|
10833
11267
|
//#region src/lib/serve/devtunnel.ts
|
|
10834
11268
|
const SERVE_TUNNEL_LABEL = "github-router-serve";
|
|
@@ -11095,7 +11529,6 @@ async function startDevtunnel(port, opts = {}) {
|
|
|
11095
11529
|
"github-router-serve-control-plane"
|
|
11096
11530
|
], timeoutMs);
|
|
11097
11531
|
}
|
|
11098
|
-
|
|
11099
11532
|
//#endregion
|
|
11100
11533
|
//#region src/lib/serve/enhancements.ts
|
|
11101
11534
|
const NOOP = { cleanup: async () => {} };
|
|
@@ -11176,7 +11609,7 @@ async function provisionServeEnhancements(serverUrl, opts = {}) {
|
|
|
11176
11609
|
if (s.name.startsWith("gh-first-mate") || s.name === "gh-artifact-review") continue;
|
|
11177
11610
|
if ((await writeInjectedSkill(s.name, s.md).catch(() => ({ written: false }))).written) skillsWritten++;
|
|
11178
11611
|
}
|
|
11179
|
-
const settingsPath =
|
|
11612
|
+
const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
|
|
11180
11613
|
await injectAttributionSuppressionIntoSettingsFile(settingsPath).catch(() => {});
|
|
11181
11614
|
const strip = await sanitizeServeSettingsEnv(settingsPath).catch((err) => {
|
|
11182
11615
|
consola.warn(`Could not sanitize serve settings env: ${String(err)}`);
|
|
@@ -11186,16 +11619,20 @@ async function provisionServeEnhancements(serverUrl, opts = {}) {
|
|
|
11186
11619
|
if (process.env.GH_ROUTER_SERVE_NO_AUTO_APPROVE !== "1") await configureServeDefaultPermissionMode(settingsPath).catch((err) => consola.warn(`Could not set serve permission defaults: ${String(err)}`));
|
|
11187
11620
|
await injectAllowRules(settingsPath, planModeAllowRules(injected.ok ? injected.serversAdded : [...Object.values(groupKeys).filter((k) => Boolean(k)), ...opts.codexCli === true ? ["codex-cli"] : []])).catch((err) => consola.warn(`Could not auto-approve injected tools: ${String(err)}`));
|
|
11188
11621
|
if (workerToolsEnabled()) {
|
|
11189
|
-
|
|
11622
|
+
const promptCmd = buildPromptSubmitHookCommand(process.execPath, process.argv[1]);
|
|
11623
|
+
await injectStopHookIntoSettingsFile(settingsPath, promptCmd, "UserPromptSubmit", 45).catch((err) => consola.warn(`Could not register the UserPromptSubmit hook: ${String(err)}`));
|
|
11190
11624
|
if (!injected.ok) consola.warn("Workers non-blocking guard NOT registered: subagent MCP injection fell back to parent-only, so worker-* dispatchers cannot reach the workers server. Raw worker tools remain usable on the main thread.");
|
|
11191
11625
|
else if (process.env.GH_ROUTER_DISABLE_WORKER_GUARD !== "1") {
|
|
11192
11626
|
const workersKey = workersKeyOf(groupKeys);
|
|
11193
11627
|
const modes = activeDispatchModes({ browse: browseAllowed && browseAgentEnabled() });
|
|
11194
|
-
|
|
11628
|
+
const cmd = buildWorkerGuardHookCommand(process.execPath, process.argv[1], workersKey, modes);
|
|
11629
|
+
const matcher = guardToolMatcher(workersKey, modes);
|
|
11630
|
+
await injectStopHookIntoSettingsFile(settingsPath, cmd, "PreToolUse", 10, matcher).catch((err) => consola.warn(`Could not register the workers PreToolUse guard hook: ${String(err)}`));
|
|
11195
11631
|
} else consola.info("Workers non-blocking guard disabled via GH_ROUTER_DISABLE_WORKER_GUARD=1; raw worker tools remain callable on the main thread.");
|
|
11196
11632
|
}
|
|
11197
11633
|
if (planReviewEnabled()) try {
|
|
11198
|
-
|
|
11634
|
+
const command = buildPlanReviewHookCommand(process.execPath, process.argv[1]);
|
|
11635
|
+
await injectStopHookIntoSettingsFile(settingsPath, command, "PostToolUse", void 0, "ExitPlanMode");
|
|
11199
11636
|
} catch (err) {
|
|
11200
11637
|
consola.warn(`Could not register the advisory plan-review hook: ${String(err)}`);
|
|
11201
11638
|
}
|
|
@@ -11211,7 +11648,6 @@ async function provisionServeEnhancements(serverUrl, opts = {}) {
|
|
|
11211
11648
|
return NOOP;
|
|
11212
11649
|
}
|
|
11213
11650
|
}
|
|
11214
|
-
|
|
11215
11651
|
//#endregion
|
|
11216
11652
|
//#region src/lib/serve/provider-facade.ts
|
|
11217
11653
|
function isObject(value) {
|
|
@@ -11274,9 +11710,9 @@ function rewriteModels(upstreamJson, ctx) {
|
|
|
11274
11710
|
if (!isObject(upstreamJson)) return null;
|
|
11275
11711
|
const data = envelopedData(upstreamJson);
|
|
11276
11712
|
if (!data || !isObject(data.models) || data.cache === void 0) return null;
|
|
11277
|
-
const models
|
|
11278
|
-
if (!Array.isArray(models
|
|
11279
|
-
const options = models
|
|
11713
|
+
const models = ctx.getModels()?.data;
|
|
11714
|
+
if (!Array.isArray(models) || models.length === 0) return null;
|
|
11715
|
+
const options = models.filter(isPickerModel).map((m) => {
|
|
11280
11716
|
const option = {
|
|
11281
11717
|
value: m.id,
|
|
11282
11718
|
label: m.name || m.id,
|
|
@@ -11310,21 +11746,21 @@ async function rewriteMcp(upstreamJson, ctx, query) {
|
|
|
11310
11746
|
if (!isObject(upstreamJson) || query.get("scope") !== "user") return null;
|
|
11311
11747
|
const data = envelopedData(upstreamJson);
|
|
11312
11748
|
if (!data || !Array.isArray(data.servers)) return null;
|
|
11313
|
-
const mirror = await readJson(
|
|
11749
|
+
const mirror = await readJson(path.join(ctx.claudeConfigDir, ".claude.json"));
|
|
11314
11750
|
if (!isObject(mirror) || !isObject(mirror.mcpServers)) return null;
|
|
11315
|
-
const existing = new Set(data.servers.filter(isObject).map((s) => s.name).filter((name
|
|
11751
|
+
const existing = new Set(data.servers.filter(isObject).map((s) => s.name).filter((name) => typeof name === "string"));
|
|
11316
11752
|
const additions = [];
|
|
11317
|
-
for (const [name
|
|
11318
|
-
if (existing.has(name
|
|
11319
|
-
const server
|
|
11753
|
+
for (const [name, entry] of Object.entries(mirror.mcpServers)) {
|
|
11754
|
+
if (existing.has(name) || !isObject(entry)) continue;
|
|
11755
|
+
const server = {
|
|
11320
11756
|
provider: "claude",
|
|
11321
|
-
name
|
|
11757
|
+
name,
|
|
11322
11758
|
scope: "user",
|
|
11323
11759
|
transport: entry.type === "http" ? "http" : "stdio"
|
|
11324
11760
|
};
|
|
11325
|
-
if (typeof entry.url === "string") server
|
|
11326
|
-
additions.push(server
|
|
11327
|
-
existing.add(name
|
|
11761
|
+
if (typeof entry.url === "string") server.url = entry.url;
|
|
11762
|
+
additions.push(server);
|
|
11763
|
+
existing.add(name);
|
|
11328
11764
|
}
|
|
11329
11765
|
if (additions.length === 0) return null;
|
|
11330
11766
|
const nextData = cloneObject(data);
|
|
@@ -11338,7 +11774,7 @@ async function rewriteSkills(upstreamJson, ctx) {
|
|
|
11338
11774
|
if (!isObject(upstreamJson)) return null;
|
|
11339
11775
|
const data = envelopedData(upstreamJson);
|
|
11340
11776
|
if (!data || !Array.isArray(data.skills)) return null;
|
|
11341
|
-
const skills = await readSkillDirs(
|
|
11777
|
+
const skills = await readSkillDirs(path.join(ctx.claudeConfigDir, "skills"));
|
|
11342
11778
|
if (skills.length === 0) return null;
|
|
11343
11779
|
const existing = new Set(data.skills.filter(isObject).map((s) => s.command).filter((command) => typeof command === "string"));
|
|
11344
11780
|
const additions = skills.filter((skill) => !existing.has(skill.name)).map((skill) => ({
|
|
@@ -11358,10 +11794,10 @@ async function rewriteSkills(upstreamJson, ctx) {
|
|
|
11358
11794
|
}
|
|
11359
11795
|
async function rewriteCommands(upstreamJson, ctx) {
|
|
11360
11796
|
if (!isObject(upstreamJson) || !Array.isArray(upstreamJson.custom)) return null;
|
|
11361
|
-
const dirs = [
|
|
11797
|
+
const dirs = [path.join(ctx.claudeConfigDir, "commands"), path.join(ctx.claudeConfigDir, ".claude", "commands")];
|
|
11362
11798
|
const commandFiles = (await Promise.all(dirs.map(readCommandFiles))).flat();
|
|
11363
11799
|
if (commandFiles.length === 0) return null;
|
|
11364
|
-
const existing = new Set(upstreamJson.custom.filter(isObject).map((c) => c.name).filter((name
|
|
11800
|
+
const existing = new Set(upstreamJson.custom.filter(isObject).map((c) => c.name).filter((name) => typeof name === "string"));
|
|
11365
11801
|
const additions = commandFiles.filter((command) => !existing.has(command.name)).map((command) => ({
|
|
11366
11802
|
name: command.name,
|
|
11367
11803
|
path: command.relativePath,
|
|
@@ -11402,7 +11838,7 @@ async function readSkillDirs(skillsDir) {
|
|
|
11402
11838
|
try {
|
|
11403
11839
|
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
11404
11840
|
return await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith("gh-")).map(async (entry) => {
|
|
11405
|
-
const sourcePath =
|
|
11841
|
+
const sourcePath = path.join(skillsDir, entry.name, "SKILL.md");
|
|
11406
11842
|
let description = "";
|
|
11407
11843
|
try {
|
|
11408
11844
|
description = parseFrontmatterField(await fs.readFile(sourcePath, "utf8"), "description") ?? "";
|
|
@@ -11434,21 +11870,21 @@ async function readCommandFilesRecursive(root, dir) {
|
|
|
11434
11870
|
}
|
|
11435
11871
|
const out = [];
|
|
11436
11872
|
for (const entry of entries) {
|
|
11437
|
-
const filePath =
|
|
11873
|
+
const filePath = path.join(dir, entry.name);
|
|
11438
11874
|
if (entry.isDirectory()) {
|
|
11439
11875
|
out.push(...await readCommandFilesRecursive(root, filePath));
|
|
11440
11876
|
continue;
|
|
11441
11877
|
}
|
|
11442
11878
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
11443
|
-
const relativePath = normalizeRelative(
|
|
11444
|
-
const name
|
|
11879
|
+
const relativePath = normalizeRelative(path.relative(root, filePath));
|
|
11880
|
+
const name = relativePath.replace(/\.md$/i, "");
|
|
11445
11881
|
let description = "";
|
|
11446
11882
|
try {
|
|
11447
11883
|
const md = await fs.readFile(filePath, "utf8");
|
|
11448
11884
|
description = parseFrontmatterField(md, "description") ?? firstMarkdownLine(md);
|
|
11449
11885
|
} catch {}
|
|
11450
11886
|
out.push({
|
|
11451
|
-
name
|
|
11887
|
+
name,
|
|
11452
11888
|
relativePath,
|
|
11453
11889
|
filePath,
|
|
11454
11890
|
description
|
|
@@ -11477,12 +11913,11 @@ function firstMarkdownLine(markdown) {
|
|
|
11477
11913
|
return "";
|
|
11478
11914
|
}
|
|
11479
11915
|
function normalizeRelative(value) {
|
|
11480
|
-
return value.split(
|
|
11916
|
+
return value.split(path.sep).join("/");
|
|
11481
11917
|
}
|
|
11482
11918
|
function countCommands(builtIn) {
|
|
11483
11919
|
return Array.isArray(builtIn) ? builtIn.length : 0;
|
|
11484
11920
|
}
|
|
11485
|
-
|
|
11486
11921
|
//#endregion
|
|
11487
11922
|
//#region src/lib/serve/reverse-proxy.ts
|
|
11488
11923
|
/**
|
|
@@ -11504,7 +11939,7 @@ const CLAUDE_SETTINGS_KEY = "claude-settings";
|
|
|
11504
11939
|
* the question widget / plan card. Stripped defensively at the seed chokepoint
|
|
11505
11940
|
* so no upstream list (mcp names, a future caller) can reintroduce them.
|
|
11506
11941
|
*/
|
|
11507
|
-
const NEVER_AUTO_APPROVE = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
11942
|
+
const NEVER_AUTO_APPROVE = /* @__PURE__ */ new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
11508
11943
|
/**
|
|
11509
11944
|
* Embed the token as a JS string literal. JSON.stringify escapes quotes and
|
|
11510
11945
|
* backslashes; we additionally neutralize `<` so the value can never break out
|
|
@@ -11540,18 +11975,18 @@ async function startReverseProxy(opts) {
|
|
|
11540
11975
|
const { targetHost, targetPort, bindHost, bindPort, authToken } = opts;
|
|
11541
11976
|
const ownOrigin = `http://${bindHost}:${bindPort}`;
|
|
11542
11977
|
const injection = buildInjection(authToken, opts.seedToolSettings === true, opts.seedAllowedTools ?? []);
|
|
11543
|
-
const allowedHosts = new Set([
|
|
11978
|
+
const allowedHosts = /* @__PURE__ */ new Set([
|
|
11544
11979
|
`127.0.0.1:${bindPort}`,
|
|
11545
11980
|
`localhost:${bindPort}`,
|
|
11546
11981
|
`[::1]:${bindPort}`,
|
|
11547
11982
|
...(opts.extraAllowedHosts ?? []).map((h) => h.toLowerCase())
|
|
11548
11983
|
]);
|
|
11549
|
-
const loopbackHosts = new Set([
|
|
11984
|
+
const loopbackHosts = /* @__PURE__ */ new Set([
|
|
11550
11985
|
`127.0.0.1:${bindPort}`,
|
|
11551
11986
|
`localhost:${bindPort}`,
|
|
11552
11987
|
`[::1]:${bindPort}`
|
|
11553
11988
|
]);
|
|
11554
|
-
const allowedOrigins = new Set([
|
|
11989
|
+
const allowedOrigins = /* @__PURE__ */ new Set([
|
|
11555
11990
|
`http://127.0.0.1:${bindPort}`,
|
|
11556
11991
|
`http://localhost:${bindPort}`,
|
|
11557
11992
|
`http://[::1]:${bindPort}`,
|
|
@@ -11571,7 +12006,7 @@ async function startReverseProxy(opts) {
|
|
|
11571
12006
|
const hostAllowed = (host) => !!host && (allowedHosts.has(host.toLowerCase()) || isDevtunnelHost(host.toLowerCase()));
|
|
11572
12007
|
const originAllowed = (origin) => !origin || allowedOrigins.has(origin.toLowerCase()) || isDevtunnelOrigin(origin);
|
|
11573
12008
|
const liveSockets = /* @__PURE__ */ new Set();
|
|
11574
|
-
const server
|
|
12009
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
11575
12010
|
clientReq.on("error", () => clientRes.destroy());
|
|
11576
12011
|
clientRes.on("error", () => clientReq.destroy());
|
|
11577
12012
|
if (!hostAllowed(clientReq.headers.host) || !originAllowed(clientReq.headers.origin)) {
|
|
@@ -11579,7 +12014,7 @@ async function startReverseProxy(opts) {
|
|
|
11579
12014
|
clientRes.end("Forbidden");
|
|
11580
12015
|
return;
|
|
11581
12016
|
}
|
|
11582
|
-
if ((clientReq.url ?? "").split("?")[0] ===
|
|
12017
|
+
if ((clientReq.url ?? "").split("?")[0] === "/__github-router-serve__") {
|
|
11583
12018
|
if (!loopbackHosts.has(String(clientReq.headers.host ?? "").toLowerCase())) {
|
|
11584
12019
|
clientRes.writeHead(404, { "content-type": "text/plain" });
|
|
11585
12020
|
clientRes.end("Not found");
|
|
@@ -11588,7 +12023,7 @@ async function startReverseProxy(opts) {
|
|
|
11588
12023
|
clientRes.writeHead(200, { "content-type": "application/json" });
|
|
11589
12024
|
clientRes.end(JSON.stringify({
|
|
11590
12025
|
service: SERVE_IDENTITY_SERVICE,
|
|
11591
|
-
version: getPackageVersion()
|
|
12026
|
+
version: getPackageVersion$1()
|
|
11592
12027
|
}));
|
|
11593
12028
|
return;
|
|
11594
12029
|
}
|
|
@@ -11673,7 +12108,7 @@ async function startReverseProxy(opts) {
|
|
|
11673
12108
|
});
|
|
11674
12109
|
clientReq.pipe(proxyReq);
|
|
11675
12110
|
});
|
|
11676
|
-
server
|
|
12111
|
+
server.on("upgrade", (clientReq, clientSock, clientHead) => {
|
|
11677
12112
|
clientSock.on("error", () => clientSock.destroy());
|
|
11678
12113
|
liveSockets.add(clientSock);
|
|
11679
12114
|
clientSock.once("close", () => liveSockets.delete(clientSock));
|
|
@@ -11718,15 +12153,15 @@ async function startReverseProxy(opts) {
|
|
|
11718
12153
|
});
|
|
11719
12154
|
await new Promise((resolve, reject) => {
|
|
11720
12155
|
const onError = (err) => reject(err);
|
|
11721
|
-
server
|
|
11722
|
-
server
|
|
11723
|
-
server
|
|
12156
|
+
server.once("error", onError);
|
|
12157
|
+
server.listen(bindPort, bindHost, () => {
|
|
12158
|
+
server.removeListener("error", onError);
|
|
11724
12159
|
resolve();
|
|
11725
12160
|
});
|
|
11726
12161
|
});
|
|
11727
|
-
const addr = server
|
|
12162
|
+
const addr = server.address();
|
|
11728
12163
|
if (addr && addr.address !== "127.0.0.1" && addr.address !== "::1") {
|
|
11729
|
-
await new Promise((r) => server
|
|
12164
|
+
await new Promise((r) => server.close(() => r()));
|
|
11730
12165
|
throw new Error(`reverse proxy refused non-loopback bind (got ${addr.address})`);
|
|
11731
12166
|
}
|
|
11732
12167
|
return {
|
|
@@ -11736,14 +12171,13 @@ async function startReverseProxy(opts) {
|
|
|
11736
12171
|
for (const s of liveSockets) s.destroy();
|
|
11737
12172
|
liveSockets.clear();
|
|
11738
12173
|
const t = setTimeout(resolve, 1500);
|
|
11739
|
-
server
|
|
12174
|
+
server.close(() => {
|
|
11740
12175
|
clearTimeout(t);
|
|
11741
12176
|
resolve();
|
|
11742
12177
|
});
|
|
11743
12178
|
})
|
|
11744
12179
|
};
|
|
11745
12180
|
}
|
|
11746
|
-
|
|
11747
12181
|
//#endregion
|
|
11748
12182
|
//#region src/serve.ts
|
|
11749
12183
|
async function getFreePort() {
|
|
@@ -11777,7 +12211,7 @@ function isPortFree(port) {
|
|
|
11777
12211
|
*/
|
|
11778
12212
|
function probeExistingServe(port) {
|
|
11779
12213
|
return new Promise((resolve) => {
|
|
11780
|
-
const req
|
|
12214
|
+
const req = http.get({
|
|
11781
12215
|
host: "127.0.0.1",
|
|
11782
12216
|
port,
|
|
11783
12217
|
path: SERVE_IDENTITY_PATH,
|
|
@@ -11800,8 +12234,8 @@ function probeExistingServe(port) {
|
|
|
11800
12234
|
});
|
|
11801
12235
|
res.on("error", () => resolve(false));
|
|
11802
12236
|
});
|
|
11803
|
-
req
|
|
11804
|
-
req
|
|
12237
|
+
req.on("timeout", () => req.destroy());
|
|
12238
|
+
req.on("error", () => resolve(false));
|
|
11805
12239
|
});
|
|
11806
12240
|
}
|
|
11807
12241
|
/**
|
|
@@ -11910,7 +12344,7 @@ const serve$1 = defineCommand({
|
|
|
11910
12344
|
if (args["no-open"] !== true) openBrowser(existingUrl);
|
|
11911
12345
|
return;
|
|
11912
12346
|
}
|
|
11913
|
-
const { server
|
|
12347
|
+
const { server, serverUrl } = await setupAndServe({
|
|
11914
12348
|
...parsed,
|
|
11915
12349
|
port: void 0,
|
|
11916
12350
|
silent: true
|
|
@@ -11993,7 +12427,7 @@ const serve$1 = defineCommand({
|
|
|
11993
12427
|
} catch {}
|
|
11994
12428
|
if (reverseClose) await reverseClose().catch(() => {});
|
|
11995
12429
|
try {
|
|
11996
|
-
server
|
|
12430
|
+
server.close?.();
|
|
11997
12431
|
} catch {}
|
|
11998
12432
|
await stopKeepAwake().catch(() => {});
|
|
11999
12433
|
await enhancements.cleanup().catch(() => {});
|
|
@@ -12094,12 +12528,11 @@ const serve$1 = defineCommand({
|
|
|
12094
12528
|
if (args["no-open"] !== true) openBrowser(reverse.url);
|
|
12095
12529
|
}
|
|
12096
12530
|
});
|
|
12097
|
-
|
|
12098
12531
|
//#endregion
|
|
12099
12532
|
//#region src/lib/shell.ts
|
|
12100
12533
|
function getShell() {
|
|
12101
|
-
const { platform
|
|
12102
|
-
if (platform
|
|
12534
|
+
const { platform, env } = process$1;
|
|
12535
|
+
if (platform === "win32") {
|
|
12103
12536
|
if (env.SHELL) {
|
|
12104
12537
|
if (env.SHELL.endsWith("zsh")) return "zsh";
|
|
12105
12538
|
if (env.SHELL.endsWith("fish")) return "fish";
|
|
@@ -12157,7 +12590,6 @@ function generateEnvScript(envVars, commandToRun = "") {
|
|
|
12157
12590
|
if (commandBlock && commandToRun) return `${commandBlock}${shell === "cmd" ? " & " : shell === "powershell" ? "; " : " && "}${commandToRun}`;
|
|
12158
12591
|
return commandBlock || commandToRun;
|
|
12159
12592
|
}
|
|
12160
|
-
|
|
12161
12593
|
//#endregion
|
|
12162
12594
|
//#region src/start.ts
|
|
12163
12595
|
function printAndCopyCommand(command, label) {
|
|
@@ -12173,7 +12605,7 @@ function generateClaudeCodeCommand(serverUrl, model) {
|
|
|
12173
12605
|
printAndCopyCommand(generateEnvScript(getClaudeCodeEnvVars(serverUrl, model), "claude --dangerously-skip-permissions"), "Claude Code");
|
|
12174
12606
|
}
|
|
12175
12607
|
function generateCodexCommand(serverUrl, model) {
|
|
12176
|
-
const codexModel = model ??
|
|
12608
|
+
const codexModel = model ?? "gpt-5.6-sol";
|
|
12177
12609
|
printAndCopyCommand(generateEnvScript(getCodexEnvVars(serverUrl), `codex -m ${codexModel}`), "Codex CLI");
|
|
12178
12610
|
}
|
|
12179
12611
|
const start = defineCommand({
|
|
@@ -12203,7 +12635,7 @@ const start = defineCommand({
|
|
|
12203
12635
|
const parsed = parseSharedArgs(args);
|
|
12204
12636
|
const { serverUrl } = await setupAndServe({
|
|
12205
12637
|
...parsed,
|
|
12206
|
-
port: parsed.port ??
|
|
12638
|
+
port: parsed.port ?? 8787,
|
|
12207
12639
|
silent: false
|
|
12208
12640
|
});
|
|
12209
12641
|
runSelfUpdate({ selfUpdate: args["self-update"] !== false });
|
|
@@ -12216,7 +12648,6 @@ const start = defineCommand({
|
|
|
12216
12648
|
consola.box(`🌐 Usage Viewer: https://animeshkundu.github.io/github-router/dashboard.html?endpoint=${serverUrl}/usage`);
|
|
12217
12649
|
}
|
|
12218
12650
|
});
|
|
12219
|
-
|
|
12220
12651
|
//#endregion
|
|
12221
12652
|
//#region src/main.ts
|
|
12222
12653
|
process.on("unhandledRejection", (error) => {
|
|
@@ -12226,12 +12657,12 @@ process.on("uncaughtException", (error) => {
|
|
|
12226
12657
|
consola.error("Uncaught exception:", error);
|
|
12227
12658
|
process.exit(1);
|
|
12228
12659
|
});
|
|
12229
|
-
const version = getPackageVersion();
|
|
12660
|
+
const version = getPackageVersion$1();
|
|
12230
12661
|
const argv = process.argv.slice(2);
|
|
12231
12662
|
const isVersionFlag = argv.includes("--version");
|
|
12232
12663
|
const isInternalHook = argv[0] === "internal-stop-hook" || argv[0] === "internal-prompt-submit" || argv[0] === "internal-stop-review" || argv[0] === "internal-plan-review" || argv[0] === "internal-session-bind" || argv[0] === "internal-workspace-header" || argv[0] === "internal-artifact-open" || argv[0] === "internal-first-mate-guard" || argv[0] === "internal-worker-guard";
|
|
12233
12664
|
if (!isVersionFlag && !isInternalHook) consola.info(`github-router v${version}`);
|
|
12234
|
-
|
|
12665
|
+
const main = defineCommand({
|
|
12235
12666
|
meta: {
|
|
12236
12667
|
name: "github-router",
|
|
12237
12668
|
version,
|
|
@@ -12256,8 +12687,9 @@ await runMain(defineCommand({
|
|
|
12256
12687
|
"internal-first-mate-guard": internalFirstMateGuard,
|
|
12257
12688
|
"internal-worker-guard": internalWorkerGuard
|
|
12258
12689
|
}
|
|
12259
|
-
})
|
|
12260
|
-
|
|
12690
|
+
});
|
|
12691
|
+
await runMain(main);
|
|
12261
12692
|
//#endregion
|
|
12262
|
-
export {
|
|
12693
|
+
export {};
|
|
12694
|
+
|
|
12263
12695
|
//# sourceMappingURL=main.js.map
|