[eric] auto-heal corrupt npx cache in MCP stdio discovery: on ERR_MODULE_NOT_FOUND pointing into ~/.npm/_npx/<hash>/, wipe

that dir and retry once — fixes Discord/YouTube failing with "MCP stdio process exited unexpectedly" after partial npx installs,
  per-browser auto-close: spawned browser cards now disappear on their own sub-agent's completion instead of lingering until the parent agent
  finishes, WebSocketManager agent:status matches by session.browser_id (spawned_by is the parent, so it never matched the finishing
  sub-agent), user-created/pre-selected browsers untouched
This commit is contained in:
ciregenz
2026-04-20 01:20:20 -07:00
parent dfc2f63488
commit 301b0f7707
4 changed files with 58 additions and 18 deletions
@@ -20,12 +20,21 @@ export function claudeToOpenAIRequest(model, body, stream) {
result.temperature = body.temperature;
}
// System message
// System message — strip the claude-agent-sdk's "You are Claude Code..."
// identity sentence before forwarding to non-Anthropic providers. Without
// this, Gemini/Codex/etc receive the Claude Code preset's first line as
// gospel and dutifully introduce themselves as Claude. The rest of the
// preset (tool conventions, scaffolding) stays intact.
if (body.system) {
const systemContent = Array.isArray(body.system)
let systemContent = Array.isArray(body.system)
? body.system.map(s => s.text || "").join("\n")
: body.system;
systemContent = systemContent.replace(
/You are Claude Code, Anthropic's official CLI for Claude\.\s*/,
""
);
if (systemContent) {
result.messages.push({
role: "system",
+35 -3
View File
@@ -716,8 +716,38 @@ async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list
raise HTTPException(status_code=502, detail=f"SSE discovery failed: {first}") from first
async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, env: dict | None = None) -> list[dict]:
"""Spawn a stdio MCP server process and call tools/list via JSON-RPC over stdin/stdout."""
_NPX_CACHE_RE = re.compile(r"_npx[/\\]([0-9a-f]{8,})[/\\]")
def _try_heal_npx_cache(stderr: str) -> str | None:
"""On `ERR_MODULE_NOT_FOUND` pointing into `~/.npm/_npx/<hash>/`, wipe that one dir.
Why: interrupted npx installs leave a `package-lock.json` in the cache dir so
subsequent spawns reuse a partially-extracted node_modules tree, which dies at
import time. Scoped strictly to the extracted hash subdir — never touches
anything outside `~/.npm/_npx/`.
"""
if "ERR_MODULE_NOT_FOUND" not in stderr:
return None
m = _NPX_CACHE_RE.search(stderr)
if not m:
return None
hash_ = m.group(1)
cache_dir = os.path.join(os.path.expanduser("~"), ".npm", "_npx", hash_)
if not os.path.isdir(cache_dir):
return None
logger.warning("Corrupted npx cache detected at %s; wiping and letting caller retry", cache_dir)
shutil.rmtree(cache_dir, ignore_errors=True)
return hash_
async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, env: dict | None = None, _attempt: int = 0) -> list[dict]:
"""Spawn a stdio MCP server process and call tools/list via JSON-RPC over stdin/stdout.
On the first attempt, a failure that looks like corrupted npx cache
(`ERR_MODULE_NOT_FOUND` pointing into `~/.npm/_npx/<hash>/`) triggers one
auto-heal + retry. No heal on `_attempt >= 1`.
"""
cmd_path = _resolve_command(command)
if not cmd_path:
raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations")
@@ -782,7 +812,9 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
tools_list = data.get("result", {}).get("tools", [])
return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
except HTTPException:
except HTTPException as e:
if _attempt == 0 and _try_heal_npx_cache(str(e.detail) if e.detail is not None else ""):
return await _discover_mcp_tools_stdio(command, args, env, _attempt=1)
raise
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="MCP stdio server timed out during discovery")
+10 -11
View File
@@ -129,17 +129,16 @@ class WebSocketManager {
if (data.status === 'running' && session_id) {
store.dispatch(trackAgentNotification(session_id));
}
// Auto-remove browsers spawned by this agent when it reaches a
// terminal state on its own. agent:closed only fires when the user
// clicks X to close the session, so without this hook the browser
// cards would linger after natural completion or error. 'stopped'
// is intentionally skipped to preserve the "inspect after manual
// stop" affordance — matching the agent:closed branch below.
if (session_id && (data.status === 'completed' || data.status === 'error')) {
const browserCards = store.getState().dashboardLayout.browserCards;
for (const card of Object.values(browserCards)) {
if (card.spawned_by === session_id) {
store.dispatch(removeBrowserCard(card.browser_id));
// Per-sub-agent close via browser_id; skip user-created cards (no spawned_by).
if (
(data.status === 'completed' || data.status === 'error') &&
data.session?.mode === 'browser-agent'
) {
const browserId = data.session.browser_id;
if (browserId) {
const card = store.getState().dashboardLayout.browserCards[browserId];
if (card && card.spawned_by) {
store.dispatch(removeBrowserCard(browserId));
}
}
}
File diff suppressed because one or more lines are too long