From daf4e6a09a35359bb7f06cc5fb998e86aebbbe67 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:13:45 +0530 Subject: [PATCH 01/43] 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 4f29c4e..dc23aa8 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 bb0526c..5bf96f8 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 bfcd7af..e44c599 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 ebc296743e9643e8fe87e52381827fbcab620088 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:16:05 +0530 Subject: [PATCH 02/43] 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 f2027ea..8be6067 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", + "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", "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 0000000..7720ce7 --- /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 0000000..c76fdbb --- /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 0cd25ea52f9262e9b0855ee9f0e027ff3c441413 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:17:21 +0530 Subject: [PATCH 03/43] 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 e44c599..324e899 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 d0aa115bf6a9fef131b02278aad12852abb6a699 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:19:27 +0530 Subject: [PATCH 04/43] 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 8be6067..d482ae7 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", + "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", "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 0000000..16b2c12 --- /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 0000000..83125c0 --- /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 a440d423b1c33cd9d098247a2cd2ba60c5c46489 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:21:03 +0530 Subject: [PATCH 05/43] 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 324e899..d07ed58 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 2889ae9e83e047231e3f3cbefd42b9fd8cc6d6ed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:23:26 +0530 Subject: [PATCH 06/43] feat(exec): support optional PTY sessions --- package-lock.json | 24 ++++- package.json | 3 + src/process-sessions.test.ts | 23 +++++ src/process-sessions.ts | 193 ++++++++++++++++++++++++++--------- src/server.ts | 17 ++- 5 files changed, 206 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index acb8f63..040f526 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": "*", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -39,6 +40,9 @@ }, "engines": { "node": ">=20.12 <27" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -571,7 +575,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4672,6 +4676,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 d482ae7..73c8b44 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,8 @@ "overrides": { "protobufjs": "7.6.4", "ws": "8.21.0" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 16b2c12..1cceb8e 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 83125c0..83aea56 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 d07ed58..96f1124 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 eb178361f6008bab10a6ff1665d62ee53c3420fb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:24:23 +0530 Subject: [PATCH 07/43] 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 83aea56..b6c2c94 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 c97f41ebf6a62b5d7106aab8b3bc483b63fc0e34 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:17 +0530 Subject: [PATCH 08/43] 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 c5efc66..3b0efaf 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 0000000..63a8a4a --- /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 7107338..75848e1 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 7720ce7..9498fcf 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 e447f7be931bd307eebb2df9c3421f916cbf47d0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:50 +0530 Subject: [PATCH 09/43] 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 75848e1..fa3a61d 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 96f1124..2993c28 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 d7cac9b3d136c6689309fa61b8d63e1a8beababa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:11:47 +0530 Subject: [PATCH 10/43] 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 9498fcf..bea0607 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 c76fdbb..b518cdb 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 2993c28..8661932 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 fcc59a8770204ce8565738f74e6b84d99132375f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:12:00 +0530 Subject: [PATCH 11/43] 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 73c8b44..ec5d4a6 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", + "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", "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 0000000..6a98ff4 --- /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 89ec3fe..107f8f1 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 373b3ac..4be351c 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 8d591ee253aaa5dd2b5ba90397137893cf99d2a0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 15:18:28 +0530 Subject: [PATCH 12/43] 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 1cceb8e..01ee788 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 b6c2c94..236bc94 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 76469eeb503ba14ca1525268cb41208067c7403e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:03:18 +0530 Subject: [PATCH 13/43] 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 01ee788..706608c 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 236bc94..f79d319 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 59d08f38f365565b0fdc62668096ab43dd6e7111 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:16:50 +0530 Subject: [PATCH 14/43] 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 040f526..b684b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "node-pty": "*", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", From 961abd944e10275bf0a3b41a7153de33d622bc4c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:55:08 +0530 Subject: [PATCH 15/43] 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 ec5d4a6..48cc85e 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", + "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", "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 0000000..256e125 --- /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 0000000..88e6775 --- /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 f79d319..e0d18f8 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 7efd7e8b5893bd27519a860a303b90036f560451 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:56:43 +0530 Subject: [PATCH 16/43] 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 256e125..39b494d 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 88e6775..905d4d7 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 e0d18f8..18c974c 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 bfe41b7455cfabcca6aeba2fb1ecccab97e782e1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:58:12 +0530 Subject: [PATCH 17/43] 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 bea0607..c9bea76 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 b518cdb..43c3962 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 9437059e005e5ba1bdbdcc75239afc741d74753c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:59:01 +0530 Subject: [PATCH 18/43] 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 c9bea76..d69901e 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 706608c..94f5adc 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 a0193d6444e4d92c9a07cc9de94f2ef1483d69b7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:04:05 +0530 Subject: [PATCH 19/43] 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 39b494d..caaaefb 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 905d4d7..ba99a4b 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 94f5adc..0148f97 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 b00718a42ff0febdf4ee3d847e29487c3c19acdc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:08:47 +0530 Subject: [PATCH 20/43] 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 b684b0d..0ff8f44 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": ">=20.12 <27" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.12" } }, "node_modules/@clack/core": { @@ -4683,9 +4684,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 48cc85e..b926fc4 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,6 @@ "ws": "8.21.0" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.12" } } From 8b2c13599036ca237cd49abe3f2180ff32ebe3b0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:10:14 +0530 Subject: [PATCH 21/43] 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 0148f97..ee11556 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 9a3c61664cb68029b2b90c9637d6aaeb3ee8fb88 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:11:42 +0530 Subject: [PATCH 22/43] 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 ee11556..7183f63 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 fcdb03f30947b2a1ab4e7cc0d98f74e64ba9420b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:13:18 +0530 Subject: [PATCH 23/43] 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 18c974c..43cd8c7 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 a0361cd90b7f7cb4eab8bcada661e8c38e92bb17 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:16:35 +0530 Subject: [PATCH 24/43] 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 caaaefb..39b494d 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 ba99a4b..905d4d7 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 5950b7154bd1c0c82dba056da9ecdbf4540a9e77 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:19:16 +0530 Subject: [PATCH 25/43] 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 7183f63..4e185c0 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 03198bf8596bca43e87a28c8d6f92189a02064d6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:22:43 +0530 Subject: [PATCH 26/43] 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 39b494d..24a214d 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 905d4d7..69ca950 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 e76ef808b968da2681d9979f885052a0b3c97f7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:26:09 +0530 Subject: [PATCH 27/43] 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 4e185c0..f636094 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 a2cefeb9854d05ac70e3730eacf22dadbcb60740 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:28:53 +0530 Subject: [PATCH 28/43] 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 f636094..59cee81 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 584e7f049b0ef7d4ccdbaa48cf1c3c4e862b1d84 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:31:15 +0530 Subject: [PATCH 29/43] 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 59cee81..d7f0c6d 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 ce9522c35babad81bf6aa98b9886e2eed6dfab27 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:33:22 +0530 Subject: [PATCH 30/43] 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 24a214d..cd96d3f 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 69ca950..80d80f1 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 8db6800562124845b2e19e421306a68cabe8b1e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:39:04 +0530 Subject: [PATCH 31/43] 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 0ff8f44..8af04a0 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": ">=20.12 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.12" + "node-pty": "^1.2.0-beta.13" } }, "node_modules/@clack/core": { @@ -4684,9 +4684,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 b926fc4..4e1ee48 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,6 @@ "ws": "8.21.0" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.12" + "node-pty": "^1.2.0-beta.13" } } From 330e416b76b6812792ea9c925eceb16857c2cab6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:42:27 +0530 Subject: [PATCH 32/43] 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 cd96d3f..39b494d 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 80d80f1..905d4d7 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 43cd8c7..2895ba5 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 ed792a13f3989e1e5400df78743f36f5c1b8d860 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:48:05 +0530 Subject: [PATCH 33/43] 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 2895ba5..aa58506 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 6d02dfc0d03947595b7a90fdb3b0a22f825a03de Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:52:56 +0530 Subject: [PATCH 34/43] 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 8af04a0..35da64f 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": ">=20.12 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -4684,9 +4684,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 4e1ee48..65a3de0 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", "typecheck": "tsc -p tsconfig.json --noEmit" @@ -62,6 +63,6 @@ "ws": "8.21.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 0000000..1990bf4 --- /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 1447588276593238efaa83b0f4200414d4229fd8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:57:00 +0530 Subject: [PATCH 35/43] 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 aa58506..391825a 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 043547366e9b0242a517b491a7adfdc72eee6914 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:00:08 +0530 Subject: [PATCH 36/43] 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 391825a..36427b0 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 fee90beec3d4e1d4a3a298940f522b45764d7ed5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:03:56 +0530 Subject: [PATCH 37/43] 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 d7f0c6d..0609782 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 905750034f8b4e73c87f295a123b42dc3fbfb1db Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:08:45 +0530 Subject: [PATCH 38/43] 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 36427b0..90bfb0d 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 19116dafc2f2c10a58015cb6d48eed711fb3d75b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:12:20 +0530 Subject: [PATCH 39/43] 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 35da64f..ab3b827 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": ">=20.12 <27" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.13" } }, "node_modules/@clack/core": { @@ -4684,9 +4685,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 65a3de0..9ad5d21 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,6 @@ "ws": "8.21.0" }, "optionalDependencies": { - "node-pty": "^1.1.0" + "node-pty": "^1.2.0-beta.13" } } From 6055a327c9b798d9431716863dd13f78c40126b4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:16:38 +0530 Subject: [PATCH 40/43] 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 90bfb0d..0b8d758 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 20429b4cb8e11f33d5653ae1ced68f7909f85e61 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:21:26 +0530 Subject: [PATCH 41/43] 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 0b8d758..736890e 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 2324f2a07612b86b60c9a30d6f3999b46f34eda1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 42/43] 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 736890e..c6a0c89 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 7fa956cf5ea97fb3a60eb0ddccff54f5a1f89a7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 43/43] 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 ab3b827..bba78ab 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": ">=20.12 <27" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -4685,9 +4685,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 9ad5d21..65a3de0 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,6 @@ "ws": "8.21.0" }, "optionalDependencies": { - "node-pty": "^1.2.0-beta.13" + "node-pty": "^1.1.0" } }