From ceda59ce42d0c9ee12cc22b96af2e59849111736 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 02:28:44 +0530 Subject: [PATCH 01/73] Remove tool naming configuration --- src/config.test.ts | 7 ------- src/config.ts | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4ed..7bd9a667 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -15,9 +15,6 @@ assert.equal(loadConfig(baseEnv).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); -assert.equal(loadConfig(baseEnv).toolNaming, "short"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "short" }).toolNaming, "short"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "legacy" }).toolNaming, "legacy"); assert.equal(loadConfig(baseEnv).minimalTools, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTools, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); @@ -43,10 +40,6 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "invalid" }), - /Invalid DEVSPACE_TOOL_NAMING: invalid/, -); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", diff --git a/src/config.ts b/src/config.ts index bb0526c4..972b23e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,6 @@ import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { loadDevspaceFiles } from "./user-config.js"; -export type ToolNamingMode = "legacy" | "short"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -18,7 +17,6 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; minimalTools: boolean; - toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; worktreeRoot: string; @@ -133,13 +131,6 @@ function parsePositiveInteger(value: string | undefined, fallback: number, name: return parsed; } -function parseToolNaming(value: string | undefined): ToolNamingMode { - if (!value || value === "short") return "short"; - if (value === "legacy") return "legacy"; - - throw new Error(`Invalid DEVSPACE_TOOL_NAMING: ${value}`); -} - function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { return { level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), @@ -228,7 +219,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, minimalTools: parseMinimalTools(env), - toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), From b0058b7933d15912a6448c93e74baf9e7787ea02 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 02:29:43 +0530 Subject: [PATCH 02/73] Expose only short MCP tool names --- src/server.ts | 57 +++++++++++++++------------------------------------ 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/src/server.ts b/src/server.ts index bfcd7afc..962a7a4e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -137,16 +137,16 @@ function toolWidgetDescriptorMeta( }; } -interface ToolNames { - openWorkspace: "open_workspace"; - read: "read_file" | "read"; - write: "write_file" | "write"; - edit: "edit_file" | "edit"; - grep: "grep_files" | "grep"; - glob: "find_files" | "glob"; - ls: "list_directory" | "ls"; - shell: "run_shell" | "bash"; -} +const toolNames = { + openWorkspace: "open_workspace", + read: "read", + write: "write", + edit: "edit", + grep: "grep", + glob: "glob", + ls: "ls", + shell: "bash", +} as const; interface ToolLogFields { tool: string; @@ -160,31 +160,7 @@ interface ToolLogFields { error?: string; } -function toolNamesFor(config: ServerConfig): ToolNames { - return config.toolNaming === "short" - ? { - openWorkspace: "open_workspace", - read: "read", - write: "write", - edit: "edit", - grep: "grep", - glob: "glob", - ls: "ls", - shell: "bash", - } - : { - openWorkspace: "open_workspace", - read: "read_file", - write: "write_file", - edit: "edit_file", - grep: "grep_files", - glob: "find_files", - ls: "list_directory", - shell: "run_shell", - }; -} - -function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { +function serverInstructions(config: ServerConfig): string { const inspection = config.minimalTools ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -457,7 +433,6 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, ): McpServer { - const toolNames = toolNamesFor(config); const server = new McpServer( { name: "devspace", @@ -467,7 +442,7 @@ function createMcpServer( "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.", }, { - instructions: serverInstructions(config, toolNames), + instructions: serverInstructions(config), }, ); @@ -966,7 +941,7 @@ function createMcpServer( server, toolNames.grep, { - title: config.toolNaming === "short" ? "Grep" : "Grep files", + title: "Grep", description: "Search file contents inside an open workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.", inputSchema: { @@ -1039,7 +1014,7 @@ function createMcpServer( server, toolNames.glob, { - title: config.toolNaming === "short" ? "Glob" : "Find files", + title: "Glob", description: "Find files by glob pattern inside an open workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.", inputSchema: { @@ -1109,7 +1084,7 @@ function createMcpServer( server, toolNames.ls, { - title: config.toolNaming === "short" ? "Ls" : "List directory", + title: "Ls", description: "List a directory inside an open workspace. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.", inputSchema: { @@ -1176,7 +1151,7 @@ function createMcpServer( server, toolNames.shell, { - title: config.toolNaming === "short" ? "Bash" : "Run shell", + title: "Bash", description: config.minimalTools ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, From bf137c5386972d3bbf2c1198c8c03090f695bea4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 02:30:31 +0530 Subject: [PATCH 03/73] Drop legacy tool names from workspace UI --- src/ui/card-types.ts | 24 +++++------------------- src/ui/workspace-app.css | 1 - src/ui/workspace-app.tsx | 7 ------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 89ec3fe6..3a4d3f9c 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,13 +2,6 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" - | "read_file" - | "write_file" - | "edit_file" - | "grep_files" - | "find_files" - | "list_directory" - | "run_shell" | "show_changes" | "read" | "write" @@ -67,13 +60,6 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || - value === "read_file" || - value === "write_file" || - value === "edit_file" || - value === "grep_files" || - value === "find_files" || - value === "list_directory" || - value === "run_shell" || value === "show_changes" || value === "read" || value === "write" || @@ -86,23 +72,23 @@ export function isToolName(value: unknown): value is ToolName { } export function isReadTool(tool: ToolName): boolean { - return tool === "read_file" || tool === "read"; + return tool === "read"; } export function isWriteTool(tool: ToolName): boolean { - return tool === "write_file" || tool === "write"; + return tool === "write"; } export function isEditTool(tool: ToolName): boolean { - return tool === "edit_file" || tool === "edit"; + return tool === "edit"; } export function isSearchTool(tool: ToolName): boolean { - return tool === "grep_files" || tool === "find_files" || tool === "grep" || tool === "glob"; + return tool === "grep" || tool === "glob"; } export function isShellTool(tool: ToolName): boolean { - return tool === "run_shell" || tool === "bash"; + return tool === "bash"; } export function isReviewTool(tool: ToolName): boolean { diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 0f5003f4..28ba6bf5 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -366,7 +366,6 @@ body { word-break: break-word; } -.text-payload.run_shell, .text-payload.bash { color: var(--color-text-primary, #f5f5f6); background: var(--color-background-primary, #101114); diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 373b3ac2..0c1969a6 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -492,25 +492,18 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; - case "read_file": case "read": return { icon: fileIcon(), title: "Read File", label, tone: "read" }; - case "write_file": case "write": return { icon: filePlusIcon(), title: "Write File", label, tone: "write" }; - case "edit_file": case "edit": return { icon: editIcon(), title: "Edit File", label, tone: "edit" }; - case "grep_files": case "grep": return { icon: searchIcon(), title: "Grep", label, tone: "search" }; - case "find_files": case "glob": return { icon: filesIcon(), title: "Glob", label, tone: "search" }; - case "list_directory": case "ls": return { icon: listIcon(), title: "List Directory", label, tone: "directory" }; - case "run_shell": case "bash": return { icon: terminalIcon(), title: "Bash", label, tone: "shell" }; case "show_changes": From 5430542f10d55171fbebdcfd100ee980d0bf7aaf Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 02:31:34 +0530 Subject: [PATCH 04/73] Remove legacy tool naming documentation --- .env.example | 1 - docs/chatgpt-coding-workflow.md | 10 +--------- docs/configuration.md | 8 -------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 1c2cf93c..d4a49662 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,6 @@ DEVSPACE_ALLOWED_ROOTS=/home/waishnav/personal,/home/waishnav/work # Advanced escape hatch. `*` disables Host header allowlist protection. # DEVSPACE_ALLOWED_HOSTS=localhost,127.0.0.1,your-public-host.example.com DEVSPACE_TOOL_MODE=full -DEVSPACE_TOOL_NAMING=legacy # off | changes | full. Defaults to changes. # changes creates one aggregate review widget via review_changes instead of per-tool iframes. DEVSPACE_WIDGETS=changes diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc662..955b7f01 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -97,7 +97,7 @@ Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. ## Tool Names -Short names are the default: +DevSpace exposes these tool names: - `open_workspace` - `read` @@ -109,14 +109,6 @@ 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. -Legacy names are available with `DEVSPACE_TOOL_NAMING=legacy`: - -- `open_workspace` -- `read_file` -- `write_file` -- `edit_file` -- `run_shell` - Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. ## Show Changes diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..2bb14e4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,13 +59,6 @@ MCP clients discover metadata from: ## Tool Modes -`DEVSPACE_TOOL_NAMING` controls tool names. - -| Value | Behavior | -| --- | --- | -| `short` | Default. Uses `read`, `edit`, `bash`, and related names. | -| `legacy` | Uses `read_file`, `edit_file`, `run_shell`, and related names. | - `DEVSPACE_TOOL_MODE` controls the tool surface. | Value | Behavior | @@ -123,7 +116,6 @@ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ DEVSPACE_TOOL_MODE="minimal" \ -DEVSPACE_TOOL_NAMING="short" \ DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve ``` From 4391bb7fc36c999b557286a7a09d412b50afd512 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:06:04 +0530 Subject: [PATCH 05/73] add small NOTE in README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 3605847c..16c8817d 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ Most users should connect through a public HTTPS tunnel: https://your-tunnel-host.example.com/mcp ``` +> [!NOTE] +> Using DevSpace as an MCP connector isn't against OpenAI's Usage Policies — it's +> a standard custom App/connector setup, and writing or running code isn't a +> restricted use case. But your account is governed by your usage, not by +> DevSpace. Don't point it at anything that would violate your provider's terms. +> Used normally, you're fine. (Based on OpenAI's Usage Policies and Service Terms +> as of June 2026.) + ## What ChatGPT Can Do Once connected, ChatGPT can open one of your approved project folders as a From f5286f90092eaa6942f7507a4e9647a6f6b16e87 Mon Sep 17 00:00:00 2001 From: Ahmed Hindy Date: Sun, 28 Jun 2026 14:28:10 +0300 Subject: [PATCH 06/73] Fix Windows allowedRoots bypass (#40) --- src/roots.test.ts | 7 +++++++ src/roots.ts | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/roots.test.ts b/src/roots.test.ts index 96d479a0..90bdaa24 100644 --- a/src/roots.test.ts +++ b/src/roots.test.ts @@ -24,3 +24,10 @@ assert.equal( resolveAllowedPath("~/file.txt", "/workspace", ["/workspace"]), resolve("/workspace", "~/file.txt"), ); + +if (process.platform === "win32") { + assert.throws( + () => assertAllowedPath("C:\\Users\\Administrator", ["G:\\Projects\\Dev\\Github\\devspace"]), + /Path is outside allowed roots/, + ); +} diff --git a/src/roots.ts b/src/roots.ts index 0ebd49ab..214ffb2b 100644 --- a/src/roots.ts +++ b/src/roots.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { relative, resolve, sep } from "node:path"; +import { isAbsolute, relative, resolve, sep } from "node:path"; export class AccessDeniedError extends Error { constructor(message: string) { @@ -24,7 +24,10 @@ export function isPathInsideRoot(path: string, root: string): boolean { return ( relationship === "" || - (!relationship.startsWith("..") && relationship !== ".." && !relationship.includes(`..${sep}`)) + (!isAbsolute(relationship) && + !relationship.startsWith("..") && + relationship !== ".." && + !relationship.includes(`..${sep}`)) ); } From 8a8fea944af59edebd7040b90729253529d4a732 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:13:45 +0530 Subject: [PATCH 07/73] feat(config): add codex tool mode --- src/config.test.ts | 11 ++++++----- src/config.ts | 20 +++++++++++--------- src/server.ts | 6 +++--- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4ed..dc23aa8a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,11 +18,12 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off") assert.equal(loadConfig(baseEnv).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "short" }).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "legacy" }).toolNaming, "legacy"); -assert.equal(loadConfig(baseEnv).minimalTools, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTools, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).minimalTools, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).minimalTools, true); +assert.equal(loadConfig(baseEnv).toolMode, "minimal"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); diff --git a/src/config.ts b/src/config.ts index bb0526c4..5bf96f87 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import type { OAuthConfig } from "./oauth-provider.js"; import { loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; +export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -17,7 +18,7 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - minimalTools: boolean; + toolMode: ToolMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; @@ -79,14 +80,15 @@ function parseBoolean(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); } -function parseMinimalTools(env: NodeJS.ProcessEnv): boolean { - if (env.DEVSPACE_TOOL_MODE === "minimal") return true; - if (env.DEVSPACE_TOOL_MODE === "full") return false; - if (env.DEVSPACE_TOOL_MODE) { - throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${env.DEVSPACE_TOOL_MODE}`); +function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { + const mode = env.DEVSPACE_TOOL_MODE; + if (mode === "minimal" || mode === "full" || mode === "codex") return mode; + if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); + + if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { + return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; } - if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS); - return true; + return "minimal"; } function parseLogLevel(value: string | undefined): LogLevel { @@ -227,7 +229,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - minimalTools: parseMinimalTools(env), + toolMode: parseToolMode(env), toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), diff --git a/src/server.ts b/src/server.ts index bfcd7afc..e44c599d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -185,7 +185,7 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { - const inspection = config.minimalTools + const inspection = config.toolMode !== "full" ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -961,7 +961,7 @@ function createMcpServer( ); } - if (!config.minimalTools) { + if (config.toolMode === "full") { registerAppTool( server, toolNames.grep, @@ -1177,7 +1177,7 @@ function createMcpServer( toolNames.shell, { title: config.toolNaming === "short" ? "Bash" : "Run shell", - description: config.minimalTools + description: config.toolMode !== "full" ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, inputSchema: { From 908c9a0b8081361925b7b2f498128d55316ed55b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:16:05 +0530 Subject: [PATCH 08/73] feat(tools): add workspace-confined patch engine --- package.json | 2 +- src/apply-patch.test.ts | 100 +++++++++++++ src/apply-patch.ts | 303 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 src/apply-patch.test.ts create mode 100644 src/apply-patch.ts diff --git a/package.json b/package.json index 7962ae98..e83edbea 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/apply-patch.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts new file mode 100644 index 00000000..7720ce7b --- /dev/null +++ b/src/apply-patch.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyPatch, parsePatch } from "./apply-patch.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-apply-patch-")); +await writeFile(join(root, "alpha.txt"), "one\ntwo\nthree\n"); +await writeFile(join(root, "remove.txt"), "remove me\n"); +await writeFile(join(root, "windows.txt"), "first\r\nsecond\r\n"); + +const result = await applyPatch( + root, + `*** Begin Patch +*** Add File: nested/added.txt ++new ++file +*** Update File: alpha.txt +@@ + one +-two ++changed + three +*** Update File: windows.txt +@@ + first +-second ++updated +*** Delete File: remove.txt +*** End Patch`, +); + +assert.deepEqual(result.files, [ + { path: "nested/added.txt", operation: "add" }, + { path: "alpha.txt", operation: "update" }, + { path: "windows.txt", operation: "update" }, + { path: "remove.txt", operation: "delete" }, +]); +assert.equal(await readFile(join(root, "nested/added.txt"), "utf8"), "new\nfile\n"); +assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthree\n"); +assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); +await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); + +const moveResult = await applyPatch( + root, + `*** Begin Patch +*** Update File: alpha.txt +*** Move to: moved/alpha.txt +@@ +-one ++ONE + changed +*** End Patch`, +); +assert.deepEqual(moveResult.files, [ + { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, +]); +assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); + +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: ../escape.txt ++no +*** End Patch`, + ), + /path escapes the workspace/, +); + +const outside = await mkdtemp(join(tmpdir(), "devspace-apply-patch-outside-")); +await symlink(outside, join(root, "outside-link")); +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: outside-link/escape.txt ++no +*** End Patch`, + ), + /path resolves outside the workspace/, +); + +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Update File: moved/alpha.txt +@@ +-not present ++replacement +*** End Patch`, + ), + /could not find hunk context/, +); +assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); + +assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); +assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); diff --git a/src/apply-patch.ts b/src/apply-patch.ts new file mode 100644 index 00000000..c76fdbbe --- /dev/null +++ b/src/apply-patch.ts @@ -0,0 +1,303 @@ +import { constants } from "node:fs"; +import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; + +export type PatchOperation = "add" | "update" | "delete" | "move"; + +export interface AppliedPatchFile { + path: string; + previousPath?: string; + operation: PatchOperation; +} + +export interface ApplyPatchResult { + files: AppliedPatchFile[]; +} + +interface HunkLine { + kind: "context" | "add" | "remove"; + text: string; +} + +interface UpdateHunk { + lines: HunkLine[]; +} + +type PatchAction = + | { kind: "add"; path: string; content: string } + | { kind: "delete"; path: string } + | { kind: "update"; path: string; moveTo?: string; hunks: UpdateHunk[] }; + +interface StagedFile { + content: string; + mode?: number; +} + +function patchError(message: string): Error { + return new Error(`Invalid patch: ${message}`); +} + +export function parsePatch(patch: string): PatchAction[] { + const lines = patch.replace(/\r\n/g, "\n").split("\n"); + if (lines.at(-1) === "") lines.pop(); + if (lines.shift() !== "*** Begin Patch") { + throw patchError("missing *** Begin Patch marker"); + } + if (lines.pop() !== "*** End Patch") { + throw patchError("missing *** End Patch marker"); + } + + const actions: PatchAction[] = []; + let index = 0; + + while (index < lines.length) { + const header = lines[index++]; + + if (header.startsWith("*** Add File: ")) { + const path = header.slice("*** Add File: ".length); + const content: string[] = []; + while (index < lines.length && !lines[index].startsWith("*** ")) { + const line = lines[index++]; + if (!line.startsWith("+")) { + throw patchError(`added file line must start with +: ${line}`); + } + content.push(line.slice(1)); + } + actions.push({ + kind: "add", + path, + content: content.length > 0 ? `${content.join("\n")}\n` : "", + }); + continue; + } + + if (header.startsWith("*** Delete File: ")) { + actions.push({ kind: "delete", path: header.slice("*** Delete File: ".length) }); + continue; + } + + if (header.startsWith("*** Update File: ")) { + const path = header.slice("*** Update File: ".length); + let moveTo: string | undefined; + const hunks: UpdateHunk[] = []; + + if (lines[index]?.startsWith("*** Move to: ")) { + moveTo = lines[index++].slice("*** Move to: ".length); + } + + while (index < lines.length && !lines[index].startsWith("*** ")) { + const hunkHeader = lines[index++]; + if (!hunkHeader.startsWith("@@")) { + throw patchError(`expected hunk header, received: ${hunkHeader}`); + } + + const hunkLines: HunkLine[] = []; + while ( + index < lines.length && + !lines[index].startsWith("@@") && + !lines[index].startsWith("*** ") + ) { + const line = lines[index++]; + if (line.startsWith(" ")) hunkLines.push({ kind: "context", text: line.slice(1) }); + else if (line.startsWith("+")) hunkLines.push({ kind: "add", text: line.slice(1) }); + else if (line.startsWith("-")) hunkLines.push({ kind: "remove", text: line.slice(1) }); + else if (line === "\\ No newline at end of file") continue; + else throw patchError(`hunk line must start with space, +, or -: ${line}`); + } + + if (hunkLines.length === 0) throw patchError(`empty update hunk for ${path}`); + hunks.push({ lines: hunkLines }); + } + + if (hunks.length === 0 && !moveTo) { + throw patchError(`update for ${path} has no hunks or move destination`); + } + actions.push({ kind: "update", path, moveTo, hunks }); + continue; + } + + throw patchError(`unknown action header: ${header}`); + } + + if (actions.length === 0) throw patchError("contains no file actions"); + return actions; +} + +function isInside(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +async function resolveConfinedPath(root: string, input: string): Promise { + if (!input || input.includes("\0") || isAbsolute(input)) { + throw patchError(`path must be relative to the workspace: ${input}`); + } + + const rootPath = await realpath(root); + const target = resolve(rootPath, input); + if (!isInside(rootPath, target)) { + throw patchError(`path escapes the workspace: ${input}`); + } + + let existing = target; + while (true) { + try { + const resolved = await realpath(existing); + if (!isInside(rootPath, resolved)) { + throw patchError(`path resolves outside the workspace: ${input}`); + } + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + const parent = dirname(existing); + if (parent === existing) throw error; + existing = parent; + } + } + + return target; +} + +function splitFile(content: string): { lines: string[]; eol: string; finalNewline: boolean } { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const normalized = content.replace(/\r\n/g, "\n"); + const finalNewline = normalized.endsWith("\n"); + const lines = normalized.split("\n"); + if (finalNewline) lines.pop(); + return { lines, eol, finalNewline }; +} + +function findSequence(haystack: string[], needle: string[], from: number): number { + if (needle.length === 0) return from; + + const matchAt = (index: number, normalize: (value: string) => string): boolean => + needle.every((line, offset) => normalize(haystack[index + offset] ?? "") === normalize(line)); + + for (const normalize of [ + (value: string) => value, + (value: string) => value.trimEnd(), + (value: string) => value.trim(), + ]) { + for (let index = from; index <= haystack.length - needle.length; index += 1) { + if (matchAt(index, normalize)) return index; + } + } + + return -1; +} + +function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string { + const file = splitFile(content); + const lines = [...file.lines]; + let cursor = 0; + + for (const hunk of hunks) { + const oldLines = hunk.lines + .filter((line) => line.kind !== "add") + .map((line) => line.text); + const newLines = hunk.lines + .filter((line) => line.kind !== "remove") + .map((line) => line.text); + const index = findSequence(lines, oldLines, cursor); + + if (index < 0) { + const preview = oldLines.slice(0, 3).join("\n"); + throw patchError(`could not find hunk context in ${path}: ${preview}`); + } + + lines.splice(index, oldLines.length, ...newLines); + cursor = index + newLines.length; + } + + const normalized = lines.join("\n") + (file.finalNewline ? "\n" : ""); + return file.eol === "\r\n" ? normalized.replace(/\n/g, "\r\n") : normalized; +} + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function applyPatch(root: string, patch: string): Promise { + const actions = parsePatch(patch); + const staged = new Map(); + const results: AppliedPatchFile[] = []; + + const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { + const absolute = await resolveConfinedPath(root, displayPath); + if (staged.has(absolute)) { + const file = staged.get(absolute); + if (!file) throw patchError(`file does not exist: ${displayPath}`); + return { absolute, file }; + } + if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); + const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + const file = { content: await readFile(absolute, "utf8"), mode: metadata.mode }; + staged.set(absolute, file); + return { absolute, file }; + }; + + for (const action of actions) { + if (action.kind === "add") { + const absolute = await resolveConfinedPath(root, action.path); + if (staged.get(absolute) || (!staged.has(absolute) && (await fileExists(absolute)))) { + throw patchError(`file already exists: ${action.path}`); + } + staged.set(absolute, { content: action.content }); + results.push({ path: action.path, operation: "add" }); + continue; + } + + const { absolute, file } = await load(action.path); + + if (action.kind === "delete") { + staged.set(absolute, null); + results.push({ path: action.path, operation: "delete" }); + continue; + } + + const updated = applyHunks(action.path, file.content, action.hunks); + if (action.moveTo) { + const destination = await resolveConfinedPath(root, action.moveTo); + if ( + destination !== absolute && + (staged.get(destination) || (!staged.has(destination) && (await fileExists(destination)))) + ) { + throw patchError(`move destination already exists: ${action.moveTo}`); + } + staged.set(absolute, null); + staged.set(destination, { content: updated, mode: file.mode }); + results.push({ path: action.moveTo, previousPath: action.path, operation: "move" }); + } else { + staged.set(absolute, { content: updated, mode: file.mode }); + results.push({ path: action.path, operation: "update" }); + } + } + + const pendingWrites: Array<{ temporary: string; destination: string }> = []; + for (const [destination, file] of staged) { + if (!file) continue; + await mkdir(dirname(destination), { recursive: true }); + const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; + await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); + pendingWrites.push({ temporary, destination }); + } + + try { + for (const write of pendingWrites) await rename(write.temporary, write.destination); + for (const [path, file] of staged) { + if (!file) await rm(path, { force: true }); + } + } catch (error) { + await Promise.all(pendingWrites.map(({ temporary }) => rm(temporary, { force: true }))); + throw error; + } + + return { files: results }; +} From a0e6caa6abaacf276f754ab1cd2c05b9b3397f52 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:17:21 +0530 Subject: [PATCH 09/73] feat(tools): expose apply_patch in codex mode --- src/server.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/server.ts b/src/server.ts index e44c599d..324e899e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ import { import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; +import { applyPatch } from "./apply-patch.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; import { logEvent, @@ -185,6 +186,10 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + 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, and ${toolNames.shell} for inspection, tests, builds, and other commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + } + const inspection = config.toolMode !== "full" ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -733,6 +738,7 @@ function createMcpServer( }, ); + if (config.toolMode !== "codex") { registerAppTool( server, toolNames.write, @@ -896,6 +902,69 @@ function createMcpServer( }; }, ); + } + + if (config.toolMode === "codex") { + registerAppTool( + server, + "apply_patch", + { + title: "Apply patch", + description: + "Apply one Codex-style patch inside an open workspace. Supports adding, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + patch: z + .string() + .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), + }, + outputSchema: resultOutputSchema({ + files: z.array( + z.object({ + path: z.string(), + previousPath: z.string().optional(), + operation: z.enum(["add", "update", "delete", "move"]), + }), + ), + }), + ...toolWidgetDescriptorMeta(config, "edit"), + annotations: EDIT_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, patch }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const applied = await applyPatch(workspace.root, patch); + const paths = applied.files.map((file) => file.path).join(", "); + const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; + const content = [textBlock(result)]; + + logToolCall(config, { + tool: "apply_patch", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "apply_patch", + card: { + workspaceId, + summary: { files: applied.files.length }, + payload: { patch }, + }, + }, + structuredContent: { + result, + files: applied.files, + }, + }; + }, + ); + } if (config.widgets === "changes") { registerAppTool( From 60c43dc29f2bcba5effc691a090277bd13b84a67 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:19:27 +0530 Subject: [PATCH 10/73] feat(exec): add resumable process session manager --- package.json | 2 +- src/process-sessions.test.ts | 77 ++++++++++++ src/process-sessions.ts | 235 +++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/process-sessions.test.ts create mode 100644 src/process-sessions.ts diff --git a/package.json b/package.json index e83edbea..6f566199 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/apply-patch.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/apply-patch.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts new file mode 100644 index 00000000..16b2c120 --- /dev/null +++ b/src/process-sessions.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { ProcessSessionManager } from "./process-sessions.js"; + +const manager = new ProcessSessionManager({ + maxBufferCharacters: 1_024, + completedSessionTtlMs: 1_000, +}); + +const node = JSON.stringify(process.execPath); + +const foreground = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "console.log('foreground')"`, + yieldTimeMs: 2_000, +}); +assert.equal(foreground.running, false); +assert.equal(foreground.exitCode, 0); +assert.match(foreground.output, /foreground/); +assert.equal(foreground.sessionId, undefined); + +const background = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setTimeout(() => console.log('finished'), 100)"`, + yieldTimeMs: 5, +}); +assert.equal(background.running, true); +assert.ok(background.sessionId); + +await assert.rejects( + manager.write({ + workspaceId: "workspace-b", + sessionId: background.sessionId, + yieldTimeMs: 1, + }), + /does not belong to workspace/, +); + +const completed = await manager.write({ + workspaceId: "workspace-a", + sessionId: background.sessionId, + yieldTimeMs: 2_000, +}); +assert.equal(completed.running, false); +assert.equal(completed.exitCode, 0); +assert.match(completed.output, /finished/); + +const interactive = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, + yieldTimeMs: 5, +}); +assert.equal(interactive.running, true); +assert.ok(interactive.sessionId); + +const inputResult = await manager.write({ + workspaceId: "workspace-a", + sessionId: interactive.sessionId, + chars: "hello\n", + yieldTimeMs: 2_000, +}); +assert.equal(inputResult.running, false); +assert.match(inputResult.output, /input:hello/); + +const buffered = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "console.log('x'.repeat(5000)); setTimeout(() => {}, 100)"`, + yieldTimeMs: 50, + maxOutputTokens: 100, +}); +assert.equal(buffered.outputTruncated, true); +if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); + +manager.shutdown(); diff --git a/src/process-sessions.ts b/src/process-sessions.ts new file mode 100644 index 00000000..83125c0a --- /dev/null +++ b/src/process-sessions.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +const DEFAULT_YIELD_MS = 10_000; +const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; +const DEFAULT_BUFFER_CHARACTERS = 1_000_000; +const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1_000; + +export interface StartCommandInput { + workspaceId: string; + command: string; + cwd: string; + yieldTimeMs?: number; + maxOutputTokens?: number; +} + +export interface WriteStdinInput { + workspaceId: string; + sessionId: string; + chars?: string; + yieldTimeMs?: number; + maxOutputTokens?: number; +} + +export interface ProcessSnapshot { + sessionId?: string; + output: string; + outputTruncated: boolean; + running: boolean; + exitCode?: number; + signal?: NodeJS.Signals; + wallTimeMs: number; +} + +interface ProcessSession { + id: string; + workspaceId: string; + child: ChildProcessWithoutNullStreams; + startedAt: number; + buffer: string; + bufferStart: number; + consumedThrough: number; + running: boolean; + exitCode?: number; + signal?: NodeJS.Signals; + exitPromise: Promise; + resolveExit: () => void; + cleanupTimer?: NodeJS.Timeout; +} + +interface ProcessSessionManagerOptions { + maxBufferCharacters?: number; + completedSessionTtlMs?: number; +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0) throw new Error("Duration and output limits must be non-negative."); + return Math.min(Math.floor(value), maximum); +} + +function shellCommand(command: string): { executable: string; args: string[] } { + if (process.platform === "win32") { + return { + executable: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", command], + }; + } + + return { + executable: process.env.SHELL ?? "/bin/bash", + args: ["-lc", command], + }; +} + +function truncateOutput(output: string, maxOutputTokens: number): { output: string; truncated: boolean } { + const maxCharacters = Math.max(256, maxOutputTokens * 4); + if (output.length <= maxCharacters) return { output, truncated: false }; + + const marker = "\n... output truncated ...\n"; + const available = maxCharacters - marker.length; + const head = Math.ceil(available / 2); + const tail = Math.floor(available / 2); + return { + output: output.slice(0, head) + marker + output.slice(output.length - tail), + truncated: true, + }; +} + +export class ProcessSessionManager { + private readonly sessions = new Map(); + private readonly maxBufferCharacters: number; + private readonly completedSessionTtlMs: number; + + constructor(options: ProcessSessionManagerOptions = {}) { + this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS; + this.completedSessionTtlMs = options.completedSessionTtlMs ?? COMPLETED_SESSION_TTL_MS; + } + + async start(input: StartCommandInput): Promise { + const id = randomUUID(); + const shell = shellCommand(input.command); + const child = spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: process.env, + stdio: "pipe", + windowsHide: true, + }); + + let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + const session: ProcessSession = { + id, + workspaceId: input.workspaceId, + child, + startedAt: Date.now(), + buffer: "", + bufferStart: 0, + consumedThrough: 0, + running: true, + exitPromise, + resolveExit, + }; + this.sessions.set(id, session); + + child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.on("error", (error) => this.append(session, `${error.message}\n`)); + child.on("close", (code, signal) => { + session.running = false; + session.exitCode = code ?? undefined; + session.signal = signal ?? undefined; + session.resolveExit(); + session.cleanupTimer = setTimeout(() => this.sessions.delete(id), this.completedSessionTtlMs); + session.cleanupTimer.unref(); + }); + + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + await Promise.race([ + session.exitPromise, + new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), + ]); + + const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; + } + + async write(input: WriteStdinInput): Promise { + const session = this.getOwnedSession(input.workspaceId, input.sessionId); + const chars = input.chars ?? ""; + + if (chars.includes("\u0003") && session.running) { + session.child.kill("SIGINT"); + } + const writableChars = chars.replaceAll("\u0003", ""); + if (writableChars && session.running) session.child.stdin.write(writableChars); + + const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; + if (!hasUnreadOutput && session.running) { + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + await Promise.race([ + session.exitPromise, + new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), + ]); + } + + const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; + } + + terminate(workspaceId: string, sessionId: string): void { + const session = this.getOwnedSession(workspaceId, sessionId); + if (session.running) session.child.kill("SIGTERM"); + } + + shutdown(): void { + for (const session of this.sessions.values()) { + if (session.cleanupTimer) clearTimeout(session.cleanupTimer); + if (session.running) session.child.kill("SIGTERM"); + } + this.sessions.clear(); + } + + private append(session: ProcessSession, output: string): void { + session.buffer += output; + if (session.buffer.length <= this.maxBufferCharacters) return; + + const remove = session.buffer.length - this.maxBufferCharacters; + session.buffer = session.buffer.slice(remove); + session.bufferStart += remove; + } + + private consume(session: ProcessSession, maxOutputTokens?: number): ProcessSnapshot { + const missedOutput = session.consumedThrough < session.bufferStart; + const start = Math.max(0, session.consumedThrough - session.bufferStart); + const unread = session.buffer.slice(start); + session.consumedThrough = session.bufferStart + session.buffer.length; + + const limit = boundedInteger( + maxOutputTokens, + DEFAULT_MAX_OUTPUT_TOKENS, + 100_000, + ); + const truncated = truncateOutput(unread, limit); + + return { + sessionId: session.running ? session.id : undefined, + output: truncated.output, + outputTruncated: missedOutput || truncated.truncated, + running: session.running, + exitCode: session.exitCode, + signal: session.signal, + wallTimeMs: Date.now() - session.startedAt, + }; + } + + private getOwnedSession(workspaceId: string, sessionId: string): ProcessSession { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Unknown process session: ${sessionId}`); + if (session.workspaceId !== workspaceId) { + throw new Error(`Process session ${sessionId} does not belong to workspace ${workspaceId}.`); + } + return session; + } + + private removeSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.cleanupTimer) clearTimeout(session.cleanupTimer); + this.sessions.delete(sessionId); + } +} From a4aa58a5e7bb164d00395dec58679289db426367 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:21:03 +0530 Subject: [PATCH 11/73] feat(exec): expose exec_command and write_stdin --- src/server.ts | 194 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 324e899e..d07ed587 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,7 @@ import { writeFileTool, } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -187,7 +188,7 @@ function toolNamesFor(config: ServerConfig): ToolNames { function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { 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, and ${toolNames.shell} for inspection, tests, builds, and other commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + 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.`; } const inspection = config.toolMode !== "full" @@ -457,10 +458,191 @@ async function assertWorkspaceAppAssets(): Promise { } } +function processResult(snapshot: ProcessSnapshot): string { + const status = snapshot.running + ? `Process running with session ID ${snapshot.sessionId}.` + : snapshot.signal + ? `Process exited after signal ${snapshot.signal}.` + : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`; + return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; +} + +function processOutputSchema(): z.ZodRawShape { + return resultOutputSchema({ + sessionId: z.string().optional(), + running: z.boolean(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + wallTimeMs: z.number().nonnegative(), + outputTruncated: z.boolean(), + }); +} + +function processToolResponse( + tool: "exec_command" | "write_stdin", + workspaceId: string, + snapshot: ProcessSnapshot, + summary: Record, +) { + const result = processResult(snapshot); + const content = [textBlock(result)]; + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary, + payload: { content }, + }, + }, + structuredContent: { + result, + sessionId: snapshot.sessionId, + running: snapshot.running, + exitCode: snapshot.exitCode, + signal: snapshot.signal, + wallTimeMs: snapshot.wallTimeMs, + outputTruncated: snapshot.outputTruncated, + }, + }; +} + +function registerCodexProcessTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + processSessions: ProcessSessionManager, +): void { + registerAppTool( + server, + "exec_command", + { + title: "Execute command", + description: + "Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes. Call open_workspace first and pass workspaceId.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + cmd: z.string().min(1).describe("Shell command to execute."), + workingDirectory: z + .string() + .optional() + .describe("Working directory relative to the workspace root. Defaults to the workspace root."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Milliseconds to wait before returning a running session. Defaults to 10000."), + maxOutputTokens: z + .number() + .int() + .positive() + .max(100_000) + .optional() + .describe("Approximate output token budget. Defaults to 10000."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, cmd, workingDirectory, yieldTimeMs, maxOutputTokens }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); + const snapshot = await processSessions.start({ + workspaceId, + command: cmd, + cwd, + yieldTimeMs, + maxOutputTokens, + }); + + logToolCall(config, { + tool: "exec_command", + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: cmd, + commandLength: cmd.length, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return processToolResponse("exec_command", workspaceId, snapshot, { + command: cmd, + workingDirectory: workingDirectory ?? ".", + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); + + registerAppTool( + server, + "write_stdin", + { + title: "Write to process", + description: + "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier used to start the process."), + sessionId: z.string().describe("Process session identifier returned by exec_command."), + chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Milliseconds to wait for process output or completion. Defaults to 10000."), + maxOutputTokens: z + .number() + .int() + .positive() + .max(100_000) + .optional() + .describe("Approximate output token budget. Defaults to 10000."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, sessionId, chars, yieldTimeMs, maxOutputTokens }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const snapshot = await processSessions.write({ + workspaceId, + sessionId, + chars, + yieldTimeMs, + maxOutputTokens, + }); + + logToolCall(config, { + tool: "write_stdin", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return processToolResponse("write_stdin", workspaceId, snapshot, { + sessionId, + charactersWritten: chars?.length ?? 0, + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); +} + function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + processSessions: ProcessSessionManager, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -1241,6 +1423,7 @@ function createMcpServer( ); } + if (config.toolMode !== "codex") { registerAppTool( server, toolNames.shell, @@ -1330,6 +1513,11 @@ function createMcpServer( }; }, ); + } + + if (config.toolMode === "codex") { + registerCodexProcessTools(server, config, workspaces, processSessions); + } return server; } @@ -1354,6 +1542,7 @@ export function createServer(config = loadConfig()): RunningServer { const workspaceStore = createWorkspaceStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); + const processSessions = new ProcessSessionManager(); if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1477,7 +1666,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints); + const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1503,6 +1692,7 @@ export function createServer(config = loadConfig()): RunningServer { close: () => { if (closed) return; closed = true; + processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); }, From e3938ca6d0a236325bcd0e76766022c0583d20ee Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:23:26 +0530 Subject: [PATCH 12/73] feat(exec): support optional PTY sessions --- package-lock.json | 23 ++++- package.json | 3 + src/process-sessions.test.ts | 23 +++++ src/process-sessions.ts | 193 ++++++++++++++++++++++++++--------- src/server.ts | 17 ++- 5 files changed, 205 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 690ad496..c2b84f6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,9 @@ }, "engines": { "node": ">=22.19 <27" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -572,7 +575,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4693,6 +4696,24 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 6f566199..a9166934 100644 --- a/package.json +++ b/package.json @@ -61,5 +61,8 @@ "protobufjs": "7.6.4", "ws": "8.21.0", "undici": "8.5.0" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 16b2c120..1cceb8eb 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -74,4 +74,27 @@ const buffered = await manager.start({ assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); +const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, +}); +assert.equal(pty.running, true); +assert.ok(pty.sessionId); + +const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + chars: "continue\r", + columns: 120, + rows: 30, + yieldTimeMs: 2_000, +}); +assert.equal(resizedPty.running, false); +assert.match(resizedPty.output, /columns:120/); + manager.shutdown(); diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 83125c0a..83aea56b 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,15 +1,20 @@ import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn } from "node:child_process"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; const DEFAULT_BUFFER_CHARACTERS = 1_000_000; const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1_000; +const DEFAULT_COLUMNS = 80; +const DEFAULT_ROWS = 24; export interface StartCommandInput { workspaceId: string; command: string; cwd: string; + tty?: boolean; + columns?: number; + rows?: number; yieldTimeMs?: number; maxOutputTokens?: number; } @@ -18,6 +23,8 @@ export interface WriteStdinInput { workspaceId: string; sessionId: string; chars?: string; + columns?: number; + rows?: number; yieldTimeMs?: number; maxOutputTokens?: number; } @@ -28,21 +35,29 @@ export interface ProcessSnapshot { outputTruncated: boolean; running: boolean; exitCode?: number; - signal?: NodeJS.Signals; + signal?: string; wallTimeMs: number; } +interface ManagedProcess { + write(data: string): void; + kill(signal?: NodeJS.Signals): void; + resize?(columns: number, rows: number): void; +} + interface ProcessSession { id: string; workspaceId: string; - child: ChildProcessWithoutNullStreams; + process?: ManagedProcess; startedAt: number; + columns: number; + rows: number; buffer: string; bufferStart: number; consumedThrough: number; running: boolean; exitCode?: number; - signal?: NodeJS.Signals; + signal?: string; exitPromise: Promise; resolveExit: () => void; cleanupTimer?: NodeJS.Timeout; @@ -55,10 +70,20 @@ interface ProcessSessionManagerOptions { function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { if (value === undefined) return fallback; - if (!Number.isFinite(value) || value < 0) throw new Error("Duration and output limits must be non-negative."); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Duration and output limits must be non-negative."); + } return Math.min(Math.floor(value), maximum); } +function terminalSize(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > 1_000) { + throw new Error("Terminal dimensions must be integers between 1 and 1000."); + } + return value; +} + function shellCommand(command: string): { executable: string; args: string[] } { if (process.platform === "win32") { return { @@ -73,6 +98,12 @@ function shellCommand(command: string): { executable: string; args: string[] } { }; } +function processEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +} + function truncateOutput(output: string, maxOutputTokens: number): { output: string; truncated: boolean } { const maxCharacters = Math.max(256, maxOutputTokens * 4); if (output.length <= maxCharacters) return { output, truncated: false }; @@ -98,44 +129,16 @@ export class ProcessSessionManager { } async start(input: StartCommandInput): Promise { - const id = randomUUID(); - const shell = shellCommand(input.command); - const child = spawn(shell.executable, shell.args, { - cwd: input.cwd, - env: process.env, - stdio: "pipe", - windowsHide: true, - }); + const session = this.createSession(input); + this.sessions.set(session.id, session); - let resolveExit = (): void => undefined; - const exitPromise = new Promise((resolve) => { - resolveExit = resolve; - }); - const session: ProcessSession = { - id, - workspaceId: input.workspaceId, - child, - startedAt: Date.now(), - buffer: "", - bufferStart: 0, - consumedThrough: 0, - running: true, - exitPromise, - resolveExit, - }; - this.sessions.set(id, session); - - child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); - child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); - child.on("error", (error) => this.append(session, `${error.message}\n`)); - child.on("close", (code, signal) => { - session.running = false; - session.exitCode = code ?? undefined; - session.signal = signal ?? undefined; - session.resolveExit(); - session.cleanupTimer = setTimeout(() => this.sessions.delete(id), this.completedSessionTtlMs); - session.cleanupTimer.unref(); - }); + try { + if (input.tty) await this.startPty(session, input); + else this.startPipe(session, input); + } catch (error) { + this.sessions.delete(session.id); + throw error; + } const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); await Promise.race([ @@ -152,11 +155,20 @@ export class ProcessSessionManager { const session = this.getOwnedSession(input.workspaceId, input.sessionId); const chars = input.chars ?? ""; + if (input.columns !== undefined || input.rows !== undefined) { + session.columns = terminalSize(input.columns, session.columns); + session.rows = terminalSize(input.rows, session.rows); + if (!session.process?.resize) { + throw new Error(`Process session ${session.id} is not a PTY and cannot be resized.`); + } + session.process.resize(session.columns, session.rows); + } + if (chars.includes("\u0003") && session.running) { - session.child.kill("SIGINT"); + session.process?.kill("SIGINT"); } const writableChars = chars.replaceAll("\u0003", ""); - if (writableChars && session.running) session.child.stdin.write(writableChars); + if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; if (!hasUnreadOutput && session.running) { @@ -174,17 +186,100 @@ export class ProcessSessionManager { terminate(workspaceId: string, sessionId: string): void { const session = this.getOwnedSession(workspaceId, sessionId); - if (session.running) session.child.kill("SIGTERM"); + if (session.running) session.process?.kill("SIGTERM"); } shutdown(): void { for (const session of this.sessions.values()) { if (session.cleanupTimer) clearTimeout(session.cleanupTimer); - if (session.running) session.child.kill("SIGTERM"); + if (session.running) session.process?.kill("SIGTERM"); } this.sessions.clear(); } + private createSession(input: StartCommandInput): ProcessSession { + let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + + return { + id: randomUUID(), + workspaceId: input.workspaceId, + startedAt: Date.now(), + columns: terminalSize(input.columns, DEFAULT_COLUMNS), + rows: terminalSize(input.rows, DEFAULT_ROWS), + buffer: "", + bufferStart: 0, + consumedThrough: 0, + running: true, + exitPromise, + resolveExit, + }; + } + + private startPipe(session: ProcessSession, input: StartCommandInput): void { + const shell = shellCommand(input.command); + const child = spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: process.env, + stdio: "pipe", + windowsHide: true, + }); + + session.process = { + write: (data) => child.stdin.write(data), + kill: (signal) => { + child.kill(signal); + }, + }; + child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.on("error", (error) => this.append(session, `${error.message}\n`)); + child.on("close", (code, signal) => this.finish(session, code ?? undefined, signal ?? undefined)); + } + + private async startPty(session: ProcessSession, input: StartCommandInput): Promise { + let nodePty: typeof import("node-pty"); + try { + nodePty = await import("node-pty"); + } catch { + throw new Error("PTY support requires the optional node-pty dependency."); + } + + const shell = shellCommand(input.command); + const pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment(), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); + + session.process = { + write: (data) => pty.write(data), + kill: (signal) => pty.kill(signal), + resize: (columns, rows) => pty.resize(columns, rows), + }; + pty.onData((data) => this.append(session, data)); + pty.onExit(({ exitCode, signal }) => { + this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); + }); + } + + private finish(session: ProcessSession, exitCode?: number, signal?: string): void { + if (!session.running) return; + session.running = false; + session.exitCode = exitCode; + session.signal = signal; + session.resolveExit(); + session.cleanupTimer = setTimeout( + () => this.sessions.delete(session.id), + this.completedSessionTtlMs, + ); + session.cleanupTimer.unref(); + } + private append(session: ProcessSession, output: string): void { session.buffer += output; if (session.buffer.length <= this.maxBufferCharacters) return; @@ -200,11 +295,7 @@ export class ProcessSessionManager { const unread = session.buffer.slice(start); session.consumedThrough = session.bufferStart + session.buffer.length; - const limit = boundedInteger( - maxOutputTokens, - DEFAULT_MAX_OUTPUT_TOKENS, - 100_000, - ); + const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000); const truncated = truncateOutput(unread, limit); return { diff --git a/src/server.ts b/src/server.ts index d07ed587..96f11249 100644 --- a/src/server.ts +++ b/src/server.ts @@ -524,6 +524,12 @@ function registerCodexProcessTools( inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), cmd: z.string().min(1).describe("Shell command to execute."), + tty: z + .boolean() + .optional() + .describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."), + columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."), + rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."), workingDirectory: z .string() .optional() @@ -547,7 +553,7 @@ function registerCodexProcessTools( ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, cmd, workingDirectory, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); @@ -555,6 +561,9 @@ function registerCodexProcessTools( workspaceId, command: cmd, cwd, + tty, + columns, + rows, yieldTimeMs, maxOutputTokens, }); @@ -590,6 +599,8 @@ function registerCodexProcessTools( workspaceId: z.string().describe("Workspace identifier used to start the process."), sessionId: z.string().describe("Process session identifier returned by exec_command."), chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), + columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."), + rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."), yieldTimeMs: z .number() .int() @@ -609,13 +620,15 @@ function registerCodexProcessTools( ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, sessionId, chars, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const snapshot = await processSessions.write({ workspaceId, sessionId, chars, + columns, + rows, yieldTimeMs, maxOutputTokens, }); From 4fc552d0bd644b04f03e58292c48f24aaff54eae Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:24:23 +0530 Subject: [PATCH 13/73] fix(exec): terminate spawned process groups --- src/process-sessions.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 83aea56b..b6c2c94f 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -220,16 +220,26 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = shellCommand(input.command); + const detached = process.platform !== "win32"; const child = spawn(shell.executable, shell.args, { cwd: input.cwd, env: process.env, stdio: "pipe", windowsHide: true, + detached, }); session.process = { write: (data) => child.stdin.write(data), kill: (signal) => { + if (detached && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + } + } child.kill(signal); }, }; From 6d54923cb7fd3eeae731a1648c06bf245a2fe40d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:17 +0530 Subject: [PATCH 14/73] docs: document codex mode QA and rollout --- docs/chatgpt-coding-workflow.md | 14 +++++++ docs/codex-tool-mode-qa.md | 73 +++++++++++++++++++++++++++++++++ docs/configuration.md | 14 ++++++- src/apply-patch.test.ts | 20 ++++++++- 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 docs/codex-tool-mode-qa.md diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc662..3b0efaf2 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -119,6 +119,20 @@ Legacy names are available with `DEVSPACE_TOOL_NAMING=legacy`: Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. +The experimental Codex-style surface is enabled with +`DEVSPACE_TOOL_MODE=codex`. It exposes: + +- `open_workspace` +- `read` +- `apply_patch` +- `exec_command` +- `write_stdin` + +In this mode, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not +registered. `exec_command` returns a process session ID when a command is still +running after its yield window. Use `write_stdin` to poll it, send input, resize +a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. + ## Show Changes By default, `DEVSPACE_WIDGETS=full`. diff --git a/docs/codex-tool-mode-qa.md b/docs/codex-tool-mode-qa.md new file mode 100644 index 00000000..63a8a4aa --- /dev/null +++ b/docs/codex-tool-mode-qa.md @@ -0,0 +1,73 @@ +# Codex Tool Mode Manual QA + +Run these checks against a disposable Git repository inside an allowed DevSpace +root. Keep the DevSpace server logs visible during the test. + +## Setup + +1. Build the current branch with `npm ci && npm run build`. +2. Start DevSpace with `DEVSPACE_TOOL_MODE=codex devspace serve`. +3. Connect or refresh the DevSpace connector in ChatGPT. +4. Open the disposable repository with `open_workspace`. +5. Confirm the core tools are `open_workspace`, `read`, `apply_patch`, + `exec_command`, and `write_stdin`. +6. Confirm `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are absent. +7. If `DEVSPACE_WIDGETS=changes`, also expect `show_changes`. + +## Apply Patch + +1. Add a text file containing multiple lines and a blank line. +2. Update two separate regions of that file in one patch. +3. Create a nested file, rename it, and then delete it. +4. Patch an existing CRLF file and verify it remains CRLF. +5. Verify executable permissions survive an update and a move. +6. Try to add `../outside.txt`; confirm the tool rejects the path. +7. Patch through a symlink targeting an external directory; confirm rejection. +8. Submit a hunk whose context is absent; confirm no file from that patch changes. +9. With changes widgets enabled, inspect the aggregate diff. + +## Foreground Commands + +1. Run `pwd` and confirm it reports the opened workspace. +2. Run a command in a relative `workingDirectory` and confirm the directory. +3. Write to stdout and stderr; confirm both appear. +4. Exit nonzero; confirm `running=false` and the exit code. +5. Use a small output budget on a noisy command; confirm truncation is reported. + +## Background Sessions + +1. Start a delayed command with a short yield time. +2. Confirm `exec_command` returns `running=true` and a `sessionId`. +3. Poll with empty `chars`; confirm output is not duplicated. +4. Poll until completion; confirm the final exit code and no `sessionId`. +5. Poll the completed session again; confirm it is unknown. +6. Reconnect MCP without restarting DevSpace and confirm polling still works. +7. Restart DevSpace and confirm old process session IDs are invalid. + +## Input, Interrupt, And PTY + +1. Start a program that reads stdin without a PTY and send it a line. +2. Start a long-running process and send `\u0003`; confirm it stops. +3. Start an interactive program with `tty=true`; confirm it detects a TTY. +4. Resize a PTY from 80x24 to 120x30 and verify the observed dimensions. +5. Omit optional dependencies; normal commands must work and `tty=true` must + return the explicit `node-pty` error. + +## Cleanup + +1. Start a non-PTY command that creates a long-running child process. +2. Stop DevSpace with SIGINT and verify both shell and child exit. +3. Repeat with a PTY command. +4. Confirm no process remains after server exit. +5. Repeat session cycles and check that memory use does not steadily increase. + +## Existing Mode Regression + +1. Start without `DEVSPACE_TOOL_MODE`; confirm `minimal` remains the default. +2. Minimal must expose `read`, `write`, `edit`, and `bash`, but not Codex tools + or dedicated search tools. +3. `DEVSPACE_TOOL_MODE=full` must add `grep`, `glob`, and `ls`. +4. With no explicit mode, `DEVSPACE_MINIMAL_TOOLS=1` maps to minimal and `0` + maps to full. +5. Set `DEVSPACE_TOOL_MODE=codex` with `DEVSPACE_MINIMAL_TOOLS=0`; confirm the + explicit Codex mode wins. diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..75848e19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,8 +70,18 @@ MCP clients discover metadata from: | Value | Behavior | | --- | --- | -| `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. | -| `full` | Enables dedicated `grep`, `glob`, and `ls` tools. | +| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | +| `full` | Exposes the minimal tools plus dedicated `grep`, `glob`, and `ls` tools. | +| `codex` | Experimental. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. Existing mutation and shell tools are hidden. | + +`DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when +`DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. +The `codex` mode must be selected through `DEVSPACE_TOOL_MODE`. + +Codex-mode commands run without a PTY by default. Set `tty: true` on +`exec_command` for interactive terminal programs. PTY support uses the optional +`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY +sessions. ## Widgets diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 7720ce7b..9498fcfc 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyPatch, parsePatch } from "./apply-patch.js"; @@ -41,6 +41,7 @@ assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthr assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); +await chmod(join(root, "alpha.txt"), 0o755); const moveResult = await applyPatch( root, `*** Begin Patch @@ -56,6 +57,7 @@ assert.deepEqual(moveResult.files, [ { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, ]); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); await assert.rejects( @@ -96,5 +98,21 @@ await assert.rejects( ); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: should-not-exist.txt ++staged +*** Update File: moved/alpha.txt +@@ +-missing context ++replacement +*** End Patch`, + ), + /could not find hunk context/, +); +await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); + assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); From e70e9bcf6bc770d287b33c6ebf5b50032828e711 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:50 +0530 Subject: [PATCH 15/73] fix(config): keep codex tool names stable --- docs/configuration.md | 3 ++- src/server.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 75848e19..fa3a61de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,7 +76,8 @@ MCP clients discover metadata from: `DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when `DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. -The `codex` mode must be selected through `DEVSPACE_TOOL_MODE`. +The `codex` mode must be selected through `DEVSPACE_TOOL_MODE` and always uses +its fixed short tool names regardless of `DEVSPACE_TOOL_NAMING`. Codex-mode commands run without a PTY by default. Set `tty: true` on `exec_command` for interactive terminal programs. PTY support uses the optional diff --git a/src/server.ts b/src/server.ts index 96f11249..2993c284 100644 --- a/src/server.ts +++ b/src/server.ts @@ -163,7 +163,7 @@ interface ToolLogFields { } function toolNamesFor(config: ServerConfig): ToolNames { - return config.toolNaming === "short" + return config.toolNaming === "short" || config.toolMode === "codex" ? { openWorkspace: "open_workspace", read: "read", From b9c0933ea641d30f42cbb33c569981d14e0f3e49 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:11:47 +0530 Subject: [PATCH 16/73] feat(ui): add codex tool card payloads --- src/apply-patch.test.ts | 4 ++ src/apply-patch.ts | 109 +++++++++++++++++++++++++++++++++++++++- src/server.ts | 19 +++++-- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 9498fcfc..bea06079 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -36,6 +36,10 @@ assert.deepEqual(result.files, [ { path: "windows.txt", operation: "update" }, { path: "remove.txt", operation: "delete" }, ]); +assert.equal(result.additions, 4); +assert.equal(result.removals, 3); +assert.match(result.patch, /diff --git a\/alpha\.txt b\/alpha\.txt/); +assert.match(result.patch, /-two\n\+changed/); assert.equal(await readFile(join(root, "nested/added.txt"), "utf8"), "new\nfile\n"); assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthree\n"); assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index c76fdbbe..b518cdb0 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -12,6 +12,9 @@ export interface AppliedPatchFile { export interface ApplyPatchResult { files: AppliedPatchFile[]; + patch: string; + additions: number; + removals: number; } interface HunkLine { @@ -226,8 +229,19 @@ async function fileExists(path: string): Promise { export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); const staged = new Map(); + const originals = new Map(); + const currentPaths = new Map(); const results: AppliedPatchFile[] = []; + const rememberOriginal = ( + absolute: string, + displayPath: string, + content: string | null, + ): void => { + if (!originals.has(absolute)) originals.set(absolute, { content, path: displayPath }); + currentPaths.set(absolute, displayPath); + }; + const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { const absolute = await resolveConfinedPath(root, displayPath); if (staged.has(absolute)) { @@ -239,6 +253,7 @@ export async function applyPatch(root: string, patch: string): Promise { + const original = originals.get(absolute); + if (!original || original.content === file?.content) return ""; + return unifiedFilePatch( + original.path, + currentPaths.get(absolute) ?? original.path, + original.content, + file?.content ?? null, + ); + }).filter(Boolean); + const unifiedPatch = patches.join("\n"); + const stats = countPatchStats(unifiedPatch); + const pendingWrites: Array<{ temporary: string; destination: string }> = []; for (const [destination, file] of staged) { if (!file) continue; @@ -299,5 +329,82 @@ export async function applyPatch(root: string, patch: string): Promise ` ${line}`), + ...oldChanged.map((line) => `-${line}`), + ...newChanged.map((line) => `+${line}`), + ...after.map((line) => ` ${line}`), + ] + .filter((line): line is string => line !== undefined) + .join("\n"); +} + +function countPatchStats(patch: string): { additions: number; removals: number } { + let additions = 0; + let removals = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + if (line.startsWith("-") && !line.startsWith("---")) removals += 1; + } + return { additions, removals }; } diff --git a/src/server.ts b/src/server.ts index 2993c284..86619323 100644 --- a/src/server.ts +++ b/src/server.ts @@ -486,13 +486,14 @@ function processToolResponse( ) { const result = processResult(snapshot); const content = [textBlock(result)]; + const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); return { content, _meta: { tool, card: { workspaceId, - summary, + summary: { ...summary, ...outputSummary }, payload: { content }, }, }, @@ -1116,6 +1117,8 @@ function createMcpServer( .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), }, outputSchema: resultOutputSchema({ + additions: z.number(), + removals: z.number(), files: z.array( z.object({ path: z.string(), @@ -1134,6 +1137,9 @@ function createMcpServer( const paths = applied.files.map((file) => file.path).join(", "); const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; const content = [textBlock(result)]; + const displayPath = applied.files.length === 1 + ? applied.files[0]?.path + : `${applied.files.length} files`; logToolCall(config, { tool: "apply_patch", @@ -1148,12 +1154,19 @@ function createMcpServer( tool: "apply_patch", card: { workspaceId, - summary: { files: applied.files.length }, - payload: { patch }, + path: displayPath, + summary: { + files: applied.files.length, + additions: applied.additions, + removals: applied.removals, + }, + payload: { patch: applied.patch }, }, }, structuredContent: { result, + additions: applied.additions, + removals: applied.removals, files: applied.files, }, }; From 0164595c3d47cf0d7b59392dcf127a93466dba73 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:12:00 +0530 Subject: [PATCH 17/73] feat(ui): render codex tool cards --- package.json | 2 +- src/ui/card-types.test.ts | 16 ++++++++++++++++ src/ui/card-types.ts | 15 +++++++++++++-- src/ui/workspace-app.tsx | 14 ++++++++++++-- 4 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/ui/card-types.test.ts diff --git a/package.json b/package.json index a9166934..7106d281 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/apply-patch.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts new file mode 100644 index 00000000..6a98ff47 --- /dev/null +++ b/src/ui/card-types.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { + isEditTool, + isShellTool, + isToolName, +} from "./card-types.js"; + +for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { + assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); +} + +assert.equal(isEditTool("apply_patch"), true); +assert.equal(isShellTool("exec_command"), true); +assert.equal(isShellTool("write_stdin"), true); +assert.equal(isEditTool("exec_command"), false); +assert.equal(isShellTool("apply_patch"), false); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 89ec3fe6..107f8f1d 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -10,6 +10,9 @@ export type ToolName = | "list_directory" | "run_shell" | "show_changes" + | "apply_patch" + | "exec_command" + | "write_stdin" | "read" | "write" | "edit" @@ -75,6 +78,9 @@ export function isToolName(value: unknown): value is ToolName { value === "list_directory" || value === "run_shell" || value === "show_changes" || + value === "apply_patch" || + value === "exec_command" || + value === "write_stdin" || value === "read" || value === "write" || value === "edit" || @@ -94,7 +100,7 @@ export function isWriteTool(tool: ToolName): boolean { } export function isEditTool(tool: ToolName): boolean { - return tool === "edit_file" || tool === "edit"; + return tool === "edit_file" || tool === "edit" || tool === "apply_patch"; } export function isSearchTool(tool: ToolName): boolean { @@ -102,7 +108,12 @@ export function isSearchTool(tool: ToolName): boolean { } export function isShellTool(tool: ToolName): boolean { - return tool === "run_shell" || tool === "bash"; + return ( + tool === "run_shell" || + tool === "bash" || + tool === "exec_command" || + tool === "write_stdin" + ); } export function isReviewTool(tool: ToolName): boolean { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 373b3ac2..4be351ca 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -369,7 +369,11 @@ function renderSummaryBadge(card: ToolResultCard): HTMLElement { } if (isShellTool(card.tool)) { - return element("span", { className: "badge", text: `ran · ${String(summary.lines ?? 0)} lines` }); + const state = summary.running === true ? "running" : "ran"; + return element("span", { + className: "badge", + text: `${state} · ${String(summary.lines ?? 0)} lines`, + }); } if (isSearchTool(card.tool)) { @@ -501,6 +505,8 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { case "edit_file": case "edit": return { icon: editIcon(), title: "Edit File", label, tone: "edit" }; + case "apply_patch": + return { icon: editIcon(), title: "Apply Patch", label, tone: "edit" }; case "grep_files": case "grep": return { icon: searchIcon(), title: "Grep", label, tone: "search" }; @@ -513,6 +519,10 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { case "run_shell": case "bash": return { icon: terminalIcon(), title: "Bash", label, tone: "shell" }; + case "exec_command": + return { icon: terminalIcon(), title: "Exec Command", label, tone: "shell" }; + case "write_stdin": + return { icon: terminalIcon(), title: "Process Session", label, tone: "shell" }; case "show_changes": return { icon: reviewIcon(), title: "Show Changes", label, tone: "review" }; } @@ -520,7 +530,7 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { function getToolLabel(card: ToolResultCard): string { if (isShellTool(card.tool)) { - return String(card.summary?.command ?? card.path ?? card.tool); + return String(card.summary?.command ?? card.summary?.sessionId ?? card.path ?? card.tool); } if (isReviewTool(card.tool)) { const count = Number(card.summary?.files ?? card.files?.length ?? 0); From ee1dd50b728f38ffc48e42a3f67aa4253fb8c51c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 15:18:28 +0530 Subject: [PATCH 18/73] fix(exec): wait for interrupted process exit --- src/process-sessions.test.ts | 20 ++++++++++++++++++++ src/process-sessions.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 1cceb8eb..01ee788a 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -64,6 +64,26 @@ const inputResult = await manager.write({ assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); +const interruptible = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setInterval(() => console.log('tick'), 10)"`, + yieldTimeMs: 100, +}); +assert.equal(interruptible.running, true); +assert.ok(interruptible.sessionId); + +await new Promise((resolve) => setTimeout(resolve, 50)); +const interrupted = await manager.write({ + workspaceId: "workspace-a", + sessionId: interruptible.sessionId, + chars: "\u0003", + yieldTimeMs: 2_000, +}); +assert.equal(interrupted.running, false); +assert.equal(interrupted.signal, "SIGINT"); +assert.match(interrupted.output, /tick/); + const buffered = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), diff --git a/src/process-sessions.ts b/src/process-sessions.ts index b6c2c94f..236bc94f 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -164,14 +164,15 @@ export class ProcessSessionManager { session.process.resize(session.columns, session.rows); } - if (chars.includes("\u0003") && session.running) { + const interruptRequested = chars.includes("\u0003") && session.running; + if (interruptRequested) { session.process?.kill("SIGINT"); } const writableChars = chars.replaceAll("\u0003", ""); if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; - if (!hasUnreadOutput && session.running) { + if ((interruptRequested || !hasUnreadOutput) && session.running) { const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); await Promise.race([ session.exitPromise, From d6bff09fe45d45c2067b671d40af69f0a7ac1192 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:03:18 +0530 Subject: [PATCH 19/73] fix(exec): wait after process interactions --- src/process-sessions.test.ts | 20 ++++++++++++++++++++ src/process-sessions.ts | 28 +++++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 01ee788a..706608c3 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -64,6 +64,26 @@ const inputResult = await manager.write({ assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); +const noisyInteractive = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setInterval(() => console.log('tick'), 10); process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, + yieldTimeMs: 100, +}); +assert.equal(noisyInteractive.running, true); +assert.ok(noisyInteractive.sessionId); + +await new Promise((resolve) => setTimeout(resolve, 50)); +const noisyInputResult = await manager.write({ + workspaceId: "workspace-a", + sessionId: noisyInteractive.sessionId, + chars: "hello\n", + yieldTimeMs: 2_000, +}); +assert.equal(noisyInputResult.running, false); +assert.match(noisyInputResult.output, /tick/); +assert.match(noisyInputResult.output, /input:hello/); + const interruptible = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 236bc94f..f79d3193 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -141,10 +141,7 @@ export class ProcessSessionManager { } const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); - await Promise.race([ - session.exitPromise, - new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), - ]); + await this.waitForExit(session, yieldTimeMs); const snapshot = this.consume(session, input.maxOutputTokens); if (!session.running) this.removeSession(session.id); @@ -154,6 +151,8 @@ export class ProcessSessionManager { async write(input: WriteStdinInput): Promise { const session = this.getOwnedSession(input.workspaceId, input.sessionId); const chars = input.chars ?? ""; + const interactionRequested = + chars.length > 0 || input.columns !== undefined || input.rows !== undefined; if (input.columns !== undefined || input.rows !== undefined) { session.columns = terminalSize(input.columns, session.columns); @@ -172,12 +171,9 @@ export class ProcessSessionManager { if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; - if ((interruptRequested || !hasUnreadOutput) && session.running) { + if ((interactionRequested || !hasUnreadOutput) && session.running) { const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); - await Promise.race([ - session.exitPromise, - new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), - ]); + await this.waitForExit(session, yieldTimeMs); } const snapshot = this.consume(session, input.maxOutputTokens); @@ -198,6 +194,20 @@ export class ProcessSessionManager { this.sessions.clear(); } + private async waitForExit(session: ProcessSession, yieldTimeMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + session.exitPromise, + new Promise((resolve) => { + timer = setTimeout(resolve, yieldTimeMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + private createSession(input: StartCommandInput): ProcessSession { let resolveExit = (): void => undefined; const exitPromise = new Promise((resolve) => { From ab72d843d037a93c804052d912d8aa1a52b43bc4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:55:08 +0530 Subject: [PATCH 20/73] refactor(exec): isolate platform shell selection --- package.json | 2 +- src/process-platform.test.ts | 22 ++++++++++++++++++++++ src/process-platform.ts | 33 +++++++++++++++++++++++++++++++++ src/process-sessions.ts | 19 +++---------------- 4 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 src/process-platform.test.ts create mode 100644 src/process-platform.ts diff --git a/package.json b/package.json index 7106d281..a099cfa4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts new file mode 100644 index 00000000..256e1253 --- /dev/null +++ b/src/process-platform.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { resolveShellCommand } from "./process-platform.js"; + +assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { + executable: "C:\\Windows\\cmd.exe", + args: ["/d", "/s", "/c", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { + executable: "/bin/zsh", + args: ["-lc", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/bin/dash" }), { + executable: "/bin/dash", + args: ["-c", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/usr/bin/fish" }), { + executable: "/bin/sh", + args: ["-c", "echo ok"], +}); diff --git a/src/process-platform.ts b/src/process-platform.ts new file mode 100644 index 00000000..88e67753 --- /dev/null +++ b/src/process-platform.ts @@ -0,0 +1,33 @@ +import { basename } from "node:path"; + +export interface ShellCommand { + executable: string; + args: string[]; +} + +const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]); +const POSIX_SHELLS = new Set(["ash", "dash", "sh"]); + +export function resolveShellCommand( + command: string, + platform: NodeJS.Platform = process.platform, + environment: NodeJS.ProcessEnv = process.env, +): ShellCommand { + if (platform === "win32") { + return { + executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", + args: ["/d", "/s", "/c", command], + }; + } + + const configuredShell = environment.SHELL; + const shellName = configuredShell ? basename(configuredShell) : ""; + if (configuredShell && LOGIN_SHELLS.has(shellName)) { + return { executable: configuredShell, args: ["-lc", command] }; + } + if (configuredShell && POSIX_SHELLS.has(shellName)) { + return { executable: configuredShell, args: ["-c", command] }; + } + + return { executable: "/bin/sh", args: ["-c", command] }; +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f79d3193..e0d18f8a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; +import { resolveShellCommand } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; @@ -84,20 +85,6 @@ function terminalSize(value: number | undefined, fallback: number): number { return value; } -function shellCommand(command: string): { executable: string; args: string[] } { - if (process.platform === "win32") { - return { - executable: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", command], - }; - } - - return { - executable: process.env.SHELL ?? "/bin/bash", - args: ["-lc", command], - }; -} - function processEnvironment(): Record { return Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -230,7 +217,7 @@ export class ProcessSessionManager { } private startPipe(session: ProcessSession, input: StartCommandInput): void { - const shell = shellCommand(input.command); + const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; const child = spawn(shell.executable, shell.args, { cwd: input.cwd, @@ -268,7 +255,7 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - const shell = shellCommand(input.command); + const shell = resolveShellCommand(input.command); const pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, env: processEnvironment(), From 4d5a37c485e511faba3b05a53293408145396930 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:56:43 +0530 Subject: [PATCH 21/73] fix(exec): terminate process trees on Windows --- src/process-platform.test.ts | 41 ++++++++++++++++++++++++++++++++- src/process-platform.ts | 44 ++++++++++++++++++++++++++++++++++++ src/process-sessions.ts | 14 ++---------- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 256e1253..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { resolveShellCommand } from "./process-platform.js"; +import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", @@ -20,3 +20,42 @@ assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/usr/bin/fish executable: "/bin/sh", args: ["-c", "echo ok"], }); + +const windowsCalls: string[] = []; +terminateProcessTree( + { pid: 42, kill: (signal) => (windowsCalls.push(`child:${signal}`), true) }, + "SIGTERM", + false, + { + platform: "win32", + killGroup: () => undefined, + killWindowsTree: (pid) => (windowsCalls.push(`tree:${pid}`), true), + }, +); +assert.deepEqual(windowsCalls, ["tree:42"]); + +const posixCalls: string[] = []; +terminateProcessTree( + { pid: 43, kill: (signal) => (posixCalls.push(`child:${signal}`), true) }, + "SIGINT", + true, + { + platform: "darwin", + killGroup: (pid, signal) => posixCalls.push(`group:${pid}:${signal}`), + killWindowsTree: () => false, + }, +); +assert.deepEqual(posixCalls, ["group:43:SIGINT"]); + +const fallbackCalls: string[] = []; +terminateProcessTree( + { pid: 44, kill: (signal) => (fallbackCalls.push(`child:${signal}`), true) }, + "SIGTERM", + false, + { + platform: "linux", + killGroup: () => undefined, + killWindowsTree: () => false, + }, +); +assert.deepEqual(fallbackCalls, ["child:SIGTERM"]); diff --git a/src/process-platform.ts b/src/process-platform.ts index 88e67753..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -1,10 +1,34 @@ import { basename } from "node:path"; +import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; args: string[]; } +export interface KillableProcess { + pid?: number; + kill(signal?: NodeJS.Signals): boolean; +} + +interface ProcessTreeRuntime { + platform: NodeJS.Platform; + killGroup(pid: number, signal: NodeJS.Signals): void; + killWindowsTree(pid: number): boolean; +} + +const defaultProcessTreeRuntime: ProcessTreeRuntime = { + platform: process.platform, + killGroup: (pid, signal) => process.kill(-pid, signal), + killWindowsTree: (pid) => { + const result = spawnSync("taskkill.exe", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return !result.error && result.status === 0; + }, +}; + const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]); const POSIX_SHELLS = new Set(["ash", "dash", "sh"]); @@ -31,3 +55,23 @@ export function resolveShellCommand( return { executable: "/bin/sh", args: ["-c", command] }; } + +export function terminateProcessTree( + child: KillableProcess, + signal: NodeJS.Signals, + detached: boolean, + runtime: ProcessTreeRuntime = defaultProcessTreeRuntime, +): void { + if (runtime.platform === "win32" && child.pid) { + if (runtime.killWindowsTree(child.pid)) return; + } else if (detached && child.pid) { + try { + runtime.killGroup(child.pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + } + } + + child.kill(signal); +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts index e0d18f8a..18c974c7 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { resolveShellCommand } from "./process-platform.js"; +import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; @@ -229,17 +229,7 @@ export class ProcessSessionManager { session.process = { write: (data) => child.stdin.write(data), - kill: (signal) => { - if (detached && child.pid) { - try { - process.kill(-child.pid, signal); - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return; - } - } - child.kill(signal); - }, + kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached), }; child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); From d02ac26178a4d9950fc3ed5ad9ab2eedb171242f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:58:12 +0530 Subject: [PATCH 22/73] fix(patch): replace existing files on Windows --- src/apply-patch.test.ts | 8 +++++++- src/apply-patch.ts | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index bea06079..c9bea765 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -2,9 +2,15 @@ import assert from "node:assert/strict"; import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyPatch, parsePatch } from "./apply-patch.js"; +import { applyPatch, parsePatch, replaceFile } from "./apply-patch.js"; const root = await mkdtemp(join(tmpdir(), "devspace-apply-patch-")); +const replacement = join(root, "replacement.txt"); +const replacementTemporary = join(root, "replacement.tmp"); +await writeFile(replacement, "old\n"); +await writeFile(replacementTemporary, "new\n"); +await replaceFile(replacementTemporary, replacement, true, "win32"); +assert.equal(await readFile(replacement, "utf8"), "new\n"); await writeFile(join(root, "alpha.txt"), "one\ntwo\nthree\n"); await writeFile(join(root, "remove.txt"), "remove me\n"); await writeFile(join(root, "windows.txt"), "first\r\nsecond\r\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index b518cdb0..43c3962d 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -226,6 +226,28 @@ async function fileExists(path: string): Promise { } } +export async function replaceFile( + temporary: string, + destination: string, + destinationExists: boolean, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "win32" || !destinationExists) { + await rename(temporary, destination); + return; + } + + const backup = `${temporary}.original`; + await rename(destination, backup); + try { + await rename(temporary, destination); + } catch (error) { + await rename(backup, destination); + throw error; + } + await rm(backup, { force: true }); +} + export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); const staged = new Map(); @@ -310,17 +332,27 @@ export async function applyPatch(root: string, patch: string): Promise = []; + const pendingWrites: Array<{ + temporary: string; + destination: string; + destinationExists: boolean; + }> = []; for (const [destination, file] of staged) { if (!file) continue; await mkdir(dirname(destination), { recursive: true }); const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); - pendingWrites.push({ temporary, destination }); + pendingWrites.push({ + temporary, + destination, + destinationExists: originals.get(destination)?.content !== null, + }); } try { - for (const write of pendingWrites) await rename(write.temporary, write.destination); + for (const write of pendingWrites) { + await replaceFile(write.temporary, write.destination, write.destinationExists); + } for (const [path, file] of staged) { if (!file) await rm(path, { force: true }); } From fa6264215e2f4142daa609da94f116834a27f662 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:59:01 +0530 Subject: [PATCH 23/73] test(tools): account for cross-platform semantics --- src/apply-patch.test.ts | 8 +++++--- src/process-sessions.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index c9bea765..d69901e6 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -51,7 +51,7 @@ assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthr assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); -await chmod(join(root, "alpha.txt"), 0o755); +if (process.platform !== "win32") await chmod(join(root, "alpha.txt"), 0o755); const moveResult = await applyPatch( root, `*** Begin Patch @@ -67,7 +67,9 @@ assert.deepEqual(moveResult.files, [ { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, ]); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); -assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); +if (process.platform !== "win32") { + assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); +} await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); await assert.rejects( @@ -82,7 +84,7 @@ await assert.rejects( ); const outside = await mkdtemp(join(tmpdir(), "devspace-apply-patch-outside-")); -await symlink(outside, join(root, "outside-link")); +await symlink(outside, join(root, "outside-link"), process.platform === "win32" ? "junction" : "dir"); await assert.rejects( applyPatch( root, diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 706608c3..94f5adcb 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -101,7 +101,7 @@ const interrupted = await manager.write({ yieldTimeMs: 2_000, }); assert.equal(interrupted.running, false); -assert.equal(interrupted.signal, "SIGINT"); +if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); assert.match(interrupted.output, /tick/); const buffered = await manager.start({ From 557d813a5ef0332f2045ca5ee7fe50957c29235d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:04:05 +0530 Subject: [PATCH 24/73] fix(exec): quote Windows commands consistently --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- src/process-sessions.test.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 39b494d8..caaaefb8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", "echo ok"], + args: ["/d", "/s", "/c", '"echo ok"'], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d73..ba99a4b9 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", command], + args: ["/d", "/s", "/c", `"${command}"`], }; } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 94f5adcb..0148f972 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -102,15 +102,23 @@ const interrupted = await manager.write({ }); assert.equal(interrupted.running, false); if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); -assert.match(interrupted.output, /tick/); +assert.match(interruptible.output + interrupted.output, /tick/); -const buffered = await manager.start({ +let buffered = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "console.log('x'.repeat(5000)); setTimeout(() => {}, 100)"`, yieldTimeMs: 50, maxOutputTokens: 100, }); +if (!buffered.outputTruncated && buffered.sessionId) { + buffered = await manager.write({ + workspaceId: "workspace-a", + sessionId: buffered.sessionId, + yieldTimeMs: 2_000, + maxOutputTokens: 100, + }); +} assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); From fbd45d1111472a4f494bcaf74414f8508993c86a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:08:47 +0530 Subject: [PATCH 25/73] fix(deps): use portable node-pty prebuilds --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2b84f6f..be7ee7da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "node-pty": "1.2.0-beta.12", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -41,7 +42,7 @@ "node": ">=22.19 <27" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.12" } }, "node_modules/@clack/core": { @@ -4704,9 +4705,9 @@ "optional": true }, "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.12.tgz", + "integrity": "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==", "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index a099cfa4..e8a86cea 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,6 @@ "undici": "8.5.0" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.12" } } From 7eaf3198d9045839b7740d8623a9a7df405bd3a0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:10:14 +0530 Subject: [PATCH 26/73] test(exec): remove timing-only output assertion --- src/process-sessions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 0148f972..ee115564 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -81,7 +81,6 @@ const noisyInputResult = await manager.write({ yieldTimeMs: 2_000, }); assert.equal(noisyInputResult.running, false); -assert.match(noisyInputResult.output, /tick/); assert.match(noisyInputResult.output, /input:hello/); const interruptible = await manager.start({ From fb1a8d9b64ead9cebb87714f159e93febfb87ae7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:11:42 +0530 Subject: [PATCH 27/73] test(exec): decouple interrupt from output timing --- src/process-sessions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index ee115564..7183f634 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -101,7 +101,6 @@ const interrupted = await manager.write({ }); assert.equal(interrupted.running, false); if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); -assert.match(interruptible.output + interrupted.output, /tick/); let buffered = await manager.start({ workspaceId: "workspace-a", From 4a672e9b83a134e3fc641fc9f3054a71308287b5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:13:18 +0530 Subject: [PATCH 28/73] fix(exec): delegate pipe shell quoting to Node --- src/process-sessions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 18c974c7..43cd8c77 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -219,12 +219,13 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; - const child = spawn(shell.executable, shell.args, { + const child = spawn(input.command, { cwd: input.cwd, env: process.env, stdio: "pipe", windowsHide: true, detached, + shell: shell.executable, }); session.process = { From 75c2ff6190356c317ef8a3ce3236387437a53858 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:16:35 +0530 Subject: [PATCH 29/73] fix(exec): pass raw commands to Windows PTYs --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index caaaefb8..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", '"echo ok"'], + args: ["/d", "/s", "/c", "echo ok"], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index ba99a4b9..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", `"${command}"`], + args: ["/d", "/s", "/c", command], }; } From e3c59e9ef9de97c0779894ba40be8835f8a34fa7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:19:16 +0530 Subject: [PATCH 30/73] test(exec): quote Windows executable paths natively --- src/process-sessions.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 7183f634..4e185c0d 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -6,7 +6,9 @@ const manager = new ProcessSessionManager({ completedSessionTtlMs: 1_000, }); -const node = JSON.stringify(process.execPath); +const node = process.platform === "win32" + ? `"${process.execPath}"` + : JSON.stringify(process.execPath); const foreground = await manager.start({ workspaceId: "workspace-a", From f900adec35acd4cb78fb749803e1ccdedd3c28d5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:22:43 +0530 Subject: [PATCH 31/73] fix(exec): preserve Windows PTY command lines --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 39b494d8..24a214d7 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", "echo ok"], + args: '/d /s /c "echo ok"', }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d73..69ca9506 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; - args: string[]; + args: string[] | string; } export interface KillableProcess { @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", command], + args: `/d /s /c "${command}"`, }; } From 5bd5af76b93da855ad5dd8119453ed6e289a4628 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:26:09 +0530 Subject: [PATCH 32/73] test(exec): clean up PTYs after assertions --- src/process-sessions.test.ts | 48 +++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 4e185c0d..f6360940 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -122,27 +122,29 @@ if (!buffered.outputTruncated && buffered.sessionId) { assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); -const pty = await manager.start({ - workspaceId: "workspace-a", - cwd: process.cwd(), - command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, - tty: true, - columns: 80, - rows: 24, - yieldTimeMs: 10, -}); -assert.equal(pty.running, true); -assert.ok(pty.sessionId); - -const resizedPty = await manager.write({ - workspaceId: "workspace-a", - sessionId: pty.sessionId, - chars: "continue\r", - columns: 120, - rows: 30, - yieldTimeMs: 2_000, -}); -assert.equal(resizedPty.running, false); -assert.match(resizedPty.output, /columns:120/); +try { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, + }); + assert.equal(pty.running, true); + assert.ok(pty.sessionId); -manager.shutdown(); + const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + chars: "continue\r", + columns: 120, + rows: 30, + yieldTimeMs: 2_000, + }); + assert.equal(resizedPty.running, false); + assert.match(resizedPty.output, /columns:120/); +} finally { + manager.shutdown(); +} From db8f99531a87ac37128b6a19de037ceddf7c339b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:28:53 +0530 Subject: [PATCH 33/73] test(exec): avoid PTY line discipline assumptions --- src/process-sessions.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index f6360940..59cee81b 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -126,7 +126,7 @@ try { const pty = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), - command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, tty: true, columns: 80, rows: 24, @@ -138,7 +138,6 @@ try { const resizedPty = await manager.write({ workspaceId: "workspace-a", sessionId: pty.sessionId, - chars: "continue\r", columns: 120, rows: 30, yieldTimeMs: 2_000, From afdd4f619e0e6e6a8948cff71677ddc9cb71ac8c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:31:15 +0530 Subject: [PATCH 34/73] test(exec): use native Windows PTY smoke command --- src/process-sessions.test.ts | 52 ++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 59cee81b..d7f0c6d6 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -123,27 +123,39 @@ assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); try { - const pty = await manager.start({ - workspaceId: "workspace-a", - cwd: process.cwd(), - command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, - tty: true, - columns: 80, - rows: 24, - yieldTimeMs: 10, - }); - assert.equal(pty.running, true); - assert.ok(pty.sessionId); + if (process.platform === "win32") { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: "echo pty-ok", + tty: true, + yieldTimeMs: 2_000, + }); + assert.equal(pty.running, false); + assert.match(pty.output, /pty-ok/); + } else { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, + }); + assert.equal(pty.running, true); + assert.ok(pty.sessionId); - const resizedPty = await manager.write({ - workspaceId: "workspace-a", - sessionId: pty.sessionId, - columns: 120, - rows: 30, - yieldTimeMs: 2_000, - }); - assert.equal(resizedPty.running, false); - assert.match(resizedPty.output, /columns:120/); + const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + columns: 120, + rows: 30, + yieldTimeMs: 2_000, + }); + assert.equal(resizedPty.running, false); + assert.match(resizedPty.output, /columns:120/); + } } finally { manager.shutdown(); } From 4620f864fa3d745db7eb8c785553b539b96e3ef4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:33:22 +0530 Subject: [PATCH 35/73] fix(exec): pass raw Windows PTY commands --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 24a214d7..cd96d3f4 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: '/d /s /c "echo ok"', + args: "/d /s /c echo ok", }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 69ca9506..80d80f18 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: `/d /s /c "${command}"`, + args: `/d /s /c ${command}`, }; } From 402e94cbd982f3eb2aa57edd318e02acb369dcf5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:39:04 +0530 Subject: [PATCH 36/73] fix(deps): update Windows PTY handle fixes --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index be7ee7da..407efe1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "node-pty": "1.2.0-beta.12", + "node-pty": "1.2.0-beta.13", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -42,7 +42,7 @@ "node": ">=22.19 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.12" + "node-pty": "^1.2.0-beta.13" } }, "node_modules/@clack/core": { @@ -4705,9 +4705,9 @@ "optional": true }, "node_modules/node-pty": { - "version": "1.2.0-beta.12", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.12.tgz", - "integrity": "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==", + "version": "1.2.0-beta.13", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.13.tgz", + "integrity": "sha512-ZbbJ7aJdmvRA53bw30D6YSJJKqo1IXTojD0kJeHZ/xZIxr7p1DCmvOmrOnjUo/rn1z4MDwKQGpx0C7K+cRKETw==", "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index e8a86cea..ea177694 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,6 @@ "undici": "8.5.0" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.12" + "node-pty": "^1.2.0-beta.13" } } From e49cb566ee1afdfabf1454dc38c36d79cacdfecf Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:42:27 +0530 Subject: [PATCH 37/73] fix(exec): run Windows PTYs through temp scripts --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 4 ++-- src/process-sessions.ts | 37 ++++++++++++++++++++++++++++-------- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index cd96d3f4..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: "/d /s /c echo ok", + args: ["/d", "/s", "/c", "echo ok"], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 80d80f18..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; - args: string[] | string; + args: string[]; } export interface KillableProcess { @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: `/d /s /c ${command}`, + args: ["/d", "/s", "/c", command], }; } diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 43cd8c77..2895ba5d 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,5 +1,8 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; @@ -246,14 +249,31 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - const shell = resolveShellCommand(input.command); - const pty = nodePty.spawn(shell.executable, shell.args, { - cwd: input.cwd, - env: processEnvironment(), - name: "xterm-256color", - cols: session.columns, - rows: session.rows, - }); + let scriptDirectory: string | undefined; + let command = input.command; + if (process.platform === "win32") { + scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); + command = join(scriptDirectory, "command.cmd"); + await writeFile(command, `@echo off\r\n${input.command}\r\n`, "utf8"); + } + + const cleanupScript = (): void => { + if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); + }; + const shell = resolveShellCommand(command); + let pty: import("node-pty").IPty; + try { + pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment(), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); + } catch (error) { + cleanupScript(); + throw error; + } session.process = { write: (data) => pty.write(data), @@ -262,6 +282,7 @@ export class ProcessSessionManager { }; pty.onData((data) => this.append(session, data)); pty.onExit(({ exitCode, signal }) => { + cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); } From 814e1d4a8d01e3df021cf8653df017221f3691b3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:48:05 +0530 Subject: [PATCH 38/73] fix(exec): guard Windows PTY listener setup --- src/process-sessions.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 2895ba5d..aa585068 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -250,11 +250,18 @@ export class ProcessSessionManager { } let scriptDirectory: string | undefined; + let readyPath: string | undefined; let command = input.command; if (process.platform === "win32") { scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); + readyPath = join(scriptDirectory, "ready"); command = join(scriptDirectory, "command.cmd"); - await writeFile(command, `@echo off\r\n${input.command}\r\n`, "utf8"); + const batchReadyPath = readyPath.replaceAll("%", "%%"); + await writeFile( + command, + `@echo off\r\n:wait\r\nif not exist "${batchReadyPath}" goto wait\r\n${input.command}\r\n`, + "utf8", + ); } const cleanupScript = (): void => { @@ -285,6 +292,15 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); + if (readyPath) { + try { + await writeFile(readyPath, "", "utf8"); + } catch (error) { + pty.kill(); + cleanupScript(); + throw error; + } + } } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From c1caac5ba5676d2df4578af6d6c06e271450c1b8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:52:56 +0530 Subject: [PATCH 39/73] fix(deps): repair stable node-pty on macOS --- package-lock.json | 10 +++++----- package.json | 3 ++- scripts/fix-node-pty-permissions.mjs | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 scripts/fix-node-pty-permissions.mjs diff --git a/package-lock.json b/package-lock.json index 407efe1b..159a7df4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@waishnav/devspace", "version": "1.0.2", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@clack/prompts": "^1.5.1", @@ -17,7 +18,6 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "node-pty": "1.2.0-beta.13", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -42,7 +42,7 @@ "node": ">=22.19 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -4705,9 +4705,9 @@ "optional": true }, "node_modules/node-pty": { - "version": "1.2.0-beta.13", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.13.tgz", - "integrity": "sha512-ZbbJ7aJdmvRA53bw30D6YSJJKqo1IXTojD0kJeHZ/xZIxr7p1DCmvOmrOnjUo/rn1z4MDwKQGpx0C7K+cRKETw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index ea177694..aabd4926 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json", "build:app": "vite build", "dev": "node scripts/dev-server.mjs", + "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" @@ -63,6 +64,6 @@ "undici": "8.5.0" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } } diff --git a/scripts/fix-node-pty-permissions.mjs b/scripts/fix-node-pty-permissions.mjs new file mode 100644 index 00000000..1990bf44 --- /dev/null +++ b/scripts/fix-node-pty-permissions.mjs @@ -0,0 +1,22 @@ +import { chmod } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.platform === "darwin") { + const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + for (const architecture of ["arm64", "x64"]) { + const helper = resolve( + projectRoot, + "node_modules", + "node-pty", + "prebuilds", + `darwin-${architecture}`, + "spawn-helper", + ); + try { + await chmod(helper, 0o755); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } +} From 3109b22cd580c75dc5837e5620b50537ce6580af Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:57:00 +0530 Subject: [PATCH 40/73] fix(exec): delay Windows PTY command startup --- src/process-sessions.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index aa585068..391825aa 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -250,16 +250,13 @@ export class ProcessSessionManager { } let scriptDirectory: string | undefined; - let readyPath: string | undefined; let command = input.command; if (process.platform === "win32") { scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); - readyPath = join(scriptDirectory, "ready"); command = join(scriptDirectory, "command.cmd"); - const batchReadyPath = readyPath.replaceAll("%", "%%"); await writeFile( command, - `@echo off\r\n:wait\r\nif not exist "${batchReadyPath}" goto wait\r\n${input.command}\r\n`, + `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n`, "utf8", ); } @@ -292,15 +289,6 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); - if (readyPath) { - try { - await writeFile(readyPath, "", "utf8"); - } catch (error) { - pty.kill(); - cleanupScript(); - throw error; - } - } } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 3988be8c6a5ec76ce485728aa9b9b472045804d3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:00:08 +0530 Subject: [PATCH 41/73] fix(exec): omit PTY signals on Windows --- src/process-sessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 391825aa..36427b0a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -281,7 +281,7 @@ export class ProcessSessionManager { session.process = { write: (data) => pty.write(data), - kill: (signal) => pty.kill(signal), + kill: (signal) => process.platform === "win32" ? pty.kill() : pty.kill(signal), resize: (columns, rows) => pty.resize(columns, rows), }; pty.onData((data) => this.append(session, data)); From 4f1feeea173b193b39319508e39cd2a757c5a79c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:03:56 +0530 Subject: [PATCH 42/73] test(exec): allow hosted Windows PTY startup --- src/process-sessions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index d7f0c6d6..06097825 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -129,7 +129,7 @@ try { cwd: process.cwd(), command: "echo pty-ok", tty: true, - yieldTimeMs: 2_000, + yieldTimeMs: 10_000, }); assert.equal(pty.running, false); assert.match(pty.output, /pty-ok/); From 64319c65d0a5bbeda3dbad840803402bd0eed014 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:08:45 +0530 Subject: [PATCH 43/73] fix(exec): exit Windows PTY scripts explicitly --- src/process-sessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 36427b0a..90bfb0de 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -256,7 +256,7 @@ export class ProcessSessionManager { command = join(scriptDirectory, "command.cmd"); await writeFile( command, - `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n`, + `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n@exit /b %errorlevel%\r\n`, "utf8", ); } From 14ed5f229c3a0e69292291434dfa449754953594 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:12:20 +0530 Subject: [PATCH 44/73] fix(deps): combine PTY platform repairs --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 159a7df4..b3591690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "node-pty": "1.2.0-beta.13", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -42,7 +43,7 @@ "node": ">=22.19 <27" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.13" } }, "node_modules/@clack/core": { @@ -4705,9 +4706,9 @@ "optional": true }, "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "version": "1.2.0-beta.13", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.13.tgz", + "integrity": "sha512-ZbbJ7aJdmvRA53bw30D6YSJJKqo1IXTojD0kJeHZ/xZIxr7p1DCmvOmrOnjUo/rn1z4MDwKQGpx0C7K+cRKETw==", "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index aabd4926..e9ca0648 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,6 @@ "undici": "8.5.0" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.13" } } From 58f014b672fc211a161b50650aeea665d82d2f5e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:16:38 +0530 Subject: [PATCH 45/73] fix(exec): use native Windows PTY command lines --- src/process-sessions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 90bfb0de..0b8d7587 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -265,9 +265,10 @@ export class ProcessSessionManager { if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); }; const shell = resolveShellCommand(command); + const shellArgs = process.platform === "win32" ? `/d /c "${command}"` : shell.args; let pty: import("node-pty").IPty; try { - pty = nodePty.spawn(shell.executable, shell.args, { + pty = nodePty.spawn(shell.executable, shellArgs, { cwd: input.cwd, env: processEnvironment(), name: "xterm-256color", From be986c5142b6567432302178b00ef984376dcc3b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:21:26 +0530 Subject: [PATCH 46/73] fix(exec): start Windows PTYs after listeners --- src/process-sessions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 0b8d7587..736890e8 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -256,7 +256,7 @@ export class ProcessSessionManager { command = join(scriptDirectory, "command.cmd"); await writeFile( command, - `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n@exit /b %errorlevel%\r\n`, + `@echo off\r\n${input.command}\r\n@exit %errorlevel%\r\n`, "utf8", ); } @@ -265,7 +265,7 @@ export class ProcessSessionManager { if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); }; const shell = resolveShellCommand(command); - const shellArgs = process.platform === "win32" ? `/d /c "${command}"` : shell.args; + const shellArgs = process.platform === "win32" ? [] : shell.args; let pty: import("node-pty").IPty; try { pty = nodePty.spawn(shell.executable, shellArgs, { @@ -290,6 +290,7 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); + if (process.platform === "win32") pty.write(`"${command}"\r\n`); } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From e7cd9fe31f631ff9f19a5c9cbd5374d16d4ec427 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 47/73] fix(exec): fall back from Windows native PTYs --- src/process-sessions.ts | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 736890e8..c6a0c89a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,8 +1,5 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; @@ -123,7 +120,7 @@ export class ProcessSessionManager { this.sessions.set(session.id, session); try { - if (input.tty) await this.startPty(session, input); + if (input.tty && process.platform !== "win32") await this.startPty(session, input); else this.startPipe(session, input); } catch (error) { this.sessions.delete(session.id); @@ -234,6 +231,7 @@ export class ProcessSessionManager { session.process = { write: (data) => child.stdin.write(data), kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached), + resize: input.tty ? () => undefined : undefined, }; child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); @@ -249,26 +247,10 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - let scriptDirectory: string | undefined; - let command = input.command; - if (process.platform === "win32") { - scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); - command = join(scriptDirectory, "command.cmd"); - await writeFile( - command, - `@echo off\r\n${input.command}\r\n@exit %errorlevel%\r\n`, - "utf8", - ); - } - - const cleanupScript = (): void => { - if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); - }; - const shell = resolveShellCommand(command); - const shellArgs = process.platform === "win32" ? [] : shell.args; + const shell = resolveShellCommand(input.command); let pty: import("node-pty").IPty; try { - pty = nodePty.spawn(shell.executable, shellArgs, { + pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, env: processEnvironment(), name: "xterm-256color", @@ -276,21 +258,18 @@ export class ProcessSessionManager { rows: session.rows, }); } catch (error) { - cleanupScript(); throw error; } session.process = { write: (data) => pty.write(data), - kill: (signal) => process.platform === "win32" ? pty.kill() : pty.kill(signal), + kill: (signal) => pty.kill(signal), resize: (columns, rows) => pty.resize(columns, rows), }; pty.onData((data) => this.append(session, data)); pty.onExit(({ exitCode, signal }) => { - cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); - if (process.platform === "win32") pty.write(`"${command}"\r\n`); } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 3fd16f1db3988c41a425fc572ed1a56a1e9c39f5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 48/73] fix(deps): retain stable Unix PTY support --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3591690..ddc11fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "node-pty": "1.2.0-beta.13", + "node-pty": "1.1.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -43,7 +43,7 @@ "node": ">=22.19 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -4706,9 +4706,9 @@ "optional": true }, "node_modules/node-pty": { - "version": "1.2.0-beta.13", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.13.tgz", - "integrity": "sha512-ZbbJ7aJdmvRA53bw30D6YSJJKqo1IXTojD0kJeHZ/xZIxr7p1DCmvOmrOnjUo/rn1z4MDwKQGpx0C7K+cRKETw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index e9ca0648..aabd4926 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,6 @@ "undici": "8.5.0" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } } From 17fa037467243a96dcef22a5447ba5626f50d423 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 28 Jun 2026 18:51:47 +0530 Subject: [PATCH 49/73] fix(deps): normalize optional pty lock entry --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ddc11fe8..159a7df4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "node-pty": "1.1.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", From 7dfe985550ff1d56cee91c1c5f9063df0ffdf678 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 14:18:28 +0530 Subject: [PATCH 50/73] feat: preserve head and tail process output --- src/process-sessions.test.ts | 29 +++++++- src/process-sessions.ts | 133 +++++++++++++++++++++++++++-------- 2 files changed, 132 insertions(+), 30 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 06097825..791ecabd 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -1,5 +1,32 @@ import assert from "node:assert/strict"; -import { ProcessSessionManager } from "./process-sessions.js"; +import { HeadTailBuffer, ProcessSessionManager } from "./process-sessions.js"; + +const smallBuffer = new HeadTailBuffer(100); +smallBuffer.append("hello\n"); +assert.deepEqual(smallBuffer.drain(100), { output: "hello\n", truncated: false }); +assert.deepEqual(smallBuffer.drain(100), { output: "", truncated: false }); + +const headTail = new HeadTailBuffer(10); +headTail.append("start-middle-end"); +const headTailResult = headTail.drain(1_000); +assert.equal(headTailResult.truncated, true); +assert.match(headTailResult.output, /^start/); +assert.match(headTailResult.output, /e-end$/); +assert.match(headTailResult.output, /characters omitted/); + +const responseLimited = new HeadTailBuffer(100); +responseLimited.append("abcdef".repeat(20)); +const responseLimitedResult = responseLimited.drain(40); +assert.equal(responseLimitedResult.truncated, true); +assert.match(responseLimitedResult.output, /^abc/); +assert.match(responseLimitedResult.output, /def$/); + +const unicodeBuffer = new HeadTailBuffer(4); +unicodeBuffer.append("a🙂b🙂c"); +const unicodeResult = unicodeBuffer.drain(1_000); +assert.equal(unicodeResult.truncated, true); +assert.match(unicodeResult.output, /^a🙂/); +assert.match(unicodeResult.output, /🙂c$/); const manager = new ProcessSessionManager({ maxBufferCharacters: 1_024, diff --git a/src/process-sessions.ts b/src/process-sessions.ts index c6a0c89a..8a6bd7eb 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -53,9 +53,7 @@ interface ProcessSession { startedAt: number; columns: number; rows: number; - buffer: string; - bufferStart: number; - consumedThrough: number; + buffer: HeadTailBuffer; running: boolean; exitCode?: number; signal?: string; @@ -91,16 +89,105 @@ function processEnvironment(): Record { ); } -function truncateOutput(output: string, maxOutputTokens: number): { output: string; truncated: boolean } { - const maxCharacters = Math.max(256, maxOutputTokens * 4); - if (output.length <= maxCharacters) return { output, truncated: false }; +function codePointLength(value: string): number { + return Array.from(value).length; +} + +function sliceCodePoints(value: string, start: number, end?: number): string { + return Array.from(value).slice(start, end).join(""); +} + +function takeHead(value: string, count: number): string { + if (count <= 0) return ""; + return sliceCodePoints(value, 0, count); +} + +function takeTail(value: string, count: number): string { + if (count <= 0) return ""; + const characters = Array.from(value); + return characters.slice(Math.max(0, characters.length - count)).join(""); +} + +function splitBudget(maxCharacters: number): { head: number; tail: number } { + return { + head: Math.ceil(maxCharacters / 2), + tail: Math.floor(maxCharacters / 2), + }; +} + +function formatHeadTail(head: string, tail: string, omittedCharacters: number): string { + if (omittedCharacters <= 0) return head + tail; + return `${head}\n... output truncated (${omittedCharacters} characters omitted) ...\n${tail}`; +} + +export class HeadTailBuffer { + private head = ""; + private tail = ""; + private totalCharacters = 0; + + constructor(private readonly maxCharacters: number) { + if (!Number.isInteger(maxCharacters) || maxCharacters < 1) { + throw new Error("Head/tail buffer limit must be a positive integer."); + } + } + + append(output: string): void { + if (!output) return; + + const previousTotal = this.totalCharacters; + this.totalCharacters += codePointLength(output); + + if (this.totalCharacters <= this.maxCharacters) { + this.head += output; + return; + } + + const budget = splitBudget(this.maxCharacters); + if (previousTotal <= this.maxCharacters) { + const fullOutput = this.head + output; + this.head = takeHead(fullOutput, budget.head); + this.tail = takeTail(fullOutput, budget.tail); + return; + } + + this.tail = takeTail(this.tail + output, budget.tail); + } + + hasOutput(): boolean { + return this.totalCharacters > 0; + } + + drain(maxCharacters: number): { output: string; truncated: boolean } { + if (!Number.isInteger(maxCharacters) || maxCharacters < 1) { + throw new Error("Output limit must be a positive integer."); + } + + const omittedByBuffer = Math.max( + 0, + this.totalCharacters - codePointLength(this.head) - codePointLength(this.tail), + ); + const retained = formatHeadTail(this.head, this.tail, omittedByBuffer); + const output = truncateOutput(retained, maxCharacters); + const truncated = omittedByBuffer > 0 || output.truncated; + + this.head = ""; + this.tail = ""; + this.totalCharacters = 0; + + return { output: output.output, truncated }; + } +} + +function truncateOutput(output: string, maxCharacters: number): { output: string; truncated: boolean } { + const outputCharacters = codePointLength(output); + if (outputCharacters <= maxCharacters) return { output, truncated: false }; const marker = "\n... output truncated ...\n"; - const available = maxCharacters - marker.length; - const head = Math.ceil(available / 2); - const tail = Math.floor(available / 2); + const markerCharacters = codePointLength(marker); + const available = Math.max(0, maxCharacters - markerCharacters); + const budget = splitBudget(available); return { - output: output.slice(0, head) + marker + output.slice(output.length - tail), + output: takeHead(output, budget.head) + marker + takeTail(output, budget.tail), truncated: true, }; } @@ -157,8 +244,7 @@ export class ProcessSessionManager { const writableChars = chars.replaceAll("\u0003", ""); if (writableChars && session.running) session.process?.write(writableChars); - const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; - if ((interactionRequested || !hasUnreadOutput) && session.running) { + if ((interactionRequested || !session.buffer.hasOutput()) && session.running) { const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); await this.waitForExit(session, yieldTimeMs); } @@ -207,9 +293,7 @@ export class ProcessSessionManager { startedAt: Date.now(), columns: terminalSize(input.columns, DEFAULT_COLUMNS), rows: terminalSize(input.rows, DEFAULT_ROWS), - buffer: "", - bufferStart: 0, - consumedThrough: 0, + buffer: new HeadTailBuffer(this.maxBufferCharacters), running: true, exitPromise, resolveExit, @@ -286,27 +370,18 @@ export class ProcessSessionManager { } private append(session: ProcessSession, output: string): void { - session.buffer += output; - if (session.buffer.length <= this.maxBufferCharacters) return; - - const remove = session.buffer.length - this.maxBufferCharacters; - session.buffer = session.buffer.slice(remove); - session.bufferStart += remove; + session.buffer.append(output); } private consume(session: ProcessSession, maxOutputTokens?: number): ProcessSnapshot { - const missedOutput = session.consumedThrough < session.bufferStart; - const start = Math.max(0, session.consumedThrough - session.bufferStart); - const unread = session.buffer.slice(start); - session.consumedThrough = session.bufferStart + session.buffer.length; - const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000); - const truncated = truncateOutput(unread, limit); + const maxCharacters = Math.max(256, limit * 4); + const buffered = session.buffer.drain(maxCharacters); return { sessionId: session.running ? session.id : undefined, - output: truncated.output, - outputTruncated: missedOutput || truncated.truncated, + output: buffered.output, + outputTruncated: buffered.truncated, running: session.running, exitCode: session.exitCode, signal: session.signal, From 474aeb8c2285c213b48b1d9d1ee141937a8f4ced Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 14:19:22 +0530 Subject: [PATCH 51/73] feat: normalize codex command sessions --- src/process-sessions.test.ts | 26 ++++++++++++++++++++++++++ src/process-sessions.ts | 30 +++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 791ecabd..df07ae02 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -48,6 +48,15 @@ assert.equal(foreground.exitCode, 0); assert.match(foreground.output, /foreground/); assert.equal(foreground.sessionId, undefined); +const environment = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI].join(','))"`, + yieldTimeMs: 2_000, +}); +assert.equal(environment.running, false); +assert.match(environment.output, /1,dumb,cat,cat,cat,1/); + const background = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), @@ -93,6 +102,23 @@ const inputResult = await manager.write({ assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); +const defaultInteractive = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', data => setTimeout(() => { console.log('default-input:' + data.toString().trim()); process.exit(0); }, 100))"`, + yieldTimeMs: 5, +}); +assert.equal(defaultInteractive.running, true); +assert.ok(defaultInteractive.sessionId); + +const defaultInputResult = await manager.write({ + workspaceId: "workspace-a", + sessionId: defaultInteractive.sessionId, + chars: "hello\n", +}); +assert.equal(defaultInputResult.running, false); +assert.match(defaultInputResult.output, /default-input:hello/); + const noisyInteractive = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 8a6bd7eb..f73b0d3b 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; -const DEFAULT_YIELD_MS = 10_000; +const DEFAULT_EXEC_YIELD_MS = 10_000; +const DEFAULT_INTERACTIVE_YIELD_MS = 250; +const DEFAULT_POLL_YIELD_MS = 5_000; +const MAX_COMMAND_YIELD_MS = 30_000; +const MAX_POLL_YIELD_MS = 110_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; const DEFAULT_BUFFER_CHARACTERS = 1_000_000; const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1_000; @@ -84,9 +88,19 @@ function terminalSize(value: number | undefined, fallback: number): number { } function processEnvironment(): Record { - return Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); + return { + ...Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + NO_COLOR: "1", + TERM: "dumb", + PAGER: "cat", + GIT_PAGER: "cat", + GH_PAGER: "cat", + CODEX_CI: "1", + LANG: process.env.LANG ?? "C.UTF-8", + LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + }; } function codePointLength(value: string): number { @@ -214,7 +228,7 @@ export class ProcessSessionManager { throw error; } - const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, MAX_COMMAND_YIELD_MS); await this.waitForExit(session, yieldTimeMs); const snapshot = this.consume(session, input.maxOutputTokens); @@ -245,7 +259,9 @@ export class ProcessSessionManager { if (writableChars && session.running) session.process?.write(writableChars); if ((interactionRequested || !session.buffer.hasOutput()) && session.running) { - const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS; + const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS; + const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum); await this.waitForExit(session, yieldTimeMs); } @@ -305,7 +321,7 @@ export class ProcessSessionManager { const detached = process.platform !== "win32"; const child = spawn(input.command, { cwd: input.cwd, - env: process.env, + env: processEnvironment(), stdio: "pipe", windowsHide: true, detached, From 7510c543ee411e165892fac0692b09f0a780fd8b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 14:20:09 +0530 Subject: [PATCH 52/73] feat: use numeric process session ids --- src/process-sessions.test.ts | 2 ++ src/process-sessions.ts | 18 +++++++++--------- src/server.ts | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index df07ae02..346c67cd 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -65,6 +65,7 @@ const background = await manager.start({ }); assert.equal(background.running, true); assert.ok(background.sessionId); +assert.equal(typeof background.sessionId, "number"); await assert.rejects( manager.write({ @@ -92,6 +93,7 @@ const interactive = await manager.start({ }); assert.equal(interactive.running, true); assert.ok(interactive.sessionId); +assert.equal(typeof interactive.sessionId, "number"); const inputResult = await manager.write({ workspaceId: "workspace-a", diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f73b0d3b..9884df12 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; @@ -26,7 +25,7 @@ export interface StartCommandInput { export interface WriteStdinInput { workspaceId: string; - sessionId: string; + sessionId: number; chars?: string; columns?: number; rows?: number; @@ -35,7 +34,7 @@ export interface WriteStdinInput { } export interface ProcessSnapshot { - sessionId?: string; + sessionId?: number; output: string; outputTruncated: boolean; running: boolean; @@ -51,7 +50,7 @@ interface ManagedProcess { } interface ProcessSession { - id: string; + id: number; workspaceId: string; process?: ManagedProcess; startedAt: number; @@ -207,9 +206,10 @@ function truncateOutput(output: string, maxCharacters: number): { output: string } export class ProcessSessionManager { - private readonly sessions = new Map(); + private readonly sessions = new Map(); private readonly maxBufferCharacters: number; private readonly completedSessionTtlMs: number; + private nextSessionId = 1; constructor(options: ProcessSessionManagerOptions = {}) { this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS; @@ -270,7 +270,7 @@ export class ProcessSessionManager { return snapshot; } - terminate(workspaceId: string, sessionId: string): void { + terminate(workspaceId: string, sessionId: number): void { const session = this.getOwnedSession(workspaceId, sessionId); if (session.running) session.process?.kill("SIGTERM"); } @@ -304,7 +304,7 @@ export class ProcessSessionManager { }); return { - id: randomUUID(), + id: this.nextSessionId++, workspaceId: input.workspaceId, startedAt: Date.now(), columns: terminalSize(input.columns, DEFAULT_COLUMNS), @@ -405,7 +405,7 @@ export class ProcessSessionManager { }; } - private getOwnedSession(workspaceId: string, sessionId: string): ProcessSession { + private getOwnedSession(workspaceId: string, sessionId: number): ProcessSession { const session = this.sessions.get(sessionId); if (!session) throw new Error(`Unknown process session: ${sessionId}`); if (session.workspaceId !== workspaceId) { @@ -414,7 +414,7 @@ export class ProcessSessionManager { return session; } - private removeSession(sessionId: string): void { + private removeSession(sessionId: number): void { const session = this.sessions.get(sessionId); if (session?.cleanupTimer) clearTimeout(session.cleanupTimer); this.sessions.delete(sessionId); diff --git a/src/server.ts b/src/server.ts index 86619323..933bdccf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -469,7 +469,7 @@ function processResult(snapshot: ProcessSnapshot): string { function processOutputSchema(): z.ZodRawShape { return resultOutputSchema({ - sessionId: z.string().optional(), + sessionId: z.number().optional(), running: z.boolean(), exitCode: z.number().int().optional(), signal: z.string().optional(), @@ -598,7 +598,7 @@ function registerCodexProcessTools( "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", inputSchema: { workspaceId: z.string().describe("Workspace identifier used to start the process."), - sessionId: z.string().describe("Process session identifier returned by exec_command."), + sessionId: z.number().describe("Process session identifier returned by exec_command."), chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."), rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."), From b43d85f5cdc6f8eb415de7608f971054db44eab1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 15:08:11 +0530 Subject: [PATCH 53/73] feat: align apply patch semantics with codex --- src/apply-patch.test.ts | 124 ++++++++++++++++++- src/apply-patch.ts | 259 ++++++++++++++++++++++------------------ 2 files changed, 269 insertions(+), 114 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index d69901e6..202dda06 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -124,7 +124,129 @@ await assert.rejects( ), /could not find hunk context/, ); -await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); +assert.equal(await readFile(join(root, "should-not-exist.txt"), "utf8"), "staged\n"); assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); +assert.throws( + () => parsePatch("*** Begin Patch\n*** Add File: empty.txt\n*** End Patch"), + /has no content/, +); + +const overwriteRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-overwrite-")); +await writeFile(join(overwriteRoot, "duplicate.txt"), "old content\n"); +await applyPatch( + overwriteRoot, + `*** Begin Patch +*** Add File: duplicate.txt ++new content +*** End Patch`, +); +assert.equal(await readFile(join(overwriteRoot, "duplicate.txt"), "utf8"), "new content\n"); + +await writeFile(join(overwriteRoot, "source.txt"), "from\n"); +await writeFile(join(overwriteRoot, "destination.txt"), "existing\n"); +await applyPatch( + overwriteRoot, + `*** Begin Patch +*** Update File: source.txt +*** Move to: destination.txt +@@ +-from ++new +*** End Patch`, +); +assert.equal(await readFile(join(overwriteRoot, "destination.txt"), "utf8"), "new\n"); +await assert.rejects(readFile(join(overwriteRoot, "source.txt"), "utf8"), /ENOENT/); + +const noNewlineRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-newline-")); +await writeFile(join(noNewlineRoot, "no-newline.txt"), "old"); +await applyPatch( + noNewlineRoot, + `*** Begin Patch +*** Update File: no-newline.txt +@@ +-old ++new +*** End Patch`, +); +assert.equal(await readFile(join(noNewlineRoot, "no-newline.txt"), "utf8"), "new\n"); + +const eofRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-eof-")); +await writeFile(join(eofRoot, "tail.txt"), "first\nsecond\n"); +await applyPatch( + eofRoot, + `*** Begin Patch +*** Update File: tail.txt +@@ + first +-second ++second updated +*** End of File +*** End Patch`, +); +assert.equal(await readFile(join(eofRoot, "tail.txt"), "utf8"), "first\nsecond updated\n"); +await assert.rejects( + applyPatch( + eofRoot, + `*** Begin Patch +*** Update File: tail.txt +@@ + first ++not tail +*** End of File +*** End Patch`, + ), + /could not find hunk context/, +); + +const lenientRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-lenient-")); +await writeFile(join(lenientRoot, "file.txt"), "one\n"); +await applyPatch( + lenientRoot, + `<<'EOF' + *** Begin Patch + *** Update File: file.txt +@@ +-one ++two + *** End Patch +EOF`, +); +assert.equal(await readFile(join(lenientRoot, "file.txt"), "utf8"), "two\n"); + +await applyPatch( + lenientRoot, + `*** Begin Patch +*** Environment ID: ignored +*** Update File: file.txt + two ++three +*** End Patch`, +); +assert.equal(await readFile(join(lenientRoot, "file.txt"), "utf8"), "two\nthree\n"); + +await assert.rejects( + applyPatch( + lenientRoot, + `*** Begin Patch +*** Add File: ${join(lenientRoot, "absolute.txt")} ++no +*** End Patch`, + ), + /path must be relative/, +); + +await writeFile(join(lenientRoot, "binary.dat"), Buffer.from([0, 159, 146, 150])); +await assert.rejects( + applyPatch( + lenientRoot, + `*** Begin Patch +*** Update File: binary.dat +@@ +-x ++y +*** End Patch`, + ), + /not valid UTF-8|binary/, +); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 43c3962d..ed1832df 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -1,6 +1,8 @@ +import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { TextDecoder } from "node:util"; export type PatchOperation = "add" | "update" | "delete" | "move"; @@ -24,6 +26,8 @@ interface HunkLine { interface UpdateHunk { lines: HunkLine[]; + changeContext?: string; + endOfFile?: boolean; } type PatchAction = @@ -31,7 +35,7 @@ type PatchAction = | { kind: "delete"; path: string } | { kind: "update"; path: string; moveTo?: string; hunks: UpdateHunk[] }; -interface StagedFile { +interface TextFile { content: string; mode?: number; } @@ -41,12 +45,11 @@ function patchError(message: string): Error { } export function parsePatch(patch: string): PatchAction[] { - const lines = patch.replace(/\r\n/g, "\n").split("\n"); - if (lines.at(-1) === "") lines.pop(); - if (lines.shift() !== "*** Begin Patch") { + const lines = patchLines(patch); + if (lines.shift()?.trim() !== "*** Begin Patch") { throw patchError("missing *** Begin Patch marker"); } - if (lines.pop() !== "*** End Patch") { + if (lines.pop()?.trim() !== "*** End Patch") { throw patchError("missing *** End Patch marker"); } @@ -54,22 +57,31 @@ export function parsePatch(patch: string): PatchAction[] { let index = 0; while (index < lines.length) { - const header = lines[index++]; + const header = lines[index++].trim(); + if (header === "") continue; + + if (header.startsWith("*** Environment ID: ")) { + if (!header.slice("*** Environment ID: ".length).trim()) { + throw patchError("environment id cannot be empty"); + } + continue; + } if (header.startsWith("*** Add File: ")) { const path = header.slice("*** Add File: ".length); const content: string[] = []; - while (index < lines.length && !lines[index].startsWith("*** ")) { + while (index < lines.length && !isTopLevelHeader(lines[index])) { const line = lines[index++]; if (!line.startsWith("+")) { throw patchError(`added file line must start with +: ${line}`); } content.push(line.slice(1)); } + if (content.length === 0) throw patchError(`add file for ${path} has no content`); actions.push({ kind: "add", path, - content: content.length > 0 ? `${content.join("\n")}\n` : "", + content: `${content.join("\n")}\n`, }); continue; } @@ -84,33 +96,51 @@ export function parsePatch(patch: string): PatchAction[] { let moveTo: string | undefined; const hunks: UpdateHunk[] = []; - if (lines[index]?.startsWith("*** Move to: ")) { - moveTo = lines[index++].slice("*** Move to: ".length); + if (lines[index]?.trim().startsWith("*** Move to: ")) { + moveTo = lines[index++].trim().slice("*** Move to: ".length); } - while (index < lines.length && !lines[index].startsWith("*** ")) { - const hunkHeader = lines[index++]; - if (!hunkHeader.startsWith("@@")) { - throw patchError(`expected hunk header, received: ${hunkHeader}`); + let current: UpdateHunk | undefined; + const finishCurrent = (): void => { + if (!current) return; + if (current.lines.length === 0) throw patchError(`empty update hunk for ${path}`); + hunks.push(current); + current = undefined; + }; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + if (!current && trimmed === "") { + index++; + continue; + } + if (trimmed === "*** End of File") { + if (!current) throw patchError(`end-of-file marker without update hunk for ${path}`); + current.endOfFile = true; + index++; + continue; } - const hunkLines: HunkLine[] = []; - while ( - index < lines.length && - !lines[index].startsWith("@@") && - !lines[index].startsWith("*** ") - ) { - const line = lines[index++]; - if (line.startsWith(" ")) hunkLines.push({ kind: "context", text: line.slice(1) }); - else if (line.startsWith("+")) hunkLines.push({ kind: "add", text: line.slice(1) }); - else if (line.startsWith("-")) hunkLines.push({ kind: "remove", text: line.slice(1) }); - else if (line === "\\ No newline at end of file") continue; - else throw patchError(`hunk line must start with space, +, or -: ${line}`); + if ((!current || !line.startsWith(" ")) && isTopLevelHeader(line)) break; + + if (trimmed.startsWith("@@") && !line.startsWith(" ")) { + finishCurrent(); + const changeContext = trimmed.slice(2).trim(); + current = { lines: [], changeContext: changeContext || undefined }; + index++; + continue; } - if (hunkLines.length === 0) throw patchError(`empty update hunk for ${path}`); - hunks.push({ lines: hunkLines }); + current ??= { lines: [] }; + index++; + if (line.startsWith(" ")) current.lines.push({ kind: "context", text: line.slice(1) }); + else if (line.startsWith("+")) current.lines.push({ kind: "add", text: line.slice(1) }); + else if (line.startsWith("-")) current.lines.push({ kind: "remove", text: line.slice(1) }); + else if (line === "\\ No newline at end of file") continue; + else throw patchError(`hunk line must start with space, +, or -: ${line}`); } + finishCurrent(); if (hunks.length === 0 && !moveTo) { throw patchError(`update for ${path} has no hunks or move destination`); @@ -126,6 +156,30 @@ export function parsePatch(patch: string): PatchAction[] { return actions; } +function patchLines(patch: string): string[] { + let lines = patch.replace(/\r\n/g, "\n").trim().split("\n"); + const first = lines[0]?.trim(); + const last = lines.at(-1)?.trim(); + if ( + (first === "<= 4 + ) { + lines = lines.slice(1, -1); + } + return lines; +} + +function isTopLevelHeader(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed.startsWith("*** Add File: ") || + trimmed.startsWith("*** Delete File: ") || + trimmed.startsWith("*** Update File: ") || + trimmed.startsWith("*** Environment ID: ") + ); +} + function isInside(root: string, path: string): boolean { const rel = relative(root, path); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); @@ -171,7 +225,7 @@ function splitFile(content: string): { lines: string[]; eol: string; finalNewlin return { lines, eol, finalNewline }; } -function findSequence(haystack: string[], needle: string[], from: number): number { +function findSequence(haystack: string[], needle: string[], from: number, endOfFile = false): number { if (needle.length === 0) return from; const matchAt = (index: number, normalize: (value: string) => string): boolean => @@ -182,8 +236,10 @@ function findSequence(haystack: string[], needle: string[], from: number): numbe (value: string) => value.trimEnd(), (value: string) => value.trim(), ]) { - for (let index = from; index <= haystack.length - needle.length; index += 1) { - if (matchAt(index, normalize)) return index; + const start = endOfFile ? haystack.length - needle.length : from; + const end = haystack.length - needle.length; + for (let index = start; index <= end; index += 1) { + if (index >= from && matchAt(index, normalize)) return index; } } @@ -196,13 +252,23 @@ function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string let cursor = 0; for (const hunk of hunks) { + if (hunk.changeContext) { + const contextIndex = findSequence(lines, [hunk.changeContext], cursor); + if (contextIndex < 0) { + throw patchError(`could not find hunk context in ${path}: ${hunk.changeContext}`); + } + cursor = contextIndex + 1; + } + const oldLines = hunk.lines .filter((line) => line.kind !== "add") .map((line) => line.text); const newLines = hunk.lines .filter((line) => line.kind !== "remove") .map((line) => line.text); - const index = findSequence(lines, oldLines, cursor); + const index = hunk.endOfFile && oldLines.length === 0 + ? lines.length + : findSequence(lines, oldLines, cursor, hunk.endOfFile); if (index < 0) { const preview = oldLines.slice(0, 3).join("\n"); @@ -213,7 +279,7 @@ function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string cursor = index + newLines.length; } - const normalized = lines.join("\n") + (file.finalNewline ? "\n" : ""); + const normalized = `${lines.join("\n")}\n`; return file.eol === "\r\n" ? normalized.replace(/\n/g, "\r\n") : normalized; } @@ -250,52 +316,25 @@ export async function replaceFile( export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); - const staged = new Map(); - const originals = new Map(); - const currentPaths = new Map(); const results: AppliedPatchFile[] = []; - - const rememberOriginal = ( - absolute: string, - displayPath: string, - content: string | null, - ): void => { - if (!originals.has(absolute)) originals.set(absolute, { content, path: displayPath }); - currentPaths.set(absolute, displayPath); - }; - - const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { - const absolute = await resolveConfinedPath(root, displayPath); - if (staged.has(absolute)) { - const file = staged.get(absolute); - if (!file) throw patchError(`file does not exist: ${displayPath}`); - return { absolute, file }; - } - if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); - const metadata = await stat(absolute); - if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); - const file = { content: await readFile(absolute, "utf8"), mode: metadata.mode }; - rememberOriginal(absolute, displayPath, file.content); - staged.set(absolute, file); - return { absolute, file }; - }; + const patches: string[] = []; for (const action of actions) { if (action.kind === "add") { const absolute = await resolveConfinedPath(root, action.path); - if (staged.get(absolute) || (!staged.has(absolute) && (await fileExists(absolute)))) { - throw patchError(`file already exists: ${action.path}`); - } - rememberOriginal(absolute, action.path, null); - staged.set(absolute, { content: action.content }); + const original = await readOptionalTextFile(absolute, action.path); + await writeTextFile(absolute, action.content, original?.mode); + patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content)); results.push({ path: action.path, operation: "add" }); continue; } - const { absolute, file } = await load(action.path); + const absolute = await resolveConfinedPath(root, action.path); + const file = await readRequiredTextFile(absolute, action.path); if (action.kind === "delete") { - staged.set(absolute, null); + await rm(absolute); + patches.push(unifiedFilePatch(action.path, action.path, file.content, null)); results.push({ path: action.path, operation: "delete" }); continue; } @@ -303,65 +342,59 @@ export async function applyPatch(root: string, patch: string): Promise { - const original = originals.get(absolute); - if (!original || original.content === file?.content) return ""; - return unifiedFilePatch( - original.path, - currentPaths.get(absolute) ?? original.path, - original.content, - file?.content ?? null, - ); - }).filter(Boolean); - const unifiedPatch = patches.join("\n"); + const unifiedPatch = patches.filter(Boolean).join("\n"); const stats = countPatchStats(unifiedPatch); + return { files: results, patch: unifiedPatch, ...stats }; +} + +async function readRequiredTextFile(absolute: string, displayPath: string): Promise { + if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); + const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + return { content: await readUtf8Text(absolute, displayPath), mode: metadata.mode }; +} + +async function readOptionalTextFile(absolute: string, displayPath: string): Promise { + if (!(await fileExists(absolute))) return null; + const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + return { content: await readUtf8Text(absolute, displayPath), mode: metadata.mode }; +} - const pendingWrites: Array<{ - temporary: string; - destination: string; - destinationExists: boolean; - }> = []; - for (const [destination, file] of staged) { - if (!file) continue; - await mkdir(dirname(destination), { recursive: true }); - const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; - await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); - pendingWrites.push({ - temporary, - destination, - destinationExists: originals.get(destination)?.content !== null, - }); +async function readUtf8Text(absolute: string, displayPath: string): Promise { + const bytes = await readFile(absolute); + let content: string; + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw patchError(`file is not valid UTF-8 text: ${displayPath}`); } + if (content.includes("\0")) throw patchError(`file appears to be binary: ${displayPath}`); + return content; +} +async function writeTextFile(destination: string, content: string, mode?: number): Promise { + await mkdir(dirname(destination), { recursive: true }); + const temporary = `${destination}.devspace-patch-${process.pid}-${randomUUID()}`; try { - for (const write of pendingWrites) { - await replaceFile(write.temporary, write.destination, write.destinationExists); - } - for (const [path, file] of staged) { - if (!file) await rm(path, { force: true }); - } + await writeFile(temporary, content, mode === undefined ? undefined : { mode }); + await replaceFile(temporary, destination, await fileExists(destination)); } catch (error) { - await Promise.all(pendingWrites.map(({ temporary }) => rm(temporary, { force: true }))); + await rm(temporary, { force: true }); throw error; } - - return { files: results, patch: unifiedPatch, ...stats }; } function fileLines(content: string): string[] { From 960fd783bda6c6ba430865f54a0914db5d1dc92d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 15:08:39 +0530 Subject: [PATCH 54/73] docs: clarify codex patch semantics --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 933bdccf..9b1c0dbe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1107,7 +1107,7 @@ function createMcpServer( { title: "Apply patch", description: - "Apply one Codex-style patch inside an open workspace. Supports adding, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Earlier successful file changes remain if a later patch action fails. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", inputSchema: { workspaceId: z .string() From f28552d442005be71ae2891ff0286d218ddc8269 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:09:10 +0530 Subject: [PATCH 55/73] fix(skills): discover standard agent skill paths --- src/skills.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/skills.ts | 20 ++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 1b0ebae7..787493c3 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -4,22 +4,51 @@ import { join } from "node:path"; import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { + effectiveSkillPaths, formatPathForPrompt, loadWorkspaceSkills, resolveSkillReadPath, } from "./skills.js"; const root = await mkdtemp(join(tmpdir(), "devspace-skills-test-")); +const originalHome = process.env.HOME; try { + process.env.HOME = root; const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); + const globalAgentsSkills = join(root, ".agents", "skills"); + const projectAgentsSkills = join(projectRoot, ".agents", "skills"); + await mkdir(join(globalAgentsSkills, "agent-global-skill"), { recursive: true }); + await mkdir(join(projectAgentsSkills, "agent-project-skill"), { recursive: true }); await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await writeFile( + join(globalAgentsSkills, "agent-global-skill", "SKILL.md"), + [ + "---", + "name: agent-global-skill", + "description: Agent global skill description.", + "---", + "", + "# Agent Global Skill", + ].join("\n"), + ); + await writeFile( + join(projectAgentsSkills, "agent-project-skill", "SKILL.md"), + [ + "---", + "name: agent-project-skill", + "description: Agent project skill description.", + "---", + "", + "# Agent Project Skill", + ].join("\n"), + ); await writeFile( join(projectRoot, ".pi", "skills", "project-skill", "SKILL.md"), [ @@ -84,11 +113,25 @@ try { PORT: "1", }); const loaded = loadWorkspaceSkills(config, projectRoot); + assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); + const duplicateConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SKILL_PATHS: [explicitSkills, projectAgentsSkills].join(","), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + assert.equal( + effectiveSkillPaths(duplicateConfig, projectRoot).filter((path) => path === projectAgentsSkills).length, + 1, + ); + const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill"); assert.ok(projectSkill); assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/); @@ -106,5 +149,7 @@ try { false, ); } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; await rm(root, { recursive: true, force: true }); } diff --git a/src/skills.ts b/src/skills.ts index 20a35205..1b1dbc58 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,5 +1,6 @@ +import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { resolve, sep } from "node:path"; +import { join, resolve, sep } from "node:path"; import { loadSkills, type Skill, @@ -19,13 +20,28 @@ export interface SkillReadResolution { isSkillFile: boolean; } +export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { + const defaultPaths = [ + join(homedir(), ".agents", "skills"), + resolve(cwd, ".agents", "skills"), + ].filter((path) => existsSync(path)); + + const seen = new Set(); + return [...defaultPaths, ...config.skillPaths].filter((path) => { + const resolvedPath = resolve(expandHomePath(path)); + if (seen.has(resolvedPath)) return false; + seen.add(resolvedPath); + return true; + }); +} + export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; return loadSkills({ cwd, agentDir: config.agentDir, - skillPaths: config.skillPaths, + skillPaths: effectiveSkillPaths(config, cwd), includeDefaults: true, }); } From b1de12efc927327c623783b22295eb1ee0b17612 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:10:25 +0530 Subject: [PATCH 56/73] docs(skills): document standard agent skill paths --- docs/chatgpt-coding-workflow.md | 11 ++++++++--- docs/configuration.md | 17 ++++++++++++++--- docs/gotchas.md | 11 ++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 3b0efaf2..1def8a75 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -79,11 +79,16 @@ new context during later tool calls. Skills are enabled by default for coding-agent workflows. -DevSpace discovers skills from: +DevSpace discovers standard Agent Skills from: -- `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` +- `~/.agents/skills` +- project `.agents/skills` + +It also keeps compatibility with: + +- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - project `.pi/skills` -- optional paths from `DEVSPACE_SKILL_PATHS` +- additional paths from `DEVSPACE_SKILL_PATHS` When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. diff --git a/docs/configuration.md b/docs/configuration.md index fa3a61de..a731cba1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,13 +99,24 @@ sessions. | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`. | -| `DEVSPACE_SKILL_PATHS` | Optional comma-separated skill directories. | +| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | +| `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | + +DevSpace discovers standard Agent Skills from: + +- `~/.agents/skills` +- project `.agents/skills` + +It also keeps compatibility with: + +- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` +- project `.pi/skills` +- additional paths from `DEVSPACE_SKILL_PATHS` Example: ```bash -DEVSPACE_SKILL_PATHS="$HOME/.codex/skills,$HOME/.claude/skills" \ +DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ npx @waishnav/devspace serve ``` diff --git a/docs/gotchas.md b/docs/gotchas.md index 33823b5a..0ef8a7f4 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -193,11 +193,16 @@ Skills are enabled by default. Check: DEVSPACE_SKILLS=1 npx @waishnav/devspace serve ``` -DevSpace looks in: +DevSpace looks in standard Agent Skills locations: -- `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex` +- `~/.agents/skills` +- project `.agents/skills` + +It also checks compatibility and custom paths: + +- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - project `.pi/skills` -- `DEVSPACE_SKILL_PATHS` +- additional paths from `DEVSPACE_SKILL_PATHS` If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. From 2dfe62171247cd9154e862ccfec8d052893e4dcb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:23:30 +0530 Subject: [PATCH 57/73] fix(skills): make pi project skills opt-in --- src/skills.test.ts | 16 ++++++++++++++-- src/skills.ts | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 787493c3..f8af6c1d 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -115,7 +115,7 @@ try { const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); @@ -132,7 +132,19 @@ try { 1, ); - const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill"); + const legacyPiConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SKILL_PATHS: [explicitSkills, join(projectRoot, ".pi", "skills")].join(","), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + assert.equal( + loadWorkspaceSkills(legacyPiConfig, projectRoot).skills.some((skill) => skill.name === "project-skill"), + true, + ); + + const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill"); assert.ok(projectSkill); assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/); diff --git a/src/skills.ts b/src/skills.ts index 1b1dbc58..9a1141a1 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -24,6 +24,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] const defaultPaths = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), + join(config.agentDir, "skills"), ].filter((path) => existsSync(path)); const seen = new Set(); @@ -42,7 +43,7 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk cwd, agentDir: config.agentDir, skillPaths: effectiveSkillPaths(config, cwd), - includeDefaults: true, + includeDefaults: false, }); } From 7d360cc1f1074f59c90ecc93a374c65f436a801b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:24:30 +0530 Subject: [PATCH 58/73] docs(skills): mark pi project skills as opt-in --- docs/chatgpt-coding-workflow.md | 3 ++- docs/configuration.md | 3 ++- docs/gotchas.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 1def8a75..13269f52 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,9 +87,10 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- project `.pi/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. + When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. diff --git a/docs/configuration.md b/docs/configuration.md index a731cba1..32293631 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,9 +110,10 @@ DevSpace discovers standard Agent Skills from: It also keeps compatibility with: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- project `.pi/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. + Example: ```bash diff --git a/docs/gotchas.md b/docs/gotchas.md index 0ef8a7f4..c6ef1d2e 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,9 +201,10 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- project `.pi/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. + If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. From 3ea42e105f807badff291e0de5f2a68b94a5d3ac Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:28:06 +0530 Subject: [PATCH 59/73] test(skills): set windows home fixture --- src/skills.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/skills.test.ts b/src/skills.test.ts index f8af6c1d..669cb9fc 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -12,9 +12,11 @@ import { const root = await mkdtemp(join(tmpdir(), "devspace-skills-test-")); const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; try { process.env.HOME = root; + process.env.USERPROFILE = root; const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); @@ -163,5 +165,7 @@ try { } finally { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; await rm(root, { recursive: true, force: true }); } From 4ab5e857c42a4234407c5c776b684bf47ab07bf4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 29 Jun 2026 19:39:09 +0530 Subject: [PATCH 60/73] fix(skills): resolve configured paths per workspace --- src/config.ts | 3 +-- src/skills.test.ts | 32 ++++++++++++++++++++++++++++++-- src/skills.ts | 17 +++++++++++------ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5bf96f87..d065b17c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,8 +110,7 @@ function parsePathList(value: string | undefined): string[] { value ?.split(",") .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => resolve(expandHomePath(entry))) ?? [] + .filter(Boolean) ?? [] ); } diff --git a/src/skills.test.ts b/src/skills.test.ts index 669cb9fc..16a49c8a 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -22,8 +22,12 @@ try { const explicitSkills = join(root, "explicit-skills"); const globalAgentsSkills = join(root, ".agents", "skills"); const projectAgentsSkills = join(projectRoot, ".agents", "skills"); + const globalClaudeSkills = join(root, ".claude", "skills"); + const projectClaudeSkills = join(projectRoot, ".claude", "skills"); await mkdir(join(globalAgentsSkills, "agent-global-skill"), { recursive: true }); await mkdir(join(projectAgentsSkills, "agent-project-skill"), { recursive: true }); + await mkdir(join(globalClaudeSkills, "claude-global-skill"), { recursive: true }); + await mkdir(join(projectClaudeSkills, "claude-project-skill"), { recursive: true }); await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); @@ -51,6 +55,28 @@ try { "# Agent Project Skill", ].join("\n"), ); + await writeFile( + join(globalClaudeSkills, "claude-global-skill", "SKILL.md"), + [ + "---", + "name: claude-global-skill", + "description: Claude global skill description.", + "---", + "", + "# Claude Global Skill", + ].join("\n"), + ); + await writeFile( + join(projectClaudeSkills, "claude-project-skill", "SKILL.md"), + [ + "---", + "name: claude-project-skill", + "description: Claude project skill description.", + "---", + "", + "# Claude Project Skill", + ].join("\n"), + ); await writeFile( join(projectRoot, ".pi", "skills", "project-skill", "SKILL.md"), [ @@ -110,13 +136,15 @@ try { const config = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: explicitSkills, + DEVSPACE_SKILL_PATHS: [explicitSkills, "~/.claude/skills", "./.claude/skills"].join(","), DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "claude-global-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), false); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); @@ -125,7 +153,7 @@ try { const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SKILL_PATHS: [explicitSkills, projectAgentsSkills].join(","), + DEVSPACE_SKILL_PATHS: [explicitSkills, "./.agents/skills"].join(","), DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); diff --git a/src/skills.ts b/src/skills.ts index 9a1141a1..e3da62ff 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -28,12 +28,17 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] ].filter((path) => existsSync(path)); const seen = new Set(); - return [...defaultPaths, ...config.skillPaths].filter((path) => { - const resolvedPath = resolve(expandHomePath(path)); - if (seen.has(resolvedPath)) return false; - seen.add(resolvedPath); - return true; - }); + return [...defaultPaths, ...config.skillPaths] + .map((path) => resolveSkillPath(path, cwd)) + .filter((path) => { + if (seen.has(path)) return false; + seen.add(path); + return true; + }); +} + +function resolveSkillPath(path: string, cwd: string): string { + return resolve(cwd, expandHomePath(path)); } export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { From d18851a0d27e7a00c92376fe427cd4def5ff36d8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 00:56:02 +0530 Subject: [PATCH 61/73] docs: remove internal codex tool mode QA doc --- docs/codex-tool-mode-qa.md | 73 -------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 docs/codex-tool-mode-qa.md diff --git a/docs/codex-tool-mode-qa.md b/docs/codex-tool-mode-qa.md deleted file mode 100644 index 63a8a4aa..00000000 --- a/docs/codex-tool-mode-qa.md +++ /dev/null @@ -1,73 +0,0 @@ -# Codex Tool Mode Manual QA - -Run these checks against a disposable Git repository inside an allowed DevSpace -root. Keep the DevSpace server logs visible during the test. - -## Setup - -1. Build the current branch with `npm ci && npm run build`. -2. Start DevSpace with `DEVSPACE_TOOL_MODE=codex devspace serve`. -3. Connect or refresh the DevSpace connector in ChatGPT. -4. Open the disposable repository with `open_workspace`. -5. Confirm the core tools are `open_workspace`, `read`, `apply_patch`, - `exec_command`, and `write_stdin`. -6. Confirm `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are absent. -7. If `DEVSPACE_WIDGETS=changes`, also expect `show_changes`. - -## Apply Patch - -1. Add a text file containing multiple lines and a blank line. -2. Update two separate regions of that file in one patch. -3. Create a nested file, rename it, and then delete it. -4. Patch an existing CRLF file and verify it remains CRLF. -5. Verify executable permissions survive an update and a move. -6. Try to add `../outside.txt`; confirm the tool rejects the path. -7. Patch through a symlink targeting an external directory; confirm rejection. -8. Submit a hunk whose context is absent; confirm no file from that patch changes. -9. With changes widgets enabled, inspect the aggregate diff. - -## Foreground Commands - -1. Run `pwd` and confirm it reports the opened workspace. -2. Run a command in a relative `workingDirectory` and confirm the directory. -3. Write to stdout and stderr; confirm both appear. -4. Exit nonzero; confirm `running=false` and the exit code. -5. Use a small output budget on a noisy command; confirm truncation is reported. - -## Background Sessions - -1. Start a delayed command with a short yield time. -2. Confirm `exec_command` returns `running=true` and a `sessionId`. -3. Poll with empty `chars`; confirm output is not duplicated. -4. Poll until completion; confirm the final exit code and no `sessionId`. -5. Poll the completed session again; confirm it is unknown. -6. Reconnect MCP without restarting DevSpace and confirm polling still works. -7. Restart DevSpace and confirm old process session IDs are invalid. - -## Input, Interrupt, And PTY - -1. Start a program that reads stdin without a PTY and send it a line. -2. Start a long-running process and send `\u0003`; confirm it stops. -3. Start an interactive program with `tty=true`; confirm it detects a TTY. -4. Resize a PTY from 80x24 to 120x30 and verify the observed dimensions. -5. Omit optional dependencies; normal commands must work and `tty=true` must - return the explicit `node-pty` error. - -## Cleanup - -1. Start a non-PTY command that creates a long-running child process. -2. Stop DevSpace with SIGINT and verify both shell and child exit. -3. Repeat with a PTY command. -4. Confirm no process remains after server exit. -5. Repeat session cycles and check that memory use does not steadily increase. - -## Existing Mode Regression - -1. Start without `DEVSPACE_TOOL_MODE`; confirm `minimal` remains the default. -2. Minimal must expose `read`, `write`, `edit`, and `bash`, but not Codex tools - or dedicated search tools. -3. `DEVSPACE_TOOL_MODE=full` must add `grep`, `glob`, and `ls`. -4. With no explicit mode, `DEVSPACE_MINIMAL_TOOLS=1` maps to minimal and `0` - maps to full. -5. Set `DEVSPACE_TOOL_MODE=codex` with `DEVSPACE_MINIMAL_TOOLS=0`; confirm the - explicit Codex mode wins. From e31b9236bd34a82baeb01a89c730c8b7d8842a35 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 00:58:33 +0530 Subject: [PATCH 62/73] Release v1.0.3 --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 159a7df4..6c1f8894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@waishnav/devspace", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@waishnav/devspace", - "version": "1.0.2", + "version": "1.0.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -881,7 +881,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.2", + "version": "1.0.3", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" @@ -3211,7 +3211,7 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", + "version": "1.0.3", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", @@ -5278,7 +5278,7 @@ } }, "node_modules/side-channel-weakmap": { - "version": "1.0.2", + "version": "1.0.3", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", @@ -5641,7 +5641,7 @@ } }, "node_modules/util-deprecate": { - "version": "1.0.2", + "version": "1.0.3", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" @@ -5777,7 +5777,7 @@ } }, "node_modules/wrappy": { - "version": "1.0.2", + "version": "1.0.3", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" diff --git a/package.json b/package.json index aabd4926..8b1e6e02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@waishnav/devspace", - "version": "1.0.2", + "version": "1.0.3", "description": "Expose a secure local coding workspace through an MCP server.", "type": "module", "main": "dist/server.js", From 943d6d64bbab2c31c141a0e4329c4627a7787667 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:07:43 +0530 Subject: [PATCH 63/73] test(ui): classify apply patch cards separately --- src/ui/card-types.test.ts | 11 ++++++++++- src/ui/card-types.ts | 7 ++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 6a98ff47..eb47e9a0 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { isEditTool, + isExpandableCard, + isPatchTool, isShellTool, isToolName, } from "./card-types.js"; @@ -9,8 +11,15 @@ for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); } -assert.equal(isEditTool("apply_patch"), true); +assert.equal(isPatchTool("apply_patch"), true); +assert.equal(isEditTool("apply_patch"), false); assert.equal(isShellTool("exec_command"), true); assert.equal(isShellTool("write_stdin"), true); assert.equal(isEditTool("exec_command"), false); assert.equal(isShellTool("apply_patch"), false); + +assert.equal( + isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), + true, +); +assert.equal(isExpandableCard({ tool: "apply_patch" }), false); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 107f8f1d..11734237 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -100,7 +100,11 @@ export function isWriteTool(tool: ToolName): boolean { } export function isEditTool(tool: ToolName): boolean { - return tool === "edit_file" || tool === "edit" || tool === "apply_patch"; + return tool === "edit_file" || tool === "edit"; +} + +export function isPatchTool(tool: ToolName): boolean { + return tool === "apply_patch"; } export function isSearchTool(tool: ToolName): boolean { @@ -158,6 +162,7 @@ export function isExpandableCard(card: ToolResultCard): boolean { } if (isReviewTool(card.tool)) return Boolean(card.files?.length || card.payload?.patch); + if (isPatchTool(card.tool)) return Boolean(card.payload?.patch); return Boolean(card.payload); } From 46e99a62693e95c7862fe0cf7780b8f3bc4a702f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:08:35 +0530 Subject: [PATCH 64/73] fix(ui): render apply patch as multi-file diff --- src/ui/workspace-app.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 4be351ca..b802bd08 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -8,6 +8,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { isEditTool, isExpandableCard, + isPatchTool, isReadTool, isReviewTool, isSearchTool, @@ -261,17 +262,17 @@ async function renderPayloadIfNeeded(): Promise { return; } - if (isReviewTool(card.tool)) { - const visibleFileCount = reviewFilesExpanded - ? undefined - : Math.max(3, (card.files ?? []).slice(0, 3).length); + if (isReviewTool(card.tool) || isPatchTool(card.tool)) { + const visibleFileCount = isReviewTool(card.tool) && !reviewFilesExpanded + ? Math.max(3, (card.files ?? []).slice(0, 3).length) + : undefined; if (currentPayload) { currentPayload.update({ card, hostContext, errorMessage, visibleFileCount }); return; } - renderStatus(target, "Loading review..."); + renderStatus(target, isReviewTool(card.tool) ? "Loading review..." : "Loading diff..."); const { mountReviewPayload } = await import("./review-payload.js"); if (target !== currentPayloadContainer || !card) return; @@ -340,7 +341,7 @@ function renderSummaryBadge(card: ToolResultCard): HTMLElement { return stats; } - if (isEditTool(card.tool) || isWriteTool(card.tool)) { + if (isPatchTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool)) { const stats = element("span", { className: "stats" }); stats.setAttribute("aria-label", "Diff statistics"); stats.append( From 02bda1fca7f5e03c326b4284b3b4b8988d9a88ed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:10:19 +0530 Subject: [PATCH 65/73] fix(apply-patch): stage changes before writing --- src/apply-patch.test.ts | 2 +- src/apply-patch.ts | 47 ++++++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 202dda06..a16b9d72 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -124,7 +124,7 @@ await assert.rejects( ), /could not find hunk context/, ); -assert.equal(await readFile(join(root, "should-not-exist.txt"), "utf8"), "staged\n"); +await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index ed1832df..f7204dc3 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -40,6 +40,8 @@ interface TextFile { mode?: number; } +type StagedTextFile = TextFile | null; + function patchError(message: string): Error { return new Error(`Invalid patch: ${message}`); } @@ -318,22 +320,36 @@ export async function applyPatch(root: string, patch: string): Promise(); + + const readStagedOptional = async (absolute: string, displayPath: string): Promise => { + if (staged.has(absolute)) return staged.get(absolute) ?? null; + const file = await readOptionalTextFile(absolute, displayPath); + staged.set(absolute, file); + return file; + }; + + const readStagedRequired = async (absolute: string, displayPath: string): Promise => { + const file = await readStagedOptional(absolute, displayPath); + if (!file) throw patchError(`file does not exist: ${displayPath}`); + return file; + }; for (const action of actions) { if (action.kind === "add") { const absolute = await resolveConfinedPath(root, action.path); - const original = await readOptionalTextFile(absolute, action.path); - await writeTextFile(absolute, action.content, original?.mode); + const original = await readStagedOptional(absolute, action.path); + staged.set(absolute, { content: action.content, mode: original?.mode }); patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content)); results.push({ path: action.path, operation: "add" }); continue; } const absolute = await resolveConfinedPath(root, action.path); - const file = await readRequiredTextFile(absolute, action.path); + const file = await readStagedRequired(absolute, action.path); if (action.kind === "delete") { - await rm(absolute); + staged.set(absolute, null); patches.push(unifiedFilePatch(action.path, action.path, file.content, null)); results.push({ path: action.path, operation: "delete" }); continue; @@ -342,30 +358,31 @@ export async function applyPatch(root: string, patch: string): Promise { - if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); - const metadata = await stat(absolute); - if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); - return { content: await readUtf8Text(absolute, displayPath), mode: metadata.mode }; -} - async function readOptionalTextFile(absolute: string, displayPath: string): Promise { if (!(await fileExists(absolute))) return null; const metadata = await stat(absolute); From db7d4f6a01c4f5399e8bcedbd714a86632e0b9cf Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:11:29 +0530 Subject: [PATCH 66/73] docs(apply-patch): describe staged writes --- src/apply-patch.test.ts | 1 + src/server.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index a16b9d72..33372542 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -125,6 +125,7 @@ await assert.rejects( /could not find hunk context/, ); await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); +assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); diff --git a/src/server.ts b/src/server.ts index 9b1c0dbe..6063d4ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1107,7 +1107,7 @@ function createMcpServer( { title: "Apply patch", description: - "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Earlier successful file changes remain if a later patch action fails. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. File changes are staged and written only after all patch actions validate. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", inputSchema: { workspaceId: z .string() From 2d2c47f8bc586b006b4e3f7d0040e412580f4ad0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:15:57 +0530 Subject: [PATCH 67/73] fix(apply-patch): generate multi-hunk diffs --- package-lock.json | 1 + package.json | 1 + src/apply-patch.test.ts | 23 +++++++++++++++ src/apply-patch.ts | 64 +++++++++-------------------------------- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c1f8894..c226ef47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", + "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "react": "^19.2.6", diff --git a/package.json b/package.json index 8b1e6e02..15e011a4 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", + "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "react": "^19.2.6", diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 33372542..1e269dc3 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -127,6 +127,29 @@ await assert.rejects( await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +const splitHunkRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-split-hunk-")); +await writeFile( + join(splitHunkRoot, "long.txt"), + Array.from({ length: 20 }, (_, index) => String(index + 1)).join("\n") + "\n", +); +const splitHunkResult = await applyPatch( + splitHunkRoot, + `*** Begin Patch +*** Update File: long.txt +@@ + 1 +-2 ++two + 3 +@@ + 17 +-18 ++eighteen + 19 +*** End Patch`, +); +assert.equal(splitHunkResult.patch.match(/^@@ /gm)?.length, 2); + assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); assert.throws( diff --git a/src/apply-patch.ts b/src/apply-patch.ts index f7204dc3..f18c19ff 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -3,6 +3,7 @@ import { constants } from "node:fs"; import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { TextDecoder } from "node:util"; +import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff"; export type PatchOperation = "add" | "update" | "delete" | "move"; @@ -414,68 +415,29 @@ async function writeTextFile(destination: string, content: string, mode?: number } } -function fileLines(content: string): string[] { - if (content.length === 0) return []; - const normalized = content.replace(/\r\n/g, "\n"); - const lines = normalized.split("\n"); - if (normalized.endsWith("\n")) lines.pop(); - return lines; -} - -function hunkRange(start: number, count: number): string { - return count === 0 ? "0,0" : `${start},${count}`; -} - function unifiedFilePatch( oldPath: string, newPath: string, oldContent: string | null, newContent: string | null, ): string { - const oldLines = fileLines(oldContent ?? ""); - const newLines = fileLines(newContent ?? ""); - let prefix = 0; - while ( - prefix < oldLines.length && - prefix < newLines.length && - oldLines[prefix] === newLines[prefix] - ) { - prefix += 1; - } - - let suffix = 0; - while ( - suffix < oldLines.length - prefix && - suffix < newLines.length - prefix && - oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] - ) { - suffix += 1; - } - - const contextBefore = Math.min(3, prefix); - const contextAfter = Math.min(3, suffix); - const oldChanged = oldLines.slice(prefix, oldLines.length - suffix); - const newChanged = newLines.slice(prefix, newLines.length - suffix); - const before = oldLines.slice(prefix - contextBefore, prefix); - const after = oldLines.slice(oldLines.length - suffix, oldLines.length - suffix + contextAfter); - const oldCount = contextBefore + oldChanged.length + contextAfter; - const newCount = contextBefore + newChanged.length + contextAfter; - const oldStart = oldContent === null ? 0 : prefix - contextBefore + 1; - const newStart = newContent === null ? 0 : prefix - contextBefore + 1; - const displayOld = oldContent === null ? "/dev/null" : `a/${oldPath}`; - const displayNew = newContent === null ? "/dev/null" : `b/${newPath}`; + const oldFileName = oldContent === null ? "/dev/null" : `a/${oldPath}`; + const newFileName = newContent === null ? "/dev/null" : `b/${newPath}`; + const body = createTwoFilesPatch( + oldFileName, + newFileName, + oldContent ?? "", + newContent ?? "", + "", + "", + { context: 3, headerOptions: FILE_HEADERS_ONLY }, + ).trimEnd(); return [ `diff --git a/${oldPath} b/${newPath}`, oldContent === null ? "new file mode 100644" : undefined, newContent === null ? "deleted file mode 100644" : undefined, - `--- ${displayOld}`, - `+++ ${displayNew}`, - `@@ -${hunkRange(oldStart, oldCount)} +${hunkRange(newStart, newCount)} @@`, - ...before.map((line) => ` ${line}`), - ...oldChanged.map((line) => `-${line}`), - ...newChanged.map((line) => `+${line}`), - ...after.map((line) => ` ${line}`), + body, ] .filter((line): line is string => line !== undefined) .join("\n"); From daa729a6a9345926d5167ef88d0059961bdf07ee Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 30 Jun 2026 18:48:42 +0530 Subject: [PATCH 68/73] test(apply-patch): cover review edge cases --- src/apply-patch.test.ts | 21 +++++++++++++++++++++ src/apply-patch.ts | 10 ++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 1e269dc3..df3e70af 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -149,6 +149,27 @@ const splitHunkResult = await applyPatch( *** End Patch`, ); assert.equal(splitHunkResult.patch.match(/^@@ /gm)?.length, 2); +assert.equal( + await readFile(join(splitHunkRoot, "long.txt"), "utf8"), + [ + "1", "two", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "eighteen", "19", "20", + ].join("\n") + "\n", +); + +const trailingSpaceRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-trailing-space-")); +await writeFile(join(trailingSpaceRoot, "spaces.txt"), "old\n"); +const trailingSpaceResult = await applyPatch( + trailingSpaceRoot, + `*** Begin Patch +*** Update File: spaces.txt +@@ +-old ++new${" "} +*** End Patch`, +); +assert.equal(trailingSpaceResult.patch.endsWith("+new "), true); +assert.equal(await readFile(join(trailingSpaceRoot, "spaces.txt"), "utf8"), "new \n"); assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index f18c19ff..d1ca5db3 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -431,18 +431,24 @@ function unifiedFilePatch( "", "", { context: 3, headerOptions: FILE_HEADERS_ONLY }, - ).trimEnd(); + ); return [ `diff --git a/${oldPath} b/${newPath}`, oldContent === null ? "new file mode 100644" : undefined, newContent === null ? "deleted file mode 100644" : undefined, - body, + stripFinalNewline(body), ] .filter((line): line is string => line !== undefined) .join("\n"); } +function stripFinalNewline(value: string): string { + if (value.endsWith("\r\n")) return value.slice(0, -2); + if (value.endsWith("\n")) return value.slice(0, -1); + return value; +} + function countPatchStats(patch: string): { additions: number; removals: number } { let additions = 0; let removals = 0; From 466e8cc978d8d2461be5c5cca991974eec38a5c6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 00:15:00 +0530 Subject: [PATCH 69/73] refactor(ui): derive apply patch card title --- package.json | 2 +- src/ui/card-types.ts | 3 ++ src/ui/patch-display.test.ts | 70 ++++++++++++++++++++++++++++++++++ src/ui/patch-display.ts | 74 ++++++++++++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 22 ++++++++++- 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/ui/patch-display.test.ts create mode 100644 src/ui/patch-display.ts diff --git a/package.json b/package.json index 15e011a4..e16711b3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 11734237..1376f62a 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,8 @@ export type ToolName = export type HostContext = NonNullable>; +export type PatchOperation = "add" | "update" | "delete" | "move"; + export interface ToolResultCard { tool: ToolName; workspaceId?: string; @@ -33,6 +35,7 @@ export interface ToolResultCard { files?: Array<{ path?: string; previousPath?: string; + operation?: PatchOperation; type?: string; additions?: number; removals?: number; diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts new file mode 100644 index 00000000..5583acea --- /dev/null +++ b/src/ui/patch-display.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { getPatchDisplayParts } from "./patch-display.js"; + +assert.deepEqual(getPatchDisplayParts({}), { + title: "Apply Patch", + tone: "edit", +}); + +assert.deepEqual( + getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), + { + title: "Write File", + iconOperation: "add", + tone: "write", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "a.ts", operation: "add" }, + { path: "b.ts", operation: "add" }, + ], + }), + { + title: "Write Files", + iconOperation: "add", + tone: "write", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "created.ts", operation: "add" }, + { path: "edited.ts", operation: "update" }, + ], + }), + { + title: "Write & Edit Files", + tone: "edit", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "same.ts", operation: "add" }, + { path: "same.ts", operation: "update" }, + ], + }), + { + title: "Write & Edit File", + tone: "edit", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "edited.ts", operation: "update" }, + { path: "moved.ts", previousPath: "old.ts", operation: "move" }, + { path: "removed.ts", operation: "delete" }, + ], + }), + { + title: "Edit, Move & Delete Files", + tone: "edit", + }, +); diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts new file mode 100644 index 00000000..92632331 --- /dev/null +++ b/src/ui/patch-display.ts @@ -0,0 +1,74 @@ +import type { PatchOperation, ToolResultCard } from "./card-types.js"; + +export interface PatchDisplayParts { + title: string; + iconOperation?: PatchOperation; + tone: "edit" | "write"; +} + +const patchOperationLabels: Record = { + add: "Write", + update: "Edit", + delete: "Delete", + move: "Move", +}; + +export function getPatchDisplayParts(card: Pick): PatchDisplayParts { + const files = card.files ?? []; + const operations = patchOperations(files); + + if (operations.length === 0) { + return { title: "Apply Patch", tone: "edit" }; + } + + const singleOperation = operations.length === 1 ? operations[0] : undefined; + + const display: PatchDisplayParts = { + title: patchTitle(operations, countChangedFiles(files)), + tone: singleOperation === "add" ? "write" : "edit", + }; + if (singleOperation) display.iconOperation = singleOperation; + return display; +} + +function patchOperations(files: NonNullable): PatchOperation[] { + const operations = new Set(); + for (const file of files) { + if (file.operation) operations.add(file.operation); + } + return [...operations]; +} + +function countChangedFiles(files: NonNullable): number { + const paths = new Set(); + let unnamedFiles = 0; + + for (const file of files) { + const path = file.path ?? file.previousPath; + if (path) { + paths.add(path); + } else { + unnamedFiles += 1; + } + } + + return paths.size + unnamedFiles; +} + +function patchTitle(operations: PatchOperation[], fileCount: number): string { + if (operations.length === 1) { + return `${patchOperationLabels[operations[0]]} ${fileNoun(fileCount)}`; + } + + return `${joinTitleParts(operations.map((operation) => patchOperationLabels[operation]))} ${fileNoun(fileCount)}`; +} + +function fileNoun(fileCount: number): "File" | "Files" { + return fileCount === 1 ? "File" : "Files"; +} + +function joinTitleParts(parts: string[]): string { + if (parts.length <= 1) return parts[0] ?? ""; + if (parts.length === 2) return `${parts[0]} & ${parts[1]}`; + return `${parts.slice(0, -1).join(", ")} & ${parts.at(-1)}`; +} diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index b802bd08..c7bb58a1 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -19,9 +19,11 @@ import { payloadText, summaryNumber, type HostContext, + type PatchOperation, type ToolName, type ToolResultCard, } from "./card-types.js"; +import { getPatchDisplayParts } from "./patch-display.js"; import "./workspace-app.css"; interface ToolDisplay { @@ -491,6 +493,24 @@ function formatAgentsFilesForPayload( .join("\n\n"); } +function getPatchToolDisplay(card: ToolResultCard, label: string): ToolDisplay { + const display = getPatchDisplayParts(card); + + return { + icon: patchIcon(display.iconOperation), + title: display.title, + label, + tone: display.tone, + }; +} + +function patchIcon(operation: PatchOperation | undefined): string { + if (operation === "add") return filePlusIcon(); + if (operation === "delete") return fileIcon(); + if (operation === "move") return filesIcon(); + return editIcon(); +} + function getToolDisplay(card: ToolResultCard): ToolDisplay { const label = getToolLabel(card); @@ -507,7 +527,7 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { case "edit": return { icon: editIcon(), title: "Edit File", label, tone: "edit" }; case "apply_patch": - return { icon: editIcon(), title: "Apply Patch", label, tone: "edit" }; + return getPatchToolDisplay(card, label); case "grep_files": case "grep": return { icon: searchIcon(), title: "Grep", label, tone: "search" }; From 0464e550ec2054425b0de5274c3735ec4bdbeacc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 00:24:04 +0530 Subject: [PATCH 70/73] docs(tool): simplify apply patch description --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 562e04b4..da241312 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1082,7 +1082,7 @@ function createMcpServer( { title: "Apply patch", description: - "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. File changes are staged and written only after all patch actions validate. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + "Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", inputSchema: { workspaceId: z .string() From 25c25cae8176985ebbf325e9de250ad65fa215a6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 1 Jul 2026 00:50:31 +0530 Subject: [PATCH 71/73] fix(apply-patch): handle case-only moves --- src/apply-patch.test.ts | 13 ++++++++++++- src/apply-patch.ts | 33 +++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index df3e70af..c6f08695 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyPatch, parsePatch, replaceFile } from "./apply-patch.js"; +import { applyPatch, isSamePatchFile, parsePatch, replaceFile } from "./apply-patch.js"; const root = await mkdtemp(join(tmpdir(), "devspace-apply-patch-")); const replacement = join(root, "replacement.txt"); @@ -11,6 +11,17 @@ await writeFile(replacement, "old\n"); await writeFile(replacementTemporary, "new\n"); await replaceFile(replacementTemporary, replacement, true, "win32"); assert.equal(await readFile(replacement, "utf8"), "new\n"); + +const sameIdentity = async (): Promise<{ dev: number; ino: number }> => ({ dev: 1, ino: 2 }); +const differentIdentity = async (path: string): Promise<{ dev: number; ino: number }> => ({ + dev: 1, + ino: path.endsWith("foo.txt") ? 3 : 2, +}); +assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/Foo.txt"), true); +assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/foo.txt", sameIdentity), true); +assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/bar.txt", sameIdentity), false); +assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/foo.txt", differentIdentity), false); + await writeFile(join(root, "alpha.txt"), "one\ntwo\nthree\n"); await writeFile(join(root, "remove.txt"), "remove me\n"); await writeFile(join(root, "windows.txt"), "first\r\nsecond\r\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index d1ca5db3..32ec4a46 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { constants } from "node:fs"; -import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { constants, type Stats } from "node:fs"; +import { access, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { TextDecoder } from "node:util"; import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff"; @@ -42,6 +42,8 @@ interface TextFile { } type StagedTextFile = TextFile | null; +type FileIdentity = Pick; +type FileIdentityReader = (path: string) => Promise; function patchError(message: string): Error { return new Error(`Invalid patch: ${message}`); @@ -317,6 +319,27 @@ export async function replaceFile( await rm(backup, { force: true }); } +export async function isSamePatchFile( + source: string, + destination: string, + readIdentity: FileIdentityReader = lstat, +): Promise { + if (source === destination) return true; + if (source.toLowerCase() !== destination.toLowerCase()) return false; + + try { + const [sourceIdentity, destinationIdentity] = await Promise.all([ + readIdentity(source), + readIdentity(destination), + ]); + return sourceIdentity.dev === destinationIdentity.dev && sourceIdentity.ino === destinationIdentity.ino; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return false; + throw error; + } +} + export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); const results: AppliedPatchFile[] = []; @@ -359,9 +382,11 @@ export async function applyPatch(root: string, patch: string): Promise Date: Wed, 1 Jul 2026 02:50:01 +0530 Subject: [PATCH 72/73] Clarify show changes review timing --- src/server.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/server.ts b/src/server.ts index 9b1c0dbe..dac0d730 100644 --- a/src/server.ts +++ b/src/server.ts @@ -187,8 +187,13 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + const showChangesInstruction = + config.widgets === "changes" + ? " If you successfully create, edit, overwrite, delete, move, or apply patches to files in a turn, 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 change; do not skip it because individual file-change tools already returned diffs." + : ""; + 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.`; + 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" @@ -201,12 +206,7 @@ function serverInstructions(config: ServerConfig, toolNames: ToolNames): 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. `; - const showChanges = - config.widgets === "changes" - ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." - : ""; - - 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.${showChanges}`; + 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 resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { @@ -1181,7 +1181,7 @@ function createMcpServer( { title: "Show changes", description: - "Show aggregate file changes in an open workspace since the last shown checkpoint or since the workspace was opened. After you create, edit, or overwrite files, call this once when the related file changes are complete so the user can inspect the combined diff.", + "Show aggregate file changes for an open workspace. After the final successful edit, write, or apply_patch call in the current turn, call this exactly once for that workspace before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual change, and do not skip it because prior file-change tools already displayed per-tool diffs.", inputSchema: { workspaceId: z .string() @@ -1189,7 +1189,7 @@ function createMcpServer( since: z .enum(["last_shown", "workspace_open"]) .optional() - .describe("Defaults to last_shown. Use workspace_open to compare against the initial open_workspace checkpoint."), + .describe("Defaults to last_shown, which is correct for normal end-of-turn review. Use workspace_open only when the user asks to review all changes since opening the workspace."), markReviewed: z .boolean() .optional() From a5b7816dbca1e6d5689d8356c304aa45c6f8b6ba Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:08:53 +0530 Subject: [PATCH 73/73] Merge pull request #51 from Waishnav/simplify-show-changes-schema Simplify show changes tool schema --- docs/chatgpt-coding-workflow.md | 5 +++++ src/server.ts | 18 +++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 2119898e..8f75d57b 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -141,6 +141,11 @@ and shell tools. The aggregate `show_changes` tool is not exposed by default. Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` to expose the aggregate show-changes flow. +When `show_changes` is exposed, models should call it exactly once after the +final file modification in any turn that changes files. The tool only requires +the `workspaceId`; DevSpace automatically compares against the last shown +checkpoint and advances that checkpoint after rendering the aggregate diff. + ## Shell Use The shell tool is for commands that belong in a terminal: diff --git a/src/server.ts b/src/server.ts index cd77cce0..f13a1b8e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -165,7 +165,7 @@ interface ToolLogFields { function serverInstructions(config: ServerConfig): string { const showChangesInstruction = config.widgets === "changes" - ? " If you successfully create, edit, overwrite, delete, move, or apply patches to files in a turn, 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 change; do not skip it because individual file-change tools already returned diffs." + ? " 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." : ""; if (config.toolMode === "codex") { @@ -1156,32 +1156,24 @@ function createMcpServer( { title: "Show changes", description: - "Show aggregate file changes for an open workspace. After the final successful edit, write, or apply_patch call in the current turn, call this exactly once for that workspace before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual change, and do not skip it because prior file-change tools already displayed per-tool diffs.", + "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs.", inputSchema: { workspaceId: z .string() .describe("Workspace identifier returned by open_workspace."), - since: z - .enum(["last_shown", "workspace_open"]) - .optional() - .describe("Defaults to last_shown, which is correct for normal end-of-turn review. Use workspace_open only when the user asks to review all changes since opening the workspace."), - markReviewed: z - .boolean() - .optional() - .describe("Defaults to true. When true, advances the last shown checkpoint to the current workspace state."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "show_changes"), annotations: { readOnlyHint: true }, }, - async ({ workspaceId, since, markReviewed }) => { + async ({ workspaceId }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const review = await reviewCheckpoints.reviewChanges({ workspaceId, root: workspace.root, - since: since ?? "last_shown", - markReviewed: markReviewed ?? true, + since: "last_shown", + markReviewed: true, }); const content = [textBlock(review.result)];