github-router 0.3.30 → 0.3.32
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 +3804 -0
- package/dist/lifecycle-3OXRVrtQ.js +292 -0
- package/dist/lifecycle-3OXRVrtQ.js.map +1 -0
- package/dist/{lifecycle-De6QsSv8.js → lifecycle-DxRKANCV.js} +2 -1
- package/dist/main.js +1741 -64
- package/dist/main.js.map +1 -1
- package/dist/paths-Cf3OVCaJ.js +3 -0
- package/dist/{lifecycle-BrNqqJZH.js → paths-Cr2gfGiA.js} +8 -293
- package/dist/paths-Cr2gfGiA.js.map +1 -0
- package/package.json +5 -2
- package/dist/lifecycle-BrNqqJZH.js.map +0 -1
package/dist/main.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-Cr2gfGiA.js";
|
|
3
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-3OXRVrtQ.js";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import { defineCommand, runMain } from "citty";
|
|
5
6
|
import consola from "consola";
|
|
6
7
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
8
|
import fs, { readFile, stat } from "node:fs/promises";
|
|
8
|
-
import os from "node:os";
|
|
9
|
+
import os, { homedir, platform } from "node:os";
|
|
9
10
|
import * as path$1 from "node:path";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import process$1 from "node:process";
|
|
12
13
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
13
14
|
import { promisify } from "node:util";
|
|
14
|
-
import fs$1, { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
15
|
+
import fs$1, { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
15
16
|
import { createInterface } from "node:readline";
|
|
16
17
|
import Parser from "web-tree-sitter";
|
|
18
|
+
import WebSocket from "ws";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
17
20
|
import { Type } from "typebox";
|
|
18
21
|
import "partial-json";
|
|
19
22
|
import { Compile } from "typebox/compile";
|
|
@@ -41,6 +44,7 @@ const state = {
|
|
|
41
44
|
rateLimitWait: false,
|
|
42
45
|
showToken: false,
|
|
43
46
|
extendedBetas: false,
|
|
47
|
+
browseEnabled: false,
|
|
44
48
|
sessionId: randomUUID(),
|
|
45
49
|
machineId: randomBytes(32).toString("hex")
|
|
46
50
|
};
|
|
@@ -2048,8 +2052,8 @@ async function runStructuralPass(opts) {
|
|
|
2048
2052
|
consola.debug(`[code_search] structural skip ${relFile} (${size} bytes > cap)`);
|
|
2049
2053
|
continue;
|
|
2050
2054
|
}
|
|
2051
|
-
let cached = cacheGet(absPath, mtimeMs);
|
|
2052
|
-
if (!cached) {
|
|
2055
|
+
let cached$1 = cacheGet(absPath, mtimeMs);
|
|
2056
|
+
if (!cached$1) {
|
|
2053
2057
|
let source;
|
|
2054
2058
|
try {
|
|
2055
2059
|
source = readFileSync(absPath, "utf8");
|
|
@@ -2074,23 +2078,23 @@ async function runStructuralPass(opts) {
|
|
|
2074
2078
|
} catch (err) {
|
|
2075
2079
|
consola.debug(`[code_search] tree-sitter parse failed for ${relFile}: ${err.message}`);
|
|
2076
2080
|
}
|
|
2077
|
-
cached = {
|
|
2081
|
+
cached$1 = {
|
|
2078
2082
|
mtimeMs,
|
|
2079
2083
|
tree,
|
|
2080
2084
|
source: tree ? source : null
|
|
2081
2085
|
};
|
|
2082
|
-
cachePut(absPath, cached);
|
|
2086
|
+
cachePut(absPath, cached$1);
|
|
2083
2087
|
filesParsed += 1;
|
|
2084
2088
|
}
|
|
2085
|
-
if (!cached.tree || !cached.source) continue;
|
|
2089
|
+
if (!cached$1.tree || !cached$1.source) continue;
|
|
2086
2090
|
for (const entry of entries) {
|
|
2087
|
-
const lineStart = lineStartByte(cached.source, entry.hit.line);
|
|
2091
|
+
const lineStart = lineStartByte(cached$1.source, entry.hit.line);
|
|
2088
2092
|
if (lineStart < 0) continue;
|
|
2089
2093
|
const matchByteStart = lineStart + entry.hit.match_start;
|
|
2090
2094
|
const matchByteEnd = lineStart + entry.hit.match_end;
|
|
2091
2095
|
let node;
|
|
2092
2096
|
try {
|
|
2093
|
-
node = cached.tree.rootNode.descendantForIndex(matchByteStart, matchByteEnd);
|
|
2097
|
+
node = cached$1.tree.rootNode.descendantForIndex(matchByteStart, matchByteEnd);
|
|
2094
2098
|
} catch {
|
|
2095
2099
|
node = null;
|
|
2096
2100
|
}
|
|
@@ -2468,6 +2472,1086 @@ function round4(x) {
|
|
|
2468
2472
|
return Math.round(x * 1e4) / 1e4;
|
|
2469
2473
|
}
|
|
2470
2474
|
|
|
2475
|
+
//#endregion
|
|
2476
|
+
//#region src/lib/browser-mcp/browser-detect.ts
|
|
2477
|
+
let cached;
|
|
2478
|
+
function probeWindows() {
|
|
2479
|
+
const found = [];
|
|
2480
|
+
const probe = (subkey) => {
|
|
2481
|
+
try {
|
|
2482
|
+
execFileSync("reg.exe", [
|
|
2483
|
+
"query",
|
|
2484
|
+
`HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
|
|
2485
|
+
"/ve"
|
|
2486
|
+
], {
|
|
2487
|
+
windowsHide: true,
|
|
2488
|
+
timeout: 3e3,
|
|
2489
|
+
stdio: [
|
|
2490
|
+
"ignore",
|
|
2491
|
+
"pipe",
|
|
2492
|
+
"ignore"
|
|
2493
|
+
]
|
|
2494
|
+
});
|
|
2495
|
+
return true;
|
|
2496
|
+
} catch {
|
|
2497
|
+
try {
|
|
2498
|
+
execFileSync("reg.exe", [
|
|
2499
|
+
"query",
|
|
2500
|
+
`HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${subkey}`,
|
|
2501
|
+
"/ve"
|
|
2502
|
+
], {
|
|
2503
|
+
windowsHide: true,
|
|
2504
|
+
timeout: 3e3,
|
|
2505
|
+
stdio: [
|
|
2506
|
+
"ignore",
|
|
2507
|
+
"pipe",
|
|
2508
|
+
"ignore"
|
|
2509
|
+
]
|
|
2510
|
+
});
|
|
2511
|
+
return true;
|
|
2512
|
+
} catch {
|
|
2513
|
+
return false;
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
};
|
|
2517
|
+
if (probe("chrome.exe")) found.push("chrome");
|
|
2518
|
+
if (probe("msedge.exe")) found.push("edge");
|
|
2519
|
+
if (!found.includes("chrome")) {
|
|
2520
|
+
const localApp = process$1.env.LOCALAPPDATA;
|
|
2521
|
+
const pf = process$1.env["PROGRAMFILES"];
|
|
2522
|
+
const pf86 = process$1.env["PROGRAMFILES(X86)"];
|
|
2523
|
+
if ([
|
|
2524
|
+
localApp ? path.join(localApp, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
2525
|
+
pf ? path.join(pf, "Google", "Chrome", "Application", "chrome.exe") : void 0,
|
|
2526
|
+
pf86 ? path.join(pf86, "Google", "Chrome", "Application", "chrome.exe") : void 0
|
|
2527
|
+
].filter((p) => typeof p === "string").some(existsSync)) found.push("chrome");
|
|
2528
|
+
}
|
|
2529
|
+
if (!found.includes("edge")) {
|
|
2530
|
+
const pf86 = process$1.env["PROGRAMFILES(X86)"];
|
|
2531
|
+
const pf = process$1.env["PROGRAMFILES"];
|
|
2532
|
+
if ([pf86 ? path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") : void 0, pf ? path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe") : void 0].filter((p) => typeof p === "string").some(existsSync)) found.push("edge");
|
|
2533
|
+
}
|
|
2534
|
+
return found;
|
|
2535
|
+
}
|
|
2536
|
+
function probeMacOS() {
|
|
2537
|
+
const found = [];
|
|
2538
|
+
if (existsSync("/Applications/Google Chrome.app")) found.push("chrome");
|
|
2539
|
+
if (existsSync("/Applications/Microsoft Edge.app")) found.push("edge");
|
|
2540
|
+
return found;
|
|
2541
|
+
}
|
|
2542
|
+
function probeLinux() {
|
|
2543
|
+
const found = [];
|
|
2544
|
+
const which = (cmd) => {
|
|
2545
|
+
try {
|
|
2546
|
+
execFileSync("which", [cmd], {
|
|
2547
|
+
timeout: 2e3,
|
|
2548
|
+
stdio: [
|
|
2549
|
+
"ignore",
|
|
2550
|
+
"pipe",
|
|
2551
|
+
"ignore"
|
|
2552
|
+
]
|
|
2553
|
+
});
|
|
2554
|
+
return true;
|
|
2555
|
+
} catch {
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
if (which("google-chrome") || which("google-chrome-stable") || which("chromium") || which("chromium-browser")) found.push("chrome");
|
|
2560
|
+
if (which("microsoft-edge") || which("microsoft-edge-stable")) found.push("edge");
|
|
2561
|
+
return found;
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* Returns the supported browsers detected on this host. Result is
|
|
2565
|
+
* cached on first call; restart the proxy to re-detect after a fresh
|
|
2566
|
+
* install.
|
|
2567
|
+
*/
|
|
2568
|
+
function detectSupportedBrowsers() {
|
|
2569
|
+
if (cached !== void 0) return cached;
|
|
2570
|
+
let found;
|
|
2571
|
+
switch (process$1.platform) {
|
|
2572
|
+
case "win32":
|
|
2573
|
+
found = probeWindows();
|
|
2574
|
+
break;
|
|
2575
|
+
case "darwin":
|
|
2576
|
+
found = probeMacOS();
|
|
2577
|
+
break;
|
|
2578
|
+
default:
|
|
2579
|
+
found = probeLinux();
|
|
2580
|
+
break;
|
|
2581
|
+
}
|
|
2582
|
+
cached = Object.freeze(found);
|
|
2583
|
+
return cached;
|
|
2584
|
+
}
|
|
2585
|
+
/** Convenience: true iff Chrome OR Edge is detected. */
|
|
2586
|
+
function hasSupportedBrowserInstalled() {
|
|
2587
|
+
return detectSupportedBrowsers().length > 0;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
//#endregion
|
|
2591
|
+
//#region src/lib/browser-mcp/native-host-installer.ts
|
|
2592
|
+
const NMH_HOST_ID = "com.githubrouter.browser";
|
|
2593
|
+
/**
|
|
2594
|
+
* Compute the deterministic 32-char chrome-extension ID from the
|
|
2595
|
+
* base64-DER-encoded RSA public key in the extension's manifest.json
|
|
2596
|
+
* `key` field. Chrome's derivation:
|
|
2597
|
+
*
|
|
2598
|
+
* 1. base64-decode the key into DER bytes.
|
|
2599
|
+
* 2. SHA-256 the bytes.
|
|
2600
|
+
* 3. Take the first 16 bytes of the digest as 32 hex chars.
|
|
2601
|
+
* 4. Map each hex digit VALUE (0..15) to a letter (a..p). hex value
|
|
2602
|
+
* 0 → 'a', 1 → 'b', …, 15 → 'p'. The result is 32 chars long.
|
|
2603
|
+
*
|
|
2604
|
+
* See https://developer.chrome.com/docs/extensions/reference/manifest/key
|
|
2605
|
+
* for the spec.
|
|
2606
|
+
*/
|
|
2607
|
+
function computeExtensionIdFromKey(keyB64) {
|
|
2608
|
+
const der = Buffer.from(keyB64, "base64");
|
|
2609
|
+
const hex = createHash("sha256").update(der).digest().subarray(0, 16).toString("hex");
|
|
2610
|
+
const aCode = "a".charCodeAt(0);
|
|
2611
|
+
let out = "";
|
|
2612
|
+
for (let i = 0; i < hex.length; i++) out += String.fromCharCode(aCode + parseInt(hex[i], 16));
|
|
2613
|
+
return out;
|
|
2614
|
+
}
|
|
2615
|
+
function readManifestKey() {
|
|
2616
|
+
const candidates = [path.resolve(extensionDir(), "manifest.json")];
|
|
2617
|
+
for (const candidate of candidates) try {
|
|
2618
|
+
const raw = readFileSync(candidate, "utf8");
|
|
2619
|
+
const parsed = JSON.parse(raw);
|
|
2620
|
+
if (typeof parsed.key === "string") return parsed.key;
|
|
2621
|
+
} catch {}
|
|
2622
|
+
throw new Error(`native-host-installer: could not read manifest.json from ${candidates.join(", ")}`);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Walk up from a starting directory looking for the github-router
|
|
2626
|
+
* package.json. Returns the package root or undefined if not found
|
|
2627
|
+
* within `maxHops` levels.
|
|
2628
|
+
*/
|
|
2629
|
+
function findPackageRoot(startDir, maxHops = 10) {
|
|
2630
|
+
let cur = startDir;
|
|
2631
|
+
for (let i = 0; i < maxHops; i++) {
|
|
2632
|
+
try {
|
|
2633
|
+
const pkgPath = path.join(cur, "package.json");
|
|
2634
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
2635
|
+
if (pkg.name && pkg.name.includes("github-router")) return cur;
|
|
2636
|
+
} catch {}
|
|
2637
|
+
const parent = path.dirname(cur);
|
|
2638
|
+
if (parent === cur) break;
|
|
2639
|
+
cur = parent;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* Resolve the github-router package root. Uses two sources in order:
|
|
2644
|
+
* 1. process.argv[1] — the entrypoint script, walks up from there.
|
|
2645
|
+
* 2. import.meta.url of THIS module, walks up from there.
|
|
2646
|
+
* 3. process.cwd() as last resort.
|
|
2647
|
+
*
|
|
2648
|
+
* Robust across bun (src/main.ts) and node (dist/main.js) launch paths.
|
|
2649
|
+
*/
|
|
2650
|
+
function packageRoot() {
|
|
2651
|
+
const entryPath = typeof process$1?.argv?.[1] === "string" ? process$1.argv[1] : void 0;
|
|
2652
|
+
if (entryPath) {
|
|
2653
|
+
const fromEntry = findPackageRoot(path.dirname(entryPath));
|
|
2654
|
+
if (fromEntry) return fromEntry;
|
|
2655
|
+
}
|
|
2656
|
+
try {
|
|
2657
|
+
const fromHere = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
2658
|
+
if (fromHere) return fromHere;
|
|
2659
|
+
} catch {}
|
|
2660
|
+
return process$1?.cwd?.() ?? ".";
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* Absolute path to the extension's source directory. Extension is
|
|
2664
|
+
* plain JS / JSON (no build pipeline) so `src/browser-ext/` is the
|
|
2665
|
+
* canonical location whether installed via npm or running from a
|
|
2666
|
+
* checkout.
|
|
2667
|
+
*/
|
|
2668
|
+
function extensionDir() {
|
|
2669
|
+
return path.join(packageRoot(), "src", "browser-ext");
|
|
2670
|
+
}
|
|
2671
|
+
/** Absolute path to the bundled bridge entrypoint. */
|
|
2672
|
+
function bridgeBundlePath() {
|
|
2673
|
+
return path.join(packageRoot(), "dist", "browser-bridge", "index.js");
|
|
2674
|
+
}
|
|
2675
|
+
function appBrowserMcpDir() {
|
|
2676
|
+
const dir = path.join(PATHS.APP_DIR, "browser-mcp");
|
|
2677
|
+
mkdirSync(dir, { recursive: true });
|
|
2678
|
+
return dir;
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Pick a runtime interpreter for the bridge. The bridge uses Node's
|
|
2682
|
+
* binary-stdin framing for native messaging which Bun handles
|
|
2683
|
+
* differently (Bun closes the bridge prematurely as soon as anything
|
|
2684
|
+
* unexpected lands on stdin). So prefer `node` when available;
|
|
2685
|
+
* fall back to `process.execPath` (which may be bun or a packaged
|
|
2686
|
+
* binary) only if node isn't on PATH.
|
|
2687
|
+
*/
|
|
2688
|
+
function resolveBridgeInterpreter() {
|
|
2689
|
+
const probeCmd = platform() === "win32" ? "where" : "which";
|
|
2690
|
+
try {
|
|
2691
|
+
const out = execFileSync(probeCmd, ["node"], {
|
|
2692
|
+
stdio: [
|
|
2693
|
+
"ignore",
|
|
2694
|
+
"pipe",
|
|
2695
|
+
"ignore"
|
|
2696
|
+
],
|
|
2697
|
+
timeout: 2e3,
|
|
2698
|
+
windowsHide: true
|
|
2699
|
+
}).toString().trim().split(/\r?\n/)[0];
|
|
2700
|
+
if (out) return out;
|
|
2701
|
+
} catch {}
|
|
2702
|
+
return process$1.execPath;
|
|
2703
|
+
}
|
|
2704
|
+
function writeLauncherShim() {
|
|
2705
|
+
const dir = appBrowserMcpDir();
|
|
2706
|
+
const bridgeJs = bridgeBundlePath();
|
|
2707
|
+
const interp = resolveBridgeInterpreter();
|
|
2708
|
+
if (platform() === "win32") {
|
|
2709
|
+
const batPath = path.join(dir, "launcher.bat");
|
|
2710
|
+
writeFileSync(batPath, `@echo off\r\n"${interp}" "${bridgeJs}" %*\r\n`, "utf8");
|
|
2711
|
+
return batPath;
|
|
2712
|
+
}
|
|
2713
|
+
const shPath = path.join(dir, "launcher.sh");
|
|
2714
|
+
writeFileSync(shPath, `#!/usr/bin/env bash\nexec "${interp}" "${bridgeJs}" "$@"\n`, { mode: 493 });
|
|
2715
|
+
try {
|
|
2716
|
+
chmodSync(shPath, 493);
|
|
2717
|
+
} catch {}
|
|
2718
|
+
return shPath;
|
|
2719
|
+
}
|
|
2720
|
+
function nmhPathsFor(browser) {
|
|
2721
|
+
switch (platform()) {
|
|
2722
|
+
case "win32": {
|
|
2723
|
+
const local = process$1.env.LOCALAPPDATA;
|
|
2724
|
+
const base = local ? path.join(local, "github-router", "browser-mcp") : path.join(homedir(), "AppData", "Local", "github-router", "browser-mcp");
|
|
2725
|
+
mkdirSync(base, { recursive: true });
|
|
2726
|
+
return {
|
|
2727
|
+
manifestPath: path.join(base, `${NMH_HOST_ID}.json`),
|
|
2728
|
+
registryKey: browser === "chrome" ? `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NMH_HOST_ID}` : `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NMH_HOST_ID}`
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
case "darwin": {
|
|
2732
|
+
const base = browser === "chrome" ? path.join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") : path.join(homedir(), "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts");
|
|
2733
|
+
mkdirSync(base, { recursive: true });
|
|
2734
|
+
return { manifestPath: path.join(base, `${NMH_HOST_ID}.json`) };
|
|
2735
|
+
}
|
|
2736
|
+
default: {
|
|
2737
|
+
const base = browser === "chrome" ? path.join(homedir(), ".config", "google-chrome", "NativeMessagingHosts") : path.join(homedir(), ".config", "microsoft-edge", "NativeMessagingHosts");
|
|
2738
|
+
mkdirSync(base, { recursive: true });
|
|
2739
|
+
return { manifestPath: path.join(base, `${NMH_HOST_ID}.json`) };
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Write the NMH manifest + (Windows) registry key for a given browser.
|
|
2745
|
+
* Returns the manifest path written; throws if any step fails (caller
|
|
2746
|
+
* surfaces as part of install_required.reason).
|
|
2747
|
+
*/
|
|
2748
|
+
function installNativeHostFor(browser) {
|
|
2749
|
+
const manifest = {
|
|
2750
|
+
name: NMH_HOST_ID,
|
|
2751
|
+
description: "github-router browser bridge",
|
|
2752
|
+
path: writeLauncherShim(),
|
|
2753
|
+
type: "stdio",
|
|
2754
|
+
allowed_origins: [`chrome-extension://${computeExtensionIdFromKey(readManifestKey())}/`]
|
|
2755
|
+
};
|
|
2756
|
+
const { manifestPath, registryKey } = nmhPathsFor(browser);
|
|
2757
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
|
|
2758
|
+
if (platform() !== "win32") try {
|
|
2759
|
+
chmodSync(manifestPath, 420);
|
|
2760
|
+
} catch {}
|
|
2761
|
+
if (registryKey) execFileSync("reg.exe", [
|
|
2762
|
+
"add",
|
|
2763
|
+
registryKey,
|
|
2764
|
+
"/ve",
|
|
2765
|
+
"/t",
|
|
2766
|
+
"REG_SZ",
|
|
2767
|
+
"/d",
|
|
2768
|
+
manifestPath,
|
|
2769
|
+
"/f"
|
|
2770
|
+
], {
|
|
2771
|
+
windowsHide: true,
|
|
2772
|
+
timeout: 5e3,
|
|
2773
|
+
stdio: [
|
|
2774
|
+
"ignore",
|
|
2775
|
+
"pipe",
|
|
2776
|
+
"pipe"
|
|
2777
|
+
]
|
|
2778
|
+
});
|
|
2779
|
+
return manifestPath;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Install the NMH manifest for every supported browser detected on
|
|
2783
|
+
* this host. Returns the list of (browser, manifestPath) tuples
|
|
2784
|
+
* actually written so the install_required response can report what
|
|
2785
|
+
* auto-installed.
|
|
2786
|
+
*/
|
|
2787
|
+
function installNativeHostForAll(browsers) {
|
|
2788
|
+
const results = [];
|
|
2789
|
+
for (const b of browsers) try {
|
|
2790
|
+
const manifestPath = installNativeHostFor(b);
|
|
2791
|
+
results.push({
|
|
2792
|
+
browser: b,
|
|
2793
|
+
manifestPath
|
|
2794
|
+
});
|
|
2795
|
+
} catch (err) {
|
|
2796
|
+
console.warn(`[browser-mcp] failed to install NMH manifest for ${b}:`, err instanceof Error ? err.message : String(err));
|
|
2797
|
+
}
|
|
2798
|
+
return results;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
//#endregion
|
|
2802
|
+
//#region src/lib/browser-mcp/install-check.ts
|
|
2803
|
+
function discoveryPath() {
|
|
2804
|
+
return path.join(PATHS.APP_DIR, "browser-mcp", "bridge.json");
|
|
2805
|
+
}
|
|
2806
|
+
function readBridgeDiscovery() {
|
|
2807
|
+
try {
|
|
2808
|
+
const raw = readFileSync(discoveryPath(), "utf8");
|
|
2809
|
+
const parsed = JSON.parse(raw);
|
|
2810
|
+
if (typeof parsed.pid === "number" && typeof parsed.port === "number" && typeof parsed.token === "string" && typeof parsed.startedAt === "number") return parsed;
|
|
2811
|
+
} catch {}
|
|
2812
|
+
}
|
|
2813
|
+
async function probeHealth(port, token, timeoutMs = 500) {
|
|
2814
|
+
const controller = new AbortController();
|
|
2815
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2816
|
+
try {
|
|
2817
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
2818
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2819
|
+
signal: controller.signal
|
|
2820
|
+
});
|
|
2821
|
+
if (!res.ok) return void 0;
|
|
2822
|
+
return await res.json();
|
|
2823
|
+
} catch {
|
|
2824
|
+
return;
|
|
2825
|
+
} finally {
|
|
2826
|
+
clearTimeout(timer);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
function bridgeBundleExists() {
|
|
2830
|
+
try {
|
|
2831
|
+
readFileSync(bridgeBundlePath());
|
|
2832
|
+
return true;
|
|
2833
|
+
} catch {
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
function loadStableExtensionId() {
|
|
2838
|
+
try {
|
|
2839
|
+
const raw = readFileSync(path.join(extensionDir(), "manifest.json"), "utf8");
|
|
2840
|
+
const parsed = JSON.parse(raw);
|
|
2841
|
+
if (typeof parsed.key === "string") return computeExtensionIdFromKey(parsed.key);
|
|
2842
|
+
} catch {}
|
|
2843
|
+
return "unknown";
|
|
2844
|
+
}
|
|
2845
|
+
function buildInstallRequired(reason, autoInstalled) {
|
|
2846
|
+
return {
|
|
2847
|
+
install_required: true,
|
|
2848
|
+
reason,
|
|
2849
|
+
auto_installed: autoInstalled,
|
|
2850
|
+
manual_steps: {
|
|
2851
|
+
load_unpacked_dir: extensionDir(),
|
|
2852
|
+
expected_extension_id: loadStableExtensionId(),
|
|
2853
|
+
instructions: reason === "no_supported_browser" ? "No Chrome or Edge installation was detected on this host. Install one and restart the github-router proxy." : reason === "bridge_bundle_missing" ? "The bridge bundle is missing. Run `bun run build` from the github-router checkout to produce dist/browser-bridge/index.js, then retry." : "Open chrome://extensions (or edge://extensions), enable Developer Mode, click 'Load unpacked', and select the load_unpacked_dir above. Then retry this tool call."
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Full pre-flight. Returns either `{install_required: false, port,
|
|
2859
|
+
* token, pid}` (bridge ready, extension connected) or an
|
|
2860
|
+
* `install_required` payload the dispatcher hands directly to the
|
|
2861
|
+
* model. Side effect: when reason is `extension_not_loaded`, attempts
|
|
2862
|
+
* to install the NMH manifest for every detected browser so that the
|
|
2863
|
+
* extension can connect immediately on load.
|
|
2864
|
+
*/
|
|
2865
|
+
async function ensureBridgeReady() {
|
|
2866
|
+
const browsers = detectSupportedBrowsers();
|
|
2867
|
+
if (browsers.length === 0) return buildInstallRequired("no_supported_browser", []);
|
|
2868
|
+
if (!bridgeBundleExists()) return buildInstallRequired("bridge_bundle_missing", []);
|
|
2869
|
+
const autoInstalled = installNativeHostForAll(browsers).flatMap((r) => [`nmh_manifest_${r.browser}`]);
|
|
2870
|
+
const discovery = readBridgeDiscovery();
|
|
2871
|
+
if (!discovery) return buildInstallRequired("bridge_not_running", autoInstalled);
|
|
2872
|
+
const health = await probeHealth(discovery.port, discovery.token);
|
|
2873
|
+
if (!health || !health.ok) return buildInstallRequired("bridge_not_running", autoInstalled);
|
|
2874
|
+
if (!health.extension_connected) return buildInstallRequired("extension_not_loaded", autoInstalled);
|
|
2875
|
+
return {
|
|
2876
|
+
install_required: false,
|
|
2877
|
+
port: discovery.port,
|
|
2878
|
+
token: discovery.token,
|
|
2879
|
+
pid: discovery.pid
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
function installRequiredToolResult(payload) {
|
|
2883
|
+
return {
|
|
2884
|
+
content: [{
|
|
2885
|
+
type: "text",
|
|
2886
|
+
text: JSON.stringify(payload, null, 2)
|
|
2887
|
+
}],
|
|
2888
|
+
isError: true
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
//#endregion
|
|
2893
|
+
//#region src/lib/browser-mcp/policy.ts
|
|
2894
|
+
const BLOCKED_URL_RE = /^(chrome|edge|brave|opera|vivaldi):\/\/(settings|preferences|extensions|policy|management|password|flags|flag-descriptions)/i;
|
|
2895
|
+
const BLOCKED_VIEW_SOURCE_RE = /^view-source:(chrome|edge):\/\/(settings|extensions)/i;
|
|
2896
|
+
const BLOCKED_OPTIONS_HTML_RE = /^(chrome|edge)-extension:\/\/.*\/(options|popup)\.html/i;
|
|
2897
|
+
const FILE_URL_RE = /^file:/i;
|
|
2898
|
+
function checkUrlPolicy(url) {
|
|
2899
|
+
if (typeof url !== "string" || url.length === 0) return { blocked: false };
|
|
2900
|
+
if (BLOCKED_URL_RE.test(url) || BLOCKED_VIEW_SOURCE_RE.test(url)) return {
|
|
2901
|
+
blocked: true,
|
|
2902
|
+
reason: "Browser-internal pages (settings / preferences / extensions / flags / passwords) are not accessible to the browser MCP. devtools:// is allowed."
|
|
2903
|
+
};
|
|
2904
|
+
if (BLOCKED_OPTIONS_HTML_RE.test(url)) return {
|
|
2905
|
+
blocked: true,
|
|
2906
|
+
reason: "Extension options / popup pages are not accessible to the browser MCP."
|
|
2907
|
+
};
|
|
2908
|
+
if (FILE_URL_RE.test(url) && process.env.GH_ROUTER_BROWSER_ALLOW_FILE_URLS !== "1") return {
|
|
2909
|
+
blocked: true,
|
|
2910
|
+
reason: "file:// URLs are blocked by default. Set GH_ROUTER_BROWSER_ALLOW_FILE_URLS=1 to enable."
|
|
2911
|
+
};
|
|
2912
|
+
return { blocked: false };
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2915
|
+
* Extract URL fields from a tool call's arguments. Returns the first
|
|
2916
|
+
* URL that violates policy, or undefined if all clear. Currently checks
|
|
2917
|
+
* the `url` field (used by browser_open_tab + browser_navigate).
|
|
2918
|
+
*/
|
|
2919
|
+
function preflightUrlPolicy(toolName, args) {
|
|
2920
|
+
if (toolName !== "browser_open_tab" && toolName !== "browser_navigate") return { blocked: false };
|
|
2921
|
+
return checkUrlPolicy(args.url);
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
//#endregion
|
|
2925
|
+
//#region src/lib/browser-mcp/dispatch.ts
|
|
2926
|
+
const PER_TOOL_TIMEOUTS = {
|
|
2927
|
+
browser_list_tabs: {
|
|
2928
|
+
defaultMs: 5e3,
|
|
2929
|
+
maxMs: 1e4
|
|
2930
|
+
},
|
|
2931
|
+
browser_open_tab: {
|
|
2932
|
+
defaultMs: 3e4,
|
|
2933
|
+
maxMs: 6e4
|
|
2934
|
+
},
|
|
2935
|
+
browser_close_tab: {
|
|
2936
|
+
defaultMs: 5e3,
|
|
2937
|
+
maxMs: 1e4
|
|
2938
|
+
},
|
|
2939
|
+
browser_navigate: {
|
|
2940
|
+
defaultMs: 3e4,
|
|
2941
|
+
maxMs: 6e4
|
|
2942
|
+
},
|
|
2943
|
+
browser_screenshot: {
|
|
2944
|
+
defaultMs: 15e3,
|
|
2945
|
+
maxMs: 3e4
|
|
2946
|
+
},
|
|
2947
|
+
browser_read_page: {
|
|
2948
|
+
defaultMs: 1e4,
|
|
2949
|
+
maxMs: 3e4
|
|
2950
|
+
},
|
|
2951
|
+
browser_click: {
|
|
2952
|
+
defaultMs: 1e4,
|
|
2953
|
+
maxMs: 3e4
|
|
2954
|
+
},
|
|
2955
|
+
browser_fill: {
|
|
2956
|
+
defaultMs: 1e4,
|
|
2957
|
+
maxMs: 3e4
|
|
2958
|
+
},
|
|
2959
|
+
browser_scroll: {
|
|
2960
|
+
defaultMs: 5e3,
|
|
2961
|
+
maxMs: 1e4
|
|
2962
|
+
},
|
|
2963
|
+
browser_keyboard: {
|
|
2964
|
+
defaultMs: 5e3,
|
|
2965
|
+
maxMs: 1e4
|
|
2966
|
+
},
|
|
2967
|
+
browser_wait: {
|
|
2968
|
+
defaultMs: 1e4,
|
|
2969
|
+
maxMs: 6e4
|
|
2970
|
+
},
|
|
2971
|
+
browser_eval_js: {
|
|
2972
|
+
defaultMs: 5e3,
|
|
2973
|
+
maxMs: 3e4
|
|
2974
|
+
},
|
|
2975
|
+
browser_download: {
|
|
2976
|
+
defaultMs: 6e4,
|
|
2977
|
+
maxMs: 3e5
|
|
2978
|
+
},
|
|
2979
|
+
browser_console_logs: {
|
|
2980
|
+
defaultMs: 5e3,
|
|
2981
|
+
maxMs: 1e4
|
|
2982
|
+
},
|
|
2983
|
+
browser_network_log: {
|
|
2984
|
+
defaultMs: 5e3,
|
|
2985
|
+
maxMs: 1e4
|
|
2986
|
+
}
|
|
2987
|
+
};
|
|
2988
|
+
function pickTimeout(tool) {
|
|
2989
|
+
if (tool in PER_TOOL_TIMEOUTS) return PER_TOOL_TIMEOUTS[tool];
|
|
2990
|
+
return {
|
|
2991
|
+
defaultMs: 1e4,
|
|
2992
|
+
maxMs: 3e4
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Send one request to the bridge over a fresh WebSocket connection.
|
|
2997
|
+
* Resolves to the bridge's response envelope or rejects on timeout /
|
|
2998
|
+
* transport failure. Honors the caller's AbortSignal — when the MCP
|
|
2999
|
+
* client sends notifications/cancelled, the WS is force-closed and
|
|
3000
|
+
* the promise rejects so the slot releases cleanly.
|
|
3001
|
+
*/
|
|
3002
|
+
async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
|
|
3003
|
+
return new Promise((resolve, reject) => {
|
|
3004
|
+
const id = randomUUID();
|
|
3005
|
+
const ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}`, { headers: { authorization: `Bearer ${endpoint.token}` } });
|
|
3006
|
+
let settled = false;
|
|
3007
|
+
const finish = (fn) => {
|
|
3008
|
+
if (settled) return;
|
|
3009
|
+
settled = true;
|
|
3010
|
+
clearTimeout(timer);
|
|
3011
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
3012
|
+
try {
|
|
3013
|
+
ws.close();
|
|
3014
|
+
} catch {}
|
|
3015
|
+
fn();
|
|
3016
|
+
};
|
|
3017
|
+
const onAbort = () => finish(() => reject(/* @__PURE__ */ new Error("aborted")));
|
|
3018
|
+
if (signal) {
|
|
3019
|
+
if (signal.aborted) {
|
|
3020
|
+
onAbort();
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3024
|
+
}
|
|
3025
|
+
const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`))), timeoutMs);
|
|
3026
|
+
ws.on("open", () => {
|
|
3027
|
+
ws.send(JSON.stringify({
|
|
3028
|
+
id,
|
|
3029
|
+
tool,
|
|
3030
|
+
args
|
|
3031
|
+
}));
|
|
3032
|
+
});
|
|
3033
|
+
ws.on("message", (raw) => {
|
|
3034
|
+
try {
|
|
3035
|
+
const parsed = JSON.parse(raw.toString());
|
|
3036
|
+
if (parsed && parsed.id === id) finish(() => resolve(parsed));
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
finish(() => reject(err));
|
|
3039
|
+
}
|
|
3040
|
+
});
|
|
3041
|
+
ws.on("error", (err) => {
|
|
3042
|
+
finish(() => reject(err));
|
|
3043
|
+
});
|
|
3044
|
+
ws.on("close", () => {
|
|
3045
|
+
finish(() => reject(/* @__PURE__ */ new Error("bridge connection closed before response")));
|
|
3046
|
+
});
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Real dispatcher for any browser_* tool. Used by the entries in
|
|
3051
|
+
* src/lib/browser-mcp/index.ts. Returns the standard MCP tool-result
|
|
3052
|
+
* envelope.
|
|
3053
|
+
*/
|
|
3054
|
+
async function dispatchBrowserTool(tool, args, signal, opts = {}) {
|
|
3055
|
+
const policy = preflightUrlPolicy(tool, args);
|
|
3056
|
+
if (policy.blocked) return {
|
|
3057
|
+
content: [{
|
|
3058
|
+
type: "text",
|
|
3059
|
+
text: JSON.stringify({
|
|
3060
|
+
blocked: true,
|
|
3061
|
+
reason: policy.reason
|
|
3062
|
+
}, null, 2)
|
|
3063
|
+
}],
|
|
3064
|
+
isError: true
|
|
3065
|
+
};
|
|
3066
|
+
const ready = await ensureBridgeReady();
|
|
3067
|
+
if (ready.install_required) return installRequiredToolResult(ready);
|
|
3068
|
+
const { defaultMs, maxMs } = pickTimeout(tool);
|
|
3069
|
+
const callerTimeout = typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? Math.min(opts.timeoutMs, maxMs) : defaultMs;
|
|
3070
|
+
try {
|
|
3071
|
+
const resp = await bridgeCall({
|
|
3072
|
+
port: ready.port,
|
|
3073
|
+
token: ready.token
|
|
3074
|
+
}, tool, args, callerTimeout, signal);
|
|
3075
|
+
if (resp.ok) {
|
|
3076
|
+
const text = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data, null, 2);
|
|
3077
|
+
logAudit$1({
|
|
3078
|
+
tool,
|
|
3079
|
+
argsBytes: argsByteSize(args),
|
|
3080
|
+
durationMs: 0,
|
|
3081
|
+
profile: typeof args.profile === "string" ? args.profile : "isolated",
|
|
3082
|
+
result: "ok"
|
|
3083
|
+
});
|
|
3084
|
+
return { content: [{
|
|
3085
|
+
type: "text",
|
|
3086
|
+
text
|
|
3087
|
+
}] };
|
|
3088
|
+
}
|
|
3089
|
+
logAudit$1({
|
|
3090
|
+
tool,
|
|
3091
|
+
argsBytes: argsByteSize(args),
|
|
3092
|
+
durationMs: 0,
|
|
3093
|
+
profile: typeof args.profile === "string" ? args.profile : "isolated",
|
|
3094
|
+
result: "bridge_error",
|
|
3095
|
+
error: resp.error
|
|
3096
|
+
});
|
|
3097
|
+
return {
|
|
3098
|
+
content: [{
|
|
3099
|
+
type: "text",
|
|
3100
|
+
text: `${tool} failed: ${resp.error}${resp.code ? ` (${resp.code})` : ""}`
|
|
3101
|
+
}],
|
|
3102
|
+
isError: true
|
|
3103
|
+
};
|
|
3104
|
+
} catch (err) {
|
|
3105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3106
|
+
logAudit$1({
|
|
3107
|
+
tool,
|
|
3108
|
+
argsBytes: argsByteSize(args),
|
|
3109
|
+
durationMs: 0,
|
|
3110
|
+
profile: typeof args.profile === "string" ? args.profile : "isolated",
|
|
3111
|
+
result: "exception",
|
|
3112
|
+
error: message
|
|
3113
|
+
});
|
|
3114
|
+
return {
|
|
3115
|
+
content: [{
|
|
3116
|
+
type: "text",
|
|
3117
|
+
text: `${tool} failed: ${message}`
|
|
3118
|
+
}],
|
|
3119
|
+
isError: true
|
|
3120
|
+
};
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
function argsByteSize(args) {
|
|
3124
|
+
try {
|
|
3125
|
+
return Buffer.byteLength(JSON.stringify(args), "utf8");
|
|
3126
|
+
} catch {
|
|
3127
|
+
return -1;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
function logAudit$1(record) {
|
|
3131
|
+
if (process.env.GH_ROUTER_LOG_BROWSER_MCP !== "1") return;
|
|
3132
|
+
(async () => {
|
|
3133
|
+
try {
|
|
3134
|
+
const fs$2 = await import("node:fs/promises");
|
|
3135
|
+
const path$2 = await import("node:path");
|
|
3136
|
+
const { PATHS: PATHS$1 } = await import("./paths-Cf3OVCaJ.js");
|
|
3137
|
+
const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
|
|
3138
|
+
await fs$2.mkdir(dir, { recursive: true });
|
|
3139
|
+
const line = JSON.stringify({
|
|
3140
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3141
|
+
...record
|
|
3142
|
+
}) + "\n";
|
|
3143
|
+
await fs$2.appendFile(path$2.join(dir, "audit.log"), line, "utf8");
|
|
3144
|
+
} catch {}
|
|
3145
|
+
})();
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
//#endregion
|
|
3149
|
+
//#region src/lib/browser-mcp/index.ts
|
|
3150
|
+
/**
|
|
3151
|
+
* Browser-control MCP tools (`browser_*`). All entries route through
|
|
3152
|
+
* `dispatchBrowserTool()` which (1) runs the bridge-layer URL policy
|
|
3153
|
+
* check, (2) runs the install-check pre-flight (returning structured
|
|
3154
|
+
* install_required JSON when the bridge or extension isn't ready),
|
|
3155
|
+
* and (3) opens a WS to the bridge, sends the tool call, awaits the
|
|
3156
|
+
* response with a per-tool timeout.
|
|
3157
|
+
*
|
|
3158
|
+
* Each entry carries `capability: "browser"` so `browserToolsEnabled()`
|
|
3159
|
+
* in `src/routes/mcp/handler.ts` drops them at both list-time and
|
|
3160
|
+
* call-time when the operator hasn't opted in via `--browse` or
|
|
3161
|
+
* `GH_ROUTER_ENABLE_BROWSE=1`.
|
|
3162
|
+
*
|
|
3163
|
+
* v1 surface: 15 tools (Phases 3 + 4a + 4b).
|
|
3164
|
+
*/
|
|
3165
|
+
const BROWSER_TOOLS = Object.freeze([
|
|
3166
|
+
{
|
|
3167
|
+
toolNameHttp: "browser_list_tabs",
|
|
3168
|
+
description: "List all open tabs across all browser windows. Returns each tab's id (used by other browser_* tools), URL, title, active flag, and window id.",
|
|
3169
|
+
inputSchema: {
|
|
3170
|
+
type: "object",
|
|
3171
|
+
additionalProperties: false,
|
|
3172
|
+
properties: {}
|
|
3173
|
+
},
|
|
3174
|
+
capability: "browser",
|
|
3175
|
+
async handler(args, signal) {
|
|
3176
|
+
return dispatchBrowserTool("browser_list_tabs", args, signal);
|
|
3177
|
+
}
|
|
3178
|
+
},
|
|
3179
|
+
{
|
|
3180
|
+
toolNameHttp: "browser_open_tab",
|
|
3181
|
+
description: "Open a URL in a new browser tab and wait for the page to finish loading. Returns the new tab's id, final URL after redirects, and HTTP status. Refuses to navigate to browser-internal settings / preferences / extensions / flags pages (returns {blocked: true, reason}); devtools://* is allowed.",
|
|
3182
|
+
inputSchema: {
|
|
3183
|
+
type: "object",
|
|
3184
|
+
required: ["url"],
|
|
3185
|
+
additionalProperties: false,
|
|
3186
|
+
properties: {
|
|
3187
|
+
url: {
|
|
3188
|
+
type: "string",
|
|
3189
|
+
description: "The URL to load. Maximum 8 KB. Settings / preferences / extensions / flags pages are blocked."
|
|
3190
|
+
},
|
|
3191
|
+
reuseActive: {
|
|
3192
|
+
type: "boolean",
|
|
3193
|
+
description: "When true, navigate the currently active tab instead of opening a new one. Default false."
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
},
|
|
3197
|
+
capability: "browser",
|
|
3198
|
+
async handler(args, signal) {
|
|
3199
|
+
return dispatchBrowserTool("browser_open_tab", args, signal);
|
|
3200
|
+
}
|
|
3201
|
+
},
|
|
3202
|
+
{
|
|
3203
|
+
toolNameHttp: "browser_close_tab",
|
|
3204
|
+
description: "Close one or more tabs by tab id.",
|
|
3205
|
+
inputSchema: {
|
|
3206
|
+
type: "object",
|
|
3207
|
+
required: ["tabIds"],
|
|
3208
|
+
additionalProperties: false,
|
|
3209
|
+
properties: { tabIds: {
|
|
3210
|
+
type: "array",
|
|
3211
|
+
items: { type: "number" },
|
|
3212
|
+
description: "Array of tab ids to close (from browser_list_tabs)."
|
|
3213
|
+
} }
|
|
3214
|
+
},
|
|
3215
|
+
capability: "browser",
|
|
3216
|
+
async handler(args, signal) {
|
|
3217
|
+
return dispatchBrowserTool("browser_close_tab", args, signal);
|
|
3218
|
+
}
|
|
3219
|
+
},
|
|
3220
|
+
{
|
|
3221
|
+
toolNameHttp: "browser_navigate",
|
|
3222
|
+
description: "Navigate an existing tab: goto a URL, go back, go forward, or reload. Same URL-blocking policy as browser_open_tab.",
|
|
3223
|
+
inputSchema: {
|
|
3224
|
+
type: "object",
|
|
3225
|
+
required: ["tabId", "action"],
|
|
3226
|
+
additionalProperties: false,
|
|
3227
|
+
properties: {
|
|
3228
|
+
tabId: {
|
|
3229
|
+
type: "number",
|
|
3230
|
+
description: "Tab id from browser_list_tabs / browser_open_tab."
|
|
3231
|
+
},
|
|
3232
|
+
action: {
|
|
3233
|
+
type: "string",
|
|
3234
|
+
enum: [
|
|
3235
|
+
"goto",
|
|
3236
|
+
"back",
|
|
3237
|
+
"forward",
|
|
3238
|
+
"reload"
|
|
3239
|
+
],
|
|
3240
|
+
description: "The navigation action."
|
|
3241
|
+
},
|
|
3242
|
+
url: {
|
|
3243
|
+
type: "string",
|
|
3244
|
+
description: "Required when action=goto. Max 8 KB."
|
|
3245
|
+
},
|
|
3246
|
+
hard: {
|
|
3247
|
+
type: "boolean",
|
|
3248
|
+
description: "Reload only: bypass cache (Ctrl+Shift+R behavior). Default false."
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
},
|
|
3252
|
+
capability: "browser",
|
|
3253
|
+
async handler(args, signal) {
|
|
3254
|
+
return dispatchBrowserTool("browser_navigate", args, signal);
|
|
3255
|
+
}
|
|
3256
|
+
},
|
|
3257
|
+
{
|
|
3258
|
+
toolNameHttp: "browser_screenshot",
|
|
3259
|
+
description: "Capture a PNG screenshot of the visible area of a tab. Returns base64-encoded image bytes plus contentType. The tab must be active in its window; this tool auto-activates if needed.",
|
|
3260
|
+
inputSchema: {
|
|
3261
|
+
type: "object",
|
|
3262
|
+
required: ["tabId"],
|
|
3263
|
+
additionalProperties: false,
|
|
3264
|
+
properties: {
|
|
3265
|
+
tabId: {
|
|
3266
|
+
type: "number",
|
|
3267
|
+
description: "Tab id from browser_list_tabs / browser_open_tab."
|
|
3268
|
+
},
|
|
3269
|
+
format: {
|
|
3270
|
+
type: "string",
|
|
3271
|
+
enum: ["png", "jpeg"],
|
|
3272
|
+
description: "Image format. Default 'png'."
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
},
|
|
3276
|
+
capability: "browser",
|
|
3277
|
+
async handler(args, signal) {
|
|
3278
|
+
return dispatchBrowserTool("browser_screenshot", args, signal);
|
|
3279
|
+
}
|
|
3280
|
+
},
|
|
3281
|
+
{
|
|
3282
|
+
toolNameHttp: "browser_read_page",
|
|
3283
|
+
description: "Extract rendered page text plus the list of interactive elements (refs, roles, names, bounding boxes). Element refs returned here are intended as the input to a follow-up browser_click / browser_fill / browser_scroll — preferred over CSS selectors because refs are stable across dynamic class names. Text is capped at 256 KiB.",
|
|
3284
|
+
inputSchema: {
|
|
3285
|
+
type: "object",
|
|
3286
|
+
required: ["tabId"],
|
|
3287
|
+
additionalProperties: false,
|
|
3288
|
+
properties: { tabId: {
|
|
3289
|
+
type: "number",
|
|
3290
|
+
description: "Tab id from browser_list_tabs / browser_open_tab."
|
|
3291
|
+
} }
|
|
3292
|
+
},
|
|
3293
|
+
capability: "browser",
|
|
3294
|
+
async handler(args, signal) {
|
|
3295
|
+
return dispatchBrowserTool("browser_read_page", args, signal);
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
{
|
|
3299
|
+
toolNameHttp: "browser_click",
|
|
3300
|
+
description: "Click an element by ref (from a prior browser_read_page) or CSS selector. Returns {ok, navigated} where navigated=true if the URL changed within ~300ms of the click.",
|
|
3301
|
+
inputSchema: {
|
|
3302
|
+
type: "object",
|
|
3303
|
+
required: ["tabId"],
|
|
3304
|
+
additionalProperties: false,
|
|
3305
|
+
properties: {
|
|
3306
|
+
tabId: { type: "number" },
|
|
3307
|
+
ref: {
|
|
3308
|
+
type: "string",
|
|
3309
|
+
description: "Element ref from browser_read_page (preferred)."
|
|
3310
|
+
},
|
|
3311
|
+
selector: {
|
|
3312
|
+
type: "string",
|
|
3313
|
+
description: "CSS selector (fallback when no ref)."
|
|
3314
|
+
},
|
|
3315
|
+
button: {
|
|
3316
|
+
type: "string",
|
|
3317
|
+
enum: ["left", "right"],
|
|
3318
|
+
description: "Mouse button. Default 'left'."
|
|
3319
|
+
},
|
|
3320
|
+
clickCount: {
|
|
3321
|
+
type: "number",
|
|
3322
|
+
description: "Number of times to click. Default 1."
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
},
|
|
3326
|
+
capability: "browser",
|
|
3327
|
+
async handler(args, signal) {
|
|
3328
|
+
return dispatchBrowserTool("browser_click", args, signal);
|
|
3329
|
+
}
|
|
3330
|
+
},
|
|
3331
|
+
{
|
|
3332
|
+
toolNameHttp: "browser_fill",
|
|
3333
|
+
description: "Type into an input / textarea, select from a dropdown, or toggle a checkbox / radio. Dispatches native input and change events so React-style controlled inputs see the value.",
|
|
3334
|
+
inputSchema: {
|
|
3335
|
+
type: "object",
|
|
3336
|
+
required: ["tabId", "value"],
|
|
3337
|
+
additionalProperties: false,
|
|
3338
|
+
properties: {
|
|
3339
|
+
tabId: { type: "number" },
|
|
3340
|
+
ref: {
|
|
3341
|
+
type: "string",
|
|
3342
|
+
description: "Element ref from browser_read_page (preferred)."
|
|
3343
|
+
},
|
|
3344
|
+
selector: {
|
|
3345
|
+
type: "string",
|
|
3346
|
+
description: "CSS selector (fallback when no ref)."
|
|
3347
|
+
},
|
|
3348
|
+
value: { description: "The value to set. String for inputs / textareas / select option value. Boolean for checkbox / radio. Max 1 MB." },
|
|
3349
|
+
clearFirst: {
|
|
3350
|
+
type: "boolean",
|
|
3351
|
+
description: "Clear the input before typing (default true). No effect on select / checkbox."
|
|
3352
|
+
},
|
|
3353
|
+
pressEnter: {
|
|
3354
|
+
type: "boolean",
|
|
3355
|
+
description: "After typing, dispatch Enter keydown / keyup and call form.requestSubmit if available. Default false."
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
},
|
|
3359
|
+
capability: "browser",
|
|
3360
|
+
async handler(args, signal) {
|
|
3361
|
+
return dispatchBrowserTool("browser_fill", args, signal);
|
|
3362
|
+
}
|
|
3363
|
+
},
|
|
3364
|
+
{
|
|
3365
|
+
toolNameHttp: "browser_scroll",
|
|
3366
|
+
description: "Scroll a tab to the top, to the bottom, by a pixel amount, or to a specific element by ref.",
|
|
3367
|
+
inputSchema: {
|
|
3368
|
+
type: "object",
|
|
3369
|
+
required: ["tabId", "target"],
|
|
3370
|
+
additionalProperties: false,
|
|
3371
|
+
properties: {
|
|
3372
|
+
tabId: { type: "number" },
|
|
3373
|
+
target: {
|
|
3374
|
+
type: "string",
|
|
3375
|
+
enum: [
|
|
3376
|
+
"top",
|
|
3377
|
+
"bottom",
|
|
3378
|
+
"pixels",
|
|
3379
|
+
"element"
|
|
3380
|
+
],
|
|
3381
|
+
description: "Scroll target type."
|
|
3382
|
+
},
|
|
3383
|
+
pixels: {
|
|
3384
|
+
type: "number",
|
|
3385
|
+
description: "Pixel delta when target=pixels. Positive scrolls down, negative scrolls up."
|
|
3386
|
+
},
|
|
3387
|
+
ref: {
|
|
3388
|
+
type: "string",
|
|
3389
|
+
description: "Element ref when target=element. Scrolls so the element is centered in the viewport."
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
},
|
|
3393
|
+
capability: "browser",
|
|
3394
|
+
async handler(args, signal) {
|
|
3395
|
+
return dispatchBrowserTool("browser_scroll", args, signal);
|
|
3396
|
+
}
|
|
3397
|
+
},
|
|
3398
|
+
{
|
|
3399
|
+
toolNameHttp: "browser_keyboard",
|
|
3400
|
+
description: "Send a keystroke or chord to the focused element. Use 'Control+L' / 'Command+L' for browser shortcuts, single characters for typing. Uses chrome.debugger so browser-level shortcuts (Ctrl+T, Ctrl+W, etc) actually fire.",
|
|
3401
|
+
inputSchema: {
|
|
3402
|
+
type: "object",
|
|
3403
|
+
required: ["tabId", "keys"],
|
|
3404
|
+
additionalProperties: false,
|
|
3405
|
+
properties: {
|
|
3406
|
+
tabId: { type: "number" },
|
|
3407
|
+
keys: {
|
|
3408
|
+
type: "string",
|
|
3409
|
+
description: "Key or chord. Modifiers (Control, Alt, Shift, Meta / Command) joined with '+'. Example: 'Control+L'."
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
},
|
|
3413
|
+
capability: "browser",
|
|
3414
|
+
async handler(args, signal) {
|
|
3415
|
+
return dispatchBrowserTool("browser_keyboard", args, signal);
|
|
3416
|
+
}
|
|
3417
|
+
},
|
|
3418
|
+
{
|
|
3419
|
+
toolNameHttp: "browser_wait",
|
|
3420
|
+
description: "Wait for an element to appear (until='selector'), the tab URL to match a regex (until='url'), or the network to go idle (until='networkIdle' - heuristic: tab status complete + 500ms quiet). Returns {ok: true, elapsedMs} on success, {ok: false, reason: 'timeout'} on miss.",
|
|
3421
|
+
inputSchema: {
|
|
3422
|
+
type: "object",
|
|
3423
|
+
required: ["tabId", "until"],
|
|
3424
|
+
additionalProperties: false,
|
|
3425
|
+
properties: {
|
|
3426
|
+
tabId: { type: "number" },
|
|
3427
|
+
until: {
|
|
3428
|
+
type: "string",
|
|
3429
|
+
enum: [
|
|
3430
|
+
"selector",
|
|
3431
|
+
"url",
|
|
3432
|
+
"networkIdle"
|
|
3433
|
+
],
|
|
3434
|
+
description: "What to wait for."
|
|
3435
|
+
},
|
|
3436
|
+
selector: {
|
|
3437
|
+
type: "string",
|
|
3438
|
+
description: "CSS selector when until=selector."
|
|
3439
|
+
},
|
|
3440
|
+
urlPattern: {
|
|
3441
|
+
type: "string",
|
|
3442
|
+
description: "JS regex (string form) when until=url."
|
|
3443
|
+
},
|
|
3444
|
+
timeoutMs: {
|
|
3445
|
+
type: "number",
|
|
3446
|
+
description: "Max wait. Default 10000, hard cap 60000."
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
},
|
|
3450
|
+
capability: "browser",
|
|
3451
|
+
async handler(args, signal) {
|
|
3452
|
+
return dispatchBrowserTool("browser_wait", args, signal);
|
|
3453
|
+
}
|
|
3454
|
+
},
|
|
3455
|
+
{
|
|
3456
|
+
toolNameHttp: "browser_eval_js",
|
|
3457
|
+
description: "Evaluate a JavaScript expression in the tab's main world (equivalent to typing in the DevTools console). Returns {result} or {error}. Awaits promises returned by the expression. Single narrowly-named escape hatch for behaviors the other tools don't cover.",
|
|
3458
|
+
inputSchema: {
|
|
3459
|
+
type: "object",
|
|
3460
|
+
required: ["tabId", "expression"],
|
|
3461
|
+
additionalProperties: false,
|
|
3462
|
+
properties: {
|
|
3463
|
+
tabId: { type: "number" },
|
|
3464
|
+
expression: {
|
|
3465
|
+
type: "string",
|
|
3466
|
+
description: "JS expression. Max 100 KB. Top-level await NOT supported - wrap in (async () => ...)()."
|
|
3467
|
+
},
|
|
3468
|
+
timeoutMs: {
|
|
3469
|
+
type: "number",
|
|
3470
|
+
description: "Max evaluation time. Default 5000, hard cap 30000."
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
},
|
|
3474
|
+
capability: "browser",
|
|
3475
|
+
async handler(args, signal) {
|
|
3476
|
+
return dispatchBrowserTool("browser_eval_js", args, signal);
|
|
3477
|
+
}
|
|
3478
|
+
},
|
|
3479
|
+
{
|
|
3480
|
+
toolNameHttp: "browser_download",
|
|
3481
|
+
description: "Trigger a download by URL and wait for it to complete. Returns {downloadId, path, bytes, mimeType}. The file lands in Chrome's default Downloads dir unless saveAs is given.",
|
|
3482
|
+
inputSchema: {
|
|
3483
|
+
type: "object",
|
|
3484
|
+
required: ["tabId", "url"],
|
|
3485
|
+
additionalProperties: false,
|
|
3486
|
+
properties: {
|
|
3487
|
+
tabId: {
|
|
3488
|
+
type: "number",
|
|
3489
|
+
description: "Tab id is logged but the download itself is window-scoped, not tab-scoped."
|
|
3490
|
+
},
|
|
3491
|
+
source: {
|
|
3492
|
+
type: "string",
|
|
3493
|
+
enum: ["url"],
|
|
3494
|
+
description: "Download source. Only 'url' supported in v1; click-then-wait awaits Phase 5."
|
|
3495
|
+
},
|
|
3496
|
+
url: {
|
|
3497
|
+
type: "string",
|
|
3498
|
+
description: "Direct URL to download. Max 8 KB."
|
|
3499
|
+
},
|
|
3500
|
+
saveAs: {
|
|
3501
|
+
type: "string",
|
|
3502
|
+
description: "Optional filename / relative subdir under Downloads. Conflicts auto-uniquify."
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
},
|
|
3506
|
+
capability: "browser",
|
|
3507
|
+
async handler(args, signal) {
|
|
3508
|
+
return dispatchBrowserTool("browser_download", args, signal);
|
|
3509
|
+
}
|
|
3510
|
+
},
|
|
3511
|
+
{
|
|
3512
|
+
toolNameHttp: "browser_console_logs",
|
|
3513
|
+
description: "Drain console messages a tab has emitted since the last call. The first call for a tab attaches chrome.debugger and starts capturing, so very-early-load messages from before the first call are missed; subsequent calls return everything since the previous drain. Buffer is capped at 1000 entries per tab.",
|
|
3514
|
+
inputSchema: {
|
|
3515
|
+
type: "object",
|
|
3516
|
+
required: ["tabId"],
|
|
3517
|
+
additionalProperties: false,
|
|
3518
|
+
properties: {
|
|
3519
|
+
tabId: { type: "number" },
|
|
3520
|
+
level: {
|
|
3521
|
+
type: "string",
|
|
3522
|
+
enum: [
|
|
3523
|
+
"log",
|
|
3524
|
+
"info",
|
|
3525
|
+
"warn",
|
|
3526
|
+
"error",
|
|
3527
|
+
"debug",
|
|
3528
|
+
"all"
|
|
3529
|
+
],
|
|
3530
|
+
description: "Filter by console level. Default 'all'."
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
},
|
|
3534
|
+
capability: "browser",
|
|
3535
|
+
async handler(args, signal) {
|
|
3536
|
+
return dispatchBrowserTool("browser_console_logs", args, signal);
|
|
3537
|
+
}
|
|
3538
|
+
},
|
|
3539
|
+
{
|
|
3540
|
+
toolNameHttp: "browser_network_log",
|
|
3541
|
+
description: "Drain network responses a tab has received since the last call. Same lazy-attach + cap-1000 behavior as browser_console_logs. Returns request URL, method, status, mime type, and timestamp per entry.",
|
|
3542
|
+
inputSchema: {
|
|
3543
|
+
type: "object",
|
|
3544
|
+
required: ["tabId"],
|
|
3545
|
+
additionalProperties: false,
|
|
3546
|
+
properties: { tabId: { type: "number" } }
|
|
3547
|
+
},
|
|
3548
|
+
capability: "browser",
|
|
3549
|
+
async handler(args, signal) {
|
|
3550
|
+
return dispatchBrowserTool("browser_network_log", args, signal);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
]);
|
|
3554
|
+
|
|
2471
3555
|
//#endregion
|
|
2472
3556
|
//#region src/vendor/pi/ai/api-registry.ts
|
|
2473
3557
|
const apiProviderRegistry = /* @__PURE__ */ new Map();
|
|
@@ -2711,8 +3795,8 @@ function coerceWithJsonSchema(value, schema) {
|
|
|
2711
3795
|
}
|
|
2712
3796
|
function getValidator(schema) {
|
|
2713
3797
|
const key = schema;
|
|
2714
|
-
const cached = validatorCache.get(key);
|
|
2715
|
-
if (cached) return cached;
|
|
3798
|
+
const cached$1 = validatorCache.get(key);
|
|
3799
|
+
if (cached$1) return cached$1;
|
|
2716
3800
|
const validator = Compile(schema);
|
|
2717
3801
|
validatorCache.set(key, validator);
|
|
2718
3802
|
return validator;
|
|
@@ -4405,12 +5489,12 @@ function joinTextChunks(accum, idx) {
|
|
|
4405
5489
|
*/
|
|
4406
5490
|
function makeLazyTextPart(chunks) {
|
|
4407
5491
|
const upTo = chunks.length;
|
|
4408
|
-
let cached;
|
|
5492
|
+
let cached$1;
|
|
4409
5493
|
return {
|
|
4410
5494
|
type: "text",
|
|
4411
5495
|
get text() {
|
|
4412
|
-
if (cached === void 0) cached = upTo === chunks.length ? chunks.join("") : chunks.slice(0, upTo).join("");
|
|
4413
|
-
return cached;
|
|
5496
|
+
if (cached$1 === void 0) cached$1 = upTo === chunks.length ? chunks.join("") : chunks.slice(0, upTo).join("");
|
|
5497
|
+
return cached$1;
|
|
4414
5498
|
}
|
|
4415
5499
|
};
|
|
4416
5500
|
}
|
|
@@ -4874,6 +5958,35 @@ function geminiAvailable() {
|
|
|
4874
5958
|
return models.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
4875
5959
|
}
|
|
4876
5960
|
/**
|
|
5961
|
+
* Gate for the `stand_in` tool.
|
|
5962
|
+
*
|
|
5963
|
+
* Returns true iff Copilot's live catalog (`state.models?.data`) contains
|
|
5964
|
+
* ALL THREE peer models the consensus protocol needs:
|
|
5965
|
+
* - `gpt-5.5` (codex_critic's model)
|
|
5966
|
+
* - `claude-opus-4-7` (opus_critic's model)
|
|
5967
|
+
* - any `gemini-3.X.*pro` (gemini_critic's model family — matches the
|
|
5968
|
+
* same regex `geminiAvailable()` uses, so the gate stays in sync if
|
|
5969
|
+
* the GA slug renames `gemini-3.1-pro-preview` → `gemini-3.1-pro`)
|
|
5970
|
+
*
|
|
5971
|
+
* If any one is missing, `stand_in` is dropped from `tools/list` AND
|
|
5972
|
+
* fails `tools/call` with -32601 (mirroring the `worker` capability's
|
|
5973
|
+
* defense-in-depth pattern — the gated tool is functionally invisible).
|
|
5974
|
+
*
|
|
5975
|
+
* Tier-mismatch on `claude-opus-4-7`: the proxy's `resolveModel` will
|
|
5976
|
+
* fuzzy-match `claude-opus-4-7` to `claude-opus-4.7` (Copilot's dotted
|
|
5977
|
+
* slug). For the catalog probe we use the Anthropic-published dashed
|
|
5978
|
+
* slug too — `state.models?.data` mirrors Copilot's catalog where these
|
|
5979
|
+
* land under the dotted slug, so we match by Copilot's actual id shape.
|
|
5980
|
+
*/
|
|
5981
|
+
function standInToolEnabled() {
|
|
5982
|
+
const models = state.models?.data;
|
|
5983
|
+
if (!models) return false;
|
|
5984
|
+
const hasGpt55 = models.some((m) => m.id === "gpt-5.5");
|
|
5985
|
+
const hasOpus = models.some((m) => m.id === "claude-opus-4-7" || m.id === "claude-opus-4.7");
|
|
5986
|
+
const hasGeminiPro = models.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
5987
|
+
return hasGpt55 && hasOpus && hasGeminiPro;
|
|
5988
|
+
}
|
|
5989
|
+
/**
|
|
4877
5990
|
* Gate for the worker tools (`worker_explore`, `worker_implement`).
|
|
4878
5991
|
*
|
|
4879
5992
|
* Returns true iff BOTH:
|
|
@@ -4906,6 +6019,37 @@ function workerToolsEnabled() {
|
|
|
4906
6019
|
if (!found) return false;
|
|
4907
6020
|
return found.capabilities?.supports?.tool_calls === true;
|
|
4908
6021
|
}
|
|
6022
|
+
/**
|
|
6023
|
+
* Gate for the browser-control MCP tools (`browser_*`).
|
|
6024
|
+
*
|
|
6025
|
+
* Returns true iff BOTH:
|
|
6026
|
+
* 1. The operator opted in via `--browse` (which sets
|
|
6027
|
+
* `state.browseEnabled`) OR the equivalent env var
|
|
6028
|
+
* `GH_ROUTER_ENABLE_BROWSE=1`. Default OFF — browser-control is
|
|
6029
|
+
* side-effectful (mutates the user's browser session, downloads
|
|
6030
|
+
* files, can navigate to phishing URLs the model was prompted with),
|
|
6031
|
+
* so dormant-register is the safe default.
|
|
6032
|
+
* 2. At least one supported Chromium-family browser (Chrome or Edge)
|
|
6033
|
+
* is detected on disk by `hasSupportedBrowserInstalled()`. No
|
|
6034
|
+
* browser → nothing for the bridge to attach to → tools stay
|
|
6035
|
+
* invisible rather than fail at call time. Detection is cached for
|
|
6036
|
+
* the proxy lifetime; a fresh install requires a restart.
|
|
6037
|
+
*
|
|
6038
|
+
* Mirrors the defense-in-depth pattern of `workerToolsEnabled()` /
|
|
6039
|
+
* `standInToolEnabled()`: this same function gates BOTH the
|
|
6040
|
+
* `tools/list` filter in `toolEntries()` AND the call-time rejection in
|
|
6041
|
+
* `handleToolsCall` (returning -32601 for hard-coded tool-name
|
|
6042
|
+
* bypasses), so the two surfaces stay symmetric.
|
|
6043
|
+
*
|
|
6044
|
+
* The env-var check reads `process.env` directly instead of relying
|
|
6045
|
+
* solely on `state.browseEnabled` so a non-`setupAndServe` startup path
|
|
6046
|
+
* (tests, embedded use) can still flip the gate via env. The CLI flag
|
|
6047
|
+
* path is the canonical one for end users.
|
|
6048
|
+
*/
|
|
6049
|
+
function browserToolsEnabled() {
|
|
6050
|
+
if (!(state.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1")) return false;
|
|
6051
|
+
return hasSupportedBrowserInstalled();
|
|
6052
|
+
}
|
|
4909
6053
|
function activePersonas() {
|
|
4910
6054
|
return PERSONAS_READ.filter((p) => !p.requiresGeminiCatalog || geminiAvailable());
|
|
4911
6055
|
}
|
|
@@ -4934,7 +6078,12 @@ function toolEntries() {
|
|
|
4934
6078
|
}
|
|
4935
6079
|
}
|
|
4936
6080
|
}));
|
|
4937
|
-
const nonPersonaEntries = NON_PERSONA_MCP_TOOLS.filter((t) =>
|
|
6081
|
+
const nonPersonaEntries = NON_PERSONA_MCP_TOOLS.filter((t) => {
|
|
6082
|
+
if (t.capability === "worker") return workerToolsEnabled();
|
|
6083
|
+
if (t.capability === "stand_in") return standInToolEnabled();
|
|
6084
|
+
if (t.capability === "browser") return browserToolsEnabled();
|
|
6085
|
+
return true;
|
|
6086
|
+
}).map((t) => ({
|
|
4938
6087
|
name: t.toolNameHttp,
|
|
4939
6088
|
description: t.description,
|
|
4940
6089
|
inputSchema: t.inputSchema
|
|
@@ -5054,10 +6203,21 @@ function jsonPathPreflightCap(body) {
|
|
|
5054
6203
|
const params = body.params ?? {};
|
|
5055
6204
|
const name$1 = typeof params.name === "string" ? params.name : "";
|
|
5056
6205
|
const args = params.arguments ?? {};
|
|
6206
|
+
if (!name$1) return void 0;
|
|
6207
|
+
if (name$1 === "stand_in") {
|
|
6208
|
+
const decision = typeof args.decision === "string" ? args.decision : "";
|
|
6209
|
+
const optionsRaw = Array.isArray(args.options) ? args.options : [];
|
|
6210
|
+
const standInContext = typeof args.context === "string" ? args.context : "";
|
|
6211
|
+
if (!decision || optionsRaw.length === 0) return void 0;
|
|
6212
|
+
const briefBytes$1 = Buffer.byteLength(decision + JSON.stringify(optionsRaw) + standInContext, "utf8");
|
|
6213
|
+
const STAND_IN_CAP_BYTES = 6 * 1024;
|
|
6214
|
+
if (briefBytes$1 > STAND_IN_CAP_BYTES) return rpcResult(body.id, toolError(`pre-flight rejected: stand_in on a ${briefBytes$1}-byte input is predicted to exceed the JSON tools/call timeout (cap=${STAND_IN_CAP_BYTES} bytes). stand_in runs two sequential voting rounds across three frontier models — wall-clock is typically 2-3 minutes regardless of input size. Send Accept: text/event-stream to use the SSE path which bypasses this cap, or trim the decision/options/context.`));
|
|
6215
|
+
return;
|
|
6216
|
+
}
|
|
5057
6217
|
const prompt = typeof args.prompt === "string" ? args.prompt : "";
|
|
5058
6218
|
const context = typeof args.context === "string" ? args.context : void 0;
|
|
5059
6219
|
const rawEffort = args.effort;
|
|
5060
|
-
if (!
|
|
6220
|
+
if (!prompt) return void 0;
|
|
5061
6221
|
const persona = activePersonas().find((p) => p.toolNameHttp === name$1);
|
|
5062
6222
|
if (!persona) return void 0;
|
|
5063
6223
|
if (rawEffort !== void 0 && !isEffort(rawEffort)) return void 0;
|
|
@@ -5069,60 +6229,81 @@ function jsonPathPreflightCap(body) {
|
|
|
5069
6229
|
if (!verdict.tooLong) return void 0;
|
|
5070
6230
|
return rpcResult(body.id, toolError(`pre-flight rejected: ${persona.toolNameHttp} at effort=${effort} on a ${briefBytes}-byte brief is empirically predicted to exceed the JSON tools/call timeout (cap=${verdict.capBytes} bytes for this tier). Either drop to a lower effort tier, split the brief into 2-4 parallel sub-calls per the decomposition guidance, or send Accept: text/event-stream to use the SSE path which bypasses this cap.`));
|
|
5071
6231
|
}
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
6232
|
+
/**
|
|
6233
|
+
* Per-endpoint wire dispatch for a single peer-model call. Returns the
|
|
6234
|
+
* assistant's raw text (possibly empty — caller decides what "empty"
|
|
6235
|
+
* means in their context). Upstream errors (network, 4xx, 5xx) propagate
|
|
6236
|
+
* as exceptions via `await`.
|
|
6237
|
+
*
|
|
6238
|
+
* Extracted from `callPersona()` so non-persona callers — specifically
|
|
6239
|
+
* the `stand_in` orchestrator in `src/lib/stand-in.ts` — can reuse the
|
|
6240
|
+
* same per-endpoint request shaping without re-implementing it. The
|
|
6241
|
+
* stand_in tool needs to drive its own per-round system prompts across
|
|
6242
|
+
* three concrete models (gpt-5.5, claude-opus-4-7, gemini-3.1-pro-preview),
|
|
6243
|
+
* each on a different endpoint; doing that with a `PersonaSpec` would
|
|
6244
|
+
* require either inventing throwaway personas per round or duplicating
|
|
6245
|
+
* the dispatch switch.
|
|
6246
|
+
*
|
|
6247
|
+
* NOTE on consumer-cancel signal: we deliberately do NOT pass
|
|
6248
|
+
* c.req.raw.signal into the upstream fetch. Bun/srvx aborts the
|
|
6249
|
+
* request signal as soon as the request body is fully consumed
|
|
6250
|
+
* (after `await c.req.json()`), which would make every call fail
|
|
6251
|
+
* immediately with "This operation was aborted". The caller creates
|
|
6252
|
+
* its own AbortController and threads it through `signal`. See CLAUDE.md
|
|
6253
|
+
* "Bun request-signal quirk" for full context.
|
|
6254
|
+
*/
|
|
6255
|
+
async function dispatchModelCall(args) {
|
|
6256
|
+
const resolvedModel = resolveModel(args.model);
|
|
6257
|
+
if (args.endpoint === "/v1/responses") return extractResponsesText(await createResponses({
|
|
6258
|
+
model: resolvedModel,
|
|
6259
|
+
instructions: args.instructions,
|
|
6260
|
+
input: [{
|
|
6261
|
+
role: "user",
|
|
6262
|
+
content: [{
|
|
6263
|
+
type: "input_text",
|
|
6264
|
+
text: args.userText
|
|
6265
|
+
}]
|
|
6266
|
+
}],
|
|
6267
|
+
stream: false,
|
|
6268
|
+
reasoning: { effort: args.effort }
|
|
6269
|
+
}, void 0, args.signal));
|
|
6270
|
+
if (args.endpoint === "/v1/messages") {
|
|
6271
|
+
const maxTokens = args.effort === "low" ? 4096 : args.effort === "medium" ? 8192 : args.effort === "high" ? 16384 : 32768;
|
|
6272
|
+
return extractMessagesText(await (await createMessages(JSON.stringify({
|
|
5098
6273
|
model: resolvedModel,
|
|
5099
6274
|
max_tokens: maxTokens,
|
|
5100
|
-
system:
|
|
6275
|
+
system: args.instructions,
|
|
5101
6276
|
thinking: { type: "adaptive" },
|
|
5102
|
-
output_config: { effort },
|
|
6277
|
+
output_config: { effort: args.effort },
|
|
5103
6278
|
messages: [{
|
|
5104
6279
|
role: "user",
|
|
5105
|
-
content: userText
|
|
6280
|
+
content: args.userText
|
|
5106
6281
|
}]
|
|
5107
|
-
}), void 0, signal)).json());
|
|
5108
|
-
if (!text$1) return toolError(`persona ${persona.agentName}: empty assistant output`);
|
|
5109
|
-
return { content: [{
|
|
5110
|
-
type: "text",
|
|
5111
|
-
text: text$1
|
|
5112
|
-
}] };
|
|
6282
|
+
}), void 0, args.signal)).json());
|
|
5113
6283
|
}
|
|
5114
|
-
|
|
6284
|
+
return extractChatCompletionText(await createChatCompletions({
|
|
5115
6285
|
model: resolvedModel,
|
|
5116
6286
|
messages: [{
|
|
5117
6287
|
role: "system",
|
|
5118
|
-
content:
|
|
6288
|
+
content: args.instructions
|
|
5119
6289
|
}, {
|
|
5120
6290
|
role: "user",
|
|
5121
|
-
content: userText
|
|
6291
|
+
content: args.userText
|
|
5122
6292
|
}],
|
|
5123
6293
|
stream: false,
|
|
5124
|
-
reasoning_effort: effort
|
|
5125
|
-
}, void 0, signal));
|
|
6294
|
+
reasoning_effort: args.effort
|
|
6295
|
+
}, void 0, args.signal));
|
|
6296
|
+
}
|
|
6297
|
+
async function callPersona(persona, prompt, context, effort, signal) {
|
|
6298
|
+
const userText = buildUserText(prompt, context);
|
|
6299
|
+
const text = await dispatchModelCall({
|
|
6300
|
+
model: persona.model,
|
|
6301
|
+
endpoint: persona.endpoint,
|
|
6302
|
+
instructions: persona.baseInstructions,
|
|
6303
|
+
userText,
|
|
6304
|
+
effort,
|
|
6305
|
+
signal
|
|
6306
|
+
});
|
|
5126
6307
|
if (!text) return toolError(`persona ${persona.agentName}: empty assistant output`);
|
|
5127
6308
|
return { content: [{
|
|
5128
6309
|
type: "text",
|
|
@@ -5150,6 +6331,8 @@ async function handleToolsCall(body) {
|
|
|
5150
6331
|
const nonPersonaTool = persona ? void 0 : NON_PERSONA_MCP_TOOLS.find((t) => t.toolNameHttp === name$1);
|
|
5151
6332
|
if (!persona && !nonPersonaTool) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
|
|
5152
6333
|
if (nonPersonaTool && nonPersonaTool.capability === "worker" && !workerToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
|
|
6334
|
+
if (nonPersonaTool && nonPersonaTool.capability === "stand_in" && !standInToolEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
|
|
6335
|
+
if (nonPersonaTool && nonPersonaTool.capability === "browser" && !browserToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
|
|
5153
6336
|
let personaPrompt;
|
|
5154
6337
|
let personaContext;
|
|
5155
6338
|
let personaEffort;
|
|
@@ -7980,6 +9163,341 @@ async function runWorkerAgent(opts) {
|
|
|
7980
9163
|
}
|
|
7981
9164
|
}
|
|
7982
9165
|
|
|
9166
|
+
//#endregion
|
|
9167
|
+
//#region src/lib/stand-in.ts
|
|
9168
|
+
/**
|
|
9169
|
+
* The three frontier peers. Effort is FIXED per model — not caller-tunable.
|
|
9170
|
+
* The tool's purpose is "give me the best 3-lab judgment available";
|
|
9171
|
+
* exposing effort knobs would invite the caller to cheap out and would
|
|
9172
|
+
* muddy the consensus signal.
|
|
9173
|
+
*
|
|
9174
|
+
* gemini-3.1-pro-preview is pinned to `high` because the model rejects
|
|
9175
|
+
* `xhigh` at the wire with a Copilot 400. `high` is the realistic ceiling.
|
|
9176
|
+
*/
|
|
9177
|
+
const STAND_IN_MODELS = Object.freeze([
|
|
9178
|
+
{
|
|
9179
|
+
key: "gpt-5.5",
|
|
9180
|
+
model: "gpt-5.5",
|
|
9181
|
+
endpoint: "/v1/responses",
|
|
9182
|
+
effort: "xhigh"
|
|
9183
|
+
},
|
|
9184
|
+
{
|
|
9185
|
+
key: "claude-opus-4-7",
|
|
9186
|
+
model: "claude-opus-4-7",
|
|
9187
|
+
endpoint: "/v1/messages",
|
|
9188
|
+
effort: "xhigh"
|
|
9189
|
+
},
|
|
9190
|
+
{
|
|
9191
|
+
key: "gemini-3.1-pro-preview",
|
|
9192
|
+
model: "gemini-3.1-pro-preview",
|
|
9193
|
+
endpoint: "/v1/chat/completions",
|
|
9194
|
+
effort: "high"
|
|
9195
|
+
}
|
|
9196
|
+
]);
|
|
9197
|
+
const SYSTEM_PROMPT_R1 = `You are one of three frontier reasoning models the user has authorized to stand in for them on a bounded decision while they are unavailable. Your task: pick the best option from those provided.
|
|
9198
|
+
|
|
9199
|
+
Respond with ONLY a single JSON object — no prose, no markdown fences, no preamble. Schema:
|
|
9200
|
+
|
|
9201
|
+
{
|
|
9202
|
+
"choice": "<option.id>" | null,
|
|
9203
|
+
"confidence": <number between 0.0 and 1.0>,
|
|
9204
|
+
"reasoning": "<one short sentence>",
|
|
9205
|
+
"need_more_info": "<what context is missing, if you cannot decide>"
|
|
9206
|
+
}
|
|
9207
|
+
|
|
9208
|
+
Calibration rules:
|
|
9209
|
+
- "confidence" reflects how sure you are this is the better option (not how confident you are in your prose). 0.5 = coin flip. 0.9 = clear winner. Be honestly calibrated; the orchestrator weighs your number directly.
|
|
9210
|
+
- If the question is genuinely under-specified — you'd need information you don't have to choose well — set "choice": null AND populate "need_more_info" with the specific gap. Do NOT guess.
|
|
9211
|
+
- One sentence of reasoning. Not a paragraph.
|
|
9212
|
+
- The other two models will vote independently and you will see their votes in round 2. There is no benefit to anticipating what they'll pick; vote on the merits.
|
|
9213
|
+
|
|
9214
|
+
Output ONLY the JSON object. No preamble, no markdown fences, no closing remarks.`;
|
|
9215
|
+
const SYSTEM_PROMPT_R2 = `You are one of three frontier reasoning models standing in for the user on a bounded decision. Round 1 voting is complete; you will now see the other models' votes and reasoning. Reconsider with their input visible.
|
|
9216
|
+
|
|
9217
|
+
Same JSON schema as round 1:
|
|
9218
|
+
|
|
9219
|
+
{
|
|
9220
|
+
"choice": "<option.id>" | null,
|
|
9221
|
+
"confidence": <number between 0.0 and 1.0>,
|
|
9222
|
+
"reasoning": "<one short sentence>",
|
|
9223
|
+
"need_more_info": "<gap, if any>"
|
|
9224
|
+
}
|
|
9225
|
+
|
|
9226
|
+
Calibration rules:
|
|
9227
|
+
- You may keep your round-1 vote OR change it. Do NOT change just to agree — agreement is not the goal, the right answer is. Capitulating to peer pressure when you still believe your original choice is better is a failure mode, not a success.
|
|
9228
|
+
- If a peer's reasoning identifies a consideration you missed or weighed wrong, update freely. The blind round was the anti-anchor mechanism; this round is where genuine evidence can move you.
|
|
9229
|
+
- If round 1 left you genuinely uncertain and peer reasoning hasn't resolved it, "choice": null is still the honest answer.
|
|
9230
|
+
|
|
9231
|
+
Output ONLY the JSON object.`;
|
|
9232
|
+
const RETRY_PROMPT_SUFFIX = `\n\nYour previous response was not valid JSON matching the schema. Respond with ONLY the JSON object — no preamble, no markdown fences, no closing remarks. Schema reminder: {"choice": "<id>" | null, "confidence": 0.0-1.0, "reasoning": "<one sentence>", "need_more_info": "<gap, if any>"}`;
|
|
9233
|
+
/**
|
|
9234
|
+
* Run the two-round stand-in protocol. Returns a structured verdict
|
|
9235
|
+
* envelope. Throws only on systemic failure (e.g., all three upstream
|
|
9236
|
+
* calls failed) — model-level errors and parse failures are surfaced as
|
|
9237
|
+
* `VoteFailure` entries in the result.
|
|
9238
|
+
*/
|
|
9239
|
+
async function runStandIn(input, signal) {
|
|
9240
|
+
const r1UserText = buildRound1UserText(input);
|
|
9241
|
+
const r1 = await Promise.all(STAND_IN_MODELS.map((cfg) => callAndParse(cfg, SYSTEM_PROMPT_R1, r1UserText, signal)));
|
|
9242
|
+
const successfulR1 = r1.filter((r) => isVote(r.vote));
|
|
9243
|
+
if (successfulR1.length === STAND_IN_MODELS.length && successfulR1.every((r) => r.vote.needMoreInfo && r.vote.choice === null)) {
|
|
9244
|
+
const gaps = successfulR1.map((r) => `- ${r.key}: ${r.vote.needMoreInfo}`).join("\n");
|
|
9245
|
+
return {
|
|
9246
|
+
verdict: "need_more_info",
|
|
9247
|
+
recommendation: null,
|
|
9248
|
+
confidence: 0,
|
|
9249
|
+
votes: voteRecord(r1, null),
|
|
9250
|
+
notes: `All three models reported they need more context to decide:\n${gaps}`
|
|
9251
|
+
};
|
|
9252
|
+
}
|
|
9253
|
+
const r1Decision = aggregateVotes(successfulR1);
|
|
9254
|
+
if (r1Decision.verdict === "consensus" && r1Decision.meanConfidence >= .8) return {
|
|
9255
|
+
verdict: "consensus",
|
|
9256
|
+
recommendation: r1Decision.winner,
|
|
9257
|
+
confidence: round2(r1Decision.meanConfidence),
|
|
9258
|
+
votes: voteRecord(r1, null),
|
|
9259
|
+
notes: `All three models picked ${r1Decision.winner} in round 1 with high confidence (skipped round 2).`
|
|
9260
|
+
};
|
|
9261
|
+
if (successfulR1.length < 2) return {
|
|
9262
|
+
verdict: "no_consensus",
|
|
9263
|
+
recommendation: null,
|
|
9264
|
+
confidence: 0,
|
|
9265
|
+
votes: voteRecord(r1, null),
|
|
9266
|
+
notes: `Only ${successfulR1.length} of 3 models returned a parseable round-1 vote; insufficient signal to run round 2.`
|
|
9267
|
+
};
|
|
9268
|
+
const r2UserTextBase = buildRound2UserTextBase(input, r1);
|
|
9269
|
+
const r2 = await Promise.all(STAND_IN_MODELS.map((cfg) => callAndParse(cfg, SYSTEM_PROMPT_R2, r2UserTextBase + `\n\nYou are ${cfg.key}. Reconsider and vote.`, signal)));
|
|
9270
|
+
const successfulR2 = r2.filter((r) => isVote(r.vote));
|
|
9271
|
+
if (successfulR2.length < 2) return {
|
|
9272
|
+
verdict: "no_consensus",
|
|
9273
|
+
recommendation: null,
|
|
9274
|
+
confidence: 0,
|
|
9275
|
+
votes: voteRecord(r1, r2),
|
|
9276
|
+
notes: `Only ${successfulR2.length} of 3 models returned a parseable round-2 vote; deferring to user.`
|
|
9277
|
+
};
|
|
9278
|
+
const r2Decision = aggregateVotes(successfulR2);
|
|
9279
|
+
if (r2Decision.verdict === "consensus") return {
|
|
9280
|
+
verdict: "consensus",
|
|
9281
|
+
recommendation: r2Decision.winner,
|
|
9282
|
+
confidence: round2(r2Decision.meanConfidence),
|
|
9283
|
+
votes: voteRecord(r1, r2),
|
|
9284
|
+
notes: `All three models picked ${r2Decision.winner} in round 2.`
|
|
9285
|
+
};
|
|
9286
|
+
if (r2Decision.verdict === "majority") {
|
|
9287
|
+
const dissenters = successfulR2.filter((r) => r.vote.choice !== r2Decision.winner).map((r) => `${r.key} picked ${r.vote.choice ?? "abstain"} (${r.vote.reasoning})`).join("; ");
|
|
9288
|
+
return {
|
|
9289
|
+
verdict: "majority",
|
|
9290
|
+
recommendation: r2Decision.winner,
|
|
9291
|
+
confidence: round2(r2Decision.meanConfidence),
|
|
9292
|
+
votes: voteRecord(r1, r2),
|
|
9293
|
+
notes: `Majority (2 of 3) picked ${r2Decision.winner}. Dissent: ${dissenters}.`
|
|
9294
|
+
};
|
|
9295
|
+
}
|
|
9296
|
+
return {
|
|
9297
|
+
verdict: "no_consensus",
|
|
9298
|
+
recommendation: null,
|
|
9299
|
+
confidence: 0,
|
|
9300
|
+
votes: voteRecord(r1, r2),
|
|
9301
|
+
notes: `Models did not converge in round 2 (votes split). Defer to user.`
|
|
9302
|
+
};
|
|
9303
|
+
}
|
|
9304
|
+
async function callAndParse(cfg, instructions, userText, signal) {
|
|
9305
|
+
let raw;
|
|
9306
|
+
try {
|
|
9307
|
+
raw = await dispatchModelCall({
|
|
9308
|
+
model: cfg.model,
|
|
9309
|
+
endpoint: cfg.endpoint,
|
|
9310
|
+
instructions,
|
|
9311
|
+
userText,
|
|
9312
|
+
effort: cfg.effort,
|
|
9313
|
+
signal
|
|
9314
|
+
});
|
|
9315
|
+
} catch (err) {
|
|
9316
|
+
return {
|
|
9317
|
+
key: cfg.key,
|
|
9318
|
+
vote: {
|
|
9319
|
+
error: "upstream_error",
|
|
9320
|
+
message: String(err)
|
|
9321
|
+
}
|
|
9322
|
+
};
|
|
9323
|
+
}
|
|
9324
|
+
const first = tryParseVote(raw);
|
|
9325
|
+
if (first.ok) return {
|
|
9326
|
+
key: cfg.key,
|
|
9327
|
+
vote: first.vote
|
|
9328
|
+
};
|
|
9329
|
+
let retryRaw;
|
|
9330
|
+
try {
|
|
9331
|
+
retryRaw = await dispatchModelCall({
|
|
9332
|
+
model: cfg.model,
|
|
9333
|
+
endpoint: cfg.endpoint,
|
|
9334
|
+
instructions,
|
|
9335
|
+
userText: userText + RETRY_PROMPT_SUFFIX,
|
|
9336
|
+
effort: cfg.effort,
|
|
9337
|
+
signal
|
|
9338
|
+
});
|
|
9339
|
+
} catch (err) {
|
|
9340
|
+
return {
|
|
9341
|
+
key: cfg.key,
|
|
9342
|
+
vote: {
|
|
9343
|
+
error: "upstream_error",
|
|
9344
|
+
message: `retry after parse failure: ${String(err)}`
|
|
9345
|
+
}
|
|
9346
|
+
};
|
|
9347
|
+
}
|
|
9348
|
+
const second = tryParseVote(retryRaw);
|
|
9349
|
+
if (second.ok) return {
|
|
9350
|
+
key: cfg.key,
|
|
9351
|
+
vote: second.vote
|
|
9352
|
+
};
|
|
9353
|
+
return {
|
|
9354
|
+
key: cfg.key,
|
|
9355
|
+
vote: {
|
|
9356
|
+
error: "parse_failure",
|
|
9357
|
+
message: `Could not parse vote JSON after one retry. Last error: ${second.error}.`,
|
|
9358
|
+
raw: retryRaw.slice(0, 500)
|
|
9359
|
+
}
|
|
9360
|
+
};
|
|
9361
|
+
}
|
|
9362
|
+
function tryParseVote(raw) {
|
|
9363
|
+
if (!raw || !raw.trim()) return {
|
|
9364
|
+
ok: false,
|
|
9365
|
+
error: "empty response"
|
|
9366
|
+
};
|
|
9367
|
+
let parsed;
|
|
9368
|
+
try {
|
|
9369
|
+
parsed = JSON.parse(raw.trim());
|
|
9370
|
+
} catch {
|
|
9371
|
+
const fence = /```(?:json)?\s*([\s\S]*?)\s*```/.exec(raw);
|
|
9372
|
+
if (!fence) return {
|
|
9373
|
+
ok: false,
|
|
9374
|
+
error: "not valid JSON and no code fence found"
|
|
9375
|
+
};
|
|
9376
|
+
try {
|
|
9377
|
+
parsed = JSON.parse(fence[1]);
|
|
9378
|
+
} catch {
|
|
9379
|
+
return {
|
|
9380
|
+
ok: false,
|
|
9381
|
+
error: "code fence content was not valid JSON"
|
|
9382
|
+
};
|
|
9383
|
+
}
|
|
9384
|
+
}
|
|
9385
|
+
if (typeof parsed !== "object" || parsed === null) return {
|
|
9386
|
+
ok: false,
|
|
9387
|
+
error: "parsed value is not an object"
|
|
9388
|
+
};
|
|
9389
|
+
const obj = parsed;
|
|
9390
|
+
const choice = obj.choice === null ? null : typeof obj.choice === "string" && obj.choice.length > 0 ? obj.choice : void 0;
|
|
9391
|
+
if (choice === void 0) return {
|
|
9392
|
+
ok: false,
|
|
9393
|
+
error: "missing or invalid 'choice' field (string or null required)"
|
|
9394
|
+
};
|
|
9395
|
+
const confidenceRaw = obj.confidence;
|
|
9396
|
+
const confidence = typeof confidenceRaw === "number" && Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : void 0;
|
|
9397
|
+
if (confidence === void 0) return {
|
|
9398
|
+
ok: false,
|
|
9399
|
+
error: "missing or invalid 'confidence' field (number 0-1 required)"
|
|
9400
|
+
};
|
|
9401
|
+
const reasoning = typeof obj.reasoning === "string" ? obj.reasoning : "";
|
|
9402
|
+
if (!reasoning) return {
|
|
9403
|
+
ok: false,
|
|
9404
|
+
error: "missing or empty 'reasoning' field"
|
|
9405
|
+
};
|
|
9406
|
+
return {
|
|
9407
|
+
ok: true,
|
|
9408
|
+
vote: {
|
|
9409
|
+
choice,
|
|
9410
|
+
confidence,
|
|
9411
|
+
reasoning,
|
|
9412
|
+
needMoreInfo: typeof obj.need_more_info === "string" && obj.need_more_info.length > 0 ? obj.need_more_info : void 0
|
|
9413
|
+
}
|
|
9414
|
+
};
|
|
9415
|
+
}
|
|
9416
|
+
function aggregateVotes(results) {
|
|
9417
|
+
const tally = /* @__PURE__ */ new Map();
|
|
9418
|
+
for (const r of results) {
|
|
9419
|
+
if (r.vote.choice === null) continue;
|
|
9420
|
+
const entry = tally.get(r.vote.choice) ?? {
|
|
9421
|
+
count: 0,
|
|
9422
|
+
sumConfidence: 0
|
|
9423
|
+
};
|
|
9424
|
+
entry.count++;
|
|
9425
|
+
entry.sumConfidence += r.vote.confidence;
|
|
9426
|
+
tally.set(r.vote.choice, entry);
|
|
9427
|
+
}
|
|
9428
|
+
let topChoice = null;
|
|
9429
|
+
let topCount = 0;
|
|
9430
|
+
let topSumConfidence = 0;
|
|
9431
|
+
for (const [choice, { count, sumConfidence }] of tally) if (count > topCount) {
|
|
9432
|
+
topChoice = choice;
|
|
9433
|
+
topCount = count;
|
|
9434
|
+
topSumConfidence = sumConfidence;
|
|
9435
|
+
}
|
|
9436
|
+
const total = STAND_IN_MODELS.length;
|
|
9437
|
+
if (topChoice && topCount === total) return {
|
|
9438
|
+
verdict: "consensus",
|
|
9439
|
+
winner: topChoice,
|
|
9440
|
+
meanConfidence: topSumConfidence / topCount
|
|
9441
|
+
};
|
|
9442
|
+
if (topChoice && topCount >= 2) return {
|
|
9443
|
+
verdict: "majority",
|
|
9444
|
+
winner: topChoice,
|
|
9445
|
+
meanConfidence: topSumConfidence / topCount
|
|
9446
|
+
};
|
|
9447
|
+
return {
|
|
9448
|
+
verdict: "split",
|
|
9449
|
+
winner: null,
|
|
9450
|
+
meanConfidence: 0
|
|
9451
|
+
};
|
|
9452
|
+
}
|
|
9453
|
+
function buildRound1UserText(input) {
|
|
9454
|
+
const lines = [];
|
|
9455
|
+
lines.push(`Decision: ${input.decision}`);
|
|
9456
|
+
lines.push("");
|
|
9457
|
+
lines.push("Options:");
|
|
9458
|
+
for (const opt of input.options) {
|
|
9459
|
+
const suffix = opt.detail ? ` — ${opt.detail}` : "";
|
|
9460
|
+
lines.push(`- ${opt.id}: ${opt.summary}${suffix}`);
|
|
9461
|
+
}
|
|
9462
|
+
if (input.context) {
|
|
9463
|
+
lines.push("");
|
|
9464
|
+
lines.push("Context:");
|
|
9465
|
+
lines.push(input.context);
|
|
9466
|
+
}
|
|
9467
|
+
return lines.join("\n");
|
|
9468
|
+
}
|
|
9469
|
+
function buildRound2UserTextBase(input, r1) {
|
|
9470
|
+
const base = buildRound1UserText(input);
|
|
9471
|
+
const summaries = ["", "Round 1 votes:"];
|
|
9472
|
+
for (const r of r1) if (isVote(r.vote)) {
|
|
9473
|
+
const choiceText = r.vote.choice === null ? "abstain" : r.vote.choice;
|
|
9474
|
+
const gapText = r.vote.needMoreInfo ? ` (needs: ${r.vote.needMoreInfo})` : "";
|
|
9475
|
+
summaries.push(`- ${r.key} picked ${choiceText}, confidence ${r.vote.confidence.toFixed(2)}, reasoning: ${r.vote.reasoning}${gapText}`);
|
|
9476
|
+
} else summaries.push(`- ${r.key} did not return a valid round-1 vote (${r.vote.error}).`);
|
|
9477
|
+
return base + "\n" + summaries.join("\n");
|
|
9478
|
+
}
|
|
9479
|
+
function isVote(v) {
|
|
9480
|
+
return !("error" in v);
|
|
9481
|
+
}
|
|
9482
|
+
function voteRecord(r1, r2) {
|
|
9483
|
+
const record = {};
|
|
9484
|
+
for (const cfg of STAND_IN_MODELS) {
|
|
9485
|
+
const r1Entry = r1.find((r) => r.key === cfg.key);
|
|
9486
|
+
const r2Entry = r2?.find((r) => r.key === cfg.key) ?? null;
|
|
9487
|
+
record[cfg.key] = {
|
|
9488
|
+
round1: r1Entry?.vote ?? {
|
|
9489
|
+
error: "upstream_error",
|
|
9490
|
+
message: "no round-1 result recorded"
|
|
9491
|
+
},
|
|
9492
|
+
round2: r2Entry ? r2Entry.vote : null
|
|
9493
|
+
};
|
|
9494
|
+
}
|
|
9495
|
+
return record;
|
|
9496
|
+
}
|
|
9497
|
+
function round2(n) {
|
|
9498
|
+
return Math.round(n * 100) / 100;
|
|
9499
|
+
}
|
|
9500
|
+
|
|
7983
9501
|
//#endregion
|
|
7984
9502
|
//#region src/lib/peer-mcp-personas.ts
|
|
7985
9503
|
/**
|
|
@@ -8526,7 +10044,56 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
|
|
|
8526
10044
|
signal
|
|
8527
10045
|
});
|
|
8528
10046
|
}
|
|
8529
|
-
}
|
|
10047
|
+
},
|
|
10048
|
+
{
|
|
10049
|
+
toolNameHttp: "stand_in",
|
|
10050
|
+
capability: "stand_in",
|
|
10051
|
+
description: "**Away-mode decision tiebreak.** Three-lab advisor (gpt-5.5 xhigh, opus-4.7 xhigh, gemini-3.1-pro high) for **when the user is unavailable and you are stuck between two or more concrete options**. Polls all three across two structured rounds (blind vote → informed re-vote with peer reasoning visible) and returns a ranked-choice verdict. Use when: you would otherwise halt and wait for the user. Do NOT use for: code review (use `peer-review-coordinator`), open-ended exploration, single-model second opinions (use `codex_critic` / `gemini_critic` / `opus_critic` directly), or as a substitute for user confirmation on irreversible actions (push, delete, drop, deploy — those still require the user even with three-lab consensus).",
|
|
10052
|
+
inputSchema: {
|
|
10053
|
+
type: "object",
|
|
10054
|
+
required: ["decision", "options"],
|
|
10055
|
+
additionalProperties: false,
|
|
10056
|
+
properties: {
|
|
10057
|
+
decision: {
|
|
10058
|
+
type: "string",
|
|
10059
|
+
description: "One-sentence framing of the choice the user would otherwise make. Be specific about what's being decided, not why."
|
|
10060
|
+
},
|
|
10061
|
+
options: {
|
|
10062
|
+
type: "array",
|
|
10063
|
+
minItems: 2,
|
|
10064
|
+
maxItems: 6,
|
|
10065
|
+
description: "2-6 concrete options for the panel to vote on. Caller-provided — do NOT ask the panel to generate options. The verdict cites the chosen option by `id`.",
|
|
10066
|
+
items: {
|
|
10067
|
+
type: "object",
|
|
10068
|
+
required: ["id", "summary"],
|
|
10069
|
+
additionalProperties: false,
|
|
10070
|
+
properties: {
|
|
10071
|
+
id: {
|
|
10072
|
+
type: "string",
|
|
10073
|
+
description: "Short stable identifier the verdict refers to (e.g., \"A\", \"lib-x\")."
|
|
10074
|
+
},
|
|
10075
|
+
summary: {
|
|
10076
|
+
type: "string",
|
|
10077
|
+
description: "One-line description of the option."
|
|
10078
|
+
},
|
|
10079
|
+
detail: {
|
|
10080
|
+
type: "string",
|
|
10081
|
+
description: "Optional longer context for the option (constraints, trade-offs)."
|
|
10082
|
+
}
|
|
10083
|
+
}
|
|
10084
|
+
}
|
|
10085
|
+
},
|
|
10086
|
+
context: {
|
|
10087
|
+
type: "string",
|
|
10088
|
+
description: "Task / code background that informs the decision. Keep tight — the input is capped at ~6KB total across decision + options + context."
|
|
10089
|
+
}
|
|
10090
|
+
}
|
|
10091
|
+
},
|
|
10092
|
+
async handler(args, signal) {
|
|
10093
|
+
return runStandInToolCall(args, signal);
|
|
10094
|
+
}
|
|
10095
|
+
},
|
|
10096
|
+
...BROWSER_TOOLS
|
|
8530
10097
|
]);
|
|
8531
10098
|
/**
|
|
8532
10099
|
* Shared closure body for the two worker MCP tools. Validates the
|
|
@@ -8609,6 +10176,109 @@ async function runWorkerToolCall(call) {
|
|
|
8609
10176
|
isError: result.isError
|
|
8610
10177
|
};
|
|
8611
10178
|
}
|
|
10179
|
+
/**
|
|
10180
|
+
* Shared closure body for the `stand_in` MCP tool. Validates the input
|
|
10181
|
+
* shape ({decision, options, context}) then calls `runStandIn`. The
|
|
10182
|
+
* orchestrator never throws — failure modes (upstream errors, parse
|
|
10183
|
+
* failures, abstains) all surface inside the structured `StandInResult`
|
|
10184
|
+
* envelope, which we JSON-stringify into the single MCP text block.
|
|
10185
|
+
*
|
|
10186
|
+
* Arg-validation policy mirrors `runWorkerToolCall` and `web_search`:
|
|
10187
|
+
* shape errors surface as `isError: true` tool-result envelopes (NOT
|
|
10188
|
+
* JSON-RPC -32602). The `tools/list` JSON schema documents required
|
|
10189
|
+
* fields; this runtime check is defense against a schema-ignoring
|
|
10190
|
+
* client.
|
|
10191
|
+
*
|
|
10192
|
+
* `isError` is FALSE for the no_consensus / need_more_info verdicts —
|
|
10193
|
+
* those are valid protocol outcomes the caller acts on, not errors.
|
|
10194
|
+
* `isError` is TRUE only for input-shape failures (bad arg types,
|
|
10195
|
+
* missing required fields).
|
|
10196
|
+
*/
|
|
10197
|
+
async function runStandInToolCall(args, signal) {
|
|
10198
|
+
const decision = typeof args.decision === "string" ? args.decision : "";
|
|
10199
|
+
if (!decision) return {
|
|
10200
|
+
content: [{
|
|
10201
|
+
type: "text",
|
|
10202
|
+
text: "stand_in: arguments.decision is required (non-empty string)"
|
|
10203
|
+
}],
|
|
10204
|
+
isError: true
|
|
10205
|
+
};
|
|
10206
|
+
const optionsRaw = args.options;
|
|
10207
|
+
if (!Array.isArray(optionsRaw)) return {
|
|
10208
|
+
content: [{
|
|
10209
|
+
type: "text",
|
|
10210
|
+
text: "stand_in: arguments.options must be an array (2-6 entries)"
|
|
10211
|
+
}],
|
|
10212
|
+
isError: true
|
|
10213
|
+
};
|
|
10214
|
+
if (optionsRaw.length < 2 || optionsRaw.length > 6) return {
|
|
10215
|
+
content: [{
|
|
10216
|
+
type: "text",
|
|
10217
|
+
text: `stand_in: arguments.options must contain 2-6 entries; got ${optionsRaw.length}`
|
|
10218
|
+
}],
|
|
10219
|
+
isError: true
|
|
10220
|
+
};
|
|
10221
|
+
const options = [];
|
|
10222
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
10223
|
+
for (let i = 0; i < optionsRaw.length; i++) {
|
|
10224
|
+
const entry = optionsRaw[i];
|
|
10225
|
+
if (typeof entry !== "object" || entry === null) return {
|
|
10226
|
+
content: [{
|
|
10227
|
+
type: "text",
|
|
10228
|
+
text: `stand_in: arguments.options[${i}] must be an object`
|
|
10229
|
+
}],
|
|
10230
|
+
isError: true
|
|
10231
|
+
};
|
|
10232
|
+
const e = entry;
|
|
10233
|
+
const id = typeof e.id === "string" ? e.id : "";
|
|
10234
|
+
const summary = typeof e.summary === "string" ? e.summary : "";
|
|
10235
|
+
if (!id) return {
|
|
10236
|
+
content: [{
|
|
10237
|
+
type: "text",
|
|
10238
|
+
text: `stand_in: arguments.options[${i}].id is required (non-empty string)`
|
|
10239
|
+
}],
|
|
10240
|
+
isError: true
|
|
10241
|
+
};
|
|
10242
|
+
if (!summary) return {
|
|
10243
|
+
content: [{
|
|
10244
|
+
type: "text",
|
|
10245
|
+
text: `stand_in: arguments.options[${i}].summary is required (non-empty string)`
|
|
10246
|
+
}],
|
|
10247
|
+
isError: true
|
|
10248
|
+
};
|
|
10249
|
+
if (seenIds.has(id)) return {
|
|
10250
|
+
content: [{
|
|
10251
|
+
type: "text",
|
|
10252
|
+
text: `stand_in: arguments.options[${i}].id="${id}" is duplicated; ids must be unique`
|
|
10253
|
+
}],
|
|
10254
|
+
isError: true
|
|
10255
|
+
};
|
|
10256
|
+
seenIds.add(id);
|
|
10257
|
+
const detail = typeof e.detail === "string" && e.detail.length > 0 ? e.detail : void 0;
|
|
10258
|
+
options.push({
|
|
10259
|
+
id,
|
|
10260
|
+
summary,
|
|
10261
|
+
detail
|
|
10262
|
+
});
|
|
10263
|
+
}
|
|
10264
|
+
const context = args.context === void 0 ? void 0 : typeof args.context === "string" ? args.context : null;
|
|
10265
|
+
if (context === null) return {
|
|
10266
|
+
content: [{
|
|
10267
|
+
type: "text",
|
|
10268
|
+
text: "stand_in: arguments.context must be a string when provided"
|
|
10269
|
+
}],
|
|
10270
|
+
isError: true
|
|
10271
|
+
};
|
|
10272
|
+
const result = await runStandIn({
|
|
10273
|
+
decision,
|
|
10274
|
+
options,
|
|
10275
|
+
context
|
|
10276
|
+
}, signal);
|
|
10277
|
+
return { content: [{
|
|
10278
|
+
type: "text",
|
|
10279
|
+
text: JSON.stringify(result)
|
|
10280
|
+
}] };
|
|
10281
|
+
}
|
|
8612
10282
|
|
|
8613
10283
|
//#endregion
|
|
8614
10284
|
//#region src/lib/codex-mcp-config.ts
|
|
@@ -9194,7 +10864,7 @@ function initProxyFromEnv() {
|
|
|
9194
10864
|
//#endregion
|
|
9195
10865
|
//#region package.json
|
|
9196
10866
|
var name = "github-router";
|
|
9197
|
-
var version = "0.3.
|
|
10867
|
+
var version = "0.3.32";
|
|
9198
10868
|
|
|
9199
10869
|
//#endregion
|
|
9200
10870
|
//#region src/lib/approval.ts
|
|
@@ -9411,8 +11081,8 @@ const calculateTokens = (messages, encoder, constants) => {
|
|
|
9411
11081
|
*/
|
|
9412
11082
|
const getEncodeChatFunction = async (encoding) => {
|
|
9413
11083
|
if (encodingCache.has(encoding)) {
|
|
9414
|
-
const cached = encodingCache.get(encoding);
|
|
9415
|
-
if (cached) return cached;
|
|
11084
|
+
const cached$1 = encodingCache.get(encoding);
|
|
11085
|
+
if (cached$1) return cached$1;
|
|
9416
11086
|
}
|
|
9417
11087
|
const supportedEncoding = encoding;
|
|
9418
11088
|
if (!(supportedEncoding in ENCODING_MAP)) {
|
|
@@ -11071,6 +12741,7 @@ async function setupAndServe(options) {
|
|
|
11071
12741
|
state.rateLimitWait = options.rateLimitWait;
|
|
11072
12742
|
state.showToken = options.showToken;
|
|
11073
12743
|
state.extendedBetas = options.extendedBetas;
|
|
12744
|
+
state.browseEnabled = options.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1";
|
|
11074
12745
|
if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
|
|
11075
12746
|
await ensurePaths();
|
|
11076
12747
|
await cacheVSCodeVersion();
|
|
@@ -11173,6 +12844,11 @@ const sharedServerArgs = {
|
|
|
11173
12844
|
type: "boolean",
|
|
11174
12845
|
default: false,
|
|
11175
12846
|
description: "Forward extended beta headers for Claude CLI compatibility (default: VS Code-only)"
|
|
12847
|
+
},
|
|
12848
|
+
browse: {
|
|
12849
|
+
type: "boolean",
|
|
12850
|
+
default: false,
|
|
12851
|
+
description: "Enable the browser-control MCP tools (browser_open_tab, browser_screenshot, browser_click, etc.) on /mcp. Requires Chrome or Edge installed; the bundled extension must be loaded on first tool call (the proxy returns install_required with Web Store URLs + a Load Unpacked fallback path). Off by default; can also be enabled with GH_ROUTER_ENABLE_BROWSE=1."
|
|
11176
12852
|
}
|
|
11177
12853
|
};
|
|
11178
12854
|
const allowedAccountTypes = new Set([
|
|
@@ -11209,7 +12885,8 @@ function parseSharedArgs(args) {
|
|
|
11209
12885
|
githubToken,
|
|
11210
12886
|
showToken: args["show-token"],
|
|
11211
12887
|
proxyEnv: args["proxy-env"],
|
|
11212
|
-
extendedBetas: args["extended-betas"]
|
|
12888
|
+
extendedBetas: args["extended-betas"],
|
|
12889
|
+
browseEnabled: args.browse
|
|
11213
12890
|
};
|
|
11214
12891
|
}
|
|
11215
12892
|
/**
|
|
@@ -11606,8 +13283,8 @@ const debug = defineCommand({
|
|
|
11606
13283
|
//#endregion
|
|
11607
13284
|
//#region src/lib/shell.ts
|
|
11608
13285
|
function getShell() {
|
|
11609
|
-
const { platform, env } = process$1;
|
|
11610
|
-
if (platform === "win32") {
|
|
13286
|
+
const { platform: platform$1, env } = process$1;
|
|
13287
|
+
if (platform$1 === "win32") {
|
|
11611
13288
|
if (env.SHELL) {
|
|
11612
13289
|
if (env.SHELL.endsWith("zsh")) return "zsh";
|
|
11613
13290
|
if (env.SHELL.endsWith("fish")) return "fish";
|