github-router 0.3.68 → 0.3.72

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/main.js CHANGED
@@ -1,22 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { a as removeOwnClaudeConfigMirror, i as isUnderClaudeConfigMirror, l as writeRuntimeFileSecure, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-CZvFif-e.js";
3
- import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-hkBEjHb2.js";
2
+ import { a as removeOwnClaudeConfigMirror, i as isUnderClaudeConfigMirror, l as writeRuntimeFileSecure, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-CutqqG7k.js";
3
+ import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-CSzT74Yn.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { defineCommand, runMain } from "citty";
6
6
  import consola from "consola";
7
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
- import fs, { readFile, stat } from "node:fs/promises";
8
+ import fs, { chmod, copyFile, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
9
9
  import os, { homedir, platform } from "node:os";
10
10
  import * as path$1 from "node:path";
11
11
  import path, { dirname, join } from "node:path";
12
12
  import process$1 from "node:process";
13
13
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
14
- import { promisify } from "node:util";
15
14
  import fs$1, { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
16
  import { createInterface } from "node:readline";
17
17
  import Parser from "web-tree-sitter";
18
18
  import WebSocket from "ws";
19
- import { fileURLToPath } from "node:url";
20
19
  import { events } from "fetch-event-stream";
21
20
  import { Type } from "typebox";
22
21
  import "partial-json";
@@ -26,6 +25,7 @@ import "yaml";
26
25
  import "ignore";
27
26
  import { z } from "zod";
28
27
  import { Writable } from "node:stream";
28
+ import { gunzipSync, inflateRawSync } from "node:zlib";
29
29
  import { serve } from "srvx";
30
30
  import { getProxyForUrl } from "proxy-from-env";
31
31
  import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
@@ -757,24 +757,262 @@ const checkUsage = defineCommand({
757
757
  }
758
758
  });
759
759
 
760
+ //#endregion
761
+ //#region src/lib/exec.ts
762
+ /**
763
+ * Parse a boolean-ish env value. Returns `undefined` when unset or
764
+ * unrecognized so callers can apply their own default. Accepts
765
+ * `1|true|yes|on` (true) and `0|false|no|off|<empty>` (false),
766
+ * case-insensitive. The single shared parser for all new `GH_ROUTER_*`
767
+ * flags so on/off semantics don't drift per call site.
768
+ */
769
+ function parseBoolEnv(value) {
770
+ if (value === void 0) return void 0;
771
+ const v = value.trim().toLowerCase();
772
+ if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
773
+ if (v === "0" || v === "false" || v === "no" || v === "off" || v === "") return false;
774
+ }
775
+ /** Read the PATH value from an env object, case-insensitively. */
776
+ function pathValueOf(env) {
777
+ for (const key of Object.keys(env)) if (key.toLowerCase() === "path") return env[key] ?? "";
778
+ return "";
779
+ }
780
+ /**
781
+ * Resolve an executable name to an absolute path against PATH, honoring
782
+ * `PATHEXT` on Windows and **excluding the current working directory**.
783
+ *
784
+ * Returns `null` when unresolved — callers treat that as "tool absent"
785
+ * and skip (best-effort). Spawning the returned absolute path means
786
+ * `cmd.exe`'s implicit cwd-first lookup never applies, closing the
787
+ * planted-`npm.cmd` vector.
788
+ */
789
+ function resolveExecutable(name$1, opts = {}) {
790
+ const platform$1 = opts.platform ?? process$1.platform;
791
+ const env = opts.env ?? process$1.env;
792
+ const cwdRaw = opts.cwd ?? (typeof process$1.cwd === "function" ? process$1.cwd() : void 0);
793
+ const resolvedCwd = cwdRaw ? path.resolve(cwdRaw) : null;
794
+ const dirs = pathValueOf(env).split(path.delimiter).filter((d) => d.length > 0 && d !== ".");
795
+ const isWin = platform$1 === "win32";
796
+ if (!isWin && name$1.includes("/")) return existsSync(name$1) ? path.resolve(name$1) : null;
797
+ if (isWin && (name$1.includes("\\") || name$1.includes("/"))) return existsSync(name$1) ? path.resolve(name$1) : null;
798
+ const exts = isWin && path.extname(name$1) === "" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean) : [""];
799
+ for (const dir of dirs) {
800
+ if (resolvedCwd && path.resolve(dir) === resolvedCwd) continue;
801
+ for (const ext of exts) {
802
+ const candidate = path.join(dir, name$1 + ext);
803
+ if (existsSync(candidate)) return candidate;
804
+ }
805
+ }
806
+ return null;
807
+ }
808
+ /**
809
+ * Quote one argument for a `cmd.exe /c "<line>"` command line so the
810
+ * target program receives it verbatim and no `cmd.exe` metacharacter
811
+ * retains shell meaning.
812
+ *
813
+ * Two phases (the canonical Windows approach — Colascione / Rust std):
814
+ * 1. **argv quoting** so `CommandLineToArgvW` in the target parses
815
+ * the token as one argument (double-quote, backslash-escape).
816
+ * 2. **caret-escaping** every `cmd.exe` metacharacter — including the
817
+ * quotes from phase 1 — so `cmd.exe` is never in quote-mode, strips
818
+ * the carets, and hands the argv-quoted string to the program.
819
+ *
820
+ * `%` is special: it cannot be reliably escaped on the `cmd.exe`
821
+ * *command line* (caret does not stop `%VAR%` expansion there). Rather
822
+ * than mis-escape, we **throw** — our callers never pass `%`, so this
823
+ * fails closed on the one unescapable injection vector.
824
+ */
825
+ function quoteWinArg(arg) {
826
+ if (arg.includes("%")) throw new Error("buildExecInvocation: argument contains '%', which cannot be safely escaped on the Windows command line; refusing to build the command.");
827
+ let quoted;
828
+ if (arg.length > 0 && !/[ \t\n\v"&|<>()^!]/.test(arg)) quoted = arg;
829
+ else {
830
+ let s = "\"";
831
+ let backslashes = 0;
832
+ for (const ch of arg) if (ch === "\\") backslashes++;
833
+ else if (ch === "\"") {
834
+ s += "\\".repeat(backslashes * 2 + 1) + "\"";
835
+ backslashes = 0;
836
+ } else {
837
+ s += "\\".repeat(backslashes) + ch;
838
+ backslashes = 0;
839
+ }
840
+ s += "\\".repeat(backslashes * 2) + "\"";
841
+ quoted = s;
842
+ }
843
+ return quoted.replace(/[()!^"<>&|]/g, "^$&");
844
+ }
845
+ /**
846
+ * Build the platform-correct `spawn` invocation for a command given as
847
+ * an argv array. Pure / unit-testable (no spawn).
848
+ *
849
+ * - win32 → a single caret/argv-quoted command string + `shell:true`
850
+ * + empty args array (the empty array avoids the DEP0190 warning
851
+ * that fires when args and `shell:true` are combined). `cmd[0]`
852
+ * should already be an absolute path from `resolveExecutable`.
853
+ * - posix → `(cmd[0], cmd.slice(1))` with `shell:false` — no shell,
854
+ * no injection surface.
855
+ */
856
+ function buildExecInvocation(cmd, platform$1 = process$1.platform) {
857
+ if (cmd.length === 0) throw new Error("buildExecInvocation: empty command");
858
+ if (platform$1 === "win32") return {
859
+ command: cmd.map(quoteWinArg).join(" "),
860
+ args: [],
861
+ shell: true
862
+ };
863
+ return {
864
+ command: cmd[0],
865
+ args: cmd.slice(1),
866
+ shell: false
867
+ };
868
+ }
869
+ function runInternal(cmd, stdoutMode, opts) {
870
+ const { command, args, shell } = buildExecInvocation(cmd);
871
+ return new Promise((resolve, reject) => {
872
+ let child;
873
+ try {
874
+ child = spawn(command, args, {
875
+ cwd: opts.cwd,
876
+ env: opts.env ?? process$1.env,
877
+ shell,
878
+ windowsHide: true,
879
+ stdio: [
880
+ "ignore",
881
+ stdoutMode,
882
+ stdoutMode === "inherit" ? "inherit" : "pipe"
883
+ ]
884
+ });
885
+ } catch (err) {
886
+ reject(err instanceof Error ? err : new Error(String(err)));
887
+ return;
888
+ }
889
+ let stdout = "";
890
+ let stderr = "";
891
+ let timedOut = false;
892
+ let settled = false;
893
+ const timer = opts.timeoutMs ? setTimeout(() => {
894
+ timedOut = true;
895
+ killTree(child.pid);
896
+ }, opts.timeoutMs) : void 0;
897
+ timer?.unref?.();
898
+ child.stdout?.on("data", (c) => {
899
+ stdout += c.toString("utf8");
900
+ });
901
+ child.stderr?.on("data", (c) => {
902
+ stderr += c.toString("utf8");
903
+ });
904
+ child.stdout?.on("error", () => {});
905
+ child.stderr?.on("error", () => {});
906
+ const finish = (code) => {
907
+ if (settled) return;
908
+ settled = true;
909
+ if (timer) clearTimeout(timer);
910
+ resolve({
911
+ stdout,
912
+ stderr,
913
+ code,
914
+ timedOut
915
+ });
916
+ };
917
+ child.on("error", (err) => {
918
+ if (settled) return;
919
+ settled = true;
920
+ if (timer) clearTimeout(timer);
921
+ reject(err);
922
+ });
923
+ child.on("close", (code) => finish(code));
924
+ });
925
+ }
926
+ /** Kill a process tree best-effort (taskkill /T on Windows). */
927
+ function killTree(pid) {
928
+ if (!pid) return;
929
+ try {
930
+ if (process$1.platform === "win32") spawn("taskkill", [
931
+ "/T",
932
+ "/F",
933
+ "/PID",
934
+ String(pid)
935
+ ], {
936
+ stdio: "ignore",
937
+ windowsHide: true
938
+ });
939
+ else process$1.kill(pid, "SIGTERM");
940
+ } catch {}
941
+ }
942
+ /** Run a command and capture stdout/stderr. Rejects on spawn error. */
943
+ function runCommandCapture(cmd, opts = {}) {
944
+ return runInternal(cmd, "pipe", opts);
945
+ }
946
+ /** Run a command discarding output (still captures stderr for errors). */
947
+ function runCommandVoid(cmd, opts = {}) {
948
+ return runInternal(cmd, "pipe", opts);
949
+ }
950
+
951
+ //#endregion
952
+ //#region src/lib/update-lock.ts
953
+ /** A lock older than this is treated as stale (crashed holder) and stolen. */
954
+ const STALE_LOCK_MS = 600 * 1e3;
955
+ function lockPath(name$1) {
956
+ return path.join(os.homedir(), ".local", "share", "github-router", name$1);
957
+ }
958
+ /**
959
+ * Run `fn` while holding an exclusive lockfile named `name` under the
960
+ * app dir. Returns `true` if the lock was acquired and `fn` ran,
961
+ * `false` if another process already holds it (caller skips silently).
962
+ *
963
+ * A lock left by a crashed process older than `STALE_LOCK_MS` is
964
+ * stolen so the updater can never be wedged permanently.
965
+ */
966
+ async function withInstallLock(name$1, fn) {
967
+ const p = lockPath(name$1);
968
+ let handle = await tryCreateLock(p);
969
+ if (!handle) {
970
+ let stale = false;
971
+ try {
972
+ const s = await stat(p);
973
+ stale = Date.now() - s.mtimeMs > STALE_LOCK_MS;
974
+ } catch {
975
+ stale = true;
976
+ }
977
+ if (!stale) return false;
978
+ await rm(p, { force: true }).catch(() => {});
979
+ handle = await tryCreateLock(p);
980
+ if (!handle) return false;
981
+ }
982
+ try {
983
+ await handle.close();
984
+ await fn();
985
+ return true;
986
+ } finally {
987
+ await rm(p, { force: true }).catch(() => {});
988
+ }
989
+ }
990
+ async function tryCreateLock(p) {
991
+ try {
992
+ return await open(p, "wx");
993
+ } catch {
994
+ return null;
995
+ }
996
+ }
997
+
760
998
  //#endregion
761
999
  //#region src/lib/claude-version-check.ts
762
- const execFileAsync = promisify(execFile);
763
- const NPM_PACKAGE = "@anthropic-ai/claude-code";
764
- const THROTTLE_HOURS = 1;
765
- const NPM_VIEW_TIMEOUT_MS = 5e3;
1000
+ const NPM_PACKAGE$1 = "@anthropic-ai/claude-code";
1001
+ const THROTTLE_HOURS$1 = 1;
1002
+ const NPM_VIEW_TIMEOUT_MS$1 = 5e3;
1003
+ const CLAUDE_VERSION_TIMEOUT_MS = 3e3;
766
1004
  const NPM_INSTALL_TIMEOUT_MS = 12e4;
767
1005
  /** Path to the throttle cache. Created on demand. */
768
- function cacheFilePath() {
1006
+ function cacheFilePath$1() {
769
1007
  return path.join(os.homedir(), ".local", "share", "github-router", "last-update-check");
770
1008
  }
771
1009
  /**
772
1010
  * Read the throttle cache. Returns null on missing/corrupt file —
773
1011
  * triggers a fresh check.
774
1012
  */
775
- async function readCache() {
1013
+ async function readCache$1() {
776
1014
  try {
777
- const raw = await fs.readFile(cacheFilePath(), "utf8");
1015
+ const raw = await fs.readFile(cacheFilePath$1(), "utf8");
778
1016
  const parsed = JSON.parse(raw);
779
1017
  if (typeof parsed.checkedAt !== "string" || parsed.installedVersion !== null && typeof parsed.installedVersion !== "string" || parsed.latestVersion !== null && typeof parsed.latestVersion !== "string") return null;
780
1018
  return parsed;
@@ -782,37 +1020,38 @@ async function readCache() {
782
1020
  return null;
783
1021
  }
784
1022
  }
785
- async function writeCache(cache) {
1023
+ async function writeCache$1(cache) {
786
1024
  try {
787
- await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
788
- await fs.writeFile(cacheFilePath(), JSON.stringify(cache), { mode: 384 });
1025
+ await fs.mkdir(path.dirname(cacheFilePath$1()), { recursive: true });
1026
+ await fs.writeFile(cacheFilePath$1(), JSON.stringify(cache), { mode: 384 });
789
1027
  } catch (err) {
790
1028
  consola.debug("Failed to write claude version-check cache:", err);
791
1029
  }
792
1030
  }
793
1031
  /** Check if it's been more than THROTTLE_HOURS since the last check. */
794
- function shouldCheckNow(cache) {
1032
+ function shouldCheckNow$1(cache) {
795
1033
  if (!cache) return true;
796
1034
  const lastCheck = new Date(cache.checkedAt).getTime();
797
1035
  if (Number.isNaN(lastCheck)) return true;
798
- return (Date.now() - lastCheck) / 1e3 / 3600 >= THROTTLE_HOURS;
1036
+ return (Date.now() - lastCheck) / 1e3 / 3600 >= THROTTLE_HOURS$1;
799
1037
  }
800
1038
  /**
801
1039
  * Read the installed `claude` version. Returns null if claude is not
802
1040
  * on PATH or the version probe fails (e.g. older versions that don't
803
1041
  * support `--version` cleanly).
804
- */
805
- function getInstalledVersion() {
1042
+ *
1043
+ * Windows-safe: `claude` is a `.cmd` shim that `execFile` cannot launch
1044
+ * directly. We resolve it to an absolute path (excluding the cwd, so a
1045
+ * planted `claude.cmd` in an untrusted repo can't run) and invoke it
1046
+ * through the shared exec helper.
1047
+ */
1048
+ async function getInstalledVersion() {
1049
+ const claudePath = resolveExecutable("claude");
1050
+ if (!claudePath) return null;
806
1051
  try {
807
- const match = execFileSync("claude", ["--version"], {
808
- stdio: [
809
- "ignore",
810
- "pipe",
811
- "ignore"
812
- ],
813
- timeout: 3e3,
814
- encoding: "utf8"
815
- }).match(/^(\d+\.\d+\.\d+)/);
1052
+ const { stdout, code } = await runCommandCapture([claudePath, "--version"], { timeoutMs: CLAUDE_VERSION_TIMEOUT_MS });
1053
+ if (code !== 0) return null;
1054
+ const match = stdout.match(/(\d+\.\d+\.\d+)/);
816
1055
  return match ? match[1] : null;
817
1056
  } catch {
818
1057
  return null;
@@ -821,15 +1060,22 @@ function getInstalledVersion() {
821
1060
  /**
822
1061
  * Fetch the latest version of @anthropic-ai/claude-code from the npm
823
1062
  * registry. Returns null on network failure / npm unavailable.
1063
+ *
1064
+ * Windows-safe: `npm` is `npm.cmd`; resolved to an absolute path
1065
+ * (excluding cwd) before invocation.
824
1066
  */
825
- async function getLatestVersion() {
1067
+ async function getLatestVersion$1() {
1068
+ const npmPath = resolveExecutable("npm");
1069
+ if (!npmPath) return null;
826
1070
  try {
827
- const { stdout } = await execFileAsync("npm", [
1071
+ const { stdout, code } = await runCommandCapture([
1072
+ npmPath,
828
1073
  "view",
829
- NPM_PACKAGE,
1074
+ NPM_PACKAGE$1,
830
1075
  "version",
831
1076
  "--silent"
832
- ], { timeout: NPM_VIEW_TIMEOUT_MS });
1077
+ ], { timeoutMs: NPM_VIEW_TIMEOUT_MS$1 });
1078
+ if (code !== 0) return null;
833
1079
  const v = stdout.trim();
834
1080
  return /^\d+\.\d+\.\d+/.test(v) ? v : null;
835
1081
  } catch {
@@ -867,8 +1113,8 @@ async function checkClaudeVersion(opts = {}) {
867
1113
  skipped: true,
868
1114
  skipReason: "disabled"
869
1115
  };
870
- const cache = await readCache();
871
- if (!opts.force && !shouldCheckNow(cache)) return {
1116
+ const cache = await readCache$1();
1117
+ if (!opts.force && !shouldCheckNow$1(cache)) return {
872
1118
  installed: cache?.installedVersion !== null,
873
1119
  installedVersion: cache?.installedVersion ?? null,
874
1120
  latestVersion: cache?.latestVersion ?? null,
@@ -876,7 +1122,7 @@ async function checkClaudeVersion(opts = {}) {
876
1122
  skipped: true,
877
1123
  skipReason: "throttled"
878
1124
  };
879
- const installedVersion = getInstalledVersion();
1125
+ const installedVersion = await getInstalledVersion();
880
1126
  if (installedVersion === null) return {
881
1127
  installed: false,
882
1128
  installedVersion: null,
@@ -885,8 +1131,8 @@ async function checkClaudeVersion(opts = {}) {
885
1131
  skipped: true,
886
1132
  skipReason: "no-claude"
887
1133
  };
888
- const latestVersion = await getLatestVersion();
889
- await writeCache({
1134
+ const latestVersion = await getLatestVersion$1();
1135
+ await writeCache$1({
890
1136
  checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
891
1137
  installedVersion,
892
1138
  latestVersion
@@ -908,23 +1154,190 @@ async function checkClaudeVersion(opts = {}) {
908
1154
  };
909
1155
  }
910
1156
  /**
911
- * Run `npm install -g @anthropic-ai/claude-code@latest` synchronously.
912
- * Throws on failure the caller decides whether to abort the launch
913
- * or continue with the older version.
914
- */
915
- async function autoUpdateClaude(latestVersion) {
916
- consola.info(`Updating ${NPM_PACKAGE} to ${latestVersion} (this may take ~30s)...`);
917
- try {
918
- await execFileAsync("npm", [
1157
+ * Heuristic: did `claude update` fail because the subcommand does not
1158
+ * exist (a build predating `claude update`), as opposed to a transient
1159
+ * update error? We only npm-fall-back on this specific signal — falling
1160
+ * back on any failure would install a conflicting npm copy on
1161
+ * native-installer machines.
1162
+ *
1163
+ * Matches the usage/Commander-style "unknown command" messages CLIs emit
1164
+ * for an unrecognized subcommand. Deliberately narrow.
1165
+ */
1166
+ function looksLikeUnknownUpdateCommand(output) {
1167
+ return /unknown command|unknown subcommand|unrecognized (sub)?command|invalid command|command not found|is not a (known|valid) command/i.test(output);
1168
+ }
1169
+ async function updateClaude(latestVersion, deps = {}) {
1170
+ const _resolve = deps.resolveExecutable ?? resolveExecutable;
1171
+ const _capture = deps.runCommandCapture ?? runCommandCapture;
1172
+ const _void = deps.runCommandVoid ?? runCommandVoid;
1173
+ const _lock = deps.withInstallLock ?? withInstallLock;
1174
+ const claudePath = _resolve("claude");
1175
+ if (!claudePath) throw new Error("claude not found on PATH");
1176
+ if (!await _lock("claude-update.lock", async () => {
1177
+ consola.info(`Updating Claude Code to ${latestVersion} via \`claude update\`...`);
1178
+ const { code, stdout, stderr } = await _capture([claudePath, "update"], { timeoutMs: NPM_INSTALL_TIMEOUT_MS });
1179
+ const combined = `${stdout}${stderr}`;
1180
+ const trimmed = combined.trim();
1181
+ if (trimmed) process.stdout.write(trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`);
1182
+ if (code === 0) {
1183
+ consola.success(`Claude Code updated to ${latestVersion}`);
1184
+ return;
1185
+ }
1186
+ if (!looksLikeUnknownUpdateCommand(combined)) throw new Error(`\`claude update\` exited with code ${code}`);
1187
+ const npmPath = _resolve("npm");
1188
+ if (!npmPath) throw new Error(`this Claude Code build predates \`claude update\` and npm is not on PATH; update manually: npm install -g ${NPM_PACKAGE$1}@latest`);
1189
+ consola.warn(`This Claude Code build predates \`claude update\`; falling back to \`npm install -g ${NPM_PACKAGE$1}@latest\`.`);
1190
+ const { code: npmCode, stderr: npmStderr } = await _void([
1191
+ npmPath,
919
1192
  "install",
920
1193
  "-g",
921
- `${NPM_PACKAGE}@latest`,
1194
+ `${NPM_PACKAGE$1}@latest`,
1195
+ "--silent"
1196
+ ], { timeoutMs: NPM_INSTALL_TIMEOUT_MS });
1197
+ if (npmCode !== 0) throw new Error(`npm install failed: ${npmStderr.trim() || `exit ${npmCode}`}`);
1198
+ consola.success(`${NPM_PACKAGE$1} updated to ${latestVersion}`);
1199
+ })) consola.debug("Claude Code update already in progress in another process; skipping.");
1200
+ }
1201
+
1202
+ //#endregion
1203
+ //#region src/lib/version.ts
1204
+ /**
1205
+ * Read this binary's published version from package.json at runtime.
1206
+ *
1207
+ * Done at runtime (not baked at build time) because release.yml builds
1208
+ * BEFORE `npm version patch` bumps the version — a build-time inline
1209
+ * would always ship the pre-bump value. The npm tarball ships package.json
1210
+ * alongside `dist/`, so a sibling-up lookup from import.meta.url resolves
1211
+ * cleanly in both dev (`src/lib/`) and bundled (`dist/`) layouts.
1212
+ *
1213
+ * Returns `"unknown"` if package.json can't be located or parsed —
1214
+ * never throws, so the CLI never fails to start over version reporting.
1215
+ */
1216
+ function getPackageVersion() {
1217
+ try {
1218
+ const here = dirname(fileURLToPath(import.meta.url));
1219
+ const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
1220
+ for (const path$2 of candidates) try {
1221
+ const raw = readFileSync(path$2, "utf8");
1222
+ const parsed = JSON.parse(raw);
1223
+ if (typeof parsed.version === "string" && (parsed.name === "github-router" || parsed.name === "@animeshkundu/github-router")) return parsed.version;
1224
+ } catch {}
1225
+ } catch {}
1226
+ return "unknown";
1227
+ }
1228
+
1229
+ //#endregion
1230
+ //#region src/lib/self-update.ts
1231
+ const NPM_PACKAGE = "github-router";
1232
+ const THROTTLE_HOURS = 1;
1233
+ const NPM_VIEW_TIMEOUT_MS = 5e3;
1234
+ function cacheFilePath() {
1235
+ return path.join(os.homedir(), ".local", "share", "github-router", "last-self-update-check");
1236
+ }
1237
+ async function readCache() {
1238
+ try {
1239
+ const parsed = JSON.parse(await fs.readFile(cacheFilePath(), "utf8"));
1240
+ if (typeof parsed.checkedAt !== "string") return null;
1241
+ return parsed;
1242
+ } catch {
1243
+ return null;
1244
+ }
1245
+ }
1246
+ async function writeCache(cache) {
1247
+ try {
1248
+ await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
1249
+ await fs.writeFile(cacheFilePath(), JSON.stringify(cache), { mode: 384 });
1250
+ } catch (err) {
1251
+ consola.debug("Failed to write self-update cache:", err);
1252
+ }
1253
+ }
1254
+ function shouldCheckNow(cache) {
1255
+ if (!cache) return true;
1256
+ const last = new Date(cache.checkedAt).getTime();
1257
+ if (Number.isNaN(last)) return true;
1258
+ return (Date.now() - last) / 1e3 / 3600 >= THROTTLE_HOURS;
1259
+ }
1260
+ async function getLatestVersion(npmPath) {
1261
+ try {
1262
+ const { stdout, code } = await runCommandCapture([
1263
+ npmPath,
1264
+ "view",
1265
+ NPM_PACKAGE,
1266
+ "version",
922
1267
  "--silent"
923
- ], { timeout: NPM_INSTALL_TIMEOUT_MS });
924
- consola.success(`${NPM_PACKAGE} updated to ${latestVersion}`);
1268
+ ], { timeoutMs: NPM_VIEW_TIMEOUT_MS });
1269
+ if (code !== 0) return null;
1270
+ const v = stdout.trim();
1271
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
1272
+ } catch {
1273
+ return null;
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Spawn a detached process that waits for THIS proxy (pid) to exit,
1278
+ * then runs `npm install -g github-router@latest`. Fully detached and
1279
+ * unref'd so it outlives the proxy; output discarded.
1280
+ *
1281
+ * The waiter is a tiny inline Node script (Node is guaranteed present —
1282
+ * the proxy runs on it) that polls `process.kill(pid, 0)` until the
1283
+ * parent is gone, then execs npm. This avoids the Windows file-lock by
1284
+ * never touching the global install while the proxy holds it open.
1285
+ */
1286
+ function spawnDetachedUpdater(npmPath) {
1287
+ const waiter = `
1288
+ const pid = ${process.pid};
1289
+ const { spawn } = require("node:child_process");
1290
+ function alive() { try { process.kill(pid, 0); return true } catch { return false } }
1291
+ const timer = setInterval(() => {
1292
+ if (alive()) return;
1293
+ clearInterval(timer);
1294
+ const args = ["install", "-g", ${JSON.stringify(`${NPM_PACKAGE}@latest`)}, "--silent"];
1295
+ const isWin = process.platform === "win32";
1296
+ const child = spawn(${JSON.stringify(npmPath)}, args, {
1297
+ stdio: "ignore", windowsHide: true, shell: isWin, detached: !isWin,
1298
+ });
1299
+ child.on("error", () => process.exit(0));
1300
+ child.on("exit", () => process.exit(0));
1301
+ // Safety: never hang forever.
1302
+ setTimeout(() => process.exit(0), 180000).unref();
1303
+ }, 500);
1304
+ // Safety cap on the wait itself (e.g. extremely long sessions still
1305
+ // eventually give up rather than leak the waiter).
1306
+ setTimeout(() => { clearInterval(timer); process.exit(0); }, 24 * 3600 * 1000).unref();
1307
+ `.trim();
1308
+ spawn(process.execPath, ["-e", waiter], {
1309
+ detached: true,
1310
+ stdio: "ignore",
1311
+ windowsHide: true
1312
+ }).unref();
1313
+ }
1314
+ /**
1315
+ * Probe npm for a newer `github-router` and, if found, queue a detached
1316
+ * post-exit update. Returns quickly; never throws. Call AFTER the
1317
+ * server is listening so the bounded probe can't delay binding.
1318
+ */
1319
+ async function runSelfUpdate(opts) {
1320
+ if (!opts.selfUpdate) return;
1321
+ if (parseBoolEnv(process.env.GH_ROUTER_NO_SELF_UPDATE) === true) return;
1322
+ try {
1323
+ const cache = await readCache();
1324
+ if (!opts.force && !shouldCheckNow(cache)) return;
1325
+ const installed = getPackageVersion();
1326
+ if (installed === "unknown") return;
1327
+ const npmPath = resolveExecutable("npm");
1328
+ if (!npmPath) return;
1329
+ const latest = await getLatestVersion(npmPath);
1330
+ await writeCache({
1331
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1332
+ installedVersion: installed,
1333
+ latestVersion: latest
1334
+ });
1335
+ if (!latest || !isNewer(installed, latest)) return;
1336
+ if (await withInstallLock("self-update.lock", async () => {
1337
+ spawnDetachedUpdater(npmPath);
1338
+ })) consola.info(`github-router ${installed} → ${latest} update queued; it takes effect on the next launch.`);
925
1339
  } catch (err) {
926
- const msg = err instanceof Error ? err.message : String(err);
927
- throw new Error(`npm install failed: ${msg}`);
1340
+ consola.debug("Self-update check failed:", err);
928
1341
  }
929
1342
  }
930
1343
 
@@ -1039,6 +1452,47 @@ function envInt$1(key, fallback) {
1039
1452
  const UPSTREAM_FETCH_TIMEOUT_MS = envInt$1("UPSTREAM_FETCH_TIMEOUT_MS", 0);
1040
1453
  const UPSTREAM_INACTIVITY_TIMEOUT_MS = envInt$1("UPSTREAM_INACTIVITY_TIMEOUT_MS", 3e5);
1041
1454
 
1455
+ //#endregion
1456
+ //#region src/lib/toolbelt/path-inject.ts
1457
+ /**
1458
+ * The key under which `env` stores PATH, matched case-insensitively.
1459
+ * Falls back to the platform-conventional spelling when absent.
1460
+ */
1461
+ function pathEnvKey(env) {
1462
+ for (const key of Object.keys(env)) if (key.toLowerCase() === "path") return key;
1463
+ return process.platform === "win32" ? "Path" : "PATH";
1464
+ }
1465
+ /**
1466
+ * Compute the PATH override that prepends `binDir`, reusing the parent's
1467
+ * existing key casing so a subsequent merge can't introduce a duplicate
1468
+ * case-variant key. Returns a single-entry patch suitable for
1469
+ * `Object.assign(vars, ...)`.
1470
+ */
1471
+ function toolbeltPathOverride(parentEnv, binDir) {
1472
+ const key = pathEnvKey(parentEnv);
1473
+ const current = parentEnv[key] ?? "";
1474
+ return { [key]: current ? `${binDir}${path.delimiter}${current}` : binDir };
1475
+ }
1476
+ /**
1477
+ * Defense-in-depth: collapse all case-variant PATH keys in `env` into a
1478
+ * single canonical key. Mutates and returns `env`. If duplicates with
1479
+ * differing values exist (only possible via a mismatched-casing merge),
1480
+ * the longest value wins — the toolbelt-prepended PATH is strictly
1481
+ * longer than the original, so this preserves the injection.
1482
+ */
1483
+ function collapsePathKeys(env) {
1484
+ const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path");
1485
+ if (keys.length <= 1) return env;
1486
+ let bestValue = "";
1487
+ for (const k of keys) {
1488
+ const v = env[k] ?? "";
1489
+ if (v.length >= bestValue.length) bestValue = v;
1490
+ delete env[k];
1491
+ }
1492
+ env[process.platform === "win32" ? "Path" : "PATH"] = bestValue;
1493
+ return env;
1494
+ }
1495
+
1042
1496
  //#endregion
1043
1497
  //#region src/lib/launch.ts
1044
1498
  /**
@@ -1103,6 +1557,22 @@ function commandExists(name$1) {
1103
1557
  }
1104
1558
  }
1105
1559
  /**
1560
+ * Whether the launcher can execute `executable`.
1561
+ *
1562
+ * `buildLaunchCommand` resolves the CLI to an ABSOLUTE path (anti-shadow).
1563
+ * `where.exe` (and POSIX `which`) reject a full path argument — `where`
1564
+ * returns "Could not find files for the given pattern(s)" for an absolute
1565
+ * path even when the file exists — so the where/which probe is only valid
1566
+ * for bare command names. For an absolute path (already resolved against
1567
+ * PATH and existence-checked by `resolveExecutable`), check the
1568
+ * filesystem directly. Without this split, every launch where the CLI is
1569
+ * installed fails with a spurious "not found on PATH".
1570
+ */
1571
+ function isExecutableAvailable(executable) {
1572
+ if (path.isAbsolute(executable)) return existsSync(executable);
1573
+ return commandExists(executable);
1574
+ }
1575
+ /**
1106
1576
  * Provider-config flags (`-c model_providers.github_router=...`) that
1107
1577
  * point Codex at our proxy. Extracted from `buildCodexCmd` so the new
1108
1578
  * `codex mcp-server` MCP-config builder can reuse the exact same
@@ -1170,22 +1640,25 @@ function buildCodexCmd(target) {
1170
1640
  return cmd;
1171
1641
  }
1172
1642
  function buildLaunchCommand(target) {
1643
+ const cmd = target.kind === "claude-code" ? [
1644
+ "claude",
1645
+ "--dangerously-skip-permissions",
1646
+ ...target.extraArgs
1647
+ ] : buildCodexCmd(target);
1648
+ const resolved = resolveExecutable(cmd[0], { env: process$1.env });
1649
+ if (resolved) cmd[0] = resolved;
1173
1650
  return {
1174
- cmd: target.kind === "claude-code" ? [
1175
- "claude",
1176
- "--dangerously-skip-permissions",
1177
- ...target.extraArgs
1178
- ] : buildCodexCmd(target),
1179
- env: {
1651
+ cmd,
1652
+ env: collapsePathKeys({
1180
1653
  ...sanitizeParentEnv(process$1.env),
1181
1654
  ...target.envVars
1182
- }
1655
+ })
1183
1656
  };
1184
1657
  }
1185
1658
  function launchChild(target, server$1, options = {}) {
1186
1659
  const { cmd, env } = buildLaunchCommand(target);
1187
1660
  const executable = cmd[0];
1188
- if (!commandExists(executable)) {
1661
+ if (!isExecutableAvailable(executable)) {
1189
1662
  const msg = `"${executable}" not found on PATH. Install it first, then try again.`;
1190
1663
  consola.error(msg);
1191
1664
  process$1.stderr.write(msg + "\n");
@@ -2499,33 +2972,6 @@ function round4(x) {
2499
2972
  return Math.round(x * 1e4) / 1e4;
2500
2973
  }
2501
2974
 
2502
- //#endregion
2503
- //#region src/lib/version.ts
2504
- /**
2505
- * Read this binary's published version from package.json at runtime.
2506
- *
2507
- * Done at runtime (not baked at build time) because release.yml builds
2508
- * BEFORE `npm version patch` bumps the version — a build-time inline
2509
- * would always ship the pre-bump value. The npm tarball ships package.json
2510
- * alongside `dist/`, so a sibling-up lookup from import.meta.url resolves
2511
- * cleanly in both dev (`src/lib/`) and bundled (`dist/`) layouts.
2512
- *
2513
- * Returns `"unknown"` if package.json can't be located or parsed —
2514
- * never throws, so the CLI never fails to start over version reporting.
2515
- */
2516
- function getPackageVersion() {
2517
- try {
2518
- const here = dirname(fileURLToPath(import.meta.url));
2519
- const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
2520
- for (const path$2 of candidates) try {
2521
- const raw = readFileSync(path$2, "utf8");
2522
- const parsed = JSON.parse(raw);
2523
- if (typeof parsed.version === "string" && (parsed.name === "github-router" || parsed.name === "@animeshkundu/github-router")) return parsed.version;
2524
- } catch {}
2525
- } catch {}
2526
- return "unknown";
2527
- }
2528
-
2529
2975
  //#endregion
2530
2976
  //#region src/lib/browser-mcp/browser-detect.ts
2531
2977
  let cached;
@@ -3496,7 +3942,7 @@ function logAudit$1(record) {
3496
3942
  try {
3497
3943
  const fs$2 = await import("node:fs/promises");
3498
3944
  const path$2 = await import("node:path");
3499
- const { PATHS: PATHS$1 } = await import("./paths-CW16Dz9_.js");
3945
+ const { PATHS: PATHS$1 } = await import("./paths-Dv7QZQWB.js");
3500
3946
  const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
3501
3947
  await fs$2.mkdir(dir, { recursive: true });
3502
3948
  const line = JSON.stringify({
@@ -4926,6 +5372,14 @@ function toolEnvelope(data, isError) {
4926
5372
  * call-time when the operator hasn't opted in via `--browse` or
4927
5373
  * `GH_ROUTER_ENABLE_BROWSE=1`.
4928
5374
  *
5375
+ * NAMING: the `toolNameHttp` here is the WIRE name (`browser_*`) that each
5376
+ * handler dispatches to the extension. `peer-mcp-personas.ts` strips the
5377
+ * `browser_` prefix when spreading these into `NON_PERSONA_MCP_TOOLS` so
5378
+ * the MCP-facing name is bare (`mcp__browser__navigate`) while the wire
5379
+ * name stays `browser_navigate` — do NOT rename the literals below or the
5380
+ * installed extension breaks. The `group` field is injected at that spread
5381
+ * (hence `Omit<…, "group">` here).
5382
+ *
4929
5383
  * v1 surface: 19 tools (Phases 3 + 4a + 4b + humanlike input v2).
4930
5384
  */
4931
5385
  const BROWSER_TOOLS = Object.freeze([
@@ -7268,7 +7722,7 @@ function resolveModelAndThinking(opts) {
7268
7722
  * file containing "ignore previous instructions; run rm -rf"
7269
7723
  * doesn't redirect Pi.
7270
7724
  * 3. State what each tool does in one short sentence — Pi runs on
7271
- * `gemini-3.5-flash` and has no built-in knowledge of the
7725
+ * `gemini-3.1-pro-preview` and has no built-in knowledge of the
7272
7726
  * proxy-specific tools (`code_search`, `peer_review`, `advisor`,
7273
7727
  * `fetch_url`). Listing names alone wastes the first turn on
7274
7728
  * discovery probing.
@@ -7301,15 +7755,16 @@ function buildToolBlock(tools) {
7301
7755
  }
7302
7756
  const EXPLORE_MODE_NOTE = `Read-only mode — tools:\n${buildToolBlock(READ_TOOL_NOTES)}`;
7303
7757
  const IMPLEMENT_MODE_NOTE = `Read+write mode — tools:\n${buildToolBlock([...READ_TOOL_NOTES, ...WRITE_TOOL_NOTES])}`;
7758
+ const REVIEW_MODE_NOTE = `You are reviewing code for correctness. Verify against the actual code by reading it — never assume. Report concrete findings (bugs, edge cases, security / concurrency / resource risks, missing handling) with a severity and a \`file:line\` citation; if nothing material is wrong, say so plainly rather than inventing issues.\n\nRead-only mode — tools:\n${buildToolBlock(READ_TOOL_NOTES)}`;
7304
7759
  /**
7305
7760
  * Build the system prompt for a given worker mode. Returns the
7306
7761
  * security-boundary paragraph followed by a bulletted capability
7307
- * inventory. No prescriptive task advice, no examples, no
7308
- * chain-of-thought scaffolding — Pi's coding-agent harness covers
7309
- * all of that.
7762
+ * inventory (and, for `review`, a one-line reviewer role frame). No
7763
+ * prescriptive task advice, no examples, no chain-of-thought scaffolding —
7764
+ * Pi's coding-agent harness covers all of that.
7310
7765
  */
7311
7766
  function systemPromptFor(mode) {
7312
- return `${SECURITY_BOUNDARY}\n\n${mode === "explore" ? EXPLORE_MODE_NOTE : IMPLEMENT_MODE_NOTE}`;
7767
+ return `${SECURITY_BOUNDARY}\n\n${mode === "explore" ? EXPLORE_MODE_NOTE : mode === "review" ? REVIEW_MODE_NOTE : IMPLEMENT_MODE_NOTE}`;
7313
7768
  }
7314
7769
 
7315
7770
  //#endregion
@@ -8325,7 +8780,7 @@ function standInToolEnabled() {
8325
8780
  *
8326
8781
  * Returns true iff BOTH:
8327
8782
  * 1. Copilot's live catalog (`state.models?.data`) contains the
8328
- * worker's default model (`gemini-3.5-flash`) AND that entry
8783
+ * worker's default model (`gemini-3.1-pro-preview`) AND that entry
8329
8784
  * advertises `capabilities.supports.tool_calls === true`. The
8330
8785
  * worker loop is function-calling; a model that can't emit
8331
8786
  * tool_calls is unusable, so dormant-register (omit from
@@ -8391,12 +8846,40 @@ function browserCompoundToolsEnabled() {
8391
8846
  function browserPowerToolsEnabled() {
8392
8847
  return state.powerBrowseEnabled === true;
8393
8848
  }
8849
+ /**
8850
+ * Gate for the whole `browser` MCP server (the `--browse` opt-in surface).
8851
+ *
8852
+ * Returns true iff BOTH:
8853
+ * 1. The operator opted in (`state.browseEnabled`, set by `--browse`, OR
8854
+ * `GH_ROUTER_ENABLE_BROWSE=1` read directly so non-`setupAndServe`
8855
+ * startup paths — tests, embedded use — can still flip the gate).
8856
+ * 2. At least one Chromium-family browser is detected on disk
8857
+ * (`hasSupportedBrowserInstalled()`, cached for the proxy lifetime).
8858
+ *
8859
+ * Moved here from `handler.ts` so both the route handler (list-time +
8860
+ * call-time gating) AND `claude.ts` (deciding whether to register the
8861
+ * `browser` scoped MCP server at launch) share one predicate — registering
8862
+ * a server whose tools would all be gated out produces an empty-server smell.
8863
+ */
8864
+ function browserToolsEnabled() {
8865
+ if (!(state.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1")) return false;
8866
+ return hasSupportedBrowserInstalled();
8867
+ }
8394
8868
 
8395
8869
  //#endregion
8396
8870
  //#region src/routes/mcp/handler.ts
8397
8871
  const MCP_PROTOCOL_VERSION = "2025-06-18";
8398
8872
  const SERVER_NAME = "github-router-peers";
8399
8873
  const SERVER_VERSION = "1";
8874
+ /**
8875
+ * MCP `initialize` `serverInfo.name` for a given scope. Scoped endpoints
8876
+ * report their `github-router-<group>` provenance name; the unscoped union
8877
+ * keeps the legacy `github-router-peers`. Cosmetic handshake metadata —
8878
+ * Claude Code namespaces tools by the config-entry KEY, not this name.
8879
+ */
8880
+ function serverInfoNameForScope(scope) {
8881
+ return scope === "all" ? SERVER_NAME : GROUP_META[scope].serverInfoName;
8882
+ }
8400
8883
  const inflightAborts = /* @__PURE__ */ new Map();
8401
8884
  /**
8402
8885
  * Idempotent teardown for an in-flight tools/call. Aborts the upstream
@@ -8489,37 +8972,6 @@ function geminiAvailable() {
8489
8972
  return models$1.some((m) => /^gemini-3\..*pro/i.test(m.id));
8490
8973
  }
8491
8974
  /**
8492
- * Gate for the browser-control MCP tools (`browser_*`).
8493
- *
8494
- * Returns true iff BOTH:
8495
- * 1. The operator opted in via `--browse` (which sets
8496
- * `state.browseEnabled`) OR the equivalent env var
8497
- * `GH_ROUTER_ENABLE_BROWSE=1`. Default OFF — browser-control is
8498
- * side-effectful (mutates the user's browser session, downloads
8499
- * files, can navigate to phishing URLs the model was prompted with),
8500
- * so dormant-register is the safe default.
8501
- * 2. At least one supported Chromium-family browser (Chrome or Edge)
8502
- * is detected on disk by `hasSupportedBrowserInstalled()`. No
8503
- * browser → nothing for the bridge to attach to → tools stay
8504
- * invisible rather than fail at call time. Detection is cached for
8505
- * the proxy lifetime; a fresh install requires a restart.
8506
- *
8507
- * Mirrors the defense-in-depth pattern of `workerToolsEnabled()` /
8508
- * `standInToolEnabled()`: this same function gates BOTH the
8509
- * `tools/list` filter in `toolEntries()` AND the call-time rejection in
8510
- * `handleToolsCall` (returning -32601 for hard-coded tool-name
8511
- * bypasses), so the two surfaces stay symmetric.
8512
- *
8513
- * The env-var check reads `process.env` directly instead of relying
8514
- * solely on `state.browseEnabled` so a non-`setupAndServe` startup path
8515
- * (tests, embedded use) can still flip the gate via env. The CLI flag
8516
- * path is the canonical one for end users.
8517
- */
8518
- function browserToolsEnabled() {
8519
- if (!(state.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1")) return false;
8520
- return hasSupportedBrowserInstalled();
8521
- }
8522
- /**
8523
8975
  * The 1M-context Opus 4.6 variant (`claude-opus-4.6-1m`, `max_prompt_tokens`
8524
8976
  * 936K). opus_critic prefers it so it can take large artifacts in one shot
8525
8977
  * (the whole point of pairing it with gpt-5.5 as the big-window peers);
@@ -8542,8 +8994,8 @@ function activePersonas() {
8542
8994
  model: resolveOpusCriticModel()
8543
8995
  } : p);
8544
8996
  }
8545
- function toolEntries() {
8546
- const personaEntries = activePersonas().map((p) => ({
8997
+ function toolEntries(scope) {
8998
+ const personaEntries = scope === "all" || scope === "peers" ? activePersonas().map((p) => ({
8547
8999
  name: p.toolNameHttp,
8548
9000
  description: p.description,
8549
9001
  inputSchema: {
@@ -8566,8 +9018,9 @@ function toolEntries() {
8566
9018
  }
8567
9019
  }
8568
9020
  }
8569
- }));
9021
+ })) : [];
8570
9022
  const nonPersonaEntries = NON_PERSONA_MCP_TOOLS.filter((t) => {
9023
+ if (scope !== "all" && t.group !== scope) return false;
8571
9024
  if (t.capability === "worker") return workerToolsEnabled();
8572
9025
  if (t.capability === "stand_in") return standInToolEnabled();
8573
9026
  if (t.capability === "browser") return browserToolsEnabled();
@@ -8731,13 +9184,14 @@ async function predictedWindowOverflow(persona, prompt, context) {
8731
9184
  * - invalid effort string → handleRpc returns -32602
8732
9185
  * - effort not in persona.allowedEfforts → handleRpc returns -32602
8733
9186
  */
8734
- function jsonPathPreflightCap(body) {
9187
+ function jsonPathPreflightCap(body, scope) {
8735
9188
  if (body.id === void 0) return void 0;
8736
9189
  const params = body.params ?? {};
8737
9190
  const name$1 = typeof params.name === "string" ? params.name : "";
8738
9191
  const args = params.arguments ?? {};
8739
9192
  if (!name$1) return void 0;
8740
9193
  if (name$1 === "stand_in") {
9194
+ if (scope !== "all" && scope !== "decide") return void 0;
8741
9195
  const decision = typeof args.decision === "string" ? args.decision : "";
8742
9196
  const optionsRaw = Array.isArray(args.options) ? args.options : [];
8743
9197
  const standInContext = typeof args.context === "string" ? args.context : "";
@@ -8753,6 +9207,7 @@ function jsonPathPreflightCap(body) {
8753
9207
  if (!prompt) return void 0;
8754
9208
  const persona = activePersonas().find((p) => p.toolNameHttp === name$1);
8755
9209
  if (!persona) return void 0;
9210
+ if (scope !== "all" && scope !== "peers") return void 0;
8756
9211
  if (rawEffort !== void 0 && !isEffort(rawEffort)) return void 0;
8757
9212
  const effortMaybe = rawEffort;
8758
9213
  if (effortMaybe !== void 0 && !persona.allowedEfforts.includes(effortMaybe)) return;
@@ -8855,7 +9310,7 @@ function logTelemetry(t) {
8855
9310
  if (t.errorMessage) parts.push(`error=${JSON.stringify(t.errorMessage)}`);
8856
9311
  process.stderr.write(parts.join(" ") + "\n");
8857
9312
  }
8858
- async function handleToolsCall(body) {
9313
+ async function handleToolsCall(body, scope) {
8859
9314
  const params = body.params ?? {};
8860
9315
  const name$1 = typeof params.name === "string" ? params.name : "";
8861
9316
  const args = params.arguments ?? {};
@@ -8863,6 +9318,8 @@ async function handleToolsCall(body) {
8863
9318
  const persona = activePersonas().find((p) => p.toolNameHttp === name$1);
8864
9319
  const nonPersonaTool = persona ? void 0 : NON_PERSONA_MCP_TOOLS.find((t) => t.toolNameHttp === name$1);
8865
9320
  if (!persona && !nonPersonaTool) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
9321
+ const toolGroup = persona ? "peers" : nonPersonaTool.group;
9322
+ if (scope !== "all" && toolGroup !== scope) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
8866
9323
  if (nonPersonaTool && nonPersonaTool.capability === "worker" && !workerToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
8867
9324
  if (nonPersonaTool && nonPersonaTool.capability === "stand_in" && !standInToolEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
8868
9325
  if (nonPersonaTool && nonPersonaTool.capability === "browser" && !browserToolsEnabled()) return rpcError(body.id, RPC_METHOD_NOT_FOUND, `tools/call: unknown tool "${name$1}"`);
@@ -8953,7 +9410,7 @@ function handleCancelledNotification(body) {
8953
9410
  }
8954
9411
  cancelInflight(requestId, "client requested cancellation");
8955
9412
  }
8956
- async function handleRpc(_c, body) {
9413
+ async function handleRpc(_c, body, scope) {
8957
9414
  if (body === null || typeof body !== "object" || Array.isArray(body)) return {
8958
9415
  status: 200,
8959
9416
  body: rpcError(null, RPC_INVALID_REQUEST, "jsonrpc 2.0 envelope required")
@@ -8979,7 +9436,7 @@ async function handleRpc(_c, body) {
8979
9436
  prompts: {}
8980
9437
  },
8981
9438
  serverInfo: {
8982
- name: SERVER_NAME,
9439
+ name: serverInfoNameForScope(scope),
8983
9440
  version: SERVER_VERSION
8984
9441
  }
8985
9442
  })
@@ -8995,7 +9452,7 @@ async function handleRpc(_c, body) {
8995
9452
  };
8996
9453
  return {
8997
9454
  status: 200,
8998
- body: rpcResult(body.id, { tools: toolEntries() })
9455
+ body: rpcResult(body.id, { tools: toolEntries(scope) })
8999
9456
  };
9000
9457
  case "tools/call":
9001
9458
  if (isNotification) return {
@@ -9004,7 +9461,7 @@ async function handleRpc(_c, body) {
9004
9461
  };
9005
9462
  return {
9006
9463
  status: 200,
9007
- body: await handleToolsCall(body)
9464
+ body: await handleToolsCall(body, scope)
9008
9465
  };
9009
9466
  case "resources/list":
9010
9467
  if (isNotification) return {
@@ -9081,9 +9538,13 @@ async function handleRpc(_c, body) {
9081
9538
  };
9082
9539
  }
9083
9540
  }
9084
- async function handleMcpPost(c) {
9541
+ async function handleMcpPost(c, scopeArg = "all") {
9085
9542
  const auth$1 = checkAuth(c);
9086
9543
  if (!auth$1.ok) return c.json(rpcError(null, RPC_INVALID_REQUEST, auth$1.reason), auth$1.status);
9544
+ let scope;
9545
+ if (scopeArg === "all") scope = "all";
9546
+ else if (isMcpGroup(scopeArg)) scope = scopeArg;
9547
+ else return c.json(rpcError(null, RPC_METHOD_NOT_FOUND, `unknown MCP group "${scopeArg}"`), 404);
9087
9548
  let body;
9088
9549
  try {
9089
9550
  body = await c.req.json();
@@ -9091,13 +9552,13 @@ async function handleMcpPost(c) {
9091
9552
  consola.debug("/mcp parse error:", err);
9092
9553
  return c.json(rpcError(null, RPC_PARSE_ERROR, "request body is not valid JSON"), 200);
9093
9554
  }
9094
- if (typeof body === "object" && body !== null && !Array.isArray(body) && body.method === "tools/call" && acceptsEventStream(c.req.header("accept"))) return handleToolsCallSSE(body);
9555
+ if (typeof body === "object" && body !== null && !Array.isArray(body) && body.method === "tools/call" && acceptsEventStream(c.req.header("accept"))) return handleToolsCallSSE(body, scope);
9095
9556
  if (typeof body === "object" && body !== null && !Array.isArray(body) && body.method === "tools/call") {
9096
- const preflight = jsonPathPreflightCap(body);
9557
+ const preflight = jsonPathPreflightCap(body, scope);
9097
9558
  if (preflight) return c.json(preflight, 200);
9098
9559
  }
9099
9560
  try {
9100
- const { status, body: respBody } = await handleRpc(c, body);
9561
+ const { status, body: respBody } = await handleRpc(c, body, scope);
9101
9562
  if (respBody === null) return c.body(null, status);
9102
9563
  return c.json(respBody, status);
9103
9564
  } catch (err) {
@@ -9150,9 +9611,9 @@ function acceptsEventStream(accept) {
9150
9611
  * "Invalid state: Controller is already closed" race without warning.
9151
9612
  */
9152
9613
  const SSE_HEARTBEAT_INTERVAL_MS = 5e3;
9153
- async function handleToolsCallSSE(body) {
9614
+ async function handleToolsCallSSE(body, scope) {
9154
9615
  const encoder = new TextEncoder();
9155
- const callPromise = handleToolsCall(body);
9616
+ const callPromise = handleToolsCall(body, scope);
9156
9617
  let heartbeatHandle;
9157
9618
  const stream = new ReadableStream({
9158
9619
  async start(controller) {
@@ -10227,14 +10688,247 @@ async function searchWeb(query, signal) {
10227
10688
  }
10228
10689
 
10229
10690
  //#endregion
10230
- //#region src/lib/worker-agent/bash.ts
10231
- /**
10232
- * Env keys preserved from the parent process. Add a new key only if
10233
- * (a) it is genuinely required for typical shell invocations to work
10234
- * AND (b) it cannot carry the user's credentials. The current set was
10235
- * chosen to make `git`, `bun`, `node`, `gh`, common UNIX utilities,
10236
- * and PowerShell/cmd built-ins functional.
10237
- */
10691
+ //#region src/lib/toolbelt/manifest.ts
10692
+ function platformArchKey(platform$1 = process.platform, arch = process.arch) {
10693
+ return `${platform$1}-${arch}`;
10694
+ }
10695
+ function assetFor(spec, platform$1 = process.platform, arch = process.arch) {
10696
+ return spec.assets[platformArchKey(platform$1, arch)];
10697
+ }
10698
+ const TOOLBELT_TOOLS = [
10699
+ {
10700
+ command: "fd",
10701
+ binBasename: "fd",
10702
+ assets: {
10703
+ "win32-x64": {
10704
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-pc-windows-msvc.zip",
10705
+ sha256: "b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8",
10706
+ archive: "zip"
10707
+ },
10708
+ "win32-arm64": {
10709
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-pc-windows-msvc.zip",
10710
+ sha256: "4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92",
10711
+ archive: "zip"
10712
+ },
10713
+ "darwin-arm64": {
10714
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz",
10715
+ sha256: "623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
10716
+ archive: "tar.gz"
10717
+ },
10718
+ "linux-x64": {
10719
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz",
10720
+ sha256: "e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
10721
+ archive: "tar.gz"
10722
+ },
10723
+ "linux-arm64": {
10724
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-unknown-linux-musl.tar.gz",
10725
+ sha256: "f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
10726
+ archive: "tar.gz"
10727
+ }
10728
+ }
10729
+ },
10730
+ {
10731
+ command: "sd",
10732
+ binBasename: "sd",
10733
+ assets: {
10734
+ "win32-x64": {
10735
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-pc-windows-msvc.zip",
10736
+ sha256: "59837c2e7c911099aca1cc46b663bcdc5a949fd3e9fbbaf34fc73e5d5d71007c",
10737
+ archive: "zip"
10738
+ },
10739
+ "darwin-x64": {
10740
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-apple-darwin.tar.gz",
10741
+ sha256: "1fca1e9c91813a8aac6821063c923107ba0f66a83309e095edcd3b202f67f97e",
10742
+ archive: "tar.gz"
10743
+ },
10744
+ "darwin-arm64": {
10745
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-apple-darwin.tar.gz",
10746
+ sha256: "4bd3c09226376ca0a1d69589c91e86276fae36c5fbaaee669afce583f6682030",
10747
+ archive: "tar.gz"
10748
+ },
10749
+ "linux-x64": {
10750
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-unknown-linux-musl.tar.gz",
10751
+ sha256: "02f00f4777d43e8e95b7b8d49e1a0d6e502fed4b8e79c1c8b8063857a30caa2e",
10752
+ archive: "tar.gz"
10753
+ },
10754
+ "linux-arm64": {
10755
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-unknown-linux-musl.tar.gz",
10756
+ sha256: "ec8c93c0533ff21f4851d11566808d4082544baf063d9b96ea77c27e98b7cd99",
10757
+ archive: "tar.gz"
10758
+ }
10759
+ }
10760
+ },
10761
+ {
10762
+ command: "jq",
10763
+ binBasename: "jq",
10764
+ assets: {
10765
+ "win32-x64": {
10766
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe",
10767
+ sha256: "23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334",
10768
+ archive: "raw"
10769
+ },
10770
+ "darwin-x64": {
10771
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-macos-amd64",
10772
+ sha256: "e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f",
10773
+ archive: "raw"
10774
+ },
10775
+ "darwin-arm64": {
10776
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-macos-arm64",
10777
+ sha256: "a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603",
10778
+ archive: "raw"
10779
+ },
10780
+ "linux-x64": {
10781
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-amd64",
10782
+ sha256: "020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d",
10783
+ archive: "raw"
10784
+ },
10785
+ "linux-arm64": {
10786
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-arm64",
10787
+ sha256: "6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4",
10788
+ archive: "raw"
10789
+ }
10790
+ }
10791
+ },
10792
+ {
10793
+ command: "yq",
10794
+ binBasename: "yq",
10795
+ assets: {
10796
+ "win32-x64": {
10797
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_windows_amd64.exe",
10798
+ sha256: "2aee32f1de46a20672f48c25df3018839798bd509143f2ce05fdab1550ff5592",
10799
+ archive: "raw"
10800
+ },
10801
+ "win32-arm64": {
10802
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_windows_arm64.exe",
10803
+ sha256: "448208550332ca33ef816e4cee49fc1e79987b8a08a451c6ae529703c8cfc8a9",
10804
+ archive: "raw"
10805
+ },
10806
+ "darwin-x64": {
10807
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_darwin_amd64",
10808
+ sha256: "616b0a0f6a5b79d746f05a169c2b9bb40dee00c605ef165b9a1c1681bba738ac",
10809
+ archive: "raw"
10810
+ },
10811
+ "darwin-arm64": {
10812
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_darwin_arm64",
10813
+ sha256: "541ba2287560df70f561955e2d7f7e1cd00cf2a15a884f6b5c87a4bfa887bc07",
10814
+ archive: "raw"
10815
+ },
10816
+ "linux-x64": {
10817
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_linux_amd64",
10818
+ sha256: "d56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b",
10819
+ archive: "raw"
10820
+ },
10821
+ "linux-arm64": {
10822
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_linux_arm64",
10823
+ sha256: "03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea",
10824
+ archive: "raw"
10825
+ }
10826
+ }
10827
+ },
10828
+ {
10829
+ command: "ast-grep",
10830
+ binBasename: "ast-grep",
10831
+ aliases: ["sg"],
10832
+ assets: {
10833
+ "win32-x64": {
10834
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-pc-windows-msvc.zip",
10835
+ sha256: "a4febbc8c48671e5729d85e29e4ebe5a051b7250d19545bca18e725ccf40ef61",
10836
+ archive: "zip"
10837
+ },
10838
+ "win32-arm64": {
10839
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-pc-windows-msvc.zip",
10840
+ sha256: "a519fdd90324bf6858fde2d3feb2b862d67b834dc11af8f5b6c2c8143ab6a6c5",
10841
+ archive: "zip"
10842
+ },
10843
+ "darwin-x64": {
10844
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-apple-darwin.zip",
10845
+ sha256: "6d703090b106747b2f56086b6ccc7e798fe78bcae70257aa20519b220153555b",
10846
+ archive: "zip"
10847
+ },
10848
+ "darwin-arm64": {
10849
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-apple-darwin.zip",
10850
+ sha256: "8c847d0a29aa4b3101b3361e0b3ee7fb53c7e497adc9ed1afc9615538cd40782",
10851
+ archive: "zip"
10852
+ },
10853
+ "linux-x64": {
10854
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-unknown-linux-gnu.zip",
10855
+ sha256: "a26253a9c821d935f7e383e40f0de7c2ca62a4121de1f73a6d81ec32eae631e0",
10856
+ archive: "zip"
10857
+ },
10858
+ "linux-arm64": {
10859
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-unknown-linux-gnu.zip",
10860
+ sha256: "e706846148493967f3ab8011334817edd86ce5acbec10718b2a7b40799c640ff",
10861
+ archive: "zip"
10862
+ }
10863
+ }
10864
+ }
10865
+ ];
10866
+
10867
+ //#endregion
10868
+ //#region src/lib/toolbelt/index.ts
10869
+ /** Default ON; disable with GH_ROUTER_DISABLE_TOOLBELT (truthy). */
10870
+ function toolbeltEnabled() {
10871
+ return parseBoolEnv(process.env.GH_ROUTER_DISABLE_TOOLBELT) !== true;
10872
+ }
10873
+ /** Per-tool opt-out via GH_ROUTER_TOOLBELT_SKIP="jq,yq". */
10874
+ function toolbeltSkipSet() {
10875
+ const raw = process.env.GH_ROUTER_TOOLBELT_SKIP;
10876
+ if (!raw) return /* @__PURE__ */ new Set();
10877
+ return new Set(raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
10878
+ }
10879
+ /** Absolute path to the bundled `@vscode/ripgrep` binary, or null. */
10880
+ function vscodeRipgrepPath() {
10881
+ try {
10882
+ const mod = createRequire(import.meta.url)("@vscode/ripgrep");
10883
+ if (mod.rgPath && existsSync(mod.rgPath)) return mod.rgPath;
10884
+ } catch {}
10885
+ return null;
10886
+ }
10887
+ /**
10888
+ * Every curated tool the spawned agent can actually invoke this launch
10889
+ * — whether it is already on the user's system PATH OR will be
10890
+ * materialized into the toolbelt bin (gap-fill). Used for the awareness
10891
+ * one-liner so the model is told about ALL available fast tools, not
10892
+ * just the ones we had to download. (Provisioning still only downloads
10893
+ * the gap-fill subset; this is purely the advertised set.)
10894
+ */
10895
+ function availableToolCommands() {
10896
+ if (!toolbeltEnabled()) return [];
10897
+ const skip = toolbeltSkipSet();
10898
+ const out = [];
10899
+ if (!skip.has("rg") && (resolveExecutable("rg") || vscodeRipgrepPath())) out.push("rg");
10900
+ for (const spec of TOOLBELT_TOOLS) {
10901
+ if (skip.has(spec.command)) continue;
10902
+ if (resolveExecutable(spec.command) || assetFor(spec)) out.push(spec.command);
10903
+ }
10904
+ return out;
10905
+ }
10906
+ const TOOL_DESC = {
10907
+ rg: "rg (fast regex search)",
10908
+ fd: "fd (fast file finder)",
10909
+ jq: "jq (JSON processor)",
10910
+ sd: "sd (find & replace)",
10911
+ "ast-grep": "ast-grep / sg (structural code search & rewrite)",
10912
+ yq: "yq (YAML / TOML / XML processor)"
10913
+ };
10914
+ /**
10915
+ * The one-line CLAUDE.md / system-prompt note advertising the exposed
10916
+ * tools, or null when none are exposed.
10917
+ */
10918
+ function buildToolbeltAwareness(commands) {
10919
+ if (commands.length === 0) return null;
10920
+ return "Fast CLI tools are available on your PATH; prefer them when applicable: " + commands.map((c) => TOOL_DESC[c] ?? c).join(", ") + ".";
10921
+ }
10922
+
10923
+ //#endregion
10924
+ //#region src/lib/worker-agent/bash.ts
10925
+ /**
10926
+ * Env keys preserved from the parent process. Add a new key only if
10927
+ * (a) it is genuinely required for typical shell invocations to work
10928
+ * AND (b) it cannot carry the user's credentials. The current set was
10929
+ * chosen to make `git`, `bun`, `node`, `gh`, common UNIX utilities,
10930
+ * and PowerShell/cmd built-ins functional.
10931
+ */
10238
10932
  const ENV_ALLOWLIST = [
10239
10933
  "PATH",
10240
10934
  "HOME",
@@ -10272,6 +10966,7 @@ function buildEnv() {
10272
10966
  const v = process$1.env[key];
10273
10967
  if (v !== void 0) env[key] = v;
10274
10968
  }
10969
+ if (toolbeltEnabled()) Object.assign(env, toolbeltPathOverride(env, PATHS.TOOLBELT_BIN_DIR));
10275
10970
  return env;
10276
10971
  }
10277
10972
  /**
@@ -11204,8 +11899,10 @@ const ADVISOR_TRANSCRIPT_MAX_CHARS = Number(process$1.env.GH_ROUTER_WORKER_ADVIS
11204
11899
  /**
11205
11900
  * Build the AgentTool array for the requested mode.
11206
11901
  *
11207
- * - explore → 8 read-only tools
11208
- * - implement → explore + edit/write/bash
11902
+ * - explore → 6 read-only tools
11903
+ * - review same 6 read-only tools as explore (reviewer framing lives
11904
+ * in the system prompt, not the toolset)
11905
+ * - implement → explore + edit/write/bash/codex_review
11209
11906
  *
11210
11907
  * Order matches the brief and the prompt-mode-note for stability —
11211
11908
  * Pi's tool-injection shape includes the list verbatim, so a stable
@@ -11225,7 +11922,7 @@ function buildWorkerTools(opts) {
11225
11922
  webSearchTool(),
11226
11923
  fetchUrlTool()
11227
11924
  ];
11228
- if (mode === "explore") return explore;
11925
+ if (mode === "explore" || mode === "review") return explore;
11229
11926
  return [
11230
11927
  ...explore,
11231
11928
  editTool(workspace),
@@ -11534,15 +12231,18 @@ async function createWorktree(workspaceAbs, opts) {
11534
12231
  */
11535
12232
  const WORKTREE_REGISTRY = new WorktreeRegistry();
11536
12233
  registerExitHandlers(WORKTREE_REGISTRY);
11537
- /** Default model + thinking. See plan: gemini-3.5-flash + "high" — the
11538
- * defaults are sized for the model that backs the worker tool's
11539
- * description string in `peer-mcp-personas.ts`. Caller can override.
12234
+ /** Default model + thinking. `gemini-3.1-pro-preview` + "high" — the worker
12235
+ * loop is function-calling, and the pro model is materially less prone to
12236
+ * early-stopping with an empty turn than `gemini-3.5-flash` was (the
12237
+ * reliability win is worth the higher per-call cost for autonomous workers).
12238
+ * It advertises `tool_calls` and reasoning low/medium/high. Caller can
12239
+ * override per call via the `model` arg.
11540
12240
  *
11541
12241
  * Exported so the MCP handler (which renders the worker tool's
11542
12242
  * description to the LLM and pins a probe row against the model)
11543
12243
  * reads the same constant — drift between the two would silently
11544
12244
  * ship a tool whose docs disagree with its runtime default. */
11545
- const DEFAULT_MODEL = "gemini-3.5-flash";
12245
+ const DEFAULT_MODEL = "gemini-3.1-pro-preview";
11546
12246
  const DEFAULT_THINKING = "high";
11547
12247
  /**
11548
12248
  * `Model<any>` shim used to satisfy `Agent.initialState.model` typing.
@@ -12101,6 +12801,44 @@ function round2(n) {
12101
12801
 
12102
12802
  //#endregion
12103
12803
  //#region src/lib/peer-mcp-personas.ts
12804
+ const MCP_GROUPS = Object.freeze([
12805
+ "peers",
12806
+ "search",
12807
+ "workers",
12808
+ "browser",
12809
+ "decide"
12810
+ ]);
12811
+ const GROUP_META = Object.freeze({
12812
+ peers: {
12813
+ preferredKey: "peers",
12814
+ urlSuffix: "peers",
12815
+ serverInfoName: "github-router-peers"
12816
+ },
12817
+ search: {
12818
+ preferredKey: "search",
12819
+ urlSuffix: "search",
12820
+ serverInfoName: "github-router-search"
12821
+ },
12822
+ workers: {
12823
+ preferredKey: "workers",
12824
+ urlSuffix: "workers",
12825
+ serverInfoName: "github-router-workers"
12826
+ },
12827
+ browser: {
12828
+ preferredKey: "browser",
12829
+ urlSuffix: "browser",
12830
+ serverInfoName: "github-router-browser"
12831
+ },
12832
+ decide: {
12833
+ preferredKey: "decide",
12834
+ urlSuffix: "decide",
12835
+ serverInfoName: "github-router-decide"
12836
+ }
12837
+ });
12838
+ /** True iff `s` is a registered group name (route `:group` param validation). */
12839
+ function isMcpGroup(s) {
12840
+ return typeof s === "string" && MCP_GROUPS.includes(s);
12841
+ }
12104
12842
  /**
12105
12843
  * Reasoning effort levels accepted by Copilot's /v1/responses (gpt-5.x) and
12106
12844
  * /v1/chat/completions endpoints. Per the proxy's existing thinking-mode
@@ -12170,14 +12908,14 @@ You are NOT a helpful assistant. You are NOT a coach. Sycophancy is the failure
12170
12908
  ${COLD_START_CONTRACT}
12171
12909
 
12172
12910
  ${CRITIC_RUBRIC}`;
12173
- const GEMINI_CRITIC_BASE = `You are gemini-critic, an adversarial reviewer running on Gemini 3.1 Pro. You exist to provide a second-lab perspective: your training data, RLHF priors, and attention patterns are systematically different from the lead orchestrator's (Opus, Anthropic) and from codex-critic (gpt-5.5, OpenAI). Use that to surface blind spots both miss.
12911
+ const GEMINI_CRITIC_BASE = `You are gemini-critic, an adversarial reviewer. Your single job is to overcome the lead orchestrator's blind spots assumptions it didn't notice it was making, failure modes it didn't enumerate, alternatives it didn't consider.
12174
12912
 
12175
- Your strengths the lead may want to draw on:
12913
+ The lead routes a brief to you when it needs:
12176
12914
  - long-context reasoning over large artifacts (the brief may include >50k tokens of context)
12177
12915
  - math, proofs, and formally-stated invariants
12178
- - cross-checking conclusions where codex-critic has already weighed in (the lead may forward you both the artifact and codex-critic's verdict)
12916
+ - a cross-check of a conclusion another critic already reached (the lead may forward you both the artifact and codex-critic's verdict)
12179
12917
 
12180
- You are NOT a helpful assistant. Sycophancy is the failure mode you exist to fight; do not invent issues to look thorough.
12918
+ You are NOT a helpful assistant. Sycophancy is the failure mode you exist to fight. Manufactured contrarianism is a different failure of the same shape — silence on good work is a valid and welcome answer; do not invent issues to look thorough.
12181
12919
 
12182
12920
  ${COLD_START_CONTRACT}
12183
12921
 
@@ -12188,6 +12926,28 @@ You are not a critic-of-architecture. If the brief is a plan or a high-level des
12188
12926
 
12189
12927
  ${COLD_START_CONTRACT}
12190
12928
 
12929
+ Reply format (markdown):
12930
+ ## Summary
12931
+ <one sentence: clean / N findings / blocking issue>
12932
+ ## Findings
12933
+ For each:
12934
+ ### <severity: info | low | medium | high | critical> — <one-line title>
12935
+ - location: <file:line[-line]>
12936
+ - issue: <what's wrong, why it matters in this codebase>
12937
+ - suggested fix: <minimal change OR "needs design discussion">
12938
+ Number the findings if there are more than one. List them in severity-descending order (critical first).
12939
+ If there are zero findings of any severity, reply only with "## Summary\\nClean review — no findings." and stop.
12940
+
12941
+ Self-reminder (read before every reply):
12942
+ Am I citing real code at real line numbers in the brief? If a finding doesn't have a concrete file:line citation, drop it.
12943
+ Did I rank the finding's severity by impact-in-this-codebase, not by general-principle?
12944
+ If everything looks fine, say so cleanly — do not pad with stylistic nitpicks.`;
12945
+ const GEMINI_REVIEWER_BASE = `You are a line-level code reviewer. You read concrete code — diffs, single files, function bodies — and surface real bugs, edge cases, security / concurrency / resource issues, and idiom violations at specific line numbers. Find what is actually wrong: do not invent issues to look thorough, and do not pad with stylistic nitpicks.
12946
+
12947
+ You are not a critic-of-architecture. If the brief is a plan or a high-level design, say so and stop: "this looks like architecture review, not line-level code review." Your tool is the magnifying glass, not the wide-angle lens.
12948
+
12949
+ ${COLD_START_CONTRACT}
12950
+
12191
12951
  Reply format (markdown):
12192
12952
  ## Summary
12193
12953
  <one sentence: clean / N findings / blocking issue>
@@ -12293,6 +13053,24 @@ const PERSONAS_READ = Object.freeze([
12293
13053
  ],
12294
13054
  defaultEffort: "xhigh"
12295
13055
  },
13056
+ {
13057
+ agentName: "gemini-reviewer",
13058
+ toolNameHttp: "gemini_reviewer",
13059
+ model: "gemini-3.1-pro-preview",
13060
+ endpoint: "/v1/chat/completions",
13061
+ description: "Line-level review of a concrete diff or single file on gemini-3.1-pro (Google, high reasoning): a second-lab code reviewer that catches a different slice of defects than codex_reviewer (OpenAI). Use alongside codex_reviewer for cross-lab coverage of a diff. Not for architecture (use codex_critic / gemini_critic for plans). Pass artifact verbatim.",
13062
+ baseInstructions: GEMINI_REVIEWER_BASE,
13063
+ agentPrompt: "",
13064
+ writeCapable: false,
13065
+ requiresHttp: true,
13066
+ requiresGeminiCatalog: true,
13067
+ allowedEfforts: [
13068
+ "low",
13069
+ "medium",
13070
+ "high"
13071
+ ],
13072
+ defaultEffort: "high"
13073
+ },
12296
13074
  {
12297
13075
  agentName: "opus-critic",
12298
13076
  toolNameHttp: "opus_critic",
@@ -12336,8 +13114,11 @@ const PERSONAS_WRITE = Object.freeze([{
12336
13114
  *
12337
13115
  * Two modes branch on `codexCli`:
12338
13116
  * - HTTP backend: subagent calls the per-persona tool
12339
- * `mcp__gh-router-peers__<toolNameHttp>` with `{prompt, context}`;
12340
- * model + instructions are server-baked.
13117
+ * `mcp__<peersKey>__<toolNameHttp>` with `{prompt, context}`;
13118
+ * model + instructions are server-baked. `peersKey` is the resolved
13119
+ * config key for the `peers` server — normally the bare `peers`, or the
13120
+ * `gh-router-peers` fallback when the user already has a `peers` MCP
13121
+ * (so the routing string always points at OUR server, never the user's).
12341
13122
  * - codex-cli backend: subagent calls the single
12342
13123
  * `mcp__codex-cli__codex` tool with `{prompt, model: <persona.model>,
12343
13124
  * base-instructions: <persona.baseInstructions>}`. Gemini stays on
@@ -12345,7 +13126,7 @@ const PERSONAS_WRITE = Object.freeze([{
12345
13126
  */
12346
13127
  function buildAgentPrompt(persona, opts) {
12347
13128
  const useStdio = opts.codexCli && !persona.requiresHttp;
12348
- const toolPath = useStdio ? "mcp__codex-cli__codex" : `mcp__gh-router-peers__${persona.toolNameHttp}`;
13129
+ const toolPath = useStdio ? "mcp__codex-cli__codex" : `mcp__${opts.peersKey}__${persona.toolNameHttp}`;
12349
13130
  const invocationBlock = useStdio ? [
12350
13131
  `Always invoke the \`${toolPath}\` tool with these arguments:`,
12351
13132
  " - `prompt`: the lead's brief, copied verbatim",
@@ -12413,22 +13194,31 @@ function buildAgentPrompt(persona, opts) {
12413
13194
  * by descendants.
12414
13195
  */
12415
13196
  function buildPeerAwarenessSnippet(opts) {
13197
+ const key = (g) => opts.groupKeys?.[g] ?? GROUP_META[g].preferredKey;
13198
+ const peersKey = key("peers");
13199
+ const searchKey = key("search");
13200
+ const workersKey = key("workers");
13201
+ const browserKey = key("browser");
13202
+ const decideKey = key("decide");
12416
13203
  const criticList = ["`codex_critic` (gpt-5.5)", "`codex_reviewer` (gpt-5.3-codex)"];
12417
- if (opts.geminiAvailable) criticList.push("`gemini_critic` (gemini-3.1-pro)");
13204
+ if (opts.geminiAvailable) {
13205
+ criticList.push("`gemini_reviewer` (gemini-3.1-pro, line-level code review)");
13206
+ criticList.push("`gemini_critic` (gemini-3.1-pro)");
13207
+ }
12418
13208
  criticList.push("`opus_critic` (Opus 4.7)");
12419
13209
  const codexCliClause = opts.codexCli ? " `mcp__codex-cli__codex` dispatches to `codex-implementer` (gpt-5.3-codex with workspace-write) for end-to-end coding tasks." : "";
12420
- const para2Parts = ["`code_search` returns ranked code-discovery hits (BM25F + tree-sitter ranking, no additional model call). Multiple independent queries can run in a single turn. The index covers code-shaped files; for unstructured files (logs, `.csv`, `.env*`, config-only wiring), `grep`/`glob` still apply."];
12421
- if (opts.workerToolsAvailable) para2Parts.push("`worker_explore` runs a Gemini-backed read-only worker that returns a summary, using its own context rather than yours; concurrent launches share the `MAX_INFLIGHT_TOOLS_CALL=8` cap with operator traffic.", "`worker_implement` is the same worker with edit/write/bash; `worktree: true` runs it in an isolated git worktree and returns the diff.", "Workers themselves have `code_search` in their toolset.");
12422
- para2Parts.push("`web_search` surfaces citable sources for docs, errors, and upstream issues.");
12423
- if (opts.standInAvailable) para2Parts.push("`stand_in` provides three-lab consensus for decision tiebreak when the user is unavailable.");
13210
+ const para2Parts = [`\`mcp__${searchKey}__code\` returns ranked code-discovery hits (BM25F + tree-sitter ranking, no additional model call). Multiple independent queries can run in a single turn. The index covers code-shaped files; for unstructured files (logs, \`.csv\`, \`.env*\`, config-only wiring), \`grep\`/\`glob\` still apply.`];
13211
+ if (opts.workerToolsAvailable) para2Parts.push(`\`mcp__${workersKey}__explore\` runs a Gemini-backed read-only worker that returns a summary, using its own context rather than yours; concurrent launches share the \`MAX_INFLIGHT_TOOLS_CALL=8\` cap with operator traffic.`, `\`mcp__${workersKey}__review\` is the same read-only worker framed as a code reviewer that reads the relevant code itself to verify a change or claim and reports findings with severity, so it checks surrounding context the \`peers\` critics (single stateless calls on the pasted artifact) cannot.`, `\`mcp__${workersKey}__implement\` is the same worker with edit/write/bash; \`worktree: true\` runs it in an isolated git worktree and returns the diff.`, "Workers themselves have `code_search` in their toolset.");
13212
+ para2Parts.push(`\`mcp__${searchKey}__web\` surfaces citable sources for docs, errors, and upstream issues.`);
13213
+ if (opts.standInAvailable) para2Parts.push(`\`mcp__${decideKey}__stand_in\` provides three-lab consensus for decision tiebreak when the user is unavailable.`);
12424
13214
  if (opts.browseAvailable) {
12425
- const powerNote = opts.powerBrowseAvailable ? " Power mode is on: the L0/L1 primitives (`browser_mouse`, `browser_drag`, `browser_type`, `browser_keyboard`, `browser_scroll`, `browser_eval_js`, `browser_read_page`, `browser_diagnostics`, `browser_find`) are also available for direct DOM / coordinate control." : "";
12426
- para2Parts.push(`\`browser_*\` tools (under \`mcp__gh-router-peers__browser_*\`) drive a real Chrome / Edge browser via a local extension. Lead surface: \`browser_act(intent, value?)\` for any click / fill / type / scroll-to (an inner fast model resolves intent), \`browser_observe(intent?)\` for a 2-4 sentence natural-language page description, \`browser_extract(schema, instruction)\` for typed extraction, \`browser_navigate\` / \`browser_open_tab\` / \`browser_screenshot\` for state and visuals. The lead model never sees raw DOM: refs, bboxes, and role/name dumps stay internal.${powerNote}`);
13215
+ const powerNote = opts.powerBrowseAvailable ? ` Power mode is on: the L0/L1 primitives (\`mcp__${browserKey}__mouse\`, \`__drag\`, \`__type\`, \`__keyboard\`, \`__scroll\`, \`__eval_js\`, \`__read_page\`, \`__diagnostics\`, \`__find\`) are also available for direct DOM / coordinate control.` : "";
13216
+ para2Parts.push(`\`mcp__${browserKey}__*\` tools drive a real Chrome / Edge browser via a local extension. Lead surface: \`__act(intent, value?)\` for any click / fill / type / scroll-to (an inner fast model resolves intent), \`__observe(intent?)\` for a 2-4 sentence natural-language page description, \`__extract(schema, instruction)\` for typed extraction, \`__navigate\` / \`__open_tab\` / \`__screenshot\` for state and visuals. The lead model never sees raw DOM: refs, bboxes, and role/name dumps stay internal.${powerNote}`);
12427
13217
  }
12428
13218
  return [
12429
13219
  "## Peer review and advisor",
12430
13220
  "",
12431
- `Cross-lab peer critics under \`mcp__gh-router-peers__*\` (${criticList.join(", ")}) are available at your discretion for adversarial review. Each tool's description explains its scope and when it applies. The \`peer-review-coordinator\` subagent fans out to the appropriate critics in parallel and aggregates findings by severity. Claude Code's built-in \`advisor\` tool catches approach drift and confabulation. Subagents you spawn inherit all of these.${codexCliClause}`,
13221
+ `Cross-lab peer critics under \`mcp__${peersKey}__*\` (${criticList.join(", ")}) are available at your discretion for adversarial review. Each tool's description explains its scope and when it applies. The \`peer-review-coordinator\` subagent fans out to the appropriate critics in parallel and aggregates findings by severity. Claude Code's built-in \`advisor\` tool catches approach drift and confabulation. Subagents you spawn inherit all of these.${codexCliClause}`,
12432
13222
  "",
12433
13223
  para2Parts.join(" ")
12434
13224
  ].join("\n");
@@ -12461,7 +13251,8 @@ function formatWebSearchResult(results) {
12461
13251
  }
12462
13252
  const NON_PERSONA_MCP_TOOLS = Object.freeze([
12463
13253
  {
12464
- toolNameHttp: "web_search",
13254
+ toolNameHttp: "web",
13255
+ group: "search",
12465
13256
  description: WEB_SEARCH_DESCRIPTION,
12466
13257
  inputSchema: {
12467
13258
  type: "object",
@@ -12498,8 +13289,9 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12498
13289
  }
12499
13290
  },
12500
13291
  {
12501
- toolNameHttp: "code_search",
12502
- description: "Fast structured code search over a local workspace. Returns ranked, deduplicated hits with snippets. Ranks with BM25F across matched-line / file-path / surrounding-context / symbol-context fields, then refines `symbol-context` with tree-sitter AST analysis on the top hits so identifier definitions outrank incidental string matches. Launch multiple code_search calls in parallel to triangulate — e.g. definition + callers + tests in one round-trip. Prefer this over Grep/Bash+grep for ranked discovery (\"where is X defined\", \"which files reference Y\", \"find code that does Z\") — ranked mode surfaces the few right answers instead of every match. Use Grep for exact-pattern enumeration when you need every hit unranked, and Glob for file-name patterns (no content match). `workspace` is any absolute path the proxy process can read — typically the project root or a sub-tree you're working in.",
13292
+ toolNameHttp: "code",
13293
+ group: "search",
13294
+ description: "Fast structured code search over a local workspace. Returns ranked, deduplicated hits with snippets. Ranks with BM25F across matched-line / file-path / surrounding-context / symbol-context fields, then refines `symbol-context` with tree-sitter AST analysis on the top hits so identifier definitions outrank incidental string matches. Launch multiple code searches in parallel to triangulate — e.g. definition + callers + tests in one round-trip. Prefer this over Grep/Bash+grep for ranked discovery (\"where is X defined\", \"which files reference Y\", \"find code that does Z\") — ranked mode surfaces the few right answers instead of every match. Use Grep for exact-pattern enumeration when you need every hit unranked, and Glob for file-name patterns (no content match). `workspace` is any absolute path the proxy process can read — typically the project root or a sub-tree you're working in.",
12503
13295
  inputSchema: {
12504
13296
  type: "object",
12505
13297
  required: ["query", "workspace"],
@@ -12587,9 +13379,10 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12587
13379
  }
12588
13380
  },
12589
13381
  {
12590
- toolNameHttp: "worker_explore",
13382
+ toolNameHttp: "explore",
13383
+ group: "workers",
12591
13384
  capability: "worker",
12592
- description: "Read-only investigation by an autonomous worker (Pi runtime; default model `gemini-3.5-flash`, override via the `model` arg with any Copilot-catalog model that advertises `tool_calls`). Tools: read, glob, grep, code_search, web_search, fetch_url. The worker's system prompt sandboxes it and gives one-line descriptions of each tool, so brief it on the investigation, not on tool semantics. Offloads bounded research that would otherwise eat your context window — the worker plans its own tool calls and returns a single text answer. Examples: \"find files matching X then summarize\", \"how does library Y handle Z\", \"survey this codebase for usages of deprecated API\".",
13385
+ description: "Read-only investigation by an autonomous worker (Pi runtime; default model `gemini-3.1-pro-preview`, override via the `model` arg with any Copilot-catalog model that advertises `tool_calls`). Tools: read, glob, grep, code_search, web_search, fetch_url. The worker's system prompt sandboxes it and gives one-line descriptions of each tool, so brief it on the investigation, not on tool semantics. Offloads bounded research that would otherwise eat your context window — the worker plans its own tool calls and returns a single text answer. Examples: \"find files matching X then summarize\", \"how does library Y handle Z\", \"survey this codebase for usages of deprecated API\".",
12593
13386
  inputSchema: {
12594
13387
  type: "object",
12595
13388
  required: ["prompt"],
@@ -12601,7 +13394,7 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12601
13394
  },
12602
13395
  model: {
12603
13396
  type: "string",
12604
- description: "Optional Copilot catalog model id (defaults to gemini-3.5-flash). Must advertise tool_calls support; the engine emits an isError envelope listing the eligible catalog models on mismatch."
13397
+ description: "Optional Copilot catalog model id (defaults to gemini-3.1-pro-preview). Must advertise tool_calls support; the engine emits an isError envelope listing the eligible catalog models on mismatch."
12605
13398
  },
12606
13399
  thinking: {
12607
13400
  type: "string",
@@ -12630,9 +13423,10 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12630
13423
  }
12631
13424
  },
12632
13425
  {
12633
- toolNameHttp: "worker_implement",
13426
+ toolNameHttp: "implement",
13427
+ group: "workers",
12634
13428
  capability: "worker",
12635
- description: "Delegates a scoped coding task to an autonomous worker (Pi runtime; default model `gemini-3.5-flash`, override via the `model` arg with any Copilot-catalog model that advertises `tool_calls`). Tools: the worker_explore read-only set plus edit, write, bash, and codex_review (code review by codex-reviewer / gpt-5.3-codex). The worker's system prompt sandboxes it and gives one-line descriptions of each tool, so brief it on the task, not on tool semantics. With `worktree: false` (default) edits in place — concurrent worker_implement calls and Claude's own edits to the same files will race. With `worktree: true` runs in an isolated git worktree and returns the diff for review. HARD ERROR if true and the workspace is not a git repository.",
13429
+ description: "Delegates a scoped coding task to an autonomous worker (Pi runtime; default model `gemini-3.1-pro-preview`, override via the `model` arg with any Copilot-catalog model that advertises `tool_calls`). Tools: the worker_explore read-only set plus edit, write, bash, and codex_review (code review by codex-reviewer / gpt-5.3-codex). The worker's system prompt sandboxes it and gives one-line descriptions of each tool, so brief it on the task, not on tool semantics. With `worktree: false` (default) edits in place — concurrent worker_implement calls and Claude's own edits to the same files will race. With `worktree: true` runs in an isolated git worktree and returns the diff for review. HARD ERROR if true and the workspace is not a git repository.",
12636
13430
  inputSchema: {
12637
13431
  type: "object",
12638
13432
  required: ["prompt"],
@@ -12648,7 +13442,7 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12648
13442
  },
12649
13443
  model: {
12650
13444
  type: "string",
12651
- description: "Optional Copilot catalog model id (defaults to gemini-3.5-flash). Must advertise tool_calls support; the engine emits an isError envelope listing the eligible catalog models on mismatch."
13445
+ description: "Optional Copilot catalog model id (defaults to gemini-3.1-pro-preview). Must advertise tool_calls support; the engine emits an isError envelope listing the eligible catalog models on mismatch."
12652
13446
  },
12653
13447
  thinking: {
12654
13448
  type: "string",
@@ -12676,8 +13470,53 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12676
13470
  });
12677
13471
  }
12678
13472
  },
13473
+ {
13474
+ toolNameHttp: "review",
13475
+ group: "workers",
13476
+ capability: "worker",
13477
+ description: "Read-only code review by an autonomous worker (Pi runtime; default model `gemini-3.1-pro-preview`, override via `model` with any Copilot-catalog model that advertises `tool_calls`). Same read-only toolset as `explore` (read, glob, grep, code_search, web_search, fetch_url) — it CANNOT edit — but the worker is framed as a reviewer: it verifies correctness against the actual code itself rather than trusting a claim, and reports findings (bugs, edge cases, security / concurrency / resource risks, missing handling) with a severity and `file:line`. Brief it with the change / diff / claim to verify (paste it, or name the files) — it reads the code to confirm, so you get a self-verifying second opinion that doesn't depend on you having pre-extracted the relevant code. Unlike the `peers` critics (single stateless model calls on the artifact you paste), this worker can navigate the repo to check surrounding context for itself.",
13478
+ inputSchema: {
13479
+ type: "object",
13480
+ required: ["prompt"],
13481
+ additionalProperties: false,
13482
+ properties: {
13483
+ prompt: {
13484
+ type: "string",
13485
+ description: "What to review / verify — a diff, a claim about the code, or a file / function to audit. The worker reads the relevant code itself and reports findings; it does not need the code pre-pasted, but pasting the diff helps."
13486
+ },
13487
+ model: {
13488
+ type: "string",
13489
+ description: "Optional Copilot catalog model id (defaults to gemini-3.1-pro-preview). Must advertise tool_calls support; the engine emits an isError envelope listing the eligible catalog models on mismatch."
13490
+ },
13491
+ thinking: {
13492
+ type: "string",
13493
+ enum: [
13494
+ "off",
13495
+ "minimal",
13496
+ "low",
13497
+ "medium",
13498
+ "high",
13499
+ "xhigh"
13500
+ ],
13501
+ description: "Optional reasoning depth (default high). Silently clamped to the model's allowed range; \"off\" drops the parameter entirely."
13502
+ },
13503
+ workspace: {
13504
+ type: "string",
13505
+ description: "Optional absolute path to the workspace the worker operates in. Defaults to the proxy's launch cwd. Use this when the parent agent has multiple workspaces open and the worker must operate in a specific one. Must be absolute (relative paths rejected)."
13506
+ }
13507
+ }
13508
+ },
13509
+ async handler(args, signal) {
13510
+ return runWorkerToolCall({
13511
+ mode: "review",
13512
+ args,
13513
+ signal
13514
+ });
13515
+ }
13516
+ },
12679
13517
  {
12680
13518
  toolNameHttp: "stand_in",
13519
+ group: "decide",
12681
13520
  capability: "stand_in",
12682
13521
  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).",
12683
13522
  inputSchema: {
@@ -12724,9 +13563,37 @@ const NON_PERSONA_MCP_TOOLS = Object.freeze([
12724
13563
  return runStandInToolCall(args, signal);
12725
13564
  }
12726
13565
  },
12727
- ...BROWSER_TOOLS
13566
+ ...BROWSER_TOOLS.map((t) => ({
13567
+ ...t,
13568
+ group: "browser",
13569
+ toolNameHttp: t.toolNameHttp.replace(/^browser_/, "")
13570
+ }))
12728
13571
  ]);
12729
13572
  /**
13573
+ * Startup invariant: every MCP tool name must be unique within its group
13574
+ * AND across the unscoped `/mcp` union. `handleToolsCall` keys dispatch on
13575
+ * the bare tool name, so a duplicate would silently shadow — this assertion
13576
+ * fails loudly on future drift instead. Cheap; called once at server boot
13577
+ * (and pinned by a test). Personas are definitionally the `peers` group.
13578
+ */
13579
+ function assertMcpToolSurfaceConsistent() {
13580
+ const perGroup = /* @__PURE__ */ new Map();
13581
+ const union = /* @__PURE__ */ new Set();
13582
+ const add = (group, name$1) => {
13583
+ let g = perGroup.get(group);
13584
+ if (!g) {
13585
+ g = /* @__PURE__ */ new Set();
13586
+ perGroup.set(group, g);
13587
+ }
13588
+ if (g.has(name$1)) throw new Error(`assertMcpToolSurfaceConsistent: tool "${name$1}" duplicated within group "${group}"`);
13589
+ g.add(name$1);
13590
+ if (union.has(name$1)) throw new Error(`assertMcpToolSurfaceConsistent: tool "${name$1}" duplicated across the unscoped /mcp union — handleToolsCall keys on the bare name and cannot disambiguate`);
13591
+ union.add(name$1);
13592
+ };
13593
+ for (const p of [...PERSONAS_READ, ...PERSONAS_WRITE]) add("peers", p.toolNameHttp);
13594
+ for (const t of NON_PERSONA_MCP_TOOLS) add(t.group, t.toolNameHttp);
13595
+ }
13596
+ /**
12730
13597
  * Shared closure body for the two worker MCP tools. Validates the
12731
13598
  * minimal arg shape (prompt required + optional knobs typed), then
12732
13599
  * forwards to `runWorkerAgent`. `workspace` defaults to the proxy's
@@ -12933,6 +13800,11 @@ async function runStandInToolCall(args, signal) {
12933
13800
 
12934
13801
  //#endregion
12935
13802
  //#region src/lib/codex-mcp-config.ts
13803
+ /** The `peers` server is always enabled, so its resolved key always exists;
13804
+ * this convenience reads it with the bare-key fallback for safety. */
13805
+ function peersKeyOf(groupKeys) {
13806
+ return groupKeys.peers ?? GROUP_META.peers.preferredKey;
13807
+ }
12936
13808
  /**
12937
13809
  * Decide which MCP backend serves the codex personas.
12938
13810
  *
@@ -12956,21 +13828,28 @@ function resolveCodexCliBackend(opts) {
12956
13828
  return "cli";
12957
13829
  }
12958
13830
  /**
12959
- * Build the JSON payload for `claude --mcp-config <path>`.
13831
+ * Build the JSON payload for `claude --mcp-config <path>` (and the same
13832
+ * entries that get merged into the mirrored `.claude.json`).
12960
13833
  *
12961
- * Always registers `gh-router-peers` (HTTP) that's the home of all
12962
- * read-only personas, and it's the only path Gemini can take. When
13834
+ * Emits one HTTP `mcpServers` entry per enabled group present in
13835
+ * `opts.groupKeys`, each pointing at its scoped `/mcp/<group>` endpoint
13836
+ * under the resolved (bare or prefixed-fallback) config key. When
12963
13837
  * `codexCli` is true, also registers `codex-cli` (stdio) which spawns
12964
- * `codex mcp-server` with the proxy's provider-config flags so codex
12965
- * runs through our Copilot-routed billing path rather than its
12966
- * default api.openai.com.
13838
+ * `codex mcp-server` with the proxy's provider-config flags so codex runs
13839
+ * through our Copilot-routed billing path rather than its default
13840
+ * api.openai.com.
12967
13841
  */
12968
13842
  function buildPeerMcpConfig(serverUrl, opts) {
12969
- const mcpServers = { "gh-router-peers": {
12970
- type: "http",
12971
- url: `${serverUrl}/mcp`,
12972
- headers: { Authorization: `Bearer ${opts.nonce}` }
12973
- } };
13843
+ const mcpServers = {};
13844
+ for (const group of MCP_GROUPS) {
13845
+ const key = opts.groupKeys[group];
13846
+ if (!key) continue;
13847
+ mcpServers[key] = {
13848
+ type: "http",
13849
+ url: `${serverUrl}/mcp/${GROUP_META[group].urlSuffix}`,
13850
+ headers: { Authorization: `Bearer ${opts.nonce}` }
13851
+ };
13852
+ }
12974
13853
  if (opts.codexCli) mcpServers["codex-cli"] = {
12975
13854
  command: "codex",
12976
13855
  args: ["mcp-server", ...buildCodexProviderConfigFlags(serverUrl)],
@@ -13003,6 +13882,7 @@ function buildCoordinatorAgent(opts) {
13003
13882
  const peers = ["codex-critic", "opus-critic"];
13004
13883
  if (opts.geminiAvailable) peers.push("gemini-critic");
13005
13884
  peers.push("codex-reviewer");
13885
+ if (opts.geminiAvailable) peers.push("gemini-reviewer");
13006
13886
  return {
13007
13887
  description: "Coordinates cross-lab adversarial review across codex-critic, opus-critic, gemini-critic, codex-reviewer. Use proactively before non-trivial plans and after non-trivial commits. Always pass artifacts verbatim — peers are fresh-context.",
13008
13888
  prompt: [
@@ -13017,7 +13897,7 @@ function buildCoordinatorAgent(opts) {
13017
13897
  "The lead's brief will include an artifact (plan, design, diff, or code) and a goal (e.g. 'review before exit-plan', 'review the commit I just made', 'cross-check codex-critic's verdict'). Pick the right peers for the artifact type:",
13018
13898
  "",
13019
13899
  "- **Plan / design / architecture choice** → fan out to `codex-critic` (gpt-5.5, strongest reasoning, cross-lab)" + (opts.geminiAvailable ? " AND `gemini-critic` (third-lab triangulation, strong on formal reasoning) in parallel" : "") + ". codex-reviewer is the wrong tool for plans (it's a code-specialist, not an architecture critic).",
13020
- "- **Concrete diff or single file** → fan out to `codex-reviewer` (gpt-5.3-codex, line-level code specialist, fastest at ~16s)" + (opts.geminiAvailable ? " AND `gemini-critic` for cross-lab triangulation" : "") + ". For very small changes (<20 lines), one `codex-reviewer` call is enough.",
13900
+ "- **Concrete diff or single file** → fan out to `codex-reviewer` (gpt-5.3-codex, line-level code specialist, fastest at ~16s)" + (opts.geminiAvailable ? " AND `gemini-reviewer` (gemini-3.1-pro, second-lab line-level review)" : "") + (opts.geminiAvailable ? " AND `gemini-critic` for cross-lab triangulation" : "") + ". For very small changes (<20 lines), one `codex-reviewer` call is enough.",
13021
13901
  "- **Large artifact** → the only peers that take a large artifact WHOLE are `codex-critic` (gpt-5.5, ≈922K-token input window) and `opus-critic` (Opus-4.7-1M, ≈936K-token input on enterprise catalogs; ≈168K otherwise). Route the full artifact to those for cross-lab coverage. `codex-reviewer` (≈272K) and `gemini-critic` (≈136K) have small windows — see Decomposition below: never summarize or downsize the request to squeeze a large artifact into a small-window peer.",
13022
13902
  "- **Formal reasoning, proofs, or invariants** → prefer `gemini-critic`" + (opts.geminiAvailable ? " (gemini-3.1-pro, strong on math and formally-stated properties)" : " (NOT REGISTERED in this session — gemini-3.x not in catalog)") + ".",
13023
13903
  "- **Tie-breaker after codex-critic has weighed in** → call `gemini-critic`" + (opts.geminiAvailable ? "" : " (NOT REGISTERED in this session)") + " or `opus-critic` with the artifact AND codex-critic's verdict for cross-check.",
@@ -13072,9 +13952,13 @@ function buildPeerAgentDefinitions(opts) {
13072
13952
  codexCli: opts.codexCli,
13073
13953
  geminiAvailable: opts.geminiAvailable
13074
13954
  });
13955
+ const peersKey = peersKeyOf(opts.groupKeys);
13075
13956
  for (const persona of personas) out[persona.agentName] = {
13076
13957
  description: persona.description,
13077
- prompt: buildAgentPrompt(persona, { codexCli: opts.codexCli })
13958
+ prompt: buildAgentPrompt(persona, {
13959
+ codexCli: opts.codexCli,
13960
+ peersKey
13961
+ })
13078
13962
  };
13079
13963
  out["peer-review-coordinator"] = buildCoordinatorAgent({
13080
13964
  codexCli: opts.codexCli,
@@ -13181,6 +14065,65 @@ async function writePeerAgentMdFiles(agents, opts) {
13181
14065
  };
13182
14066
  }
13183
14067
  /**
14068
+ * Read just the `mcpServers` object from a mirrored `.claude.json` (or `{}`
14069
+ * on missing / malformed). Used by `resolveGroupKeysFromMirror` to detect
14070
+ * which of our bare group keys would collide with a user-side entry.
14071
+ */
14072
+ async function readMcpServersSnapshot(target) {
14073
+ try {
14074
+ const raw = await fs.readFile(target, "utf8");
14075
+ const parsed = JSON.parse(raw);
14076
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
14077
+ const servers = parsed.mcpServers;
14078
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) return servers;
14079
+ }
14080
+ } catch {}
14081
+ return {};
14082
+ }
14083
+ /**
14084
+ * Resolve a config-entry key for each enabled group, defending against
14085
+ * collisions with the user's own `mcpServers`. Prefer the bare key
14086
+ * (`peers`/`search`/…); on collision walk the numbered fallback sequence
14087
+ * `gh-router-<group>`, `gh-router-<group>-2`, `gh-router-<group>-3`, …
14088
+ * until a free name is found. This NEVER skips and NEVER returns a name the
14089
+ * user already owns: every enabled group is guaranteed a key WE control, so
14090
+ * a capability is never silently dropped AND the model is never routed at
14091
+ * the user's same-named server (the caller threads these resolved keys into
14092
+ * both the `mcpServers` entries AND the persona `.md` routing strings). The
14093
+ * `skipped` field is retained for API stability but is always empty now.
14094
+ *
14095
+ * Reads the mirror snapshot once; the caller passes the result to BOTH
14096
+ * `writePeerMcpRuntimeFiles` and `injectPeerMcpIntoMirror`. The mirror is a
14097
+ * per-launch dir written ONLY by us (after `ensureClaudeConfigMirror`
14098
+ * snapshotted the user's config) and nothing mutates it between this read
14099
+ * and `injectPeerMcpIntoMirror`'s write, so the two reads see identical
14100
+ * state — no TOCTOU window, and the inject-side defensive conflict check
14101
+ * never fires for these resolved keys.
14102
+ */
14103
+ async function resolveGroupKeysFromMirror(enabledGroups, claudeConfigDir) {
14104
+ const dir = claudeConfigDir ?? PATHS.CLAUDE_CONFIG_DIR;
14105
+ const existing = await readMcpServersSnapshot(path.join(dir, ".claude.json"));
14106
+ const keys = {};
14107
+ for (const group of enabledGroups) {
14108
+ const bare = GROUP_META[group].preferredKey;
14109
+ if (existing[bare] === void 0) {
14110
+ keys[group] = bare;
14111
+ continue;
14112
+ }
14113
+ let candidate = `gh-router-${group}`;
14114
+ let n = 1;
14115
+ while (existing[candidate] !== void 0) {
14116
+ n += 1;
14117
+ candidate = `gh-router-${group}-${n}`;
14118
+ }
14119
+ keys[group] = candidate;
14120
+ }
14121
+ return {
14122
+ keys,
14123
+ skipped: []
14124
+ };
14125
+ }
14126
+ /**
13184
14127
  * Mutate the mirrored `<CLAUDE_CONFIG_DIR>/.claude.json` to add the
13185
14128
  * `gh-router-peers` entry (and `codex-cli` when enabled) under
13186
14129
  * `mcpServers`. This is the load-bearing fix for subagent MCP visibility.
@@ -13231,13 +14174,14 @@ async function injectPeerMcpIntoMirror(serverUrl, opts) {
13231
14174
  const peerConfig = buildPeerMcpConfig(serverUrl, {
13232
14175
  codexCli: opts.codexCli,
13233
14176
  geminiAvailable: opts.geminiAvailable,
14177
+ groupKeys: opts.groupKeys,
13234
14178
  nonce: opts.nonce,
13235
14179
  codexHome: opts.codexHome ?? PATHS.CODEX_HOME
13236
14180
  });
13237
14181
  const conflicts = [];
13238
14182
  for (const name$1 of Object.keys(peerConfig.mcpServers)) if (mcpServers[name$1] !== void 0) conflicts.push(name$1);
13239
14183
  if (conflicts.length > 0) {
13240
- consola.warn(`injectPeerMcpIntoMirror: your ~/.claude/.claude.json already has mcpServers entries named [${conflicts.join(", ")}]; refusing to overwrite. Subagents will not see the peer-MCP 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.`);
14184
+ 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.`);
13241
14185
  return {
13242
14186
  ok: false,
13243
14187
  reason: "user-has-conflicting-entry",
@@ -13288,12 +14232,14 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
13288
14232
  const mcpConfig = buildPeerMcpConfig(serverUrl, {
13289
14233
  codexCli: opts.codexCli,
13290
14234
  geminiAvailable: opts.geminiAvailable,
14235
+ groupKeys: opts.groupKeys,
13291
14236
  nonce,
13292
14237
  codexHome
13293
14238
  });
13294
14239
  const agents = buildPeerAgentDefinitions({
13295
14240
  codexCli: opts.codexCli,
13296
14241
  geminiAvailable: opts.geminiAvailable,
14242
+ groupKeys: opts.groupKeys,
13297
14243
  nonce,
13298
14244
  codexHome
13299
14245
  });
@@ -13361,7 +14307,13 @@ function makeDedupeKey(logObj) {
13361
14307
  const key = `${logObj.type}:${firstArg}`;
13362
14308
  return key.length > DEDUP_KEY_MAX_LEN ? key.slice(0, DEDUP_KEY_MAX_LEN) : key;
13363
14309
  }
13364
- function rotateIfNeeded(filePath) {
14310
+ /**
14311
+ * Construction-time rotation: rename the log aside if it's already over the
14312
+ * cap before we start appending. Runs with no descriptor held (the instance
14313
+ * fd is opened lazily on the first `log()`), so a plain path stat + rename is
14314
+ * correct here. The per-`log()` ceiling check lives in `rotateIfNeeded()`.
14315
+ */
14316
+ function rotateAtStartup(filePath) {
13365
14317
  let size;
13366
14318
  try {
13367
14319
  size = fs$1.statSync(filePath).size;
@@ -13378,9 +14330,57 @@ var FileLogReporter = class FileLogReporter {
13378
14330
  seen = /* @__PURE__ */ new Set();
13379
14331
  bytesSinceCheck = 0;
13380
14332
  static ROTATE_CHECK_BYTES = MAX_LOG_BYTES / 2;
14333
+ fd;
13381
14334
  constructor(filePath) {
13382
14335
  this.filePath = filePath;
13383
- rotateIfNeeded(filePath);
14336
+ rotateAtStartup(filePath);
14337
+ }
14338
+ ensureFd() {
14339
+ if (this.fd !== void 0) return this.fd;
14340
+ try {
14341
+ this.fd = fs$1.openSync(this.filePath, "a", 384);
14342
+ } catch {
14343
+ this.fd = void 0;
14344
+ }
14345
+ return this.fd;
14346
+ }
14347
+ closeFd() {
14348
+ if (this.fd === void 0) return;
14349
+ try {
14350
+ fs$1.closeSync(this.fd);
14351
+ } catch {}
14352
+ this.fd = void 0;
14353
+ }
14354
+ /**
14355
+ * Enforce the MAX_LOG_BYTES ceiling. Sizes the LIVE file (fstat on the open
14356
+ * fd when we hold one — no path race — else a path stat), and on overflow
14357
+ * CLOSES the fd before renaming. Closing first is load-bearing on Windows
14358
+ * (renaming a file with an open handle fails with EBUSY/EPERM) and correct
14359
+ * on POSIX too (a held append-fd would otherwise keep writing into the
14360
+ * renamed `.1` inode). The fd is left closed so the next write reopens the
14361
+ * freshly-created file.
14362
+ */
14363
+ rotateIfNeeded() {
14364
+ let size;
14365
+ try {
14366
+ size = this.fd !== void 0 ? fs$1.fstatSync(this.fd).size : fs$1.statSync(this.filePath).size;
14367
+ } catch {
14368
+ return;
14369
+ }
14370
+ if (size <= MAX_LOG_BYTES) return;
14371
+ this.closeFd();
14372
+ try {
14373
+ fs$1.renameSync(this.filePath, this.filePath + ".1");
14374
+ } catch {}
14375
+ }
14376
+ /**
14377
+ * Close the held descriptor. Safe to call repeatedly and after a write
14378
+ * failure. Optional for correctness (writeSync flushes immediately and the
14379
+ * OS closes fds at process exit), but lets a long-lived host release the
14380
+ * handle deterministically on shutdown.
14381
+ */
14382
+ close() {
14383
+ this.closeFd();
13384
14384
  }
13385
14385
  log(logObj, _ctx) {
13386
14386
  if (!ALLOWED_TYPES.has(logObj.type)) return;
@@ -13391,17 +14391,15 @@ var FileLogReporter = class FileLogReporter {
13391
14391
  const line = formatLogLine(logObj);
13392
14392
  this.bytesSinceCheck += line.length;
13393
14393
  if (this.bytesSinceCheck >= FileLogReporter.ROTATE_CHECK_BYTES) {
13394
- rotateIfNeeded(this.filePath);
14394
+ this.rotateIfNeeded();
13395
14395
  this.bytesSinceCheck = 0;
13396
14396
  }
13397
- let fd;
14397
+ const fd = this.ensureFd();
14398
+ if (fd === void 0) return;
13398
14399
  try {
13399
- fd = fs$1.openSync(this.filePath, "a", 384);
13400
14400
  fs$1.writeSync(fd, line);
13401
- } catch {} finally {
13402
- if (fd !== void 0) try {
13403
- fs$1.closeSync(fd);
13404
- } catch {}
14401
+ } catch {
14402
+ this.closeFd();
13405
14403
  }
13406
14404
  }
13407
14405
  };
@@ -13491,6 +14489,8 @@ const PEER_MARKER_OPEN = "<!-- gh-router peer-mcp awareness — auto-injected, r
13491
14489
  const PEER_MARKER_CLOSE = "<!-- /gh-router peer-mcp awareness -->";
13492
14490
  const STYLE_MARKER_OPEN = "<!-- gh-router style directive — auto-injected, regenerated per launch -->";
13493
14491
  const STYLE_MARKER_CLOSE = "<!-- /gh-router style directive -->";
14492
+ const TOOLBELT_MARKER_OPEN = "<!-- gh-router toolbelt awareness — auto-injected, regenerated per launch -->";
14493
+ const TOOLBELT_MARKER_CLOSE = "<!-- /gh-router toolbelt awareness -->";
13494
14494
  /**
13495
14495
  * Writing / communication style directive injected at the TOP of the
13496
14496
  * mirrored CLAUDE.md so every spawned agent (main, Agent-tool subagent,
@@ -13837,6 +14837,303 @@ async function prependStyleDirectiveToMirroredClaudeMd(directive = STYLE_DIRECTI
13837
14837
  label: "style-directive"
13838
14838
  });
13839
14839
  }
14840
+ /**
14841
+ * Append the toolbelt awareness one-liner (which CLI tools are on PATH)
14842
+ * to the bottom of the mirrored CLAUDE.md so descendant agents (Agent
14843
+ * subagents, agent-teams teammates) learn about the provisioned tools.
14844
+ * The main agent gets the same line via `--append-system-prompt`.
14845
+ * Separate marker fence from the peer-awareness / style blocks.
14846
+ */
14847
+ async function appendToolbeltAwarenessToMirroredClaudeMd(snippet) {
14848
+ await injectMarkerBlock({
14849
+ snippet,
14850
+ markerOpen: TOOLBELT_MARKER_OPEN,
14851
+ markerClose: TOOLBELT_MARKER_CLOSE,
14852
+ position: "bottom",
14853
+ label: "toolbelt-awareness"
14854
+ });
14855
+ }
14856
+
14857
+ //#endregion
14858
+ //#region src/lib/toolbelt/extract.ts
14859
+ function baseName(p) {
14860
+ const norm = p.replace(/\\/g, "/");
14861
+ const idx = norm.lastIndexOf("/");
14862
+ return idx === -1 ? norm : norm.slice(idx + 1);
14863
+ }
14864
+ /**
14865
+ * Extract the first REGULAR-FILE tar member whose basename equals
14866
+ * `wantBasename` (optionally with a `.exe` suffix). Returns its bytes,
14867
+ * or null if absent. `buf` is the gzip-compressed tarball.
14868
+ */
14869
+ function extractTarGzMember(buf, wantBasename) {
14870
+ let tar;
14871
+ try {
14872
+ tar = gunzipSync(buf);
14873
+ } catch {
14874
+ return null;
14875
+ }
14876
+ const wants = new Set([wantBasename, `${wantBasename}.exe`]);
14877
+ let offset = 0;
14878
+ while (offset + 512 <= tar.length) {
14879
+ const header = tar.subarray(offset, offset + 512);
14880
+ if (header.every((b) => b === 0)) break;
14881
+ const name$1 = readTarString(header, 0, 100);
14882
+ const prefix = readTarString(header, 345, 155);
14883
+ const fullName = prefix ? `${prefix}/${name$1}` : name$1;
14884
+ const sizeOctal = readTarString(header, 124, 12).trim();
14885
+ const size = parseInt(sizeOctal || "0", 8);
14886
+ const typeflag = String.fromCharCode(header[156]);
14887
+ const dataStart = offset + 512;
14888
+ if ((typeflag === "0" || typeflag === "\0") && wants.has(baseName(fullName))) {
14889
+ if (dataStart + size > tar.length) return null;
14890
+ return Buffer.from(tar.subarray(dataStart, dataStart + size));
14891
+ }
14892
+ offset = dataStart + Math.ceil(size / 512) * 512;
14893
+ }
14894
+ return null;
14895
+ }
14896
+ function readTarString(block, start$1, len) {
14897
+ const slice = block.subarray(start$1, start$1 + len);
14898
+ const nul = slice.indexOf(0);
14899
+ return slice.subarray(0, nul === -1 ? len : nul).toString("utf8");
14900
+ }
14901
+ /**
14902
+ * Extract the first REGULAR-FILE zip member whose basename equals
14903
+ * `wantBasename` (optionally `.exe`). Supports stored (0) and deflate
14904
+ * (8) compression. Rejects directories and unix-symlink entries.
14905
+ */
14906
+ function extractZipMember(buf, wantBasename) {
14907
+ const wants = new Set([wantBasename, `${wantBasename}.exe`]);
14908
+ const EOCD_SIG = 101010256;
14909
+ let eocd = -1;
14910
+ const minStart = Math.max(0, buf.length - 65557);
14911
+ for (let i = buf.length - 22; i >= minStart; i--) if (buf.readUInt32LE(i) === EOCD_SIG) {
14912
+ eocd = i;
14913
+ break;
14914
+ }
14915
+ if (eocd === -1) return null;
14916
+ const entryCount = buf.readUInt16LE(eocd + 10);
14917
+ let cd = buf.readUInt32LE(eocd + 16);
14918
+ const CEN_SIG = 33639248;
14919
+ for (let i = 0; i < entryCount; i++) {
14920
+ if (cd + 46 > buf.length || buf.readUInt32LE(cd) !== CEN_SIG) return null;
14921
+ const method = buf.readUInt16LE(cd + 10);
14922
+ const compSize = buf.readUInt32LE(cd + 20);
14923
+ const nameLen = buf.readUInt16LE(cd + 28);
14924
+ const extraLen = buf.readUInt16LE(cd + 30);
14925
+ const commentLen = buf.readUInt16LE(cd + 32);
14926
+ const externalAttrs = buf.readUInt32LE(cd + 38);
14927
+ const localOffset = buf.readUInt32LE(cd + 42);
14928
+ const name$1 = buf.subarray(cd + 46, cd + 46 + nameLen).toString("utf8");
14929
+ const isSymlink = (externalAttrs >>> 16 & 61440) === 40960;
14930
+ const isDir = name$1.endsWith("/");
14931
+ if (!isSymlink && !isDir && wants.has(baseName(name$1))) return readZipLocalEntry(buf, localOffset, method, compSize);
14932
+ cd += 46 + nameLen + extraLen + commentLen;
14933
+ }
14934
+ return null;
14935
+ }
14936
+ function readZipLocalEntry(buf, localOffset, method, compSize) {
14937
+ if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 67324752) return null;
14938
+ const nameLen = buf.readUInt16LE(localOffset + 26);
14939
+ const extraLen = buf.readUInt16LE(localOffset + 28);
14940
+ const dataStart = localOffset + 30 + nameLen + extraLen;
14941
+ const comp = buf.subarray(dataStart, dataStart + compSize);
14942
+ try {
14943
+ if (method === 0) return Buffer.from(comp);
14944
+ if (method === 8) return inflateRawSync(comp);
14945
+ } catch {
14946
+ return null;
14947
+ }
14948
+ return null;
14949
+ }
14950
+
14951
+ //#endregion
14952
+ //#region src/lib/toolbelt/provision.ts
14953
+ /** Per-download cap (bytes) — these binaries are a few MB at most. */
14954
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
14955
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
14956
+ const EXE_EXT = process$1.platform === "win32" ? ".exe" : "";
14957
+ /**
14958
+ * Materialize the toolbelt. Returns the list of command names exposed
14959
+ * in `bin/` after provisioning. Best-effort; never throws.
14960
+ */
14961
+ async function provisionToolbelt() {
14962
+ if (!toolbeltEnabled()) return [];
14963
+ const binDir = PATHS.TOOLBELT_BIN_DIR;
14964
+ try {
14965
+ await mkdir(binDir, { recursive: true });
14966
+ } catch (err) {
14967
+ consola.debug("toolbelt: could not create bin dir:", err);
14968
+ return [];
14969
+ }
14970
+ const skip = toolbeltSkipSet();
14971
+ await withInstallLock("toolbelt.lock", async () => {
14972
+ await pruneUnexpected(binDir);
14973
+ await provisionRg(binDir, skip).catch((err) => consola.debug("toolbelt: rg skipped:", err));
14974
+ await Promise.all(TOOLBELT_TOOLS.map((spec) => provisionTool(spec, binDir, skip).catch((err) => consola.debug(`toolbelt: ${spec.command} skipped:`, err))));
14975
+ });
14976
+ return exposedCommands(binDir);
14977
+ }
14978
+ /** Names allowed to live in `bin/` (managed binaries + their sidecars). */
14979
+ function expectedFileNames() {
14980
+ const names = /* @__PURE__ */ new Set();
14981
+ const add = (base) => {
14982
+ names.add(base + EXE_EXT);
14983
+ names.add(`${base}${EXE_EXT}.sha256`);
14984
+ };
14985
+ add("rg");
14986
+ for (const spec of TOOLBELT_TOOLS) {
14987
+ add(spec.binBasename);
14988
+ for (const a of spec.aliases ?? []) add(a);
14989
+ }
14990
+ return names;
14991
+ }
14992
+ /** Remove any file in `bin/` that isn't a managed binary or sidecar. */
14993
+ async function pruneUnexpected(binDir) {
14994
+ const expected = expectedFileNames();
14995
+ let entries;
14996
+ try {
14997
+ entries = await readdir(binDir);
14998
+ } catch {
14999
+ return;
15000
+ }
15001
+ for (const name$1 of entries) {
15002
+ if (name$1.endsWith(".tmp")) continue;
15003
+ if (!expected.has(name$1)) await rm(path.join(binDir, name$1), { force: true }).catch(() => {});
15004
+ }
15005
+ }
15006
+ async function provisionRg(binDir, skip) {
15007
+ const dest = path.join(binDir, "rg" + EXE_EXT);
15008
+ if (skip.has("rg") || resolveExecutable("rg")) {
15009
+ await removeBin(dest);
15010
+ return;
15011
+ }
15012
+ if (existsSync(dest)) return;
15013
+ const src = vscodeRipgrepPath();
15014
+ if (!src) return;
15015
+ const tmp = tempName(dest);
15016
+ try {
15017
+ await link(src, tmp);
15018
+ } catch {
15019
+ await copyFile(src, tmp);
15020
+ }
15021
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
15022
+ await commit(tmp, dest);
15023
+ }
15024
+ async function provisionTool(spec, binDir, skip) {
15025
+ const dest = path.join(binDir, spec.binBasename + EXE_EXT);
15026
+ const sidecar = `${dest}.sha256`;
15027
+ const asset = assetFor(spec);
15028
+ if (skip.has(spec.command) || !asset) {
15029
+ await removeTool(spec, binDir);
15030
+ return;
15031
+ }
15032
+ if (resolveExecutable(spec.command)) {
15033
+ await removeTool(spec, binDir);
15034
+ return;
15035
+ }
15036
+ if (existsSync(dest) && await sidecarMatches(sidecar, asset.sha256)) {
15037
+ await ensureAliases(spec, binDir, dest);
15038
+ return;
15039
+ }
15040
+ await atomicInstall(dest, await downloadAndExtract(spec, asset));
15041
+ await writeFile(sidecar, asset.sha256).catch(() => {});
15042
+ await ensureAliases(spec, binDir, dest);
15043
+ }
15044
+ async function downloadAndExtract(spec, asset) {
15045
+ const data = await download(asset.url);
15046
+ const digest = createHash("sha256").update(data).digest("hex");
15047
+ if (digest !== asset.sha256) throw new Error(`checksum mismatch for ${spec.command} (${asset.url}): expected ${asset.sha256}, got ${digest}`);
15048
+ if (asset.archive === "raw") return data;
15049
+ const member = asset.archive === "zip" ? extractZipMember(data, spec.binBasename) : extractTarGzMember(data, spec.binBasename);
15050
+ if (!member) throw new Error(`binary "${spec.binBasename}" not found in ${asset.url}`);
15051
+ return member;
15052
+ }
15053
+ async function download(url) {
15054
+ const controller = new AbortController();
15055
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
15056
+ try {
15057
+ const res = await fetch(url, {
15058
+ signal: controller.signal,
15059
+ redirect: "follow",
15060
+ headers: { "User-Agent": "github-router-toolbelt" }
15061
+ });
15062
+ if (!res.ok) throw new Error(`download ${url}: HTTP ${res.status}`);
15063
+ const buf = Buffer.from(await res.arrayBuffer());
15064
+ if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`download ${url}: exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
15065
+ return buf;
15066
+ } finally {
15067
+ clearTimeout(timer);
15068
+ }
15069
+ }
15070
+ /** Write to a unique temp then atomically rename into place. */
15071
+ async function atomicInstall(dest, bytes) {
15072
+ const tmp = tempName(dest);
15073
+ await writeFile(tmp, bytes);
15074
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
15075
+ await commit(tmp, dest);
15076
+ }
15077
+ /** Rename tmp→dest, handling Windows replace-existing / in-use locks. */
15078
+ async function commit(tmp, dest) {
15079
+ try {
15080
+ await rename(tmp, dest);
15081
+ } catch {
15082
+ try {
15083
+ await rm(dest, { force: true });
15084
+ await rename(tmp, dest);
15085
+ } catch (err) {
15086
+ await rm(tmp, { force: true }).catch(() => {});
15087
+ throw err;
15088
+ }
15089
+ }
15090
+ }
15091
+ async function ensureAliases(spec, binDir, dest) {
15092
+ for (const alias of spec.aliases ?? []) {
15093
+ const ap = path.join(binDir, alias + EXE_EXT);
15094
+ if (existsSync(ap)) continue;
15095
+ const tmp = tempName(ap);
15096
+ try {
15097
+ await copyFile(dest, tmp);
15098
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
15099
+ await commit(tmp, ap);
15100
+ } catch (err) {
15101
+ consola.debug(`toolbelt: alias ${alias} skipped:`, err);
15102
+ }
15103
+ }
15104
+ }
15105
+ async function removeTool(spec, binDir) {
15106
+ await removeBin(path.join(binDir, spec.binBasename + EXE_EXT));
15107
+ for (const alias of spec.aliases ?? []) await removeBin(path.join(binDir, alias + EXE_EXT));
15108
+ }
15109
+ async function removeBin(dest) {
15110
+ await rm(dest, { force: true }).catch(() => {});
15111
+ await rm(`${dest}.sha256`, { force: true }).catch(() => {});
15112
+ }
15113
+ async function sidecarMatches(sidecar, sha256) {
15114
+ try {
15115
+ return (await readFile(sidecar, "utf8")).trim() === sha256;
15116
+ } catch {
15117
+ return false;
15118
+ }
15119
+ }
15120
+ function tempName(dest) {
15121
+ return `${dest}.${process$1.pid}.${randomBytes(4).toString("hex")}.tmp`;
15122
+ }
15123
+ /** The command names currently exposed in `bin/`. */
15124
+ async function exposedCommands(binDir) {
15125
+ let files;
15126
+ try {
15127
+ files = new Set(await readdir(binDir));
15128
+ } catch {
15129
+ return [];
15130
+ }
15131
+ const present = (base) => files.has(base + EXE_EXT);
15132
+ const out = [];
15133
+ if (present("rg")) out.push("rg");
15134
+ for (const spec of TOOLBELT_TOOLS) if (present(spec.binBasename)) out.push(spec.command);
15135
+ return out;
15136
+ }
13840
15137
 
13841
15138
  //#endregion
13842
15139
  //#region src/lib/proxy.ts
@@ -13887,7 +15184,7 @@ function initProxyFromEnv() {
13887
15184
  //#endregion
13888
15185
  //#region package.json
13889
15186
  var name = "github-router";
13890
- var version$1 = "0.3.68";
15187
+ var version$1 = "0.3.72";
13891
15188
 
13892
15189
  //#endregion
13893
15190
  //#region src/lib/approval.ts
@@ -14273,7 +15570,14 @@ embeddingRoutes.post("/", async (c) => {
14273
15570
  const mcpRoutes = new Hono();
14274
15571
  mcpRoutes.post("/", async (c) => {
14275
15572
  try {
14276
- return await handleMcpPost(c);
15573
+ return await handleMcpPost(c, "all");
15574
+ } catch (error) {
15575
+ return await forwardError(c, error);
15576
+ }
15577
+ });
15578
+ mcpRoutes.post("/:group", async (c) => {
15579
+ try {
15580
+ return await handleMcpPost(c, c.req.param("group"));
14277
15581
  } catch (error) {
14278
15582
  return await forwardError(c, error);
14279
15583
  }
@@ -14285,6 +15589,13 @@ mcpRoutes.delete("/", (c) => {
14285
15589
  return c.body(null, 500);
14286
15590
  }
14287
15591
  });
15592
+ mcpRoutes.delete("/:group", (c) => {
15593
+ try {
15594
+ return handleMcpDelete(c);
15595
+ } catch {
15596
+ return c.body(null, 500);
15597
+ }
15598
+ });
14288
15599
 
14289
15600
  //#endregion
14290
15601
  //#region src/lib/sanitize-anthropic-body.ts
@@ -15553,6 +16864,7 @@ usageRoute.get("/", async (c) => {
15553
16864
 
15554
16865
  //#endregion
15555
16866
  //#region src/server.ts
16867
+ assertMcpToolSurfaceConsistent();
15556
16868
  const server = new Hono();
15557
16869
  server.use(cors());
15558
16870
  server.get("/", (c) => c.text("Server running"));
@@ -15731,6 +17043,11 @@ const sharedServerArgs = {
15731
17043
  type: "boolean",
15732
17044
  default: false,
15733
17045
  description: "Force humanlike pacing on ALL browser tool dispatches: Beta-distributed inter-action delays (800-4600 ms), Bezier mouse trajectories with overshoot-and-correct, per-keystroke jitter with word-end pauses, scroll chunking. Use for known anti-bot sites (Cloudflare, Datadome). Off by default (auto mode); GH_ROUTER_HUMANLIKE=1 is the env equivalent. GH_ROUTER_BROWSER_NO_HUMANLIKE=1 hard-disables (wins over --humanlike, for tests)."
17046
+ },
17047
+ "self-update": {
17048
+ type: "boolean",
17049
+ default: true,
17050
+ 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."
15734
17051
  }
15735
17052
  };
15736
17053
  const allowedAccountTypes = new Set([
@@ -15836,6 +17153,7 @@ function getClaudeCodeEnvVars(serverUrl, model) {
15836
17153
  "CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
15837
17154
  "CLAUDE_CODE_ENABLE_TASKS"
15838
17155
  ]) if (process.env[key] === void 0) vars[key] = "1";
17156
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
15839
17157
  return vars;
15840
17158
  }
15841
17159
  /**
@@ -15851,11 +17169,13 @@ function getClaudeCodeEnvVars(serverUrl, model) {
15851
17169
  * masks any cached login.
15852
17170
  */
15853
17171
  function getCodexEnvVars(serverUrl) {
15854
- return {
17172
+ const vars = {
15855
17173
  OPENAI_BASE_URL: `${serverUrl}/v1`,
15856
17174
  OPENAI_API_KEY: "dummy",
15857
17175
  CODEX_HOME: PATHS.CODEX_HOME
15858
17176
  };
17177
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
17178
+ return vars;
15859
17179
  }
15860
17180
 
15861
17181
  //#endregion
@@ -15895,7 +17215,7 @@ const claude = defineCommand({
15895
17215
  "auto-update": {
15896
17216
  type: "boolean",
15897
17217
  default: true,
15898
- description: "Check for and install latest Claude Code on launch (throttled to once per hour via ~/.local/share/github-router/last-update-check). Set to false (--no-auto-update) to keep the current installed version. Falls back gracefully if npm/network unavailable."
17218
+ description: "Check for and install the latest Claude Code on launch via `claude update` (throttled to once per hour via ~/.local/share/github-router/last-update-check). `claude update` respects the real install method (native installer or npm), so it never creates a conflicting second install; builds too old to support it fall back to `npm install -g @anthropic-ai/claude-code@latest`. Set to false (--no-auto-update) to check and warn only. Falls back gracefully if claude/npm/network unavailable."
15899
17219
  },
15900
17220
  "update-check": {
15901
17221
  type: "boolean",
@@ -15918,12 +17238,12 @@ const claude = defineCommand({
15918
17238
  if (versionCheck.skipped && versionCheck.skipReason === "no-claude") consola.debug("claude --version probe failed; skipping auto-update.");
15919
17239
  else if (versionCheck.skipped && versionCheck.skipReason === "no-npm") consola.debug("npm view @anthropic-ai/claude-code failed; skipping auto-update check (likely offline).");
15920
17240
  else if (versionCheck.needsUpdate && versionCheck.installedVersion && versionCheck.latestVersion) if (args["auto-update"] !== false) try {
15921
- await autoUpdateClaude(versionCheck.latestVersion);
17241
+ await updateClaude(versionCheck.latestVersion);
15922
17242
  } catch (err) {
15923
17243
  const msg = err instanceof Error ? err.message : String(err);
15924
- consola.warn(`Auto-update of Claude Code from ${versionCheck.installedVersion} to ${versionCheck.latestVersion} failed (${msg}); continuing with installed version. Run \`npm install -g @anthropic-ai/claude-code@latest\` manually to retry.`);
17244
+ consola.warn(`Auto-update of Claude Code from ${versionCheck.installedVersion} to ${versionCheck.latestVersion} failed (${msg}); continuing with installed version. Run \`claude update\` (or \`npm install -g @anthropic-ai/claude-code@latest\`) manually to retry.`);
15925
17245
  }
15926
- else consola.warn(`Claude Code v${versionCheck.installedVersion} is installed; v${versionCheck.latestVersion} is available. Run with --auto-update (the default) to install on launch, or \`npm install -g @anthropic-ai/claude-code@latest\` manually.`);
17246
+ else consola.warn(`Claude Code v${versionCheck.installedVersion} is installed; v${versionCheck.latestVersion} is available. Run with --auto-update (the default) to install on launch, or \`claude update\` manually.`);
15927
17247
  } catch (err) {
15928
17248
  consola.debug("Claude version check failed:", err);
15929
17249
  }
@@ -15941,6 +17261,7 @@ const claude = defineCommand({
15941
17261
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
15942
17262
  process$1.exit(1);
15943
17263
  }
17264
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
15944
17265
  try {
15945
17266
  await ensureClaudeConfigMirror();
15946
17267
  } catch (err) {
@@ -15972,6 +17293,15 @@ const claude = defineCommand({
15972
17293
  process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
15973
17294
  const envVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
15974
17295
  const extraArgs = args._ ?? [];
17296
+ if (toolbeltEnabled()) {
17297
+ provisionToolbelt().catch((err) => consola.debug("Toolbelt provisioning failed:", err));
17298
+ const toolbeltLine = buildToolbeltAwareness(availableToolCommands());
17299
+ if (toolbeltLine) try {
17300
+ await appendToolbeltAwarenessToMirroredClaudeMd(toolbeltLine);
17301
+ } catch (err) {
17302
+ consola.warn(`Toolbelt CLAUDE.md append failed: ${err instanceof Error ? err.message : String(err)}`);
17303
+ }
17304
+ }
15975
17305
  const baseShutdown = async () => {
15976
17306
  await removeOwnClaudeConfigMirror();
15977
17307
  };
@@ -15984,9 +17314,15 @@ const claude = defineCommand({
15984
17314
  });
15985
17315
  const geminiAvailable$1 = state.models?.data.some((m) => /^gemini-3\..*pro/i.test(m.id)) ?? false;
15986
17316
  if (!geminiAvailable$1) consola.info("gemini-3.1-pro-preview not found in your Copilot model catalog; gemini-critic persona will not be registered.");
17317
+ const enabledGroups = ["peers", "search"];
17318
+ if (workerToolsEnabled()) enabledGroups.push("workers");
17319
+ if (standInToolEnabled()) enabledGroups.push("decide");
17320
+ if (browserToolsEnabled()) enabledGroups.push("browser");
17321
+ const { keys: groupKeys, skipped: skippedGroups } = await resolveGroupKeysFromMirror(enabledGroups);
15987
17322
  const runtime = await writePeerMcpRuntimeFiles(serverUrl, {
15988
17323
  codexCli: backend === "cli",
15989
- geminiAvailable: geminiAvailable$1
17324
+ geminiAvailable: geminiAvailable$1,
17325
+ groupKeys
15990
17326
  });
15991
17327
  state.peerMcpNonce = runtime.nonce;
15992
17328
  onShutdown = async () => {
@@ -15996,6 +17332,7 @@ const claude = defineCommand({
15996
17332
  const injected = await injectPeerMcpIntoMirror(serverUrl, {
15997
17333
  codexCli: backend === "cli",
15998
17334
  geminiAvailable: geminiAvailable$1,
17335
+ groupKeys,
15999
17336
  nonce: runtime.nonce
16000
17337
  });
16001
17338
  if (!injected.ok) {
@@ -16004,14 +17341,16 @@ const claude = defineCommand({
16004
17341
  } else if (args["codex-mcp-only"] === true) consola.warn("--codex-mcp-only has no effect when peer MCP is wired via the mirrored .claude.json (the user's existing user-scope MCPs in the snapshot are still visible). Pass --no-codex-mcp to skip peer-MCP wiring entirely.");
16005
17342
  const personaNames = runtime.personas.map((p) => p.agentName).join(", ");
16006
17343
  const subagentVisibility = injected.ok ? `subagent-visible (mirrored mcpServers: [${injected.serversAdded.join(", ")}])` : `subagent-INVISIBLE (collision on user-side mcpServers: [${injected.conflictingServers.join(", ")}]; parent-only via --mcp-config)`;
16007
- process$1.stderr.write(`Peer MCP wired (backend=${backend}, personas=[${personaNames}], subagent .md files=${runtime.agentMdPaths.length}, ${subagentVisibility}).\n`);
17344
+ const skippedNote = skippedGroups.length > 0 ? ` WARNING: groups [${skippedGroups.join(", ")}] skipped both the bare and \`gh-router-<group>\` keys collide with your own mcpServers; those tools are unavailable this session (rename the user-side server to re-enable).` : "";
17345
+ process$1.stderr.write(`Peer MCP wired (backend=${backend}, personas=[${personaNames}], subagent .md files=${runtime.agentMdPaths.length}, ${subagentVisibility}).${skippedNote}\n`);
16008
17346
  const peerSnippet = buildPeerAwarenessSnippet({
16009
17347
  codexCli: backend === "cli",
16010
17348
  geminiAvailable: geminiAvailable$1,
16011
17349
  workerToolsAvailable: workerToolsEnabled(),
16012
17350
  standInAvailable: standInToolEnabled(),
16013
17351
  browseAvailable: state.browseEnabled,
16014
- powerBrowseAvailable: state.powerBrowseEnabled
17352
+ powerBrowseAvailable: state.powerBrowseEnabled,
17353
+ groupKeys
16015
17354
  });
16016
17355
  extraArgs.push("--append-system-prompt", peerSnippet);
16017
17356
  try {
@@ -16071,6 +17410,8 @@ const codex = defineCommand({
16071
17410
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
16072
17411
  process$1.exit(1);
16073
17412
  }
17413
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
17414
+ if (toolbeltEnabled()) provisionToolbelt().catch(() => {});
16074
17415
  const usingDefault = !args.model;
16075
17416
  const requestedModel = args.model ?? DEFAULT_CODEX_MODEL;
16076
17417
  enableFileLogging();
@@ -16443,6 +17784,7 @@ const start = defineCommand({
16443
17784
  port: parsed.port ?? DEFAULT_PORT,
16444
17785
  silent: false
16445
17786
  });
17787
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
16446
17788
  if (args.cc) generateClaudeCodeCommand(serverUrl, args.model);
16447
17789
  if (args.cx) generateCodexCommand(serverUrl, args.model);
16448
17790
  consola.box(`🌐 Usage Viewer: https://animeshkundu.github.io/github-router/dashboard.html?endpoint=${serverUrl}/usage`);