From daf4e6a09a35359bb7f06cc5fb998e86aebbbe67 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:13:45 +0530 Subject: [PATCH 01/46] feat(config): add codex tool mode --- src/config.test.ts | 11 ++++++----- src/config.ts | 20 +++++++++++--------- src/server.ts | 6 +++--- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4ed..dc23aa8a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,11 +18,12 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off") assert.equal(loadConfig(baseEnv).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "short" }).toolNaming, "short"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "legacy" }).toolNaming, "legacy"); -assert.equal(loadConfig(baseEnv).minimalTools, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTools, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).minimalTools, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).minimalTools, true); +assert.equal(loadConfig(baseEnv).toolMode, "minimal"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); diff --git a/src/config.ts b/src/config.ts index bb0526c4..5bf96f87 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import type { OAuthConfig } from "./oauth-provider.js"; import { loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; +export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -17,7 +18,7 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - minimalTools: boolean; + toolMode: ToolMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; @@ -79,14 +80,15 @@ function parseBoolean(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); } -function parseMinimalTools(env: NodeJS.ProcessEnv): boolean { - if (env.DEVSPACE_TOOL_MODE === "minimal") return true; - if (env.DEVSPACE_TOOL_MODE === "full") return false; - if (env.DEVSPACE_TOOL_MODE) { - throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${env.DEVSPACE_TOOL_MODE}`); +function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { + const mode = env.DEVSPACE_TOOL_MODE; + if (mode === "minimal" || mode === "full" || mode === "codex") return mode; + if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); + + if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { + return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; } - if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS); - return true; + return "minimal"; } function parseLogLevel(value: string | undefined): LogLevel { @@ -227,7 +229,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - minimalTools: parseMinimalTools(env), + toolMode: parseToolMode(env), toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), diff --git a/src/server.ts b/src/server.ts index bfcd7afc..e44c599d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -185,7 +185,7 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { - const inspection = config.minimalTools + const inspection = config.toolMode !== "full" ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -961,7 +961,7 @@ function createMcpServer( ); } - if (!config.minimalTools) { + if (config.toolMode === "full") { registerAppTool( server, toolNames.grep, @@ -1177,7 +1177,7 @@ function createMcpServer( toolNames.shell, { title: config.toolNaming === "short" ? "Bash" : "Run shell", - description: config.minimalTools + description: config.toolMode !== "full" ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, inputSchema: { From ebc296743e9643e8fe87e52381827fbcab620088 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:16:05 +0530 Subject: [PATCH 02/46] 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 f2027eaf..8be60671 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 00000000..7720ce7b --- /dev/null +++ b/src/apply-patch.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyPatch, parsePatch } from "./apply-patch.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-apply-patch-")); +await writeFile(join(root, "alpha.txt"), "one\ntwo\nthree\n"); +await writeFile(join(root, "remove.txt"), "remove me\n"); +await writeFile(join(root, "windows.txt"), "first\r\nsecond\r\n"); + +const result = await applyPatch( + root, + `*** Begin Patch +*** Add File: nested/added.txt ++new ++file +*** Update File: alpha.txt +@@ + one +-two ++changed + three +*** Update File: windows.txt +@@ + first +-second ++updated +*** Delete File: remove.txt +*** End Patch`, +); + +assert.deepEqual(result.files, [ + { path: "nested/added.txt", operation: "add" }, + { path: "alpha.txt", operation: "update" }, + { path: "windows.txt", operation: "update" }, + { path: "remove.txt", operation: "delete" }, +]); +assert.equal(await readFile(join(root, "nested/added.txt"), "utf8"), "new\nfile\n"); +assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthree\n"); +assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); +await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); + +const moveResult = await applyPatch( + root, + `*** Begin Patch +*** Update File: alpha.txt +*** Move to: moved/alpha.txt +@@ +-one ++ONE + changed +*** End Patch`, +); +assert.deepEqual(moveResult.files, [ + { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, +]); +assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); + +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: ../escape.txt ++no +*** End Patch`, + ), + /path escapes the workspace/, +); + +const outside = await mkdtemp(join(tmpdir(), "devspace-apply-patch-outside-")); +await symlink(outside, join(root, "outside-link")); +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: outside-link/escape.txt ++no +*** End Patch`, + ), + /path resolves outside the workspace/, +); + +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Update File: moved/alpha.txt +@@ +-not present ++replacement +*** End Patch`, + ), + /could not find hunk context/, +); +assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); + +assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); +assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); diff --git a/src/apply-patch.ts b/src/apply-patch.ts new file mode 100644 index 00000000..c76fdbbe --- /dev/null +++ b/src/apply-patch.ts @@ -0,0 +1,303 @@ +import { constants } from "node:fs"; +import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; + +export type PatchOperation = "add" | "update" | "delete" | "move"; + +export interface AppliedPatchFile { + path: string; + previousPath?: string; + operation: PatchOperation; +} + +export interface ApplyPatchResult { + files: AppliedPatchFile[]; +} + +interface HunkLine { + kind: "context" | "add" | "remove"; + text: string; +} + +interface UpdateHunk { + lines: HunkLine[]; +} + +type PatchAction = + | { kind: "add"; path: string; content: string } + | { kind: "delete"; path: string } + | { kind: "update"; path: string; moveTo?: string; hunks: UpdateHunk[] }; + +interface StagedFile { + content: string; + mode?: number; +} + +function patchError(message: string): Error { + return new Error(`Invalid patch: ${message}`); +} + +export function parsePatch(patch: string): PatchAction[] { + const lines = patch.replace(/\r\n/g, "\n").split("\n"); + if (lines.at(-1) === "") lines.pop(); + if (lines.shift() !== "*** Begin Patch") { + throw patchError("missing *** Begin Patch marker"); + } + if (lines.pop() !== "*** End Patch") { + throw patchError("missing *** End Patch marker"); + } + + const actions: PatchAction[] = []; + let index = 0; + + while (index < lines.length) { + const header = lines[index++]; + + if (header.startsWith("*** Add File: ")) { + const path = header.slice("*** Add File: ".length); + const content: string[] = []; + while (index < lines.length && !lines[index].startsWith("*** ")) { + const line = lines[index++]; + if (!line.startsWith("+")) { + throw patchError(`added file line must start with +: ${line}`); + } + content.push(line.slice(1)); + } + actions.push({ + kind: "add", + path, + content: content.length > 0 ? `${content.join("\n")}\n` : "", + }); + continue; + } + + if (header.startsWith("*** Delete File: ")) { + actions.push({ kind: "delete", path: header.slice("*** Delete File: ".length) }); + continue; + } + + if (header.startsWith("*** Update File: ")) { + const path = header.slice("*** Update File: ".length); + let moveTo: string | undefined; + const hunks: UpdateHunk[] = []; + + if (lines[index]?.startsWith("*** Move to: ")) { + moveTo = lines[index++].slice("*** Move to: ".length); + } + + while (index < lines.length && !lines[index].startsWith("*** ")) { + const hunkHeader = lines[index++]; + if (!hunkHeader.startsWith("@@")) { + throw patchError(`expected hunk header, received: ${hunkHeader}`); + } + + const hunkLines: HunkLine[] = []; + while ( + index < lines.length && + !lines[index].startsWith("@@") && + !lines[index].startsWith("*** ") + ) { + const line = lines[index++]; + if (line.startsWith(" ")) hunkLines.push({ kind: "context", text: line.slice(1) }); + else if (line.startsWith("+")) hunkLines.push({ kind: "add", text: line.slice(1) }); + else if (line.startsWith("-")) hunkLines.push({ kind: "remove", text: line.slice(1) }); + else if (line === "\\ No newline at end of file") continue; + else throw patchError(`hunk line must start with space, +, or -: ${line}`); + } + + if (hunkLines.length === 0) throw patchError(`empty update hunk for ${path}`); + hunks.push({ lines: hunkLines }); + } + + if (hunks.length === 0 && !moveTo) { + throw patchError(`update for ${path} has no hunks or move destination`); + } + actions.push({ kind: "update", path, moveTo, hunks }); + continue; + } + + throw patchError(`unknown action header: ${header}`); + } + + if (actions.length === 0) throw patchError("contains no file actions"); + return actions; +} + +function isInside(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +async function resolveConfinedPath(root: string, input: string): Promise { + if (!input || input.includes("\0") || isAbsolute(input)) { + throw patchError(`path must be relative to the workspace: ${input}`); + } + + const rootPath = await realpath(root); + const target = resolve(rootPath, input); + if (!isInside(rootPath, target)) { + throw patchError(`path escapes the workspace: ${input}`); + } + + let existing = target; + while (true) { + try { + const resolved = await realpath(existing); + if (!isInside(rootPath, resolved)) { + throw patchError(`path resolves outside the workspace: ${input}`); + } + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + const parent = dirname(existing); + if (parent === existing) throw error; + existing = parent; + } + } + + return target; +} + +function splitFile(content: string): { lines: string[]; eol: string; finalNewline: boolean } { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const normalized = content.replace(/\r\n/g, "\n"); + const finalNewline = normalized.endsWith("\n"); + const lines = normalized.split("\n"); + if (finalNewline) lines.pop(); + return { lines, eol, finalNewline }; +} + +function findSequence(haystack: string[], needle: string[], from: number): number { + if (needle.length === 0) return from; + + const matchAt = (index: number, normalize: (value: string) => string): boolean => + needle.every((line, offset) => normalize(haystack[index + offset] ?? "") === normalize(line)); + + for (const normalize of [ + (value: string) => value, + (value: string) => value.trimEnd(), + (value: string) => value.trim(), + ]) { + for (let index = from; index <= haystack.length - needle.length; index += 1) { + if (matchAt(index, normalize)) return index; + } + } + + return -1; +} + +function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string { + const file = splitFile(content); + const lines = [...file.lines]; + let cursor = 0; + + for (const hunk of hunks) { + const oldLines = hunk.lines + .filter((line) => line.kind !== "add") + .map((line) => line.text); + const newLines = hunk.lines + .filter((line) => line.kind !== "remove") + .map((line) => line.text); + const index = findSequence(lines, oldLines, cursor); + + if (index < 0) { + const preview = oldLines.slice(0, 3).join("\n"); + throw patchError(`could not find hunk context in ${path}: ${preview}`); + } + + lines.splice(index, oldLines.length, ...newLines); + cursor = index + newLines.length; + } + + const normalized = lines.join("\n") + (file.finalNewline ? "\n" : ""); + return file.eol === "\r\n" ? normalized.replace(/\n/g, "\r\n") : normalized; +} + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function applyPatch(root: string, patch: string): Promise { + const actions = parsePatch(patch); + const staged = new Map(); + const results: AppliedPatchFile[] = []; + + const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { + const absolute = await resolveConfinedPath(root, displayPath); + if (staged.has(absolute)) { + const file = staged.get(absolute); + if (!file) throw patchError(`file does not exist: ${displayPath}`); + return { absolute, file }; + } + if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); + const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + const file = { content: await readFile(absolute, "utf8"), mode: metadata.mode }; + staged.set(absolute, file); + return { absolute, file }; + }; + + for (const action of actions) { + if (action.kind === "add") { + const absolute = await resolveConfinedPath(root, action.path); + if (staged.get(absolute) || (!staged.has(absolute) && (await fileExists(absolute)))) { + throw patchError(`file already exists: ${action.path}`); + } + staged.set(absolute, { content: action.content }); + results.push({ path: action.path, operation: "add" }); + continue; + } + + const { absolute, file } = await load(action.path); + + if (action.kind === "delete") { + staged.set(absolute, null); + results.push({ path: action.path, operation: "delete" }); + continue; + } + + const updated = applyHunks(action.path, file.content, action.hunks); + if (action.moveTo) { + const destination = await resolveConfinedPath(root, action.moveTo); + if ( + destination !== absolute && + (staged.get(destination) || (!staged.has(destination) && (await fileExists(destination)))) + ) { + throw patchError(`move destination already exists: ${action.moveTo}`); + } + staged.set(absolute, null); + staged.set(destination, { content: updated, mode: file.mode }); + results.push({ path: action.moveTo, previousPath: action.path, operation: "move" }); + } else { + staged.set(absolute, { content: updated, mode: file.mode }); + results.push({ path: action.path, operation: "update" }); + } + } + + const pendingWrites: Array<{ temporary: string; destination: string }> = []; + for (const [destination, file] of staged) { + if (!file) continue; + await mkdir(dirname(destination), { recursive: true }); + const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; + await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); + pendingWrites.push({ temporary, destination }); + } + + try { + for (const write of pendingWrites) await rename(write.temporary, write.destination); + for (const [path, file] of staged) { + if (!file) await rm(path, { force: true }); + } + } catch (error) { + await Promise.all(pendingWrites.map(({ temporary }) => rm(temporary, { force: true }))); + throw error; + } + + return { files: results }; +} From 0cd25ea52f9262e9b0855ee9f0e027ff3c441413 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:17:21 +0530 Subject: [PATCH 03/46] feat(tools): expose apply_patch in codex mode --- src/server.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/server.ts b/src/server.ts index e44c599d..324e899e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ import { import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; +import { applyPatch } from "./apply-patch.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; import { logEvent, @@ -185,6 +186,10 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + if (config.toolMode === "codex") { + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, and ${toolNames.shell} for inspection, tests, builds, and other commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + } + const inspection = config.toolMode !== "full" ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -733,6 +738,7 @@ function createMcpServer( }, ); + if (config.toolMode !== "codex") { registerAppTool( server, toolNames.write, @@ -896,6 +902,69 @@ function createMcpServer( }; }, ); + } + + if (config.toolMode === "codex") { + registerAppTool( + server, + "apply_patch", + { + title: "Apply patch", + description: + "Apply one Codex-style patch inside an open workspace. Supports adding, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace. Call open_workspace first and pass workspaceId.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + patch: z + .string() + .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), + }, + outputSchema: resultOutputSchema({ + files: z.array( + z.object({ + path: z.string(), + previousPath: z.string().optional(), + operation: z.enum(["add", "update", "delete", "move"]), + }), + ), + }), + ...toolWidgetDescriptorMeta(config, "edit"), + annotations: EDIT_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, patch }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const applied = await applyPatch(workspace.root, patch); + const paths = applied.files.map((file) => file.path).join(", "); + const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; + const content = [textBlock(result)]; + + logToolCall(config, { + tool: "apply_patch", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "apply_patch", + card: { + workspaceId, + summary: { files: applied.files.length }, + payload: { patch }, + }, + }, + structuredContent: { + result, + files: applied.files, + }, + }; + }, + ); + } if (config.widgets === "changes") { registerAppTool( From d0aa115bf6a9fef131b02278aad12852abb6a699 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:19:27 +0530 Subject: [PATCH 04/46] 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 8be60671..d482ae7d 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 00000000..16b2c120 --- /dev/null +++ b/src/process-sessions.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { ProcessSessionManager } from "./process-sessions.js"; + +const manager = new ProcessSessionManager({ + maxBufferCharacters: 1_024, + completedSessionTtlMs: 1_000, +}); + +const node = JSON.stringify(process.execPath); + +const foreground = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "console.log('foreground')"`, + yieldTimeMs: 2_000, +}); +assert.equal(foreground.running, false); +assert.equal(foreground.exitCode, 0); +assert.match(foreground.output, /foreground/); +assert.equal(foreground.sessionId, undefined); + +const background = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setTimeout(() => console.log('finished'), 100)"`, + yieldTimeMs: 5, +}); +assert.equal(background.running, true); +assert.ok(background.sessionId); + +await assert.rejects( + manager.write({ + workspaceId: "workspace-b", + sessionId: background.sessionId, + yieldTimeMs: 1, + }), + /does not belong to workspace/, +); + +const completed = await manager.write({ + workspaceId: "workspace-a", + sessionId: background.sessionId, + yieldTimeMs: 2_000, +}); +assert.equal(completed.running, false); +assert.equal(completed.exitCode, 0); +assert.match(completed.output, /finished/); + +const interactive = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, + yieldTimeMs: 5, +}); +assert.equal(interactive.running, true); +assert.ok(interactive.sessionId); + +const inputResult = await manager.write({ + workspaceId: "workspace-a", + sessionId: interactive.sessionId, + chars: "hello\n", + yieldTimeMs: 2_000, +}); +assert.equal(inputResult.running, false); +assert.match(inputResult.output, /input:hello/); + +const buffered = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "console.log('x'.repeat(5000)); setTimeout(() => {}, 100)"`, + yieldTimeMs: 50, + maxOutputTokens: 100, +}); +assert.equal(buffered.outputTruncated, true); +if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); + +manager.shutdown(); diff --git a/src/process-sessions.ts b/src/process-sessions.ts new file mode 100644 index 00000000..83125c0a --- /dev/null +++ b/src/process-sessions.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +const DEFAULT_YIELD_MS = 10_000; +const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; +const DEFAULT_BUFFER_CHARACTERS = 1_000_000; +const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1_000; + +export interface StartCommandInput { + workspaceId: string; + command: string; + cwd: string; + yieldTimeMs?: number; + maxOutputTokens?: number; +} + +export interface WriteStdinInput { + workspaceId: string; + sessionId: string; + chars?: string; + yieldTimeMs?: number; + maxOutputTokens?: number; +} + +export interface ProcessSnapshot { + sessionId?: string; + output: string; + outputTruncated: boolean; + running: boolean; + exitCode?: number; + signal?: NodeJS.Signals; + wallTimeMs: number; +} + +interface ProcessSession { + id: string; + workspaceId: string; + child: ChildProcessWithoutNullStreams; + startedAt: number; + buffer: string; + bufferStart: number; + consumedThrough: number; + running: boolean; + exitCode?: number; + signal?: NodeJS.Signals; + exitPromise: Promise; + resolveExit: () => void; + cleanupTimer?: NodeJS.Timeout; +} + +interface ProcessSessionManagerOptions { + maxBufferCharacters?: number; + completedSessionTtlMs?: number; +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0) throw new Error("Duration and output limits must be non-negative."); + return Math.min(Math.floor(value), maximum); +} + +function shellCommand(command: string): { executable: string; args: string[] } { + if (process.platform === "win32") { + return { + executable: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", command], + }; + } + + return { + executable: process.env.SHELL ?? "/bin/bash", + args: ["-lc", command], + }; +} + +function truncateOutput(output: string, maxOutputTokens: number): { output: string; truncated: boolean } { + const maxCharacters = Math.max(256, maxOutputTokens * 4); + if (output.length <= maxCharacters) return { output, truncated: false }; + + const marker = "\n... output truncated ...\n"; + const available = maxCharacters - marker.length; + const head = Math.ceil(available / 2); + const tail = Math.floor(available / 2); + return { + output: output.slice(0, head) + marker + output.slice(output.length - tail), + truncated: true, + }; +} + +export class ProcessSessionManager { + private readonly sessions = new Map(); + private readonly maxBufferCharacters: number; + private readonly completedSessionTtlMs: number; + + constructor(options: ProcessSessionManagerOptions = {}) { + this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS; + this.completedSessionTtlMs = options.completedSessionTtlMs ?? COMPLETED_SESSION_TTL_MS; + } + + async start(input: StartCommandInput): Promise { + const id = randomUUID(); + const shell = shellCommand(input.command); + const child = spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: process.env, + stdio: "pipe", + windowsHide: true, + }); + + let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + const session: ProcessSession = { + id, + workspaceId: input.workspaceId, + child, + startedAt: Date.now(), + buffer: "", + bufferStart: 0, + consumedThrough: 0, + running: true, + exitPromise, + resolveExit, + }; + this.sessions.set(id, session); + + child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.on("error", (error) => this.append(session, `${error.message}\n`)); + child.on("close", (code, signal) => { + session.running = false; + session.exitCode = code ?? undefined; + session.signal = signal ?? undefined; + session.resolveExit(); + session.cleanupTimer = setTimeout(() => this.sessions.delete(id), this.completedSessionTtlMs); + session.cleanupTimer.unref(); + }); + + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + await Promise.race([ + session.exitPromise, + new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), + ]); + + const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; + } + + async write(input: WriteStdinInput): Promise { + const session = this.getOwnedSession(input.workspaceId, input.sessionId); + const chars = input.chars ?? ""; + + if (chars.includes("\u0003") && session.running) { + session.child.kill("SIGINT"); + } + const writableChars = chars.replaceAll("\u0003", ""); + if (writableChars && session.running) session.child.stdin.write(writableChars); + + const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; + if (!hasUnreadOutput && session.running) { + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + await Promise.race([ + session.exitPromise, + new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), + ]); + } + + const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; + } + + terminate(workspaceId: string, sessionId: string): void { + const session = this.getOwnedSession(workspaceId, sessionId); + if (session.running) session.child.kill("SIGTERM"); + } + + shutdown(): void { + for (const session of this.sessions.values()) { + if (session.cleanupTimer) clearTimeout(session.cleanupTimer); + if (session.running) session.child.kill("SIGTERM"); + } + this.sessions.clear(); + } + + private append(session: ProcessSession, output: string): void { + session.buffer += output; + if (session.buffer.length <= this.maxBufferCharacters) return; + + const remove = session.buffer.length - this.maxBufferCharacters; + session.buffer = session.buffer.slice(remove); + session.bufferStart += remove; + } + + private consume(session: ProcessSession, maxOutputTokens?: number): ProcessSnapshot { + const missedOutput = session.consumedThrough < session.bufferStart; + const start = Math.max(0, session.consumedThrough - session.bufferStart); + const unread = session.buffer.slice(start); + session.consumedThrough = session.bufferStart + session.buffer.length; + + const limit = boundedInteger( + maxOutputTokens, + DEFAULT_MAX_OUTPUT_TOKENS, + 100_000, + ); + const truncated = truncateOutput(unread, limit); + + return { + sessionId: session.running ? session.id : undefined, + output: truncated.output, + outputTruncated: missedOutput || truncated.truncated, + running: session.running, + exitCode: session.exitCode, + signal: session.signal, + wallTimeMs: Date.now() - session.startedAt, + }; + } + + private getOwnedSession(workspaceId: string, sessionId: string): ProcessSession { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Unknown process session: ${sessionId}`); + if (session.workspaceId !== workspaceId) { + throw new Error(`Process session ${sessionId} does not belong to workspace ${workspaceId}.`); + } + return session; + } + + private removeSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session?.cleanupTimer) clearTimeout(session.cleanupTimer); + this.sessions.delete(sessionId); + } +} From a440d423b1c33cd9d098247a2cd2ba60c5c46489 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:21:03 +0530 Subject: [PATCH 05/46] feat(exec): expose exec_command and write_stdin --- src/server.ts | 194 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 324e899e..d07ed587 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,7 @@ import { writeFileTool, } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -187,7 +188,7 @@ function toolNamesFor(config: ServerConfig): ToolNames { function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, and ${toolNames.shell} for inspection, tests, builds, and other commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; } const inspection = config.toolMode !== "full" @@ -457,10 +458,191 @@ async function assertWorkspaceAppAssets(): Promise { } } +function processResult(snapshot: ProcessSnapshot): string { + const status = snapshot.running + ? `Process running with session ID ${snapshot.sessionId}.` + : snapshot.signal + ? `Process exited after signal ${snapshot.signal}.` + : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`; + return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; +} + +function processOutputSchema(): z.ZodRawShape { + return resultOutputSchema({ + sessionId: z.string().optional(), + running: z.boolean(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + wallTimeMs: z.number().nonnegative(), + outputTruncated: z.boolean(), + }); +} + +function processToolResponse( + tool: "exec_command" | "write_stdin", + workspaceId: string, + snapshot: ProcessSnapshot, + summary: Record, +) { + const result = processResult(snapshot); + const content = [textBlock(result)]; + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary, + payload: { content }, + }, + }, + structuredContent: { + result, + sessionId: snapshot.sessionId, + running: snapshot.running, + exitCode: snapshot.exitCode, + signal: snapshot.signal, + wallTimeMs: snapshot.wallTimeMs, + outputTruncated: snapshot.outputTruncated, + }, + }; +} + +function registerCodexProcessTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + processSessions: ProcessSessionManager, +): void { + registerAppTool( + server, + "exec_command", + { + title: "Execute command", + description: + "Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes. Call open_workspace first and pass workspaceId.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + cmd: z.string().min(1).describe("Shell command to execute."), + workingDirectory: z + .string() + .optional() + .describe("Working directory relative to the workspace root. Defaults to the workspace root."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Milliseconds to wait before returning a running session. Defaults to 10000."), + maxOutputTokens: z + .number() + .int() + .positive() + .max(100_000) + .optional() + .describe("Approximate output token budget. Defaults to 10000."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, cmd, workingDirectory, yieldTimeMs, maxOutputTokens }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); + const snapshot = await processSessions.start({ + workspaceId, + command: cmd, + cwd, + yieldTimeMs, + maxOutputTokens, + }); + + logToolCall(config, { + tool: "exec_command", + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: cmd, + commandLength: cmd.length, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return processToolResponse("exec_command", workspaceId, snapshot, { + command: cmd, + workingDirectory: workingDirectory ?? ".", + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); + + registerAppTool( + server, + "write_stdin", + { + title: "Write to process", + description: + "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier used to start the process."), + sessionId: z.string().describe("Process session identifier returned by exec_command."), + chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Milliseconds to wait for process output or completion. Defaults to 10000."), + maxOutputTokens: z + .number() + .int() + .positive() + .max(100_000) + .optional() + .describe("Approximate output token budget. Defaults to 10000."), + }, + outputSchema: processOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, sessionId, chars, yieldTimeMs, maxOutputTokens }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const snapshot = await processSessions.write({ + workspaceId, + sessionId, + chars, + yieldTimeMs, + maxOutputTokens, + }); + + logToolCall(config, { + tool: "write_stdin", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return processToolResponse("write_stdin", workspaceId, snapshot, { + sessionId, + charactersWritten: chars?.length ?? 0, + running: snapshot.running, + exitCode: snapshot.exitCode, + wallTimeMs: snapshot.wallTimeMs, + }); + }, + ); +} + function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + processSessions: ProcessSessionManager, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -1241,6 +1423,7 @@ function createMcpServer( ); } + if (config.toolMode !== "codex") { registerAppTool( server, toolNames.shell, @@ -1330,6 +1513,11 @@ function createMcpServer( }; }, ); + } + + if (config.toolMode === "codex") { + registerCodexProcessTools(server, config, workspaces, processSessions); + } return server; } @@ -1354,6 +1542,7 @@ export function createServer(config = loadConfig()): RunningServer { const workspaceStore = createWorkspaceStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); + const processSessions = new ProcessSessionManager(); if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1477,7 +1666,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints); + const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1503,6 +1692,7 @@ export function createServer(config = loadConfig()): RunningServer { close: () => { if (closed) return; closed = true; + processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); }, From 2889ae9e83e047231e3f3cbefd42b9fd8cc6d6ed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:23:26 +0530 Subject: [PATCH 06/46] 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 acb8f63d..040f5262 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 d482ae7d..73c8b44c 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 16b2c120..1cceb8eb 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -74,4 +74,27 @@ const buffered = await manager.start({ assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); +const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, +}); +assert.equal(pty.running, true); +assert.ok(pty.sessionId); + +const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + chars: "continue\r", + columns: 120, + rows: 30, + yieldTimeMs: 2_000, +}); +assert.equal(resizedPty.running, false); +assert.match(resizedPty.output, /columns:120/); + manager.shutdown(); diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 83125c0a..83aea56b 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,15 +1,20 @@ import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn } from "node:child_process"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; const DEFAULT_BUFFER_CHARACTERS = 1_000_000; const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1_000; +const DEFAULT_COLUMNS = 80; +const DEFAULT_ROWS = 24; export interface StartCommandInput { workspaceId: string; command: string; cwd: string; + tty?: boolean; + columns?: number; + rows?: number; yieldTimeMs?: number; maxOutputTokens?: number; } @@ -18,6 +23,8 @@ export interface WriteStdinInput { workspaceId: string; sessionId: string; chars?: string; + columns?: number; + rows?: number; yieldTimeMs?: number; maxOutputTokens?: number; } @@ -28,21 +35,29 @@ export interface ProcessSnapshot { outputTruncated: boolean; running: boolean; exitCode?: number; - signal?: NodeJS.Signals; + signal?: string; wallTimeMs: number; } +interface ManagedProcess { + write(data: string): void; + kill(signal?: NodeJS.Signals): void; + resize?(columns: number, rows: number): void; +} + interface ProcessSession { id: string; workspaceId: string; - child: ChildProcessWithoutNullStreams; + process?: ManagedProcess; startedAt: number; + columns: number; + rows: number; buffer: string; bufferStart: number; consumedThrough: number; running: boolean; exitCode?: number; - signal?: NodeJS.Signals; + signal?: string; exitPromise: Promise; resolveExit: () => void; cleanupTimer?: NodeJS.Timeout; @@ -55,10 +70,20 @@ interface ProcessSessionManagerOptions { function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { if (value === undefined) return fallback; - if (!Number.isFinite(value) || value < 0) throw new Error("Duration and output limits must be non-negative."); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Duration and output limits must be non-negative."); + } return Math.min(Math.floor(value), maximum); } +function terminalSize(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > 1_000) { + throw new Error("Terminal dimensions must be integers between 1 and 1000."); + } + return value; +} + function shellCommand(command: string): { executable: string; args: string[] } { if (process.platform === "win32") { return { @@ -73,6 +98,12 @@ function shellCommand(command: string): { executable: string; args: string[] } { }; } +function processEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +} + function truncateOutput(output: string, maxOutputTokens: number): { output: string; truncated: boolean } { const maxCharacters = Math.max(256, maxOutputTokens * 4); if (output.length <= maxCharacters) return { output, truncated: false }; @@ -98,44 +129,16 @@ export class ProcessSessionManager { } async start(input: StartCommandInput): Promise { - const id = randomUUID(); - const shell = shellCommand(input.command); - const child = spawn(shell.executable, shell.args, { - cwd: input.cwd, - env: process.env, - stdio: "pipe", - windowsHide: true, - }); + const session = this.createSession(input); + this.sessions.set(session.id, session); - let resolveExit = (): void => undefined; - const exitPromise = new Promise((resolve) => { - resolveExit = resolve; - }); - const session: ProcessSession = { - id, - workspaceId: input.workspaceId, - child, - startedAt: Date.now(), - buffer: "", - bufferStart: 0, - consumedThrough: 0, - running: true, - exitPromise, - resolveExit, - }; - this.sessions.set(id, session); - - child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); - child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); - child.on("error", (error) => this.append(session, `${error.message}\n`)); - child.on("close", (code, signal) => { - session.running = false; - session.exitCode = code ?? undefined; - session.signal = signal ?? undefined; - session.resolveExit(); - session.cleanupTimer = setTimeout(() => this.sessions.delete(id), this.completedSessionTtlMs); - session.cleanupTimer.unref(); - }); + try { + if (input.tty) await this.startPty(session, input); + else this.startPipe(session, input); + } catch (error) { + this.sessions.delete(session.id); + throw error; + } const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); await Promise.race([ @@ -152,11 +155,20 @@ export class ProcessSessionManager { const session = this.getOwnedSession(input.workspaceId, input.sessionId); const chars = input.chars ?? ""; + if (input.columns !== undefined || input.rows !== undefined) { + session.columns = terminalSize(input.columns, session.columns); + session.rows = terminalSize(input.rows, session.rows); + if (!session.process?.resize) { + throw new Error(`Process session ${session.id} is not a PTY and cannot be resized.`); + } + session.process.resize(session.columns, session.rows); + } + if (chars.includes("\u0003") && session.running) { - session.child.kill("SIGINT"); + session.process?.kill("SIGINT"); } const writableChars = chars.replaceAll("\u0003", ""); - if (writableChars && session.running) session.child.stdin.write(writableChars); + if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; if (!hasUnreadOutput && session.running) { @@ -174,17 +186,100 @@ export class ProcessSessionManager { terminate(workspaceId: string, sessionId: string): void { const session = this.getOwnedSession(workspaceId, sessionId); - if (session.running) session.child.kill("SIGTERM"); + if (session.running) session.process?.kill("SIGTERM"); } shutdown(): void { for (const session of this.sessions.values()) { if (session.cleanupTimer) clearTimeout(session.cleanupTimer); - if (session.running) session.child.kill("SIGTERM"); + if (session.running) session.process?.kill("SIGTERM"); } this.sessions.clear(); } + private createSession(input: StartCommandInput): ProcessSession { + let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + + return { + id: randomUUID(), + workspaceId: input.workspaceId, + startedAt: Date.now(), + columns: terminalSize(input.columns, DEFAULT_COLUMNS), + rows: terminalSize(input.rows, DEFAULT_ROWS), + buffer: "", + bufferStart: 0, + consumedThrough: 0, + running: true, + exitPromise, + resolveExit, + }; + } + + private startPipe(session: ProcessSession, input: StartCommandInput): void { + const shell = shellCommand(input.command); + const child = spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: process.env, + stdio: "pipe", + windowsHide: true, + }); + + session.process = { + write: (data) => child.stdin.write(data), + kill: (signal) => { + child.kill(signal); + }, + }; + child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); + child.on("error", (error) => this.append(session, `${error.message}\n`)); + child.on("close", (code, signal) => this.finish(session, code ?? undefined, signal ?? undefined)); + } + + private async startPty(session: ProcessSession, input: StartCommandInput): Promise { + let nodePty: typeof import("node-pty"); + try { + nodePty = await import("node-pty"); + } catch { + throw new Error("PTY support requires the optional node-pty dependency."); + } + + const shell = shellCommand(input.command); + const pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment(), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); + + session.process = { + write: (data) => pty.write(data), + kill: (signal) => pty.kill(signal), + resize: (columns, rows) => pty.resize(columns, rows), + }; + pty.onData((data) => this.append(session, data)); + pty.onExit(({ exitCode, signal }) => { + this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); + }); + } + + private finish(session: ProcessSession, exitCode?: number, signal?: string): void { + if (!session.running) return; + session.running = false; + session.exitCode = exitCode; + session.signal = signal; + session.resolveExit(); + session.cleanupTimer = setTimeout( + () => this.sessions.delete(session.id), + this.completedSessionTtlMs, + ); + session.cleanupTimer.unref(); + } + private append(session: ProcessSession, output: string): void { session.buffer += output; if (session.buffer.length <= this.maxBufferCharacters) return; @@ -200,11 +295,7 @@ export class ProcessSessionManager { const unread = session.buffer.slice(start); session.consumedThrough = session.bufferStart + session.buffer.length; - const limit = boundedInteger( - maxOutputTokens, - DEFAULT_MAX_OUTPUT_TOKENS, - 100_000, - ); + const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000); const truncated = truncateOutput(unread, limit); return { diff --git a/src/server.ts b/src/server.ts index d07ed587..96f11249 100644 --- a/src/server.ts +++ b/src/server.ts @@ -524,6 +524,12 @@ function registerCodexProcessTools( inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), cmd: z.string().min(1).describe("Shell command to execute."), + tty: z + .boolean() + .optional() + .describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."), + columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."), + rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."), workingDirectory: z .string() .optional() @@ -547,7 +553,7 @@ function registerCodexProcessTools( ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, cmd, workingDirectory, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); @@ -555,6 +561,9 @@ function registerCodexProcessTools( workspaceId, command: cmd, cwd, + tty, + columns, + rows, yieldTimeMs, maxOutputTokens, }); @@ -590,6 +599,8 @@ function registerCodexProcessTools( workspaceId: z.string().describe("Workspace identifier used to start the process."), sessionId: z.string().describe("Process session identifier returned by exec_command."), chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."), + columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."), + rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."), yieldTimeMs: z .number() .int() @@ -609,13 +620,15 @@ function registerCodexProcessTools( ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, sessionId, chars, yieldTimeMs, maxOutputTokens }) => { + async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const snapshot = await processSessions.write({ workspaceId, sessionId, chars, + columns, + rows, yieldTimeMs, maxOutputTokens, }); From eb178361f6008bab10a6ff1665d62ee53c3420fb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:24:23 +0530 Subject: [PATCH 07/46] fix(exec): terminate spawned process groups --- src/process-sessions.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 83aea56b..b6c2c94f 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -220,16 +220,26 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = shellCommand(input.command); + const detached = process.platform !== "win32"; const child = spawn(shell.executable, shell.args, { cwd: input.cwd, env: process.env, stdio: "pipe", windowsHide: true, + detached, }); session.process = { write: (data) => child.stdin.write(data), kill: (signal) => { + if (detached && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + } + } child.kill(signal); }, }; From c97f41ebf6a62b5d7106aab8b3bc483b63fc0e34 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:17 +0530 Subject: [PATCH 08/46] docs: document codex mode QA and rollout --- docs/chatgpt-coding-workflow.md | 14 +++++++ docs/codex-tool-mode-qa.md | 73 +++++++++++++++++++++++++++++++++ docs/configuration.md | 14 ++++++- src/apply-patch.test.ts | 20 ++++++++- 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 docs/codex-tool-mode-qa.md diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc662..3b0efaf2 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -119,6 +119,20 @@ Legacy names are available with `DEVSPACE_TOOL_NAMING=legacy`: Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. +The experimental Codex-style surface is enabled with +`DEVSPACE_TOOL_MODE=codex`. It exposes: + +- `open_workspace` +- `read` +- `apply_patch` +- `exec_command` +- `write_stdin` + +In this mode, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not +registered. `exec_command` returns a process session ID when a command is still +running after its yield window. Use `write_stdin` to poll it, send input, resize +a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. + ## Show Changes By default, `DEVSPACE_WIDGETS=full`. diff --git a/docs/codex-tool-mode-qa.md b/docs/codex-tool-mode-qa.md new file mode 100644 index 00000000..63a8a4aa --- /dev/null +++ b/docs/codex-tool-mode-qa.md @@ -0,0 +1,73 @@ +# Codex Tool Mode Manual QA + +Run these checks against a disposable Git repository inside an allowed DevSpace +root. Keep the DevSpace server logs visible during the test. + +## Setup + +1. Build the current branch with `npm ci && npm run build`. +2. Start DevSpace with `DEVSPACE_TOOL_MODE=codex devspace serve`. +3. Connect or refresh the DevSpace connector in ChatGPT. +4. Open the disposable repository with `open_workspace`. +5. Confirm the core tools are `open_workspace`, `read`, `apply_patch`, + `exec_command`, and `write_stdin`. +6. Confirm `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are absent. +7. If `DEVSPACE_WIDGETS=changes`, also expect `show_changes`. + +## Apply Patch + +1. Add a text file containing multiple lines and a blank line. +2. Update two separate regions of that file in one patch. +3. Create a nested file, rename it, and then delete it. +4. Patch an existing CRLF file and verify it remains CRLF. +5. Verify executable permissions survive an update and a move. +6. Try to add `../outside.txt`; confirm the tool rejects the path. +7. Patch through a symlink targeting an external directory; confirm rejection. +8. Submit a hunk whose context is absent; confirm no file from that patch changes. +9. With changes widgets enabled, inspect the aggregate diff. + +## Foreground Commands + +1. Run `pwd` and confirm it reports the opened workspace. +2. Run a command in a relative `workingDirectory` and confirm the directory. +3. Write to stdout and stderr; confirm both appear. +4. Exit nonzero; confirm `running=false` and the exit code. +5. Use a small output budget on a noisy command; confirm truncation is reported. + +## Background Sessions + +1. Start a delayed command with a short yield time. +2. Confirm `exec_command` returns `running=true` and a `sessionId`. +3. Poll with empty `chars`; confirm output is not duplicated. +4. Poll until completion; confirm the final exit code and no `sessionId`. +5. Poll the completed session again; confirm it is unknown. +6. Reconnect MCP without restarting DevSpace and confirm polling still works. +7. Restart DevSpace and confirm old process session IDs are invalid. + +## Input, Interrupt, And PTY + +1. Start a program that reads stdin without a PTY and send it a line. +2. Start a long-running process and send `\u0003`; confirm it stops. +3. Start an interactive program with `tty=true`; confirm it detects a TTY. +4. Resize a PTY from 80x24 to 120x30 and verify the observed dimensions. +5. Omit optional dependencies; normal commands must work and `tty=true` must + return the explicit `node-pty` error. + +## Cleanup + +1. Start a non-PTY command that creates a long-running child process. +2. Stop DevSpace with SIGINT and verify both shell and child exit. +3. Repeat with a PTY command. +4. Confirm no process remains after server exit. +5. Repeat session cycles and check that memory use does not steadily increase. + +## Existing Mode Regression + +1. Start without `DEVSPACE_TOOL_MODE`; confirm `minimal` remains the default. +2. Minimal must expose `read`, `write`, `edit`, and `bash`, but not Codex tools + or dedicated search tools. +3. `DEVSPACE_TOOL_MODE=full` must add `grep`, `glob`, and `ls`. +4. With no explicit mode, `DEVSPACE_MINIMAL_TOOLS=1` maps to minimal and `0` + maps to full. +5. Set `DEVSPACE_TOOL_MODE=codex` with `DEVSPACE_MINIMAL_TOOLS=0`; confirm the + explicit Codex mode wins. diff --git a/docs/configuration.md b/docs/configuration.md index 71073380..75848e19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,8 +70,18 @@ MCP clients discover metadata from: | Value | Behavior | | --- | --- | -| `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. | -| `full` | Enables dedicated `grep`, `glob`, and `ls` tools. | +| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | +| `full` | Exposes the minimal tools plus dedicated `grep`, `glob`, and `ls` tools. | +| `codex` | Experimental. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. Existing mutation and shell tools are hidden. | + +`DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when +`DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. +The `codex` mode must be selected through `DEVSPACE_TOOL_MODE`. + +Codex-mode commands run without a PTY by default. Set `tty: true` on +`exec_command` for interactive terminal programs. PTY support uses the optional +`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY +sessions. ## Widgets diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 7720ce7b..9498fcfc 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyPatch, parsePatch } from "./apply-patch.js"; @@ -41,6 +41,7 @@ assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthr assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); +await chmod(join(root, "alpha.txt"), 0o755); const moveResult = await applyPatch( root, `*** Begin Patch @@ -56,6 +57,7 @@ assert.deepEqual(moveResult.files, [ { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, ]); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); await assert.rejects( @@ -96,5 +98,21 @@ await assert.rejects( ); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); +await assert.rejects( + applyPatch( + root, + `*** Begin Patch +*** Add File: should-not-exist.txt ++staged +*** Update File: moved/alpha.txt +@@ +-missing context ++replacement +*** End Patch`, + ), + /could not find hunk context/, +); +await assert.rejects(readFile(join(root, "should-not-exist.txt"), "utf8"), /ENOENT/); + assert.throws(() => parsePatch("*** Begin Patch\n*** End Patch"), /contains no file actions/); assert.throws(() => parsePatch("*** Add File: bad.txt\n+x"), /missing .* marker/); From e447f7be931bd307eebb2df9c3421f916cbf47d0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 04:26:50 +0530 Subject: [PATCH 09/46] fix(config): keep codex tool names stable --- docs/configuration.md | 3 ++- src/server.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 75848e19..fa3a61de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,7 +76,8 @@ MCP clients discover metadata from: `DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when `DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. -The `codex` mode must be selected through `DEVSPACE_TOOL_MODE`. +The `codex` mode must be selected through `DEVSPACE_TOOL_MODE` and always uses +its fixed short tool names regardless of `DEVSPACE_TOOL_NAMING`. Codex-mode commands run without a PTY by default. Set `tty: true` on `exec_command` for interactive terminal programs. PTY support uses the optional diff --git a/src/server.ts b/src/server.ts index 96f11249..2993c284 100644 --- a/src/server.ts +++ b/src/server.ts @@ -163,7 +163,7 @@ interface ToolLogFields { } function toolNamesFor(config: ServerConfig): ToolNames { - return config.toolNaming === "short" + return config.toolNaming === "short" || config.toolMode === "codex" ? { openWorkspace: "open_workspace", read: "read", From d7cac9b3d136c6689309fa61b8d63e1a8beababa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:11:47 +0530 Subject: [PATCH 10/46] feat(ui): add codex tool card payloads --- src/apply-patch.test.ts | 4 ++ src/apply-patch.ts | 109 +++++++++++++++++++++++++++++++++++++++- src/server.ts | 19 +++++-- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 9498fcfc..bea06079 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -36,6 +36,10 @@ assert.deepEqual(result.files, [ { path: "windows.txt", operation: "update" }, { path: "remove.txt", operation: "delete" }, ]); +assert.equal(result.additions, 4); +assert.equal(result.removals, 3); +assert.match(result.patch, /diff --git a\/alpha\.txt b\/alpha\.txt/); +assert.match(result.patch, /-two\n\+changed/); assert.equal(await readFile(join(root, "nested/added.txt"), "utf8"), "new\nfile\n"); assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthree\n"); assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index c76fdbbe..b518cdb0 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -12,6 +12,9 @@ export interface AppliedPatchFile { export interface ApplyPatchResult { files: AppliedPatchFile[]; + patch: string; + additions: number; + removals: number; } interface HunkLine { @@ -226,8 +229,19 @@ async function fileExists(path: string): Promise { export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); const staged = new Map(); + const originals = new Map(); + const currentPaths = new Map(); const results: AppliedPatchFile[] = []; + const rememberOriginal = ( + absolute: string, + displayPath: string, + content: string | null, + ): void => { + if (!originals.has(absolute)) originals.set(absolute, { content, path: displayPath }); + currentPaths.set(absolute, displayPath); + }; + const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { const absolute = await resolveConfinedPath(root, displayPath); if (staged.has(absolute)) { @@ -239,6 +253,7 @@ export async function applyPatch(root: string, patch: string): Promise { + const original = originals.get(absolute); + if (!original || original.content === file?.content) return ""; + return unifiedFilePatch( + original.path, + currentPaths.get(absolute) ?? original.path, + original.content, + file?.content ?? null, + ); + }).filter(Boolean); + const unifiedPatch = patches.join("\n"); + const stats = countPatchStats(unifiedPatch); + const pendingWrites: Array<{ temporary: string; destination: string }> = []; for (const [destination, file] of staged) { if (!file) continue; @@ -299,5 +329,82 @@ export async function applyPatch(root: string, patch: string): Promise ` ${line}`), + ...oldChanged.map((line) => `-${line}`), + ...newChanged.map((line) => `+${line}`), + ...after.map((line) => ` ${line}`), + ] + .filter((line): line is string => line !== undefined) + .join("\n"); +} + +function countPatchStats(patch: string): { additions: number; removals: number } { + let additions = 0; + let removals = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + if (line.startsWith("-") && !line.startsWith("---")) removals += 1; + } + return { additions, removals }; } diff --git a/src/server.ts b/src/server.ts index 2993c284..86619323 100644 --- a/src/server.ts +++ b/src/server.ts @@ -486,13 +486,14 @@ function processToolResponse( ) { const result = processResult(snapshot); const content = [textBlock(result)]; + const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); return { content, _meta: { tool, card: { workspaceId, - summary, + summary: { ...summary, ...outputSummary }, payload: { content }, }, }, @@ -1116,6 +1117,8 @@ function createMcpServer( .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."), }, outputSchema: resultOutputSchema({ + additions: z.number(), + removals: z.number(), files: z.array( z.object({ path: z.string(), @@ -1134,6 +1137,9 @@ function createMcpServer( const paths = applied.files.map((file) => file.path).join(", "); const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; const content = [textBlock(result)]; + const displayPath = applied.files.length === 1 + ? applied.files[0]?.path + : `${applied.files.length} files`; logToolCall(config, { tool: "apply_patch", @@ -1148,12 +1154,19 @@ function createMcpServer( tool: "apply_patch", card: { workspaceId, - summary: { files: applied.files.length }, - payload: { patch }, + path: displayPath, + summary: { + files: applied.files.length, + additions: applied.additions, + removals: applied.removals, + }, + payload: { patch: applied.patch }, }, }, structuredContent: { result, + additions: applied.additions, + removals: applied.removals, files: applied.files, }, }; From fcc59a8770204ce8565738f74e6b84d99132375f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 14:12:00 +0530 Subject: [PATCH 11/46] 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 73c8b44c..ec5d4a6d 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 00000000..6a98ff47 --- /dev/null +++ b/src/ui/card-types.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { + isEditTool, + isShellTool, + isToolName, +} from "./card-types.js"; + +for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { + assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); +} + +assert.equal(isEditTool("apply_patch"), true); +assert.equal(isShellTool("exec_command"), true); +assert.equal(isShellTool("write_stdin"), true); +assert.equal(isEditTool("exec_command"), false); +assert.equal(isShellTool("apply_patch"), false); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 89ec3fe6..107f8f1d 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -10,6 +10,9 @@ export type ToolName = | "list_directory" | "run_shell" | "show_changes" + | "apply_patch" + | "exec_command" + | "write_stdin" | "read" | "write" | "edit" @@ -75,6 +78,9 @@ export function isToolName(value: unknown): value is ToolName { value === "list_directory" || value === "run_shell" || value === "show_changes" || + value === "apply_patch" || + value === "exec_command" || + value === "write_stdin" || value === "read" || value === "write" || value === "edit" || @@ -94,7 +100,7 @@ export function isWriteTool(tool: ToolName): boolean { } export function isEditTool(tool: ToolName): boolean { - return tool === "edit_file" || tool === "edit"; + return tool === "edit_file" || tool === "edit" || tool === "apply_patch"; } export function isSearchTool(tool: ToolName): boolean { @@ -102,7 +108,12 @@ export function isSearchTool(tool: ToolName): boolean { } export function isShellTool(tool: ToolName): boolean { - return tool === "run_shell" || tool === "bash"; + return ( + tool === "run_shell" || + tool === "bash" || + tool === "exec_command" || + tool === "write_stdin" + ); } export function isReviewTool(tool: ToolName): boolean { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 373b3ac2..4be351ca 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -369,7 +369,11 @@ function renderSummaryBadge(card: ToolResultCard): HTMLElement { } if (isShellTool(card.tool)) { - return element("span", { className: "badge", text: `ran · ${String(summary.lines ?? 0)} lines` }); + const state = summary.running === true ? "running" : "ran"; + return element("span", { + className: "badge", + text: `${state} · ${String(summary.lines ?? 0)} lines`, + }); } if (isSearchTool(card.tool)) { @@ -501,6 +505,8 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { case "edit_file": case "edit": return { icon: editIcon(), title: "Edit File", label, tone: "edit" }; + case "apply_patch": + return { icon: editIcon(), title: "Apply Patch", label, tone: "edit" }; case "grep_files": case "grep": return { icon: searchIcon(), title: "Grep", label, tone: "search" }; @@ -513,6 +519,10 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { case "run_shell": case "bash": return { icon: terminalIcon(), title: "Bash", label, tone: "shell" }; + case "exec_command": + return { icon: terminalIcon(), title: "Exec Command", label, tone: "shell" }; + case "write_stdin": + return { icon: terminalIcon(), title: "Process Session", label, tone: "shell" }; case "show_changes": return { icon: reviewIcon(), title: "Show Changes", label, tone: "review" }; } @@ -520,7 +530,7 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { function getToolLabel(card: ToolResultCard): string { if (isShellTool(card.tool)) { - return String(card.summary?.command ?? card.path ?? card.tool); + return String(card.summary?.command ?? card.summary?.sessionId ?? card.path ?? card.tool); } if (isReviewTool(card.tool)) { const count = Number(card.summary?.files ?? card.files?.length ?? 0); From 8d591ee253aaa5dd2b5ba90397137893cf99d2a0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 15:18:28 +0530 Subject: [PATCH 12/46] fix(exec): wait for interrupted process exit --- src/process-sessions.test.ts | 20 ++++++++++++++++++++ src/process-sessions.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 1cceb8eb..01ee788a 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -64,6 +64,26 @@ const inputResult = await manager.write({ assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); +const interruptible = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setInterval(() => console.log('tick'), 10)"`, + yieldTimeMs: 100, +}); +assert.equal(interruptible.running, true); +assert.ok(interruptible.sessionId); + +await new Promise((resolve) => setTimeout(resolve, 50)); +const interrupted = await manager.write({ + workspaceId: "workspace-a", + sessionId: interruptible.sessionId, + chars: "\u0003", + yieldTimeMs: 2_000, +}); +assert.equal(interrupted.running, false); +assert.equal(interrupted.signal, "SIGINT"); +assert.match(interrupted.output, /tick/); + const buffered = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), diff --git a/src/process-sessions.ts b/src/process-sessions.ts index b6c2c94f..236bc94f 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -164,14 +164,15 @@ export class ProcessSessionManager { session.process.resize(session.columns, session.rows); } - if (chars.includes("\u0003") && session.running) { + const interruptRequested = chars.includes("\u0003") && session.running; + if (interruptRequested) { session.process?.kill("SIGINT"); } const writableChars = chars.replaceAll("\u0003", ""); if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; - if (!hasUnreadOutput && session.running) { + if ((interruptRequested || !hasUnreadOutput) && session.running) { const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); await Promise.race([ session.exitPromise, From 76469eeb503ba14ca1525268cb41208067c7403e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:03:18 +0530 Subject: [PATCH 13/46] fix(exec): wait after process interactions --- src/process-sessions.test.ts | 20 ++++++++++++++++++++ src/process-sessions.ts | 28 +++++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 01ee788a..706608c3 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -64,6 +64,26 @@ const inputResult = await manager.write({ assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); +const noisyInteractive = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setInterval(() => console.log('tick'), 10); process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, + yieldTimeMs: 100, +}); +assert.equal(noisyInteractive.running, true); +assert.ok(noisyInteractive.sessionId); + +await new Promise((resolve) => setTimeout(resolve, 50)); +const noisyInputResult = await manager.write({ + workspaceId: "workspace-a", + sessionId: noisyInteractive.sessionId, + chars: "hello\n", + yieldTimeMs: 2_000, +}); +assert.equal(noisyInputResult.running, false); +assert.match(noisyInputResult.output, /tick/); +assert.match(noisyInputResult.output, /input:hello/); + const interruptible = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 236bc94f..f79d3193 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -141,10 +141,7 @@ export class ProcessSessionManager { } const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); - await Promise.race([ - session.exitPromise, - new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), - ]); + await this.waitForExit(session, yieldTimeMs); const snapshot = this.consume(session, input.maxOutputTokens); if (!session.running) this.removeSession(session.id); @@ -154,6 +151,8 @@ export class ProcessSessionManager { async write(input: WriteStdinInput): Promise { const session = this.getOwnedSession(input.workspaceId, input.sessionId); const chars = input.chars ?? ""; + const interactionRequested = + chars.length > 0 || input.columns !== undefined || input.rows !== undefined; if (input.columns !== undefined || input.rows !== undefined) { session.columns = terminalSize(input.columns, session.columns); @@ -172,12 +171,9 @@ export class ProcessSessionManager { if (writableChars && session.running) session.process?.write(writableChars); const hasUnreadOutput = session.consumedThrough < session.bufferStart + session.buffer.length; - if ((interruptRequested || !hasUnreadOutput) && session.running) { + if ((interactionRequested || !hasUnreadOutput) && session.running) { const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); - await Promise.race([ - session.exitPromise, - new Promise((resolve) => setTimeout(resolve, yieldTimeMs)), - ]); + await this.waitForExit(session, yieldTimeMs); } const snapshot = this.consume(session, input.maxOutputTokens); @@ -198,6 +194,20 @@ export class ProcessSessionManager { this.sessions.clear(); } + private async waitForExit(session: ProcessSession, yieldTimeMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + session.exitPromise, + new Promise((resolve) => { + timer = setTimeout(resolve, yieldTimeMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + private createSession(input: StartCommandInput): ProcessSession { let resolveExit = (): void => undefined; const exitPromise = new Promise((resolve) => { From 59d08f38f365565b0fdc62668096ab43dd6e7111 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:16:50 +0530 Subject: [PATCH 14/46] 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 040f5262..b684b0d9 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/46] 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 ec5d4a6d..48cc85e0 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 00000000..256e1253 --- /dev/null +++ b/src/process-platform.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { resolveShellCommand } from "./process-platform.js"; + +assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { + executable: "C:\\Windows\\cmd.exe", + args: ["/d", "/s", "/c", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { + executable: "/bin/zsh", + args: ["-lc", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/bin/dash" }), { + executable: "/bin/dash", + args: ["-c", "echo ok"], +}); + +assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/usr/bin/fish" }), { + executable: "/bin/sh", + args: ["-c", "echo ok"], +}); diff --git a/src/process-platform.ts b/src/process-platform.ts new file mode 100644 index 00000000..88e67753 --- /dev/null +++ b/src/process-platform.ts @@ -0,0 +1,33 @@ +import { basename } from "node:path"; + +export interface ShellCommand { + executable: string; + args: string[]; +} + +const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]); +const POSIX_SHELLS = new Set(["ash", "dash", "sh"]); + +export function resolveShellCommand( + command: string, + platform: NodeJS.Platform = process.platform, + environment: NodeJS.ProcessEnv = process.env, +): ShellCommand { + if (platform === "win32") { + return { + executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", + args: ["/d", "/s", "/c", command], + }; + } + + const configuredShell = environment.SHELL; + const shellName = configuredShell ? basename(configuredShell) : ""; + if (configuredShell && LOGIN_SHELLS.has(shellName)) { + return { executable: configuredShell, args: ["-lc", command] }; + } + if (configuredShell && POSIX_SHELLS.has(shellName)) { + return { executable: configuredShell, args: ["-c", command] }; + } + + return { executable: "/bin/sh", args: ["-c", command] }; +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f79d3193..e0d18f8a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; +import { resolveShellCommand } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; @@ -84,20 +85,6 @@ function terminalSize(value: number | undefined, fallback: number): number { return value; } -function shellCommand(command: string): { executable: string; args: string[] } { - if (process.platform === "win32") { - return { - executable: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", command], - }; - } - - return { - executable: process.env.SHELL ?? "/bin/bash", - args: ["-lc", command], - }; -} - function processEnvironment(): Record { return Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -230,7 +217,7 @@ export class ProcessSessionManager { } private startPipe(session: ProcessSession, input: StartCommandInput): void { - const shell = shellCommand(input.command); + const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; const child = spawn(shell.executable, shell.args, { cwd: input.cwd, @@ -268,7 +255,7 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - const shell = shellCommand(input.command); + const shell = resolveShellCommand(input.command); const pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, env: processEnvironment(), From 7efd7e8b5893bd27519a860a303b90036f560451 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:56:43 +0530 Subject: [PATCH 16/46] fix(exec): terminate process trees on Windows --- src/process-platform.test.ts | 41 ++++++++++++++++++++++++++++++++- src/process-platform.ts | 44 ++++++++++++++++++++++++++++++++++++ src/process-sessions.ts | 14 ++---------- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 256e1253..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { resolveShellCommand } from "./process-platform.js"; +import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", @@ -20,3 +20,42 @@ assert.deepEqual(resolveShellCommand("echo ok", "linux", { SHELL: "/usr/bin/fish executable: "/bin/sh", args: ["-c", "echo ok"], }); + +const windowsCalls: string[] = []; +terminateProcessTree( + { pid: 42, kill: (signal) => (windowsCalls.push(`child:${signal}`), true) }, + "SIGTERM", + false, + { + platform: "win32", + killGroup: () => undefined, + killWindowsTree: (pid) => (windowsCalls.push(`tree:${pid}`), true), + }, +); +assert.deepEqual(windowsCalls, ["tree:42"]); + +const posixCalls: string[] = []; +terminateProcessTree( + { pid: 43, kill: (signal) => (posixCalls.push(`child:${signal}`), true) }, + "SIGINT", + true, + { + platform: "darwin", + killGroup: (pid, signal) => posixCalls.push(`group:${pid}:${signal}`), + killWindowsTree: () => false, + }, +); +assert.deepEqual(posixCalls, ["group:43:SIGINT"]); + +const fallbackCalls: string[] = []; +terminateProcessTree( + { pid: 44, kill: (signal) => (fallbackCalls.push(`child:${signal}`), true) }, + "SIGTERM", + false, + { + platform: "linux", + killGroup: () => undefined, + killWindowsTree: () => false, + }, +); +assert.deepEqual(fallbackCalls, ["child:SIGTERM"]); diff --git a/src/process-platform.ts b/src/process-platform.ts index 88e67753..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -1,10 +1,34 @@ import { basename } from "node:path"; +import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; args: string[]; } +export interface KillableProcess { + pid?: number; + kill(signal?: NodeJS.Signals): boolean; +} + +interface ProcessTreeRuntime { + platform: NodeJS.Platform; + killGroup(pid: number, signal: NodeJS.Signals): void; + killWindowsTree(pid: number): boolean; +} + +const defaultProcessTreeRuntime: ProcessTreeRuntime = { + platform: process.platform, + killGroup: (pid, signal) => process.kill(-pid, signal), + killWindowsTree: (pid) => { + const result = spawnSync("taskkill.exe", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return !result.error && result.status === 0; + }, +}; + const LOGIN_SHELLS = new Set(["bash", "ksh", "zsh"]); const POSIX_SHELLS = new Set(["ash", "dash", "sh"]); @@ -31,3 +55,23 @@ export function resolveShellCommand( return { executable: "/bin/sh", args: ["-c", command] }; } + +export function terminateProcessTree( + child: KillableProcess, + signal: NodeJS.Signals, + detached: boolean, + runtime: ProcessTreeRuntime = defaultProcessTreeRuntime, +): void { + if (runtime.platform === "win32" && child.pid) { + if (runtime.killWindowsTree(child.pid)) return; + } else if (detached && child.pid) { + try { + runtime.killGroup(child.pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + } + } + + child.kill(signal); +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts index e0d18f8a..18c974c7 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { resolveShellCommand } from "./process-platform.js"; +import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; const DEFAULT_MAX_OUTPUT_TOKENS = 10_000; @@ -229,17 +229,7 @@ export class ProcessSessionManager { session.process = { write: (data) => child.stdin.write(data), - kill: (signal) => { - if (detached && child.pid) { - try { - process.kill(-child.pid, signal); - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return; - } - } - child.kill(signal); - }, + kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached), }; child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); From bfe41b7455cfabcca6aeba2fb1ecccab97e782e1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:58:12 +0530 Subject: [PATCH 17/46] fix(patch): replace existing files on Windows --- src/apply-patch.test.ts | 8 +++++++- src/apply-patch.ts | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index bea06079..c9bea765 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -2,9 +2,15 @@ import assert from "node:assert/strict"; import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyPatch, parsePatch } from "./apply-patch.js"; +import { applyPatch, parsePatch, replaceFile } from "./apply-patch.js"; const root = await mkdtemp(join(tmpdir(), "devspace-apply-patch-")); +const replacement = join(root, "replacement.txt"); +const replacementTemporary = join(root, "replacement.tmp"); +await writeFile(replacement, "old\n"); +await writeFile(replacementTemporary, "new\n"); +await replaceFile(replacementTemporary, replacement, true, "win32"); +assert.equal(await readFile(replacement, "utf8"), "new\n"); await writeFile(join(root, "alpha.txt"), "one\ntwo\nthree\n"); await writeFile(join(root, "remove.txt"), "remove me\n"); await writeFile(join(root, "windows.txt"), "first\r\nsecond\r\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index b518cdb0..43c3962d 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -226,6 +226,28 @@ async function fileExists(path: string): Promise { } } +export async function replaceFile( + temporary: string, + destination: string, + destinationExists: boolean, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "win32" || !destinationExists) { + await rename(temporary, destination); + return; + } + + const backup = `${temporary}.original`; + await rename(destination, backup); + try { + await rename(temporary, destination); + } catch (error) { + await rename(backup, destination); + throw error; + } + await rm(backup, { force: true }); +} + export async function applyPatch(root: string, patch: string): Promise { const actions = parsePatch(patch); const staged = new Map(); @@ -310,17 +332,27 @@ export async function applyPatch(root: string, patch: string): Promise = []; + const pendingWrites: Array<{ + temporary: string; + destination: string; + destinationExists: boolean; + }> = []; for (const [destination, file] of staged) { if (!file) continue; await mkdir(dirname(destination), { recursive: true }); const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); - pendingWrites.push({ temporary, destination }); + pendingWrites.push({ + temporary, + destination, + destinationExists: originals.get(destination)?.content !== null, + }); } try { - for (const write of pendingWrites) await rename(write.temporary, write.destination); + for (const write of pendingWrites) { + await replaceFile(write.temporary, write.destination, write.destinationExists); + } for (const [path, file] of staged) { if (!file) await rm(path, { force: true }); } From 9437059e005e5ba1bdbdcc75239afc741d74753c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 17:59:01 +0530 Subject: [PATCH 18/46] test(tools): account for cross-platform semantics --- src/apply-patch.test.ts | 8 +++++--- src/process-sessions.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index c9bea765..d69901e6 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -51,7 +51,7 @@ assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthr assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdated\r\n"); await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); -await chmod(join(root, "alpha.txt"), 0o755); +if (process.platform !== "win32") await chmod(join(root, "alpha.txt"), 0o755); const moveResult = await applyPatch( root, `*** Begin Patch @@ -67,7 +67,9 @@ assert.deepEqual(moveResult.files, [ { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, ]); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); -assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); +if (process.platform !== "win32") { + assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); +} await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); await assert.rejects( @@ -82,7 +84,7 @@ await assert.rejects( ); const outside = await mkdtemp(join(tmpdir(), "devspace-apply-patch-outside-")); -await symlink(outside, join(root, "outside-link")); +await symlink(outside, join(root, "outside-link"), process.platform === "win32" ? "junction" : "dir"); await assert.rejects( applyPatch( root, diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 706608c3..94f5adcb 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -101,7 +101,7 @@ const interrupted = await manager.write({ yieldTimeMs: 2_000, }); assert.equal(interrupted.running, false); -assert.equal(interrupted.signal, "SIGINT"); +if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); assert.match(interrupted.output, /tick/); const buffered = await manager.start({ From a0193d6444e4d92c9a07cc9de94f2ef1483d69b7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:04:05 +0530 Subject: [PATCH 19/46] fix(exec): quote Windows commands consistently --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- src/process-sessions.test.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 39b494d8..caaaefb8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", "echo ok"], + args: ["/d", "/s", "/c", '"echo ok"'], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d73..ba99a4b9 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", command], + args: ["/d", "/s", "/c", `"${command}"`], }; } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 94f5adcb..0148f972 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -102,15 +102,23 @@ const interrupted = await manager.write({ }); assert.equal(interrupted.running, false); if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); -assert.match(interrupted.output, /tick/); +assert.match(interruptible.output + interrupted.output, /tick/); -const buffered = await manager.start({ +let buffered = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "console.log('x'.repeat(5000)); setTimeout(() => {}, 100)"`, yieldTimeMs: 50, maxOutputTokens: 100, }); +if (!buffered.outputTruncated && buffered.sessionId) { + buffered = await manager.write({ + workspaceId: "workspace-a", + sessionId: buffered.sessionId, + yieldTimeMs: 2_000, + maxOutputTokens: 100, + }); +} assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); From b00718a42ff0febdf4ee3d847e29487c3c19acdc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:08:47 +0530 Subject: [PATCH 20/46] 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 b684b0d9..0ff8f442 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 48cc85e0..b926fc4f 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/46] test(exec): remove timing-only output assertion --- src/process-sessions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 0148f972..ee115564 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -81,7 +81,6 @@ const noisyInputResult = await manager.write({ yieldTimeMs: 2_000, }); assert.equal(noisyInputResult.running, false); -assert.match(noisyInputResult.output, /tick/); assert.match(noisyInputResult.output, /input:hello/); const interruptible = await manager.start({ From 9a3c61664cb68029b2b90c9637d6aaeb3ee8fb88 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:11:42 +0530 Subject: [PATCH 22/46] test(exec): decouple interrupt from output timing --- src/process-sessions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index ee115564..7183f634 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -101,7 +101,6 @@ const interrupted = await manager.write({ }); assert.equal(interrupted.running, false); if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); -assert.match(interruptible.output + interrupted.output, /tick/); let buffered = await manager.start({ workspaceId: "workspace-a", From fcdb03f30947b2a1ab4e7cc0d98f74e64ba9420b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:13:18 +0530 Subject: [PATCH 23/46] fix(exec): delegate pipe shell quoting to Node --- src/process-sessions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 18c974c7..43cd8c77 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -219,12 +219,13 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; - const child = spawn(shell.executable, shell.args, { + const child = spawn(input.command, { cwd: input.cwd, env: process.env, stdio: "pipe", windowsHide: true, detached, + shell: shell.executable, }); session.process = { From a0361cd90b7f7cb4eab8bcada661e8c38e92bb17 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:16:35 +0530 Subject: [PATCH 24/46] fix(exec): pass raw commands to Windows PTYs --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index caaaefb8..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", '"echo ok"'], + args: ["/d", "/s", "/c", "echo ok"], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index ba99a4b9..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", `"${command}"`], + args: ["/d", "/s", "/c", command], }; } From 5950b7154bd1c0c82dba056da9ecdbf4540a9e77 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:19:16 +0530 Subject: [PATCH 25/46] test(exec): quote Windows executable paths natively --- src/process-sessions.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 7183f634..4e185c0d 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -6,7 +6,9 @@ const manager = new ProcessSessionManager({ completedSessionTtlMs: 1_000, }); -const node = JSON.stringify(process.execPath); +const node = process.platform === "win32" + ? `"${process.execPath}"` + : JSON.stringify(process.execPath); const foreground = await manager.start({ workspaceId: "workspace-a", From 03198bf8596bca43e87a28c8d6f92189a02064d6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:22:43 +0530 Subject: [PATCH 26/46] fix(exec): preserve Windows PTY command lines --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 39b494d8..24a214d7 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: ["/d", "/s", "/c", "echo ok"], + args: '/d /s /c "echo ok"', }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d73..69ca9506 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; - args: string[]; + args: string[] | string; } export interface KillableProcess { @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: ["/d", "/s", "/c", command], + args: `/d /s /c "${command}"`, }; } From e76ef808b968da2681d9979f885052a0b3c97f7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:26:09 +0530 Subject: [PATCH 27/46] test(exec): clean up PTYs after assertions --- src/process-sessions.test.ts | 48 +++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 4e185c0d..f6360940 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -122,27 +122,29 @@ if (!buffered.outputTruncated && buffered.sessionId) { assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); -const pty = await manager.start({ - workspaceId: "workspace-a", - cwd: process.cwd(), - command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, - tty: true, - columns: 80, - rows: 24, - yieldTimeMs: 10, -}); -assert.equal(pty.running, true); -assert.ok(pty.sessionId); - -const resizedPty = await manager.write({ - workspaceId: "workspace-a", - sessionId: pty.sessionId, - chars: "continue\r", - columns: 120, - rows: 30, - yieldTimeMs: 2_000, -}); -assert.equal(resizedPty.running, false); -assert.match(resizedPty.output, /columns:120/); +try { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, + }); + assert.equal(pty.running, true); + assert.ok(pty.sessionId); -manager.shutdown(); + const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + chars: "continue\r", + columns: 120, + rows: 30, + yieldTimeMs: 2_000, + }); + assert.equal(resizedPty.running, false); + assert.match(resizedPty.output, /columns:120/); +} finally { + manager.shutdown(); +} From a2cefeb9854d05ac70e3730eacf22dadbcb60740 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:28:53 +0530 Subject: [PATCH 28/46] test(exec): avoid PTY line discipline assumptions --- src/process-sessions.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index f6360940..59cee81b 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -126,7 +126,7 @@ try { const pty = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), - command: `${node} -e "process.stdin.once('data', () => { console.log('columns:' + process.stdout.columns); process.exit(0); })"`, + command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, tty: true, columns: 80, rows: 24, @@ -138,7 +138,6 @@ try { const resizedPty = await manager.write({ workspaceId: "workspace-a", sessionId: pty.sessionId, - chars: "continue\r", columns: 120, rows: 30, yieldTimeMs: 2_000, From 584e7f049b0ef7d4ccdbaa48cf1c3c4e862b1d84 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:31:15 +0530 Subject: [PATCH 29/46] test(exec): use native Windows PTY smoke command --- src/process-sessions.test.ts | 52 ++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index 59cee81b..d7f0c6d6 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -123,27 +123,39 @@ assert.equal(buffered.outputTruncated, true); if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); try { - const pty = await manager.start({ - workspaceId: "workspace-a", - cwd: process.cwd(), - command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, - tty: true, - columns: 80, - rows: 24, - yieldTimeMs: 10, - }); - assert.equal(pty.running, true); - assert.ok(pty.sessionId); + if (process.platform === "win32") { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: "echo pty-ok", + tty: true, + yieldTimeMs: 2_000, + }); + assert.equal(pty.running, false); + assert.match(pty.output, /pty-ok/); + } else { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: `${node} -e "setTimeout(() => console.log('columns:' + process.stdout.columns), 250)"`, + tty: true, + columns: 80, + rows: 24, + yieldTimeMs: 10, + }); + assert.equal(pty.running, true); + assert.ok(pty.sessionId); - const resizedPty = await manager.write({ - workspaceId: "workspace-a", - sessionId: pty.sessionId, - columns: 120, - rows: 30, - yieldTimeMs: 2_000, - }); - assert.equal(resizedPty.running, false); - assert.match(resizedPty.output, /columns:120/); + const resizedPty = await manager.write({ + workspaceId: "workspace-a", + sessionId: pty.sessionId, + columns: 120, + rows: 30, + yieldTimeMs: 2_000, + }); + assert.equal(resizedPty.running, false); + assert.match(resizedPty.output, /columns:120/); + } } finally { manager.shutdown(); } From ce9522c35babad81bf6aa98b9886e2eed6dfab27 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:33:22 +0530 Subject: [PATCH 30/46] fix(exec): pass raw Windows PTY commands --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index 24a214d7..cd96d3f4 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: '/d /s /c "echo ok"', + args: "/d /s /c echo ok", }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 69ca9506..80d80f18 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: `/d /s /c "${command}"`, + args: `/d /s /c ${command}`, }; } From 8db6800562124845b2e19e421306a68cabe8b1e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:39:04 +0530 Subject: [PATCH 31/46] 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 0ff8f442..8af04a05 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 b926fc4f..4e1ee480 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/46] fix(exec): run Windows PTYs through temp scripts --- src/process-platform.test.ts | 2 +- src/process-platform.ts | 4 ++-- src/process-sessions.ts | 37 ++++++++++++++++++++++++++++-------- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/process-platform.test.ts b/src/process-platform.test.ts index cd96d3f4..39b494d8 100644 --- a/src/process-platform.test.ts +++ b/src/process-platform.test.ts @@ -3,7 +3,7 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js assert.deepEqual(resolveShellCommand("echo ok", "win32", { ComSpec: "C:\\Windows\\cmd.exe" }), { executable: "C:\\Windows\\cmd.exe", - args: "/d /s /c echo ok", + args: ["/d", "/s", "/c", "echo ok"], }); assert.deepEqual(resolveShellCommand("echo ok", "darwin", { SHELL: "/bin/zsh" }), { diff --git a/src/process-platform.ts b/src/process-platform.ts index 80d80f18..905d4d73 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; export interface ShellCommand { executable: string; - args: string[] | string; + args: string[]; } export interface KillableProcess { @@ -40,7 +40,7 @@ export function resolveShellCommand( if (platform === "win32") { return { executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe", - args: `/d /s /c ${command}`, + args: ["/d", "/s", "/c", command], }; } diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 43cd8c77..2895ba5d 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,5 +1,8 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; @@ -246,14 +249,31 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - const shell = resolveShellCommand(input.command); - const pty = nodePty.spawn(shell.executable, shell.args, { - cwd: input.cwd, - env: processEnvironment(), - name: "xterm-256color", - cols: session.columns, - rows: session.rows, - }); + let scriptDirectory: string | undefined; + let command = input.command; + if (process.platform === "win32") { + scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); + command = join(scriptDirectory, "command.cmd"); + await writeFile(command, `@echo off\r\n${input.command}\r\n`, "utf8"); + } + + const cleanupScript = (): void => { + if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); + }; + const shell = resolveShellCommand(command); + let pty: import("node-pty").IPty; + try { + pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment(), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); + } catch (error) { + cleanupScript(); + throw error; + } session.process = { write: (data) => pty.write(data), @@ -262,6 +282,7 @@ export class ProcessSessionManager { }; pty.onData((data) => this.append(session, data)); pty.onExit(({ exitCode, signal }) => { + cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); } From ed792a13f3989e1e5400df78743f36f5c1b8d860 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:48:05 +0530 Subject: [PATCH 33/46] fix(exec): guard Windows PTY listener setup --- src/process-sessions.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 2895ba5d..aa585068 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -250,11 +250,18 @@ export class ProcessSessionManager { } let scriptDirectory: string | undefined; + let readyPath: string | undefined; let command = input.command; if (process.platform === "win32") { scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); + readyPath = join(scriptDirectory, "ready"); command = join(scriptDirectory, "command.cmd"); - await writeFile(command, `@echo off\r\n${input.command}\r\n`, "utf8"); + const batchReadyPath = readyPath.replaceAll("%", "%%"); + await writeFile( + command, + `@echo off\r\n:wait\r\nif not exist "${batchReadyPath}" goto wait\r\n${input.command}\r\n`, + "utf8", + ); } const cleanupScript = (): void => { @@ -285,6 +292,15 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); + if (readyPath) { + try { + await writeFile(readyPath, "", "utf8"); + } catch (error) { + pty.kill(); + cleanupScript(); + throw error; + } + } } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 6d02dfc0d03947595b7a90fdb3b0a22f825a03de Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:52:56 +0530 Subject: [PATCH 34/46] 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 8af04a05..35da64f9 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 4e1ee480..65a3de08 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 00000000..1990bf44 --- /dev/null +++ b/scripts/fix-node-pty-permissions.mjs @@ -0,0 +1,22 @@ +import { chmod } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.platform === "darwin") { + const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + for (const architecture of ["arm64", "x64"]) { + const helper = resolve( + projectRoot, + "node_modules", + "node-pty", + "prebuilds", + `darwin-${architecture}`, + "spawn-helper", + ); + try { + await chmod(helper, 0o755); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } +} From 1447588276593238efaa83b0f4200414d4229fd8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 18:57:00 +0530 Subject: [PATCH 35/46] fix(exec): delay Windows PTY command startup --- src/process-sessions.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index aa585068..391825aa 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -250,16 +250,13 @@ export class ProcessSessionManager { } let scriptDirectory: string | undefined; - let readyPath: string | undefined; let command = input.command; if (process.platform === "win32") { scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); - readyPath = join(scriptDirectory, "ready"); command = join(scriptDirectory, "command.cmd"); - const batchReadyPath = readyPath.replaceAll("%", "%%"); await writeFile( command, - `@echo off\r\n:wait\r\nif not exist "${batchReadyPath}" goto wait\r\n${input.command}\r\n`, + `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n`, "utf8", ); } @@ -292,15 +289,6 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); - if (readyPath) { - try { - await writeFile(readyPath, "", "utf8"); - } catch (error) { - pty.kill(); - cleanupScript(); - throw error; - } - } } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 043547366e9b0242a517b491a7adfdc72eee6914 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:00:08 +0530 Subject: [PATCH 36/46] fix(exec): omit PTY signals on Windows --- src/process-sessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 391825aa..36427b0a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -281,7 +281,7 @@ export class ProcessSessionManager { session.process = { write: (data) => pty.write(data), - kill: (signal) => pty.kill(signal), + kill: (signal) => process.platform === "win32" ? pty.kill() : pty.kill(signal), resize: (columns, rows) => pty.resize(columns, rows), }; pty.onData((data) => this.append(session, data)); From fee90beec3d4e1d4a3a298940f522b45764d7ed5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:03:56 +0530 Subject: [PATCH 37/46] test(exec): allow hosted Windows PTY startup --- src/process-sessions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index d7f0c6d6..06097825 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -129,7 +129,7 @@ try { cwd: process.cwd(), command: "echo pty-ok", tty: true, - yieldTimeMs: 2_000, + yieldTimeMs: 10_000, }); assert.equal(pty.running, false); assert.match(pty.output, /pty-ok/); From 905750034f8b4e73c87f295a123b42dc3fbfb1db Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:08:45 +0530 Subject: [PATCH 38/46] fix(exec): exit Windows PTY scripts explicitly --- src/process-sessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 36427b0a..90bfb0de 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -256,7 +256,7 @@ export class ProcessSessionManager { command = join(scriptDirectory, "command.cmd"); await writeFile( command, - `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n`, + `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n@exit /b %errorlevel%\r\n`, "utf8", ); } From 19116dafc2f2c10a58015cb6d48eed711fb3d75b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:12:20 +0530 Subject: [PATCH 39/46] 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 35da64f9..ab3b8277 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 65a3de08..9ad5d21f 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/46] fix(exec): use native Windows PTY command lines --- src/process-sessions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 90bfb0de..0b8d7587 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -265,9 +265,10 @@ export class ProcessSessionManager { if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); }; const shell = resolveShellCommand(command); + const shellArgs = process.platform === "win32" ? `/d /c "${command}"` : shell.args; let pty: import("node-pty").IPty; try { - pty = nodePty.spawn(shell.executable, shell.args, { + pty = nodePty.spawn(shell.executable, shellArgs, { cwd: input.cwd, env: processEnvironment(), name: "xterm-256color", From 20429b4cb8e11f33d5653ae1ced68f7909f85e61 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:21:26 +0530 Subject: [PATCH 41/46] fix(exec): start Windows PTYs after listeners --- src/process-sessions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 0b8d7587..736890e8 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -256,7 +256,7 @@ export class ProcessSessionManager { command = join(scriptDirectory, "command.cmd"); await writeFile( command, - `@ping 127.0.0.1 -n 2 > nul\r\n@echo off\r\n${input.command}\r\n@exit /b %errorlevel%\r\n`, + `@echo off\r\n${input.command}\r\n@exit %errorlevel%\r\n`, "utf8", ); } @@ -265,7 +265,7 @@ export class ProcessSessionManager { if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); }; const shell = resolveShellCommand(command); - const shellArgs = process.platform === "win32" ? `/d /c "${command}"` : shell.args; + const shellArgs = process.platform === "win32" ? [] : shell.args; let pty: import("node-pty").IPty; try { pty = nodePty.spawn(shell.executable, shellArgs, { @@ -290,6 +290,7 @@ export class ProcessSessionManager { cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); + if (process.platform === "win32") pty.write(`"${command}"\r\n`); } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 2324f2a07612b86b60c9a30d6f3999b46f34eda1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 42/46] fix(exec): fall back from Windows native PTYs --- src/process-sessions.ts | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 736890e8..c6a0c89a 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,8 +1,5 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; const DEFAULT_YIELD_MS = 10_000; @@ -123,7 +120,7 @@ export class ProcessSessionManager { this.sessions.set(session.id, session); try { - if (input.tty) await this.startPty(session, input); + if (input.tty && process.platform !== "win32") await this.startPty(session, input); else this.startPipe(session, input); } catch (error) { this.sessions.delete(session.id); @@ -234,6 +231,7 @@ export class ProcessSessionManager { session.process = { write: (data) => child.stdin.write(data), kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached), + resize: input.tty ? () => undefined : undefined, }; child.stdout.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); child.stderr.on("data", (data: Buffer) => this.append(session, data.toString("utf8"))); @@ -249,26 +247,10 @@ export class ProcessSessionManager { throw new Error("PTY support requires the optional node-pty dependency."); } - let scriptDirectory: string | undefined; - let command = input.command; - if (process.platform === "win32") { - scriptDirectory = await mkdtemp(join(tmpdir(), "devspace-pty-")); - command = join(scriptDirectory, "command.cmd"); - await writeFile( - command, - `@echo off\r\n${input.command}\r\n@exit %errorlevel%\r\n`, - "utf8", - ); - } - - const cleanupScript = (): void => { - if (scriptDirectory) void rm(scriptDirectory, { recursive: true, force: true }); - }; - const shell = resolveShellCommand(command); - const shellArgs = process.platform === "win32" ? [] : shell.args; + const shell = resolveShellCommand(input.command); let pty: import("node-pty").IPty; try { - pty = nodePty.spawn(shell.executable, shellArgs, { + pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, env: processEnvironment(), name: "xterm-256color", @@ -276,21 +258,18 @@ export class ProcessSessionManager { rows: session.rows, }); } catch (error) { - cleanupScript(); throw error; } session.process = { write: (data) => pty.write(data), - kill: (signal) => process.platform === "win32" ? pty.kill() : pty.kill(signal), + kill: (signal) => pty.kill(signal), resize: (columns, rows) => pty.resize(columns, rows), }; pty.onData((data) => this.append(session, data)); pty.onExit(({ exitCode, signal }) => { - cleanupScript(); this.finish(session, exitCode, signal === 0 ? undefined : String(signal)); }); - if (process.platform === "win32") pty.write(`"${command}"\r\n`); } private finish(session: ProcessSession, exitCode?: number, signal?: string): void { From 7fa956cf5ea97fb3a60eb0ddccff54f5a1f89a7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 22 Jun 2026 19:27:31 +0530 Subject: [PATCH 43/46] 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 ab3b8277..bba78ab4 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 9ad5d21f..65a3de08 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" } } From 3d9061805eba23c26d02461e7f82cc3f4ab1d6ce Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 23 Jun 2026 23:58:40 +0530 Subject: [PATCH 44/46] feat(goals): add workspace goal persistence --- package.json | 2 +- src/config.test.ts | 6 ++ src/config.ts | 8 ++- src/db/migrations.ts | 28 ++++++++ src/db/schema.ts | 20 ++++++ src/goal-store.test.ts | 75 ++++++++++++++++++++++ src/goal-store.ts | 137 ++++++++++++++++++++++++++++++++++++++++ src/oauth-store.test.ts | 1 + src/user-config.ts | 1 + 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/goal-store.test.ts create mode 100644 src/goal-store.ts diff --git a/package.json b/package.json index 65a3de08..9b776220 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/goal-store.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/config.test.ts b/src/config.test.ts index dc23aa8a..b3db4d6f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -24,6 +24,10 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "f 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).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, true); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex", DEVSPACE_GOALS: "0" }).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); @@ -150,6 +154,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + goalsEnabled: true, }), ); writeFileSync( @@ -161,6 +166,7 @@ writeFileSync( const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); +assert.equal(fileConfig.goalsEnabled, true); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.deepEqual(fileConfig.allowedHosts, [ diff --git a/src/config.ts b/src/config.ts index 5bf96f87..8e2662b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface ServerConfig { widgets: WidgetMode; stateDir: string; worktreeRoot: string; + goalsEnabled: boolean; skillsEnabled: boolean; skillPaths: string[]; agentDir: string; @@ -210,6 +211,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); const host = env.HOST ?? files.config.host ?? "127.0.0.1"; const port = parsePort(env.PORT ?? files.config.port); + const toolMode = parseToolMode(env); const publicBaseUrl = parsePublicBaseUrl( env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); @@ -229,11 +231,15 @@ 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, - toolMode: parseToolMode(env), + toolMode, toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + goalsEnabled: + env.DEVSPACE_GOALS === undefined + ? (files.config.goalsEnabled ?? toolMode === "codex") + : parseBoolean(env.DEVSPACE_GOALS), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1ce1e1c7..2af05357 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -17,6 +17,11 @@ const migrations: Migration[] = [ name: "oauth-state", up: migrateOAuthState, }, + { + version: 3, + name: "workspace-goals", + up: migrateWorkspaceGoals, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -138,6 +143,29 @@ function migrateOAuthState(sqlite: Database.Database): void { `); } +function migrateWorkspaceGoals(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_goals ( + workspace_session_id text primary key, + goal_id text not null, + objective text not null, + status text not null check(status in ('active', 'blocked', 'complete')), + created_at text not null, + updated_at text not null, + completed_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_goals_goal_id_idx + on workspace_goals(goal_id); + + create index if not exists workspace_goals_status_idx + on workspace_goals(status, updated_at desc); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862b..3e53263a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,25 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceGoals = sqliteTable( + "workspace_goals", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + goalId: text("goal_id").notNull(), + objective: text("objective").notNull(), + status: text("status", { enum: ["active", "blocked", "complete"] }).notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + completedAt: text("completed_at"), + }, + (table) => [ + index("workspace_goals_goal_id_idx").on(table.goalId), + index("workspace_goals_status_idx").on(table.status, table.updatedAt), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -77,3 +96,4 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspaceGoalRow = typeof workspaceGoals.$inferSelect; diff --git a/src/goal-store.test.ts b/src/goal-store.test.ts new file mode 100644 index 00000000..8c458add --- /dev/null +++ b/src/goal-store.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SqliteGoalStore, validateObjective } from "./goal-store.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-goal-store-test-")); + +try { + testObjectiveValidation(); + testGoalLifecycle(join(root, "lifecycle")); + testGoalPersistence(join(root, "persistence")); +} finally { + await rm(root, { recursive: true, force: true }); +} + +function testObjectiveValidation(): void { + assert.equal(validateObjective(" ship goals "), "ship goals"); + assert.throws(() => validateObjective(" "), /Goal objective must not be empty/); + assert.throws(() => validateObjective("x".repeat(4001)), /must not exceed 4000/); +} + +function testGoalLifecycle(stateDir: string): void { + const workspaceStore = new SqliteWorkspaceStore(stateDir); + const goalStore = new SqliteGoalStore(stateDir); + try { + const workspace = workspaceStore.createSession({ id: "ws_1", root: process.cwd() }); + assert.equal(goalStore.getGoal(workspace.id), undefined); + + const goal = goalStore.createGoal({ workspaceId: workspace.id, objective: " implement workspace goals " }); + assert.equal(goal.workspaceId, workspace.id); + assert.equal(goal.objective, "implement workspace goals"); + assert.equal(goal.status, "active"); + assert.ok(goal.goalId); + + assert.throws( + () => goalStore.createGoal({ workspaceId: workspace.id, objective: "replace too early" }), + /unfinished goal already exists/, + ); + + const blocked = goalStore.updateGoal({ workspaceId: workspace.id, status: "blocked" }); + assert.equal(blocked?.status, "blocked"); + assert.equal(blocked?.completedAt, undefined); + + const complete = goalStore.updateGoal({ workspaceId: workspace.id, status: "complete" }); + assert.equal(complete?.status, "complete"); + assert.ok(complete?.completedAt); + + const replacement = goalStore.createGoal({ workspaceId: workspace.id, objective: "next goal" }); + assert.equal(replacement.status, "active"); + assert.equal(replacement.objective, "next goal"); + assert.notEqual(replacement.goalId, goal.goalId); + } finally { + goalStore.close(); + workspaceStore.close(); + } +} + +function testGoalPersistence(stateDir: string): void { + const workspaceStore = new SqliteWorkspaceStore(stateDir); + const firstGoalStore = new SqliteGoalStore(stateDir); + const workspace = workspaceStore.createSession({ id: "ws_2", root: process.cwd() }); + const created = firstGoalStore.createGoal({ workspaceId: workspace.id, objective: "persist me" }); + firstGoalStore.close(); + workspaceStore.close(); + + const secondGoalStore = new SqliteGoalStore(stateDir); + try { + assert.deepEqual(secondGoalStore.getGoal(workspace.id), created); + assert.equal(secondGoalStore.updateGoal({ workspaceId: "missing", status: "complete" }), undefined); + } finally { + secondGoalStore.close(); + } +} diff --git a/src/goal-store.ts b/src/goal-store.ts new file mode 100644 index 00000000..e718daba --- /dev/null +++ b/src/goal-store.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import { workspaceGoals, type WorkspaceGoalRow } from "./db/schema.js"; + +export type GoalStatus = "active" | "blocked" | "complete"; +export type ModelSettableGoalStatus = "blocked" | "complete"; + +export interface WorkspaceGoal { + workspaceId: string; + goalId: string; + objective: string; + status: GoalStatus; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +export interface GoalStore { + getGoal(workspaceId: string): WorkspaceGoal | undefined; + createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal; + updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined; + close?(): void; +} + +export class SqliteGoalStore implements GoalStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + getGoal(workspaceId: string): WorkspaceGoal | undefined { + const row = this.database.db + .select() + .from(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceId)) + .get(); + + return row ? rowToWorkspaceGoal(row) : undefined; + } + + createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal { + const objective = validateObjective(input.objective); + const existing = this.getGoal(input.workspaceId); + if (existing && existing.status !== "complete") { + throw new Error("An unfinished goal already exists for this workspace. Use update_goal to change its status."); + } + + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + workspaceId: input.workspaceId, + goalId: randomUUID(), + objective, + status: "active", + createdAt: now, + updatedAt: now, + }; + + this.database.db + .insert(workspaceGoals) + .values({ + workspaceSessionId: goal.workspaceId, + goalId: goal.goalId, + objective: goal.objective, + status: goal.status, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + completedAt: null, + }) + .onConflictDoUpdate({ + target: workspaceGoals.workspaceSessionId, + set: { + goalId: goal.goalId, + objective: goal.objective, + status: goal.status, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + completedAt: null, + }, + }) + .run(); + + return goal; + } + + updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined { + const existing = this.getGoal(input.workspaceId); + if (!existing) return undefined; + if (existing.status === "complete") return existing; + + const now = new Date().toISOString(); + this.database.db + .update(workspaceGoals) + .set({ + status: input.status, + updatedAt: now, + completedAt: input.status === "complete" ? now : null, + }) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceId)) + .run(); + + return this.getGoal(input.workspaceId); + } + + close(): void { + this.database.close(); + } +} + +export function createGoalStore(stateDir: string): GoalStore { + return new SqliteGoalStore(stateDir); +} + +export function validateObjective(value: string): string { + const objective = value.trim(); + if (!objective) { + throw new Error("Goal objective must not be empty."); + } + if (objective.length > 4000) { + throw new Error("Goal objective must not exceed 4000 characters."); + } + return objective; +} + +function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { + const goal: WorkspaceGoal = { + workspaceId: row.workspaceSessionId, + goalId: row.goalId, + objective: row.objective, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.completedAt) goal.completedAt = row.completedAt; + return goal; +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873c..c15e701f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -43,6 +43,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { assert.deepEqual(migrations, [ { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, + { version: 3, name: "workspace-goals" }, ]); } finally { database.close(); diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c519..522c7a86 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -18,6 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + goalsEnabled?: boolean; } export interface DevspaceAuthConfig { From a83a016e46b74e5e7ca03d156b213c7dd8c5c671 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 24 Jun 2026 00:04:48 +0530 Subject: [PATCH 45/46] feat(goals): expose workspace goal tools --- src/server.ts | 204 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index 86619323..fa007582 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ 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 { createGoalStore, type GoalStore, type WorkspaceGoal } from "./goal-store.js"; import { logEvent, requestIp, @@ -89,6 +90,7 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "goal" | "read" | "write" | "edit" @@ -187,8 +189,12 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + const goals = config.goalsEnabled + ? " Goals are durable workspace state: use get_goal after opening a workspace, after context compaction or resume, and before continuing long-running work; create or update goals only when the user or governing instructions call for it." + : ""; + if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${goals}`; } const inspection = config.toolMode !== "full" @@ -206,7 +212,7 @@ function serverInstructions(config: ServerConfig, toolNames: ToolNames): string ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." : ""; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}${goals}`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { @@ -234,6 +240,16 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const goalOutputSchema = z.object({ + workspaceId: z.string(), + goalId: z.string(), + objective: z.string(), + status: z.enum(["active", "blocked", "complete"]), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().optional(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -509,6 +525,159 @@ function processToolResponse( }; } +function goalResultText(goal: WorkspaceGoal | undefined): string { + if (!goal) return "No goal exists for this workspace."; + + const completed = goal.completedAt ? `\nCompleted at: ${goal.completedAt}` : ""; + return [ + `Goal ${goal.goalId}`, + `Status: ${goal.status}`, + `Objective: ${goal.objective}`, + `Updated at: ${goal.updatedAt}${completed}`, + ].join("\n"); +} + +function goalToolResponse( + tool: "get_goal" | "create_goal" | "update_goal", + workspaceId: string, + goal: WorkspaceGoal | undefined, + action: string, +) { + const result = goalResultText(goal); + const content = [textBlock(result)]; + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary: { + action, + hasGoal: Boolean(goal), + status: goal?.status, + }, + payload: { content }, + }, + }, + structuredContent: { + result, + goal: goal ?? null, + }, + }; +} + +function registerGoalTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + goalStore: GoalStore, +): void { + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Retrieve the durable goal for an open workspace. Call this after open_workspace, after context compaction or resume, and before continuing long-running work. Returns null when no goal exists.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.getGoal(workspaceId); + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("get_goal", workspaceId, goal, "get"); + }, + ); + + registerAppTool( + server, + "create_goal", + { + title: "Create goal", + description: + "Create a durable active goal for an open workspace. Use only when explicitly requested by the user or governing instructions. Fails if an unfinished goal already exists; update that goal instead.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + objective: z.string().min(1).max(4000).describe("Concrete goal objective to persist for this workspace."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ workspaceId, objective }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.createGoal({ workspaceId, objective }); + logToolCall(config, { + tool: "create_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("create_goal", workspaceId, goal, "create"); + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Update the current workspace goal lifecycle. Allowed statuses are complete and blocked. Use complete only when the objective is done. Use blocked only when the model cannot make meaningful progress without user input or an external state change.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + status: z.enum(["complete", "blocked"]).describe("New lifecycle status for the current goal."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ workspaceId, status }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.updateGoal({ workspaceId, status }); + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("update_goal", workspaceId, goal, "update"); + }, + ); +} + function registerCodexProcessTools( server: McpServer, config: ServerConfig, @@ -657,6 +826,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + goalStore: GoalStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -747,6 +917,7 @@ function createMcpServer( skills: z.array(workspaceSkillOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + goal: goalOutputSchema.nullable().optional(), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -774,9 +945,17 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const goal = config.goalsEnabled ? goalStore.getGoal(workspace.id) : undefined; + const instructionParts = [ + "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.", + config.skillsEnabled + ? "When a task matches an available skill in skills, read its path before proceeding." + : undefined, + config.goalsEnabled + ? "Goal state is workspace-scoped and durable. Call get_goal after context compaction or resume and before continuing goal-directed work." + : undefined, + ]; + const instruction = instructionParts.filter(Boolean).join(" "); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -793,6 +972,11 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + config.goalsEnabled + ? goal + ? `Current goal: ${goal.objective} (status: ${goal.status})` + : "Current goal: none" + : undefined, instruction, ].filter(Boolean).join("\n"), }, @@ -818,6 +1002,7 @@ function createMcpServer( availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, skillDiagnostics: workspace.skillDiagnostics.length, + ...(config.goalsEnabled ? { goal: goal?.status ?? "none" } : {}), }, }, }, @@ -832,11 +1017,16 @@ function createMcpServer( skills: visibleSkills, skillDiagnostics: workspace.skillDiagnostics, instruction, + ...(config.goalsEnabled ? { goal: goal ?? null } : {}), }, }; }, ); + if (config.goalsEnabled) { + registerGoalTools(server, config, workspaces, goalStore); + } + registerAppTool( server, toolNames.read, @@ -1566,6 +1756,7 @@ export function createServer(config = loadConfig()): RunningServer { resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), }); const workspaceStore = createWorkspaceStore(config.stateDir); + const goalStore = createGoalStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); @@ -1692,7 +1883,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); + const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, goalStore); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1720,6 +1911,7 @@ export function createServer(config = loadConfig()): RunningServer { closed = true; processSessions.shutdown(); oauthProvider.close(); + goalStore.close?.(); workspaceStore.close?.(); }, }; From e9aca59465c3947c4cb0c8e6f799400a21e177a1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 24 Jun 2026 00:10:35 +0530 Subject: [PATCH 46/46] fix(goals): decouple default from codex mode --- src/config.test.ts | 2 +- src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index b3db4d6f..4c991207 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -25,7 +25,7 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).goalsEnabled, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, true); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex", DEVSPACE_GOALS: "0" }).goalsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true); assert.equal(loadConfig(baseEnv).skillsEnabled, true); diff --git a/src/config.ts b/src/config.ts index 8e2662b9..1ac51444 100644 --- a/src/config.ts +++ b/src/config.ts @@ -238,7 +238,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), goalsEnabled: env.DEVSPACE_GOALS === undefined - ? (files.config.goalsEnabled ?? toolMode === "codex") + ? (files.config.goalsEnabled ?? false) : parseBoolean(env.DEVSPACE_GOALS), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS),