diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..38951980a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,6 +102,37 @@ The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses its shell tool with programs such as `rg`, `find`, and `ls` when it needs those operations. +### Experimental Code Mode + +Set `DEVSPACE_EXPERIMENTAL_CODE_MODE=1` **when starting the server** to try +JavaScript-based tool composition without changing `tools.mode`: + +```sh +DEVSPACE_EXPERIMENTAL_CODE_MODE=1 pnpm dev +``` + +This overrides the model-facing coding tools with `open_workspace`, `execute`, +`poll_execution`, `cancel_execution`, and `show_changes`. When native artifact +download is enabled, `download_artifact` remains a separate tool. The existing +Codex primitives are available *inside* `execute`, with their JavaScript signatures +returned by `open_workspace` and `workspace_id` bound automatically. Scripts can +use top-level `await` and `return`, but cannot import modules or access Node.js +globals directly. + +`execute` returns a result immediately when possible, or an `execution_id` after +its yield window (10 seconds by default, 12 seconds maximum). Use +`poll_execution` with that ID to await completion without rerunning the script, +or `cancel_execution` to stop it. Long-running nested `exec_command` calls return +`session_id` for polling via `tools.write_stdin` inside the script. + +The experimental runtime has a five-minute script deadline, up to 32 nested tool +calls and four simultaneous nested calls. Sessions and results are in memory only +and are lost on server restart. Tool calls already performed are **not rolled +back** when a script fails or is cancelled. + +Unset the variable or set it to `0` and restart to restore the configured tool +surface. This experimental switch is not a persistent config key. + DevSpace attaches Apps UI metadata only to `open_workspace` and `show_changes`. This avoids rendering an iframe for every read, edit, search, or command call. Setting `ui.enabled` to `false` removes the metadata but does not remove the @@ -197,7 +228,9 @@ Windows; the tool is not registered on BSD. ## Environment boundary -Only two user-facing DevSpace environment variables remain: +Only two persistent user-facing DevSpace environment variables remain +(apart from temporary experiment switches such as +`DEVSPACE_EXPERIMENTAL_CODE_MODE`): | Variable | Purpose | | --- | --- | diff --git a/package.json b/package.json index fa39fb46c..25b640bb6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@anthropic-ai/claude-agent-sdk": "0.3.200", "@anthropic-ai/sandbox-runtime": "0.0.71", "@clack/prompts": "^1.5.1", + "@earendil-works/pi-codemode": "1.1.0", "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/node": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..b6174bd2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@clack/prompts': specifier: ^1.5.1 version: 1.5.1 + '@earendil-works/pi-codemode': + specifier: 1.1.0 + version: 1.1.0 '@earendil-works/pi-coding-agent': specifier: ^0.80.3 version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) @@ -320,6 +323,10 @@ packages: engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-codemode@1.1.0': + resolution: {integrity: sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q==} + engines: {node: '>=22.19.0'} + '@earendil-works/pi-coding-agent@0.80.3': resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} engines: {node: '>=22.19.0'} @@ -1873,6 +1880,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2527,6 +2537,10 @@ snapshots: - ws - zod + '@earendil-works/pi-codemode@1.1.0': + dependencies: + quickjs-wasi: 3.6.2 + '@earendil-works/pi-coding-agent@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@earendil-works/pi-agent-core': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) @@ -3878,6 +3892,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quickjs-wasi@3.6.2: {} + range-parser@1.2.1: {} raw-body@3.0.2: diff --git a/src/code-mode-executions.ts b/src/code-mode-executions.ts new file mode 100644 index 000000000..43c428617 --- /dev/null +++ b/src/code-mode-executions.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; +import type { CodemodeResult } from "@earendil-works/pi-codemode"; +import type { ProcessSessionManager } from "./process-sessions.js"; +import type { CodeModeRegistry } from "./code-mode-registry.js"; + +export const MAX_CODE_MODE_YIELD_MS = 12_000; +const DEFAULT_YIELD_MS = 10_000; +const DEFAULT_POLL_MS = 5_000; +const MAX_SCRIPT_MS = 5 * 60_000; +const MAX_CALLS = 32; +const MAX_CONCURRENT_CALLS = 4; +const COMPLETED_TTL_MS = 5 * 60_000; + +type Status = "running" | "completed" | "failed" | "cancelled"; + +interface Execution { + id: string; + workspaceId: string; + status: Status; + controller: AbortController; + done: Promise; + result?: CodemodeResult; + error?: string; + startedAt: number; + finishedAt?: number; + processSessionIds: Set; + cleanupTimer?: NodeJS.Timeout; +} + +export interface ExecutionSnapshot { + execution_id: string; + running: boolean; + status: Status; + result?: unknown; + output?: string[]; + error?: string; + calls?: CodemodeResult["calls"]; +} + +function yieldWindow(ms: number | undefined, fallback: number): number { + return ms === undefined ? fallback : Math.min(ms, MAX_CODE_MODE_YIELD_MS); +} + +export class CodeModeExecutions { + private readonly executions = new Map(); + + constructor( + private readonly registry: CodeModeRegistry, + private readonly processes: ProcessSessionManager, + ) {} + + async start(workspaceId: string, code: string, yieldTimeMs?: number): Promise { + const id = randomUUID(); + const execution: Execution = { + id, + workspaceId, + status: "running", + controller: new AbortController(), + done: Promise.resolve(), + startedAt: performance.now(), + processSessionIds: new Set(), + }; + this.executions.set(id, execution); + execution.done = this.run(execution, code); + await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_YIELD_MS)); + return this.snapshot(execution); + } + + async poll(workspaceId: string, id: string, yieldTimeMs?: number): Promise { + const execution = this.get(workspaceId, id); + await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_POLL_MS)); + return this.snapshot(execution); + } + + cancel(workspaceId: string, id: string): ExecutionSnapshot { + const execution = this.get(workspaceId, id); + if (execution.status === "running") { + execution.controller.abort(new Error("Execution cancelled by user.")); + this.terminateProcesses(execution); + } + return this.snapshot(execution); + } + + async shutdown(): Promise { + for (const execution of this.executions.values()) { + if (execution.cleanupTimer) clearTimeout(execution.cleanupTimer); + if (execution.status === "running") { + execution.controller.abort(new Error("DevSpace shutting down.")); + this.terminateProcesses(execution); + } + } + await Promise.allSettled([...this.executions.values()].map((execution) => execution.done)); + this.executions.clear(); + } + + private get(workspaceId: string, id: string): Execution { + const execution = this.executions.get(id); + if (!execution || execution.workspaceId !== workspaceId) { + throw new Error("Code execution does not exist in this workspace."); + } + return execution; + } + + private snapshot(execution: Execution): ExecutionSnapshot { + const result = execution.result; + return { + execution_id: execution.id, + running: execution.status === "running", + status: execution.status, + ...(result?.ok ? { result: result.value } : {}), + ...(result ? { + output: result.output.filter((item) => item.type === "text").map((item) => item.text), + calls: result.calls, + } : {}), + ...(execution.error ? { error: execution.error } : {}), + }; + } + + private async wait(execution: Execution, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + execution.done, + new Promise((resolve) => { timer = setTimeout(resolve, ms); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + private terminateProcesses(execution: Execution): void { + for (const id of execution.processSessionIds) { + try { + this.processes.terminate(execution.workspaceId, id); + } catch { + // A process might have finished and been reaped already. + } + } + } + + private async run(execution: Execution, code: string): Promise { + try { + // The worker and WASM runtime are loaded only when the experimental mode runs a script. + const { CodemodeSandbox } = await import("@earendil-works/pi-codemode"); + let calls = 0; + let active = 0; + const waiters: Array<() => void> = []; + const tools = this.registry.tools(execution.workspaceId, (id) => execution.processSessionIds.add(id)) + .map((tool) => ({ + ...tool, + execute: async (args: unknown, context: Parameters[1]) => { + if (++calls > MAX_CALLS) throw new Error(`Code Mode limit exceeded: ${MAX_CALLS} tool calls.`); + if (active >= MAX_CONCURRENT_CALLS) await new Promise((resolve) => waiters.push(resolve)); + active++; + try { + if (execution.controller.signal.aborted) throw new Error("Code execution cancelled."); + return await tool.execute(args, context); + } finally { + active--; + waiters.shift()?.(); + } + }, + })); + const sandbox = new CodemodeSandbox({ + tools, + timeoutMs: MAX_SCRIPT_MS, + memoryLimitBytes: 256 * 1024 * 1024, + }); + try { + execution.result = await sandbox.execute(code, { signal: execution.controller.signal }); + execution.status = execution.controller.signal.aborted ? "cancelled" : execution.result.ok ? "completed" : "failed"; + if (!execution.result.ok) execution.error = execution.result.error.message; + } finally { + await sandbox.close(); + } + } catch (error) { + execution.error = error instanceof Error ? error.message : String(error); + execution.status = execution.controller.signal.aborted ? "cancelled" : "failed"; + } finally { + if (execution.status !== "completed") this.terminateProcesses(execution); + execution.finishedAt = performance.now(); + execution.cleanupTimer = setTimeout(() => this.executions.delete(execution.id), COMPLETED_TTL_MS); + execution.cleanupTimer.unref(); + } + } +} diff --git a/src/code-mode-registry.ts b/src/code-mode-registry.ts new file mode 100644 index 000000000..fbc13e06f --- /dev/null +++ b/src/code-mode-registry.ts @@ -0,0 +1,77 @@ +import { + renderDeclarations, + type CodemodeTool, +} from "@earendil-works/pi-codemode"; +import * as z from "zod/v4"; +import type { McpRegistrationTarget } from "./mcp-modern-server.js"; +import { contentText } from "./tool-surfaces/shared.js"; +import type { ToolContent } from "./tool-surfaces/types.js"; + +type Handler = (input: Record, extra: { signal: AbortSignal }) => Promise<{ + isError?: boolean; + content?: ToolContent[]; + structuredContent?: Record; +}>; + +interface Entry { + name: string; + description: string; + input: z.ZodObject; + output: z.ZodObject; + handler: Handler; +} + +/** Records the real MCP registrations, so Code Mode never duplicates their handlers or schemas. */ +export class CodeModeRegistry { + private readonly entries = new Map(); + + readonly registrationTarget: McpRegistrationTarget = { + registerTool: ((name: string, definition: Record, handler: Handler) => { + const input = z.object(definition.inputSchema as z.ZodRawShape); + const output = z.object(definition.outputSchema as z.ZodRawShape); + this.entries.set(name, { + name, + description: definition.description as string, + input, + output, + handler, + }); + // This target collects internal registrations; nothing is registered with the MCP host. + return undefined; + }) as unknown as McpRegistrationTarget["registerTool"], + registerResource: (() => undefined) as unknown as McpRegistrationTarget["registerResource"], + }; + + signatures(): string { + return renderDeclarations({ tools: [...this.entries.values()].map((entry) => ({ + name: entry.name, + description: entry.description, + inputSchema: z.toJSONSchema(entry.input.omit({ workspace_id: true })), + outputSchema: z.toJSONSchema(entry.output), + execute: () => undefined, + })) }); + } + + tools(workspaceId: string, onProcessSession: (id: number) => void): CodemodeTool[] { + return [...this.entries.values()].map((entry) => ({ + name: entry.name, + description: entry.description, + inputSchema: z.toJSONSchema(entry.input.omit({ workspace_id: true })), + outputSchema: z.toJSONSchema(entry.output), + execute: async (value: unknown, { signal }) => { + if (signal.aborted) throw new Error("Code execution cancelled."); + const parsed = entry.input.safeParse({ ...(typeof value === "object" && value !== null ? value : {}), workspace_id: workspaceId }); + if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")); + const response = await entry.handler(parsed.data, { signal }); + if (response.isError) throw new Error(contentText(response.content ?? []) || `${entry.name} failed`); + const result = response.structuredContent ?? { result: contentText(response.content ?? []) }; + const validated = entry.output.safeParse(result); + if (!validated.success) throw new Error(`Invalid ${entry.name} response: ${validated.error.message}`); + if (entry.name === "exec_command" && result.running === true && typeof result.session_id === "number") { + onProcessSession(result.session_id); + } + return result; + }, + })); + } +} diff --git a/src/config.test.ts b/src/config.test.ts index ddb2fc1c3..1ab231874 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -19,6 +19,9 @@ try { assert.deepEqual(defaults.allowedRoots, [process.cwd()]); assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); assert.equal(defaults.toolMode, "codex"); + assert.equal(defaults.experimentalCodeMode, false); + assert.equal(loadConfig({ ...env, DEVSPACE_EXPERIMENTAL_CODE_MODE: "1" }).experimentalCodeMode, true); + assert.equal(loadConfig({ ...env, DEVSPACE_EXPERIMENTAL_CODE_MODE: "0" }).experimentalCodeMode, false); assert.equal(defaults.uiEnabled, true); assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..5ababa488 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; toolMode: ToolMode; + experimentalCodeMode: boolean; uiEnabled: boolean; stateDir: string; worktreeRoot: string; @@ -66,6 +67,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: normalizeAllowedHosts(derivedAllowedHosts), publicBaseUrl, toolMode: stored.tools.mode, + experimentalCodeMode: env.DEVSPACE_EXPERIMENTAL_CODE_MODE === "1", uiEnabled: stored.ui.enabled, stateDir: normalizePath(stored.storage.stateDir), worktreeRoot: normalizePath(stored.workspaces.worktreeRoot), diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..a4014c12e 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -49,6 +49,104 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { } }); +test("experimental Code Mode bootstraps real tools and executes through MCP", async (t) => { + const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false, git: true }); + const listed = await context.client.listTools(); + assert.deepEqual(listed.tools.map((tool) => tool.name).sort(), [ + "cancel_execution", "execute", "open_workspace", "poll_execution", "show_changes", + ]); + + const opened = await callOpen(context.client, context.project); + const workspaceId = structuredContent(opened).workspace_id as string; + const codeMode = structuredContent(opened).code_mode as { tool_signatures: string }; + assert.match(codeMode.tool_signatures, /read\(/); + assert.match(codeMode.tool_signatures, /apply_patch\(/); + assert.match(JSON.stringify(opened.content), /declare const tools/); + assert.doesNotMatch(codeMode.tool_signatures, /workspace_id/); + + await writeFile(join(context.project, "note.txt"), "hello world\n"); + const executed = structuredContent(await context.client.callTool({ + name: "execute", + arguments: { + workspace_id: workspaceId, + code: `const file = await tools.read({ path: "note.txt" }); + const found = await tools.exec_command({ cmd: "git status --short" }); + return { content: file.result.trim(), status: found.result.trim() };`, + }, + })); + assert.equal(executed.status, "completed"); + assert.match((executed.value as { status: string }).status, /^\?\? note\.txt/); + assert.equal((executed.value as { content: string }).content, "hello world"); + + const patched = structuredContent(await context.client.callTool({ + name: "execute", + arguments: { + workspace_id: workspaceId, + code: `return await tools.apply_patch({ patch: "*** Begin Patch\\n*** Update File: note.txt\\n@@\\n-hello world\\n+hello code mode\\n*** End Patch" });`, + }, + })); + assert.equal(patched.status, "completed"); + assert.equal(await readFile(join(context.project, "note.txt"), "utf8"), "hello code mode\n"); + const reviewed = structuredContent(await context.client.callTool({ + name: "show_changes", + arguments: { workspace_id: workspaceId }, + })); + assert.match(reviewed.result as string, /Changed 1 file/); +}); + +test("experimental Code Mode polls a long-running script without restarting it", async (t) => { + const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); + const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const start = structuredContent(await context.client.callTool({ + name: "execute", + arguments: { + workspace_id: workspaceId, + yield_time_ms: 0, + code: `let s = await tools.exec_command({ cmd: "node -e \\\"setTimeout(() => console.log('done'), 300)\\\"", yield_time_ms: 0 }); + while (s.running) s = await tools.write_stdin({ session_id: s.session_id, yield_time_ms: 1000 }); + return s.exit_code;`, + }, + })); + assert.equal(start.running, true); + assert.equal(typeof start.execution_id, "string"); + + const end = structuredContent(await context.client.callTool({ + name: "poll_execution", + arguments: { workspace_id: workspaceId, execution_id: start.execution_id, yield_time_ms: 5000 }, + })); + assert.equal(end.status, "completed"); + assert.equal(end.value, 0); + assert.deepEqual((end.calls as { tool: string }[]).map((call) => call.tool).slice(0, 2), [ + "exec_command", "write_stdin", + ]); +}); + +test("experimental Code Mode rejects invalid tool inputs and interrupts runaway scripts", async (t) => { + const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); + const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const invalid = structuredContent(await context.client.callTool({ + name: "execute", + arguments: { workspace_id: workspaceId, code: 'return await tools.read({ offset: -1 })' }, + })); + assert.equal(invalid.status, "failed"); + assert.match(invalid.error as string, /path/); + + const start = structuredContent(await context.client.callTool({ + name: "execute", + arguments: { workspace_id: workspaceId, code: "while (true) {}", yield_time_ms: 0 }, + })); + assert.equal(start.running, true); + await context.client.callTool({ + name: "cancel_execution", + arguments: { workspace_id: workspaceId, execution_id: start.execution_id }, + }); + const cancelled = structuredContent(await context.client.callTool({ + name: "poll_execution", + arguments: { workspace_id: workspaceId, execution_id: start.execution_id }, + })); + assert.equal(cancelled.status, "cancelled"); +}); + test("model-facing tool schemas use snake_case recursively", async (t) => { for (const toolMode of ["claude", "codex"] as const) { await t.test(toolMode, async (nested) => { @@ -583,6 +681,46 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = assert.match(await legacyTools.text(), /"open_workspace"/); }); +test("experimental Code Mode executes over stateless HTTP MCP requests", async (t) => { + const { root, localBaseUrl, accessToken } = await httpServerFixture( + t, + "devspace-codemode-http-test-", + true, + ); + await writeFile(join(root, "note.txt"), "through HTTP\n"); + + const listed = await postModernMcp(localBaseUrl, accessToken, "tools/list", {}); + const inventory = await listed.json() as { result?: { tools?: Array<{ name: string }> } }; + assert.deepEqual(inventory.result?.tools?.map((tool) => tool.name).sort(), [ + "cancel_execution", "execute", "open_workspace", "poll_execution", "show_changes", + ]); + + const opened = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "open_workspace", + arguments: { path: root }, + }); + const response = await opened.json() as { + result?: { structuredContent?: { workspace_id?: string; code_mode?: { tool_signatures: string } } }; + }; + const workspaceId = response.result?.structuredContent?.workspace_id; + assert.ok(workspaceId); + assert.match(response.result?.structuredContent?.code_mode?.tool_signatures ?? "", /tools/); + + const call = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "execute", + arguments: { + workspace_id: workspaceId, + code: 'return (await tools.read({ path: "note.txt" })).result.trim()', + }, + }); + assert.equal(call.status, 200, await call.clone().text()); + const result = await call.json() as { + result?: { structuredContent?: { status?: string; value?: string } }; + }; + assert.equal(result.result?.structuredContent?.status, "completed"); + assert.equal(result.result?.structuredContent?.value, "through HTTP"); +}); + test("server shutdown waits for an active MCP tool call", async (t) => { const { root, localBaseUrl, accessToken, running } = await httpServerFixture( t, @@ -677,10 +815,11 @@ interface HttpServerFixture { async function httpServerFixture( t: TestContext, prefix: string, + experimentalCodeMode = false, ): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); const ownerToken = "test-owner-token-that-is-long-enough"; - const config = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + const config = { ...loadConfig(writeTestDevspaceConfig(join(root, ".config"), { server: { port: 1, publicBaseUrl: "https://example.test", @@ -690,7 +829,7 @@ async function httpServerFixture( worktreeRoot: join(root, ".worktrees"), }, storage: { stateDir: join(root, ".state") }, - })); + })), experimentalCodeMode }; const running = createServer(config, { incomingArtifactAdapters: [] }); const httpServer = running.app.listen(0, "127.0.0.1"); await new Promise((resolve) => httpServer.once("listening", resolve)); @@ -722,6 +861,7 @@ async function fixture( subagents?: SubagentsConfig; toolMode?: ToolMode; uiEnabled?: boolean; + experimentalCodeMode?: boolean; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -768,6 +908,7 @@ async function fixture( ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, uiEnabled: options.uiEnabled ?? loadedConfig.uiEnabled, + experimentalCodeMode: options.experimentalCodeMode ?? loadedConfig.experimentalCodeMode, }; const config: ServerConfig = options.localAgentProviders ? { diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..5bfb65325 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,8 @@ import { import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; +import { CodeModeRegistry } from "./code-mode-registry.js"; +import { CodeModeExecutions, MAX_CODE_MODE_YIELD_MS, type ExecutionSnapshot } from "./code-mode-executions.js"; import { isArtifactDownloadSupportedPlatform, registerArtifactTools, @@ -124,6 +126,9 @@ function serverInstructions( config: ServerConfig, toolSurface: ToolSurface, ): string { + if (config.experimentalCodeMode) { + return "Call open_workspace once per project or worktree, then use the returned Code Mode tool signatures to call workspace primitives through execute. Await relevant operations; use poll_execution for long-running scripts and cancel_execution when no longer needed. Call show_changes after file modifications."; + } const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() ? " When the user provides an attached or generated file that needs to be added to the workspace, pass the provided file directly to download_artifact with the existing workspace_id and a suitable relative destination path. Do not reconstruct attached files manually." @@ -314,7 +319,7 @@ export function createMcpServer( incomingArtifactAdapters: readonly IncomingArtifactAdapter[], trackToolActivity?: TrackToolActivity, ): McpServer { - const toolSurface = getToolSurface(config.toolMode); + const toolSurface = getToolSurface(config.experimentalCodeMode ? "codex" : config.toolMode); const server = new McpServer( mcpServerInfo(), { @@ -322,6 +327,8 @@ export function createMcpServer( }, ); + const codeModeRegistry = config.experimentalCodeMode ? new CodeModeRegistry() : undefined; + const codeModeExecutions = codeModeRegistry ? new CodeModeExecutions(codeModeRegistry, processSessions) : undefined; registerMcpSurface( server, config, @@ -331,6 +338,8 @@ export function createMcpServer( resolveLocalAgentProviders, incomingArtifactAdapters, trackToolActivity, + codeModeRegistry, + codeModeExecutions, ); return server; } @@ -344,11 +353,14 @@ function registerMcpSurface( resolveLocalAgentProviders: () => LocalAgentProviderStatus[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], trackToolActivity?: TrackToolActivity, + codeModeRegistry?: CodeModeRegistry, + codeModeExecutions?: CodeModeExecutions, ): void { const registrationTarget = trackToolActivity ? withTrackedToolHandlers(server, trackToolActivity) : server; - const toolSurface = getToolSurface(config.toolMode); + const toolSurface = getToolSurface(config.experimentalCodeMode ? "codex" : config.toolMode); + const internalTarget = codeModeRegistry?.registrationTarget ?? registrationTarget; registerAppResource( registrationTarget, @@ -434,6 +446,10 @@ function registerMcpSurface( }), ]), instruction: z.string(), + code_mode: z.object({ + language: z.literal("javascript"), + tool_signatures: z.string(), + }).optional(), }, ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, @@ -514,6 +530,9 @@ function registerMcpSurface( preloadedSubagentInstructions, ].join("\n\n") : workspaceInstruction; + const codeMode = codeModeRegistry + ? { language: "javascript" as const, tool_signatures: codeModeRegistry.signatures() } + : undefined; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -541,6 +560,13 @@ function registerMcpSurface( ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` : undefined, instruction, + codeMode ? [ + "Code Mode: tools below are available inside execute({code}) as tools.name(args), with workspace_id automatically bound.", + "Scripts are JavaScript async function bodies: top-level await and return work. No Node APIs, filesystem, imports, or network.", + "Use Promise.allSettled for independent calls; await operations whose completion matters. Check running process results and poll them using tools.write_stdin.", + "Scripts longer than the initial yield return execution_id; use poll_execution. Native show_changes remains a separate MCP tool.", + codeMode.tool_signatures, + ].join("\n") : undefined, ].filter(Boolean).join("\n"), }, ]; @@ -608,12 +634,13 @@ function registerMcpSurface( } : {}), instruction, + ...(codeMode ? { code_mode: codeMode } : {}), }, }; }, ); - registrationTarget.registerTool( + internalTarget.registerTool( toolNames.read, { title: "Read file", @@ -691,11 +718,14 @@ function registerMcpSurface( ); toolSurface.register({ - server: registrationTarget, + server: internalTarget, config, workspaces, processSessions, }); + if (codeModeExecutions) { + registerCodeModeTools(registrationTarget, workspaces, codeModeExecutions); + } registerAppTool( registrationTarget, @@ -771,6 +801,82 @@ function registerMcpSurface( } } +function executionResponse(snapshot: ExecutionSnapshot) { + const calls = snapshot.calls?.map(({ name, status, durationMs }) => ({ + tool: name, + status, + duration_ms: Math.round(durationMs), + })); + const structured = { + ...snapshot, + calls, + value: snapshot.result ?? null, + result: snapshot.result ?? null, + output: snapshot.output ?? [], + }; + return { + content: [textBlock(JSON.stringify(structured))], + structuredContent: { ...structured, result: JSON.stringify(structured.result) }, + }; +} + +function registerCodeModeTools( + target: McpRegistrationTarget, + workspaces: WorkspaceRegistry, + executions: CodeModeExecutions, +): void { + const yieldSchema = z.number().int().min(0).max(MAX_CODE_MODE_YIELD_MS).optional(); + const outputSchema = resultOutputSchema({ + execution_id: z.string(), + running: z.boolean(), + status: z.enum(["running", "completed", "failed", "cancelled"]), + value: z.unknown().optional(), + output: z.array(z.string()).optional(), + error: z.string().optional(), + calls: z.array(z.object({ + tool: z.string(), + status: z.enum(["ok", "error", "cancelled"]), + duration_ms: z.number(), + })).optional(), + }); + target.registerTool("execute", { + title: "Execute JavaScript", + description: "Execute confined JavaScript using tools declared by open_workspace. If still running after the yield window, returns execution_id for poll_execution.", + inputSchema: { + workspace_id: z.string(), + code: z.string().min(1), + yield_time_ms: yieldSchema, + }, + outputSchema, + }, async ({ workspace_id, code, yield_time_ms }) => { + await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.start(workspace_id, code, yield_time_ms)); + }); + target.registerTool("poll_execution", { + title: "Poll JavaScript execution", + description: "Wait for the result of an existing execute invocation without restarting its script.", + inputSchema: { + workspace_id: z.string(), + execution_id: z.string(), + yield_time_ms: yieldSchema, + }, + outputSchema, + annotations: { readOnlyHint: true }, + }, async ({ workspace_id, execution_id, yield_time_ms }) => { + await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.poll(workspace_id, execution_id, yield_time_ms)); + }); + target.registerTool("cancel_execution", { + title: "Cancel JavaScript execution", + description: "Request cancellation of a running JavaScript execution and owned subprocesses.", + inputSchema: { workspace_id: z.string(), execution_id: z.string() }, + outputSchema, + }, async ({ workspace_id, execution_id }) => { + await workspaces.getWorkspace(workspace_id); + return executionResponse(executions.cancel(workspace_id, execution_id)); + }); +} + function withTrackedToolHandlers( server: McpRegistrationTarget, trackToolActivity: TrackToolActivity, @@ -818,6 +924,8 @@ export function createServer( const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); + const codeModeRegistry = config.experimentalCodeMode ? new CodeModeRegistry() : undefined; + const codeModeExecutions = codeModeRegistry ? new CodeModeExecutions(codeModeRegistry, processSessions) : undefined; const toolActivities = new ToolActivityTracker(); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, @@ -838,6 +946,8 @@ export function createServer( resolveLocalAgentProviders, incomingArtifactAdapters, toolActivities.track, + codeModeRegistry, + codeModeExecutions, ); }); const logMcpHandlerError = (error: Error) => logEvent( @@ -973,6 +1083,7 @@ export function createServer( error: error instanceof Error ? error.message : String(error), }); } + await codeModeExecutions?.shutdown(); await toolActivities.waitForIdle(); processSessions.shutdown(); oauthProvider.close();