github-router 0.3.66 → 0.3.71

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-CoFnpNZl.js";
3
+ import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-NQRdfY1u.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,38 +1154,224 @@ 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`,
922
1195
  "--silent"
923
- ], { timeout: NPM_INSTALL_TIMEOUT_MS });
924
- consola.success(`${NPM_PACKAGE} updated to ${latestVersion}`);
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",
1267
+ "--silent"
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
 
931
1344
  //#endregion
932
1345
  //#region src/lib/port.ts
933
1346
  const DEFAULT_PORT = 8787;
934
- const DEFAULT_CLAUDE_MODEL_FALLBACKS = ["claude-opus-4-6", "claude-opus-4-5"];
1347
+ const DEFAULT_CLAUDE_MODEL_FALLBACKS = [
1348
+ "claude-opus-4-7",
1349
+ "claude-opus-4-6",
1350
+ "claude-opus-4-5"
1351
+ ];
935
1352
  /**
936
1353
  * Cap-aware default picker for `ANTHROPIC_MODEL` on the implicit-default
937
1354
  * path. Returns `claude-opus-${family}[1m]` when the live Copilot catalog
938
- * contains an `opus-${family}-1m*` variant (enterprise tier), else the
939
- * bare `claude-opus-${family}` slug. `family` defaults to `"4.7"` so the
940
- * no-arg call preserves the original behavior; explicit values like
941
- * `"4.6"` or `"4.8"` are used to honor the `github-router claude
942
- * -m <version>` family shorthand.
1355
+ * shows the family is 1M-capable, else the bare `claude-opus-${family}`
1356
+ * slug. `family` defaults to `"4.8"` so the no-arg call selects the
1357
+ * current default; explicit values like `"4.7"` or `"4.6"` are used to
1358
+ * honor the `github-router claude -m <version>` family shorthand.
1359
+ *
1360
+ * **Dual-signal 1M detection**. The Opus families have evolved different
1361
+ * shapes in Copilot's catalog over time:
1362
+ * 1. **Sibling-slug signal** — `opus-${family}-1m` (or `opus-${family}-1m-internal`)
1363
+ * exists as a separate catalog entry distinct from the base slug.
1364
+ * This is how 4.6 and 4.7 ship (`claude-opus-4.6-1m`,
1365
+ * `claude-opus-4.7-1m-internal`). Matched by the version-anchored
1366
+ * regex below.
1367
+ * 2. **Base-slug capability signal** — the catalog entry whose id IS
1368
+ * the base `opus-${family}` slug advertises
1369
+ * `capabilities.limits.max_context_window_tokens >= 1_000_000`. This
1370
+ * is how 4.8 ships — there is no `-1m` sibling; the single
1371
+ * `claude-opus-4.8` id is the 1M variant.
1372
+ * Either signal flips on the `[1m]` decoration. Both signals together
1373
+ * also flip it on (no double-counting). The breadcrumb log names which
1374
+ * signal fired so users can spot catalog shape changes.
943
1375
  *
944
1376
  * The `[1m]` literal-bracket suffix is Claude Code's local 1M-context
945
1377
  * unlock — cc-backup `src/utils/context.ts:35-40` matches `/\[1m\]/i`
@@ -949,14 +1381,14 @@ const DEFAULT_CLAUDE_MODEL_FALLBACKS = ["claude-opus-4-6", "claude-opus-4-5"];
949
1381
  * proxy routes the underlying request.
950
1382
  *
951
1383
  * Cap-awareness matters because on non-enterprise Copilot tiers there
952
- * is no `-1m` opus backend; sending `[1m]` there would either 400 at
1384
+ * is no 1M opus backend; sending `[1m]` there would either 400 at
953
1385
  * Copilot or (with `resolveModel`'s graceful-degrade) silently
954
1386
  * downgrade upstream while Claude Code still over-accounts context.
955
1387
  * This helper detects the catalog state at launch and only opts in
956
1388
  * when the backend can actually serve 1M.
957
1389
  *
958
1390
  * Sonnet/Haiku families are intentionally NOT given `[1m]` defaults
959
- * because Copilot has no `-1m` backend for them (and Anthropic-side
1391
+ * because Copilot has no 1M backend for them (and Anthropic-side
960
1392
  * `modelSupports1M` doesn't list haiku at all). See
961
1393
  * `src/lib/server-setup.ts:getClaudeCodeEnvVars` for the
962
1394
  * `ANTHROPIC_DEFAULT_{SONNET,HAIKU,OPUS}_MODEL` tier defaults.
@@ -966,18 +1398,25 @@ const DEFAULT_CLAUDE_MODEL_FALLBACKS = ["claude-opus-4-6", "claude-opus-4-5"];
966
1398
  * can't tell the difference between "no catalog yet" and "no 1M
967
1399
  * variant" — defaulting safe-side preserves the pre-change behavior).
968
1400
  */
969
- const DEFAULT_OPUS_FAMILY = "4.7";
1401
+ const DEFAULT_OPUS_FAMILY = "4.8";
1402
+ const ONE_M_TOKENS = 1e6;
970
1403
  function pickClaudeDefault(opusFamily = DEFAULT_OPUS_FAMILY) {
971
1404
  const dotted = opusFamily.replace(/-/g, ".");
972
1405
  const bareSlug = `claude-opus-${dotted.replace(/\./g, "-")}`;
973
1406
  const versionPattern = dotted.replace(/\./g, "[.-]");
974
1407
  const oneMRegex = new RegExp(`opus-${versionPattern}-1m(?:$|-)`, "i");
1408
+ const baseSlugRegex = new RegExp(`^claude-opus-${versionPattern}$`, "i");
975
1409
  const familyRegex = new RegExp(`opus-${versionPattern}(?:$|[-.])`, "i");
976
1410
  const models$1 = state.models?.data ?? [];
977
- const has1m = models$1.some((m) => oneMRegex.test(m.id));
1411
+ const siblingOneM = models$1.some((m) => oneMRegex.test(m.id));
1412
+ const baseSlugMaxContext = models$1.reduce((max, m) => baseSlugRegex.test(m.id) ? Math.max(max, m.capabilities?.limits?.max_context_window_tokens ?? 0) : max, 0);
1413
+ const baseSlugOneM = baseSlugMaxContext >= ONE_M_TOKENS;
1414
+ const has1m = siblingOneM || baseSlugOneM;
978
1415
  if (opusFamily !== DEFAULT_OPUS_FAMILY && state.models && models$1.length > 0 && !models$1.some((m) => familyRegex.test(m.id))) consola.warn(`Requested Opus family "${dotted}" not found in Copilot catalog; using "${bareSlug}" anyway (resolveModel may not find a backend for it).`);
979
1416
  if (has1m) {
980
- consola.info(`Catalog contains opus-${dotted}-1m variant; defaulting ANTHROPIC_MODEL to "${bareSlug}[1m]" so Claude Code accounts for 1M context locally. Set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 to opt out (HIPAA), or pass --model ${bareSlug} to pin 200K.`);
1417
+ const signal = siblingOneM ? baseSlugOneM ? "sibling-slug + base-slug 1M capability" : `sibling slug opus-${dotted}-1m` : `base slug ${bareSlug} (max_context_window_tokens=${baseSlugMaxContext})`;
1418
+ const pinHint = siblingOneM ? ` Pass --model ${bareSlug} to pin 200K.` : ` (No separate 200K variant of ${dotted} exists in the catalog — the bare slug IS the 1M backend.)`;
1419
+ consola.info(`Catalog signals opus-${dotted} is 1M-capable (${signal}); defaulting ANTHROPIC_MODEL to "${bareSlug}[1m]" so Claude Code accounts for 1M context locally. Set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 to opt out (HIPAA).${pinHint}`);
981
1420
  return `${bareSlug}[1m]`;
982
1421
  }
983
1422
  return bareSlug;
@@ -1013,6 +1452,47 @@ function envInt$1(key, fallback) {
1013
1452
  const UPSTREAM_FETCH_TIMEOUT_MS = envInt$1("UPSTREAM_FETCH_TIMEOUT_MS", 0);
1014
1453
  const UPSTREAM_INACTIVITY_TIMEOUT_MS = envInt$1("UPSTREAM_INACTIVITY_TIMEOUT_MS", 3e5);
1015
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
+
1016
1496
  //#endregion
1017
1497
  //#region src/lib/launch.ts
1018
1498
  /**
@@ -1077,6 +1557,22 @@ function commandExists(name$1) {
1077
1557
  }
1078
1558
  }
1079
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
+ /**
1080
1576
  * Provider-config flags (`-c model_providers.github_router=...`) that
1081
1577
  * point Codex at our proxy. Extracted from `buildCodexCmd` so the new
1082
1578
  * `codex mcp-server` MCP-config builder can reuse the exact same
@@ -1144,22 +1640,25 @@ function buildCodexCmd(target) {
1144
1640
  return cmd;
1145
1641
  }
1146
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;
1147
1650
  return {
1148
- cmd: target.kind === "claude-code" ? [
1149
- "claude",
1150
- "--dangerously-skip-permissions",
1151
- ...target.extraArgs
1152
- ] : buildCodexCmd(target),
1153
- env: {
1651
+ cmd,
1652
+ env: collapsePathKeys({
1154
1653
  ...sanitizeParentEnv(process$1.env),
1155
1654
  ...target.envVars
1156
- }
1655
+ })
1157
1656
  };
1158
1657
  }
1159
1658
  function launchChild(target, server$1, options = {}) {
1160
1659
  const { cmd, env } = buildLaunchCommand(target);
1161
1660
  const executable = cmd[0];
1162
- if (!commandExists(executable)) {
1661
+ if (!isExecutableAvailable(executable)) {
1163
1662
  const msg = `"${executable}" not found on PATH. Install it first, then try again.`;
1164
1663
  consola.error(msg);
1165
1664
  process$1.stderr.write(msg + "\n");
@@ -2473,33 +2972,6 @@ function round4(x) {
2473
2972
  return Math.round(x * 1e4) / 1e4;
2474
2973
  }
2475
2974
 
2476
- //#endregion
2477
- //#region src/lib/version.ts
2478
- /**
2479
- * Read this binary's published version from package.json at runtime.
2480
- *
2481
- * Done at runtime (not baked at build time) because release.yml builds
2482
- * BEFORE `npm version patch` bumps the version — a build-time inline
2483
- * would always ship the pre-bump value. The npm tarball ships package.json
2484
- * alongside `dist/`, so a sibling-up lookup from import.meta.url resolves
2485
- * cleanly in both dev (`src/lib/`) and bundled (`dist/`) layouts.
2486
- *
2487
- * Returns `"unknown"` if package.json can't be located or parsed —
2488
- * never throws, so the CLI never fails to start over version reporting.
2489
- */
2490
- function getPackageVersion() {
2491
- try {
2492
- const here = dirname(fileURLToPath(import.meta.url));
2493
- const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
2494
- for (const path$2 of candidates) try {
2495
- const raw = readFileSync(path$2, "utf8");
2496
- const parsed = JSON.parse(raw);
2497
- if (typeof parsed.version === "string" && (parsed.name === "github-router" || parsed.name === "@animeshkundu/github-router")) return parsed.version;
2498
- } catch {}
2499
- } catch {}
2500
- return "unknown";
2501
- }
2502
-
2503
2975
  //#endregion
2504
2976
  //#region src/lib/browser-mcp/browser-detect.ts
2505
2977
  let cached;
@@ -3470,7 +3942,7 @@ function logAudit$1(record) {
3470
3942
  try {
3471
3943
  const fs$2 = await import("node:fs/promises");
3472
3944
  const path$2 = await import("node:path");
3473
- const { PATHS: PATHS$1 } = await import("./paths-CW16Dz9_.js");
3945
+ const { PATHS: PATHS$1 } = await import("./paths-DhM3Yi80.js");
3474
3946
  const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
3475
3947
  await fs$2.mkdir(dir, { recursive: true });
3476
3948
  const line = JSON.stringify({
@@ -8494,17 +8966,21 @@ function browserToolsEnabled() {
8494
8966
  return hasSupportedBrowserInstalled();
8495
8967
  }
8496
8968
  /**
8497
- * The 1M-context Opus variant (`claude-opus-4.7-1m-internal`,
8498
- * `max_prompt_tokens` 936K), gated `restricted_to: ["enterprise"]`.
8499
- * opus_critic prefers it so it can take large artifacts in one shot
8969
+ * The 1M-context Opus 4.6 variant (`claude-opus-4.6-1m`, `max_prompt_tokens`
8970
+ * 936K). opus_critic prefers it so it can take large artifacts in one shot
8500
8971
  * (the whole point of pairing it with gpt-5.5 as the big-window peers);
8501
- * falls back to the 200K `claude-opus-4-7` when the catalog (non-
8502
- * enterprise) doesn't carry a 1M opus slug.
8503
- */
8504
- const OPUS_1M_RE = /opus-4\.7.*1m/i;
8972
+ * falls back to the 200K `claude-opus-4-6` when the catalog doesn't carry
8973
+ * a 1M 4.6 slug. The regex is version-anchored to 4.6 AND requires a
8974
+ * `-1m` suffix boundary (not a permissive `.*1m`), so it does NOT
8975
+ * false-positive on `claude-opus-4.7-1m-internal` (stand_in's pinned
8976
+ * 4.7 row), `claude-opus-4.6-1max` (hypothetical), or `claude-opus-4.8`
8977
+ * (1M-without-sibling). Tolerates dotted (`opus-4.6-1m`) and dashed
8978
+ * (`opus-4-6-1m`) catalog separators.
8979
+ */
8980
+ const OPUS_1M_RE = /opus-4[.-]6-1m(?:$|-)/i;
8505
8981
  function resolveOpusCriticModel() {
8506
8982
  const oneM = state.models?.data?.find((m) => OPUS_1M_RE.test(m.id));
8507
- return oneM ? oneM.id : "claude-opus-4-7";
8983
+ return oneM ? oneM.id : "claude-opus-4-6";
8508
8984
  }
8509
8985
  function activePersonas() {
8510
8986
  return PERSONAS_READ.filter((p) => !p.requiresGeminiCatalog || geminiAvailable()).map((p) => p.toolNameHttp === "opus_critic" ? {
@@ -8600,6 +9076,8 @@ function toolError(message) {
8600
9076
  * gpt-5.3-codex high on ~600B = 16.0s → ~64s on 12KB
8601
9077
  * claude-opus-4-7 medium (thinking=3000) on a trivial prompt = 22.5s
8602
9078
  * but model self-paces budget → ~50s+ on a real ~6KB review
9079
+ * (still applicable to stand_in's 4.7 row; opus_critic now runs on
9080
+ * 4.6 with similar empirical shape)
8603
9081
  *
8604
9082
  * Returns `{tooLong: true, capBytes}` when the (persona, effort, briefBytes)
8605
9083
  * tuple is empirically predicted to bust the 60s ceiling.
@@ -10194,6 +10672,239 @@ async function searchWeb(query, signal) {
10194
10672
  }
10195
10673
  }
10196
10674
 
10675
+ //#endregion
10676
+ //#region src/lib/toolbelt/manifest.ts
10677
+ function platformArchKey(platform$1 = process.platform, arch = process.arch) {
10678
+ return `${platform$1}-${arch}`;
10679
+ }
10680
+ function assetFor(spec, platform$1 = process.platform, arch = process.arch) {
10681
+ return spec.assets[platformArchKey(platform$1, arch)];
10682
+ }
10683
+ const TOOLBELT_TOOLS = [
10684
+ {
10685
+ command: "fd",
10686
+ binBasename: "fd",
10687
+ assets: {
10688
+ "win32-x64": {
10689
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-pc-windows-msvc.zip",
10690
+ sha256: "b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8",
10691
+ archive: "zip"
10692
+ },
10693
+ "win32-arm64": {
10694
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-pc-windows-msvc.zip",
10695
+ sha256: "4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92",
10696
+ archive: "zip"
10697
+ },
10698
+ "darwin-arm64": {
10699
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz",
10700
+ sha256: "623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
10701
+ archive: "tar.gz"
10702
+ },
10703
+ "linux-x64": {
10704
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz",
10705
+ sha256: "e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
10706
+ archive: "tar.gz"
10707
+ },
10708
+ "linux-arm64": {
10709
+ url: "https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-unknown-linux-musl.tar.gz",
10710
+ sha256: "f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
10711
+ archive: "tar.gz"
10712
+ }
10713
+ }
10714
+ },
10715
+ {
10716
+ command: "sd",
10717
+ binBasename: "sd",
10718
+ assets: {
10719
+ "win32-x64": {
10720
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-pc-windows-msvc.zip",
10721
+ sha256: "59837c2e7c911099aca1cc46b663bcdc5a949fd3e9fbbaf34fc73e5d5d71007c",
10722
+ archive: "zip"
10723
+ },
10724
+ "darwin-x64": {
10725
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-apple-darwin.tar.gz",
10726
+ sha256: "1fca1e9c91813a8aac6821063c923107ba0f66a83309e095edcd3b202f67f97e",
10727
+ archive: "tar.gz"
10728
+ },
10729
+ "darwin-arm64": {
10730
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-apple-darwin.tar.gz",
10731
+ sha256: "4bd3c09226376ca0a1d69589c91e86276fae36c5fbaaee669afce583f6682030",
10732
+ archive: "tar.gz"
10733
+ },
10734
+ "linux-x64": {
10735
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-unknown-linux-musl.tar.gz",
10736
+ sha256: "02f00f4777d43e8e95b7b8d49e1a0d6e502fed4b8e79c1c8b8063857a30caa2e",
10737
+ archive: "tar.gz"
10738
+ },
10739
+ "linux-arm64": {
10740
+ url: "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-unknown-linux-musl.tar.gz",
10741
+ sha256: "ec8c93c0533ff21f4851d11566808d4082544baf063d9b96ea77c27e98b7cd99",
10742
+ archive: "tar.gz"
10743
+ }
10744
+ }
10745
+ },
10746
+ {
10747
+ command: "jq",
10748
+ binBasename: "jq",
10749
+ assets: {
10750
+ "win32-x64": {
10751
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe",
10752
+ sha256: "23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334",
10753
+ archive: "raw"
10754
+ },
10755
+ "darwin-x64": {
10756
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-macos-amd64",
10757
+ sha256: "e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f",
10758
+ archive: "raw"
10759
+ },
10760
+ "darwin-arm64": {
10761
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-macos-arm64",
10762
+ sha256: "a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603",
10763
+ archive: "raw"
10764
+ },
10765
+ "linux-x64": {
10766
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-amd64",
10767
+ sha256: "020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d",
10768
+ archive: "raw"
10769
+ },
10770
+ "linux-arm64": {
10771
+ url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-arm64",
10772
+ sha256: "6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4",
10773
+ archive: "raw"
10774
+ }
10775
+ }
10776
+ },
10777
+ {
10778
+ command: "yq",
10779
+ binBasename: "yq",
10780
+ assets: {
10781
+ "win32-x64": {
10782
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_windows_amd64.exe",
10783
+ sha256: "2aee32f1de46a20672f48c25df3018839798bd509143f2ce05fdab1550ff5592",
10784
+ archive: "raw"
10785
+ },
10786
+ "win32-arm64": {
10787
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_windows_arm64.exe",
10788
+ sha256: "448208550332ca33ef816e4cee49fc1e79987b8a08a451c6ae529703c8cfc8a9",
10789
+ archive: "raw"
10790
+ },
10791
+ "darwin-x64": {
10792
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_darwin_amd64",
10793
+ sha256: "616b0a0f6a5b79d746f05a169c2b9bb40dee00c605ef165b9a1c1681bba738ac",
10794
+ archive: "raw"
10795
+ },
10796
+ "darwin-arm64": {
10797
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_darwin_arm64",
10798
+ sha256: "541ba2287560df70f561955e2d7f7e1cd00cf2a15a884f6b5c87a4bfa887bc07",
10799
+ archive: "raw"
10800
+ },
10801
+ "linux-x64": {
10802
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_linux_amd64",
10803
+ sha256: "d56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b",
10804
+ archive: "raw"
10805
+ },
10806
+ "linux-arm64": {
10807
+ url: "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_linux_arm64",
10808
+ sha256: "03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea",
10809
+ archive: "raw"
10810
+ }
10811
+ }
10812
+ },
10813
+ {
10814
+ command: "ast-grep",
10815
+ binBasename: "ast-grep",
10816
+ aliases: ["sg"],
10817
+ assets: {
10818
+ "win32-x64": {
10819
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-pc-windows-msvc.zip",
10820
+ sha256: "a4febbc8c48671e5729d85e29e4ebe5a051b7250d19545bca18e725ccf40ef61",
10821
+ archive: "zip"
10822
+ },
10823
+ "win32-arm64": {
10824
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-pc-windows-msvc.zip",
10825
+ sha256: "a519fdd90324bf6858fde2d3feb2b862d67b834dc11af8f5b6c2c8143ab6a6c5",
10826
+ archive: "zip"
10827
+ },
10828
+ "darwin-x64": {
10829
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-apple-darwin.zip",
10830
+ sha256: "6d703090b106747b2f56086b6ccc7e798fe78bcae70257aa20519b220153555b",
10831
+ archive: "zip"
10832
+ },
10833
+ "darwin-arm64": {
10834
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-apple-darwin.zip",
10835
+ sha256: "8c847d0a29aa4b3101b3361e0b3ee7fb53c7e497adc9ed1afc9615538cd40782",
10836
+ archive: "zip"
10837
+ },
10838
+ "linux-x64": {
10839
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-x86_64-unknown-linux-gnu.zip",
10840
+ sha256: "a26253a9c821d935f7e383e40f0de7c2ca62a4121de1f73a6d81ec32eae631e0",
10841
+ archive: "zip"
10842
+ },
10843
+ "linux-arm64": {
10844
+ url: "https://github.com/ast-grep/ast-grep/releases/download/0.43.0/app-aarch64-unknown-linux-gnu.zip",
10845
+ sha256: "e706846148493967f3ab8011334817edd86ce5acbec10718b2a7b40799c640ff",
10846
+ archive: "zip"
10847
+ }
10848
+ }
10849
+ }
10850
+ ];
10851
+
10852
+ //#endregion
10853
+ //#region src/lib/toolbelt/index.ts
10854
+ /** Default ON; disable with GH_ROUTER_DISABLE_TOOLBELT (truthy). */
10855
+ function toolbeltEnabled() {
10856
+ return parseBoolEnv(process.env.GH_ROUTER_DISABLE_TOOLBELT) !== true;
10857
+ }
10858
+ /** Per-tool opt-out via GH_ROUTER_TOOLBELT_SKIP="jq,yq". */
10859
+ function toolbeltSkipSet() {
10860
+ const raw = process.env.GH_ROUTER_TOOLBELT_SKIP;
10861
+ if (!raw) return /* @__PURE__ */ new Set();
10862
+ return new Set(raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
10863
+ }
10864
+ /** Absolute path to the bundled `@vscode/ripgrep` binary, or null. */
10865
+ function vscodeRipgrepPath() {
10866
+ try {
10867
+ const mod = createRequire(import.meta.url)("@vscode/ripgrep");
10868
+ if (mod.rgPath && existsSync(mod.rgPath)) return mod.rgPath;
10869
+ } catch {}
10870
+ return null;
10871
+ }
10872
+ /**
10873
+ * Every curated tool the spawned agent can actually invoke this launch
10874
+ * — whether it is already on the user's system PATH OR will be
10875
+ * materialized into the toolbelt bin (gap-fill). Used for the awareness
10876
+ * one-liner so the model is told about ALL available fast tools, not
10877
+ * just the ones we had to download. (Provisioning still only downloads
10878
+ * the gap-fill subset; this is purely the advertised set.)
10879
+ */
10880
+ function availableToolCommands() {
10881
+ if (!toolbeltEnabled()) return [];
10882
+ const skip = toolbeltSkipSet();
10883
+ const out = [];
10884
+ if (!skip.has("rg") && (resolveExecutable("rg") || vscodeRipgrepPath())) out.push("rg");
10885
+ for (const spec of TOOLBELT_TOOLS) {
10886
+ if (skip.has(spec.command)) continue;
10887
+ if (resolveExecutable(spec.command) || assetFor(spec)) out.push(spec.command);
10888
+ }
10889
+ return out;
10890
+ }
10891
+ const TOOL_DESC = {
10892
+ rg: "rg (fast regex search)",
10893
+ fd: "fd (fast file finder)",
10894
+ jq: "jq (JSON processor)",
10895
+ sd: "sd (find & replace)",
10896
+ "ast-grep": "ast-grep / sg (structural code search & rewrite)",
10897
+ yq: "yq (YAML / TOML / XML processor)"
10898
+ };
10899
+ /**
10900
+ * The one-line CLAUDE.md / system-prompt note advertising the exposed
10901
+ * tools, or null when none are exposed.
10902
+ */
10903
+ function buildToolbeltAwareness(commands) {
10904
+ if (commands.length === 0) return null;
10905
+ return "Fast CLI tools are available on your PATH; prefer them when applicable: " + commands.map((c) => TOOL_DESC[c] ?? c).join(", ") + ".";
10906
+ }
10907
+
10197
10908
  //#endregion
10198
10909
  //#region src/lib/worker-agent/bash.ts
10199
10910
  /**
@@ -10240,6 +10951,7 @@ function buildEnv() {
10240
10951
  const v = process$1.env[key];
10241
10952
  if (v !== void 0) env[key] = v;
10242
10953
  }
10954
+ if (toolbeltEnabled()) Object.assign(env, toolbeltPathOverride(env, PATHS.TOOLBELT_BIN_DIR));
10243
10955
  return env;
10244
10956
  }
10245
10957
  /**
@@ -12082,10 +12794,11 @@ function round2(n) {
12082
12794
  * **xhigh on long-running personas works via SSE-streamed /mcp responses**
12083
12795
  * (handler.ts:handleToolsCallSSE). Claude Code's MCP HTTP client honors
12084
12796
  * `text/event-stream` responses without applying the ~60s per-tool-call
12085
- * timer that previously broke xhigh on gpt-5.5 (~56s wall) and
12086
- * claude-opus-4-7 (high+ thinking budgets). All four personas now expose
12087
- * all four effort tiers with `high` default; SSE handles the long tail
12088
- * transparently to the user.
12797
+ * timer that previously broke xhigh on gpt-5.5 (~56s wall) and on
12798
+ * Anthropic Opus families (high+ thinking budgets). opus-critic itself
12799
+ * now runs on claude-opus-4-6 which doesn't advertise xhigh, so the
12800
+ * SSE long-tail concern there is moot; the SSE machinery still applies
12801
+ * to the other personas that do expose xhigh.
12089
12802
  */
12090
12803
  const EFFORT_LEVELS = [
12091
12804
  "low",
@@ -12263,9 +12976,9 @@ const PERSONAS_READ = Object.freeze([
12263
12976
  {
12264
12977
  agentName: "opus-critic",
12265
12978
  toolNameHttp: "opus_critic",
12266
- model: "claude-opus-4-7",
12979
+ model: "claude-opus-4-6",
12267
12980
  endpoint: "/v1/messages",
12268
- description: "Adversarial second opinion from a fresh-context Opus 4.7 — same lab as the lead, limited blind-spot diversity vs cross-lab critics. On enterprise catalogs that carry Opus-4.7-1M it runs with a ≈936K-token input window and handles large artifacts without decomposition; otherwise ≈168K. Fast (~22s), catches confabulation and motivated reasoning. Pass artifact verbatim.",
12981
+ description: "Adversarial second opinion from a fresh-context Opus 4.6 — same lab as the lead, limited blind-spot diversity vs cross-lab critics. On enterprise catalogs that carry Opus-4.6-1M it runs with a ≈936K-token input window; otherwise ≈168K. Pinned one minor behind the default Opus so the panel spans more of the version curve. Catches confabulation. Pass artifact verbatim.",
12269
12982
  baseInstructions: OPUS_CRITIC_BASE,
12270
12983
  agentPrompt: "",
12271
12984
  writeCapable: false,
@@ -12273,10 +12986,9 @@ const PERSONAS_READ = Object.freeze([
12273
12986
  allowedEfforts: [
12274
12987
  "low",
12275
12988
  "medium",
12276
- "high",
12277
- "xhigh"
12989
+ "high"
12278
12990
  ],
12279
- defaultEffort: "xhigh"
12991
+ defaultEffort: "high"
12280
12992
  }
12281
12993
  ]);
12282
12994
  const PERSONAS_WRITE = Object.freeze([{
@@ -13459,6 +14171,8 @@ const PEER_MARKER_OPEN = "<!-- gh-router peer-mcp awareness — auto-injected, r
13459
14171
  const PEER_MARKER_CLOSE = "<!-- /gh-router peer-mcp awareness -->";
13460
14172
  const STYLE_MARKER_OPEN = "<!-- gh-router style directive — auto-injected, regenerated per launch -->";
13461
14173
  const STYLE_MARKER_CLOSE = "<!-- /gh-router style directive -->";
14174
+ const TOOLBELT_MARKER_OPEN = "<!-- gh-router toolbelt awareness — auto-injected, regenerated per launch -->";
14175
+ const TOOLBELT_MARKER_CLOSE = "<!-- /gh-router toolbelt awareness -->";
13462
14176
  /**
13463
14177
  * Writing / communication style directive injected at the TOP of the
13464
14178
  * mirrored CLAUDE.md so every spawned agent (main, Agent-tool subagent,
@@ -13805,6 +14519,303 @@ async function prependStyleDirectiveToMirroredClaudeMd(directive = STYLE_DIRECTI
13805
14519
  label: "style-directive"
13806
14520
  });
13807
14521
  }
14522
+ /**
14523
+ * Append the toolbelt awareness one-liner (which CLI tools are on PATH)
14524
+ * to the bottom of the mirrored CLAUDE.md so descendant agents (Agent
14525
+ * subagents, agent-teams teammates) learn about the provisioned tools.
14526
+ * The main agent gets the same line via `--append-system-prompt`.
14527
+ * Separate marker fence from the peer-awareness / style blocks.
14528
+ */
14529
+ async function appendToolbeltAwarenessToMirroredClaudeMd(snippet) {
14530
+ await injectMarkerBlock({
14531
+ snippet,
14532
+ markerOpen: TOOLBELT_MARKER_OPEN,
14533
+ markerClose: TOOLBELT_MARKER_CLOSE,
14534
+ position: "bottom",
14535
+ label: "toolbelt-awareness"
14536
+ });
14537
+ }
14538
+
14539
+ //#endregion
14540
+ //#region src/lib/toolbelt/extract.ts
14541
+ function baseName(p) {
14542
+ const norm = p.replace(/\\/g, "/");
14543
+ const idx = norm.lastIndexOf("/");
14544
+ return idx === -1 ? norm : norm.slice(idx + 1);
14545
+ }
14546
+ /**
14547
+ * Extract the first REGULAR-FILE tar member whose basename equals
14548
+ * `wantBasename` (optionally with a `.exe` suffix). Returns its bytes,
14549
+ * or null if absent. `buf` is the gzip-compressed tarball.
14550
+ */
14551
+ function extractTarGzMember(buf, wantBasename) {
14552
+ let tar;
14553
+ try {
14554
+ tar = gunzipSync(buf);
14555
+ } catch {
14556
+ return null;
14557
+ }
14558
+ const wants = new Set([wantBasename, `${wantBasename}.exe`]);
14559
+ let offset = 0;
14560
+ while (offset + 512 <= tar.length) {
14561
+ const header = tar.subarray(offset, offset + 512);
14562
+ if (header.every((b) => b === 0)) break;
14563
+ const name$1 = readTarString(header, 0, 100);
14564
+ const prefix = readTarString(header, 345, 155);
14565
+ const fullName = prefix ? `${prefix}/${name$1}` : name$1;
14566
+ const sizeOctal = readTarString(header, 124, 12).trim();
14567
+ const size = parseInt(sizeOctal || "0", 8);
14568
+ const typeflag = String.fromCharCode(header[156]);
14569
+ const dataStart = offset + 512;
14570
+ if ((typeflag === "0" || typeflag === "\0") && wants.has(baseName(fullName))) {
14571
+ if (dataStart + size > tar.length) return null;
14572
+ return Buffer.from(tar.subarray(dataStart, dataStart + size));
14573
+ }
14574
+ offset = dataStart + Math.ceil(size / 512) * 512;
14575
+ }
14576
+ return null;
14577
+ }
14578
+ function readTarString(block, start$1, len) {
14579
+ const slice = block.subarray(start$1, start$1 + len);
14580
+ const nul = slice.indexOf(0);
14581
+ return slice.subarray(0, nul === -1 ? len : nul).toString("utf8");
14582
+ }
14583
+ /**
14584
+ * Extract the first REGULAR-FILE zip member whose basename equals
14585
+ * `wantBasename` (optionally `.exe`). Supports stored (0) and deflate
14586
+ * (8) compression. Rejects directories and unix-symlink entries.
14587
+ */
14588
+ function extractZipMember(buf, wantBasename) {
14589
+ const wants = new Set([wantBasename, `${wantBasename}.exe`]);
14590
+ const EOCD_SIG = 101010256;
14591
+ let eocd = -1;
14592
+ const minStart = Math.max(0, buf.length - 65557);
14593
+ for (let i = buf.length - 22; i >= minStart; i--) if (buf.readUInt32LE(i) === EOCD_SIG) {
14594
+ eocd = i;
14595
+ break;
14596
+ }
14597
+ if (eocd === -1) return null;
14598
+ const entryCount = buf.readUInt16LE(eocd + 10);
14599
+ let cd = buf.readUInt32LE(eocd + 16);
14600
+ const CEN_SIG = 33639248;
14601
+ for (let i = 0; i < entryCount; i++) {
14602
+ if (cd + 46 > buf.length || buf.readUInt32LE(cd) !== CEN_SIG) return null;
14603
+ const method = buf.readUInt16LE(cd + 10);
14604
+ const compSize = buf.readUInt32LE(cd + 20);
14605
+ const nameLen = buf.readUInt16LE(cd + 28);
14606
+ const extraLen = buf.readUInt16LE(cd + 30);
14607
+ const commentLen = buf.readUInt16LE(cd + 32);
14608
+ const externalAttrs = buf.readUInt32LE(cd + 38);
14609
+ const localOffset = buf.readUInt32LE(cd + 42);
14610
+ const name$1 = buf.subarray(cd + 46, cd + 46 + nameLen).toString("utf8");
14611
+ const isSymlink = (externalAttrs >>> 16 & 61440) === 40960;
14612
+ const isDir = name$1.endsWith("/");
14613
+ if (!isSymlink && !isDir && wants.has(baseName(name$1))) return readZipLocalEntry(buf, localOffset, method, compSize);
14614
+ cd += 46 + nameLen + extraLen + commentLen;
14615
+ }
14616
+ return null;
14617
+ }
14618
+ function readZipLocalEntry(buf, localOffset, method, compSize) {
14619
+ if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 67324752) return null;
14620
+ const nameLen = buf.readUInt16LE(localOffset + 26);
14621
+ const extraLen = buf.readUInt16LE(localOffset + 28);
14622
+ const dataStart = localOffset + 30 + nameLen + extraLen;
14623
+ const comp = buf.subarray(dataStart, dataStart + compSize);
14624
+ try {
14625
+ if (method === 0) return Buffer.from(comp);
14626
+ if (method === 8) return inflateRawSync(comp);
14627
+ } catch {
14628
+ return null;
14629
+ }
14630
+ return null;
14631
+ }
14632
+
14633
+ //#endregion
14634
+ //#region src/lib/toolbelt/provision.ts
14635
+ /** Per-download cap (bytes) — these binaries are a few MB at most. */
14636
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
14637
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
14638
+ const EXE_EXT = process$1.platform === "win32" ? ".exe" : "";
14639
+ /**
14640
+ * Materialize the toolbelt. Returns the list of command names exposed
14641
+ * in `bin/` after provisioning. Best-effort; never throws.
14642
+ */
14643
+ async function provisionToolbelt() {
14644
+ if (!toolbeltEnabled()) return [];
14645
+ const binDir = PATHS.TOOLBELT_BIN_DIR;
14646
+ try {
14647
+ await mkdir(binDir, { recursive: true });
14648
+ } catch (err) {
14649
+ consola.debug("toolbelt: could not create bin dir:", err);
14650
+ return [];
14651
+ }
14652
+ const skip = toolbeltSkipSet();
14653
+ await withInstallLock("toolbelt.lock", async () => {
14654
+ await pruneUnexpected(binDir);
14655
+ await provisionRg(binDir, skip).catch((err) => consola.debug("toolbelt: rg skipped:", err));
14656
+ await Promise.all(TOOLBELT_TOOLS.map((spec) => provisionTool(spec, binDir, skip).catch((err) => consola.debug(`toolbelt: ${spec.command} skipped:`, err))));
14657
+ });
14658
+ return exposedCommands(binDir);
14659
+ }
14660
+ /** Names allowed to live in `bin/` (managed binaries + their sidecars). */
14661
+ function expectedFileNames() {
14662
+ const names = /* @__PURE__ */ new Set();
14663
+ const add = (base) => {
14664
+ names.add(base + EXE_EXT);
14665
+ names.add(`${base}${EXE_EXT}.sha256`);
14666
+ };
14667
+ add("rg");
14668
+ for (const spec of TOOLBELT_TOOLS) {
14669
+ add(spec.binBasename);
14670
+ for (const a of spec.aliases ?? []) add(a);
14671
+ }
14672
+ return names;
14673
+ }
14674
+ /** Remove any file in `bin/` that isn't a managed binary or sidecar. */
14675
+ async function pruneUnexpected(binDir) {
14676
+ const expected = expectedFileNames();
14677
+ let entries;
14678
+ try {
14679
+ entries = await readdir(binDir);
14680
+ } catch {
14681
+ return;
14682
+ }
14683
+ for (const name$1 of entries) {
14684
+ if (name$1.endsWith(".tmp")) continue;
14685
+ if (!expected.has(name$1)) await rm(path.join(binDir, name$1), { force: true }).catch(() => {});
14686
+ }
14687
+ }
14688
+ async function provisionRg(binDir, skip) {
14689
+ const dest = path.join(binDir, "rg" + EXE_EXT);
14690
+ if (skip.has("rg") || resolveExecutable("rg")) {
14691
+ await removeBin(dest);
14692
+ return;
14693
+ }
14694
+ if (existsSync(dest)) return;
14695
+ const src = vscodeRipgrepPath();
14696
+ if (!src) return;
14697
+ const tmp = tempName(dest);
14698
+ try {
14699
+ await link(src, tmp);
14700
+ } catch {
14701
+ await copyFile(src, tmp);
14702
+ }
14703
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
14704
+ await commit(tmp, dest);
14705
+ }
14706
+ async function provisionTool(spec, binDir, skip) {
14707
+ const dest = path.join(binDir, spec.binBasename + EXE_EXT);
14708
+ const sidecar = `${dest}.sha256`;
14709
+ const asset = assetFor(spec);
14710
+ if (skip.has(spec.command) || !asset) {
14711
+ await removeTool(spec, binDir);
14712
+ return;
14713
+ }
14714
+ if (resolveExecutable(spec.command)) {
14715
+ await removeTool(spec, binDir);
14716
+ return;
14717
+ }
14718
+ if (existsSync(dest) && await sidecarMatches(sidecar, asset.sha256)) {
14719
+ await ensureAliases(spec, binDir, dest);
14720
+ return;
14721
+ }
14722
+ await atomicInstall(dest, await downloadAndExtract(spec, asset));
14723
+ await writeFile(sidecar, asset.sha256).catch(() => {});
14724
+ await ensureAliases(spec, binDir, dest);
14725
+ }
14726
+ async function downloadAndExtract(spec, asset) {
14727
+ const data = await download(asset.url);
14728
+ const digest = createHash("sha256").update(data).digest("hex");
14729
+ if (digest !== asset.sha256) throw new Error(`checksum mismatch for ${spec.command} (${asset.url}): expected ${asset.sha256}, got ${digest}`);
14730
+ if (asset.archive === "raw") return data;
14731
+ const member = asset.archive === "zip" ? extractZipMember(data, spec.binBasename) : extractTarGzMember(data, spec.binBasename);
14732
+ if (!member) throw new Error(`binary "${spec.binBasename}" not found in ${asset.url}`);
14733
+ return member;
14734
+ }
14735
+ async function download(url) {
14736
+ const controller = new AbortController();
14737
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
14738
+ try {
14739
+ const res = await fetch(url, {
14740
+ signal: controller.signal,
14741
+ redirect: "follow",
14742
+ headers: { "User-Agent": "github-router-toolbelt" }
14743
+ });
14744
+ if (!res.ok) throw new Error(`download ${url}: HTTP ${res.status}`);
14745
+ const buf = Buffer.from(await res.arrayBuffer());
14746
+ if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`download ${url}: exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
14747
+ return buf;
14748
+ } finally {
14749
+ clearTimeout(timer);
14750
+ }
14751
+ }
14752
+ /** Write to a unique temp then atomically rename into place. */
14753
+ async function atomicInstall(dest, bytes) {
14754
+ const tmp = tempName(dest);
14755
+ await writeFile(tmp, bytes);
14756
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
14757
+ await commit(tmp, dest);
14758
+ }
14759
+ /** Rename tmp→dest, handling Windows replace-existing / in-use locks. */
14760
+ async function commit(tmp, dest) {
14761
+ try {
14762
+ await rename(tmp, dest);
14763
+ } catch {
14764
+ try {
14765
+ await rm(dest, { force: true });
14766
+ await rename(tmp, dest);
14767
+ } catch (err) {
14768
+ await rm(tmp, { force: true }).catch(() => {});
14769
+ throw err;
14770
+ }
14771
+ }
14772
+ }
14773
+ async function ensureAliases(spec, binDir, dest) {
14774
+ for (const alias of spec.aliases ?? []) {
14775
+ const ap = path.join(binDir, alias + EXE_EXT);
14776
+ if (existsSync(ap)) continue;
14777
+ const tmp = tempName(ap);
14778
+ try {
14779
+ await copyFile(dest, tmp);
14780
+ if (process$1.platform !== "win32") await chmod(tmp, 493).catch(() => {});
14781
+ await commit(tmp, ap);
14782
+ } catch (err) {
14783
+ consola.debug(`toolbelt: alias ${alias} skipped:`, err);
14784
+ }
14785
+ }
14786
+ }
14787
+ async function removeTool(spec, binDir) {
14788
+ await removeBin(path.join(binDir, spec.binBasename + EXE_EXT));
14789
+ for (const alias of spec.aliases ?? []) await removeBin(path.join(binDir, alias + EXE_EXT));
14790
+ }
14791
+ async function removeBin(dest) {
14792
+ await rm(dest, { force: true }).catch(() => {});
14793
+ await rm(`${dest}.sha256`, { force: true }).catch(() => {});
14794
+ }
14795
+ async function sidecarMatches(sidecar, sha256) {
14796
+ try {
14797
+ return (await readFile(sidecar, "utf8")).trim() === sha256;
14798
+ } catch {
14799
+ return false;
14800
+ }
14801
+ }
14802
+ function tempName(dest) {
14803
+ return `${dest}.${process$1.pid}.${randomBytes(4).toString("hex")}.tmp`;
14804
+ }
14805
+ /** The command names currently exposed in `bin/`. */
14806
+ async function exposedCommands(binDir) {
14807
+ let files;
14808
+ try {
14809
+ files = new Set(await readdir(binDir));
14810
+ } catch {
14811
+ return [];
14812
+ }
14813
+ const present = (base) => files.has(base + EXE_EXT);
14814
+ const out = [];
14815
+ if (present("rg")) out.push("rg");
14816
+ for (const spec of TOOLBELT_TOOLS) if (present(spec.binBasename)) out.push(spec.command);
14817
+ return out;
14818
+ }
13808
14819
 
13809
14820
  //#endregion
13810
14821
  //#region src/lib/proxy.ts
@@ -13855,7 +14866,7 @@ function initProxyFromEnv() {
13855
14866
  //#endregion
13856
14867
  //#region package.json
13857
14868
  var name = "github-router";
13858
- var version$1 = "0.3.66";
14869
+ var version$1 = "0.3.71";
13859
14870
 
13860
14871
  //#endregion
13861
14872
  //#region src/lib/approval.ts
@@ -15699,6 +16710,11 @@ const sharedServerArgs = {
15699
16710
  type: "boolean",
15700
16711
  default: false,
15701
16712
  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)."
16713
+ },
16714
+ "self-update": {
16715
+ type: "boolean",
16716
+ default: true,
16717
+ 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."
15702
16718
  }
15703
16719
  };
15704
16720
  const allowedAccountTypes = new Set([
@@ -15792,10 +16808,10 @@ function getClaudeCodeEnvVars(serverUrl, model) {
15792
16808
  DISABLE_TELEMETRY: "1"
15793
16809
  };
15794
16810
  if (model) vars.ANTHROPIC_MODEL = model;
15795
- if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-haiku-4-5";
16811
+ if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-sonnet-4-6";
15796
16812
  if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL === void 0) vars.ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-sonnet-4-6";
15797
16813
  if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === void 0) vars.ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-haiku-4-5";
15798
- if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL === void 0) vars.ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-opus-4-7";
16814
+ if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL === void 0) vars.ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-opus-4-8";
15799
16815
  if (process.env.CLAUDE_CODE_PLAN_V2_AGENT_COUNT === void 0) vars.CLAUDE_CODE_PLAN_V2_AGENT_COUNT = "7";
15800
16816
  for (const key of [
15801
16817
  "CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL",
@@ -15804,6 +16820,7 @@ function getClaudeCodeEnvVars(serverUrl, model) {
15804
16820
  "CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
15805
16821
  "CLAUDE_CODE_ENABLE_TASKS"
15806
16822
  ]) if (process.env[key] === void 0) vars[key] = "1";
16823
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
15807
16824
  return vars;
15808
16825
  }
15809
16826
  /**
@@ -15819,11 +16836,13 @@ function getClaudeCodeEnvVars(serverUrl, model) {
15819
16836
  * masks any cached login.
15820
16837
  */
15821
16838
  function getCodexEnvVars(serverUrl) {
15822
- return {
16839
+ const vars = {
15823
16840
  OPENAI_BASE_URL: `${serverUrl}/v1`,
15824
16841
  OPENAI_API_KEY: "dummy",
15825
16842
  CODEX_HOME: PATHS.CODEX_HOME
15826
16843
  };
16844
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
16845
+ return vars;
15827
16846
  }
15828
16847
 
15829
16848
  //#endregion
@@ -15863,7 +16882,7 @@ const claude = defineCommand({
15863
16882
  "auto-update": {
15864
16883
  type: "boolean",
15865
16884
  default: true,
15866
- 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."
16885
+ 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."
15867
16886
  },
15868
16887
  "update-check": {
15869
16888
  type: "boolean",
@@ -15886,12 +16905,12 @@ const claude = defineCommand({
15886
16905
  if (versionCheck.skipped && versionCheck.skipReason === "no-claude") consola.debug("claude --version probe failed; skipping auto-update.");
15887
16906
  else if (versionCheck.skipped && versionCheck.skipReason === "no-npm") consola.debug("npm view @anthropic-ai/claude-code failed; skipping auto-update check (likely offline).");
15888
16907
  else if (versionCheck.needsUpdate && versionCheck.installedVersion && versionCheck.latestVersion) if (args["auto-update"] !== false) try {
15889
- await autoUpdateClaude(versionCheck.latestVersion);
16908
+ await updateClaude(versionCheck.latestVersion);
15890
16909
  } catch (err) {
15891
16910
  const msg = err instanceof Error ? err.message : String(err);
15892
- 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.`);
16911
+ 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.`);
15893
16912
  }
15894
- 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.`);
16913
+ 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.`);
15895
16914
  } catch (err) {
15896
16915
  consola.debug("Claude version check failed:", err);
15897
16916
  }
@@ -15909,6 +16928,7 @@ const claude = defineCommand({
15909
16928
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
15910
16929
  process$1.exit(1);
15911
16930
  }
16931
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
15912
16932
  try {
15913
16933
  await ensureClaudeConfigMirror();
15914
16934
  } catch (err) {
@@ -15940,6 +16960,15 @@ const claude = defineCommand({
15940
16960
  process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
15941
16961
  const envVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
15942
16962
  const extraArgs = args._ ?? [];
16963
+ if (toolbeltEnabled()) {
16964
+ provisionToolbelt().catch((err) => consola.debug("Toolbelt provisioning failed:", err));
16965
+ const toolbeltLine = buildToolbeltAwareness(availableToolCommands());
16966
+ if (toolbeltLine) try {
16967
+ await appendToolbeltAwarenessToMirroredClaudeMd(toolbeltLine);
16968
+ } catch (err) {
16969
+ consola.warn(`Toolbelt CLAUDE.md append failed: ${err instanceof Error ? err.message : String(err)}`);
16970
+ }
16971
+ }
15943
16972
  const baseShutdown = async () => {
15944
16973
  await removeOwnClaudeConfigMirror();
15945
16974
  };
@@ -16039,6 +17068,8 @@ const codex = defineCommand({
16039
17068
  consola.error("Failed to start server:", error instanceof Error ? error.message : error);
16040
17069
  process$1.exit(1);
16041
17070
  }
17071
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
17072
+ if (toolbeltEnabled()) provisionToolbelt().catch(() => {});
16042
17073
  const usingDefault = !args.model;
16043
17074
  const requestedModel = args.model ?? DEFAULT_CODEX_MODEL;
16044
17075
  enableFileLogging();
@@ -16411,6 +17442,7 @@ const start = defineCommand({
16411
17442
  port: parsed.port ?? DEFAULT_PORT,
16412
17443
  silent: false
16413
17444
  });
17445
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
16414
17446
  if (args.cc) generateClaudeCodeCommand(serverUrl, args.model);
16415
17447
  if (args.cx) generateCodexCommand(serverUrl, args.model);
16416
17448
  consola.box(`🌐 Usage Viewer: https://animeshkundu.github.io/github-router/dashboard.html?endpoint=${serverUrl}/usage`);