github-router 0.3.25 → 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 +237 -7
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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
|
});
|
|
@@ -506,6 +530,73 @@ async function sweepStalePeerAgentMdFiles() {
|
|
|
506
530
|
* new coordinator-style agent is added in `codex-mcp-config.ts`.
|
|
507
531
|
*/
|
|
508
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
|
+
}
|
|
509
600
|
|
|
510
601
|
//#endregion
|
|
511
602
|
//#region src/lib/state.ts
|
|
@@ -2091,6 +2182,42 @@ function buildAgentPrompt(persona, opts) {
|
|
|
2091
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."
|
|
2092
2183
|
].join("\n");
|
|
2093
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
|
+
}
|
|
2094
2221
|
/** Convenience: every persona that should be registered for the given mode. */
|
|
2095
2222
|
function personasFor(opts) {
|
|
2096
2223
|
const result = [];
|
|
@@ -2404,6 +2531,90 @@ async function writePeerAgentMdFiles(agents, opts) {
|
|
|
2404
2531
|
};
|
|
2405
2532
|
}
|
|
2406
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
|
+
/**
|
|
2407
2618
|
* Generate a per-launch nonce, write the MCP config + agents JSON
|
|
2408
2619
|
* tempfiles under `CLAUDE_RUNTIME_DIR` with mode 0o600 and `O_EXCL`,
|
|
2409
2620
|
* and return a `cleanup()` to unlink them on shutdown.
|
|
@@ -2655,7 +2866,7 @@ function initProxyFromEnv() {
|
|
|
2655
2866
|
//#endregion
|
|
2656
2867
|
//#region package.json
|
|
2657
2868
|
var name = "github-router";
|
|
2658
|
-
var version = "0.3.
|
|
2869
|
+
var version = "0.3.26";
|
|
2659
2870
|
|
|
2660
2871
|
//#endregion
|
|
2661
2872
|
//#region src/lib/approval.ts
|
|
@@ -6492,7 +6703,10 @@ const claude = defineCommand({
|
|
|
6492
6703
|
process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
|
|
6493
6704
|
const envVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
|
|
6494
6705
|
const extraArgs = args._ ?? [];
|
|
6495
|
-
|
|
6706
|
+
const baseShutdown = async () => {
|
|
6707
|
+
await removeOwnClaudeConfigMirror();
|
|
6708
|
+
};
|
|
6709
|
+
let onShutdown = baseShutdown;
|
|
6496
6710
|
if (args["codex-mcp"] !== false) try {
|
|
6497
6711
|
const requestedCli = args["codex-cli"] ?? false;
|
|
6498
6712
|
const backend = resolveCodexCliBackend({
|
|
@@ -6506,11 +6720,27 @@ const claude = defineCommand({
|
|
|
6506
6720
|
geminiAvailable: geminiAvailable$1
|
|
6507
6721
|
});
|
|
6508
6722
|
state.peerMcpNonce = runtime.nonce;
|
|
6509
|
-
onShutdown =
|
|
6510
|
-
|
|
6511
|
-
|
|
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.");
|
|
6512
6736
|
const personaNames = runtime.personas.map((p) => p.agentName).join(", ");
|
|
6513
|
-
|
|
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
|
+
}));
|
|
6514
6744
|
} catch (err) {
|
|
6515
6745
|
consola.warn(`Peer MCP wiring failed (claude will launch without it): ${err instanceof Error ? err.message : String(err)}`);
|
|
6516
6746
|
}
|