From 1c4b4c40a7f8a8d0c9f4fec9eb71cc12ca505f93 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 7 Aug 2026 01:56:33 +0530 Subject: [PATCH] refactor(codex): run host-installed codex CLI instead of bundled SDK --- README.md | 15 ++ docs/configuration.md | 1 + package-lock.json | 139 +-------------- package.json | 1 - skills/subagent-delegation/SKILL.md | 8 + src/cli.ts | 8 + src/local-agent-adapters.test.ts | 23 +++ src/local-agent-adapters.ts | 19 +- src/local-agent-availability.test.ts | 37 +++- src/local-agent-availability.ts | 41 ++++- src/local-agent-codex.ts | 81 +++++++++ src/local-agent-runtime.test.ts | 223 ++++++++++++++++-------- src/local-agent-runtime.ts | 252 +++++++++++++++++++++------ src/server.test.ts | 55 ++++++ src/server.ts | 10 +- src/ui/card-types.ts | 2 + src/ui/workspace-app.tsx | 9 +- 17 files changed, 655 insertions(+), 269 deletions(-) create mode 100644 src/local-agent-codex.ts diff --git a/README.md b/README.md index 26853cfa..b788f298 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,21 @@ DevSpace gives ChatGPT tools to: - discover local agent skills from your skill folders - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts +## DevSpace Subagents + +With `DEVSPACE_SUBAGENTS=1`, DevSpace can delegate bounded coding work to +subagent workers backed by your locally installed agent CLIs (`codex`, +`claude`, `opencode`, `pi`, `cursor-agent`, `copilot`). DevSpace runs each +provider's host-installed binary; it bundles no copies of them. + +For the Codex provider, DevSpace executes your PATH `codex` binary, or the one +given by the `CODEX_COMMAND` environment override. It reports the detected Codex +CLI version and a minimum supported version (0.142.5) wherever provider +availability is shown (`open_workspace`, the `devspace serve` banner, and +`devspace agents` output), and stamps failed runs with the Codex version and raw +CLI stderr. Sessions persist in your normal `~/.codex/sessions`, so follow-ups +resume work the same way your own `codex` session would. + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/configuration.md b/docs/configuration.md index 3502a98b..f2fde130 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,6 +127,7 @@ sessions. | `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | +| `CODEX_COMMAND` | Optional absolute path to the `codex` binary used for the Codex subagent provider, mirroring `CLAUDE_COMMAND` and `PI_COMMAND`. Defaults to `codex` on `PATH`. | DevSpace discovers standard Agent Skills from: diff --git a/package-lock.json b/package-lock.json index 03bf3688..7fbc10c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", @@ -752,7 +751,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1057,7 +1056,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.3", + "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" @@ -2643,140 +2642,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@openai/codex": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", - "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "license": "Apache-2.0", - "bin": { - "codex": "bin/codex.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" - } - }, - "node_modules/@openai/codex-darwin-arm64": { - "name": "@openai/codex", - "version": "0.142.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", - "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-darwin-x64": { - "name": "@openai/codex", - "version": "0.142.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", - "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-linux-arm64": { - "name": "@openai/codex", - "version": "0.142.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", - "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-linux-x64": { - "name": "@openai/codex", - "version": "0.142.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", - "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-sdk": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.142.5.tgz", - "integrity": "sha512-MConZ+eoBoZmkc4reezuzOgLtoI1BQBzo/nVYsSjtAIBpwKcgeEm1rfmqfUnTfFaBNHFTxBntcS7ZeQYuDPbWA==", - "license": "Apache-2.0", - "dependencies": { - "@openai/codex": "0.142.5" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@openai/codex-win32-arm64": { - "name": "@openai/codex", - "version": "0.142.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", - "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-win32-x64": { - "name": "@openai/codex", - "version": "0.142.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", - "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, "node_modules/@opencode-ai/sdk": { "version": "1.17.13", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.13.tgz", diff --git a/package.json b/package.json index 5d4a7faf..3c045dd8 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index fb269df5..985180a2 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -41,6 +41,14 @@ Do not run provider CLIs such as `codex`, `claude`, `opencode`, `pi`, `cursor-agent`, or `copilot` directly unless you are explicitly debugging DevSpace agent integration. +DevSpace runs each provider's host-installed CLI; it does not bundle hidden +copies. For the Codex provider, DevSpace executes your PATH `codex` (or the +binary at `CODEX_COMMAND`) and reports the detected CLI version and the minimum +supported version in `open_workspace` and the availability summary. If a run +fails, the session error includes the detected Codex version and the CLI's raw +stderr, so a model gate keyed on CLI version can be diagnosed without digging +into process internals. + ## Choosing a profile Choose profiles from the compact subagent profile catalog returned by diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63f..4d25a3af 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; import { formatAvailableLocalAgentTargets, @@ -349,6 +350,13 @@ async function runAgentsCommand(args: string[]): Promise { async function runAgentsList(): Promise { const config = loadConfig(); + if (config.subagents) { + console.log( + `subagent providers: ${formatLocalAgentProviderAvailabilitySummary( + getLocalAgentProviderAvailabilitySnapshot(), + )}`, + ); + } const store = createLocalAgentStore(config); const agents = store.list(resolveCurrentWorkspaceScope()); diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 0e4cbf0f..59336320 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -11,6 +11,7 @@ import { resolveAcpModelConfigUpdate, resolveAcpThinkingConfigUpdate, } from "./local-agent-adapters.js"; +import { codexCommandEnvironment } from "./local-agent-codex.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -388,3 +389,25 @@ assert.equal( assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter)); } + +{ + const devspaceBin = `${process.cwd()}/node_modules/.bin`; + const userBin = "/home/user/.local/bin"; + const env = codexCommandEnvironment({ + CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "devspace", + PATH: [devspaceBin, userBin].join(delimiter), + }); + + assert.equal(env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, undefined); + assert.equal(env.PATH, userBin); +} + +{ + const devspaceBin = `${process.cwd()}/node_modules/.bin`; + const env = codexCommandEnvironment({ + CODEX_COMMAND: "/custom/codex", + PATH: [devspaceBin, "/home/user/.local/bin"].join(delimiter), + }); + + assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter)); +} diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 457b8e08..f5845086 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -3,9 +3,13 @@ import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + codexCommandEnvironment, + resolveCodexCommand, +} from "./local-agent-codex.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { - createCodexSdkLocalAgentRuntime, + CodexCliLocalAgentRuntime, type LocalAgentRunInput, type LocalAgentRunResult, } from "./local-agent-runtime.js"; @@ -48,7 +52,18 @@ class CodexLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "codex" as const; async run(input: LocalAgentRunInput): Promise { - const runtime = await createCodexSdkLocalAgentRuntime(); + const env = codexCommandEnvironment(process.env); + const resolved = resolveCodexCommand(env); + if (!resolved) { + throw new Error( + "codex provider is not available: codex executable not found. Install codex or set CODEX_COMMAND.", + ); + } + const runtime = new CodexCliLocalAgentRuntime({ + command: resolved.executable, + env, + version: resolved.version, + }); return runtime.run(input); } } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 5d56697c..bcd811d9 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -1,11 +1,42 @@ import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { checkLocalAgentProviderAvailability, formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; -assert.equal(checkLocalAgentProviderAvailability("codex").available, true); +const availableCodex = checkLocalAgentProviderAvailability("codex"); +assert.equal(availableCodex.available, true); +assert.match(availableCodex.version ?? "", /^\d+\.\d+/); +assert.equal(availableCodex.minimumVersion, "0.142.5"); + +{ + const availability = checkLocalAgentProviderAvailability("codex", { + ...process.env, + CODEX_COMMAND: "/definitely/missing/devspace-codex", + }); + assert.equal(availability.available, false); + assert.match(availability.reason ?? "", /executable not found/); +} + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-codex-test-")); + const oldCodex = join(directory, "codex"); + writeFileSync(oldCodex, "#!/bin/sh\necho 'codex-cli 0.130.0'\n", { mode: 0o755 }); + chmodSync(oldCodex, 0o755); + + const availability = checkLocalAgentProviderAvailability("codex", { + ...process.env, + CODEX_COMMAND: oldCodex, + }); + assert.equal(availability.available, false); + assert.equal(availability.version, "0.130.0"); + assert.equal(availability.minimumVersion, "0.142.5"); + assert.match(availability.reason ?? "", /below the minimum supported version 0\.142\.5/); +} { const availability = checkLocalAgentProviderAvailability("pi", { @@ -30,8 +61,8 @@ assert.equal(checkLocalAgentProviderAvailability("codex").available, true); assert.equal( formatLocalAgentProviderAvailabilitySummary([ - { name: "codex", available: true }, + { name: "codex", available: true, version: "0.147.0", minimumVersion: "0.142.5" }, { name: "pi", available: false, reason: "pi executable not found" }, ]), - "available: codex; unavailable: pi (pi executable not found)", + "available: codex (0.147.0, min 0.142.5); unavailable: pi (pi executable not found)", ); diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 747f304f..e38753f3 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,5 +1,10 @@ import { spawnSync } from "node:child_process"; import { delimiter, resolve } from "node:path"; +import { + isCodexVersionAtLeastFloor, + MINIMUM_CODEX_VERSION, + resolveCodexCommand, +} from "./local-agent-codex.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { LOCAL_AGENT_PROVIDERS, @@ -10,6 +15,8 @@ export interface LocalAgentProviderAvailability { name: LocalAgentProvider; available: boolean; reason?: string; + version?: string; + minimumVersion?: string; } export function getLocalAgentProviderAvailabilitySnapshot( @@ -24,7 +31,7 @@ export function checkLocalAgentProviderAvailability( ): LocalAgentProviderAvailability { switch (provider) { case "codex": - return packageAvailability(provider, "@openai/codex-sdk"); + return codexAvailability(env); case "claude": return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); case "opencode": @@ -56,7 +63,7 @@ export function formatLocalAgentProviderAvailabilitySummary( ): string { const available = providers .filter((provider) => provider.available) - .map((provider) => provider.name); + .map(formatProviderWithVersion); const unavailable = providers .filter((provider) => !provider.available) .map((provider) => `${provider.name} (${provider.reason ?? "unavailable"})`); @@ -66,6 +73,36 @@ export function formatLocalAgentProviderAvailabilitySummary( ].filter(Boolean).join("; "); } +function formatProviderWithVersion(provider: LocalAgentProviderAvailability): string { + if (!provider.version) return provider.name; + const floor = provider.minimumVersion ? `, min ${provider.minimumVersion}` : ""; + return `${provider.name} (${provider.version}${floor})`; +} + +function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { + const resolved = resolveCodexCommand(env); + if (!resolved) { + return { + name: "codex", + available: false, + reason: `codex executable not found`, + }; + } + const base = { + name: "codex" as const, + version: resolved.version, + minimumVersion: MINIMUM_CODEX_VERSION, + }; + if (resolved.version && !isCodexVersionAtLeastFloor(resolved.version)) { + return { + ...base, + available: false, + reason: `codex ${resolved.version} is below the minimum supported version ${MINIMUM_CODEX_VERSION}; upgrade codex or set CODEX_COMMAND`, + }; + } + return { ...base, available: true }; +} + function packageAvailability( provider: LocalAgentProvider, packageName: string, diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts new file mode 100644 index 00000000..f17db7ed --- /dev/null +++ b/src/local-agent-codex.ts @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; +import { delimiter, resolve } from "node:path"; +import { lt } from "semver"; +import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; + +export const MINIMUM_CODEX_VERSION = "0.142.5"; + +export interface ResolvedCodexCommand { + executable: string; + version?: string; +} + +// Mirror the Pi/Claude adapters: strip DevSpace's own entrypoint markers from +// the child environment and, unless the caller pinned a command, keep DevSpace's +// own `node_modules/.bin` from shadowing the host-installed `codex` on PATH. +export function codexCommandEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const next = { ...env }; + delete next.CODEX_INTERNAL_ORIGINATOR_OVERRIDE; + if (env.CODEX_COMMAND) return next; + const path = next.PATH; + if (!path) return next; + return { + ...next, + PATH: removeDevspaceNodeModulesBinFromPath(path), + }; +} + +export function resolveCodexCommand( + env: NodeJS.ProcessEnv = process.env, +): ResolvedCodexCommand | undefined { + const probeEnv = codexCommandEnvironment(env); + const command = env.CODEX_COMMAND ?? "codex"; + for (const candidate of codexCommandCandidates(command, probeEnv)) { + const probe = spawnSync(candidate, ["--version"], { + encoding: "utf8", + env: probeEnv, + windowsHide: true, + timeout: 5_000, + }); + const spawnCode = + probe.error && "code" in probe.error ? probe.error.code : undefined; + if (spawnCode === "ENOENT") continue; + return { + executable: candidate, + version: parseCodexVersion(probe.stdout), + }; + } + return undefined; +} + +export function isCodexVersionAtLeastFloor(version: string): boolean { + return !lt(version, MINIMUM_CODEX_VERSION); +} + +export function parseCodexVersion(output: string | undefined): string | undefined { + if (!output) return undefined; + const match = output + .trim() + .match(/v?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)/); + const version = match?.[1]; + return version ?? undefined; +} + +function codexCommandCandidates(command: string, env: NodeJS.ProcessEnv): string[] { + const hasPath = command.includes("/") || command.includes("\\"); + if (hasPath) return [command]; + const pathValue = env.PATH; + if (!pathValue) return [command]; + const extensions = + process.platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) + : [""]; + const candidates: string[] = []; + for (const directory of pathValue.split(delimiter)) { + if (!directory) continue; + for (const extension of extensions) { + candidates.push(resolve(directory, `${command}${extension}`)); + } + } + return candidates; +} \ No newline at end of file diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 1d45d166..785fa15b 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -1,100 +1,183 @@ import assert from "node:assert/strict"; -import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; import { - CodexSdkLocalAgentRuntime, - createCodexSdkLocalAgentRuntime, + codexCliArguments, + codexCliError, + CodexCliLocalAgentRuntime, + createCodexCliLocalAgentRuntime, + parseCodexCliLines, + type CodexCliInvocation, + type CodexCliRunner, } from "./local-agent-runtime.js"; -const emptyTurn = (finalResponse: string): RunResult => ({ - finalResponse, - items: [], - usage: null, -}); +const startedArgs = (writeMode: "read_only" | "allowed" | "full_access" | undefined = undefined) => + codexCliArguments({ + prompt: "inspect only", + workspace: "/tmp/project", + writeMode, + }); -class FakeThread { - prompts: string[] = []; +assert.deepEqual(startedArgs(), [ + "exec", + "--experimental-json", + "--config", + 'approval_policy="never"', + "--sandbox", + "read-only", + "--cd", + "/tmp/project", +]); - constructor(readonly id: string | null) {} +assert.deepEqual(codexCliArguments({ + prompt: "make change", + workspace: "/tmp/project", + writeMode: "allowed", + model: "gpt-5.4", + thinking: "high", +}), [ + "exec", + "--experimental-json", + "--model", + "gpt-5.4", + "--config", + 'model_reasoning_effort="high"', + "--config", + 'approval_policy="never"', + "--sandbox", + "workspace-write", + "--cd", + "/tmp/project", +]); - async run(prompt: string): Promise { - this.prompts.push(prompt); - return emptyTurn(`response:${prompt}`); - } -} +assert.deepEqual(codexCliArguments({ + prompt: "make change", + workspace: "/tmp/project", + writeMode: "full_access", + thinking: "max", +}), [ + "exec", + "--experimental-json", + "--config", + 'model_reasoning_effort="max"', + "--config", + 'approval_policy="never"', + "--sandbox", + "danger-full-access", + "--cd", + "/tmp/project", +]); -class FakeCodex { - started: ThreadOptions[] = []; - resumed: Array<{ id: string; options?: ThreadOptions }> = []; - readonly startThreadInstance = new FakeThread("new-thread"); - readonly resumeThreadInstance = new FakeThread("resumed-thread"); +assert.deepEqual(codexCliArguments({ + prompt: "continue", + workspace: "/tmp/project", + providerSessionId: "existing-thread", + writeMode: "full_access", +}), [ + "exec", + "--experimental-json", + "--config", + 'approval_policy="never"', + "--sandbox", + "danger-full-access", + "--cd", + "/tmp/project", + "resume", + "existing-thread", +]); - startThread(options?: ThreadOptions): FakeThread { - this.started.push(options ?? {}); - return this.startThreadInstance; - } +const invocations: CodexCliInvocation[] = []; +const runner: CodexCliRunner = async (invocation) => { + invocations.push(invocation); + return { + threadId: "new-thread", + finalResponse: `response:${invocation.prompt}`, + items: [{ type: "agent_message", text: `response:${invocation.prompt}` }], + }; +}; - resumeThread(id: string, options?: ThreadOptions): FakeThread { - this.resumed.push({ id, options }); - return this.resumeThreadInstance; - } -} +const runtime = new CodexCliLocalAgentRuntime({ + command: "/usr/local/bin/codex", + env: { PATH: "/usr/local/bin" }, + runner, +}); -const codex = new FakeCodex(); -const runtime = new CodexSdkLocalAgentRuntime(codex); const readOnly = await runtime.run({ prompt: "inspect only", workspace: "/tmp/project", }); - assert.equal(readOnly.provider, "codex"); assert.equal(readOnly.providerSessionId, "new-thread"); assert.equal(readOnly.finalResponse, "response:inspect only"); -assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); -assert.deepEqual(codex.started[0], { - workingDirectory: "/tmp/project", - sandboxMode: "read-only", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, -}); +assert.deepEqual(readOnly.items, [{ type: "agent_message", text: "response:inspect only" }]); +assert.deepEqual(invocations.at(-1)?.args, [ + "exec", + "--experimental-json", + "--config", + 'approval_policy="never"', + "--sandbox", + "read-only", + "--cd", + "/tmp/project", +]); -await runtime.run({ - prompt: "make change", +const resumed = await runtime.run({ + prompt: "continue", workspace: "/tmp/project", + providerSessionId: "existing-thread", writeMode: "allowed", model: "gpt-5.4", thinking: "high", }); +assert.equal(resumed.providerSessionId, "new-thread"); +assert.equal(resumed.finalResponse, "response:continue"); +assert.deepEqual(invocations.at(-1)?.args, [ + "exec", + "--experimental-json", + "--model", + "gpt-5.4", + "--config", + 'model_reasoning_effort="high"', + "--config", + 'approval_policy="never"', + "--sandbox", + "workspace-write", + "--cd", + "/tmp/project", + "resume", + "existing-thread", +]); -assert.deepEqual(codex.started[1], { - workingDirectory: "/tmp/project", - sandboxMode: "workspace-write", - approvalPolicy: "never", - model: "gpt-5.4", - modelReasoningEffort: "high", +const created = createCodexCliLocalAgentRuntime({ + command: "/usr/local/bin/codex", + env: process.env, + runner, }); +assert.equal(created.provider, "codex"); -const resumed = await runtime.run({ - prompt: "continue", - workspace: "/tmp/project", - providerSessionId: "existing-thread", - writeMode: "full_access", -}); +const parsed = parseCodexCliLines([ + JSON.stringify({ type: "thread.started", thread_id: "thread-9" }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_1", type: "tool_output", result: "ls" }, + }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_2", type: "agent_message", text: "Final answer." }, + }), +]); +assert.equal(parsed.threadId, "thread-9"); +assert.equal(parsed.finalResponse, "Final answer."); +assert.equal(parsed.items.length, 2); -assert.equal(resumed.providerSessionId, "resumed-thread"); -assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); -assert.deepEqual(codex.resumed, [ - { - id: "existing-thread", - options: { - workingDirectory: "/tmp/project", - sandboxMode: "danger-full-access", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, - }, - }, +const failed = parseCodexCliLines([ + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "turn.failed", + error: { message: "model rejected: cli too old" }, + }), ]); +assert.equal(failed.failure, "model rejected: cli too old"); -const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); -assert.equal(created.provider, "codex"); +const error = codexCliError("codex turn failed: boom", "0.147.0", "raw stderr"); +assert.match(error.message, /codex turn failed: boom/); +assert.match(error.message, /codex version: 0\.147\.0/); +assert.match(error.message, /raw stderr/); \ No newline at end of file diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e..34411cfb 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,11 +1,5 @@ -import type { - Codex, - CodexOptions, - ModelReasoningEffort, - RunResult, - SandboxMode, - ThreadOptions, -} from "@openai/codex-sdk"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -30,73 +24,227 @@ export interface LocalAgentRuntime { run(input: LocalAgentRunInput): Promise; } -interface CodexThreadLike { - readonly id: string | null; - run(prompt: string): Promise; +export interface CodexCliInvocation { + readonly command: string; + readonly args: string[]; + readonly env: NodeJS.ProcessEnv; + readonly prompt: string; } -interface CodexClientLike { - startThread(options?: ThreadOptions): CodexThreadLike; - resumeThread(id: string, options?: ThreadOptions): CodexThreadLike; +export interface CodexCliTurn { + readonly threadId: string | null; + readonly finalResponse: string; + readonly items: unknown[]; } -type CodexFactory = (options?: CodexOptions) => CodexClientLike; +export type CodexCliRunner = ( + invocation: CodexCliInvocation, +) => Promise; -function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode { - switch (writeMode) { - case "allowed": - return "workspace-write"; - case "full_access": - return "danger-full-access"; - case "read_only": - case undefined: - return "read-only"; +export interface CodexCliRuntimeOptions { + readonly command: string; + readonly env: NodeJS.ProcessEnv; + readonly version?: string; + readonly runner?: CodexCliRunner; +} + +interface ParsedCodexCliLines { + threadId: string | null; + finalResponse: string; + items: unknown[]; + failure: unknown; +} + +const CODEX_APPROVAL_POLICY = "never"; + +export function codexCliArguments(input: LocalAgentRunInput): string[] { + const args = ["exec", "--experimental-json"]; + if (input.model) { + args.push("--model", input.model); } + if (input.thinking) { + args.push("--config", `model_reasoning_effort="${input.thinking}"`); + } + args.push("--config", `approval_policy="${CODEX_APPROVAL_POLICY}"`); + args.push("--sandbox", sandboxModeFor(input.writeMode)); + args.push("--cd", input.workspace); + if (input.providerSessionId) { + args.push("resume", input.providerSessionId); + } + return args; } -function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { - return { - workingDirectory: input.workspace, - sandboxMode: sandboxModeFor(input.writeMode), - approvalPolicy: "never", - model: input.model, - modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, - }; +// Mirror the SDK's event handling, minus the parts DevSpace does not use: +// `thread.started` supplies the thread id, `item.completed` yields the final +// agent message and the item log, and a `turn.failed` event aborts the run. +export function parseCodexCliLines(lines: string[]): ParsedCodexCliLines { + let threadId: string | null = null; + let finalResponse = ""; + const items: unknown[] = []; + let failure: unknown; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + let event: Record; + try { + event = JSON.parse(line) as Record; + } catch { + throw new Error(`Failed to parse codex CLI output line: ${line.slice(0, 200)}`); + } + switch (event.type) { + case "thread.started": { + if (typeof event.thread_id === "string") threadId = event.thread_id; + break; + } + case "item.completed": { + const record = event.item as Record | undefined; + if (record) items.push(record); + if (record?.type === "agent_message" && typeof record.text === "string") { + finalResponse = record.text; + } + break; + } + case "turn.failed": { + failure = failureMessage(event.error); + break; + } + } + } + return { threadId, finalResponse, items, failure }; } -export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { +export class CodexCliLocalAgentRuntime implements LocalAgentRuntime { readonly provider = "codex" as const; - private readonly codex: CodexClientLike; + private readonly runner: CodexCliRunner; - constructor(codex: CodexClientLike) { - this.codex = codex; + constructor(private readonly options: CodexCliRuntimeOptions) { + this.runner = options.runner ?? createCodexCliSpawnRunner({ version: options.version }); } async run(input: LocalAgentRunInput): Promise { - const options = threadOptionsFor(input); - const thread = input.providerSessionId - ? this.codex.resumeThread(input.providerSessionId, options) - : this.codex.startThread(options); - const turn = await thread.run(input.prompt); - + const turn = await this.runner({ + command: this.options.command, + args: codexCliArguments(input), + env: this.options.env, + prompt: input.prompt, + }); return { provider: this.provider, - providerSessionId: thread.id, + providerSessionId: turn.threadId, finalResponse: turn.finalResponse, items: turn.items, }; } } -export async function createCodexSdkLocalAgentRuntime( - options?: CodexOptions, - codexFactory?: CodexFactory, -): Promise { - const factory = codexFactory ?? (await defaultCodexFactory()); - return new CodexSdkLocalAgentRuntime(factory(options)); +export function createCodexCliLocalAgentRuntime( + options: CodexCliRuntimeOptions, +): CodexCliLocalAgentRuntime { + return new CodexCliLocalAgentRuntime(options); } -async function defaultCodexFactory(): Promise { - const module = await import("@openai/codex-sdk"); - return (options) => new module.Codex(options) as Codex; +export function createCodexCliSpawnRunner(options: { version?: string } = {}): CodexCliRunner { + const version = options.version; + return async (invocation) => { + const { command, args, env, prompt } = invocation; + const child = spawn(command, args, { + env, + windowsHide: true, + }); + let spawnError: Error | undefined; + let stderr = ""; + const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + }, + ); + child.once("error", (error) => { + spawnError = error; + }); + if (!child.stdin) { + child.kill(); + throw codexCliError("codex CLI did not expose stdin", version); + } + child.stdin.write(prompt); + child.stdin.end(); + if (child.stderr) { + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + } + const output = child.stdout; + if (!output) { + child.kill(); + throw codexCliError("codex CLI did not expose stdout", version); + } + const lines: string[] = []; + const reader = createInterface({ + input: output, + crlfDelay: Infinity, + }); + try { + for await (const line of reader) { + lines.push(line); + } + } finally { + reader.close(); + } + if (spawnError) { + throw codexCliError( + `Failed to start codex CLI: ${spawnError.message}`, + version, + stderr, + ); + } + + const parsed = parseCodexCliLines(lines); + if (parsed.failure) { + throw codexCliError(`codex turn failed: ${String(parsed.failure)}`, version, stderr); + } + const { code, signal } = await exitPromise; + if (code !== 0 || signal) { + throw codexCliError( + `codex CLI exited with ${signal ? `signal ${signal}` : `code ${code ?? 1}`}`, + version, + stderr, + ); + } + return { + threadId: parsed.threadId, + finalResponse: parsed.finalResponse, + items: parsed.items, + }; + }; +} + +// Surface the CLI version and raw stderr in the exception so the session error +// row lets the host reason about model gates without forensics. +export function codexCliError(message: string, version?: string, stderr?: string): Error { + const details = [ + message, + version ? `codex version: ${version}` : undefined, + stderr && stderr.trim() ? `stderr:\n${stderr.trim()}` : undefined, + ].filter(Boolean).join("\n"); + return new Error(details); } + +function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): string { + switch (writeMode) { + case "allowed": + return "workspace-write"; + case "full_access": + return "danger-full-access"; + case "read_only": + case undefined: + return "read-only"; + } +} + +function failureMessage(error: unknown): unknown { + if (error && typeof error === "object") { + const message = (error as Record).message; + if (typeof message === "string" && message.trim()) return message; + } + if (typeof error === "string" && error.trim()) return error; + return error; +} \ No newline at end of file diff --git a/src/server.test.ts b/src/server.test.ts index c2f659d1..450e8693 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -8,6 +8,7 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; +import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; @@ -65,6 +66,60 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.agents)); }); +test("open_workspace reports provider versions and floors in its catalog", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); + const project = join(root, "project"); + await mkdir(project, { recursive: true }); + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: join(root, "agent"), + DEVSPACE_WIDGETS: "full", + DEVSPACE_TOOL_MODE: "full", + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const store = new SqliteWorkspaceStore(join(root, ".state")); + const workspaces = new WorkspaceRegistry(config, store); + const localAgentProviders: LocalAgentProviderAvailability[] = [ + { name: "codex", available: true, version: "0.147.0", minimumVersion: "0.142.5" }, + { name: "pi", available: false, reason: "pi executable not found" }, + ]; + const server = createMcpServer( + config, + workspaces, + createReviewCheckpointManager(), + new ProcessSessionManager(), + localAgentProviders, + [], + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + try { + const opened = await callOpen(client, project, "chat-1"); + + const structured = structuredContent(opened); + const providers = structured.agentProviders as Array>; + assert.equal(providers.find((provider) => provider.name === "codex")?.version, "0.147.0"); + assert.equal(providers.find((provider) => provider.name === "codex")?.minimumVersion, "0.142.5"); + + const text = responseText(opened); + assert.match(text, /Available subagent providers: codex \(0\.147\.0, min 0\.142\.5\)/); + assert.match(text, /Unavailable subagent providers: pi \(pi executable not found\)/); + } finally { + await client.close(); + await server.close(); + store.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("concurrent checkout opens return one full context and one reuse instruction", async (t) => { const context = await fixture(t); const [first, second] = await Promise.all([ diff --git a/src/server.ts b/src/server.ts index 37ec4165..2ad00d93 100644 --- a/src/server.ts +++ b/src/server.ts @@ -233,6 +233,12 @@ function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability return `${provider.name} (${provider.reason ?? "unavailable"})`; } +function formatAvailableAgentProvider(provider: LocalAgentProviderAvailability): string { + if (!provider.version) return provider.name; + const floor = provider.minimumVersion ? `, min ${provider.minimumVersion}` : ""; + return `${provider.name} (${provider.version}${floor})`; +} + function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -269,6 +275,8 @@ const workspaceLocalAgentProviderOutputSchema = z.object({ name: z.string(), available: z.boolean(), reason: z.string().optional(), + version: z.string().optional(), + minimumVersion: z.string().optional(), }); const workspaceAvailableAgentsFileOutputSchema = z.object({ @@ -876,7 +884,7 @@ export function createMcpServer( ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, visibleAgentProviders.some((provider) => provider.available) - ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` + ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map(formatAvailableAgentProvider).join(", ")}` : undefined, visibleAgentProviders.some((provider) => !provider.available) ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 3d238083..0a340083 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -68,6 +68,8 @@ export interface ToolResultCard { name?: string; available?: boolean; reason?: string; + version?: string; + minimumVersion?: string; }>; agents?: Array<{ name?: string; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index eab9c501..5b969ee5 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -548,13 +548,20 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v const name = provider.name?.trim() || "Unknown provider"; const unavailable = provider.available === false; const logo = getProviderLogo(name); + const version = provider.version + ? `${provider.version}${provider.minimumVersion ? ` (min ${provider.minimumVersion})` : ""}` + : undefined; return { label: name, logo, bareLogo: Boolean(logo), ariaLabel: name, tone: unavailable ? "muted" as const : undefined, - title: unavailable ? provider.reason ?? "Provider unavailable" : name, + title: unavailable + ? provider.reason ?? "Provider unavailable" + : version + ? `${name} ${version}` + : name, }; });