diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c25af880a..7561f40da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,6 +26,7 @@ ### Fixed - Fixed inconsistent history rendering when toggling the display setting for compacted items +- Fixed discovered plugin `.mcp.json` stdio servers launching relative `command`/`cwd` values against the session cwd instead of the plugin's config directory, which broke bundled ChatGPT/Codex plugins such as Computer Use (`ENOENT` spawning `./…` from an unrelated cwd). Relative `cwd` and path-like `command` (`./`, `../`) discovered by the `claude-plugins`/`omp-plugins` providers now resolve against the `.mcp.json` directory; bare executables like `npx` are left untouched. ([#5330](https://github.com/can1357/oh-my-pi/issues/5330)) - Fixed configured `retry.fallbackChains` never engaging on non-retryable provider errors (e.g. "Cloud Code Assist API returned an empty response"): a hard error on a model covered by a fallback chain now switches to the next candidate instead of failing the turn, while still never backoff-retrying the failing model itself - Fixed transcript rebuilds (compaction, `/compact`, and toggling history display) repainting content below stale scrollback when collapsing history; rebuilds now correctly clear the scrollback buffer when history is collapsed - Improved auto-compaction to automatically drop images and elide content when context is tight, and added persistent warning badges to the compaction divider when manual intervention is required diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index 3bb09292d..6e0fd0024 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -24,7 +24,7 @@ import { scanSkillsFromDir, } from "./helpers"; -import { substitutePluginRoot } from "./substitute-plugin-root"; +import { resolvePluginStdioPaths, substitutePluginRoot } from "./substitute-plugin-root"; const PROVIDER_ID = "claude-plugins"; const DISPLAY_NAME = "Claude Code Marketplace"; @@ -441,14 +441,20 @@ async function loadMCPServers(ctx: LoadContext): Promise> continue; } const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName; + const substitutedCommand = + raw.command !== undefined ? substitutePluginRoot(raw.command, root.path) : undefined; + const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined; + // Root relative command/cwd at the plugin's config directory, not the + // session cwd (MCP stdio spawning resolves relative values there). + const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path); const server: MCPServer = { name: namespacedName, ...(raw.enabled !== undefined && { enabled: raw.enabled }), ...(raw.timeout !== undefined && { timeout: raw.timeout }), - ...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }), + ...(rooted.command !== undefined && { command: rooted.command }), ...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }), ...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }), - ...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }), + ...(rooted.cwd !== undefined && { cwd: rooted.cwd }), ...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }), ...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }), ...(raw.auth !== undefined && { auth: raw.auth }), diff --git a/packages/coding-agent/src/discovery/omp-plugins.ts b/packages/coding-agent/src/discovery/omp-plugins.ts index 183aa1f41..cbf307bd5 100644 --- a/packages/coding-agent/src/discovery/omp-plugins.ts +++ b/packages/coding-agent/src/discovery/omp-plugins.ts @@ -30,6 +30,7 @@ import { type CustomTool, toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers"; import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots"; +import { resolvePluginStdioPaths } from "./substitute-plugin-root"; const PROVIDER_ID = "omp-plugins"; const DISPLAY_NAME = "OMP Extension Packages"; @@ -301,14 +302,17 @@ async function loadMCPServers(ctx: LoadContext): Promise> warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`); continue; } + // Root relative command/cwd at the plugin's config directory, not the + // session cwd (MCP stdio spawning resolves relative values there). + const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path); items.push({ name: serverName, ...(cfg.enabled !== undefined && { enabled: cfg.enabled }), ...(cfg.timeout !== undefined && { timeout: cfg.timeout }), - ...(cfg.command !== undefined && { command: cfg.command }), + ...(rooted.command !== undefined && { command: rooted.command }), ...(cfg.args !== undefined && { args: cfg.args }), ...(cfg.env !== undefined && { env: cfg.env }), - ...(cfg.cwd !== undefined && { cwd: cfg.cwd }), + ...(rooted.cwd !== undefined && { cwd: rooted.cwd }), ...(cfg.url !== undefined && { url: cfg.url }), ...(cfg.headers !== undefined && { headers: cfg.headers }), ...(cfg.auth !== undefined && { auth: cfg.auth }), diff --git a/packages/coding-agent/src/discovery/substitute-plugin-root.ts b/packages/coding-agent/src/discovery/substitute-plugin-root.ts index e6b5cef9c..4a97c6061 100644 --- a/packages/coding-agent/src/discovery/substitute-plugin-root.ts +++ b/packages/coding-agent/src/discovery/substitute-plugin-root.ts @@ -1,3 +1,5 @@ +import * as path from "node:path"; + /** * Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT} * with the actual plugin root path in strings, arrays, and plain objects. @@ -27,3 +29,33 @@ export function substitutePluginRoot(value: T, rootPath: string): T { } return value; } + +/** + * Rebase relative filesystem values in a discovered plugin stdio config against + * the directory of the `.mcp.json` that declared them. + * + * External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace + * plugins) express `command`/`cwd` relative to their own config file, but MCP + * stdio spawning roots relative values at the session cwd — so a plugin shipping + * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and + * fails with ENOENT. This resolves those against `configDir` instead: + * + * - relative `cwd` → resolved against `configDir`; + * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) → + * resolved against `configDir`; + * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched. + */ +export function resolvePluginStdioPaths( + config: { command?: string; cwd?: string }, + configDir: string, +): { command?: string; cwd?: string } { + const resolved: { command?: string; cwd?: string } = {}; + if (config.cwd !== undefined) { + resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd); + } + if (config.command !== undefined) { + const isPathLike = /^\.\.?[/\\]/.test(config.command); + resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command; + } + return resolved; +} diff --git a/packages/coding-agent/src/prompts/system/tan-context-switch.md b/packages/coding-agent/src/prompts/system/tan-context-switch.md index 55468b15a..88cd57291 100644 --- a/packages/coding-agent/src/prompts/system/tan-context-switch.md +++ b/packages/coding-agent/src/prompts/system/tan-context-switch.md @@ -1,5 +1,5 @@ -The conversation above belongs to your parent session. +The conversation above belongs to your parent session. You are a fork created solely to handle the user's request below. Your parent agent is still working on the original task — that responsibility is diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index 371c2e039..dd9fe301e 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -453,6 +453,53 @@ describe("listClaudePluginRoots", () => { } }); + test("resolves relative path-like command and cwd against the plugin config directory", async () => { + const pluginsDir = path.join(tempDir, ".claude", "plugins"); + const pluginPath = path.join(tempDir, "plugins", "computer-use"); + await fs.mkdir(pluginsDir, { recursive: true }); + await fs.mkdir(pluginPath, { recursive: true }); + await fs.writeFile( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + "computer-use@openai-bundled": [ + { + scope: "user", + installPath: pluginPath, + version: "1.0.0", + installedAt: "2026-06-01T00:00:00Z", + lastUpdated: "2026-06-01T00:00:00Z", + }, + ], + }, + }), + ); + await fs.writeFile( + path.join(pluginPath, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "computer-use": { command: "./bin/SkyComputerUseClient", args: ["mcp"], cwd: "." }, + bare: { command: "npx", args: ["-y", "@some/mcp"] }, + }, + }), + ); + + // Session cwd is deliberately outside the plugin directory. + const result = await loadCapability(mcpCapability.id, { + cwd: path.join(tempDir, "elsewhere"), + providers: ["claude-plugins"], + }); + const local = result.all.find(item => item.name === "computer-use:computer-use"); + const bare = result.all.find(item => item.name === "computer-use:bare"); + + expect(local?.command).toBe(path.join(pluginPath, "bin", "SkyComputerUseClient")); + expect(local?.cwd).toBe(pluginPath); + // Bare executables must keep resolving through PATH, not the plugin dir. + expect(bare?.command).toBe("npx"); + expect(bare?.cwd).toBeUndefined(); + }); + test("reads slash commands directory from plugin manifest slash-commands field", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "manifest-commands"); diff --git a/packages/coding-agent/test/discovery/omp-plugins.test.ts b/packages/coding-agent/test/discovery/omp-plugins.test.ts index 129589148..cfe26292e 100644 --- a/packages/coding-agent/test/discovery/omp-plugins.test.ts +++ b/packages/coding-agent/test/discovery/omp-plugins.test.ts @@ -188,6 +188,29 @@ test(".mcp.json with bare entries (no command/url) records a warning and is skip expect((result.warnings ?? []).some(w => w.includes('"broken"'))).toBe(true); }); +test("relative path-like command and cwd resolve against the plugin config directory", async () => { + writeFile( + path.join(ext, ".mcp.json"), + JSON.stringify({ + mcpServers: { + local: { command: "./bin/server", args: ["mcp"], cwd: "." }, + bare: { command: "npx", args: ["-y", "@some/mcp"] }, + }, + }), + ); + writeFile(path.join(project, ".omp", "settings.json"), JSON.stringify({ extensions: [ext] })); + + const servers = await loadFromPlugin<{ name: string; command?: string; cwd?: string }>(mcpCapability.id, ctx()); + const local = servers.find(s => s.name === "local"); + const bare = servers.find(s => s.name === "bare"); + // Path-like command and "." cwd rebase onto the .mcp.json directory (ext), + // not the session cwd (project). Bare executables are left untouched. + expect(local?.command).toBe(path.join(ext, "bin", "server")); + expect(local?.cwd).toBe(ext); + expect(bare?.command).toBe("npx"); + expect(bare?.cwd).toBeUndefined(); +}); + test("installed plugins under `/node_modules/` are surfaced (e.g. via `omp plugin link`/`install`)", async () => { // Simulate what `plugin install` / `plugin link` produces: a plugins root // with `package.json#dependencies` and a populated `node_modules//`.