From 391a0d6abe7c798433a385b65789a7366efa4c9f Mon Sep 17 00:00:00 2001 From: JGSphaela <39881472+JGSphaela@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:09:13 +0900 Subject: [PATCH 1/2] Add optional Executor bridge --- docs/chatgpt-coding-workflow.md | 11 ++++ docs/configuration.md | 24 +++++++ src/cli.ts | 5 ++ src/config.test.ts | 25 ++++++++ src/config.ts | 12 ++++ src/executor-tools.ts | 78 ++++++++++++++++++++++ src/server.ts | 110 +++++++++++++++++++++++++++++++- 7 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/executor-tools.ts diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 6606262..ab90313 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -128,6 +128,17 @@ DevSpace exposes these tool names: - `edit` - `bash` +When `DEVSPACE_EXECUTOR=1`, DevSpace also exposes: + +- `executor_sources` +- `executor_search_tools` +- `executor_call_tool` + +Use the Executor bridge only for integrations outside the workspace tool model. +For example, search Executor for Zotero, Obsidian, mail, or other configured MCP +tools, then call the returned path with JSON arguments. Continue using DevSpace +tools for local code edits, file reads, and shell commands. + By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated `grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools such as `rg`, `find`, and `ls` for search and directory inspection. diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1..5b99e5f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,6 +134,30 @@ DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ npx @waishnav/devspace serve ``` +## Executor Bridge + +DevSpace can optionally expose a small bridge to a local +[Executor](https://github.com/UsefulSoftwareCo/executor) service. This is useful +when you want ChatGPT to use the same MCP catalog you already maintain for local +coding apps. + +| Variable | Purpose | +| --- | --- | +| `DEVSPACE_EXECUTOR` | Set to `1` to expose the Executor bridge tools. Disabled by default. | +| `DEVSPACE_EXECUTOR_COMMAND` | Executor CLI command. Defaults to `executor`. | +| `DEVSPACE_EXECUTOR_BASE_URL` | Optional Executor service URL, such as `http://localhost:4789`. | +| `DEVSPACE_EXECUTOR_TIMEOUT_MS` | CLI timeout in milliseconds. Defaults to `120000`. | + +When enabled, DevSpace exposes: + +- `executor_sources` — list Executor integrations and tool counts +- `executor_search_tools` — search the Executor catalog +- `executor_call_tool` — invoke a dot-separated Executor tool path with JSON arguments + +Use DevSpace workspace tools for code/file/shell work. Use Executor bridge tools +for non-workspace integrations such as Zotero, Obsidian, mail, or other MCP and +API sources configured in Executor. + ## Logging | Variable | Default | diff --git a/src/cli.ts b/src/cli.ts index bbae1ae..5be26c5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -221,6 +221,11 @@ async function serve(): Promise { if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } + console.log( + config.executor.enabled + ? `executor bridge: enabled (${config.executor.command}${config.executor.baseUrl ? `, ${config.executor.baseUrl}` : ""})` + : "executor bridge: disabled", + ); console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); if (config.subagents) { diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a..722eb00 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -26,12 +26,33 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); +assert.deepEqual(loadConfig(baseEnv).executor, { + enabled: false, + command: "executor", + baseUrl: undefined, + timeoutMs: 120000, +}); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, true, ); +assert.deepEqual( + loadConfig({ + ...baseEnv, + DEVSPACE_EXECUTOR: "1", + DEVSPACE_EXECUTOR_COMMAND: "/opt/homebrew/bin/executor", + DEVSPACE_EXECUTOR_BASE_URL: "http://localhost:4789", + DEVSPACE_EXECUTOR_TIMEOUT_MS: "30000", + }).executor, + { + enabled: true, + command: "/opt/homebrew/bin/executor", + baseUrl: "http://localhost:4789", + timeoutMs: 30000, + }, +); assert.equal(resolveSubagentsFlag({}, {}), undefined); assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); @@ -138,6 +159,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_EXECUTOR_TIMEOUT_MS: "0" }), + /Invalid DEVSPACE_EXECUTOR_TIMEOUT_MS: 0/, +); assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); diff --git a/src/config.ts b/src/config.ts index 4fc1bcb..00dbe9a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,12 @@ export interface ServerConfig { devspaceAgentsDir: string; subagents: boolean; agentDir: string; + executor: { + enabled: boolean; + command: string; + baseUrl?: string; + timeoutMs: number; + }; logging: LoggingConfig; } @@ -235,6 +241,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { ? files.config.subagents === true : parseBoolean(env.DEVSPACE_SUBAGENTS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), + executor: { + enabled: parseBoolean(env.DEVSPACE_EXECUTOR), + command: env.DEVSPACE_EXECUTOR_COMMAND?.trim() || "executor", + baseUrl: env.DEVSPACE_EXECUTOR_BASE_URL?.trim() || undefined, + timeoutMs: parsePositiveInteger(env.DEVSPACE_EXECUTOR_TIMEOUT_MS, 120_000, "DEVSPACE_EXECUTOR_TIMEOUT_MS"), + }, logging: parseLoggingConfig(env), }; } diff --git a/src/executor-tools.ts b/src/executor-tools.ts new file mode 100644 index 0000000..fda0bdf --- /dev/null +++ b/src/executor-tools.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ExecutorConfig { + command: string; + baseUrl?: string; + timeoutMs: number; +} + +interface ExecutorResult { + result: string; + parsed?: unknown; +} + +function baseUrlArgs(config: ExecutorConfig): string[] { + return config.baseUrl ? ["--base-url", config.baseUrl] : []; +} + +async function runExecutor(config: ExecutorConfig, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(config.command, args, { + timeout: config.timeoutMs, + maxBuffer: 16 * 1024 * 1024, + }); + const output = [stdout, stderr].filter(Boolean).join("\n").trim(); + if (!output) return { result: "" }; + try { + return { + result: JSON.stringify(JSON.parse(output), null, 2), + parsed: JSON.parse(output), + }; + } catch { + return { result: output }; + } + } catch (error) { + const maybe = error as Error & { stdout?: string; stderr?: string; code?: number | string }; + const output = [maybe.stdout, maybe.stderr].filter(Boolean).join("\n").trim(); + const message = output || maybe.message || String(error); + throw new Error(`executor ${args.join(" ")} failed${maybe.code ? ` (${maybe.code})` : ""}: ${message}`); + } +} + +export async function listExecutorSources(config: ExecutorConfig): Promise { + return runExecutor(config, ["tools", "sources", ...baseUrlArgs(config)]); +} + +export async function searchExecutorTools( + config: ExecutorConfig, + input: { query: string; limit?: number }, +): Promise { + return runExecutor(config, [ + "tools", + "search", + input.query, + "--limit", + String(input.limit ?? 20), + ...baseUrlArgs(config), + ]); +} + +export async function callExecutorTool( + config: ExecutorConfig, + input: { path: string; arguments?: unknown }, +): Promise { + const segments = input.path.split(".").map((part) => part.trim()).filter(Boolean); + if (segments.length < 2) { + throw new Error("Executor tool path must have at least two dot-separated segments, such as zotero.user.default.zotero_list_collections."); + } + + return runExecutor(config, [ + "call", + ...segments, + JSON.stringify(input.arguments ?? {}), + ...baseUrlArgs(config), + ]); +} diff --git a/src/server.ts b/src/server.ts index c72e143..8ad1fac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,11 @@ import { runShellTool, writeFileTool, } from "./pi-tools.js"; +import { + callExecutorTool, + listExecutorSources, + searchExecutorTools, +} from "./executor-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; @@ -174,9 +179,12 @@ function serverInstructions(config: ServerConfig): string { config.widgets === "changes" ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; + const executorInstruction = config.executor.enabled + ? " Executor bridge tools are available for non-workspace MCP integrations. Use executor_sources to inspect configured integrations, executor_search_tools to find a tool, and executor_call_tool to invoke a specific Executor tool path with JSON arguments. Prefer DevSpace workspace tools for local code edits and shell work." + : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${executorInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -189,7 +197,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${executorInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -673,6 +681,100 @@ function registerCodexProcessTools( ); } +function registerExecutorBridgeTools(server: McpServer, config: ServerConfig): void { + registerAppTool( + server, + "executor_sources", + { + title: "List Executor sources", + description: + "List integrations currently configured in the local Executor service, including proxied MCP servers and built-in Executor tools. Use this before searching or calling Executor tools.", + inputSchema: {}, + outputSchema: resultOutputSchema(), + _meta: {}, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async () => { + const startedAt = performance.now(); + const response = await listExecutorSources(config.executor); + logToolCall(config, { + tool: "executor_sources", + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content: [textBlock(response.result)], + structuredContent: { result: response.result }, + }; + }, + ); + + registerAppTool( + server, + "executor_search_tools", + { + title: "Search Executor tools", + description: + "Search the local Executor catalog by natural-language query. The returned tool path can be passed to executor_call_tool.", + inputSchema: { + query: z.string().describe("Natural-language search query, such as 'zotero collections' or 'obsidian search'."), + limit: z.number().int().min(1).max(50).optional().describe("Maximum results to return. Defaults to 20."), + }, + outputSchema: resultOutputSchema(), + _meta: {}, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async (input) => { + const startedAt = performance.now(); + const response = await searchExecutorTools(config.executor, input); + logToolCall(config, { + tool: "executor_search_tools", + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content: [textBlock(response.result)], + structuredContent: { result: response.result }, + }; + }, + ); + + registerAppTool( + server, + "executor_call_tool", + { + title: "Call Executor tool", + description: + "Invoke one tool from the local Executor catalog by its dot-separated path. Search first with executor_search_tools unless the path is already known. Use JSON object arguments matching the target tool schema.", + inputSchema: { + path: z + .string() + .describe("Dot-separated Executor tool path, for example zotero.user.default.zotero_list_collections."), + arguments: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON object arguments for the target Executor tool. Use an empty object for tools with no required inputs."), + }, + outputSchema: resultOutputSchema(), + _meta: {}, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (input) => { + const startedAt = performance.now(); + const response = await callExecutorTool(config.executor, input); + logToolCall(config, { + tool: "executor_call_tool", + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content: [textBlock(response.result)], + structuredContent: { result: response.result }, + }; + }, + ); +} + function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, @@ -1583,6 +1685,10 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + if (config.executor.enabled) { + registerExecutorBridgeTools(server, config); + } + return server; } From 646b6364409e5821b8a39fa76368dac85a41125e Mon Sep 17 00:00:00 2001 From: JGSphaela <39881472+JGSphaela@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:37:19 +0900 Subject: [PATCH 2/2] Replace Executor bridge with skill workflow --- docs/chatgpt-coding-workflow.md | 17 ++- docs/configuration.md | 30 ++---- examples/skills/executor-local-mcp/SKILL.md | 68 ++++++++++++ src/cli.ts | 5 - src/config.test.ts | 25 ----- src/config.ts | 12 --- src/executor-tools.ts | 78 -------------- src/server.ts | 110 +------------------- 8 files changed, 85 insertions(+), 260 deletions(-) create mode 100644 examples/skills/executor-local-mcp/SKILL.md delete mode 100644 src/executor-tools.ts diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index ab90313..1b6b49a 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -103,6 +103,12 @@ before use. Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Example skills are packaged under `examples/skills/`. The +`examples/skills/executor-local-mcp` skill is a template for using a user's +existing Executor setup from DevSpace shell tools. Copy it into +`~/.devspace/skills` or another active skill directory when ChatGPT should +discover and call local Executor MCP/API integrations. + When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. @@ -128,17 +134,6 @@ DevSpace exposes these tool names: - `edit` - `bash` -When `DEVSPACE_EXECUTOR=1`, DevSpace also exposes: - -- `executor_sources` -- `executor_search_tools` -- `executor_call_tool` - -Use the Executor bridge only for integrations outside the workspace tool model. -For example, search Executor for Zotero, Obsidian, mail, or other configured MCP -tools, then call the returned path with JSON arguments. Continue using DevSpace -tools for local code edits, file reads, and shell commands. - By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated `grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools such as `rg`, `find`, and `ls` for search and directory inspection. diff --git a/docs/configuration.md b/docs/configuration.md index 5b99e5f..6e539e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,29 +134,17 @@ DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ npx @waishnav/devspace serve ``` -## Executor Bridge +Example skills are packaged under `examples/skills/`. To teach ChatGPT to use a +local Executor setup for existing MCP/API integrations, copy the packaged skill +into an active skill directory: -DevSpace can optionally expose a small bridge to a local -[Executor](https://github.com/UsefulSoftwareCo/executor) service. This is useful -when you want ChatGPT to use the same MCP catalog you already maintain for local -coding apps. - -| Variable | Purpose | -| --- | --- | -| `DEVSPACE_EXECUTOR` | Set to `1` to expose the Executor bridge tools. Disabled by default. | -| `DEVSPACE_EXECUTOR_COMMAND` | Executor CLI command. Defaults to `executor`. | -| `DEVSPACE_EXECUTOR_BASE_URL` | Optional Executor service URL, such as `http://localhost:4789`. | -| `DEVSPACE_EXECUTOR_TIMEOUT_MS` | CLI timeout in milliseconds. Defaults to `120000`. | - -When enabled, DevSpace exposes: - -- `executor_sources` — list Executor integrations and tool counts -- `executor_search_tools` — search the Executor catalog -- `executor_call_tool` — invoke a dot-separated Executor tool path with JSON arguments +```bash +mkdir -p ~/.devspace/skills/executor-local-mcp +cp examples/skills/executor-local-mcp/SKILL.md ~/.devspace/skills/executor-local-mcp/SKILL.md +``` -Use DevSpace workspace tools for code/file/shell work. Use Executor bridge tools -for non-workspace integrations such as Zotero, Obsidian, mail, or other MCP and -API sources configured in Executor. +That workflow uses the existing `bash` or `exec_command` tool to run the local +`executor` CLI. It does not add DevSpace bridge tools for every Executor tool. ## Logging diff --git a/examples/skills/executor-local-mcp/SKILL.md b/examples/skills/executor-local-mcp/SKILL.md new file mode 100644 index 0000000..3cf3089 --- /dev/null +++ b/examples/skills/executor-local-mcp/SKILL.md @@ -0,0 +1,68 @@ +--- +name: executor-local-mcp +description: Use the user's local Executor CLI to discover and call existing local MCP/API integrations from DevSpace shell tools. +--- + +# Executor Local MCP + +Use this skill when the user asks to use Executor, local MCP tools, or local +integrations such as Obsidian, Zotero, Thunderbird, browser automation, or other +tools configured in Executor. + +Executor is already available through the user's terminal. Do not ask DevSpace +for additional bridge tools. Use the existing DevSpace shell tool: + +- In minimal or full tool mode, use `bash`. +- In codex tool mode, use `exec_command`. + +## Discovery + +First confirm Executor is installed and reachable when needed: + +```bash +command -v executor +executor service status +``` + +List configured tool sources: + +```bash +executor tools sources +``` + +Search for candidate tools before calling one: + +```bash +executor tools search "zotero collections" --limit 20 +``` + +Describe unfamiliar tools before invoking them: + +```bash +executor tools describe zotero.user.default.zotero_list_collections +``` + +## Calling Tools + +Call tools with `executor call`, splitting the dotted tool path into CLI path +segments and passing JSON arguments as the final argument: + +```bash +executor call zotero user default zotero_list_collections '{}' +``` + +Use `user.default` by default for local single-user sources unless the user asks +for a different configured source. + +## Safety + +Prefer read-only discovery and query tools unless the user explicitly asks for a +mutation. Ask for confirmation before tools that send messages, delete data, +write notes, change app state, open UI actions on the user's machine, or expose +sensitive local content. + +If Executor pauses for approval, show the approval URL or resume command to the +user and wait for confirmation before continuing. + +Keep normal code work in DevSpace tools. Executor should be used for external +local integrations, not for reading or editing the current workspace. diff --git a/src/cli.ts b/src/cli.ts index 5be26c5..bbae1ae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -221,11 +221,6 @@ async function serve(): Promise { if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } - console.log( - config.executor.enabled - ? `executor bridge: enabled (${config.executor.command}${config.executor.baseUrl ? `, ${config.executor.baseUrl}` : ""})` - : "executor bridge: disabled", - ); console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); if (config.subagents) { diff --git a/src/config.test.ts b/src/config.test.ts index 722eb00..0b4f99a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -26,33 +26,12 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); -assert.deepEqual(loadConfig(baseEnv).executor, { - enabled: false, - command: "executor", - baseUrl: undefined, - timeoutMs: 120000, -}); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, true, ); -assert.deepEqual( - loadConfig({ - ...baseEnv, - DEVSPACE_EXECUTOR: "1", - DEVSPACE_EXECUTOR_COMMAND: "/opt/homebrew/bin/executor", - DEVSPACE_EXECUTOR_BASE_URL: "http://localhost:4789", - DEVSPACE_EXECUTOR_TIMEOUT_MS: "30000", - }).executor, - { - enabled: true, - command: "/opt/homebrew/bin/executor", - baseUrl: "http://localhost:4789", - timeoutMs: 30000, - }, -); assert.equal(resolveSubagentsFlag({}, {}), undefined); assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); @@ -159,10 +138,6 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, ); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_EXECUTOR_TIMEOUT_MS: "0" }), - /Invalid DEVSPACE_EXECUTOR_TIMEOUT_MS: 0/, -); assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); diff --git a/src/config.ts b/src/config.ts index 00dbe9a..4fc1bcb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,12 +27,6 @@ export interface ServerConfig { devspaceAgentsDir: string; subagents: boolean; agentDir: string; - executor: { - enabled: boolean; - command: string; - baseUrl?: string; - timeoutMs: number; - }; logging: LoggingConfig; } @@ -241,12 +235,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { ? files.config.subagents === true : parseBoolean(env.DEVSPACE_SUBAGENTS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), - executor: { - enabled: parseBoolean(env.DEVSPACE_EXECUTOR), - command: env.DEVSPACE_EXECUTOR_COMMAND?.trim() || "executor", - baseUrl: env.DEVSPACE_EXECUTOR_BASE_URL?.trim() || undefined, - timeoutMs: parsePositiveInteger(env.DEVSPACE_EXECUTOR_TIMEOUT_MS, 120_000, "DEVSPACE_EXECUTOR_TIMEOUT_MS"), - }, logging: parseLoggingConfig(env), }; } diff --git a/src/executor-tools.ts b/src/executor-tools.ts deleted file mode 100644 index fda0bdf..0000000 --- a/src/executor-tools.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export interface ExecutorConfig { - command: string; - baseUrl?: string; - timeoutMs: number; -} - -interface ExecutorResult { - result: string; - parsed?: unknown; -} - -function baseUrlArgs(config: ExecutorConfig): string[] { - return config.baseUrl ? ["--base-url", config.baseUrl] : []; -} - -async function runExecutor(config: ExecutorConfig, args: string[]): Promise { - try { - const { stdout, stderr } = await execFileAsync(config.command, args, { - timeout: config.timeoutMs, - maxBuffer: 16 * 1024 * 1024, - }); - const output = [stdout, stderr].filter(Boolean).join("\n").trim(); - if (!output) return { result: "" }; - try { - return { - result: JSON.stringify(JSON.parse(output), null, 2), - parsed: JSON.parse(output), - }; - } catch { - return { result: output }; - } - } catch (error) { - const maybe = error as Error & { stdout?: string; stderr?: string; code?: number | string }; - const output = [maybe.stdout, maybe.stderr].filter(Boolean).join("\n").trim(); - const message = output || maybe.message || String(error); - throw new Error(`executor ${args.join(" ")} failed${maybe.code ? ` (${maybe.code})` : ""}: ${message}`); - } -} - -export async function listExecutorSources(config: ExecutorConfig): Promise { - return runExecutor(config, ["tools", "sources", ...baseUrlArgs(config)]); -} - -export async function searchExecutorTools( - config: ExecutorConfig, - input: { query: string; limit?: number }, -): Promise { - return runExecutor(config, [ - "tools", - "search", - input.query, - "--limit", - String(input.limit ?? 20), - ...baseUrlArgs(config), - ]); -} - -export async function callExecutorTool( - config: ExecutorConfig, - input: { path: string; arguments?: unknown }, -): Promise { - const segments = input.path.split(".").map((part) => part.trim()).filter(Boolean); - if (segments.length < 2) { - throw new Error("Executor tool path must have at least two dot-separated segments, such as zotero.user.default.zotero_list_collections."); - } - - return runExecutor(config, [ - "call", - ...segments, - JSON.stringify(input.arguments ?? {}), - ...baseUrlArgs(config), - ]); -} diff --git a/src/server.ts b/src/server.ts index 8ad1fac..c72e143 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,11 +35,6 @@ import { runShellTool, writeFileTool, } from "./pi-tools.js"; -import { - callExecutorTool, - listExecutorSources, - searchExecutorTools, -} from "./executor-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; @@ -179,12 +174,9 @@ function serverInstructions(config: ServerConfig): string { config.widgets === "changes" ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; - const executorInstruction = config.executor.enabled - ? " Executor bridge tools are available for non-workspace MCP integrations. Use executor_sources to inspect configured integrations, executor_search_tools to find a tool, and executor_call_tool to invoke a specific Executor tool path with JSON arguments. Prefer DevSpace workspace tools for local code edits and shell work." - : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${executorInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -197,7 +189,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${executorInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -681,100 +673,6 @@ function registerCodexProcessTools( ); } -function registerExecutorBridgeTools(server: McpServer, config: ServerConfig): void { - registerAppTool( - server, - "executor_sources", - { - title: "List Executor sources", - description: - "List integrations currently configured in the local Executor service, including proxied MCP servers and built-in Executor tools. Use this before searching or calling Executor tools.", - inputSchema: {}, - outputSchema: resultOutputSchema(), - _meta: {}, - annotations: { readOnlyHint: true, openWorldHint: true }, - }, - async () => { - const startedAt = performance.now(); - const response = await listExecutorSources(config.executor); - logToolCall(config, { - tool: "executor_sources", - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - return { - content: [textBlock(response.result)], - structuredContent: { result: response.result }, - }; - }, - ); - - registerAppTool( - server, - "executor_search_tools", - { - title: "Search Executor tools", - description: - "Search the local Executor catalog by natural-language query. The returned tool path can be passed to executor_call_tool.", - inputSchema: { - query: z.string().describe("Natural-language search query, such as 'zotero collections' or 'obsidian search'."), - limit: z.number().int().min(1).max(50).optional().describe("Maximum results to return. Defaults to 20."), - }, - outputSchema: resultOutputSchema(), - _meta: {}, - annotations: { readOnlyHint: true, openWorldHint: true }, - }, - async (input) => { - const startedAt = performance.now(); - const response = await searchExecutorTools(config.executor, input); - logToolCall(config, { - tool: "executor_search_tools", - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - return { - content: [textBlock(response.result)], - structuredContent: { result: response.result }, - }; - }, - ); - - registerAppTool( - server, - "executor_call_tool", - { - title: "Call Executor tool", - description: - "Invoke one tool from the local Executor catalog by its dot-separated path. Search first with executor_search_tools unless the path is already known. Use JSON object arguments matching the target tool schema.", - inputSchema: { - path: z - .string() - .describe("Dot-separated Executor tool path, for example zotero.user.default.zotero_list_collections."), - arguments: z - .record(z.string(), z.unknown()) - .optional() - .describe("JSON object arguments for the target Executor tool. Use an empty object for tools with no required inputs."), - }, - outputSchema: resultOutputSchema(), - _meta: {}, - annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, - }, - async (input) => { - const startedAt = performance.now(); - const response = await callExecutorTool(config.executor, input); - logToolCall(config, { - tool: "executor_call_tool", - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - return { - content: [textBlock(response.result)], - structuredContent: { result: response.result }, - }; - }, - ); -} - function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, @@ -1685,10 +1583,6 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } - if (config.executor.enabled) { - registerExecutorBridgeTools(server, config); - } - return server; }