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..fa3a61de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,8 +70,19 @@ 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` 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 +`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY +sessions. ## Widgets diff --git a/package-lock.json b/package-lock.json index acb8f63d..bba78ab4 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,6 +18,7 @@ "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "node-pty": "1.1.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -39,6 +41,9 @@ }, "engines": { "node": ">=20.12 <27" + }, + "optionalDependencies": { + "node-pty": "^1.1.0" } }, "node_modules/@clack/core": { @@ -571,7 +576,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4672,6 +4677,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 f2027eaf..9b776220 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "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/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": [], @@ -60,5 +61,8 @@ "overrides": { "protobufjs": "7.6.4", "ws": "8.21.0" + }, + "optionalDependencies": { + "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; + } + } +} diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts new file mode 100644 index 00000000..d69901e6 --- /dev/null +++ b/src/apply-patch.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyPatch, parsePatch, replaceFile } from "./apply-patch.js"; + +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"); + +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(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"); +await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/); + +if (process.platform !== "win32") await chmod(join(root, "alpha.txt"), 0o755); +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"); +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( + 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"), process.platform === "win32" ? "junction" : "dir"); +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"); + +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/); diff --git a/src/apply-patch.ts b/src/apply-patch.ts new file mode 100644 index 00000000..43c3962d --- /dev/null +++ b/src/apply-patch.ts @@ -0,0 +1,442 @@ +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[]; + patch: string; + additions: number; + removals: number; +} + +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 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(); + const originals = new Map(); + const currentPaths = new Map(); + const results: AppliedPatchFile[] = []; + + const rememberOriginal = ( + absolute: string, + displayPath: string, + content: string | null, + ): void => { + if (!originals.has(absolute)) originals.set(absolute, { content, path: displayPath }); + currentPaths.set(absolute, displayPath); + }; + + const load = async (displayPath: string): Promise<{ absolute: string; file: StagedFile }> => { + const absolute = await resolveConfinedPath(root, displayPath); + if (staged.has(absolute)) { + const file = staged.get(absolute); + if (!file) throw patchError(`file does not exist: ${displayPath}`); + return { absolute, file }; + } + if (!(await fileExists(absolute))) throw patchError(`file does not exist: ${displayPath}`); + const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + const file = { content: await readFile(absolute, "utf8"), mode: metadata.mode }; + rememberOriginal(absolute, displayPath, file.content); + staged.set(absolute, file); + return { absolute, file }; + }; + + for (const action of actions) { + if (action.kind === "add") { + const absolute = await resolveConfinedPath(root, action.path); + if (staged.get(absolute) || (!staged.has(absolute) && (await fileExists(absolute)))) { + throw patchError(`file already exists: ${action.path}`); + } + rememberOriginal(absolute, action.path, null); + staged.set(absolute, { content: action.content }); + 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}`); + } + rememberOriginal(destination, action.moveTo, null); + 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 patches = Array.from(staged, ([absolute, file]) => { + 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; + destinationExists: boolean; + }> = []; + for (const [destination, file] of staged) { + if (!file) continue; + await mkdir(dirname(destination), { recursive: true }); + const temporary = `${destination}.devspace-patch-${process.pid}-${pendingWrites.length}`; + await writeFile(temporary, file.content, file.mode === undefined ? undefined : { mode: file.mode }); + pendingWrites.push({ + temporary, + destination, + destinationExists: originals.get(destination)?.content !== null, + }); + } + + try { + for (const write of pendingWrites) { + await replaceFile(write.temporary, write.destination, write.destinationExists); + } + for (const [path, file] of staged) { + if (!file) await rm(path, { force: true }); + } + } catch (error) { + await Promise.all(pendingWrites.map(({ temporary }) => rm(temporary, { force: true }))); + throw error; + } + + return { files: results, patch: unifiedPatch, ...stats }; +} + +function fileLines(content: string): string[] { + if (content.length === 0) return []; + const normalized = content.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + if (normalized.endsWith("\n")) lines.pop(); + return lines; +} + +function hunkRange(start: number, count: number): string { + return count === 0 ? "0,0" : `${start},${count}`; +} + +function unifiedFilePatch( + oldPath: string, + newPath: string, + oldContent: string | null, + newContent: string | null, +): string { + const oldLines = fileLines(oldContent ?? ""); + const newLines = fileLines(newContent ?? ""); + let prefix = 0; + while ( + prefix < oldLines.length && + prefix < newLines.length && + oldLines[prefix] === newLines[prefix] + ) { + prefix += 1; + } + + let suffix = 0; + while ( + suffix < oldLines.length - prefix && + suffix < newLines.length - prefix && + oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] + ) { + suffix += 1; + } + + const contextBefore = Math.min(3, prefix); + const contextAfter = Math.min(3, suffix); + const oldChanged = oldLines.slice(prefix, oldLines.length - suffix); + const newChanged = newLines.slice(prefix, newLines.length - suffix); + const before = oldLines.slice(prefix - contextBefore, prefix); + const after = oldLines.slice(oldLines.length - suffix, oldLines.length - suffix + contextAfter); + const oldCount = contextBefore + oldChanged.length + contextAfter; + const newCount = contextBefore + newChanged.length + contextAfter; + const oldStart = oldContent === null ? 0 : prefix - contextBefore + 1; + const newStart = newContent === null ? 0 : prefix - contextBefore + 1; + const displayOld = oldContent === null ? "/dev/null" : `a/${oldPath}`; + const displayNew = newContent === null ? "/dev/null" : `b/${newPath}`; + + return [ + `diff --git a/${oldPath} b/${newPath}`, + oldContent === null ? "new file mode 100644" : undefined, + newContent === null ? "deleted file mode 100644" : undefined, + `--- ${displayOld}`, + `+++ ${displayNew}`, + `@@ -${hunkRange(oldStart, oldCount)} +${hunkRange(newStart, newCount)} @@`, + ...before.map((line) => ` ${line}`), + ...oldChanged.map((line) => `-${line}`), + ...newChanged.map((line) => `+${line}`), + ...after.map((line) => ` ${line}`), + ] + .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/config.test.ts b/src/config.test.ts index 4f29c4ed..4c991207 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,11 +18,16 @@ 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).goalsEnabled, false); +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); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); @@ -149,6 +154,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + goalsEnabled: true, }), ); writeFileSync( @@ -160,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 bb0526c4..1ac51444 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,11 +18,12 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - minimalTools: boolean; + toolMode: ToolMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; worktreeRoot: string; + goalsEnabled: boolean; skillsEnabled: boolean; skillPaths: string[]; agentDir: string; @@ -79,14 +81,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 { @@ -208,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), ); @@ -227,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, - minimalTools: parseMinimalTools(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 ?? false) + : 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/process-platform.test.ts b/src/process-platform.test.ts new file mode 100644 index 00000000..39b494d8 --- /dev/null +++ b/src/process-platform.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +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"], +}); + +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"], +}); + +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 new file mode 100644 index 00000000..905d4d73 --- /dev/null +++ b/src/process-platform.ts @@ -0,0 +1,77 @@ +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"]); + +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] }; +} + +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.test.ts b/src/process-sessions.test.ts new file mode 100644 index 00000000..06097825 --- /dev/null +++ b/src/process-sessions.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { ProcessSessionManager } from "./process-sessions.js"; + +const manager = new ProcessSessionManager({ + maxBufferCharacters: 1_024, + completedSessionTtlMs: 1_000, +}); + +const node = process.platform === "win32" + ? `"${process.execPath}"` + : 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 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, /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); +if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); + +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); + +try { + if (process.platform === "win32") { + const pty = await manager.start({ + workspaceId: "workspace-a", + cwd: process.cwd(), + command: "echo pty-ok", + tty: true, + yieldTimeMs: 10_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/); + } +} finally { + manager.shutdown(); +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts new file mode 100644 index 00000000..c6a0c89a --- /dev/null +++ b/src/process-sessions.ts @@ -0,0 +1,331 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; + +const DEFAULT_YIELD_MS = 10_000; +const DEFAULT_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; +} + +export interface WriteStdinInput { + workspaceId: string; + sessionId: string; + chars?: string; + columns?: number; + rows?: number; + yieldTimeMs?: number; + maxOutputTokens?: number; +} + +export interface ProcessSnapshot { + sessionId?: string; + output: string; + outputTruncated: boolean; + running: boolean; + exitCode?: number; + 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; + process?: ManagedProcess; + startedAt: number; + columns: number; + rows: number; + buffer: string; + bufferStart: number; + consumedThrough: number; + running: boolean; + exitCode?: number; + signal?: string; + 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 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 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 }; + + 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 session = this.createSession(input); + this.sessions.set(session.id, session); + + try { + if (input.tty && process.platform !== "win32") 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 this.waitForExit(session, 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 ?? ""; + 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); + 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); + } + + 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 ((interactionRequested || !hasUnreadOutput) && session.running) { + const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_YIELD_MS, 30_000); + await this.waitForExit(session, 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.process?.kill("SIGTERM"); + } + + shutdown(): void { + for (const session of this.sessions.values()) { + if (session.cleanupTimer) clearTimeout(session.cleanupTimer); + if (session.running) session.process?.kill("SIGTERM"); + } + 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) => { + 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 = resolveShellCommand(input.command); + const detached = process.platform !== "win32"; + const child = spawn(input.command, { + cwd: input.cwd, + env: process.env, + stdio: "pipe", + windowsHide: true, + detached, + shell: shell.executable, + }); + + 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"))); + 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 = resolveShellCommand(input.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) { + throw error; + } + + 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; + + 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); + } +} diff --git a/src/server.ts b/src/server.ts index bfcd7afc..fa007582 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,9 @@ 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 { createGoalStore, type GoalStore, type WorkspaceGoal } from "./goal-store.js"; import { logEvent, requestIp, @@ -35,6 +37,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"; @@ -87,6 +90,7 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "goal" | "read" | "write" | "edit" @@ -161,7 +165,7 @@ interface ToolLogFields { } function toolNamesFor(config: ServerConfig): ToolNames { - return config.toolNaming === "short" + return config.toolNaming === "short" || config.toolMode === "codex" ? { openWorkspace: "open_workspace", read: "read", @@ -185,7 +189,15 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { - const inspection = config.minimalTools + 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.${goals}`; + } + + 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. `; @@ -200,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 { @@ -228,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(), @@ -452,10 +474,359 @@ 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)]; + const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary: { ...summary, ...outputSummary }, + payload: { content }, + }, + }, + structuredContent: { + result, + sessionId: snapshot.sessionId, + running: snapshot.running, + exitCode: snapshot.exitCode, + signal: snapshot.signal, + wallTimeMs: snapshot.wallTimeMs, + outputTruncated: snapshot.outputTruncated, + }, + }; +} + +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, + 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."), + 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() + .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, tty, columns, rows, 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, + tty, + columns, + rows, + 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."), + 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() + .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, columns, rows, yieldTimeMs, maxOutputTokens }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const snapshot = await processSessions.write({ + workspaceId, + sessionId, + chars, + columns, + rows, + 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, + goalStore: GoalStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -546,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 }, @@ -573,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, @@ -592,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"), }, @@ -617,6 +1002,7 @@ function createMcpServer( availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, skillDiagnostics: workspace.skillDiagnostics.length, + ...(config.goalsEnabled ? { goal: goal?.status ?? "none" } : {}), }, }, }, @@ -631,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, @@ -733,6 +1124,7 @@ function createMcpServer( }, ); + if (config.toolMode !== "codex") { registerAppTool( server, toolNames.write, @@ -896,6 +1288,81 @@ 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({ + additions: z.number(), + removals: z.number(), + 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)]; + const displayPath = applied.files.length === 1 + ? applied.files[0]?.path + : `${applied.files.length} files`; + + logToolCall(config, { + tool: "apply_patch", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "apply_patch", + card: { + workspaceId, + 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, + }, + }; + }, + ); + } if (config.widgets === "changes") { registerAppTool( @@ -961,7 +1428,7 @@ function createMcpServer( ); } - if (!config.minimalTools) { + if (config.toolMode === "full") { registerAppTool( server, toolNames.grep, @@ -1172,12 +1639,13 @@ function createMcpServer( ); } + if (config.toolMode !== "codex") { registerAppTool( server, 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: { @@ -1261,6 +1729,11 @@ function createMcpServer( }; }, ); + } + + if (config.toolMode === "codex") { + registerCodexProcessTools(server, config, workspaces, processSessions); + } return server; } @@ -1283,8 +1756,10 @@ 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(); if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1408,7 +1883,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints); + const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, goalStore); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1434,7 +1909,9 @@ export function createServer(config = loadConfig()): RunningServer { close: () => { if (closed) return; closed = true; + processSessions.shutdown(); oauthProvider.close(); + goalStore.close?.(); workspaceStore.close?.(); }, }; 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); 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 {