github-router 0.3.24 → 0.3.26

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
@@ -40,9 +40,30 @@ const PATHS = {
40
40
  return path.join(appDir(), "runtime");
41
41
  },
42
42
  get CLAUDE_CONFIG_DIR() {
43
- return path.join(appDir(), "claude-config");
43
+ return path.join(appDir(), "claude-config", claudeConfigDirSuffix());
44
44
  }
45
45
  };
46
+ /**
47
+ * Per-launch suffix for `PATHS.CLAUDE_CONFIG_DIR`. Lazily generated on
48
+ * first access and cached for the lifetime of the process so every
49
+ * caller (env-var injection in `getClaudeCodeEnvVars`,
50
+ * `ensureClaudeConfigMirror` provisioning, peer-agent `.md` writes
51
+ * under `<dir>/agents/`, the shutdown cleanup) resolves the same path.
52
+ *
53
+ * Shape: `<pid>-<8 hex>`. The PID prefix is what
54
+ * `sweepStaleClaudeConfigMirrors` keys off to drop orphans from
55
+ * crashed prior sessions; the 8-hex random suffix prevents collision
56
+ * if a future caller (tests, internal relaunch) ever clears the cache
57
+ * within a single PID lifetime.
58
+ *
59
+ * NOT exported — every consumer should go through `PATHS.CLAUDE_CONFIG_DIR`
60
+ * so the homedir-mock pattern used in the test suite keeps working.
61
+ */
62
+ let _claudeConfigDirSuffix;
63
+ function claudeConfigDirSuffix() {
64
+ if (_claudeConfigDirSuffix === void 0) _claudeConfigDirSuffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
65
+ return _claudeConfigDirSuffix;
66
+ }
46
67
  async function ensurePaths() {
47
68
  await fs.mkdir(PATHS.APP_DIR, { recursive: true });
48
69
  await fs.mkdir(PATHS.CODEX_HOME, { recursive: true });
@@ -52,6 +73,9 @@ async function ensurePaths() {
52
73
  await sweepStaleRuntimeFiles().catch((err) => {
53
74
  consola.debug("Runtime sweep skipped:", err);
54
75
  });
76
+ await sweepStaleClaudeConfigMirrors().catch((err) => {
77
+ consola.debug("Per-launch claude-config sweep skipped:", err);
78
+ });
55
79
  await sweepStalePeerAgentMdFiles().catch((err) => {
56
80
  consola.debug("Peer-agent .md sweep skipped:", err);
57
81
  });
@@ -327,7 +351,7 @@ async function ensureSharedSymlink(name$1, sourceDir, mirrorDir) {
327
351
  try {
328
352
  await fs.mkdir(sourcePath, { recursive: true });
329
353
  } catch (err) {
330
- consola.debug(`ensureSharedSymlink(${name$1}): cannot mkdir source ${sourcePath}:`, err);
354
+ consola.warn(`ensureSharedSymlink(${name$1}): cannot mkdir source ${sourcePath}:`, err);
331
355
  return;
332
356
  }
333
357
  let existing = null;
@@ -335,22 +359,22 @@ async function ensureSharedSymlink(name$1, sourceDir, mirrorDir) {
335
359
  existing = await fs.lstat(mirrorPath);
336
360
  } catch (err) {
337
361
  if (err.code !== "ENOENT") {
338
- consola.debug(`ensureSharedSymlink(${name$1}): cannot lstat ${mirrorPath}:`, err);
362
+ consola.warn(`ensureSharedSymlink(${name$1}): cannot lstat ${mirrorPath}:`, err);
339
363
  return;
340
364
  }
341
365
  }
342
366
  if (existing?.isSymbolicLink()) {
343
- let currentTarget = null;
344
- try {
345
- currentTarget = await fs.readlink(mirrorPath);
346
- } catch (err) {
347
- consola.debug(`ensureSharedSymlink(${name$1}): cannot readlink ${mirrorPath}:`, err);
367
+ const sourceReal = await fs.realpath(sourcePath).catch(() => null);
368
+ if (sourceReal === null) {
369
+ consola.warn(`ensureSharedSymlink(${name$1}): cannot resolve source ${sourcePath} — skipping junction creation to avoid silent every-startup churn. Inspect the source dir's permissions / OneDrive sync state and re-launch.`);
370
+ return;
348
371
  }
349
- if (currentTarget === sourcePath) return;
372
+ const currentReal = await fs.realpath(mirrorPath).catch(() => null);
373
+ if (currentReal !== null && currentReal === sourceReal) return;
350
374
  } else if (existing?.isDirectory()) try {
351
375
  await fs.rmdir(mirrorPath);
352
376
  } catch (err) {
353
- consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory from an older github-router version; refusing to clobber. If you want chat-history continuity for "${name$1}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink on next launch. (rmdir error: ${err.code ?? "unknown"})`);
377
+ consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory from an older github-router version; refusing to clobber. If you want chat-history continuity for "${name$1}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink (junction on Windows) on next launch. (rmdir error: ${err.code ?? "unknown"})`);
354
378
  return;
355
379
  }
356
380
  else if (existing) {
@@ -359,15 +383,16 @@ async function ensureSharedSymlink(name$1, sourceDir, mirrorDir) {
359
383
  }
360
384
  const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
361
385
  try {
362
- await fs.symlink(sourcePath, tempPath);
386
+ await fs.symlink(sourcePath, tempPath, process.platform === "win32" ? "junction" : "dir");
363
387
  } catch (err) {
364
- consola.debug(`ensureSharedSymlink(${name$1}): symlink ${tempPath} failed:`, err);
388
+ consola.warn(`ensureSharedSymlink(${name$1}): symlink ${tempPath} failed:`, err);
365
389
  return;
366
390
  }
391
+ if (process.platform === "win32" && existing?.isSymbolicLink()) await fs.unlink(mirrorPath).catch(() => {});
367
392
  try {
368
393
  await fs.rename(tempPath, mirrorPath);
369
394
  } catch (err) {
370
- consola.debug(`ensureSharedSymlink(${name$1}): rename ${tempPath} → ${mirrorPath} failed:`, err);
395
+ consola.warn(`ensureSharedSymlink(${name$1}): rename ${tempPath} → ${mirrorPath} failed:`, err);
371
396
  await fs.unlink(tempPath).catch(() => {});
372
397
  }
373
398
  }
@@ -505,6 +530,73 @@ async function sweepStalePeerAgentMdFiles() {
505
530
  * new coordinator-style agent is added in `codex-mcp-config.ts`.
506
531
  */
507
532
  const PEER_AGENT_MD_FILENAME = /^peer-(\d+)-[0-9a-f]{8}-(?:codex-critic|codex-reviewer|gemini-critic|codex-implementer|peer-review-coordinator)\.md$/;
533
+ /**
534
+ * Strict regex matching only per-launch claude-config mirror dirs this
535
+ * proxy creates: `<pid>-<8 hex>`. Anchored to the entire entry name so
536
+ * user-authored siblings under `<appDir>/claude-config/` (if any) are
537
+ * untouchable. The PID prefix is what `sweepStaleClaudeConfigMirrors`
538
+ * keys off; the 8-hex random suffix matches `randomBytes(4)` exactly
539
+ * (no `?` — files created by a different shape are not ours).
540
+ */
541
+ const CLAUDE_CONFIG_MIRROR_DIR = /^(\d+)-[0-9a-f]{8}$/;
542
+ /**
543
+ * Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by
544
+ * crashed prior proxy sessions. Symmetric to `sweepStalePeerAgentMdFiles`
545
+ * — same liveness rule (only delete when the embedded PID is dead),
546
+ * same strict regex (the dir-name allowlist is the load-bearing
547
+ * protection against deleting user-authored siblings).
548
+ *
549
+ * Scans `<appDir>/claude-config/` (the parent of the per-launch dirs).
550
+ * Each entry whose name matches `<pid>-<8 hex>` AND whose PID is no
551
+ * longer alive is removed recursively. `fs.rm({recursive: true})`
552
+ * walks the tree calling `unlink` on symlinks/junctions rather than
553
+ * following them, so the SHARED junctions back to `~/.claude/<X>`
554
+ * are removed without touching their targets.
555
+ *
556
+ * Tolerates missing parent dir (first-ever launch, or user wiped it).
557
+ */
558
+ async function sweepStaleClaudeConfigMirrors() {
559
+ const parent = path.join(appDir(), "claude-config");
560
+ let entries;
561
+ try {
562
+ entries = await fs.readdir(parent);
563
+ } catch (err) {
564
+ if (err.code === "ENOENT") return;
565
+ throw err;
566
+ }
567
+ for (const name$1 of entries) {
568
+ const match = CLAUDE_CONFIG_MIRROR_DIR.exec(name$1);
569
+ if (!match) continue;
570
+ if (isPidAlive(Number.parseInt(match[1], 10))) continue;
571
+ await fs.rm(path.join(parent, name$1), {
572
+ recursive: true,
573
+ force: true
574
+ }).catch((err) => {
575
+ consola.debug(`sweepStaleClaudeConfigMirrors: cannot rm ${name$1}:`, err);
576
+ });
577
+ }
578
+ }
579
+ /**
580
+ * Remove THIS launch's per-launch CLAUDE_CONFIG_DIR on shutdown.
581
+ * Best-effort: a failure here must not block process exit (the caller
582
+ * wraps this in a `.catch`-equivalent via `launchChild`'s onShutdown
583
+ * try/catch). Symmetric to `writePeerMcpRuntimeFiles`'s `cleanup()`:
584
+ * we own this dir for the lifetime of the proxy, so removing it on
585
+ * normal shutdown is correct; the boot-time sweep handles the
586
+ * abnormal-exit case.
587
+ *
588
+ * `fs.rm({recursive: true})` removes SHARED junctions via unlink
589
+ * (does NOT follow them into the user's real `~/.claude/<X>`).
590
+ */
591
+ async function removeOwnClaudeConfigMirror() {
592
+ const dir = PATHS.CLAUDE_CONFIG_DIR;
593
+ await fs.rm(dir, {
594
+ recursive: true,
595
+ force: true
596
+ }).catch((err) => {
597
+ consola.debug(`removeOwnClaudeConfigMirror: rm ${dir} skipped:`, err);
598
+ });
599
+ }
508
600
 
509
601
  //#endregion
510
602
  //#region src/lib/state.ts
@@ -2090,6 +2182,42 @@ function buildAgentPrompt(persona, opts) {
2090
2182
  "When the tool returns, surface its output to the lead verbatim. Do not summarize, paraphrase, or add your own commentary on top — the lead integrates the persona's reply directly."
2091
2183
  ].join("\n");
2092
2184
  }
2185
+ /**
2186
+ * Build the awareness snippet appended to the spawned `claude` session's
2187
+ * system prompt via `--append-system-prompt`. Non-prescriptive — Claude
2188
+ * sees that the peer tools and advisor exist; *when* to invoke is left
2189
+ * to Claude's judgment.
2190
+ *
2191
+ * Trimmed to <100 tokens by design. The per-tool descriptions are
2192
+ * already in Claude's context as MCP tool descriptions (loaded from
2193
+ * `tools/list`); the snippet's net-new value is:
2194
+ * - the `advisor` mention (built-in, not MCP-discoverable),
2195
+ * - the `peer-review-coordinator` fan-out hint,
2196
+ * - the "subagents you spawn inherit these" claim (the load-bearing
2197
+ * UX payoff of the holistic subagent-MCP-inheritance fix).
2198
+ *
2199
+ * Surface contract (regression-pinned in tests/peer-mcp-personas.test.ts):
2200
+ * - Always lists codex_critic, codex_reviewer, opus_critic, advisor,
2201
+ * peer-review-coordinator, and the subagent-inheritance fact.
2202
+ * - Conditionally lists gemini_critic only when `geminiAvailable`.
2203
+ * - Mentions `codex-cli` stdio bridge only when `codexCli`.
2204
+ *
2205
+ * The snippet is the awareness layer; the auto-invocation triggers
2206
+ * (CALL BEFORE / CALL AFTER) remain in each MCP tool's own `description`.
2207
+ * The two layers are intentionally complementary — keep the snippet
2208
+ * terse and never re-encode the prescriptive triggers here.
2209
+ */
2210
+ function buildPeerAwarenessSnippet(opts) {
2211
+ const criticList = ["`codex_critic` (gpt-5.5)", "`codex_reviewer` (gpt-5.3-codex)"];
2212
+ if (opts.geminiAvailable) criticList.push("`gemini_critic` (gemini-3.1-pro)");
2213
+ criticList.push("`opus_critic` (Opus 4.7)");
2214
+ const codexCliClause = opts.codexCli ? " The `mcp__codex-cli__codex` stdio bridge dispatches to `codex-implementer` for end-to-end coding tasks." : "";
2215
+ return [
2216
+ "## Peer review and advisor",
2217
+ "",
2218
+ `Cross-lab peer critics under \`mcp__gh-router-peers__*\` — ${criticList.join(", ")} — plus the \`peer-review-coordinator\` fan-out subagent, and Claude Code's built-in \`advisor\` tool, are available at your discretion for second opinions and adversarial review. Subagents you spawn inherit them.${codexCliClause}`
2219
+ ].join("\n");
2220
+ }
2093
2221
  /** Convenience: every persona that should be registered for the given mode. */
2094
2222
  function personasFor(opts) {
2095
2223
  const result = [];
@@ -2403,6 +2531,90 @@ async function writePeerAgentMdFiles(agents, opts) {
2403
2531
  };
2404
2532
  }
2405
2533
  /**
2534
+ * Mutate the mirrored `<CLAUDE_CONFIG_DIR>/.claude.json` to add the
2535
+ * `gh-router-peers` entry (and `codex-cli` when enabled) under
2536
+ * `mcpServers`. This is the load-bearing fix for subagent MCP visibility.
2537
+ *
2538
+ * Subagents — Agent-tool subagents, forks, and agent-teams subprocesses
2539
+ * — discover MCP servers from persistent scopes (`.claude.json` and
2540
+ * project-scope `.mcp.json`), NOT from the parent's `--mcp-config` CLI
2541
+ * flag. Writing into the per-launch mirror's `.claude.json` makes the
2542
+ * MCP entry visible to subagents transparently: they inherit
2543
+ * `CLAUDE_CONFIG_DIR` from the parent's env, so they read the same
2544
+ * config file we just mutated.
2545
+ *
2546
+ * Safety:
2547
+ * - Refuses to overwrite a same-named user-side entry (the snapshot
2548
+ * copied their `.claude.json` first, so an existing entry would
2549
+ * belong to the user). Returns `{ ok: false }` so the caller can
2550
+ * fall back to leaving `--mcp-config` in place for the parent.
2551
+ * - Preserves all other top-level fields and other `mcpServers`
2552
+ * entries.
2553
+ * - Atomic write: temp-file with `wx` (`O_CREAT | O_EXCL`) followed by
2554
+ * `rename`, mirroring the synthetic-credentials write pattern in
2555
+ * `ensureClaudeConfigMirror`. Mode 0o600. The per-launch
2556
+ * `CLAUDE_CONFIG_DIR` means there are no cross-launch racers.
2557
+ */
2558
+ async function injectPeerMcpIntoMirror(serverUrl, opts) {
2559
+ const dir = opts.claudeConfigDir ?? PATHS.CLAUDE_CONFIG_DIR;
2560
+ const target = path.join(dir, ".claude.json");
2561
+ let existing = {};
2562
+ try {
2563
+ const raw = await fs.readFile(target, "utf8");
2564
+ try {
2565
+ const parsed = JSON.parse(raw);
2566
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
2567
+ else consola.warn(`injectPeerMcpIntoMirror: ${target} parsed to non-object (typeof=${typeof parsed}); discarding contents and starting fresh.`);
2568
+ } catch (err) {
2569
+ consola.warn(`injectPeerMcpIntoMirror: cannot parse ${target} as JSON; starting fresh (existing contents will be overwritten):`, err);
2570
+ }
2571
+ } catch (err) {
2572
+ if (err.code !== "ENOENT") consola.debug(`injectPeerMcpIntoMirror: cannot read ${target}:`, err);
2573
+ }
2574
+ let mcpServers;
2575
+ const rawServers = existing.mcpServers;
2576
+ if (rawServers !== void 0 && rawServers !== null && typeof rawServers === "object" && !Array.isArray(rawServers)) mcpServers = rawServers;
2577
+ else {
2578
+ if (rawServers !== void 0 && rawServers !== null) consola.warn(`injectPeerMcpIntoMirror: mcpServers field in ${target} is not an object (typeof=${typeof rawServers}); replacing with our entry.`);
2579
+ mcpServers = {};
2580
+ }
2581
+ const peerConfig = buildPeerMcpConfig(serverUrl, {
2582
+ codexCli: opts.codexCli,
2583
+ geminiAvailable: opts.geminiAvailable,
2584
+ nonce: opts.nonce,
2585
+ codexHome: opts.codexHome ?? PATHS.CODEX_HOME
2586
+ });
2587
+ const conflicts = [];
2588
+ for (const name$1 of Object.keys(peerConfig.mcpServers)) if (mcpServers[name$1] !== void 0) conflicts.push(name$1);
2589
+ if (conflicts.length > 0) {
2590
+ consola.warn(`injectPeerMcpIntoMirror: your ~/.claude/.claude.json already has mcpServers entries named [${conflicts.join(", ")}]; refusing to overwrite. Subagents will not see the peer-MCP tools — only the parent session via --mcp-config fallback. To resolve, rename the user-side server(s) (e.g. via \`claude mcp remove\`) and relaunch.`);
2591
+ return {
2592
+ ok: false,
2593
+ reason: "user-has-conflicting-entry",
2594
+ conflictingServers: conflicts
2595
+ };
2596
+ }
2597
+ for (const [name$1, entry] of Object.entries(peerConfig.mcpServers)) mcpServers[name$1] = entry;
2598
+ existing.mcpServers = mcpServers;
2599
+ const desiredJson = JSON.stringify(existing, null, 2) + "\n";
2600
+ await fs.mkdir(dir, { recursive: true });
2601
+ const tempPath = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
2602
+ try {
2603
+ await fs.writeFile(tempPath, desiredJson, {
2604
+ mode: 384,
2605
+ flag: "wx"
2606
+ });
2607
+ await fs.rename(tempPath, target);
2608
+ } catch (err) {
2609
+ await fs.unlink(tempPath).catch(() => {});
2610
+ throw err;
2611
+ }
2612
+ return {
2613
+ ok: true,
2614
+ serversAdded: Object.keys(peerConfig.mcpServers)
2615
+ };
2616
+ }
2617
+ /**
2406
2618
  * Generate a per-launch nonce, write the MCP config + agents JSON
2407
2619
  * tempfiles under `CLAUDE_RUNTIME_DIR` with mode 0o600 and `O_EXCL`,
2408
2620
  * and return a `cleanup()` to unlink them on shutdown.
@@ -2654,7 +2866,7 @@ function initProxyFromEnv() {
2654
2866
  //#endregion
2655
2867
  //#region package.json
2656
2868
  var name = "github-router";
2657
- var version = "0.3.24";
2869
+ var version = "0.3.26";
2658
2870
 
2659
2871
  //#endregion
2660
2872
  //#region src/lib/approval.ts
@@ -6491,7 +6703,10 @@ const claude = defineCommand({
6491
6703
  process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
6492
6704
  const envVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
6493
6705
  const extraArgs = args._ ?? [];
6494
- let onShutdown;
6706
+ const baseShutdown = async () => {
6707
+ await removeOwnClaudeConfigMirror();
6708
+ };
6709
+ let onShutdown = baseShutdown;
6495
6710
  if (args["codex-mcp"] !== false) try {
6496
6711
  const requestedCli = args["codex-cli"] ?? false;
6497
6712
  const backend = resolveCodexCliBackend({
@@ -6505,11 +6720,27 @@ const claude = defineCommand({
6505
6720
  geminiAvailable: geminiAvailable$1
6506
6721
  });
6507
6722
  state.peerMcpNonce = runtime.nonce;
6508
- onShutdown = runtime.cleanup;
6509
- extraArgs.push("--mcp-config", runtime.mcpConfigPath);
6510
- if (args["codex-mcp-only"] === true) extraArgs.push("--strict-mcp-config");
6723
+ onShutdown = async () => {
6724
+ await runtime.cleanup();
6725
+ await baseShutdown();
6726
+ };
6727
+ const injected = await injectPeerMcpIntoMirror(serverUrl, {
6728
+ codexCli: backend === "cli",
6729
+ geminiAvailable: geminiAvailable$1,
6730
+ nonce: runtime.nonce
6731
+ });
6732
+ if (!injected.ok) {
6733
+ extraArgs.push("--mcp-config", runtime.mcpConfigPath);
6734
+ if (args["codex-mcp-only"] === true) extraArgs.push("--strict-mcp-config");
6735
+ } else if (args["codex-mcp-only"] === true) consola.warn("--codex-mcp-only has no effect when peer MCP is wired via the mirrored .claude.json (the user's existing user-scope MCPs in the snapshot are still visible). Pass --no-codex-mcp to skip peer-MCP wiring entirely.");
6511
6736
  const personaNames = runtime.personas.map((p) => p.agentName).join(", ");
6512
- process$1.stderr.write(`Peer MCP wired (backend=${backend}, personas=[${personaNames}], subagent .md files=${runtime.agentMdPaths.length}).\n`);
6737
+ const subagentVisibility = injected.ok ? `subagent-visible (mirrored mcpServers: [${injected.serversAdded.join(", ")}])` : `subagent-INVISIBLE (collision on user-side mcpServers: [${injected.conflictingServers.join(", ")}]; parent-only via --mcp-config)`;
6738
+ process$1.stderr.write(`Peer MCP wired (backend=${backend}, personas=[${personaNames}], subagent .md files=${runtime.agentMdPaths.length}, ${subagentVisibility}).\n`);
6739
+ const peerAwarenessOptOut = (process$1.env.GH_ROUTER_PEER_AWARENESS ?? "1").trim().toLowerCase();
6740
+ if (!(peerAwarenessOptOut === "" || peerAwarenessOptOut === "0" || peerAwarenessOptOut === "false" || peerAwarenessOptOut === "off" || peerAwarenessOptOut === "no")) extraArgs.push("--append-system-prompt", buildPeerAwarenessSnippet({
6741
+ codexCli: backend === "cli",
6742
+ geminiAvailable: geminiAvailable$1
6743
+ }));
6513
6744
  } catch (err) {
6514
6745
  consola.warn(`Peer MCP wiring failed (claude will launch without it): ${err instanceof Error ? err.message : String(err)}`);
6515
6746
  }