github-router 0.3.178 → 0.3.202

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,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { $ as isAdvisorRequested, $t as cacheCopilotVersion, A as liveExec, At as provisionBrowserAssets, Bt as DEFAULT_CODEX_MODEL, C as fileReviewDebounce, Ct as resolveMcpToolTimeoutMs, D as stopGateEnabledForRepo, Dt as MAX_RESPONSE_BODY_BYTES, E as repoRoot, Et as createChatCompletions, Ft as shouldUseInsecureTls, G as vscodeRipgrepPath, Gt as generateRandomPort, H as buildToolbeltAwareness, Ht as DEFAULT_PORT, It as ArtifactClient, J as searchWeb, Jt as withInstallLock, K as TOOLBELT_TOOLS, Kt as pickClaudeDefault, Lt as collapsePathKeys, Mt as provisionAndIndexColbert, Nt as extractTarGzMember, O as stopReviewStateDir, Ot as readResponseBodyCapped, Pt as extractZipMember, Q as injectAdvisorTool, Qt as tryRefreshAndRetry, Rt as toolbeltPathOverride, S as fileLastPromptStore, St as assembleResponsesPayload, T as repoFingerprint, Tt as createResponses, U as toolbeltEnabled, Ut as UPSTREAM_FETCH_TIMEOUT_MS, V as availableToolCommands, Vt as DEFAULT_CODEX_MODEL_FALLBACKS, W as toolbeltSkipSet, Wt as UPSTREAM_INACTIVITY_TIMEOUT_MS, X as ADVISOR_TOOL_INSTRUCTIONS, Xt as setupGitHubAgentToken, Y as ADVISOR_INTERNAL_TOOL_NAME, Yt as setupCopilotToken, Z as buildAdvisorStream, Zt as setupGitHubToken, _ as stopGateId, _t as workerToolsEnabled, a as buildPeerAwarenessSnippet, an as resolveModel, at as relayAnthropicStream, b as fileBaselineStore, bt as createMessages, c as buildArtifactOpenHookCommand, cn as fetchWithTransientRetry, ct as agentToolsEnabled, d as captureLaunchBaseline, dn as GITHUB_API_BASE_URL, dt as browserCompoundToolsEnabled, en as cacheModels, et as buildAnthropicErrorEvent, f as decideStopHook, fn as copilotBaseUrl, ft as browserToolsEnabled, g as stopGateDisabled, gt as standInToolEnabled, h as launchBaselineKey, hn as state, ht as nativeSubagentModel, i as buildAgentPrompt, in as resolveCodexModel, it as readIteratorWithTimeout, j as resolveSealedGate, jt as hasSupportedBrowserInstalled, k as trustRepo, kt as parseJsonOrDiagnose, l as buildSessionBindHookCommand, ln as HTTPError, lt as artifactToolsEnabled, m as injectStopHookIntoSettingsFile, mn as githubHeaders, mt as geminiAvailable, n as MCP_GROUPS, nn as filterBetaHeader, nt as isControllerClosedError, o as buildPeerAwarenessSummary, on as sleep, ot as handleMcpDelete, p as fileBlockBudget, pn as copilotHeaders, pt as fleetToolsEnabled, q as assetFor, qt as getPackageVersion, r as assertMcpToolSurfaceConsistent, rn as isNullish, rt as logStreamError, s as personasFor, sn as getModels, st as handleMcpPost, t as GROUP_META, tn as cacheVSCodeVersion, tt as buildOpenAIErrorEvent, u as buildStopHookCommand, un as forwardError, ut as browseAgentEnabled, v as stopGatePlanMode, vt as shimDefaultsToXhigh, w as isSubagentContext, wt as pickEndpoint, x as fileFindingsStore, xt as getTokenCount, y as stopReviewEnabled, yt as countTokens, zt as DEFAULT_CLAUDE_MODEL_FALLBACKS } from "./peer-mcp-personas-B1-1mVPB.js";
3
- import { a as isUnderClaudeConfigMirror, d as writeRuntimeFileSecure, i as ensurePaths, o as removeOwnClaudeConfigMirror, r as ensureClaudeConfigMirror, t as PATHS, u as writeArtifactCredsToMirror } from "./paths-BZPgxKUl.js";
4
- import { c as killManagedTree, d as runCommandCapture, f as runCommandVoid, l as parseBoolEnv, s as killChildProcessTree, u as resolveExecutable } from "./lifecycle-DrDEjx_c.js";
5
- import { a as sweepRegistry } from "./lifecycle-Dvr3pGGG.js";
2
+ import { $ as buildAdvisorStream, $t as setupCopilotToken, A as trustRepo, At as readResponseBodyCapped, Bt as buildWorkspaceHeaderJson, C as fileLastPromptStore, Ct as getTokenCount, D as repoRoot, Dt as createResponses, E as repoFingerprint, Et as pickEndpoint, Ft as extractTarGzMember, G as toolbeltEnabled, Gt as DEFAULT_CODEX_MODEL_FALLBACKS, H as buildEnv, Ht as toolbeltPathOverride, It as extractZipMember, J as TOOLBELT_TOOLS, Jt as UPSTREAM_INACTIVITY_TIMEOUT_MS, K as toolbeltSkipSet, Kt as DEFAULT_PORT, Lt as shouldUseInsecureTls, M as resolveSealedGate, Mt as provisionBrowserAssets, Nt as hasSupportedBrowserInstalled, O as stopGateEnabledForRepo, Ot as createChatCompletions, Pt as provisionAndIndexColbert, Q as ADVISOR_TOOL_INSTRUCTIONS, Qt as withInstallLock, Rt as ArtifactClient, S as fileFindingsStore, St as createMessages, T as isSubagentContext, Tt as resolveMcpToolTimeoutMs, U as availableToolCommands, Ut as DEFAULT_CLAUDE_MODEL_FALLBACKS, Vt as collapsePathKeys, W as buildToolbeltAwareness, Wt as DEFAULT_CODEX_MODEL, X as searchWeb, Xt as pickClaudeDefault, Y as assetFor, Yt as generateRandomPort, Z as ADVISOR_INTERNAL_TOOL_NAME, Zt as getPackageVersion, _ as stopGateDisabled, _n as copilotBaseUrl, _t as nativeSubagentModel, a as buildPeerAwarenessSnippet, an as cacheVSCodeVersion, at as logStreamError, b as stopReviewEnabled, bn as state, bt as shimDefaultsToXhigh, c as personasFor, cn as resolveCodexModel, ct as handleMcpDelete, d as buildStopHookCommand, dn as getModels, dt as artifactToolsEnabled, en as setupGitHubAgentToken, et as injectAdvisorTool, f as captureLaunchBaseline, fn as getGitHubUser, ft as browseAgentEnabled, g as launchBaselineKey, gn as GITHUB_API_BASE_URL, gt as geminiAvailable, h as injectStopHookIntoSettingsFile, hn as forwardError, ht as fleetToolsEnabled, i as buildAgentPrompt, in as cacheModels, it as isControllerClosedError, j as liveExec, jt as parseJsonOrDiagnose, k as stopReviewStateDir, kt as MAX_RESPONSE_BODY_BYTES, l as buildArtifactOpenHookCommand, ln as resolveModel, lt as handleMcpPost, m as fileBlockBudget, mn as HTTPError, mt as browserToolsEnabled, n as MCP_GROUPS, nn as tryRefreshAndRetry, nt as buildAnthropicErrorEvent, o as buildPeerAwarenessSummary, on as filterBetaHeader, ot as readIteratorWithTimeout, p as decideStopHook, pn as fetchWithTransientRetry, pt as browserCompoundToolsEnabled, q as vscodeRipgrepPath, qt as UPSTREAM_FETCH_TIMEOUT_MS, r as assertMcpToolSurfaceConsistent, rn as cacheCopilotVersion, rt as buildOpenAIErrorEvent, s as enumerateInjectedMcpToolNames, sn as isNullish, st as relayAnthropicStream, t as GROUP_META, tn as setupGitHubToken, tt as isAdvisorRequested, u as buildSessionBindHookCommand, un as sleep$1, ut as agentToolsEnabled, v as stopGateId, vn as copilotHeaders, vt as standInToolEnabled, w as fileReviewDebounce, wt as assembleResponsesPayload, x as fileBaselineStore, xt as countTokens, y as stopGatePlanMode, yn as githubHeaders, yt as workerToolsEnabled, zt as buildWorkspaceHeaderHelperCommand } from "./peer-mcp-personas-rik0SIrg.js";
3
+ import { a as isUnderClaudeConfigMirror, d as writeRuntimeFileSecure, i as ensurePaths, o as removeOwnClaudeConfigMirror, r as ensureClaudeConfigMirror, t as PATHS, u as writeArtifactCredsToMirror } from "./paths-BO22pMUb.js";
4
+ import { c as killManagedTree, d as runCommandCapture, f as runCommandVoid, l as parseBoolEnv, s as killChildProcessTree, u as resolveExecutable } from "./lifecycle-D-1CYr1Y.js";
5
+ import { a as sweepRegistry } from "./lifecycle-bPdiXjYB.js";
6
6
  import { defineCommand, runMain } from "citty";
7
7
  import consola from "consola";
8
8
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -22,6 +22,7 @@ import { getProxyForUrl } from "proxy-from-env";
22
22
  import { Hono } from "hono";
23
23
  import { cors } from "hono/cors";
24
24
  import { Marked } from "marked";
25
+ import http from "node:http";
25
26
  import clipboard from "clipboardy";
26
27
 
27
28
  //#region src/auth.ts
@@ -539,25 +540,28 @@ function startProcessGuard(child) {
539
540
  }
540
541
 
541
542
  //#endregion
542
- //#region src/lib/launch.ts
543
+ //#region src/lib/stripped-env-keys.ts
543
544
  /**
544
- * Auth-related env keys we strip from the parent before spawning the
545
- * child CLI. The proxy provides its own values for everything we care
546
- * about (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, OPENAI_BASE_URL,
547
- * OPENAI_API_KEY, CODEX_HOME, ANTHROPIC_MODEL); for the rest, we want
548
- * the child to behave as if the user had no parent-env auth at all.
545
+ * Auth / routing / remote / wire-fingerprint environment keys that must never
546
+ * reach a github-router-spawned Claude or Codex child through ANY channel.
547
+ *
548
+ * Two channels carry these keys to a spawned agent, and both must be defended:
549
+ * 1. The PARENT PROCESS ENV a user's shell exports. Stripped by
550
+ * `sanitizeParentEnv` (`src/lib/launch.ts`) before the proxy's overrides.
551
+ * 2. The serve mirror `settings.json` `env` BLOCK — a user's own
552
+ * `~/.claude/settings.json`, snapshot-mirrored and applied to the
553
+ * SDK-spawned claude via `settingSources`. Stripped by
554
+ * `sanitizeServeSettingsEnv` (`src/lib/mcp-permissions-settings.ts`).
549
555
  *
550
- * Why strip rather than override-with-empty-string:
551
- * - Claude Code emits "Auth conflict" warnings whenever both
552
- * ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY are present (regardless
553
- * of value, even when both are "dummy"). Stripping API_KEY entirely
554
- * suppresses the warning AND prevents an inherited real shell key
555
- * from leaking via x-api-key.
556
- * - Cloud-provider toggles (CLAUDE_CODE_USE_*) and OAUTH_TOKEN, etc.
557
- * are simpler dropped than overridden — a missing env var is
558
- * unambiguously falsy/absent in every code path that reads it.
559
- */
560
- const STRIPPED_PARENT_ENV_KEYS = [
556
+ * If any of these survived, a Bedrock/Vertex/gateway user's config would
557
+ * re-route the session OFF the github-router proxy, inject real auth that wins
558
+ * over the synthetic `.credentials.json`, or activate Claude Code's
559
+ * unimplemented Bridge/remote code path. `CLAUDE_CODE_COORDINATOR_MODE` (a
560
+ * toolset-stripper) is NOT here it's serve-specific, added to the serve list
561
+ * directly. `ANTHROPIC_SMALL_FAST_MODEL` is deliberately NOT stripped (users
562
+ * legitimately rely on it; `resolveModel` translates unrecognized values).
563
+ */
564
+ const STRIPPED_AUTH_ROUTING_ENV_KEYS = [
561
565
  "ANTHROPIC_API_KEY",
562
566
  "ANTHROPIC_AUTH_TOKEN",
563
567
  "ANTHROPIC_BASE_URL",
@@ -585,6 +589,22 @@ const STRIPPED_PARENT_ENV_KEYS = [
585
589
  "AIORDIE_TOKEN",
586
590
  "AIORDIE_INSECURE_TLS"
587
591
  ];
592
+
593
+ //#endregion
594
+ //#region src/lib/launch.ts
595
+ /**
596
+ * Auth-related env keys we strip from the parent before spawning the child CLI.
597
+ * The proxy provides its own values for everything we care about, so the child
598
+ * behaves as if the user had no parent-env auth at all. The canonical list lives
599
+ * in `./stripped-env-keys` (shared with serve's settings.json `env` strip).
600
+ *
601
+ * Why strip rather than override-with-empty-string: Claude Code emits "Auth
602
+ * conflict" warnings whenever both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
603
+ * are present (any value); stripping suppresses it AND prevents an inherited
604
+ * real key from leaking. Cloud-provider toggles + OAuth tokens are simpler
605
+ * dropped than overridden — a missing var is unambiguously absent everywhere.
606
+ */
607
+ const STRIPPED_PARENT_ENV_KEYS = STRIPPED_AUTH_ROUTING_ENV_KEYS;
588
608
  /**
589
609
  * Strip auth-related keys from a parent-process env object. The result
590
610
  * is suitable to spread into a spawned child's env BEFORE the proxy's
@@ -1082,14 +1102,17 @@ function resolveCodexCliBackend(opts) {
1082
1102
  */
1083
1103
  function buildPeerMcpConfig(serverUrl, opts) {
1084
1104
  const mcpServers = {};
1105
+ const workspaceHeaderCmd = opts.workspaceHeaderCmd?.trim();
1085
1106
  for (const group of MCP_GROUPS) {
1086
1107
  const key = opts.groupKeys[group];
1087
1108
  if (!key) continue;
1088
- mcpServers[key] = {
1109
+ const entry = {
1089
1110
  type: "http",
1090
1111
  url: `${serverUrl}/mcp/${GROUP_META[group].urlSuffix}`,
1091
1112
  headers: { Authorization: `Bearer ${opts.nonce}` }
1092
1113
  };
1114
+ if (workspaceHeaderCmd) entry.headersHelper = workspaceHeaderCmd;
1115
+ mcpServers[key] = entry;
1093
1116
  }
1094
1117
  if (opts.codexCli) mcpServers["codex-cli"] = {
1095
1118
  command: "codex",
@@ -1179,6 +1202,33 @@ function buildCoordinatorAgent(opts) {
1179
1202
  };
1180
1203
  }
1181
1204
  /**
1205
+ * Claude Code's built-in subagents, which the interactive CLI provides natively
1206
+ * but the Agent SDK (used by CloudCLI under `serve`) does NOT register — so a
1207
+ * serve session shows `Agent type 'Explore' not found`. We re-register them as
1208
+ * custom subagents so the model's habitual `Agent(subagent_type:"…")` calls
1209
+ * resolve. SERVE-ONLY: never inject these for `github-router claude` (the CLI's
1210
+ * native, tuned built-ins would be shadowed by a same-name custom agent).
1211
+ *
1212
+ * No `tools:` restriction — each inherits the session's full toolset; the role
1213
+ * is steered by the prompt (matching the built-ins' behavior without the risk of
1214
+ * an over-narrow allowlist). `statusline-setup`/`output-style-setup` are omitted
1215
+ * (niche, rarely invoked).
1216
+ */
1217
+ const BUILTIN_SUBAGENT_DEFINITIONS = {
1218
+ "general-purpose": {
1219
+ description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use when searching for a keyword or file and you are not confident you will find the right match in the first few tries.",
1220
+ prompt: "You are a general-purpose agent. Research the question or carry out the multi-step task you are given, using the full toolset (read, search, edit, run commands as needed). Work autonomously and return a single, complete final answer — your final message is the whole result, so include the findings, file paths, and any code the caller needs."
1221
+ },
1222
+ Explore: {
1223
+ description: "Read-only search agent for broad fan-out searches — when answering means sweeping many files or directories and you only need the conclusion, not the file dumps. It locates code; it does not modify it.",
1224
+ prompt: "You are a read-only exploration agent. Investigate the codebase to answer the question by reading and searching (Read/Glob/Grep and semantic code search); do NOT modify any files, run mutating commands, or make commits. Cast a wide net, then return a concise conclusion with the relevant file paths and line references — your final message is the whole answer."
1225
+ },
1226
+ Plan: {
1227
+ description: "Software architect agent for designing implementation plans. Use when you need to plan the implementation strategy for a task. Returns a step-by-step plan, identifies critical files, and considers architectural trade-offs.",
1228
+ prompt: "You are a planning agent. Read the codebase (read-only — do not modify files) to design a concrete, ordered implementation plan for the task: the approach, the specific files to change, reuse of existing utilities, risks, and how the result will be verified. Return the plan as your final message."
1229
+ }
1230
+ };
1231
+ /**
1182
1232
  * Build the JSON payload for `claude --agents <path>`.
1183
1233
  *
1184
1234
  * Always includes the read-only personas applicable to the mode (gemini
@@ -1272,7 +1322,7 @@ function escapeYamlString(s) {
1272
1322
  * anything that wouldn't be a safe bare YAML scalar AND a safe path
1273
1323
  * component.
1274
1324
  */
1275
- const VALID_AGENT_NAME = /^[a-z][a-z0-9-]*$/;
1325
+ const VALID_AGENT_NAME = /^[A-Za-z][A-Za-z0-9-]*$/;
1276
1326
  /** Build a single subagent .md file body (frontmatter + system prompt).
1277
1327
  *
1278
1328
  * `tools` (optional) becomes a `tools:` frontmatter allowlist RESTRICTING the
@@ -1452,7 +1502,8 @@ async function injectPeerMcpIntoMirror(serverUrl, opts) {
1452
1502
  geminiAvailable: opts.geminiAvailable,
1453
1503
  groupKeys: opts.groupKeys,
1454
1504
  nonce: opts.nonce,
1455
- codexHome: opts.codexHome ?? PATHS.CODEX_HOME
1505
+ codexHome: opts.codexHome ?? PATHS.CODEX_HOME,
1506
+ workspaceHeaderCmd: buildWorkspaceHeaderHelperCommand(process.execPath, process.argv[1])
1456
1507
  });
1457
1508
  const conflicts = [];
1458
1509
  for (const name$1 of Object.keys(peerConfig.mcpServers)) if (mcpServers[name$1] !== void 0) conflicts.push(name$1);
@@ -1510,7 +1561,8 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
1510
1561
  geminiAvailable: opts.geminiAvailable,
1511
1562
  groupKeys: opts.groupKeys,
1512
1563
  nonce,
1513
- codexHome
1564
+ codexHome,
1565
+ workspaceHeaderCmd: buildWorkspaceHeaderHelperCommand(process.execPath, process.argv[1])
1514
1566
  });
1515
1567
  const agents = buildPeerAgentDefinitions({
1516
1568
  codexCli: opts.codexCli,
@@ -1526,7 +1578,10 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
1526
1578
  await fs.unlink(agentsPath).catch(() => {});
1527
1579
  await writeRuntimeFileSecure(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
1528
1580
  await writeRuntimeFileSecure(agentsPath, JSON.stringify(agents, null, 2));
1529
- const mdResult = await writePeerAgentMdFiles(agents, {
1581
+ const mdResult = await writePeerAgentMdFiles(opts.builtinSubagents ? {
1582
+ ...agents,
1583
+ ...opts.builtinSubagents
1584
+ } : agents, {
1530
1585
  agentsDir: opts.agentsDir,
1531
1586
  fileSuffix
1532
1587
  });
@@ -1703,6 +1758,180 @@ function enableFileLogging() {
1703
1758
  consola.options.stderr = nullStream;
1704
1759
  }
1705
1760
 
1761
+ //#endregion
1762
+ //#region src/lib/mcp-permissions-settings.ts
1763
+ /**
1764
+ * CLAUDE_CODE_* / auth / routing env keys that must NOT reach the CloudCLI-spawned
1765
+ * serve agent through the mirror `settings.json` `env` block. CloudCLI applies
1766
+ * that block via the Agent SDK's `settingSources`, so anything a user set in their
1767
+ * real `~/.claude/settings.json` (for their own workflow) is mirrored in and
1768
+ * mis-shapes the single serve chat agent — the exact mechanism behind the
1769
+ * coordinator-mode bug.
1770
+ *
1771
+ * - {@link STRIPPED_AUTH_ROUTING_ENV_KEYS} (shared with the process-env strip in
1772
+ * `launch.ts`): auth/routing/remote keys that would re-route the agent OFF the
1773
+ * github-router proxy (`ANTHROPIC_BASE_URL`, `CLAUDE_CODE_USE_BEDROCK/VERTEX/
1774
+ * FOUNDRY`, a personal gateway), inject real auth over the synthetic
1775
+ * credential (`ANTHROPIC_API_KEY`/`AUTH_TOKEN`/`OAUTH_TOKEN`), pin a
1776
+ * non-Copilot `ANTHROPIC_MODEL`, or activate the unimplemented Bridge/remote
1777
+ * path. The settings.json `env` block is a SECOND vector for the same keys the
1778
+ * process-env strip already blocks.
1779
+ * - `CLAUDE_CODE_COORDINATOR_MODE`: strips the single agent to delegation-only
1780
+ * (Glob/Read/Bash fail "not enabled in this context").
1781
+ * - `CLAUDE_CODE_SUBAGENT_MODEL`: a rank-#1 hard override that would retarget
1782
+ * the injected implementer/peer-critic subagents off their frontmatter models.
1783
+ *
1784
+ * `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is intentionally NOT stripped — purely
1785
+ * additive, matching `github-router claude` parity.
1786
+ */
1787
+ const SERVE_STRIP_ENV_KEYS = [
1788
+ ...STRIPPED_AUTH_ROUTING_ENV_KEYS,
1789
+ "CLAUDE_CODE_COORDINATOR_MODE",
1790
+ "CLAUDE_CODE_SUBAGENT_MODEL"
1791
+ ];
1792
+ async function readSettingsObject(settingsPath) {
1793
+ let raw;
1794
+ try {
1795
+ raw = await fs.readFile(settingsPath, "utf8");
1796
+ } catch (err) {
1797
+ if (err.code === "ENOENT") return null;
1798
+ throw err;
1799
+ }
1800
+ const parsed = JSON.parse(raw);
1801
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1802
+ throw new Error(`settings.json at ${settingsPath} is not a JSON object; refusing to overwrite`);
1803
+ }
1804
+ async function writeSettingsObject(settingsPath, obj, suffix) {
1805
+ const tmp = `${settingsPath}.${process.pid}.${suffix}.tmp`;
1806
+ await fs.writeFile(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 384 });
1807
+ await fs.rename(tmp, settingsPath);
1808
+ }
1809
+ /**
1810
+ * Remove serve-inappropriate CLAUDE_CODE_* keys (see {@link SERVE_STRIP_ENV_KEYS})
1811
+ * from the serve mirror `settings.json` `env` block. UNCONDITIONAL — unlike the
1812
+ * permission bypass this is NOT gated by `GH_ROUTER_SERVE_NO_AUTO_APPROVE`,
1813
+ * because a user who only wants permission prompts still needs a working toolset.
1814
+ * Serve mirror only; never the operator's real `~/.claude/settings.json`. Atomic
1815
+ * temp+rename, mode 0o600. Returns the keys it removed (empty if none present).
1816
+ */
1817
+ async function sanitizeServeSettingsEnv(settingsPath) {
1818
+ const existing = await readSettingsObject(settingsPath);
1819
+ if (!existing) return { removed: [] };
1820
+ const envRaw = existing.env;
1821
+ if (!envRaw || typeof envRaw !== "object" || Array.isArray(envRaw)) return { removed: [] };
1822
+ const env = { ...envRaw };
1823
+ const removed = [];
1824
+ for (const key of SERVE_STRIP_ENV_KEYS) if (key in env) {
1825
+ delete env[key];
1826
+ removed.push(key);
1827
+ }
1828
+ if (removed.length === 0) return { removed: [] };
1829
+ await writeSettingsObject(settingsPath, {
1830
+ ...existing,
1831
+ env
1832
+ }, "serveenv");
1833
+ return { removed };
1834
+ }
1835
+ /**
1836
+ * Native Claude Code tools worth auto-approving alongside our injected MCP
1837
+ * servers so PLAN mode is frictionless for research. These are read-only
1838
+ * discovery tools that plan mode would otherwise prompt for.
1839
+ *
1840
+ * Deliberately MINIMAL and safe:
1841
+ * - `WebSearch` / `WebFetch` — network reads; exactly the research surface a
1842
+ * planner reaches for, and they prompt by default in plan mode.
1843
+ * - EXCLUDES `Read`/`Glob`/`Grep` — Claude Code already runs them in plan mode
1844
+ * without a rule (its built-in safe list), so a rule would be redundant.
1845
+ * - EXCLUDES `Bash` — can mutate (`rm`, redirects); blanket-allow is unsafe.
1846
+ * Curated `Bash(<readonly cmd> *)` rules are the user's own call.
1847
+ * - EXCLUDES `Task`/`Skill`/`Workflow`/`SendMessage` — spawn/delegate; a
1848
+ * subagent launched in plan mode isn't guaranteed to inherit plan's
1849
+ * read-only restriction, so auto-approving them is a plan-mode-bypass vector.
1850
+ * - EXCLUDES `Edit`/`Write`/`NotebookEdit` etc. — plan mode gates them by
1851
+ * design and we must not override that.
1852
+ */
1853
+ const NATIVE_RESEARCH_ALLOW_RULES = ["WebSearch", "WebFetch"];
1854
+ /**
1855
+ * Merge arbitrary allow rules into a mirror `settings.json` `permissions.allow`
1856
+ * so the listed tools auto-run WITHOUT a permission prompt in every mode —
1857
+ * including plan mode, where a matched allow rule bypasses the prompt for any
1858
+ * tool NOT on plan mode's file-edit/shell-write restricted list (MCP tools and
1859
+ * read-only natives like WebSearch/WebFetch). See {@link NATIVE_RESEARCH_ALLOW_RULES}
1860
+ * for the native additions and the "Configure permissions" Claude Code doc for
1861
+ * the plan-mode semantics.
1862
+ *
1863
+ * Rules are literal allow strings (`mcp__peers`, `WebSearch`, …). Existing
1864
+ * `allow` entries and `deny`/`ask` are preserved; merge is idempotent (no write
1865
+ * when every rule is already present). Mirror only; never the operator's real
1866
+ * settings. Atomic temp+rename, mode 0o600.
1867
+ */
1868
+ async function injectAllowRules(settingsPath, rules) {
1869
+ const want = [...new Set(rules.filter(Boolean))];
1870
+ if (want.length === 0) return { added: [] };
1871
+ const existing = await readSettingsObject(settingsPath) ?? {};
1872
+ const permsRaw = existing.permissions;
1873
+ const perms = permsRaw && typeof permsRaw === "object" && !Array.isArray(permsRaw) ? { ...permsRaw } : {};
1874
+ const allow = Array.isArray(perms.allow) ? [...perms.allow] : [];
1875
+ const present = new Set(allow.filter((x) => typeof x === "string"));
1876
+ const added = want.filter((rule) => !present.has(rule));
1877
+ if (added.length === 0) return { added: [] };
1878
+ perms.allow = [...allow, ...added];
1879
+ await writeSettingsObject(settingsPath, {
1880
+ ...existing,
1881
+ permissions: perms
1882
+ }, "allowrules");
1883
+ return { added };
1884
+ }
1885
+ /**
1886
+ * Build the full plan-mode allow-rule set: a bare `mcp__<server>` rule per
1887
+ * injected MCP server (whole-server allow) plus the native research tools. The
1888
+ * server keys are the RESOLVED mcpServers config keys (collision-aware —
1889
+ * `peers` or `gh-router-peers`, `codex-cli`, …) so each rule matches the server
1890
+ * name the model actually sees.
1891
+ */
1892
+ function planModeAllowRules(serverKeys) {
1893
+ return [...[...new Set(serverKeys.filter(Boolean))].map((k) => `mcp__${k}`), ...NATIVE_RESEARCH_ALLOW_RULES];
1894
+ }
1895
+ /**
1896
+ * Set the serve mirror's `settings.json` `permissions.defaultMode` to
1897
+ * `bypassPermissions` — the web-control-plane equivalent of `github-router
1898
+ * claude`'s default `--dangerously-skip-permissions`. Without it, CloudCLI's
1899
+ * Agent-SDK `canUseTool` callback posts a browser `permission_request` and AWAITS
1900
+ * approval for every not-yet-allowed tool, stalling the chat on each injected
1901
+ * `mcp__*` and native call.
1902
+ *
1903
+ * PRESERVES the user's `allow`/`deny`/`ask` posture verbatim — their curated
1904
+ * permission rules carry into serve unchanged, so a session switched off bypass
1905
+ * (plan / default mode via the composer) behaves exactly as their own Claude Code
1906
+ * would. We used to CLEAR `allow` on a since-debunked theory that a non-empty
1907
+ * allow narrows the toolset; empirically it does NOT (a 13-rule allow yields the
1908
+ * full 27-tool set incl Glob/Bash/Edit), and CloudCLI's `allowedTools` comes only
1909
+ * from the client `localStorage['claude-settings']` seed (`[]`), never from
1910
+ * `permissions.allow`. The real toolset-narrowing was `CLAUDE_CODE_COORDINATOR_MODE`,
1911
+ * stripped separately by {@link sanitizeServeSettingsEnv}. Not clearing `allow`
1912
+ * is what keeps serve at parity with the user's own Claude Code permission posture.
1913
+ *
1914
+ * Only ever rewrites the per-launch serve mirror, never the operator's real
1915
+ * `~/.claude/settings.json`. Atomic temp+rename, mode 0o600. A non-object
1916
+ * settings.json throws (never clobber a file we don't understand); the caller
1917
+ * wraps this in warn-and-continue so a hiccup never blocks launch.
1918
+ *
1919
+ * Opt out with `GH_ROUTER_SERVE_NO_AUTO_APPROVE=1` (the caller skips this) — the
1920
+ * mirrored `defaultMode` is then left exactly as snapshotted.
1921
+ */
1922
+ async function configureServePermissionsBypass(settingsPath) {
1923
+ const existing = await readSettingsObject(settingsPath) ?? {};
1924
+ const permsRaw = existing.permissions;
1925
+ const perms = permsRaw && typeof permsRaw === "object" && !Array.isArray(permsRaw) ? { ...permsRaw } : {};
1926
+ if (perms.defaultMode === "bypassPermissions") return { written: false };
1927
+ perms.defaultMode = "bypassPermissions";
1928
+ await writeSettingsObject(settingsPath, {
1929
+ ...existing,
1930
+ permissions: perms
1931
+ }, "servebypass");
1932
+ return { written: true };
1933
+ }
1934
+
1706
1935
  //#endregion
1707
1936
  //#region src/lib/model-validation.ts
1708
1937
  const ENDPOINT_ALIASES = {
@@ -2568,7 +2797,7 @@ async function discoverGateCommands(cwd, opts) {
2568
2797
  if (files.length === 0) return null;
2569
2798
  let result;
2570
2799
  try {
2571
- const { runWorkerAgent } = await import("./engine-D7mUKnIZ.js");
2800
+ const { runWorkerAgent } = await import("./engine-CIt6z9K3.js");
2572
2801
  result = await runWorkerAgent({
2573
2802
  mode: "explore",
2574
2803
  workspace: root,
@@ -4728,7 +4957,7 @@ function initProxyFromEnv() {
4728
4957
  //#endregion
4729
4958
  //#region package.json
4730
4959
  var name = "github-router";
4731
- var version$1 = "0.3.178";
4960
+ var version$1 = "0.3.202";
4732
4961
 
4733
4962
  //#endregion
4734
4963
  //#region src/lib/approval.ts
@@ -4745,7 +4974,7 @@ async function checkRateLimit(state$1) {
4745
4974
  const ticket = { aborted: false };
4746
4975
  const myTurn = rateLimitChain.then(() => doCheck(state$1, ticket));
4747
4976
  rateLimitChain = myTurn.catch(() => {});
4748
- return Promise.race([myTurn, sleep(RATE_LIMIT_QUEUE_TIMEOUT_MS).then(() => {
4977
+ return Promise.race([myTurn, sleep$1(RATE_LIMIT_QUEUE_TIMEOUT_MS).then(() => {
4749
4978
  ticket.aborted = true;
4750
4979
  throw new HTTPError("Rate limit queue wait exceeded", Response.json({
4751
4980
  type: "error",
@@ -4776,7 +5005,7 @@ async function doCheck(state$1, ticket) {
4776
5005
  }
4777
5006
  const waitTimeMs = waitTimeSeconds * 1e3;
4778
5007
  consola.warn(`Rate limit reached. Waiting ${waitTimeSeconds} seconds before proceeding...`);
4779
- await sleep(waitTimeMs);
5008
+ await sleep$1(waitTimeMs);
4780
5009
  if (ticket.aborted) return;
4781
5010
  state$1.lastRequestTimestamp = Date.now();
4782
5011
  consola.info("Rate limit wait completed, proceeding with request");
@@ -8552,6 +8781,12 @@ const claude = defineCommand({
8552
8781
  } catch (err) {
8553
8782
  consola.warn(`Could not register the UserPromptSubmit hook: ${String(err)}`);
8554
8783
  }
8784
+ try {
8785
+ const res = await injectAllowRules(nodePath.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json"), planModeAllowRules(injected.ok ? injected.serversAdded : [...Object.values(groupKeys).filter((k) => Boolean(k)), ...backend === "cli" ? ["codex-cli"] : []]));
8786
+ if (res.added.length) consola.debug(`Auto-approved for plan mode: ${res.added.join(", ")}`);
8787
+ } catch (err) {
8788
+ consola.warn(`Could not auto-approve injected tools: ${String(err)}`);
8789
+ }
8555
8790
  if (!injected.ok) consola.warn("Workers non-blocking guard NOT registered: subagent MCP injection fell back to parent-only (--mcp-config), so worker-* dispatchers cannot reach the workers server. Raw (blocking) worker tools remain usable on the main thread this session.");
8556
8791
  else if (process$1.env.GH_ROUTER_DISABLE_WORKER_GUARD === "1") consola.info("Workers non-blocking guard disabled via GH_ROUTER_DISABLE_WORKER_GUARD=1 — raw mcp__workers__* is callable on the main thread (blocking); the worker-* background agents remain the steered, non-blocking default.");
8557
8792
  else try {
@@ -9196,6 +9431,26 @@ const internalSessionBind = defineCommand({
9196
9431
  }
9197
9432
  });
9198
9433
 
9434
+ //#endregion
9435
+ //#region src/internal-workspace-header.ts
9436
+ function workspaceHeaderJsonForCwd(cwd = process.cwd()) {
9437
+ return buildWorkspaceHeaderJson(cwd);
9438
+ }
9439
+ const internalWorkspaceHeader = defineCommand({
9440
+ meta: {
9441
+ name: "internal-workspace-header",
9442
+ description: "Internal: headersHelper for HTTP MCP connections. Prints the current working directory as pure JSON headers."
9443
+ },
9444
+ run() {
9445
+ try {
9446
+ process.stdout.write(workspaceHeaderJsonForCwd(process.cwd()));
9447
+ } catch {
9448
+ process.stdout.write("{}");
9449
+ }
9450
+ process.exitCode = 0;
9451
+ }
9452
+ });
9453
+
9199
9454
  //#endregion
9200
9455
  //#region src/lib/artifact/plan-html.ts
9201
9456
  function escapeHtml(s) {
@@ -9951,6 +10206,1481 @@ function formatTokens(n) {
9951
10206
  return `${n}`;
9952
10207
  }
9953
10208
 
10209
+ //#endregion
10210
+ //#region src/lib/serve/cloudcli.ts
10211
+ const CLOUDCLI_PACKAGE = "@cloudcli-ai/cloudcli";
10212
+ /** Pinned, tested known-good version. Never `@latest` (see docs/serve-control-plane.md). */
10213
+ const CLOUDCLI_PINNED_VERSION = "1.36.1";
10214
+ /**
10215
+ * Build the env handed to the CloudCLI child. Starts from `buildEnv()` — the
10216
+ * vetted secret-stripping ALLOWLIST used for worker bash (drops GITHUB_TOKEN,
10217
+ * GH_ROUTER_*, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, COPILOT_TOKEN by omission)
10218
+ * — then layers the non-secret Anthropic/Claude vars. The spawned claude still
10219
+ * authenticates because that rides the synthetic `.credentials.json` FILE in
10220
+ * CLAUDE_CONFIG_DIR, not an env token. Load-bearing: CloudCLI exposes a browser
10221
+ * terminal that can read its own env, so no secret may live there.
10222
+ */
10223
+ function composeCloudCliChildEnv(anthropicVars) {
10224
+ return {
10225
+ ...buildEnv(),
10226
+ ...anthropicVars
10227
+ };
10228
+ }
10229
+ const SERVER_ENTRY_REL = nodePath.join("dist-server", "server", "index.js");
10230
+ /** Where the package's server entry lives under a given install root. */
10231
+ function serverEntryUnderRoot(root) {
10232
+ return nodePath.join(root, "node_modules", CLOUDCLI_PACKAGE, SERVER_ENTRY_REL);
10233
+ }
10234
+ async function exists(p) {
10235
+ try {
10236
+ await fs.access(p);
10237
+ return true;
10238
+ } catch {
10239
+ return false;
10240
+ }
10241
+ }
10242
+ async function readVersion(pkgRoot) {
10243
+ try {
10244
+ const raw = await fs.readFile(nodePath.join(pkgRoot, "node_modules", CLOUDCLI_PACKAGE, "package.json"), "utf8");
10245
+ return JSON.parse(raw).version ?? null;
10246
+ } catch {
10247
+ return null;
10248
+ }
10249
+ }
10250
+ /**
10251
+ * Resolve the CloudCLI server entry, in order:
10252
+ * 1. an explicit `--cloudcli-path` (a package dir OR an install root),
10253
+ * 2. an existing pinned install in the router-owned dir,
10254
+ * 3. (unless `noInstall`) install the pinned version into the router-owned dir.
10255
+ * Never installs globally, never `@latest`.
10256
+ */
10257
+ async function resolveCloudCli(opts) {
10258
+ const version$2 = opts.version ?? CLOUDCLI_PINNED_VERSION;
10259
+ if (opts.cliPath) {
10260
+ const direct = nodePath.join(opts.cliPath, SERVER_ENTRY_REL);
10261
+ const underRoot = serverEntryUnderRoot(opts.cliPath);
10262
+ if (await exists(direct)) return {
10263
+ serverEntry: direct,
10264
+ version: null
10265
+ };
10266
+ if (await exists(underRoot)) return {
10267
+ serverEntry: underRoot,
10268
+ version: await readVersion(opts.cliPath)
10269
+ };
10270
+ throw new Error(`--cloudcli-path "${opts.cliPath}" does not contain a CloudCLI install (looked for ${SERVER_ENTRY_REL}).`);
10271
+ }
10272
+ const root = PATHS.CLOUDCLI_HOME;
10273
+ const entry = serverEntryUnderRoot(root);
10274
+ if (await exists(entry)) return {
10275
+ serverEntry: entry,
10276
+ version: await readVersion(root)
10277
+ };
10278
+ if (opts.noInstall) throw new Error(`CloudCLI is not installed and --no-install was given. Install it with:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version$2}`);
10279
+ await installCloudCli(root, version$2);
10280
+ if (!await exists(entry)) throw new Error(`CloudCLI install completed but the server entry is missing (${entry}).`);
10281
+ return {
10282
+ serverEntry: entry,
10283
+ version: await readVersion(root)
10284
+ };
10285
+ }
10286
+ async function installCloudCli(root, version$2) {
10287
+ const npm = resolveExecutable("npm");
10288
+ if (!npm) throw new Error("npm not found on PATH — cannot install CloudCLI. Install Node.js/npm, or pass --cloudcli-path to an existing install.");
10289
+ await fs.mkdir(root, {
10290
+ recursive: true,
10291
+ mode: 448
10292
+ });
10293
+ const pkgJson = nodePath.join(root, "package.json");
10294
+ if (!await exists(pkgJson)) await fs.writeFile(pkgJson, JSON.stringify({
10295
+ name: "gh-router-cloudcli-host",
10296
+ private: true
10297
+ }, null, 2));
10298
+ consola.info(`Installing ${CLOUDCLI_PACKAGE}@${version$2} (one-time, ~a minute; builds native deps)…`);
10299
+ const { code, stderr } = await runCommandCapture([
10300
+ npm,
10301
+ "install",
10302
+ `${CLOUDCLI_PACKAGE}@${version$2}`,
10303
+ "--prefix",
10304
+ root,
10305
+ "--no-audit",
10306
+ "--no-fund",
10307
+ "--loglevel=error"
10308
+ ], { timeoutMs: 10 * 6e4 });
10309
+ if (code !== 0) throw new Error(`CloudCLI install failed (npm exit ${code}). This often means no network, a corporate proxy, or missing Windows build tools for native deps.\nInstall manually then re-run with --cloudcli-path:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version$2}\n${stderr.slice(0, 400)}`);
10310
+ }
10311
+ /** Spawn CloudCLI on a loopback port with the given (already-filtered) env. */
10312
+ function spawnCloudCli(opts) {
10313
+ const databasePath = nodePath.join(PATHS.CLOUDCLI_HOME, "auth.db");
10314
+ const node = resolveExecutable("node") ?? process.execPath;
10315
+ const logPath = process.env.GH_ROUTER_CLOUDCLI_LOG;
10316
+ let stdio = "ignore";
10317
+ if (logPath) try {
10318
+ const fd = fs$1.openSync(logPath, "a");
10319
+ stdio = [
10320
+ "ignore",
10321
+ fd,
10322
+ fd
10323
+ ];
10324
+ } catch {}
10325
+ return {
10326
+ child: spawn(node, [opts.serverEntry], {
10327
+ env: {
10328
+ ...opts.env,
10329
+ SERVER_PORT: String(opts.port),
10330
+ HOST: "127.0.0.1",
10331
+ DATABASE_PATH: databasePath,
10332
+ VITE_IS_PLATFORM: ""
10333
+ },
10334
+ stdio,
10335
+ windowsHide: true
10336
+ }),
10337
+ port: opts.port,
10338
+ databasePath
10339
+ };
10340
+ }
10341
+ function req(port, method, urlPath, body) {
10342
+ return new Promise((resolve, reject) => {
10343
+ const data = body === void 0 ? void 0 : JSON.stringify(body);
10344
+ const r = http.request({
10345
+ host: "127.0.0.1",
10346
+ port,
10347
+ path: urlPath,
10348
+ method,
10349
+ headers: data ? {
10350
+ "content-type": "application/json",
10351
+ "content-length": Buffer.byteLength(data)
10352
+ } : {}
10353
+ }, (res) => {
10354
+ let b = "";
10355
+ res.on("data", (d) => b += d);
10356
+ res.on("end", () => resolve({
10357
+ status: res.statusCode ?? 0,
10358
+ body: b
10359
+ }));
10360
+ });
10361
+ r.on("error", reject);
10362
+ if (data) r.write(data);
10363
+ r.end();
10364
+ });
10365
+ }
10366
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10367
+ /** Poll /api/auth/status until the server answers. Returns `needsSetup`. */
10368
+ async function waitForCloudCliReady(port, timeoutMs = 6e4) {
10369
+ const deadline = Date.now() + timeoutMs;
10370
+ while (Date.now() < deadline) {
10371
+ try {
10372
+ const r = await req(port, "GET", "/api/auth/status");
10373
+ if (r.status === 200) return { needsSetup: JSON.parse(r.body).needsSetup === true };
10374
+ } catch {}
10375
+ await sleep(1e3);
10376
+ }
10377
+ throw new Error(`CloudCLI did not become ready within ${timeoutMs}ms`);
10378
+ }
10379
+ const secretFile = () => nodePath.join(PATHS.CLOUDCLI_HOME, ".serve-secret.json");
10380
+ async function loadPassword(username) {
10381
+ try {
10382
+ const raw = await fs.readFile(secretFile(), "utf8");
10383
+ const parsed = JSON.parse(raw);
10384
+ if (parsed.username === username && parsed.password) return parsed.password;
10385
+ } catch {}
10386
+ return null;
10387
+ }
10388
+ async function persistPassword(username, password) {
10389
+ await fs.mkdir(PATHS.CLOUDCLI_HOME, {
10390
+ recursive: true,
10391
+ mode: 448
10392
+ });
10393
+ await fs.writeFile(secretFile(), JSON.stringify({
10394
+ username,
10395
+ password
10396
+ }), { mode: 384 });
10397
+ }
10398
+ /**
10399
+ * Ensure a CloudCLI user exists and return a valid JWT. On a fresh (isolated)
10400
+ * DB we register (persisting the password only AFTER success); on an existing
10401
+ * DB we log in with the persisted password.
10402
+ */
10403
+ async function mintJwt(port, username, needsSetup) {
10404
+ const existing = await loadPassword(username);
10405
+ if (needsSetup) {
10406
+ const password$1 = existing ?? randomBytes(24).toString("base64url");
10407
+ const r$1 = await req(port, "POST", "/api/auth/register", {
10408
+ username,
10409
+ password: password$1
10410
+ });
10411
+ if (r$1.status === 200) {
10412
+ const token = JSON.parse(r$1.body).token;
10413
+ if (token) {
10414
+ await persistPassword(username, password$1);
10415
+ return token;
10416
+ }
10417
+ }
10418
+ }
10419
+ const password = existing;
10420
+ if (!password) throw new Error(`CloudCLI already has a user but no stored password for "${username}". Delete ${nodePath.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
10421
+ const r = await req(port, "POST", "/api/auth/login", {
10422
+ username,
10423
+ password
10424
+ });
10425
+ if (r.status === 200) {
10426
+ const token = JSON.parse(r.body).token;
10427
+ if (token) return token;
10428
+ }
10429
+ throw new Error(`Could not obtain a CloudCLI session token (status ${r.status}). If a stale isolated DB exists, delete ${nodePath.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
10430
+ }
10431
+
10432
+ //#endregion
10433
+ //#region src/lib/serve/devtunnel.ts
10434
+ const SERVE_TUNNEL_LABEL = "github-router-serve";
10435
+ function serveTunnelMachineLabel(hostname = os.hostname()) {
10436
+ return `ghr-machine-${createHash("sha256").update(hostname).digest("hex").slice(0, 12)}`;
10437
+ }
10438
+ /**
10439
+ * Stable, deterministic tunnel ID for THIS machine, so `serve --tunnel` REUSES
10440
+ * one tunnel across launches → a stable, bookmarkable public URL. Derived from a
10441
+ * hash of the hostname (valid tunnel-id charset: lowercase alnum + hyphens,
10442
+ * bounded length). NOTE: the hosting URL's subdomain is a devtunnels-assigned
10443
+ * token, NOT this id — the id only anchors find/reuse; the URL is stable because
10444
+ * the same tunnel is re-hosted, not because the id appears in it.
10445
+ */
10446
+ function serveTunnelId(hostname = os.hostname()) {
10447
+ return `ghr-serve-${createHash("sha256").update(hostname).digest("hex").slice(0, 12)}`;
10448
+ }
10449
+ /**
10450
+ * Reconcile a reused tunnel's ports to exactly `[desired]`: which existing ports
10451
+ * to delete and whether the desired port must be created. Pure (unit-tested).
10452
+ * This is why reuse works across `--port` changes — `devtunnel host <id>
10453
+ * -p <newport>` fails ("Batch update of ports is not supported"), but per-port
10454
+ * `port delete` + `port create` does not.
10455
+ */
10456
+ function portsToReconcile(currentPorts, desired) {
10457
+ return {
10458
+ toDelete: currentPorts.filter((p) => p !== desired),
10459
+ toCreate: currentPorts.includes(desired) ? null : desired
10460
+ };
10461
+ }
10462
+ /**
10463
+ * The IDs of our own idle tunnels for this machine to sweep — EXCLUDING the
10464
+ * stable reused tunnel (`exceptId`), which we keep and re-host. Pure
10465
+ * (unit-tested). A tunnel with a live host connection (a concurrent serve
10466
+ * instance) is never swept. Cleans up pre-migration random-named tunnels + any
10467
+ * crash-orphaned duplicates while preserving the one stable tunnel.
10468
+ */
10469
+ function serveTunnelIdsToSweep(tunnels, machineLabel, exceptId) {
10470
+ return tunnels.filter((t) => (t.labels ?? []).includes(machineLabel) && !t.hostConnections).map((t) => t.tunnelId).filter((id) => !exceptId || id.split(".")[0] !== exceptId);
10471
+ }
10472
+ /** List the tunnels serve owns (label-filtered). Best-effort: [] on any error. */
10473
+ async function listServeTunnels(cli) {
10474
+ try {
10475
+ const { stdout, code } = await runCommandCapture([
10476
+ cli,
10477
+ "list",
10478
+ "-l",
10479
+ SERVE_TUNNEL_LABEL,
10480
+ "-j"
10481
+ ], { timeoutMs: 15e3 });
10482
+ if (code !== 0) return [];
10483
+ const parsed = JSON.parse(stdout);
10484
+ return Array.isArray(parsed?.tunnels) ? parsed.tunnels : [];
10485
+ } catch {
10486
+ return [];
10487
+ }
10488
+ }
10489
+ /** Best-effort delete of a single tunnel by full id (e.g. `fancy-fog-x.inc1`). */
10490
+ async function deleteTunnel(cli, tunnelId) {
10491
+ try {
10492
+ await runCommandCapture([
10493
+ cli,
10494
+ "delete",
10495
+ tunnelId,
10496
+ "-f"
10497
+ ], { timeoutMs: 15e3 });
10498
+ } catch {}
10499
+ }
10500
+ /** Current port numbers configured on a tunnel. Best-effort: [] on any error. */
10501
+ async function getTunnelPorts(cli, tunnelId) {
10502
+ try {
10503
+ const { stdout, code } = await runCommandCapture([
10504
+ cli,
10505
+ "port",
10506
+ "list",
10507
+ tunnelId,
10508
+ "-j"
10509
+ ], { timeoutMs: 15e3 });
10510
+ if (code !== 0) return [];
10511
+ return (JSON.parse(stdout)?.ports ?? []).map((p) => p.portNumber).filter((n) => typeof n === "number");
10512
+ } catch {
10513
+ return [];
10514
+ }
10515
+ }
10516
+ /**
10517
+ * Ensure the stable per-machine tunnel exists (create it labeled if absent).
10518
+ * Returns true when the stable tunnel is present + owned by us (safe to reuse),
10519
+ * false to fall back to the anonymous-labeled host path. `existing` is the
10520
+ * pre-fetched label-scoped list (so we don't re-list).
10521
+ */
10522
+ async function ensureStableTunnel(cli, stableId, machineLabel, existing) {
10523
+ if (existing.some((t) => t.tunnelId.split(".")[0] === stableId)) return true;
10524
+ try {
10525
+ const { code } = await runCommandCapture([
10526
+ cli,
10527
+ "create",
10528
+ stableId,
10529
+ "-l",
10530
+ SERVE_TUNNEL_LABEL,
10531
+ "-l",
10532
+ machineLabel,
10533
+ "-d",
10534
+ "github-router-serve-control-plane"
10535
+ ], { timeoutMs: 2e4 });
10536
+ return code === 0;
10537
+ } catch {
10538
+ return false;
10539
+ }
10540
+ }
10541
+ /** Reconcile the tunnel's ports to exactly `[port]` (delete stale, create missing). */
10542
+ async function reconcileTunnelPorts(cli, tunnelId, port) {
10543
+ const { toDelete, toCreate } = portsToReconcile(await getTunnelPorts(cli, tunnelId), port);
10544
+ for (const p of toDelete) await runCommandCapture([
10545
+ cli,
10546
+ "port",
10547
+ "delete",
10548
+ tunnelId,
10549
+ "-p",
10550
+ String(p)
10551
+ ], { timeoutMs: 15e3 }).catch(() => {});
10552
+ if (toCreate != null) await runCommandCapture([
10553
+ cli,
10554
+ "port",
10555
+ "create",
10556
+ tunnelId,
10557
+ "-p",
10558
+ String(toCreate)
10559
+ ], { timeoutMs: 15e3 }).catch(() => {});
10560
+ }
10561
+ const DEVTUNNEL_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.devtunnels\.ms\/?/i;
10562
+ var DevtunnelError = class extends Error {
10563
+ kind;
10564
+ constructor(kind, message) {
10565
+ super(message);
10566
+ this.name = "DevtunnelError";
10567
+ this.kind = kind;
10568
+ }
10569
+ };
10570
+ /** Absolute path to the `devtunnel` CLI, or null if not on PATH. */
10571
+ function resolveDevtunnelCli() {
10572
+ return resolveExecutable("devtunnel");
10573
+ }
10574
+ /** Best-effort login check via `devtunnel user show`. */
10575
+ async function isDevtunnelLoggedIn(cli) {
10576
+ try {
10577
+ const { stdout, stderr, code } = await runCommandCapture([
10578
+ cli,
10579
+ "user",
10580
+ "show"
10581
+ ], { timeoutMs: 15e3 });
10582
+ const out = `${stdout}\n${stderr}`;
10583
+ if (/not logged in/i.test(out)) return false;
10584
+ if (/logged in as/i.test(out)) return true;
10585
+ return code === 0;
10586
+ } catch {
10587
+ return false;
10588
+ }
10589
+ }
10590
+ /** Extract the dev-tunnel browser URL from CLI output. Exported for tests. */
10591
+ function parseDevtunnelUrl(text) {
10592
+ const m = text.match(DEVTUNNEL_URL_RE);
10593
+ return m ? m[0].replace(/\/+$/, "") : null;
10594
+ }
10595
+ /**
10596
+ * Spawn `devtunnel <args>` and resolve once it prints its public URL. Shared by
10597
+ * the stable-reuse and anonymous-labeled host paths. Rejects (DevtunnelError) on
10598
+ * timeout, early exit, an auth error in the output, or a spawn failure.
10599
+ */
10600
+ function hostTunnelProcess(cli, args, timeoutMs) {
10601
+ const child = spawn(cli, args, {
10602
+ stdio: [
10603
+ "ignore",
10604
+ "pipe",
10605
+ "pipe"
10606
+ ],
10607
+ windowsHide: true
10608
+ });
10609
+ return new Promise((resolve, reject) => {
10610
+ let settled = false;
10611
+ const stop = () => {
10612
+ try {
10613
+ killChildProcessTree(child, { detachedGroup: process.platform !== "win32" });
10614
+ } catch {
10615
+ try {
10616
+ child.kill();
10617
+ } catch {}
10618
+ }
10619
+ };
10620
+ const finish = (fn) => {
10621
+ if (settled) return;
10622
+ settled = true;
10623
+ clearTimeout(timer);
10624
+ fn();
10625
+ };
10626
+ const timer = setTimeout(() => finish(() => {
10627
+ stop();
10628
+ reject(new DevtunnelError("timeout", "dev tunnel did not report a URL in time"));
10629
+ }), timeoutMs);
10630
+ const onData = (buf) => {
10631
+ const text = String(buf);
10632
+ const url = parseDevtunnelUrl(text);
10633
+ if (url) {
10634
+ finish(() => resolve({
10635
+ url,
10636
+ stop
10637
+ }));
10638
+ return;
10639
+ }
10640
+ if (/not logged in|login (required|expired)|unauthorized/i.test(text)) finish(() => {
10641
+ stop();
10642
+ reject(new DevtunnelError("not-logged-in", "dev tunnel authentication failed; run `devtunnel user login`"));
10643
+ });
10644
+ };
10645
+ child.stdout?.on("data", onData);
10646
+ child.stderr?.on("data", onData);
10647
+ child.once("error", (err) => finish(() => reject(new DevtunnelError("spawn", err.message))));
10648
+ child.once("exit", (code) => finish(() => reject(new DevtunnelError("exited", `devtunnel exited (code ${code}) before printing a URL`))));
10649
+ });
10650
+ }
10651
+ /**
10652
+ * Host an AUTHENTICATED (never anonymous) dev tunnel forwarding `port`, resolving
10653
+ * once its public URL is printed. NEVER `--allow-anonymous` (owner-only access).
10654
+ *
10655
+ * REUSES a stable per-machine tunnel (`serveTunnelId`) across launches → a
10656
+ * STABLE, bookmarkable public URL. Each launch: ensure the stable tunnel exists
10657
+ * (create it labeled if absent), reconcile its ports to exactly `[port]` via
10658
+ * per-port delete/create (`host <id> -p <newport>` can't change ports — it 400s
10659
+ * "Batch update of ports is not supported"), sweep our OTHER idle labeled tunnels
10660
+ * (pre-migration random-named ones / crash orphans), then `host <id>`. The URL's
10661
+ * subdomain is a devtunnels-assigned token (not the id), but it's stable because
10662
+ * the SAME tunnel is re-hosted every launch.
10663
+ *
10664
+ * If any stable-path step fails (id already taken by another owner, CLI hiccup),
10665
+ * falls back to hosting a fresh anonymous-labeled tunnel (the prior behavior) so
10666
+ * `--tunnel` always works — just without a stable URL that launch.
10667
+ */
10668
+ async function startDevtunnel(port, opts = {}) {
10669
+ const cli = resolveDevtunnelCli();
10670
+ if (!cli) throw new DevtunnelError("not-installed", "The `devtunnel` CLI is not installed. Install it (winget install Microsoft.devtunnel / brew install --cask devtunnel) then run `devtunnel user login`.");
10671
+ if (!await isDevtunnelLoggedIn(cli)) throw new DevtunnelError("not-logged-in", "Not signed in to dev tunnels. Run `devtunnel user login` (or `devtunnel user login -g` for GitHub) first.");
10672
+ const timeoutMs = opts.timeoutMs ?? 4e4;
10673
+ const machineLabel = serveTunnelMachineLabel();
10674
+ const stableId = serveTunnelId();
10675
+ try {
10676
+ const owned = await listServeTunnels(cli);
10677
+ if (await ensureStableTunnel(cli, stableId, machineLabel, owned)) {
10678
+ await reconcileTunnelPorts(cli, stableId, port);
10679
+ for (const id of serveTunnelIdsToSweep(owned, machineLabel, stableId)) await deleteTunnel(cli, id);
10680
+ return await hostTunnelProcess(cli, ["host", stableId], timeoutMs);
10681
+ }
10682
+ } catch {}
10683
+ try {
10684
+ for (const id of serveTunnelIdsToSweep(await listServeTunnels(cli), machineLabel)) await deleteTunnel(cli, id);
10685
+ } catch {}
10686
+ return await hostTunnelProcess(cli, [
10687
+ "host",
10688
+ "-p",
10689
+ String(port),
10690
+ "-l",
10691
+ SERVE_TUNNEL_LABEL,
10692
+ "-l",
10693
+ machineLabel,
10694
+ "-d",
10695
+ "github-router-serve-control-plane"
10696
+ ], timeoutMs);
10697
+ }
10698
+
10699
+ //#endregion
10700
+ //#region src/lib/serve/enhancements.ts
10701
+ const NOOP = { cleanup: async () => {} };
10702
+ /**
10703
+ * Wire the github-router enhancement layer into the router-owned
10704
+ * CLAUDE_CONFIG_DIR mirror so a CloudCLI-spawned Claude session gets the same
10705
+ * tools `github-router claude` provides:
10706
+ * - the scoped MCP servers (peers / search / orchestrate, plus workers /
10707
+ * decide / browser / fleet / first-mate when their gate passes) — the
10708
+ * SDK-spawned claude reads them from the mirror's `.claude.json`,
10709
+ * - the peer-critic / worker / implementer subagents (`.md` files written into
10710
+ * the mirror's `agents/`),
10711
+ * - the gh-* skills (research / orchestrate / floor-keeper / worker).
10712
+ *
10713
+ * Best-effort: on any failure Claude still works, just without the extras.
10714
+ * Must run AFTER `ensureClaudeConfigMirror()` and BEFORE CloudCLI is spawned.
10715
+ * Tab-specific bits (ai-or-die hooks, artifact review) are intentionally left
10716
+ * out — they don't apply to the serve control plane.
10717
+ */
10718
+ async function provisionServeEnhancements(serverUrl, opts = {}) {
10719
+ try {
10720
+ const tunnelExposed = opts.tunnelExposed === true;
10721
+ const browseAllowed = browserToolsEnabled() && (!tunnelExposed || opts.browseOverTunnel === true);
10722
+ const firstMateAllowed = agentToolsEnabled() && (!tunnelExposed || opts.agentsOverTunnel === true);
10723
+ const fleetAllowed = fleetToolsEnabled() && (!tunnelExposed || opts.fleetOverTunnel === true);
10724
+ const enabledGroups = [
10725
+ "peers",
10726
+ "search",
10727
+ "orchestrate"
10728
+ ];
10729
+ if (workerToolsEnabled()) enabledGroups.push("workers");
10730
+ if (standInToolEnabled()) enabledGroups.push("decide");
10731
+ if (browseAllowed) enabledGroups.push("browser");
10732
+ if (fleetAllowed) enabledGroups.push("fleet");
10733
+ if (firstMateAllowed) enabledGroups.push("first-mate");
10734
+ const gem = geminiAvailable();
10735
+ const { keys: groupKeys } = await resolveGroupKeysFromMirror(enabledGroups);
10736
+ const runtime = await writePeerMcpRuntimeFiles(serverUrl, {
10737
+ codexCli: opts.codexCli === true,
10738
+ geminiAvailable: gem,
10739
+ groupKeys,
10740
+ workerToolsAvailable: workerToolsEnabled(),
10741
+ browseAvailable: browseAllowed && browseAgentEnabled(),
10742
+ nativeSubagentModel: nativeSubagentModel(),
10743
+ builtinSubagents: BUILTIN_SUBAGENT_DEFINITIONS
10744
+ });
10745
+ state.peerMcpNonce = runtime.nonce;
10746
+ const injected = await injectPeerMcpIntoMirror(serverUrl, {
10747
+ codexCli: opts.codexCli === true,
10748
+ geminiAvailable: gem,
10749
+ groupKeys,
10750
+ nonce: runtime.nonce
10751
+ });
10752
+ await appendPeerAwarenessToMirroredClaudeMd(buildPeerAwarenessSnippet({
10753
+ codexCli: opts.codexCli === true,
10754
+ geminiAvailable: gem,
10755
+ workerToolsAvailable: workerToolsEnabled(),
10756
+ standInAvailable: standInToolEnabled(),
10757
+ browseAvailable: browseAllowed,
10758
+ compoundBrowseAvailable: browseAllowed && browserCompoundToolsEnabled(),
10759
+ powerBrowseAvailable: browseAllowed && state.powerBrowseEnabled,
10760
+ agentToolsAvailable: firstMateAllowed,
10761
+ groupKeys
10762
+ })).catch((err) => consola.warn(`Peer-awareness CLAUDE.md append failed: ${String(err)}`));
10763
+ await prependStyleDirectiveToMirroredClaudeMd().catch((err) => consola.warn(`Style-directive CLAUDE.md prepend failed: ${String(err)}`));
10764
+ await prependOperatingDefaultsToMirroredClaudeMd(OPERATING_DEFAULTS_DIRECTIVE).catch((err) => consola.warn(`Operating-defaults CLAUDE.md prepend failed: ${String(err)}`));
10765
+ if (toolbeltEnabled()) {
10766
+ const toolbeltLine = buildToolbeltAwareness(availableToolCommands());
10767
+ if (toolbeltLine) await appendToolbeltAwarenessToMirroredClaudeMd(toolbeltLine).catch((err) => consola.warn(`Toolbelt CLAUDE.md append failed: ${String(err)}`));
10768
+ }
10769
+ let skillsWritten = 0;
10770
+ for (const s of INJECTED_SKILLS) {
10771
+ if (s.name.startsWith("gh-first-mate") || s.name === "gh-artifact-review") continue;
10772
+ if ((await writeInjectedSkill(s.name, s.md).catch(() => ({ written: false }))).written) skillsWritten++;
10773
+ }
10774
+ const settingsPath = nodePath.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
10775
+ await injectAttributionSuppressionIntoSettingsFile(settingsPath).catch(() => {});
10776
+ const strip = await sanitizeServeSettingsEnv(settingsPath).catch((err) => {
10777
+ consola.warn(`Could not sanitize serve settings env: ${String(err)}`);
10778
+ return { removed: [] };
10779
+ });
10780
+ if (strip.removed.length) consola.info(`Serve: removed ${strip.removed.join(", ")} from the mirror settings (single-agent surface).`);
10781
+ if (process.env.GH_ROUTER_SERVE_NO_AUTO_APPROVE !== "1") await configureServePermissionsBypass(settingsPath).catch((err) => consola.warn(`Could not set serve permission defaults: ${String(err)}`));
10782
+ await injectAllowRules(settingsPath, planModeAllowRules(injected.ok ? injected.serversAdded : [...Object.values(groupKeys).filter((k) => Boolean(k)), ...opts.codexCli === true ? ["codex-cli"] : []])).catch((err) => consola.warn(`Could not auto-approve injected tools: ${String(err)}`));
10783
+ if (workerToolsEnabled()) {
10784
+ await injectStopHookIntoSettingsFile(settingsPath, buildPromptSubmitHookCommand(process.execPath, process.argv[1]), "UserPromptSubmit", 45).catch((err) => consola.warn(`Could not register the UserPromptSubmit hook: ${String(err)}`));
10785
+ if (!injected.ok) consola.warn("Workers non-blocking guard NOT registered: subagent MCP injection fell back to parent-only, so worker-* dispatchers cannot reach the workers server. Raw worker tools remain usable on the main thread.");
10786
+ else if (process.env.GH_ROUTER_DISABLE_WORKER_GUARD !== "1") {
10787
+ const workersKey = workersKeyOf(groupKeys);
10788
+ const modes = activeDispatchModes({ browse: browseAllowed && browseAgentEnabled() });
10789
+ await injectStopHookIntoSettingsFile(settingsPath, buildWorkerGuardHookCommand(process.execPath, process.argv[1], workersKey, modes), "PreToolUse", 10, guardToolMatcher(workersKey, modes)).catch((err) => consola.warn(`Could not register the workers PreToolUse guard hook: ${String(err)}`));
10790
+ } else consola.info("Workers non-blocking guard disabled via GH_ROUTER_DISABLE_WORKER_GUARD=1; raw worker tools remain callable on the main thread.");
10791
+ }
10792
+ if (planReviewEnabled()) try {
10793
+ await injectStopHookIntoSettingsFile(settingsPath, buildPlanReviewHookCommand(process.execPath, process.argv[1]), "PostToolUse", void 0, "ExitPlanMode");
10794
+ } catch (err) {
10795
+ consola.warn(`Could not register the advisory plan-review hook: ${String(err)}`);
10796
+ }
10797
+ const servers = injected.ok ? injected.serversAdded.join(", ") : "parent-only (user mcpServers collision)";
10798
+ consola.info(`github-router tools wired into Claude sessions: MCP [${servers}], ${runtime.personas.length} subagents, ${skillsWritten} skills.`);
10799
+ return {
10800
+ cleanup: runtime.cleanup,
10801
+ nonce: runtime.nonce,
10802
+ mcpToolNames: enumerateInjectedMcpToolNames(groupKeys, { codexCli: opts.codexCli === true })
10803
+ };
10804
+ } catch (err) {
10805
+ consola.warn(`Could not wire the github-router MCP/skills layer: ${err instanceof Error ? err.message : String(err)}. Claude still works, without the extra tools.`);
10806
+ return NOOP;
10807
+ }
10808
+ }
10809
+
10810
+ //#endregion
10811
+ //#region src/lib/serve/provider-facade.ts
10812
+ function isObject(value) {
10813
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10814
+ }
10815
+ function envelopedData(value) {
10816
+ if (!isObject(value) || !isObject(value.data)) return null;
10817
+ return value.data;
10818
+ }
10819
+ function cloneObject(value) {
10820
+ return { ...value };
10821
+ }
10822
+ /**
10823
+ * Requests serve answers DIRECTLY (never forwarded to CloudCLI) with a canned
10824
+ * JSON body. `POST /api/system/update` is CloudCLI's "update available" button,
10825
+ * which runs a GLOBAL `npm install -g @cloudcli-ai/cloudcli@latest` in the user's
10826
+ * home dir (`server/index.js`) — mutating their global npm and never applying to
10827
+ * serve's pinned, router-owned install. Blocking it prevents that surprise
10828
+ * mutation and tells the user how updates actually work. HTTP 200 + `success:
10829
+ * false` so the UI neither runs the update nor throws an error toast. Returns
10830
+ * null for anything not blocked.
10831
+ */
10832
+ function facadeBlockedRequest(method, pathname) {
10833
+ if (method.toUpperCase() === "POST" && pathname === "/api/system/update") return {
10834
+ status: 200,
10835
+ json: {
10836
+ success: false,
10837
+ managed: true,
10838
+ message: "CloudCLI is managed by github-router (a pinned, router-owned install). To update, run `npm i -g github-router@latest`, then restart `github-router serve`."
10839
+ }
10840
+ };
10841
+ return null;
10842
+ }
10843
+ function facadeInterceptKind(method, pathname) {
10844
+ const normalizedMethod = method.toUpperCase();
10845
+ if (normalizedMethod === "POST" && pathname === "/api/commands/list") return "commands";
10846
+ if (normalizedMethod !== "GET") return null;
10847
+ switch (pathname) {
10848
+ case "/api/providers/claude/models": return "models";
10849
+ case "/api/providers/claude/mcp/servers": return "mcp";
10850
+ case "/api/providers/claude/skills": return "skills";
10851
+ case "/api/providers/claude/auth/status": return "auth";
10852
+ default: return null;
10853
+ }
10854
+ }
10855
+ async function rewriteProviderResponse(kind, upstreamJson, ctx, query) {
10856
+ try {
10857
+ switch (kind) {
10858
+ case "models": return rewriteModels(upstreamJson, ctx);
10859
+ case "mcp": return rewriteMcp(upstreamJson, ctx, query);
10860
+ case "skills": return rewriteSkills(upstreamJson, ctx);
10861
+ case "commands": return rewriteCommands(upstreamJson, ctx);
10862
+ case "auth": return rewriteAuth(upstreamJson);
10863
+ }
10864
+ } catch {
10865
+ return null;
10866
+ }
10867
+ }
10868
+ function rewriteModels(upstreamJson, ctx) {
10869
+ if (!isObject(upstreamJson)) return null;
10870
+ const data = envelopedData(upstreamJson);
10871
+ if (!data || !isObject(data.models) || data.cache === void 0) return null;
10872
+ const models$1 = ctx.getModels()?.data;
10873
+ if (!Array.isArray(models$1) || models$1.length === 0) return null;
10874
+ const options = models$1.filter(isPickerModel).map((m) => {
10875
+ const option = {
10876
+ value: m.id,
10877
+ label: m.name || m.id,
10878
+ description: `${m.vendor} · ${m.capabilities.family}`
10879
+ };
10880
+ const efforts = m.capabilities.supports?.reasoning_effort;
10881
+ if (Array.isArray(efforts) && efforts.length > 0) option.effort = {
10882
+ default: efforts.includes("high") ? "high" : efforts[efforts.length - 1],
10883
+ values: efforts.map((e) => ({ value: e }))
10884
+ };
10885
+ return option;
10886
+ });
10887
+ if (options.length === 0) return null;
10888
+ const values = new Set(options.map((o) => o.value));
10889
+ const upstreamDefault = typeof data.models.DEFAULT === "string" ? data.models.DEFAULT : void 0;
10890
+ const DEFAULT = values.has(ctx.defaultModel) ? ctx.defaultModel : options[0]?.value ?? upstreamDefault;
10891
+ const nextData = cloneObject(data);
10892
+ nextData.models = {
10893
+ OPTIONS: options,
10894
+ DEFAULT
10895
+ };
10896
+ return {
10897
+ ...upstreamJson,
10898
+ data: nextData
10899
+ };
10900
+ }
10901
+ function isPickerModel(model) {
10902
+ return isObject(model) && typeof model.id === "string" && model.model_picker_enabled !== false && typeof model.vendor === "string" && (model.name === void 0 || typeof model.name === "string") && isObject(model.capabilities) && typeof model.capabilities.family === "string";
10903
+ }
10904
+ async function rewriteMcp(upstreamJson, ctx, query) {
10905
+ if (!isObject(upstreamJson) || query.get("scope") !== "user") return null;
10906
+ const data = envelopedData(upstreamJson);
10907
+ if (!data || !Array.isArray(data.servers)) return null;
10908
+ const mirror = await readJson(nodePath.join(ctx.claudeConfigDir, ".claude.json"));
10909
+ if (!isObject(mirror) || !isObject(mirror.mcpServers)) return null;
10910
+ const existing = new Set(data.servers.filter(isObject).map((s) => s.name).filter((name$1) => typeof name$1 === "string"));
10911
+ const additions = [];
10912
+ for (const [name$1, entry] of Object.entries(mirror.mcpServers)) {
10913
+ if (existing.has(name$1) || !isObject(entry)) continue;
10914
+ const server$1 = {
10915
+ provider: "claude",
10916
+ name: name$1,
10917
+ scope: "user",
10918
+ transport: entry.type === "http" ? "http" : "stdio"
10919
+ };
10920
+ if (typeof entry.url === "string") server$1.url = entry.url;
10921
+ additions.push(server$1);
10922
+ existing.add(name$1);
10923
+ }
10924
+ if (additions.length === 0) return null;
10925
+ const nextData = cloneObject(data);
10926
+ nextData.servers = [...data.servers, ...additions];
10927
+ return {
10928
+ ...upstreamJson,
10929
+ data: nextData
10930
+ };
10931
+ }
10932
+ async function rewriteSkills(upstreamJson, ctx) {
10933
+ if (!isObject(upstreamJson)) return null;
10934
+ const data = envelopedData(upstreamJson);
10935
+ if (!data || !Array.isArray(data.skills)) return null;
10936
+ const skills = await readSkillDirs(nodePath.join(ctx.claudeConfigDir, "skills"));
10937
+ if (skills.length === 0) return null;
10938
+ const existing = new Set(data.skills.filter(isObject).map((s) => s.command).filter((command) => typeof command === "string"));
10939
+ const additions = skills.filter((skill) => !existing.has(skill.name)).map((skill) => ({
10940
+ provider: "claude",
10941
+ name: skill.name,
10942
+ description: skill.description,
10943
+ command: skill.name,
10944
+ scope: "user"
10945
+ }));
10946
+ if (additions.length === 0) return null;
10947
+ const nextData = cloneObject(data);
10948
+ nextData.skills = [...data.skills, ...additions];
10949
+ return {
10950
+ ...upstreamJson,
10951
+ data: nextData
10952
+ };
10953
+ }
10954
+ async function rewriteCommands(upstreamJson, ctx) {
10955
+ if (!isObject(upstreamJson) || !Array.isArray(upstreamJson.custom)) return null;
10956
+ const dirs = [nodePath.join(ctx.claudeConfigDir, "commands"), nodePath.join(ctx.claudeConfigDir, ".claude", "commands")];
10957
+ const commandFiles = (await Promise.all(dirs.map(readCommandFiles))).flat();
10958
+ if (commandFiles.length === 0) return null;
10959
+ const existing = new Set(upstreamJson.custom.filter(isObject).map((c) => c.name).filter((name$1) => typeof name$1 === "string"));
10960
+ const additions = commandFiles.filter((command) => !existing.has(command.name)).map((command) => ({
10961
+ name: command.name,
10962
+ path: command.relativePath,
10963
+ relativePath: command.relativePath,
10964
+ description: command.description,
10965
+ namespace: "user",
10966
+ metadata: { type: "custom" }
10967
+ }));
10968
+ if (additions.length === 0) return null;
10969
+ const custom = [...upstreamJson.custom, ...additions];
10970
+ return {
10971
+ ...upstreamJson,
10972
+ custom,
10973
+ count: countCommands(upstreamJson.builtIn) + custom.length
10974
+ };
10975
+ }
10976
+ function rewriteAuth(upstreamJson) {
10977
+ if (!isObject(upstreamJson)) return null;
10978
+ const data = envelopedData(upstreamJson);
10979
+ if (!data) return null;
10980
+ return {
10981
+ ...upstreamJson,
10982
+ data: {
10983
+ ...data,
10984
+ authenticated: true,
10985
+ installed: true
10986
+ }
10987
+ };
10988
+ }
10989
+ async function readJson(filePath) {
10990
+ try {
10991
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
10992
+ } catch {
10993
+ return null;
10994
+ }
10995
+ }
10996
+ async function readSkillDirs(skillsDir) {
10997
+ try {
10998
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
10999
+ return await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith("gh-")).map(async (entry) => {
11000
+ const sourcePath = nodePath.join(skillsDir, entry.name, "SKILL.md");
11001
+ let description = "";
11002
+ try {
11003
+ description = parseFrontmatterField(await fs.readFile(sourcePath, "utf8"), "description") ?? "";
11004
+ } catch {}
11005
+ return {
11006
+ name: entry.name,
11007
+ description,
11008
+ sourcePath
11009
+ };
11010
+ }));
11011
+ } catch {
11012
+ return [];
11013
+ }
11014
+ }
11015
+ async function readCommandFiles(commandsDir) {
11016
+ try {
11017
+ if (!(await fs.stat(commandsDir)).isDirectory()) return [];
11018
+ } catch {
11019
+ return [];
11020
+ }
11021
+ return readCommandFilesRecursive(commandsDir, commandsDir);
11022
+ }
11023
+ async function readCommandFilesRecursive(root, dir) {
11024
+ let entries;
11025
+ try {
11026
+ entries = await fs.readdir(dir, { withFileTypes: true });
11027
+ } catch {
11028
+ return [];
11029
+ }
11030
+ const out = [];
11031
+ for (const entry of entries) {
11032
+ const filePath = nodePath.join(dir, entry.name);
11033
+ if (entry.isDirectory()) {
11034
+ out.push(...await readCommandFilesRecursive(root, filePath));
11035
+ continue;
11036
+ }
11037
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
11038
+ const relativePath = normalizeRelative(nodePath.relative(root, filePath));
11039
+ const name$1 = relativePath.replace(/\.md$/i, "");
11040
+ let description = "";
11041
+ try {
11042
+ const md = await fs.readFile(filePath, "utf8");
11043
+ description = parseFrontmatterField(md, "description") ?? firstMarkdownLine(md);
11044
+ } catch {}
11045
+ out.push({
11046
+ name: name$1,
11047
+ relativePath,
11048
+ filePath,
11049
+ description
11050
+ });
11051
+ }
11052
+ return out;
11053
+ }
11054
+ function parseFrontmatterField(markdown, field) {
11055
+ if (!markdown.startsWith("---")) return null;
11056
+ const end = markdown.indexOf("\n---", 3);
11057
+ if (end < 0) return null;
11058
+ const frontmatter = markdown.slice(3, end).split(/\r?\n/);
11059
+ for (const line of frontmatter) {
11060
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
11061
+ if (!match || match[1] !== field) continue;
11062
+ return match[2].replace(/^['"]|['"]$/g, "").trim();
11063
+ }
11064
+ return null;
11065
+ }
11066
+ function firstMarkdownLine(markdown) {
11067
+ const body = markdown.startsWith("---") ? markdown.slice(markdown.indexOf("\n---", 3) + 4) : markdown;
11068
+ for (const line of body.split(/\r?\n/)) {
11069
+ const trimmed = line.trim();
11070
+ if (trimmed) return trimmed.replace(/^#+\s*/, "");
11071
+ }
11072
+ return "";
11073
+ }
11074
+ function normalizeRelative(value) {
11075
+ return value.split(nodePath.sep).join("/");
11076
+ }
11077
+ function countCommands(builtIn) {
11078
+ return Array.isArray(builtIn) ? builtIn.length : 0;
11079
+ }
11080
+
11081
+ //#endregion
11082
+ //#region src/lib/serve/reverse-proxy.ts
11083
+ /**
11084
+ * Loopback-only identity endpoint served directly by the reverse proxy (never
11085
+ * forwarded to CloudCLI). A second `github-router serve` probes this on the
11086
+ * intended port to detect an already-running instance (single-instance guard),
11087
+ * so it can attach/refuse instead of starting a duplicate CloudCLI. Carries no
11088
+ * secret — just a service marker + version + origin.
11089
+ */
11090
+ const SERVE_IDENTITY_PATH = "/__github-router-serve__";
11091
+ const SERVE_IDENTITY_SERVICE = "github-router-serve";
11092
+ const HEAD_CLOSE = "</head>";
11093
+ /** CloudCLI's localStorage key for per-tool chat permission settings. */
11094
+ const CLAUDE_SETTINGS_KEY = "claude-settings";
11095
+ /**
11096
+ * Embed the token as a JS string literal. JSON.stringify escapes quotes and
11097
+ * backslashes; we additionally neutralize `<` so the value can never break out
11098
+ * of the surrounding <script> element regardless of contents.
11099
+ *
11100
+ * When `seedToolSettings` is set, also seed CloudCLI's `claude-settings`
11101
+ * localStorage with `{allowedTools:[], disallowedTools:[], skipPermissions:true}`
11102
+ * so the SDK-spawned Claude gets the FULL built-in toolset with no permission
11103
+ * prompts. This is the server-side, zero-user-action fix for two CloudCLI
11104
+ * behaviors: (1) a non-empty `allowedTools` is an availability allow-list in the
11105
+ * Agent SDK (so any tool not in it is "not enabled"), and (2) `allowedTools`
11106
+ * accumulates client-side on every "allow & remember" click — so prior approvals
11107
+ * silently clamp the toolset. Forcing empty `allowedTools` + `skipPermissions`
11108
+ * gives `github-router claude`-parity (`--dangerously-skip-permissions`).
11109
+ */
11110
+ function buildInjection(token, seedToolSettings = false, seedAllowedTools = []) {
11111
+ let body = `localStorage.setItem('auth-token',${JSON.stringify(token).replace(/</g, "\\u003c")});`;
11112
+ if (seedToolSettings) {
11113
+ const settings = JSON.stringify({
11114
+ allowedTools: seedAllowedTools,
11115
+ disallowedTools: [],
11116
+ skipPermissions: true
11117
+ });
11118
+ const safeSettings = JSON.stringify(settings).replace(/</g, "\\u003c");
11119
+ body += `localStorage.setItem('${CLAUDE_SETTINGS_KEY}',${safeSettings});`;
11120
+ }
11121
+ return `<script>try{${body}}catch(e){}<\/script>`;
11122
+ }
11123
+ function headerLines(headers) {
11124
+ return Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join("\r\n");
11125
+ }
11126
+ async function startReverseProxy(opts) {
11127
+ const { targetHost, targetPort, bindHost, bindPort, authToken } = opts;
11128
+ const ownOrigin = `http://${bindHost}:${bindPort}`;
11129
+ const injection = buildInjection(authToken, opts.seedToolSettings === true, opts.seedAllowedTools ?? []);
11130
+ const allowedHosts = new Set([
11131
+ `127.0.0.1:${bindPort}`,
11132
+ `localhost:${bindPort}`,
11133
+ `[::1]:${bindPort}`,
11134
+ ...(opts.extraAllowedHosts ?? []).map((h) => h.toLowerCase())
11135
+ ]);
11136
+ const loopbackHosts = new Set([
11137
+ `127.0.0.1:${bindPort}`,
11138
+ `localhost:${bindPort}`,
11139
+ `[::1]:${bindPort}`
11140
+ ]);
11141
+ const allowedOrigins = new Set([
11142
+ `http://127.0.0.1:${bindPort}`,
11143
+ `http://localhost:${bindPort}`,
11144
+ `http://[::1]:${bindPort}`,
11145
+ ...(opts.extraAllowedOrigins ?? []).map((o) => o.toLowerCase())
11146
+ ]);
11147
+ const DEVTUNNEL_RE = /(^|\.)devtunnels\.ms$/i;
11148
+ const isDevtunnelHost = (host) => !!opts.allowDevtunnelHosts && DEVTUNNEL_RE.test(host.replace(/:\d+$/, ""));
11149
+ const isDevtunnelOrigin = (origin) => {
11150
+ if (!opts.allowDevtunnelHosts) return false;
11151
+ try {
11152
+ const u = new URL(origin);
11153
+ return u.protocol === "https:" && DEVTUNNEL_RE.test(u.hostname);
11154
+ } catch {
11155
+ return false;
11156
+ }
11157
+ };
11158
+ const hostAllowed = (host) => !!host && (allowedHosts.has(host.toLowerCase()) || isDevtunnelHost(host.toLowerCase()));
11159
+ const originAllowed = (origin) => !origin || allowedOrigins.has(origin.toLowerCase()) || isDevtunnelOrigin(origin);
11160
+ const liveSockets = /* @__PURE__ */ new Set();
11161
+ const server$1 = http.createServer((clientReq, clientRes) => {
11162
+ clientReq.on("error", () => clientRes.destroy());
11163
+ clientRes.on("error", () => clientReq.destroy());
11164
+ if (!hostAllowed(clientReq.headers.host) || !originAllowed(clientReq.headers.origin)) {
11165
+ clientRes.writeHead(403, { "content-type": "text/plain" });
11166
+ clientRes.end("Forbidden");
11167
+ return;
11168
+ }
11169
+ if ((clientReq.url ?? "").split("?")[0] === SERVE_IDENTITY_PATH) {
11170
+ if (!loopbackHosts.has(String(clientReq.headers.host ?? "").toLowerCase())) {
11171
+ clientRes.writeHead(404, { "content-type": "text/plain" });
11172
+ clientRes.end("Not found");
11173
+ return;
11174
+ }
11175
+ clientRes.writeHead(200, { "content-type": "application/json" });
11176
+ clientRes.end(JSON.stringify({
11177
+ service: SERVE_IDENTITY_SERVICE,
11178
+ version: getPackageVersion()
11179
+ }));
11180
+ return;
11181
+ }
11182
+ const requestUrl = new URL(clientReq.url ?? "/", ownOrigin);
11183
+ const blocked = opts.providerFacade?.blockedRequest?.(clientReq.method ?? "GET", requestUrl.pathname);
11184
+ if (blocked) {
11185
+ clientReq.resume();
11186
+ clientRes.writeHead(blocked.status, { "content-type": "application/json" });
11187
+ clientRes.end(JSON.stringify(blocked.json));
11188
+ return;
11189
+ }
11190
+ const facadeKind = opts.providerFacade?.kindFor(clientReq.method ?? "GET", requestUrl.pathname) ?? null;
11191
+ const headers = { ...clientReq.headers };
11192
+ delete headers["accept-encoding"];
11193
+ const proxyReq = http.request({
11194
+ host: targetHost,
11195
+ port: targetPort,
11196
+ path: clientReq.url,
11197
+ method: clientReq.method,
11198
+ headers
11199
+ }, (proxyRes) => {
11200
+ proxyRes.on("error", () => clientRes.destroy());
11201
+ const contentType = String(proxyRes.headers["content-type"] ?? "");
11202
+ if (facadeKind) {
11203
+ const chunks = [];
11204
+ proxyRes.on("data", (d) => chunks.push(Buffer.from(d)));
11205
+ proxyRes.on("end", () => {
11206
+ const original = Buffer.concat(chunks);
11207
+ const sendOriginal = () => {
11208
+ const outHeaders = { ...proxyRes.headers };
11209
+ delete outHeaders["content-length"];
11210
+ delete outHeaders["content-encoding"];
11211
+ delete outHeaders["transfer-encoding"];
11212
+ clientRes.writeHead(proxyRes.statusCode ?? 502, outHeaders);
11213
+ clientRes.end(original);
11214
+ };
11215
+ if (proxyRes.statusCode !== 200) {
11216
+ sendOriginal();
11217
+ return;
11218
+ }
11219
+ (async () => {
11220
+ try {
11221
+ const parsed = JSON.parse(original.toString("utf8"));
11222
+ const rewritten = await opts.providerFacade?.rewrite(facadeKind, parsed, requestUrl.searchParams);
11223
+ if (rewritten == null) {
11224
+ sendOriginal();
11225
+ return;
11226
+ }
11227
+ const outHeaders = { ...proxyRes.headers };
11228
+ delete outHeaders["content-length"];
11229
+ delete outHeaders["content-encoding"];
11230
+ delete outHeaders["transfer-encoding"];
11231
+ outHeaders["content-type"] = "application/json";
11232
+ clientRes.writeHead(200, outHeaders);
11233
+ clientRes.end(JSON.stringify(rewritten));
11234
+ } catch {
11235
+ sendOriginal();
11236
+ }
11237
+ })();
11238
+ });
11239
+ } else if (contentType.includes("text/html")) {
11240
+ const chunks = [];
11241
+ proxyRes.on("data", (d) => chunks.push(Buffer.from(d)));
11242
+ proxyRes.on("end", () => {
11243
+ let html = Buffer.concat(chunks).toString("utf8");
11244
+ html = html.includes(HEAD_CLOSE) ? html.replace(HEAD_CLOSE, injection + HEAD_CLOSE) : injection + html;
11245
+ const outHeaders = { ...proxyRes.headers };
11246
+ delete outHeaders["content-length"];
11247
+ delete outHeaders["content-encoding"];
11248
+ delete outHeaders["transfer-encoding"];
11249
+ clientRes.writeHead(proxyRes.statusCode ?? 502, outHeaders);
11250
+ clientRes.end(html);
11251
+ });
11252
+ } else {
11253
+ clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
11254
+ proxyRes.pipe(clientRes);
11255
+ }
11256
+ });
11257
+ proxyReq.on("error", (err) => {
11258
+ if (!clientRes.headersSent) clientRes.writeHead(502, { "content-type": "text/plain" });
11259
+ clientRes.end(`Bad gateway: ${err.message}`);
11260
+ });
11261
+ clientReq.pipe(proxyReq);
11262
+ });
11263
+ server$1.on("upgrade", (clientReq, clientSock, clientHead) => {
11264
+ clientSock.on("error", () => clientSock.destroy());
11265
+ liveSockets.add(clientSock);
11266
+ clientSock.once("close", () => liveSockets.delete(clientSock));
11267
+ if (!hostAllowed(clientReq.headers.host) || !originAllowed(clientReq.headers.origin)) {
11268
+ clientSock.destroy();
11269
+ return;
11270
+ }
11271
+ const proxyReq = http.request({
11272
+ host: targetHost,
11273
+ port: targetPort,
11274
+ path: clientReq.url,
11275
+ method: clientReq.method,
11276
+ headers: clientReq.headers
11277
+ });
11278
+ proxyReq.on("upgrade", (proxyRes, proxySock, proxyHead) => {
11279
+ clientSock.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${headerLines(proxyRes.headers)}\r\n\r\n`);
11280
+ if (clientHead && clientHead.length) proxySock.write(clientHead);
11281
+ if (proxyHead && proxyHead.length) proxySock.unshift(proxyHead);
11282
+ proxySock.pipe(clientSock);
11283
+ clientSock.pipe(proxySock);
11284
+ const cleanup = () => {
11285
+ proxySock.destroy();
11286
+ clientSock.destroy();
11287
+ };
11288
+ clientSock.on("error", cleanup);
11289
+ proxySock.on("error", cleanup);
11290
+ clientSock.on("close", cleanup);
11291
+ proxySock.on("close", cleanup);
11292
+ });
11293
+ proxyReq.on("response", (proxyRes) => {
11294
+ try {
11295
+ clientSock.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${headerLines(proxyRes.headers)}\r\n\r\n`);
11296
+ } catch {}
11297
+ proxyRes.on("data", (d) => {
11298
+ clientSock.write(d);
11299
+ });
11300
+ proxyRes.on("end", () => clientSock.end());
11301
+ proxyRes.on("error", () => clientSock.destroy());
11302
+ });
11303
+ proxyReq.on("error", () => clientSock.destroy());
11304
+ proxyReq.end();
11305
+ });
11306
+ await new Promise((resolve, reject) => {
11307
+ const onError = (err) => reject(err);
11308
+ server$1.once("error", onError);
11309
+ server$1.listen(bindPort, bindHost, () => {
11310
+ server$1.removeListener("error", onError);
11311
+ resolve();
11312
+ });
11313
+ });
11314
+ const addr = server$1.address();
11315
+ if (addr && addr.address !== "127.0.0.1" && addr.address !== "::1") {
11316
+ await new Promise((r) => server$1.close(() => r()));
11317
+ throw new Error(`reverse proxy refused non-loopback bind (got ${addr.address})`);
11318
+ }
11319
+ return {
11320
+ url: ownOrigin,
11321
+ port: bindPort,
11322
+ close: () => new Promise((resolve) => {
11323
+ for (const s of liveSockets) s.destroy();
11324
+ liveSockets.clear();
11325
+ const t = setTimeout(resolve, 1500);
11326
+ server$1.close(() => {
11327
+ clearTimeout(t);
11328
+ resolve();
11329
+ });
11330
+ })
11331
+ };
11332
+ }
11333
+
11334
+ //#endregion
11335
+ //#region src/serve.ts
11336
+ async function getFreePort() {
11337
+ return new Promise((resolve, reject) => {
11338
+ const s = http.createServer();
11339
+ s.once("error", reject);
11340
+ s.listen(0, "127.0.0.1", () => {
11341
+ const port = s.address().port;
11342
+ s.close(() => resolve(port));
11343
+ });
11344
+ });
11345
+ }
11346
+ /** Default user-facing control-plane port when `--port` is not given. */
11347
+ const DEFAULT_SERVE_PORT = 5454;
11348
+ function isPortFree(port) {
11349
+ return new Promise((resolve) => {
11350
+ const s = http.createServer();
11351
+ s.once("error", () => resolve(false));
11352
+ s.listen(port, "127.0.0.1", () => s.close(() => resolve(true)));
11353
+ });
11354
+ }
11355
+ /**
11356
+ * Probe a loopback port for an already-running `github-router serve` (its
11357
+ * identity endpoint). Returns true when the port hosts OUR serve, else false
11358
+ * (free, or a foreign process). `serve` is designed to run ONCE per machine
11359
+ * (a single machine-wide control plane for all repos), so a second launch
11360
+ * attaches to the running one instead of spawning a duplicate CloudCLI +
11361
+ * contending on the shared auth DB. We intentionally do NOT trust any URL the
11362
+ * probed service reports — a local squatter could forge the identity marker, so
11363
+ * the caller only ever opens a locally-constructed loopback origin.
11364
+ */
11365
+ function probeExistingServe(port) {
11366
+ return new Promise((resolve) => {
11367
+ const req$1 = http.get({
11368
+ host: "127.0.0.1",
11369
+ port,
11370
+ path: SERVE_IDENTITY_PATH,
11371
+ headers: { host: `127.0.0.1:${port}` },
11372
+ timeout: 1500
11373
+ }, (res) => {
11374
+ if (res.statusCode !== 200) {
11375
+ res.resume();
11376
+ resolve(false);
11377
+ return;
11378
+ }
11379
+ const chunks = [];
11380
+ res.on("data", (d) => chunks.push(Buffer.from(d)));
11381
+ res.on("end", () => {
11382
+ try {
11383
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")).service === SERVE_IDENTITY_SERVICE);
11384
+ } catch {
11385
+ resolve(false);
11386
+ }
11387
+ });
11388
+ res.on("error", () => resolve(false));
11389
+ });
11390
+ req$1.on("timeout", () => req$1.destroy());
11391
+ req$1.on("error", () => resolve(false));
11392
+ });
11393
+ }
11394
+ /**
11395
+ * Resolve the user-facing port: honor an explicit `--port`; otherwise use 5454
11396
+ * when free, else fall back to a random free port. (Single-instance detection —
11397
+ * an already-running github-router serve on the intended port — is handled by
11398
+ * the earlier `probeExistingServe` attach/refuse in `run`.)
11399
+ */
11400
+ async function resolveServePort(requested) {
11401
+ if (requested != null) return requested;
11402
+ if (await isPortFree(DEFAULT_SERVE_PORT)) return DEFAULT_SERVE_PORT;
11403
+ const fallback = await getFreePort();
11404
+ consola.info(`Port ${DEFAULT_SERVE_PORT} is in use; using ${fallback} instead.`);
11405
+ return fallback;
11406
+ }
11407
+ function openBrowser(url) {
11408
+ try {
11409
+ if (process.platform === "win32") spawn("cmd", [
11410
+ "/c",
11411
+ "start",
11412
+ "",
11413
+ url
11414
+ ], {
11415
+ detached: true,
11416
+ stdio: "ignore",
11417
+ windowsHide: true
11418
+ }).unref();
11419
+ else if (process.platform === "darwin") spawn("open", [url], {
11420
+ detached: true,
11421
+ stdio: "ignore"
11422
+ }).unref();
11423
+ else spawn("xdg-open", [url], {
11424
+ detached: true,
11425
+ stdio: "ignore"
11426
+ }).unref();
11427
+ } catch {}
11428
+ }
11429
+ const serve$1 = defineCommand({
11430
+ meta: {
11431
+ name: "serve",
11432
+ description: "Serve a browser control plane (CloudCLI) with Claude routed through the proxy"
11433
+ },
11434
+ args: {
11435
+ ...sharedServerArgs,
11436
+ model: {
11437
+ alias: "m",
11438
+ type: "string",
11439
+ description: "Override the default Claude model"
11440
+ },
11441
+ "cloudcli-path": {
11442
+ type: "string",
11443
+ description: "Use an existing CloudCLI install instead of the managed one"
11444
+ },
11445
+ "cloudcli-version": {
11446
+ type: "string",
11447
+ description: "Override the pinned CloudCLI version to install"
11448
+ },
11449
+ "codex-cli": {
11450
+ type: "boolean",
11451
+ default: false,
11452
+ description: "Route Codex personas through a local `codex mcp-server` (requires codex 0.129+; falls back to HTTP if missing)"
11453
+ },
11454
+ "browse-over-tunnel": {
11455
+ type: "boolean",
11456
+ default: false,
11457
+ description: "Expose the server-side browser MCP even when tunnel-exposed (session-hijack / SSRF risk — only with fully-trusted tunnel access)"
11458
+ },
11459
+ "agents-over-tunnel": {
11460
+ type: "boolean",
11461
+ default: false,
11462
+ description: "Expose first-mate (mints a repo+workflow GitHub write token) even when tunnel-exposed (only with fully-trusted tunnel access)"
11463
+ },
11464
+ "fleet-over-tunnel": {
11465
+ type: "boolean",
11466
+ default: false,
11467
+ description: "Expose the fleet MCP (drives remote coding sessions with your fleet credentials) even when tunnel-exposed (only with fully-trusted tunnel access)"
11468
+ },
11469
+ "no-install": {
11470
+ type: "boolean",
11471
+ default: false,
11472
+ description: "Do not auto-install CloudCLI; require an existing install"
11473
+ },
11474
+ "no-open": {
11475
+ type: "boolean",
11476
+ default: false,
11477
+ description: "Do not open the browser automatically"
11478
+ },
11479
+ "public-url": {
11480
+ type: "string",
11481
+ description: "Public URL(s) this is reachable at (e.g. a dev-tunnel https URL), comma-separated — allowlists their host+origin for remote access"
11482
+ },
11483
+ tunnel: {
11484
+ type: "boolean",
11485
+ default: false,
11486
+ description: "Auto-create + host an AUTHENTICATED (never anonymous) Microsoft dev tunnel for the serve port and print its public URL, so you can reach the control plane from anywhere. Requires the `devtunnel` CLI + `devtunnel user login`."
11487
+ }
11488
+ },
11489
+ async run({ args }) {
11490
+ state.serveMode = true;
11491
+ if (process.versions.bun) consola.warn("`serve` should run under Node.js. Bun's node:http upgrade sockets cannot relay the CloudCLI terminal/chat WebSockets; run the installed `github-router` binary (Node).");
11492
+ const parsed = parseSharedArgs(args);
11493
+ const intendedPort = parsed.port ?? DEFAULT_SERVE_PORT;
11494
+ if (await probeExistingServe(intendedPort)) {
11495
+ const existingUrl = `http://127.0.0.1:${intendedPort}`;
11496
+ consola.box(`github-router control plane already running\n\n${existingUrl}\n\nOpening it — stop that instance first if you want a fresh one.`);
11497
+ if (args["no-open"] !== true) openBrowser(existingUrl);
11498
+ return;
11499
+ }
11500
+ const { server: server$1, serverUrl } = await setupAndServe({
11501
+ ...parsed,
11502
+ port: void 0,
11503
+ silent: true
11504
+ });
11505
+ try {
11506
+ await ensureClaudeConfigMirror();
11507
+ } catch (err) {
11508
+ consola.error(`Failed to provision CLAUDE_CONFIG_DIR mirror: ${err instanceof Error ? err.message : String(err)}`);
11509
+ process.exit(1);
11510
+ }
11511
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
11512
+ provisionAndIndexColbert({ skipCwdIndex: true });
11513
+ provisionToolbelt().catch((err) => consola.debug("Toolbelt provisioning failed:", err));
11514
+ startKeepAwake();
11515
+ if (browserToolsEnabled()) provisionBrowserAssets().catch((err) => consola.debug("Browser extension provisioning failed:", err));
11516
+ const opusFamilyShorthand = args.model?.match(/^(\d+\.\d+)$/)?.[1];
11517
+ let chosenSlug = opusFamilyShorthand ? pickClaudeDefault(opusFamilyShorthand) : args.model ?? pickClaudeDefault();
11518
+ if (!args.model && state.models) {
11519
+ const inCache = (slug) => state.models?.data.some((m) => m.id === resolveModel(slug)) ?? false;
11520
+ if (!inCache(chosenSlug)) {
11521
+ for (const fb of DEFAULT_CLAUDE_MODEL_FALLBACKS) if (inCache(fb)) {
11522
+ consola.info(`Default model "${chosenSlug}" not in your Copilot model list; falling back to "${fb}".`);
11523
+ chosenSlug = fb;
11524
+ break;
11525
+ }
11526
+ }
11527
+ }
11528
+ const requestedCli = args["codex-cli"] === true;
11529
+ const backend = resolveCodexCliBackend({
11530
+ requested: requestedCli,
11531
+ codexInfo: requestedCli ? getCodexVersion() : null
11532
+ });
11533
+ const tunnelExposed = args.tunnel === true || String(args["public-url"] ?? "").split(",").map((s) => s.trim()).filter(Boolean).length > 0;
11534
+ if (tunnelExposed && browserToolsEnabled() && args["browse-over-tunnel"] !== true) consola.info("Browser MCP is disabled over the tunnel (session-hijack/SSRF risk). Pass --browse-over-tunnel to enable it for fully-trusted access.");
11535
+ if (tunnelExposed && state.agentsEnabled && args["agents-over-tunnel"] !== true) consola.info("first-mate is disabled over the tunnel (it mints a GitHub write token). Pass --agents-over-tunnel to enable it for fully-trusted access.");
11536
+ if (tunnelExposed && state.fleetEnabled && args["fleet-over-tunnel"] !== true) consola.info("Fleet MCP is disabled over the tunnel (it drives remote coding sessions with your fleet credentials). Pass --fleet-over-tunnel to enable it for fully-trusted access.");
11537
+ const enhancements = await provisionServeEnhancements(serverUrl, {
11538
+ codexCli: backend === "cli",
11539
+ tunnelExposed,
11540
+ browseOverTunnel: args["browse-over-tunnel"] === true,
11541
+ agentsOverTunnel: args["agents-over-tunnel"] === true,
11542
+ fleetOverTunnel: args["fleet-over-tunnel"] === true
11543
+ });
11544
+ const anthropicVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
11545
+ if (enhancements.nonce) {
11546
+ anthropicVars.GH_ROUTER_HOOK_MCP_URL = serverUrl;
11547
+ anthropicVars.GH_ROUTER_HOOK_NONCE = enhancements.nonce;
11548
+ }
11549
+ const childEnv = composeCloudCliChildEnv(anthropicVars);
11550
+ childEnv.ANTHROPIC_API_KEY = "sk-ant-github-router-proxy-placeholder";
11551
+ let cloudcli;
11552
+ try {
11553
+ cloudcli = await resolveCloudCli({
11554
+ cliPath: args["cloudcli-path"],
11555
+ noInstall: args["no-install"] === true,
11556
+ version: args["cloudcli-version"]
11557
+ });
11558
+ } catch (err) {
11559
+ consola.error(err instanceof Error ? err.message : String(err));
11560
+ process.exit(1);
11561
+ }
11562
+ const ccPort = await getFreePort();
11563
+ const cc = spawnCloudCli({
11564
+ serverEntry: cloudcli.serverEntry,
11565
+ port: ccPort,
11566
+ env: childEnv
11567
+ });
11568
+ let shuttingDown = false;
11569
+ let reverseClose = null;
11570
+ let tunnelStop = null;
11571
+ const shutdown = async () => {
11572
+ if (shuttingDown) return;
11573
+ shuttingDown = true;
11574
+ if (tunnelStop) try {
11575
+ tunnelStop();
11576
+ } catch {}
11577
+ try {
11578
+ killChildProcessTree(cc.child, { detachedGroup: process.platform !== "win32" });
11579
+ } catch {}
11580
+ if (reverseClose) await reverseClose().catch(() => {});
11581
+ try {
11582
+ server$1.close?.();
11583
+ } catch {}
11584
+ await stopKeepAwake().catch(() => {});
11585
+ await enhancements.cleanup().catch(() => {});
11586
+ await removeOwnClaudeConfigMirror().catch(() => {});
11587
+ };
11588
+ process.once("SIGINT", () => void shutdown().then(() => process.exit(0)));
11589
+ process.once("SIGTERM", () => void shutdown().then(() => process.exit(0)));
11590
+ cc.child.once("exit", (code) => {
11591
+ if (!shuttingDown) {
11592
+ consola.error(`CloudCLI exited unexpectedly (code ${code}).`);
11593
+ shutdown().then(() => process.exit(1));
11594
+ }
11595
+ });
11596
+ cc.child.once("error", (err) => {
11597
+ if (!shuttingDown) {
11598
+ consola.error(`CloudCLI failed to start: ${err.message}`);
11599
+ shutdown().then(() => process.exit(1));
11600
+ }
11601
+ });
11602
+ let ready;
11603
+ try {
11604
+ ready = await waitForCloudCliReady(ccPort);
11605
+ } catch (err) {
11606
+ consola.error(err instanceof Error ? err.message : String(err));
11607
+ await shutdown();
11608
+ process.exit(1);
11609
+ }
11610
+ let username = "github-router";
11611
+ try {
11612
+ username = (await getGitHubUser()).login;
11613
+ } catch {}
11614
+ let token;
11615
+ try {
11616
+ token = await mintJwt(ccPort, username, ready.needsSetup);
11617
+ } catch (err) {
11618
+ consola.error(err instanceof Error ? err.message : String(err));
11619
+ await shutdown();
11620
+ process.exit(1);
11621
+ }
11622
+ const publicUrls = String(args["public-url"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
11623
+ const extraAllowedHosts = [];
11624
+ const extraAllowedOrigins = [];
11625
+ for (const u of publicUrls) try {
11626
+ const url = new URL(u);
11627
+ extraAllowedHosts.push(url.host);
11628
+ extraAllowedOrigins.push(url.origin);
11629
+ } catch {
11630
+ consola.warn(`Ignoring invalid --public-url "${u}"`);
11631
+ }
11632
+ const tunnelMode = args.tunnel === true;
11633
+ if (extraAllowedHosts.length || tunnelMode) consola.warn("Remote access enabled: anyone who reaches this through the tunnel AND passes its authentication gets FULL control-plane access — including a shell on this machine. The tunnel is authenticated (never anonymous); only grant access to people you fully trust.");
11634
+ const servePort = await resolveServePort(parsed.port);
11635
+ const providerFacade = {
11636
+ kindFor: facadeInterceptKind,
11637
+ blockedRequest: facadeBlockedRequest,
11638
+ rewrite: (kind, json, query) => rewriteProviderResponse(kind, json, {
11639
+ getModels: () => state.models,
11640
+ defaultModel: resolveModel(chosenSlug),
11641
+ claudeConfigDir: PATHS.CLAUDE_CONFIG_DIR
11642
+ }, query)
11643
+ };
11644
+ let reverse;
11645
+ try {
11646
+ reverse = await startReverseProxy({
11647
+ targetHost: "127.0.0.1",
11648
+ targetPort: ccPort,
11649
+ bindHost: "127.0.0.1",
11650
+ bindPort: servePort,
11651
+ authToken: token,
11652
+ seedToolSettings: process.env.GH_ROUTER_SERVE_NO_AUTO_APPROVE !== "1",
11653
+ seedAllowedTools: enhancements.mcpToolNames ?? [],
11654
+ extraAllowedHosts,
11655
+ extraAllowedOrigins,
11656
+ allowDevtunnelHosts: tunnelMode,
11657
+ providerFacade
11658
+ });
11659
+ } catch (err) {
11660
+ consola.error(`Failed to bind the control-plane port ${servePort}: ${err instanceof Error ? err.message : String(err)}. Is it already in use? Pass --port <n>.`);
11661
+ await shutdown();
11662
+ process.exit(1);
11663
+ }
11664
+ reverseClose = reverse.close;
11665
+ let tunnelUrl = null;
11666
+ if (tunnelMode) {
11667
+ consola.info("Creating an authenticated dev tunnel…");
11668
+ try {
11669
+ const tunnel = await startDevtunnel(servePort);
11670
+ tunnelUrl = tunnel.url;
11671
+ tunnelStop = tunnel.stop;
11672
+ } catch (err) {
11673
+ const msg = err instanceof DevtunnelError ? err.message : String(err);
11674
+ consola.warn(`Could not auto-host a dev tunnel: ${msg}\nServing locally only. To host manually once fixed: devtunnel host -p ${servePort}`);
11675
+ }
11676
+ }
11677
+ const publicLines = [...publicUrls.map((u) => `remote: ${u}`), ...tunnelUrl ? [`remote (tunnel): ${tunnelUrl} (stable — bookmark it; same URL every launch)`] : []];
11678
+ const publicBlock = publicLines.length ? `\n${publicLines.join("\n")}` : "";
11679
+ consola.box(`🖥️ github-router control plane\n\nlocal: ${reverse.url}${publicBlock}\n\nsigned in as ${username} · model ${chosenSlug}\nCtrl+C to stop`);
11680
+ if (args["no-open"] !== true) openBrowser(reverse.url);
11681
+ }
11682
+ });
11683
+
9954
11684
  //#endregion
9955
11685
  //#region src/lib/shell.ts
9956
11686
  function getShell() {
@@ -10084,7 +11814,7 @@ process.on("uncaughtException", (error) => {
10084
11814
  const version = getPackageVersion();
10085
11815
  const argv = process.argv.slice(2);
10086
11816
  const isVersionFlag = argv.includes("--version");
10087
- const isInternalHook = argv[0] === "internal-stop-hook" || argv[0] === "internal-prompt-submit" || argv[0] === "internal-stop-review" || argv[0] === "internal-plan-review" || argv[0] === "internal-session-bind" || argv[0] === "internal-artifact-open" || argv[0] === "internal-first-mate-guard" || argv[0] === "internal-worker-guard";
11817
+ const isInternalHook = argv[0] === "internal-stop-hook" || argv[0] === "internal-prompt-submit" || argv[0] === "internal-stop-review" || argv[0] === "internal-plan-review" || argv[0] === "internal-session-bind" || argv[0] === "internal-workspace-header" || argv[0] === "internal-artifact-open" || argv[0] === "internal-first-mate-guard" || argv[0] === "internal-worker-guard";
10088
11818
  if (!isVersionFlag && !isInternalHook) consola.info(`github-router v${version}`);
10089
11819
  await runMain(defineCommand({
10090
11820
  meta: {
@@ -10097,6 +11827,7 @@ await runMain(defineCommand({
10097
11827
  start,
10098
11828
  claude,
10099
11829
  codex,
11830
+ serve: serve$1,
10100
11831
  models,
10101
11832
  "check-usage": checkUsage,
10102
11833
  debug,
@@ -10105,6 +11836,7 @@ await runMain(defineCommand({
10105
11836
  "internal-stop-review": internalStopReview,
10106
11837
  "internal-plan-review": internalPlanReview,
10107
11838
  "internal-session-bind": internalSessionBind,
11839
+ "internal-workspace-header": internalWorkspaceHeader,
10108
11840
  "internal-artifact-open": internalArtifactOpen,
10109
11841
  "internal-first-mate-guard": internalFirstMateGuard,
10110
11842
  "internal-worker-guard": internalWorkerGuard