github-router 0.3.249 → 0.3.255

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.
Files changed (95) hide show
  1. package/dist/attribution-settings-CegtE0TY.js +2347 -0
  2. package/dist/attribution-settings-CegtE0TY.js.map +1 -0
  3. package/dist/auth-CP4q2CjO.js +44 -0
  4. package/dist/auth-CP4q2CjO.js.map +1 -0
  5. package/dist/browser-ext/background.js +8 -1
  6. package/dist/browser-ext/manifest.json +1 -1
  7. package/dist/check-usage-6PB5IUFb.js +43 -0
  8. package/dist/check-usage-6PB5IUFb.js.map +1 -0
  9. package/dist/claude-BiZxoNWq.js +593 -0
  10. package/dist/claude-BiZxoNWq.js.map +1 -0
  11. package/dist/client-CQMfroGV.js +413 -0
  12. package/dist/client-CQMfroGV.js.map +1 -0
  13. package/dist/codex-ST21X3WF.js +85 -0
  14. package/dist/codex-ST21X3WF.js.map +1 -0
  15. package/dist/debug-CjmE9sgG.js +81 -0
  16. package/dist/debug-CjmE9sgG.js.map +1 -0
  17. package/dist/engine-DNCX0Hfd.js +2 -0
  18. package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
  19. package/dist/exec-DTO2oin0.js.map +1 -0
  20. package/dist/file-log-reporter-IFQcL5IK.js +158 -0
  21. package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
  22. package/dist/gate-discovery-CepmbZnc.js +635 -0
  23. package/dist/gate-discovery-CepmbZnc.js.map +1 -0
  24. package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
  25. package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
  26. package/dist/hook-mcp-client-DBvm9608.js +136 -0
  27. package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
  28. package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
  29. package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
  30. package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
  31. package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
  32. package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
  33. package/dist/internal-plan-review-ipnT3bhj.js +114 -0
  34. package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
  35. package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
  36. package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
  37. package/dist/internal-session-bind-DwK2iGW0.js +133 -0
  38. package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
  39. package/dist/internal-stop-hook-CNPGJm63.js +212 -0
  40. package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
  41. package/dist/internal-stop-review-AUcFssrW.js +122 -0
  42. package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
  43. package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
  44. package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
  45. package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
  46. package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
  47. package/dist/lifecycle-Byw5s1G8.js +134 -0
  48. package/dist/lifecycle-Byw5s1G8.js.map +1 -0
  49. package/dist/lifecycle-D80CaWie.js +2 -0
  50. package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
  51. package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
  52. package/dist/lifecycle-DzqCqDlM.js +2 -0
  53. package/dist/main.js +43 -12884
  54. package/dist/main.js.map +1 -1
  55. package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
  56. package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
  57. package/dist/models-B6UnGyRo.js +158 -0
  58. package/dist/models-B6UnGyRo.js.map +1 -0
  59. package/dist/orchestration-hRU4TdWr.js +39 -0
  60. package/dist/orchestration-hRU4TdWr.js.map +1 -0
  61. package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
  62. package/dist/paths-C1cLZ7Ga.js.map +1 -0
  63. package/dist/paths-CwKwc2pi.js +2 -0
  64. package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
  65. package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
  66. package/dist/plan-review-hook-C8x0uKPf.js +159 -0
  67. package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
  68. package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
  69. package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
  70. package/dist/provision-DSkPa5hY.js +656 -0
  71. package/dist/provision-DSkPa5hY.js.map +1 -0
  72. package/dist/serve-sa_7c-yD.js +1511 -0
  73. package/dist/serve-sa_7c-yD.js.map +1 -0
  74. package/dist/server-setup-DcO9qvN3.js +4776 -0
  75. package/dist/server-setup-DcO9qvN3.js.map +1 -0
  76. package/dist/start-siGxuZhO.js +129 -0
  77. package/dist/start-siGxuZhO.js.map +1 -0
  78. package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
  79. package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
  80. package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
  81. package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
  82. package/dist/token-R2ocFC3M.js +1054 -0
  83. package/dist/token-R2ocFC3M.js.map +1 -0
  84. package/dist/version-_Q1WpsQp.js +32 -0
  85. package/dist/version-_Q1WpsQp.js.map +1 -0
  86. package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
  87. package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
  88. package/package.json +1 -1
  89. package/dist/engine-BuuHUb4t.js +0 -2
  90. package/dist/lifecycle-0rXrwjWk.js +0 -2
  91. package/dist/lifecycle-DHZsVN8j.js.map +0 -1
  92. package/dist/lifecycle-hzPwXc3I.js +0 -2
  93. package/dist/paths-BjTMI_xK.js.map +0 -1
  94. package/dist/paths-D3XgmTcQ.js +0 -2
  95. package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
@@ -0,0 +1,1511 @@
1
+ import { $ as scoutModel, C as buildToolbeltAwareness, Et as DEFAULT_CLAUDE_MODEL_FALLBACKS, G as brainstormModel, J as browserToolsEnabled, K as browseAgentEnabled, Nt as pickClaudeDefault, Q as reviewerModel, S as availableToolCommands, U as agentToolsEnabled, X as geminiAvailable, Y as fleetToolsEnabled, Z as nativeSubagentModel, _t as provisionAndIndexColbert, a as buildPeerAwarenessSnippet, et as scribeModel, ht as provisionBrowserAssets, nt as workerToolsEnabled, q as browserCompoundToolsEnabled, s as enumerateInjectedMcpToolNames, tt as standInToolEnabled, w as toolbeltEnabled, x as buildEnv } from "./peer-mcp-personas-BDet588n.js";
2
+ import { t as getPackageVersion } from "./version-_Q1WpsQp.js";
3
+ import { o as removeOwnClaudeConfigMirror, r as ensureClaudeConfigMirror, t as PATHS } from "./paths-C1cLZ7Ga.js";
4
+ import { O as state, d as resolveModel, m as getGitHubUser } from "./token-R2ocFC3M.js";
5
+ import { a as resolveExecutable, o as runCommandCapture, t as killChildProcessTree } from "./exec-DTO2oin0.js";
6
+ import { a as sharedServerArgs, i as setupAndServe, l as runSelfUpdate, o as startKeepAwake, r as parseSharedArgs, s as stopKeepAwake, t as getClaudeCodeEnvVars } from "./server-setup-DcO9qvN3.js";
7
+ import { r as getCodexVersion, t as provisionToolbelt } from "./provision-DSkPa5hY.js";
8
+ import { s as injectStopHookIntoSettingsFile } from "./stop-gate-hook-DQc2KV7B.js";
9
+ import { l as guardToolMatcher, n as activeDispatchModes, r as buildWorkerGuardHookCommand } from "./worker-dispatch-Bj1uYyG9.js";
10
+ import { _ as injectPeerMcpIntoMirror, a as OPERATING_DEFAULTS_DIRECTIVE, b as workersKeyOf, d as SEAMLESS_BUILTIN_TOOLS, f as configureServeDefaultPermissionMode, g as BUILTIN_SUBAGENT_DEFINITIONS, h as sanitizeServeSettingsEnv, l as prependOperatingDefaultsToMirroredClaudeMd, m as planModeAllowRules, n as INJECTED_SKILLS, o as appendPeerAwarenessToMirroredClaudeMd, p as injectAllowRules, r as writeInjectedSkill, s as appendToolbeltAwarenessToMirroredClaudeMd, t as injectAttributionSuppressionIntoSettingsFile, u as prependStyleDirectiveToMirroredClaudeMd, v as resolveCodexCliBackend, x as writePeerMcpRuntimeFiles, y as resolveGroupKeysFromMirror } from "./attribution-settings-CegtE0TY.js";
11
+ import { i as planReviewEnabled, t as buildPlanReviewHookCommand } from "./plan-review-hook-C8x0uKPf.js";
12
+ import { t as buildPromptSubmitHookCommand } from "./prompt-submit-hook-BrkkLAAB.js";
13
+ import { defineCommand } from "citty";
14
+ import consola from "consola";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { createHash, randomBytes } from "node:crypto";
18
+ import fs$1 from "node:fs/promises";
19
+ import os from "node:os";
20
+ import { spawn } from "node:child_process";
21
+ import http from "node:http";
22
+ //#region src/lib/serve/cloudcli.ts
23
+ const CLOUDCLI_PACKAGE = "@cloudcli-ai/cloudcli";
24
+ /**
25
+ * Build the env handed to the CloudCLI child. Starts from `buildEnv()` — the
26
+ * vetted secret-stripping ALLOWLIST used for worker bash (drops GITHUB_TOKEN,
27
+ * GH_ROUTER_*, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, COPILOT_TOKEN by omission)
28
+ * — then layers the non-secret Anthropic/Claude vars. The spawned claude still
29
+ * authenticates because that rides the synthetic `.credentials.json` FILE in
30
+ * CLAUDE_CONFIG_DIR, not an env token. Load-bearing: CloudCLI exposes a browser
31
+ * terminal that can read its own env, so no secret may live there.
32
+ */
33
+ function composeCloudCliChildEnv(anthropicVars) {
34
+ return {
35
+ ...buildEnv(),
36
+ ...anthropicVars
37
+ };
38
+ }
39
+ const SERVER_ENTRY_REL = path.join("dist-server", "server", "index.js");
40
+ /** Where the package's server entry lives under a given install root. */
41
+ function serverEntryUnderRoot(root) {
42
+ return path.join(root, "node_modules", CLOUDCLI_PACKAGE, SERVER_ENTRY_REL);
43
+ }
44
+ async function exists(p) {
45
+ try {
46
+ await fs$1.access(p);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+ async function readVersion(pkgRoot) {
53
+ try {
54
+ const raw = await fs$1.readFile(path.join(pkgRoot, "node_modules", CLOUDCLI_PACKAGE, "package.json"), "utf8");
55
+ return JSON.parse(raw).version ?? null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Resolve the CloudCLI server entry, in order:
62
+ * 1. an explicit `--cloudcli-path` (a package dir OR an install root),
63
+ * 2. an existing pinned install in the router-owned dir,
64
+ * 3. (unless `noInstall`) install the pinned version into the router-owned dir.
65
+ * Never installs globally, never `@latest`.
66
+ */
67
+ async function resolveCloudCli(opts) {
68
+ const version = opts.version ?? "1.36.1";
69
+ if (opts.cliPath) {
70
+ const direct = path.join(opts.cliPath, SERVER_ENTRY_REL);
71
+ const underRoot = serverEntryUnderRoot(opts.cliPath);
72
+ if (await exists(direct)) return {
73
+ serverEntry: direct,
74
+ version: null
75
+ };
76
+ if (await exists(underRoot)) return {
77
+ serverEntry: underRoot,
78
+ version: await readVersion(opts.cliPath)
79
+ };
80
+ throw new Error(`--cloudcli-path "${opts.cliPath}" does not contain a CloudCLI install (looked for ${SERVER_ENTRY_REL}).`);
81
+ }
82
+ const root = PATHS.CLOUDCLI_HOME;
83
+ const entry = serverEntryUnderRoot(root);
84
+ if (await exists(entry)) return {
85
+ serverEntry: entry,
86
+ version: await readVersion(root)
87
+ };
88
+ 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}`);
89
+ await installCloudCli(root, version);
90
+ if (!await exists(entry)) throw new Error(`CloudCLI install completed but the server entry is missing (${entry}).`);
91
+ return {
92
+ serverEntry: entry,
93
+ version: await readVersion(root)
94
+ };
95
+ }
96
+ async function installCloudCli(root, version) {
97
+ const npm = resolveExecutable("npm");
98
+ 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.");
99
+ await fs$1.mkdir(root, {
100
+ recursive: true,
101
+ mode: 448
102
+ });
103
+ const pkgJson = path.join(root, "package.json");
104
+ if (!await exists(pkgJson)) await fs$1.writeFile(pkgJson, JSON.stringify({
105
+ name: "gh-router-cloudcli-host",
106
+ private: true
107
+ }, null, 2));
108
+ consola.info(`Installing ${CLOUDCLI_PACKAGE}@${version} (one-time, ~a minute; builds native deps)…`);
109
+ const { code, stderr } = await runCommandCapture([
110
+ npm,
111
+ "install",
112
+ `${CLOUDCLI_PACKAGE}@${version}`,
113
+ "--prefix",
114
+ root,
115
+ "--no-audit",
116
+ "--no-fund",
117
+ "--loglevel=error"
118
+ ], { timeoutMs: 6e5 });
119
+ if (code !== 0) throw new Error(`CloudCLI install failed (npm exit ${code}). This often means no network, a corporate proxy, or missing Windows build tools for native deps.\nInstall manually then re-run with --cloudcli-path:\n npm i --prefix "${root}" ${CLOUDCLI_PACKAGE}@${version}\n${stderr.slice(0, 400)}`);
120
+ }
121
+ /** Spawn CloudCLI on a loopback port with the given (already-filtered) env. */
122
+ function spawnCloudCli(opts) {
123
+ const databasePath = path.join(PATHS.CLOUDCLI_HOME, "auth.db");
124
+ const node = resolveExecutable("node") ?? process.execPath;
125
+ const logPath = process.env.GH_ROUTER_CLOUDCLI_LOG;
126
+ let stdio = "ignore";
127
+ if (logPath) try {
128
+ const fd = fs.openSync(logPath, "a");
129
+ stdio = [
130
+ "ignore",
131
+ fd,
132
+ fd
133
+ ];
134
+ } catch {}
135
+ return {
136
+ child: spawn(node, [opts.serverEntry], {
137
+ env: {
138
+ ...opts.env,
139
+ SERVER_PORT: String(opts.port),
140
+ HOST: "127.0.0.1",
141
+ DATABASE_PATH: databasePath,
142
+ VITE_IS_PLATFORM: ""
143
+ },
144
+ stdio,
145
+ windowsHide: true
146
+ }),
147
+ port: opts.port,
148
+ databasePath
149
+ };
150
+ }
151
+ function req(port, method, urlPath, body) {
152
+ return new Promise((resolve, reject) => {
153
+ const data = body === void 0 ? void 0 : JSON.stringify(body);
154
+ const r = http.request({
155
+ host: "127.0.0.1",
156
+ port,
157
+ path: urlPath,
158
+ method,
159
+ headers: data ? {
160
+ "content-type": "application/json",
161
+ "content-length": Buffer.byteLength(data)
162
+ } : {}
163
+ }, (res) => {
164
+ let b = "";
165
+ res.on("data", (d) => b += d);
166
+ res.on("end", () => resolve({
167
+ status: res.statusCode ?? 0,
168
+ body: b
169
+ }));
170
+ });
171
+ r.on("error", reject);
172
+ if (data) r.write(data);
173
+ r.end();
174
+ });
175
+ }
176
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
177
+ /** Poll /api/auth/status until the server answers. Returns `needsSetup`. */
178
+ async function waitForCloudCliReady(port, timeoutMs = 6e4) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (Date.now() < deadline) {
181
+ try {
182
+ const r = await req(port, "GET", "/api/auth/status");
183
+ if (r.status === 200) return { needsSetup: JSON.parse(r.body).needsSetup === true };
184
+ } catch {}
185
+ await sleep(1e3);
186
+ }
187
+ throw new Error(`CloudCLI did not become ready within ${timeoutMs}ms`);
188
+ }
189
+ const secretFile = () => path.join(PATHS.CLOUDCLI_HOME, ".serve-secret.json");
190
+ async function loadPassword(username) {
191
+ try {
192
+ const raw = await fs$1.readFile(secretFile(), "utf8");
193
+ const parsed = JSON.parse(raw);
194
+ if (parsed.username === username && parsed.password) return parsed.password;
195
+ } catch {}
196
+ return null;
197
+ }
198
+ async function persistPassword(username, password) {
199
+ await fs$1.mkdir(PATHS.CLOUDCLI_HOME, {
200
+ recursive: true,
201
+ mode: 448
202
+ });
203
+ await fs$1.writeFile(secretFile(), JSON.stringify({
204
+ username,
205
+ password
206
+ }), { mode: 384 });
207
+ }
208
+ /**
209
+ * Ensure a CloudCLI user exists and return a valid JWT. On a fresh (isolated)
210
+ * DB we register (persisting the password only AFTER success); on an existing
211
+ * DB we log in with the persisted password.
212
+ */
213
+ async function mintJwt(port, username, needsSetup) {
214
+ const existing = await loadPassword(username);
215
+ if (needsSetup) {
216
+ const password = existing ?? randomBytes(24).toString("base64url");
217
+ const r = await req(port, "POST", "/api/auth/register", {
218
+ username,
219
+ password
220
+ });
221
+ if (r.status === 200) {
222
+ const token = JSON.parse(r.body).token;
223
+ if (token) {
224
+ await persistPassword(username, password);
225
+ return token;
226
+ }
227
+ }
228
+ }
229
+ const password = existing;
230
+ if (!password) throw new Error(`CloudCLI already has a user but no stored password for "${username}". Delete ${path.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
231
+ const r = await req(port, "POST", "/api/auth/login", {
232
+ username,
233
+ password
234
+ });
235
+ if (r.status === 200) {
236
+ const token = JSON.parse(r.body).token;
237
+ if (token) return token;
238
+ }
239
+ throw new Error(`Could not obtain a CloudCLI session token (status ${r.status}). If a stale isolated DB exists, delete ${path.join(PATHS.CLOUDCLI_HOME, "auth.db")} and retry.`);
240
+ }
241
+ //#endregion
242
+ //#region src/lib/serve/devtunnel.ts
243
+ const SERVE_TUNNEL_LABEL = "github-router-serve";
244
+ function serveTunnelMachineLabel(hostname = os.hostname()) {
245
+ return `ghr-machine-${createHash("sha256").update(hostname).digest("hex").slice(0, 12)}`;
246
+ }
247
+ /**
248
+ * Stable, deterministic tunnel ID for THIS machine, so `serve --tunnel` REUSES
249
+ * one tunnel across launches → a stable, bookmarkable public URL. Derived from a
250
+ * hash of the hostname (valid tunnel-id charset: lowercase alnum + hyphens,
251
+ * bounded length). NOTE: the hosting URL's subdomain is a devtunnels-assigned
252
+ * token, NOT this id — the id only anchors find/reuse; the URL is stable because
253
+ * the same tunnel is re-hosted, not because the id appears in it.
254
+ */
255
+ function serveTunnelId(hostname = os.hostname()) {
256
+ return `ghr-serve-${createHash("sha256").update(hostname).digest("hex").slice(0, 12)}`;
257
+ }
258
+ /**
259
+ * Reconcile a reused tunnel's ports to exactly `[desired]`: which existing ports
260
+ * to delete and whether the desired port must be created. Pure (unit-tested).
261
+ * This is why reuse works across `--port` changes — `devtunnel host <id>
262
+ * -p <newport>` fails ("Batch update of ports is not supported"), but per-port
263
+ * `port delete` + `port create` does not.
264
+ */
265
+ function portsToReconcile(currentPorts, desired) {
266
+ return {
267
+ toDelete: currentPorts.filter((p) => p !== desired),
268
+ toCreate: currentPorts.includes(desired) ? null : desired
269
+ };
270
+ }
271
+ /**
272
+ * The IDs of our own idle tunnels for this machine to sweep — EXCLUDING the
273
+ * stable reused tunnel (`exceptId`), which we keep and re-host. Pure
274
+ * (unit-tested). A tunnel with a live host connection (a concurrent serve
275
+ * instance) is never swept. Cleans up pre-migration random-named tunnels + any
276
+ * crash-orphaned duplicates while preserving the one stable tunnel.
277
+ */
278
+ function serveTunnelIdsToSweep(tunnels, machineLabel, exceptId) {
279
+ return tunnels.filter((t) => (t.labels ?? []).includes(machineLabel) && !t.hostConnections).map((t) => t.tunnelId).filter((id) => !exceptId || id.split(".")[0] !== exceptId);
280
+ }
281
+ /** List the tunnels serve owns (label-filtered). Best-effort: [] on any error. */
282
+ async function listServeTunnels(cli) {
283
+ try {
284
+ const { stdout, code } = await runCommandCapture([
285
+ cli,
286
+ "list",
287
+ "-l",
288
+ SERVE_TUNNEL_LABEL,
289
+ "-j"
290
+ ], { timeoutMs: 15e3 });
291
+ if (code !== 0) return [];
292
+ const parsed = JSON.parse(stdout);
293
+ return Array.isArray(parsed?.tunnels) ? parsed.tunnels : [];
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+ /** Best-effort delete of a single tunnel by full id (e.g. `fancy-fog-x.inc1`). */
299
+ async function deleteTunnel(cli, tunnelId) {
300
+ try {
301
+ await runCommandCapture([
302
+ cli,
303
+ "delete",
304
+ tunnelId,
305
+ "-f"
306
+ ], { timeoutMs: 15e3 });
307
+ } catch {}
308
+ }
309
+ /** Current port numbers configured on a tunnel. Best-effort: [] on any error. */
310
+ async function getTunnelPorts(cli, tunnelId) {
311
+ try {
312
+ const { stdout, code } = await runCommandCapture([
313
+ cli,
314
+ "port",
315
+ "list",
316
+ tunnelId,
317
+ "-j"
318
+ ], { timeoutMs: 15e3 });
319
+ if (code !== 0) return [];
320
+ return (JSON.parse(stdout)?.ports ?? []).map((p) => p.portNumber).filter((n) => typeof n === "number");
321
+ } catch {
322
+ return [];
323
+ }
324
+ }
325
+ /**
326
+ * Ensure the stable per-machine tunnel exists (create it labeled if absent).
327
+ * Returns true when the stable tunnel is present + owned by us (safe to reuse),
328
+ * false to fall back to the anonymous-labeled host path. `existing` is the
329
+ * pre-fetched label-scoped list (so we don't re-list).
330
+ */
331
+ async function ensureStableTunnel(cli, stableId, machineLabel, existing) {
332
+ if (existing.some((t) => t.tunnelId.split(".")[0] === stableId)) return true;
333
+ try {
334
+ const { code } = await runCommandCapture([
335
+ cli,
336
+ "create",
337
+ stableId,
338
+ "-l",
339
+ SERVE_TUNNEL_LABEL,
340
+ "-l",
341
+ machineLabel,
342
+ "-d",
343
+ "github-router-serve-control-plane"
344
+ ], { timeoutMs: 2e4 });
345
+ return code === 0;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+ /** Reconcile the tunnel's ports to exactly `[port]` (delete stale, create missing). */
351
+ async function reconcileTunnelPorts(cli, tunnelId, port) {
352
+ const { toDelete, toCreate } = portsToReconcile(await getTunnelPorts(cli, tunnelId), port);
353
+ for (const p of toDelete) await runCommandCapture([
354
+ cli,
355
+ "port",
356
+ "delete",
357
+ tunnelId,
358
+ "-p",
359
+ String(p)
360
+ ], { timeoutMs: 15e3 }).catch(() => {});
361
+ if (toCreate != null) await runCommandCapture([
362
+ cli,
363
+ "port",
364
+ "create",
365
+ tunnelId,
366
+ "-p",
367
+ String(toCreate)
368
+ ], { timeoutMs: 15e3 }).catch(() => {});
369
+ }
370
+ const DEVTUNNEL_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.devtunnels\.ms\/?/i;
371
+ var DevtunnelError = class extends Error {
372
+ kind;
373
+ constructor(kind, message) {
374
+ super(message);
375
+ this.name = "DevtunnelError";
376
+ this.kind = kind;
377
+ }
378
+ };
379
+ /** Absolute path to the `devtunnel` CLI, or null if not on PATH. */
380
+ function resolveDevtunnelCli() {
381
+ return resolveExecutable("devtunnel");
382
+ }
383
+ /** Best-effort login check via `devtunnel user show`. */
384
+ async function isDevtunnelLoggedIn(cli) {
385
+ try {
386
+ const { stdout, stderr, code } = await runCommandCapture([
387
+ cli,
388
+ "user",
389
+ "show"
390
+ ], { timeoutMs: 15e3 });
391
+ const out = `${stdout}\n${stderr}`;
392
+ if (/not logged in/i.test(out)) return false;
393
+ if (/logged in as/i.test(out)) return true;
394
+ return code === 0;
395
+ } catch {
396
+ return false;
397
+ }
398
+ }
399
+ /** Extract the dev-tunnel browser URL from CLI output. Exported for tests. */
400
+ function parseDevtunnelUrl(text) {
401
+ const m = text.match(DEVTUNNEL_URL_RE);
402
+ return m ? m[0].replace(/\/+$/, "") : null;
403
+ }
404
+ /**
405
+ * Spawn `devtunnel <args>` and resolve once it prints its public URL. Shared by
406
+ * the stable-reuse and anonymous-labeled host paths. Rejects (DevtunnelError) on
407
+ * timeout, early exit, an auth error in the output, or a spawn failure.
408
+ */
409
+ function hostTunnelProcess(cli, args, timeoutMs) {
410
+ const child = spawn(cli, args, {
411
+ stdio: [
412
+ "ignore",
413
+ "pipe",
414
+ "pipe"
415
+ ],
416
+ windowsHide: true
417
+ });
418
+ return new Promise((resolve, reject) => {
419
+ let settled = false;
420
+ const stop = () => {
421
+ try {
422
+ killChildProcessTree(child, { detachedGroup: process.platform !== "win32" });
423
+ } catch {
424
+ try {
425
+ child.kill();
426
+ } catch {}
427
+ }
428
+ };
429
+ const finish = (fn) => {
430
+ if (settled) return;
431
+ settled = true;
432
+ clearTimeout(timer);
433
+ fn();
434
+ };
435
+ const timer = setTimeout(() => finish(() => {
436
+ stop();
437
+ reject(new DevtunnelError("timeout", "dev tunnel did not report a URL in time"));
438
+ }), timeoutMs);
439
+ const onData = (buf) => {
440
+ const text = String(buf);
441
+ const url = parseDevtunnelUrl(text);
442
+ if (url) {
443
+ finish(() => resolve({
444
+ url,
445
+ stop
446
+ }));
447
+ return;
448
+ }
449
+ if (/not logged in|login (required|expired)|unauthorized/i.test(text)) finish(() => {
450
+ stop();
451
+ reject(new DevtunnelError("not-logged-in", "dev tunnel authentication failed; run `devtunnel user login`"));
452
+ });
453
+ };
454
+ child.stdout?.on("data", onData);
455
+ child.stderr?.on("data", onData);
456
+ child.once("error", (err) => finish(() => reject(new DevtunnelError("spawn", err.message))));
457
+ child.once("exit", (code) => finish(() => reject(new DevtunnelError("exited", `devtunnel exited (code ${code}) before printing a URL`))));
458
+ });
459
+ }
460
+ /**
461
+ * Host an AUTHENTICATED (never anonymous) dev tunnel forwarding `port`, resolving
462
+ * once its public URL is printed. NEVER `--allow-anonymous` (owner-only access).
463
+ *
464
+ * REUSES a stable per-machine tunnel (`serveTunnelId`) across launches → a
465
+ * STABLE, bookmarkable public URL. Each launch: ensure the stable tunnel exists
466
+ * (create it labeled if absent), reconcile its ports to exactly `[port]` via
467
+ * per-port delete/create (`host <id> -p <newport>` can't change ports — it 400s
468
+ * "Batch update of ports is not supported"), sweep our OTHER idle labeled tunnels
469
+ * (pre-migration random-named ones / crash orphans), then `host <id>`. The URL's
470
+ * subdomain is a devtunnels-assigned token (not the id), but it's stable because
471
+ * the SAME tunnel is re-hosted every launch.
472
+ *
473
+ * If any stable-path step fails (id already taken by another owner, CLI hiccup),
474
+ * falls back to hosting a fresh anonymous-labeled tunnel (the prior behavior) so
475
+ * `--tunnel` always works — just without a stable URL that launch.
476
+ */
477
+ async function startDevtunnel(port, opts = {}) {
478
+ const cli = resolveDevtunnelCli();
479
+ 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`.");
480
+ 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.");
481
+ const timeoutMs = opts.timeoutMs ?? 4e4;
482
+ const machineLabel = serveTunnelMachineLabel();
483
+ const stableId = serveTunnelId();
484
+ try {
485
+ const owned = await listServeTunnels(cli);
486
+ if (await ensureStableTunnel(cli, stableId, machineLabel, owned)) {
487
+ await reconcileTunnelPorts(cli, stableId, port);
488
+ for (const id of serveTunnelIdsToSweep(owned, machineLabel, stableId)) await deleteTunnel(cli, id);
489
+ return await hostTunnelProcess(cli, ["host", stableId], timeoutMs);
490
+ }
491
+ } catch {}
492
+ try {
493
+ for (const id of serveTunnelIdsToSweep(await listServeTunnels(cli), machineLabel)) await deleteTunnel(cli, id);
494
+ } catch {}
495
+ return await hostTunnelProcess(cli, [
496
+ "host",
497
+ "-p",
498
+ String(port),
499
+ "-l",
500
+ SERVE_TUNNEL_LABEL,
501
+ "-l",
502
+ machineLabel,
503
+ "-d",
504
+ "github-router-serve-control-plane"
505
+ ], timeoutMs);
506
+ }
507
+ //#endregion
508
+ //#region src/lib/serve/enhancements.ts
509
+ const NOOP = { cleanup: async () => {} };
510
+ /**
511
+ * Wire the github-router enhancement layer into the router-owned
512
+ * CLAUDE_CONFIG_DIR mirror so a CloudCLI-spawned Claude session gets the same
513
+ * tools `github-router claude` provides:
514
+ * - the scoped MCP servers (peers / search / orchestrate, plus workers /
515
+ * decide / browser / fleet / first-mate when their gate passes) — the
516
+ * SDK-spawned claude reads them from the mirror's `.claude.json`,
517
+ * - the peer-critic / worker / implementer subagents (`.md` files written into
518
+ * the mirror's `agents/`),
519
+ * - the gh-* skills (research / orchestrate / floor-keeper / worker).
520
+ *
521
+ * Best-effort: on any failure Claude still works, just without the extras.
522
+ * Must run AFTER `ensureClaudeConfigMirror()` and BEFORE CloudCLI is spawned.
523
+ * Tab-specific bits (ai-or-die hooks, artifact review) are intentionally left
524
+ * out — they don't apply to the serve control plane.
525
+ */
526
+ async function provisionServeEnhancements(serverUrl, opts = {}) {
527
+ try {
528
+ const tunnelExposed = opts.tunnelExposed === true;
529
+ const browseAllowed = browserToolsEnabled() && (!tunnelExposed || opts.browseOverTunnel === true);
530
+ const firstMateAllowed = agentToolsEnabled() && (!tunnelExposed || opts.agentsOverTunnel === true);
531
+ const fleetAllowed = fleetToolsEnabled() && (!tunnelExposed || opts.fleetOverTunnel === true);
532
+ const enabledGroups = [
533
+ "peers",
534
+ "search",
535
+ "orchestrate"
536
+ ];
537
+ if (workerToolsEnabled()) enabledGroups.push("workers");
538
+ if (standInToolEnabled()) enabledGroups.push("decide");
539
+ if (browseAllowed) enabledGroups.push("browser");
540
+ if (fleetAllowed) enabledGroups.push("fleet");
541
+ if (firstMateAllowed) enabledGroups.push("first-mate");
542
+ const gem = geminiAvailable();
543
+ const { keys: groupKeys } = await resolveGroupKeysFromMirror(enabledGroups);
544
+ const runtime = await writePeerMcpRuntimeFiles(serverUrl, {
545
+ codexCli: opts.codexCli === true,
546
+ geminiAvailable: gem,
547
+ groupKeys,
548
+ workerToolsAvailable: workerToolsEnabled(),
549
+ browseAvailable: browseAllowed && browseAgentEnabled(),
550
+ nativeSubagentModel: nativeSubagentModel(),
551
+ reviewerModel: reviewerModel(),
552
+ brainstormModel: brainstormModel(),
553
+ scoutModel: scoutModel(),
554
+ scribeModel: scribeModel(),
555
+ builtinSubagents: BUILTIN_SUBAGENT_DEFINITIONS
556
+ });
557
+ state.peerMcpNonce = runtime.nonce;
558
+ const injected = await injectPeerMcpIntoMirror(serverUrl, {
559
+ codexCli: opts.codexCli === true,
560
+ geminiAvailable: gem,
561
+ groupKeys,
562
+ nonce: runtime.nonce
563
+ });
564
+ const peerSnippet = buildPeerAwarenessSnippet({
565
+ codexCli: opts.codexCli === true,
566
+ geminiAvailable: gem,
567
+ workerToolsAvailable: workerToolsEnabled(),
568
+ standInAvailable: standInToolEnabled(),
569
+ browseAvailable: browseAllowed,
570
+ compoundBrowseAvailable: browseAllowed && browserCompoundToolsEnabled(),
571
+ powerBrowseAvailable: browseAllowed && state.powerBrowseEnabled,
572
+ agentToolsAvailable: firstMateAllowed,
573
+ scoutAvailable: scoutModel() != null,
574
+ groupKeys
575
+ });
576
+ await appendPeerAwarenessToMirroredClaudeMd(peerSnippet).catch((err) => consola.warn(`Peer-awareness CLAUDE.md append failed: ${String(err)}`));
577
+ await prependStyleDirectiveToMirroredClaudeMd().catch((err) => consola.warn(`Style-directive CLAUDE.md prepend failed: ${String(err)}`));
578
+ await prependOperatingDefaultsToMirroredClaudeMd(OPERATING_DEFAULTS_DIRECTIVE).catch((err) => consola.warn(`Operating-defaults CLAUDE.md prepend failed: ${String(err)}`));
579
+ if (toolbeltEnabled()) {
580
+ const toolbeltLine = buildToolbeltAwareness(availableToolCommands());
581
+ if (toolbeltLine) await appendToolbeltAwarenessToMirroredClaudeMd(toolbeltLine).catch((err) => consola.warn(`Toolbelt CLAUDE.md append failed: ${String(err)}`));
582
+ }
583
+ let skillsWritten = 0;
584
+ for (const s of INJECTED_SKILLS) {
585
+ if (s.name.startsWith("gh-first-mate") || s.name === "gh-artifact-review") continue;
586
+ if ((await writeInjectedSkill(s.name, s.md).catch(() => ({ written: false }))).written) skillsWritten++;
587
+ }
588
+ const settingsPath = path.join(PATHS.CLAUDE_CONFIG_DIR, "settings.json");
589
+ await injectAttributionSuppressionIntoSettingsFile(settingsPath).catch(() => {});
590
+ const strip = await sanitizeServeSettingsEnv(settingsPath).catch((err) => {
591
+ consola.warn(`Could not sanitize serve settings env: ${String(err)}`);
592
+ return { removed: [] };
593
+ });
594
+ if (strip.removed.length) consola.info(`Serve: removed ${strip.removed.join(", ")} from the mirror settings (single-agent surface).`);
595
+ if (process.env.GH_ROUTER_SERVE_NO_AUTO_APPROVE !== "1") await configureServeDefaultPermissionMode(settingsPath).catch((err) => consola.warn(`Could not set serve permission defaults: ${String(err)}`));
596
+ const injectedServerKeys = injected.ok ? injected.serversAdded : [...Object.values(groupKeys).filter((k) => Boolean(k)), ...opts.codexCli === true ? ["codex-cli"] : []];
597
+ await injectAllowRules(settingsPath, planModeAllowRules(injectedServerKeys)).catch((err) => consola.warn(`Could not auto-approve injected tools: ${String(err)}`));
598
+ if (workerToolsEnabled()) {
599
+ const promptCmd = buildPromptSubmitHookCommand(process.execPath, process.argv[1]);
600
+ await injectStopHookIntoSettingsFile(settingsPath, promptCmd, "UserPromptSubmit", 45).catch((err) => consola.warn(`Could not register the UserPromptSubmit hook: ${String(err)}`));
601
+ 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.");
602
+ else if (process.env.GH_ROUTER_DISABLE_WORKER_GUARD !== "1") {
603
+ const workersKey = workersKeyOf(groupKeys);
604
+ const modes = activeDispatchModes({ browse: browseAllowed && browseAgentEnabled() });
605
+ const cmd = buildWorkerGuardHookCommand(process.execPath, process.argv[1], workersKey, modes);
606
+ const matcher = guardToolMatcher(workersKey, modes);
607
+ await injectStopHookIntoSettingsFile(settingsPath, cmd, "PreToolUse", 10, matcher).catch((err) => consola.warn(`Could not register the workers PreToolUse guard hook: ${String(err)}`));
608
+ } else consola.info("Workers non-blocking guard disabled via GH_ROUTER_DISABLE_WORKER_GUARD=1; raw worker tools remain callable on the main thread.");
609
+ }
610
+ if (planReviewEnabled()) try {
611
+ const command = buildPlanReviewHookCommand(process.execPath, process.argv[1]);
612
+ await injectStopHookIntoSettingsFile(settingsPath, command, "PostToolUse", void 0, "ExitPlanMode");
613
+ } catch (err) {
614
+ consola.warn(`Could not register the advisory plan-review hook: ${String(err)}`);
615
+ }
616
+ const servers = injected.ok ? injected.serversAdded.join(", ") : "parent-only (user mcpServers collision)";
617
+ consola.info(`github-router tools wired into Claude sessions: MCP [${servers}], ${runtime.personas.length} subagents, ${skillsWritten} skills.`);
618
+ return {
619
+ cleanup: runtime.cleanup,
620
+ nonce: runtime.nonce,
621
+ mcpToolNames: enumerateInjectedMcpToolNames(groupKeys, { codexCli: opts.codexCli === true })
622
+ };
623
+ } catch (err) {
624
+ 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.`);
625
+ return NOOP;
626
+ }
627
+ }
628
+ //#endregion
629
+ //#region src/lib/serve/provider-facade.ts
630
+ function isObject(value) {
631
+ return value !== null && typeof value === "object" && !Array.isArray(value);
632
+ }
633
+ function envelopedData(value) {
634
+ if (!isObject(value) || !isObject(value.data)) return null;
635
+ return value.data;
636
+ }
637
+ function cloneObject(value) {
638
+ return { ...value };
639
+ }
640
+ /**
641
+ * Requests serve answers DIRECTLY (never forwarded to CloudCLI) with a canned
642
+ * JSON body. `POST /api/system/update` is CloudCLI's "update available" button,
643
+ * which runs a GLOBAL `npm install -g @cloudcli-ai/cloudcli@latest` in the user's
644
+ * home dir (`server/index.js`) — mutating their global npm and never applying to
645
+ * serve's pinned, router-owned install. Blocking it prevents that surprise
646
+ * mutation and tells the user how updates actually work. HTTP 200 + `success:
647
+ * false` so the UI neither runs the update nor throws an error toast. Returns
648
+ * null for anything not blocked.
649
+ */
650
+ function facadeBlockedRequest(method, pathname) {
651
+ if (method.toUpperCase() === "POST" && pathname === "/api/system/update") return {
652
+ status: 200,
653
+ json: {
654
+ success: false,
655
+ managed: true,
656
+ 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`."
657
+ }
658
+ };
659
+ return null;
660
+ }
661
+ function facadeInterceptKind(method, pathname) {
662
+ const normalizedMethod = method.toUpperCase();
663
+ if (normalizedMethod === "POST" && pathname === "/api/commands/list") return "commands";
664
+ if (normalizedMethod !== "GET") return null;
665
+ switch (pathname) {
666
+ case "/api/providers/claude/models": return "models";
667
+ case "/api/providers/claude/mcp/servers": return "mcp";
668
+ case "/api/providers/claude/skills": return "skills";
669
+ case "/api/providers/claude/auth/status": return "auth";
670
+ default: return null;
671
+ }
672
+ }
673
+ async function rewriteProviderResponse(kind, upstreamJson, ctx, query) {
674
+ try {
675
+ switch (kind) {
676
+ case "models": return rewriteModels(upstreamJson, ctx);
677
+ case "mcp": return rewriteMcp(upstreamJson, ctx, query);
678
+ case "skills": return rewriteSkills(upstreamJson, ctx);
679
+ case "commands": return rewriteCommands(upstreamJson, ctx);
680
+ case "auth": return rewriteAuth(upstreamJson);
681
+ }
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+ function rewriteModels(upstreamJson, ctx) {
687
+ if (!isObject(upstreamJson)) return null;
688
+ const data = envelopedData(upstreamJson);
689
+ if (!data || !isObject(data.models) || data.cache === void 0) return null;
690
+ const models = ctx.getModels()?.data;
691
+ if (!Array.isArray(models) || models.length === 0) return null;
692
+ const options = models.filter(isPickerModel).map((m) => {
693
+ const option = {
694
+ value: m.id,
695
+ label: m.name || m.id,
696
+ description: `${m.vendor} · ${m.capabilities.family}`
697
+ };
698
+ const efforts = m.capabilities.supports?.reasoning_effort;
699
+ if (Array.isArray(efforts) && efforts.length > 0) option.effort = {
700
+ default: efforts.includes("high") ? "high" : efforts[efforts.length - 1],
701
+ values: efforts.map((e) => ({ value: e }))
702
+ };
703
+ return option;
704
+ });
705
+ if (options.length === 0) return null;
706
+ const values = new Set(options.map((o) => o.value));
707
+ const upstreamDefault = typeof data.models.DEFAULT === "string" ? data.models.DEFAULT : void 0;
708
+ const DEFAULT = values.has(ctx.defaultModel) ? ctx.defaultModel : options[0]?.value ?? upstreamDefault;
709
+ const nextData = cloneObject(data);
710
+ nextData.models = {
711
+ OPTIONS: options,
712
+ DEFAULT
713
+ };
714
+ return {
715
+ ...upstreamJson,
716
+ data: nextData
717
+ };
718
+ }
719
+ function isPickerModel(model) {
720
+ 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";
721
+ }
722
+ async function rewriteMcp(upstreamJson, ctx, query) {
723
+ if (!isObject(upstreamJson) || query.get("scope") !== "user") return null;
724
+ const data = envelopedData(upstreamJson);
725
+ if (!data || !Array.isArray(data.servers)) return null;
726
+ const mirror = await readJson(path.join(ctx.claudeConfigDir, ".claude.json"));
727
+ if (!isObject(mirror) || !isObject(mirror.mcpServers)) return null;
728
+ const existing = new Set(data.servers.filter(isObject).map((s) => s.name).filter((name) => typeof name === "string"));
729
+ const additions = [];
730
+ for (const [name, entry] of Object.entries(mirror.mcpServers)) {
731
+ if (existing.has(name) || !isObject(entry)) continue;
732
+ const server = {
733
+ provider: "claude",
734
+ name,
735
+ scope: "user",
736
+ transport: entry.type === "http" ? "http" : "stdio"
737
+ };
738
+ if (typeof entry.url === "string") server.url = entry.url;
739
+ additions.push(server);
740
+ existing.add(name);
741
+ }
742
+ if (additions.length === 0) return null;
743
+ const nextData = cloneObject(data);
744
+ nextData.servers = [...data.servers, ...additions];
745
+ return {
746
+ ...upstreamJson,
747
+ data: nextData
748
+ };
749
+ }
750
+ async function rewriteSkills(upstreamJson, ctx) {
751
+ if (!isObject(upstreamJson)) return null;
752
+ const data = envelopedData(upstreamJson);
753
+ if (!data || !Array.isArray(data.skills)) return null;
754
+ const skills = await readSkillDirs(path.join(ctx.claudeConfigDir, "skills"));
755
+ if (skills.length === 0) return null;
756
+ const existing = new Set(data.skills.filter(isObject).map((s) => s.command).filter((command) => typeof command === "string"));
757
+ const additions = skills.filter((skill) => !existing.has(skill.name)).map((skill) => ({
758
+ provider: "claude",
759
+ name: skill.name,
760
+ description: skill.description,
761
+ command: skill.name,
762
+ scope: "user"
763
+ }));
764
+ if (additions.length === 0) return null;
765
+ const nextData = cloneObject(data);
766
+ nextData.skills = [...data.skills, ...additions];
767
+ return {
768
+ ...upstreamJson,
769
+ data: nextData
770
+ };
771
+ }
772
+ async function rewriteCommands(upstreamJson, ctx) {
773
+ if (!isObject(upstreamJson) || !Array.isArray(upstreamJson.custom)) return null;
774
+ const dirs = [path.join(ctx.claudeConfigDir, "commands"), path.join(ctx.claudeConfigDir, ".claude", "commands")];
775
+ const commandFiles = (await Promise.all(dirs.map(readCommandFiles))).flat();
776
+ if (commandFiles.length === 0) return null;
777
+ const existing = new Set(upstreamJson.custom.filter(isObject).map((c) => c.name).filter((name) => typeof name === "string"));
778
+ const additions = commandFiles.filter((command) => !existing.has(command.name)).map((command) => ({
779
+ name: command.name,
780
+ path: command.relativePath,
781
+ relativePath: command.relativePath,
782
+ description: command.description,
783
+ namespace: "user",
784
+ metadata: { type: "custom" }
785
+ }));
786
+ if (additions.length === 0) return null;
787
+ const custom = [...upstreamJson.custom, ...additions];
788
+ return {
789
+ ...upstreamJson,
790
+ custom,
791
+ count: countCommands(upstreamJson.builtIn) + custom.length
792
+ };
793
+ }
794
+ function rewriteAuth(upstreamJson) {
795
+ if (!isObject(upstreamJson)) return null;
796
+ const data = envelopedData(upstreamJson);
797
+ if (!data) return null;
798
+ return {
799
+ ...upstreamJson,
800
+ data: {
801
+ ...data,
802
+ authenticated: true,
803
+ installed: true
804
+ }
805
+ };
806
+ }
807
+ async function readJson(filePath) {
808
+ try {
809
+ return JSON.parse(await fs$1.readFile(filePath, "utf8"));
810
+ } catch {
811
+ return null;
812
+ }
813
+ }
814
+ async function readSkillDirs(skillsDir) {
815
+ try {
816
+ const entries = await fs$1.readdir(skillsDir, { withFileTypes: true });
817
+ return await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith("gh-")).map(async (entry) => {
818
+ const sourcePath = path.join(skillsDir, entry.name, "SKILL.md");
819
+ let description = "";
820
+ try {
821
+ description = parseFrontmatterField(await fs$1.readFile(sourcePath, "utf8"), "description") ?? "";
822
+ } catch {}
823
+ return {
824
+ name: entry.name,
825
+ description,
826
+ sourcePath
827
+ };
828
+ }));
829
+ } catch {
830
+ return [];
831
+ }
832
+ }
833
+ async function readCommandFiles(commandsDir) {
834
+ try {
835
+ if (!(await fs$1.stat(commandsDir)).isDirectory()) return [];
836
+ } catch {
837
+ return [];
838
+ }
839
+ return readCommandFilesRecursive(commandsDir, commandsDir);
840
+ }
841
+ async function readCommandFilesRecursive(root, dir) {
842
+ let entries;
843
+ try {
844
+ entries = await fs$1.readdir(dir, { withFileTypes: true });
845
+ } catch {
846
+ return [];
847
+ }
848
+ const out = [];
849
+ for (const entry of entries) {
850
+ const filePath = path.join(dir, entry.name);
851
+ if (entry.isDirectory()) {
852
+ out.push(...await readCommandFilesRecursive(root, filePath));
853
+ continue;
854
+ }
855
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
856
+ const relativePath = normalizeRelative(path.relative(root, filePath));
857
+ const name = relativePath.replace(/\.md$/i, "");
858
+ let description = "";
859
+ try {
860
+ const md = await fs$1.readFile(filePath, "utf8");
861
+ description = parseFrontmatterField(md, "description") ?? firstMarkdownLine(md);
862
+ } catch {}
863
+ out.push({
864
+ name,
865
+ relativePath,
866
+ filePath,
867
+ description
868
+ });
869
+ }
870
+ return out;
871
+ }
872
+ function parseFrontmatterField(markdown, field) {
873
+ if (!markdown.startsWith("---")) return null;
874
+ const end = markdown.indexOf("\n---", 3);
875
+ if (end < 0) return null;
876
+ const frontmatter = markdown.slice(3, end).split(/\r?\n/);
877
+ for (const line of frontmatter) {
878
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
879
+ if (!match || match[1] !== field) continue;
880
+ return match[2].replace(/^['"]|['"]$/g, "").trim();
881
+ }
882
+ return null;
883
+ }
884
+ function firstMarkdownLine(markdown) {
885
+ const body = markdown.startsWith("---") ? markdown.slice(markdown.indexOf("\n---", 3) + 4) : markdown;
886
+ for (const line of body.split(/\r?\n/)) {
887
+ const trimmed = line.trim();
888
+ if (trimmed) return trimmed.replace(/^#+\s*/, "");
889
+ }
890
+ return "";
891
+ }
892
+ function normalizeRelative(value) {
893
+ return value.split(path.sep).join("/");
894
+ }
895
+ function countCommands(builtIn) {
896
+ return Array.isArray(builtIn) ? builtIn.length : 0;
897
+ }
898
+ //#endregion
899
+ //#region src/lib/serve/reverse-proxy.ts
900
+ /**
901
+ * Loopback-only identity endpoint served directly by the reverse proxy (never
902
+ * forwarded to CloudCLI). A second `github-router serve` probes this on the
903
+ * intended port to detect an already-running instance (single-instance guard),
904
+ * so it can attach/refuse instead of starting a duplicate CloudCLI. Carries no
905
+ * secret — just a service marker + version + origin.
906
+ */
907
+ const SERVE_IDENTITY_PATH = "/__github-router-serve__";
908
+ const SERVE_IDENTITY_SERVICE = "github-router-serve";
909
+ const HEAD_CLOSE = "</head>";
910
+ /** CloudCLI's localStorage key for per-tool chat permission settings. */
911
+ const CLAUDE_SETTINGS_KEY = "claude-settings";
912
+ /**
913
+ * The two interaction tools CloudCLI routes to the human (its
914
+ * `TOOLS_REQUIRING_INTERACTION`). They must NEVER appear in the seeded
915
+ * `allowedTools` — an exact-name match there would auto-approve them and defeat
916
+ * the question widget / plan card. Stripped defensively at the seed chokepoint
917
+ * so no upstream list (mcp names, a future caller) can reintroduce them.
918
+ */
919
+ const NEVER_AUTO_APPROVE = /* @__PURE__ */ new Set(["AskUserQuestion", "ExitPlanMode"]);
920
+ /**
921
+ * Embed the token as a JS string literal. JSON.stringify escapes quotes and
922
+ * backslashes; we additionally neutralize `<` so the value can never break out
923
+ * of the surrounding <script> element regardless of contents.
924
+ *
925
+ * When `seedToolSettings` is set, also seed CloudCLI's `claude-settings`
926
+ * localStorage with `{allowedTools:<routine+mcp>, disallowedTools:[], skipPermissions:false}`.
927
+ * This is serve's "seamless routine + decisions to the user" model: `canUseTool`
928
+ * stays LIVE (skipPermissions:false, NOT bypass), routine tools in `allowedTools`
929
+ * auto-run with no prompt, and `AskUserQuestion`/`ExitPlanMode` — deliberately
930
+ * NOT in the list and hard-cased by CloudCLI into `TOOLS_REQUIRING_INTERACTION` —
931
+ * always reach the human as a real question widget / plan Approve-Reject card.
932
+ * `skipPermissions:true` (bypass) would skip `canUseTool` entirely and silently
933
+ * auto-answer those two, which is exactly what we are fixing.
934
+ */
935
+ function buildInjection(token, seedToolSettings = false, seedAllowedTools = []) {
936
+ let body = `localStorage.setItem('auth-token',${JSON.stringify(token).replace(/</g, "\\u003c")});`;
937
+ if (seedToolSettings) {
938
+ const settings = JSON.stringify({
939
+ allowedTools: seedAllowedTools.filter((t) => !NEVER_AUTO_APPROVE.has(t)),
940
+ disallowedTools: [],
941
+ skipPermissions: false
942
+ });
943
+ const safeSettings = JSON.stringify(settings).replace(/</g, "\\u003c");
944
+ body += `localStorage.setItem('${CLAUDE_SETTINGS_KEY}',${safeSettings});`;
945
+ }
946
+ return `<script>try{${body}}catch(e){}<\/script>`;
947
+ }
948
+ function headerLines(headers) {
949
+ return Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join("\r\n");
950
+ }
951
+ async function startReverseProxy(opts) {
952
+ const { targetHost, targetPort, bindHost, bindPort, authToken } = opts;
953
+ const ownOrigin = `http://${bindHost}:${bindPort}`;
954
+ const injection = buildInjection(authToken, opts.seedToolSettings === true, opts.seedAllowedTools ?? []);
955
+ const allowedHosts = /* @__PURE__ */ new Set([
956
+ `127.0.0.1:${bindPort}`,
957
+ `localhost:${bindPort}`,
958
+ `[::1]:${bindPort}`,
959
+ ...(opts.extraAllowedHosts ?? []).map((h) => h.toLowerCase())
960
+ ]);
961
+ const loopbackHosts = /* @__PURE__ */ new Set([
962
+ `127.0.0.1:${bindPort}`,
963
+ `localhost:${bindPort}`,
964
+ `[::1]:${bindPort}`
965
+ ]);
966
+ const allowedOrigins = /* @__PURE__ */ new Set([
967
+ `http://127.0.0.1:${bindPort}`,
968
+ `http://localhost:${bindPort}`,
969
+ `http://[::1]:${bindPort}`,
970
+ ...(opts.extraAllowedOrigins ?? []).map((o) => o.toLowerCase())
971
+ ]);
972
+ const DEVTUNNEL_RE = /(^|\.)devtunnels\.ms$/i;
973
+ const isDevtunnelHost = (host) => !!opts.allowDevtunnelHosts && DEVTUNNEL_RE.test(host.replace(/:\d+$/, ""));
974
+ const isDevtunnelOrigin = (origin) => {
975
+ if (!opts.allowDevtunnelHosts) return false;
976
+ try {
977
+ const u = new URL(origin);
978
+ return u.protocol === "https:" && DEVTUNNEL_RE.test(u.hostname);
979
+ } catch {
980
+ return false;
981
+ }
982
+ };
983
+ const hostAllowed = (host) => !!host && (allowedHosts.has(host.toLowerCase()) || isDevtunnelHost(host.toLowerCase()));
984
+ const originAllowed = (origin) => !origin || allowedOrigins.has(origin.toLowerCase()) || isDevtunnelOrigin(origin);
985
+ const liveSockets = /* @__PURE__ */ new Set();
986
+ const server = http.createServer((clientReq, clientRes) => {
987
+ clientReq.on("error", () => clientRes.destroy());
988
+ clientRes.on("error", () => clientReq.destroy());
989
+ if (!hostAllowed(clientReq.headers.host) || !originAllowed(clientReq.headers.origin)) {
990
+ clientRes.writeHead(403, { "content-type": "text/plain" });
991
+ clientRes.end("Forbidden");
992
+ return;
993
+ }
994
+ if ((clientReq.url ?? "").split("?")[0] === "/__github-router-serve__") {
995
+ if (!loopbackHosts.has(String(clientReq.headers.host ?? "").toLowerCase())) {
996
+ clientRes.writeHead(404, { "content-type": "text/plain" });
997
+ clientRes.end("Not found");
998
+ return;
999
+ }
1000
+ clientRes.writeHead(200, { "content-type": "application/json" });
1001
+ clientRes.end(JSON.stringify({
1002
+ service: SERVE_IDENTITY_SERVICE,
1003
+ version: getPackageVersion()
1004
+ }));
1005
+ return;
1006
+ }
1007
+ const requestUrl = new URL(clientReq.url ?? "/", ownOrigin);
1008
+ const blocked = opts.providerFacade?.blockedRequest?.(clientReq.method ?? "GET", requestUrl.pathname);
1009
+ if (blocked) {
1010
+ clientReq.resume();
1011
+ clientRes.writeHead(blocked.status, { "content-type": "application/json" });
1012
+ clientRes.end(JSON.stringify(blocked.json));
1013
+ return;
1014
+ }
1015
+ const facadeKind = opts.providerFacade?.kindFor(clientReq.method ?? "GET", requestUrl.pathname) ?? null;
1016
+ const headers = { ...clientReq.headers };
1017
+ delete headers["accept-encoding"];
1018
+ const proxyReq = http.request({
1019
+ host: targetHost,
1020
+ port: targetPort,
1021
+ path: clientReq.url,
1022
+ method: clientReq.method,
1023
+ headers
1024
+ }, (proxyRes) => {
1025
+ proxyRes.on("error", () => clientRes.destroy());
1026
+ const contentType = String(proxyRes.headers["content-type"] ?? "");
1027
+ if (facadeKind) {
1028
+ const chunks = [];
1029
+ proxyRes.on("data", (d) => chunks.push(Buffer.from(d)));
1030
+ proxyRes.on("end", () => {
1031
+ const original = Buffer.concat(chunks);
1032
+ const sendOriginal = () => {
1033
+ const outHeaders = { ...proxyRes.headers };
1034
+ delete outHeaders["content-length"];
1035
+ delete outHeaders["content-encoding"];
1036
+ delete outHeaders["transfer-encoding"];
1037
+ clientRes.writeHead(proxyRes.statusCode ?? 502, outHeaders);
1038
+ clientRes.end(original);
1039
+ };
1040
+ if (proxyRes.statusCode !== 200) {
1041
+ sendOriginal();
1042
+ return;
1043
+ }
1044
+ (async () => {
1045
+ try {
1046
+ const parsed = JSON.parse(original.toString("utf8"));
1047
+ const rewritten = await opts.providerFacade?.rewrite(facadeKind, parsed, requestUrl.searchParams);
1048
+ if (rewritten == null) {
1049
+ sendOriginal();
1050
+ return;
1051
+ }
1052
+ const outHeaders = { ...proxyRes.headers };
1053
+ delete outHeaders["content-length"];
1054
+ delete outHeaders["content-encoding"];
1055
+ delete outHeaders["transfer-encoding"];
1056
+ outHeaders["content-type"] = "application/json";
1057
+ clientRes.writeHead(200, outHeaders);
1058
+ clientRes.end(JSON.stringify(rewritten));
1059
+ } catch {
1060
+ sendOriginal();
1061
+ }
1062
+ })();
1063
+ });
1064
+ } else if (contentType.includes("text/html")) {
1065
+ const chunks = [];
1066
+ proxyRes.on("data", (d) => chunks.push(Buffer.from(d)));
1067
+ proxyRes.on("end", () => {
1068
+ let html = Buffer.concat(chunks).toString("utf8");
1069
+ html = html.includes(HEAD_CLOSE) ? html.replace(HEAD_CLOSE, injection + HEAD_CLOSE) : injection + html;
1070
+ const outHeaders = { ...proxyRes.headers };
1071
+ delete outHeaders["content-length"];
1072
+ delete outHeaders["content-encoding"];
1073
+ delete outHeaders["transfer-encoding"];
1074
+ clientRes.writeHead(proxyRes.statusCode ?? 502, outHeaders);
1075
+ clientRes.end(html);
1076
+ });
1077
+ } else {
1078
+ clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
1079
+ proxyRes.pipe(clientRes);
1080
+ }
1081
+ });
1082
+ proxyReq.on("error", (err) => {
1083
+ if (!clientRes.headersSent) clientRes.writeHead(502, { "content-type": "text/plain" });
1084
+ clientRes.end(`Bad gateway: ${err.message}`);
1085
+ });
1086
+ clientReq.pipe(proxyReq);
1087
+ });
1088
+ server.on("upgrade", (clientReq, clientSock, clientHead) => {
1089
+ clientSock.on("error", () => clientSock.destroy());
1090
+ liveSockets.add(clientSock);
1091
+ clientSock.once("close", () => liveSockets.delete(clientSock));
1092
+ if (!hostAllowed(clientReq.headers.host) || !originAllowed(clientReq.headers.origin)) {
1093
+ clientSock.destroy();
1094
+ return;
1095
+ }
1096
+ const proxyReq = http.request({
1097
+ host: targetHost,
1098
+ port: targetPort,
1099
+ path: clientReq.url,
1100
+ method: clientReq.method,
1101
+ headers: clientReq.headers
1102
+ });
1103
+ proxyReq.on("upgrade", (proxyRes, proxySock, proxyHead) => {
1104
+ clientSock.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${headerLines(proxyRes.headers)}\r\n\r\n`);
1105
+ if (clientHead && clientHead.length) proxySock.write(clientHead);
1106
+ if (proxyHead && proxyHead.length) proxySock.unshift(proxyHead);
1107
+ proxySock.pipe(clientSock);
1108
+ clientSock.pipe(proxySock);
1109
+ const cleanup = () => {
1110
+ proxySock.destroy();
1111
+ clientSock.destroy();
1112
+ };
1113
+ clientSock.on("error", cleanup);
1114
+ proxySock.on("error", cleanup);
1115
+ clientSock.on("close", cleanup);
1116
+ proxySock.on("close", cleanup);
1117
+ });
1118
+ proxyReq.on("response", (proxyRes) => {
1119
+ try {
1120
+ clientSock.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${headerLines(proxyRes.headers)}\r\n\r\n`);
1121
+ } catch {}
1122
+ proxyRes.on("data", (d) => {
1123
+ clientSock.write(d);
1124
+ });
1125
+ proxyRes.on("end", () => clientSock.end());
1126
+ proxyRes.on("error", () => clientSock.destroy());
1127
+ });
1128
+ proxyReq.on("error", () => clientSock.destroy());
1129
+ proxyReq.end();
1130
+ });
1131
+ await new Promise((resolve, reject) => {
1132
+ const onError = (err) => reject(err);
1133
+ server.once("error", onError);
1134
+ server.listen(bindPort, bindHost, () => {
1135
+ server.removeListener("error", onError);
1136
+ resolve();
1137
+ });
1138
+ });
1139
+ const addr = server.address();
1140
+ if (addr && addr.address !== "127.0.0.1" && addr.address !== "::1") {
1141
+ await new Promise((r) => server.close(() => r()));
1142
+ throw new Error(`reverse proxy refused non-loopback bind (got ${addr.address})`);
1143
+ }
1144
+ return {
1145
+ url: ownOrigin,
1146
+ port: bindPort,
1147
+ close: () => new Promise((resolve) => {
1148
+ for (const s of liveSockets) s.destroy();
1149
+ liveSockets.clear();
1150
+ const t = setTimeout(resolve, 1500);
1151
+ server.close(() => {
1152
+ clearTimeout(t);
1153
+ resolve();
1154
+ });
1155
+ })
1156
+ };
1157
+ }
1158
+ //#endregion
1159
+ //#region src/serve.ts
1160
+ async function getFreePort() {
1161
+ return new Promise((resolve, reject) => {
1162
+ const s = http.createServer();
1163
+ s.once("error", reject);
1164
+ s.listen(0, "127.0.0.1", () => {
1165
+ const port = s.address().port;
1166
+ s.close(() => resolve(port));
1167
+ });
1168
+ });
1169
+ }
1170
+ /** Default user-facing control-plane port when `--port` is not given. */
1171
+ const DEFAULT_SERVE_PORT = 5454;
1172
+ function isPortFree(port) {
1173
+ return new Promise((resolve) => {
1174
+ const s = http.createServer();
1175
+ s.once("error", () => resolve(false));
1176
+ s.listen(port, "127.0.0.1", () => s.close(() => resolve(true)));
1177
+ });
1178
+ }
1179
+ /**
1180
+ * Probe a loopback port for an already-running `github-router serve` (its
1181
+ * identity endpoint). Returns true when the port hosts OUR serve, else false
1182
+ * (free, or a foreign process). `serve` is designed to run ONCE per machine
1183
+ * (a single machine-wide control plane for all repos), so a second launch
1184
+ * attaches to the running one instead of spawning a duplicate CloudCLI +
1185
+ * contending on the shared auth DB. We intentionally do NOT trust any URL the
1186
+ * probed service reports — a local squatter could forge the identity marker, so
1187
+ * the caller only ever opens a locally-constructed loopback origin.
1188
+ */
1189
+ function probeExistingServe(port) {
1190
+ return new Promise((resolve) => {
1191
+ const req = http.get({
1192
+ host: "127.0.0.1",
1193
+ port,
1194
+ path: SERVE_IDENTITY_PATH,
1195
+ headers: { host: `127.0.0.1:${port}` },
1196
+ timeout: 1500
1197
+ }, (res) => {
1198
+ if (res.statusCode !== 200) {
1199
+ res.resume();
1200
+ resolve(false);
1201
+ return;
1202
+ }
1203
+ const chunks = [];
1204
+ res.on("data", (d) => chunks.push(Buffer.from(d)));
1205
+ res.on("end", () => {
1206
+ try {
1207
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")).service === SERVE_IDENTITY_SERVICE);
1208
+ } catch {
1209
+ resolve(false);
1210
+ }
1211
+ });
1212
+ res.on("error", () => resolve(false));
1213
+ });
1214
+ req.on("timeout", () => req.destroy());
1215
+ req.on("error", () => resolve(false));
1216
+ });
1217
+ }
1218
+ /**
1219
+ * Resolve the user-facing port: honor an explicit `--port`; otherwise use 5454
1220
+ * when free, else fall back to a random free port. (Single-instance detection —
1221
+ * an already-running github-router serve on the intended port — is handled by
1222
+ * the earlier `probeExistingServe` attach/refuse in `run`.)
1223
+ */
1224
+ async function resolveServePort(requested) {
1225
+ if (requested != null) return requested;
1226
+ if (await isPortFree(DEFAULT_SERVE_PORT)) return DEFAULT_SERVE_PORT;
1227
+ const fallback = await getFreePort();
1228
+ consola.info(`Port ${DEFAULT_SERVE_PORT} is in use; using ${fallback} instead.`);
1229
+ return fallback;
1230
+ }
1231
+ function openBrowser(url) {
1232
+ try {
1233
+ if (process.platform === "win32") spawn("cmd", [
1234
+ "/c",
1235
+ "start",
1236
+ "",
1237
+ url
1238
+ ], {
1239
+ detached: true,
1240
+ stdio: "ignore",
1241
+ windowsHide: true
1242
+ }).unref();
1243
+ else if (process.platform === "darwin") spawn("open", [url], {
1244
+ detached: true,
1245
+ stdio: "ignore"
1246
+ }).unref();
1247
+ else spawn("xdg-open", [url], {
1248
+ detached: true,
1249
+ stdio: "ignore"
1250
+ }).unref();
1251
+ } catch {}
1252
+ }
1253
+ const serve = defineCommand({
1254
+ meta: {
1255
+ name: "serve",
1256
+ description: "Serve a browser control plane (CloudCLI) with Claude routed through the proxy"
1257
+ },
1258
+ args: {
1259
+ ...sharedServerArgs,
1260
+ model: {
1261
+ alias: "m",
1262
+ type: "string",
1263
+ description: "Override the default Claude model"
1264
+ },
1265
+ "cloudcli-path": {
1266
+ type: "string",
1267
+ description: "Use an existing CloudCLI install instead of the managed one"
1268
+ },
1269
+ "cloudcli-version": {
1270
+ type: "string",
1271
+ description: "Override the pinned CloudCLI version to install"
1272
+ },
1273
+ "codex-cli": {
1274
+ type: "boolean",
1275
+ default: false,
1276
+ description: "Route Codex personas through a local `codex mcp-server` (requires codex 0.129+; falls back to HTTP if missing)"
1277
+ },
1278
+ "browse-over-tunnel": {
1279
+ type: "boolean",
1280
+ default: false,
1281
+ description: "Expose the server-side browser MCP even when tunnel-exposed (session-hijack / SSRF risk — only with fully-trusted tunnel access)"
1282
+ },
1283
+ "agents-over-tunnel": {
1284
+ type: "boolean",
1285
+ default: false,
1286
+ description: "Expose first-mate (mints a repo+workflow GitHub write token) even when tunnel-exposed (only with fully-trusted tunnel access)"
1287
+ },
1288
+ "fleet-over-tunnel": {
1289
+ type: "boolean",
1290
+ default: false,
1291
+ description: "Expose the fleet MCP (drives remote coding sessions with your fleet credentials) even when tunnel-exposed (only with fully-trusted tunnel access)"
1292
+ },
1293
+ "no-install": {
1294
+ type: "boolean",
1295
+ default: false,
1296
+ description: "Do not auto-install CloudCLI; require an existing install"
1297
+ },
1298
+ "no-open": {
1299
+ type: "boolean",
1300
+ default: false,
1301
+ description: "Do not open the browser automatically"
1302
+ },
1303
+ "public-url": {
1304
+ type: "string",
1305
+ description: "Public URL(s) this is reachable at (e.g. a dev-tunnel https URL), comma-separated — allowlists their host+origin for remote access"
1306
+ },
1307
+ tunnel: {
1308
+ type: "boolean",
1309
+ default: false,
1310
+ 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`."
1311
+ }
1312
+ },
1313
+ async run({ args }) {
1314
+ state.serveMode = true;
1315
+ 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).");
1316
+ const parsed = parseSharedArgs(args);
1317
+ const intendedPort = parsed.port ?? DEFAULT_SERVE_PORT;
1318
+ if (await probeExistingServe(intendedPort)) {
1319
+ const existingUrl = `http://127.0.0.1:${intendedPort}`;
1320
+ consola.box(`github-router control plane already running\n\n${existingUrl}\n\nOpening it — stop that instance first if you want a fresh one.`);
1321
+ if (args["no-open"] !== true) openBrowser(existingUrl);
1322
+ return;
1323
+ }
1324
+ const { server, serverUrl } = await setupAndServe({
1325
+ ...parsed,
1326
+ port: void 0,
1327
+ silent: true
1328
+ });
1329
+ try {
1330
+ await ensureClaudeConfigMirror();
1331
+ } catch (err) {
1332
+ consola.error(`Failed to provision CLAUDE_CONFIG_DIR mirror: ${err instanceof Error ? err.message : String(err)}`);
1333
+ process.exit(1);
1334
+ }
1335
+ runSelfUpdate({ selfUpdate: args["self-update"] !== false });
1336
+ provisionAndIndexColbert({ skipCwdIndex: true });
1337
+ provisionToolbelt().catch((err) => consola.debug("Toolbelt provisioning failed:", err));
1338
+ startKeepAwake();
1339
+ if (browserToolsEnabled()) provisionBrowserAssets().catch((err) => consola.debug("Browser extension provisioning failed:", err));
1340
+ const opusFamilyShorthand = args.model?.match(/^(\d+\.\d+)$/)?.[1];
1341
+ let chosenSlug = opusFamilyShorthand ? pickClaudeDefault(opusFamilyShorthand) : args.model ?? pickClaudeDefault();
1342
+ if (!args.model && state.models) {
1343
+ const inCache = (slug) => state.models?.data.some((m) => m.id === resolveModel(slug)) ?? false;
1344
+ if (!inCache(chosenSlug)) {
1345
+ for (const fb of DEFAULT_CLAUDE_MODEL_FALLBACKS) if (inCache(fb)) {
1346
+ consola.info(`Default model "${chosenSlug}" not in your Copilot model list; falling back to "${fb}".`);
1347
+ chosenSlug = fb;
1348
+ break;
1349
+ }
1350
+ }
1351
+ }
1352
+ const requestedCli = args["codex-cli"] === true;
1353
+ const backend = resolveCodexCliBackend({
1354
+ requested: requestedCli,
1355
+ codexInfo: requestedCli ? getCodexVersion() : null
1356
+ });
1357
+ const tunnelExposed = args.tunnel === true || String(args["public-url"] ?? "").split(",").map((s) => s.trim()).filter(Boolean).length > 0;
1358
+ 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.");
1359
+ 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.");
1360
+ 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.");
1361
+ const enhancements = await provisionServeEnhancements(serverUrl, {
1362
+ codexCli: backend === "cli",
1363
+ tunnelExposed,
1364
+ browseOverTunnel: args["browse-over-tunnel"] === true,
1365
+ agentsOverTunnel: args["agents-over-tunnel"] === true,
1366
+ fleetOverTunnel: args["fleet-over-tunnel"] === true
1367
+ });
1368
+ const anthropicVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
1369
+ delete anthropicVars.CLAUDE_CODE_FORK_SUBAGENT;
1370
+ if (enhancements.nonce) {
1371
+ anthropicVars.GH_ROUTER_HOOK_MCP_URL = serverUrl;
1372
+ anthropicVars.GH_ROUTER_HOOK_NONCE = enhancements.nonce;
1373
+ }
1374
+ const childEnv = composeCloudCliChildEnv(anthropicVars);
1375
+ childEnv.ANTHROPIC_API_KEY = "sk-ant-github-router-proxy-placeholder";
1376
+ let cloudcli;
1377
+ try {
1378
+ cloudcli = await resolveCloudCli({
1379
+ cliPath: args["cloudcli-path"],
1380
+ noInstall: args["no-install"] === true,
1381
+ version: args["cloudcli-version"]
1382
+ });
1383
+ } catch (err) {
1384
+ consola.error(err instanceof Error ? err.message : String(err));
1385
+ process.exit(1);
1386
+ }
1387
+ const ccPort = await getFreePort();
1388
+ const cc = spawnCloudCli({
1389
+ serverEntry: cloudcli.serverEntry,
1390
+ port: ccPort,
1391
+ env: childEnv
1392
+ });
1393
+ let shuttingDown = false;
1394
+ let reverseClose = null;
1395
+ let tunnelStop = null;
1396
+ const shutdown = async () => {
1397
+ if (shuttingDown) return;
1398
+ shuttingDown = true;
1399
+ if (tunnelStop) try {
1400
+ tunnelStop();
1401
+ } catch {}
1402
+ try {
1403
+ killChildProcessTree(cc.child, { detachedGroup: process.platform !== "win32" });
1404
+ } catch {}
1405
+ if (reverseClose) await reverseClose().catch(() => {});
1406
+ try {
1407
+ server.close?.();
1408
+ } catch {}
1409
+ await stopKeepAwake().catch(() => {});
1410
+ await enhancements.cleanup().catch(() => {});
1411
+ await removeOwnClaudeConfigMirror().catch(() => {});
1412
+ };
1413
+ process.once("SIGINT", () => void shutdown().then(() => process.exit(0)));
1414
+ process.once("SIGTERM", () => void shutdown().then(() => process.exit(0)));
1415
+ cc.child.once("exit", (code) => {
1416
+ if (!shuttingDown) {
1417
+ consola.error(`CloudCLI exited unexpectedly (code ${code}).`);
1418
+ shutdown().then(() => process.exit(1));
1419
+ }
1420
+ });
1421
+ cc.child.once("error", (err) => {
1422
+ if (!shuttingDown) {
1423
+ consola.error(`CloudCLI failed to start: ${err.message}`);
1424
+ shutdown().then(() => process.exit(1));
1425
+ }
1426
+ });
1427
+ let ready;
1428
+ try {
1429
+ ready = await waitForCloudCliReady(ccPort);
1430
+ } catch (err) {
1431
+ consola.error(err instanceof Error ? err.message : String(err));
1432
+ await shutdown();
1433
+ process.exit(1);
1434
+ }
1435
+ let username = "github-router";
1436
+ try {
1437
+ username = (await getGitHubUser()).login;
1438
+ } catch {}
1439
+ let token;
1440
+ try {
1441
+ token = await mintJwt(ccPort, username, ready.needsSetup);
1442
+ } catch (err) {
1443
+ consola.error(err instanceof Error ? err.message : String(err));
1444
+ await shutdown();
1445
+ process.exit(1);
1446
+ }
1447
+ const publicUrls = String(args["public-url"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1448
+ const extraAllowedHosts = [];
1449
+ const extraAllowedOrigins = [];
1450
+ for (const u of publicUrls) try {
1451
+ const url = new URL(u);
1452
+ extraAllowedHosts.push(url.host);
1453
+ extraAllowedOrigins.push(url.origin);
1454
+ } catch {
1455
+ consola.warn(`Ignoring invalid --public-url "${u}"`);
1456
+ }
1457
+ const tunnelMode = args.tunnel === true;
1458
+ 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.");
1459
+ const servePort = await resolveServePort(parsed.port);
1460
+ const providerFacade = {
1461
+ kindFor: facadeInterceptKind,
1462
+ blockedRequest: facadeBlockedRequest,
1463
+ rewrite: (kind, json, query) => rewriteProviderResponse(kind, json, {
1464
+ getModels: () => state.models,
1465
+ defaultModel: resolveModel(chosenSlug),
1466
+ claudeConfigDir: PATHS.CLAUDE_CONFIG_DIR
1467
+ }, query)
1468
+ };
1469
+ let reverse;
1470
+ try {
1471
+ reverse = await startReverseProxy({
1472
+ targetHost: "127.0.0.1",
1473
+ targetPort: ccPort,
1474
+ bindHost: "127.0.0.1",
1475
+ bindPort: servePort,
1476
+ authToken: token,
1477
+ seedToolSettings: process.env.GH_ROUTER_SERVE_NO_AUTO_APPROVE !== "1",
1478
+ seedAllowedTools: [...SEAMLESS_BUILTIN_TOOLS, ...enhancements.mcpToolNames ?? []],
1479
+ extraAllowedHosts,
1480
+ extraAllowedOrigins,
1481
+ allowDevtunnelHosts: tunnelMode,
1482
+ providerFacade
1483
+ });
1484
+ } catch (err) {
1485
+ 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>.`);
1486
+ await shutdown();
1487
+ process.exit(1);
1488
+ }
1489
+ reverseClose = reverse.close;
1490
+ let tunnelUrl = null;
1491
+ if (tunnelMode) {
1492
+ consola.info("Creating an authenticated dev tunnel…");
1493
+ try {
1494
+ const tunnel = await startDevtunnel(servePort);
1495
+ tunnelUrl = tunnel.url;
1496
+ tunnelStop = tunnel.stop;
1497
+ } catch (err) {
1498
+ const msg = err instanceof DevtunnelError ? err.message : String(err);
1499
+ consola.warn(`Could not auto-host a dev tunnel: ${msg}\nServing locally only. To host manually once fixed: devtunnel host -p ${servePort}`);
1500
+ }
1501
+ }
1502
+ const publicLines = [...publicUrls.map((u) => `remote: ${u}`), ...tunnelUrl ? [`remote (tunnel): ${tunnelUrl} (stable — bookmark it; same URL every launch)`] : []];
1503
+ const publicBlock = publicLines.length ? `\n${publicLines.join("\n")}` : "";
1504
+ consola.box(`🖥️ github-router control plane\n\nlocal: ${reverse.url}${publicBlock}\n\nsigned in as ${username} · model ${chosenSlug}\nCtrl+C to stop`);
1505
+ if (args["no-open"] !== true) openBrowser(reverse.url);
1506
+ }
1507
+ });
1508
+ //#endregion
1509
+ export { serve };
1510
+
1511
+ //# sourceMappingURL=serve-sa_7c-yD.js.map