From ba33600e5455114d090477db110552ccf73f2df4 Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 22 Jan 2026 12:17:25 -0500 Subject: [PATCH 1/3] feat(worktree): add OCX profile preservation for worktrees When OpenCode is launched via `ocx opencode -p `, the worktree plugin now automatically preserves the profile context when spawning new worktrees. Changes: - Add OCX_CONTEXT, OCX_BIN, OCX_PROFILE env vars to ocx opencode command - Add parseOcxContext() and buildWorktreeCommand() helpers to worktree plugin - Proper shell escaping with quoted values for safety - Fail loud if OCX context detected but binary missing This ensures profile settings, instructions, and configuration follow users into worktrees when using OCX. --- .opencode/plugin/worktree.ts | 905 ++++++++++++++++++ AGENTS.md | 19 +- facades/opencode-worktree/README.md | 34 + packages/cli/src/commands/opencode.ts | 24 +- .../kdco-registry/files/plugin/worktree.ts | 56 +- 5 files changed, 1029 insertions(+), 9 deletions(-) create mode 100644 .opencode/plugin/worktree.ts diff --git a/.opencode/plugin/worktree.ts b/.opencode/plugin/worktree.ts new file mode 100644 index 00000000..8a44f0c6 --- /dev/null +++ b/.opencode/plugin/worktree.ts @@ -0,0 +1,905 @@ +/** + * OCX Worktree Plugin + * + * Creates isolated git worktrees for AI development sessions with + * seamless terminal spawning across macOS, Windows, and Linux. + * + * Inspired by opencode-worktree-session by Felix Anhalt + * https://github.com/felixAnhalt/opencode-worktree-session + * License: MIT + * + * Rewritten for OCX with production-proven patterns. + */ + +import type { Database } from "bun:sqlite" +import { access, copyFile, cp, mkdir, rm, stat, symlink } from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { type Plugin, tool } from "@opencode-ai/plugin" +import type { Event } from "@opencode-ai/sdk" +import type { OpencodeClient } from "./kdco-primitives/types" + +/** Logger interface for structured logging */ +interface Logger { + debug: (msg: string) => void + info: (msg: string) => void + warn: (msg: string) => void + error: (msg: string) => void +} + +import { parse as parseJsonc } from "jsonc-parser" +import { z } from "zod" +import { getProjectId } from "./kdco-primitives/get-project-id" +import { escapeBash } from "./kdco-primitives/shell" +import { + addSession, + clearPendingDelete, + getPendingDelete, + getSession, + getWorktreePath, + initStateDb, + removeSession, + setPendingDelete, +} from "./worktree/state" +import { openTerminal } from "./worktree/terminal" + +// ============================================================================ +// OCX Context Detection +// ============================================================================ + +type OcxContext = { mode: "ocx"; bin: string; profile: string | undefined } | { mode: "opencode" } + +/** + * Parse OCX context from environment variables. + * Only detects OCX if explicit OCX_CONTEXT=1 marker is set. + * Fails loud if OCX context detected but OCX_BIN is missing. + */ +function parseOcxContext(): OcxContext { + if (process.env.OCX_CONTEXT !== "1") { + return { mode: "opencode" } + } + + const bin = process.env.OCX_BIN?.trim() + if (!bin) { + throw new Error( + "OCX context detected (OCX_CONTEXT=1) but OCX_BIN not set. " + + "This indicates a configuration error in OCX.", + ) + } + + const profile = process.env.OCX_PROFILE || undefined + return { mode: "ocx", bin, profile } +} + +/** + * Build the command to spawn OpenCode in a worktree. + * Uses OCX with profile if running under OCX, otherwise plain opencode. + */ +function buildWorktreeCommand(sessionId: string): string { + const ctx = parseOcxContext() + const escapedSessionId = escapeBash(sessionId) + + if (ctx.mode === "ocx") { + const escapedBin = escapeBash(ctx.bin) + const profileArg = ctx.profile ? ` -p "${escapeBash(ctx.profile)}"` : "" + return `"${escapedBin}" opencode${profileArg} --session "${escapedSessionId}"` + } else { + const bin = process.env.OPENCODE_BIN ?? "opencode" + const escapedBin = escapeBash(bin) + return `"${escapedBin}" --session "${escapedSessionId}"` + } +} + +/** Maximum retries for database initialization */ +const DB_MAX_RETRIES = 3 + +/** Delay between retry attempts in milliseconds */ +const DB_RETRY_DELAY_MS = 100 + +/** Maximum depth to traverse session parent chain */ +const MAX_SESSION_CHAIN_DEPTH = 10 + +// ============================================================================= +// TYPES & SCHEMAS +// ============================================================================= + +/** Result type for fallible operations */ +interface OkResult { + readonly ok: true + readonly value: T +} +interface ErrResult { + readonly ok: false + readonly error: E +} +type Result = OkResult | ErrResult + +const Result = { + ok: (value: T): OkResult => ({ ok: true, value }), + err: (error: E): ErrResult => ({ ok: false, error }), +} + +/** + * Git branch name validation - blocks invalid refs and shell metacharacters + * Characters blocked: control chars (0x00-0x1f, 0x7f), ~^:?*[]\\, and shell metacharacters + */ +function isValidBranchName(name: string): boolean { + // Check for control characters + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i) + if (code <= 0x1f || code === 0x7f) return false + } + // Check for invalid git ref characters and shell metacharacters + if (/[~^:?*[\]\\;&|`$()]/.test(name)) return false + return true +} + +const branchNameSchema = z + .string() + .min(1, "Branch name cannot be empty") + .refine((name) => !name.startsWith("-"), { + message: "Branch name cannot start with '-' (prevents option injection)", + }) + .refine((name) => !name.startsWith("/") && !name.endsWith("/"), { + message: "Branch name cannot start or end with '/'", + }) + .refine((name) => !name.includes("//"), { + message: "Branch name cannot contain '//'", + }) + .refine((name) => !name.includes("@{"), { + message: "Branch name cannot contain '@{' (git reflog syntax)", + }) + .refine((name) => !name.includes(".."), { + message: "Branch name cannot contain '..'", + }) + // biome-ignore lint/suspicious/noControlCharactersInRegex: Control character detection is intentional for security + .refine((name) => !/[\x00-\x1f\x7f ~^:?*[\]\\]/.test(name), { + message: "Branch name contains invalid characters", + }) + .max(255, "Branch name too long") + .refine((name) => isValidBranchName(name), "Contains invalid git ref characters") + .refine((name) => !name.startsWith(".") && !name.endsWith("."), "Cannot start or end with dot") + .refine((name) => !name.endsWith(".lock"), "Cannot end with .lock") + +/** + * Worktree plugin configuration schema. + * Config file: .opencode/worktree.jsonc + */ +const worktreeConfigSchema = z.object({ + sync: z + .object({ + /** Files to copy from main worktree (relative paths only) */ + copyFiles: z.array(z.string()).default([]), + /** Directories to symlink from main worktree (saves disk space) */ + symlinkDirs: z.array(z.string()).default([]), + /** Patterns to exclude from copying (reserved for future use) */ + exclude: z.array(z.string()).default([]), + }) + .default(() => ({ copyFiles: [], symlinkDirs: [], exclude: [] })), + hooks: z + .object({ + /** Commands to run after worktree creation */ + postCreate: z.array(z.string()).default([]), + /** Commands to run before worktree deletion */ + preDelete: z.array(z.string()).default([]), + }) + .default(() => ({ postCreate: [], preDelete: [] })), +}) + +type WorktreeConfig = z.infer + +// ============================================================================= +// ERROR TYPES +// ============================================================================= + +class WorktreeError extends Error { + constructor( + message: string, + public readonly operation: string, + public readonly cause?: unknown, + ) { + super(`${operation}: ${message}`) + this.name = "WorktreeError" + } +} + +// ============================================================================= +// SESSION FORKING HELPERS +// ============================================================================= + +/** + * Check if a path exists, distinguishing ENOENT from other errors (Law 4) + */ +async function pathExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch (e: unknown) { + if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") { + return false + } + throw e // Re-throw permission errors, etc. + } +} + +/** + * Copy file if source exists. Returns true if copied, false if source doesn't exist. + * Throws on copy failure (Law 4: Fail Loud) + */ +async function copyIfExists(src: string, dest: string): Promise { + if (!(await pathExists(src))) return false + await copyFile(src, dest) + return true +} + +/** + * Copy directory contents if source exists. + * @param src - Source directory path + * @param dest - Destination directory path + * @returns true if copy was performed, false if source doesn't exist + */ +async function copyDirIfExists(src: string, dest: string): Promise { + if (!(await pathExists(src))) return false + await cp(src, dest, { recursive: true }) + return true +} + +interface ForkResult { + forkedSession: { id: string } + rootSessionId: string + planCopied: boolean + delegationsCopied: boolean +} + +/** + * Fork a session and copy associated plans/delegations. + * Cleans up forked session on failure (atomic operation). + */ +async function forkWithContext( + client: OpencodeClient, + sessionId: string, + projectId: string, + getRootSessionIdFn: (sessionId: string) => Promise, +): Promise { + // Guard clauses (Law 1) + if (!client) throw new WorktreeError("client is required", "forkWithContext") + if (!sessionId) throw new WorktreeError("sessionId is required", "forkWithContext") + if (!projectId) throw new WorktreeError("projectId is required", "forkWithContext") + + // Get root session ID with error wrapping + let rootSessionId: string + try { + rootSessionId = await getRootSessionIdFn(sessionId) + } catch (e) { + throw new WorktreeError("Failed to get root session ID", "forkWithContext", e) + } + + // Fork session + const forkedSessionResponse = await client.session.fork({ + path: { id: sessionId }, + body: {}, + }) + const forkedSession = forkedSessionResponse.data + if (!forkedSession?.id) { + throw new WorktreeError("Failed to fork session: no session data returned", "forkWithContext") + } + + // Copy data with cleanup on failure + let planCopied = false + let delegationsCopied = false + + try { + const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") + const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") + + const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) + const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) + + await mkdir(destWorkspaceDir, { recursive: true }) + await mkdir(destDelegationsDir, { recursive: true }) + + // Copy plan + const srcPlan = path.join(workspaceBase, projectId, rootSessionId, "plan.md") + const destPlan = path.join(destWorkspaceDir, "plan.md") + planCopied = await copyIfExists(srcPlan, destPlan) + + // Copy delegations + const srcDelegations = path.join(delegationsBase, projectId, rootSessionId) + delegationsCopied = await copyDirIfExists(srcDelegations, destDelegationsDir) + } catch (error) { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Copy failed, cleaning up forked session: ${error}`, + }, + }) + .catch(() => {}) + // Clean up orphaned directories + const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") + const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") + const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) + const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) + await rm(destWorkspaceDir, { recursive: true, force: true }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up workspace dir ${destWorkspaceDir}: ${e}`, + }, + }) + .catch(() => {}) + }) + await rm(destDelegationsDir, { recursive: true, force: true }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up delegations dir ${destDelegationsDir}: ${e}`, + }, + }) + .catch(() => {}) + }) + await client.session.delete({ path: { id: forkedSession.id } }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up forked session ${forkedSession.id}: ${e}`, + }, + }) + .catch(() => {}) + }) + throw new WorktreeError( + `Failed to copy session data: ${error instanceof Error ? error.message : String(error)}`, + "forkWithContext", + error, + ) + } + + return { forkedSession, rootSessionId, planCopied, delegationsCopied } +} + +// ============================================================================= +// MODULE-LEVEL STATE +// ============================================================================= + +/** Database instance - initialized once per plugin lifecycle */ +let db: Database | null = null + +/** Project root path - stored on first initialization */ +let projectRoot: string | null = null + +/** Flag to prevent duplicate cleanup handler registration */ +let cleanupRegistered = false + +/** + * Register process cleanup handlers for graceful database shutdown. + * Ensures WAL checkpoint and proper close on process termination. + * + * NOTE: process.once() is an EventEmitter method that never throws. + * The boolean guard is defense-in-depth for idempotency, not error recovery. + * + * @param database - The database instance to clean up + */ +function registerCleanupHandlers(database: Database): void { + if (cleanupRegistered) return // Early exit guard + cleanupRegistered = true + + const cleanup = () => { + try { + database.exec("PRAGMA wal_checkpoint(TRUNCATE)") + database.close() + } catch { + // Best effort cleanup - process is exiting anyway + } + } + + process.once("SIGTERM", cleanup) + process.once("SIGINT", cleanup) + process.once("beforeExit", cleanup) +} + +/** + * Get the database instance, initializing if needed. + * Includes retry logic for transient initialization failures. + * + * @returns Database instance + * @throws {Error} if initialization fails after all retries + */ +async function getDb(log: Logger): Promise { + if (db) return db + + if (!projectRoot) { + throw new Error("Database not initialized: projectRoot not set. Call initDb() first.") + } + + let lastError: Error | null = null + + for (let attempt = 1; attempt <= DB_MAX_RETRIES; attempt++) { + try { + db = await initStateDb(projectRoot) + registerCleanupHandlers(db) + return db + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + log.warn(`Database init attempt ${attempt}/${DB_MAX_RETRIES} failed: ${lastError.message}`) + + if (attempt < DB_MAX_RETRIES) { + Bun.sleepSync(DB_RETRY_DELAY_MS) + } + } + } + + throw new Error( + `Failed to initialize database after ${DB_MAX_RETRIES} attempts: ${lastError?.message}`, + ) +} + +/** + * Initialize the database with the project root path. + * Must be called once before any getDb() calls. + */ +async function initDb(root: string, log: Logger): Promise { + projectRoot = root + return getDb(log) +} + +// ============================================================================= +// GIT MODULE +// ============================================================================= + +/** + * Execute a git command safely using Bun.spawn with explicit array. + * Avoids shell interpolation entirely by passing args as array. + */ +async function git(args: string[], cwd: string): Promise> { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (exitCode !== 0) { + return Result.err(stderr.trim() || `git ${args[0]} failed`) + } + return Result.ok(stdout.trim()) + } catch (error) { + return Result.err(error instanceof Error ? error.message : String(error)) + } +} + +async function branchExists(cwd: string, branch: string): Promise { + const result = await git(["rev-parse", "--verify", branch], cwd) + return result.ok +} + +async function createWorktree( + repoRoot: string, + branch: string, + baseBranch?: string, +): Promise> { + const worktreePath = await getWorktreePath(repoRoot, branch) + + // Ensure parent directory exists + await mkdir(path.dirname(worktreePath), { recursive: true }) + + const exists = await branchExists(repoRoot, branch) + + if (exists) { + // Checkout existing branch into worktree + const result = await git(["worktree", "add", worktreePath, branch], repoRoot) + return result.ok ? Result.ok(worktreePath) : result + } else { + // Create new branch from base + const base = baseBranch ?? "HEAD" + const result = await git(["worktree", "add", "-b", branch, worktreePath, base], repoRoot) + return result.ok ? Result.ok(worktreePath) : result + } +} + +async function removeWorktree( + repoRoot: string, + worktreePath: string, +): Promise> { + const result = await git(["worktree", "remove", "--force", worktreePath], repoRoot) + return result.ok ? Result.ok(undefined) : Result.err(result.error) +} + +// ============================================================================= +// FILE SYNC MODULE +// ============================================================================= + +/** + * Validate that a path is safe (no escape from base directory) + */ +function isPathSafe(filePath: string, baseDir: string, log: Logger): boolean { + // Reject absolute paths + if (path.isAbsolute(filePath)) { + log.warn(`[worktree] Rejected absolute path: ${filePath}`) + return false + } + // Reject obvious path traversal + if (filePath.includes("..")) { + log.warn(`[worktree] Rejected path traversal: ${filePath}`) + return false + } + // Verify resolved path stays within base directory + const resolved = path.resolve(baseDir, filePath) + if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) { + log.warn(`[worktree] Path escapes base directory: ${filePath}`) + return false + } + return true +} + +/** + * Copy files from source directory to target directory. + * Skips missing files silently (production pattern). + */ +async function copyFiles( + sourceDir: string, + targetDir: string, + files: string[], + log: Logger, +): Promise { + for (const file of files) { + if (!isPathSafe(file, sourceDir, log)) continue + + const sourcePath = path.join(sourceDir, file) + const targetPath = path.join(targetDir, file) + + try { + const sourceFile = Bun.file(sourcePath) + if (!(await sourceFile.exists())) { + log.debug(`[worktree] Skipping missing file: ${file}`) + continue + } + + // Ensure target directory exists + const targetFileDir = path.dirname(targetPath) + await mkdir(targetFileDir, { recursive: true }) + + // Copy file + await Bun.write(targetPath, sourceFile) + log.info(`[worktree] Copied: ${file}`) + } catch (error) { + const isNotFound = + error instanceof Error && + (error.message.includes("ENOENT") || error.message.includes("no such file")) + if (isNotFound) { + log.debug(`[worktree] Skipping missing: ${file}`) + } else { + log.warn(`[worktree] Failed to copy ${file}: ${error}`) + } + } + } +} + +/** + * Create symlinks for directories from source to target. + * Uses absolute paths for symlink targets. + */ +async function symlinkDirs( + sourceDir: string, + targetDir: string, + dirs: string[], + log: Logger, +): Promise { + for (const dir of dirs) { + if (!isPathSafe(dir, sourceDir, log)) continue + + const sourcePath = path.join(sourceDir, dir) + const targetPath = path.join(targetDir, dir) + + try { + // Check if source directory exists + const fileStat = await stat(sourcePath).catch(() => null) + if (!fileStat || !fileStat.isDirectory()) { + log.debug(`[worktree] Skipping missing directory: ${dir}`) + continue + } + + // Ensure parent directory exists + const targetParentDir = path.dirname(targetPath) + await mkdir(targetParentDir, { recursive: true }) + + // Remove existing target if it exists (might be empty dir from git) + await rm(targetPath, { recursive: true, force: true }) + + // Create symlink (use absolute path for source) + await symlink(sourcePath, targetPath, "dir") + log.info(`[worktree] Symlinked: ${dir}`) + } catch (error) { + log.warn(`[worktree] Failed to symlink ${dir}: ${error}`) + } + } +} + +/** + * Run hook commands in the worktree directory. + */ +async function runHooks(cwd: string, commands: string[], log: Logger): Promise { + for (const command of commands) { + log.info(`[worktree] Running hook: ${command}`) + try { + // Use shell to properly handle quoted arguments and complex commands + const result = Bun.spawnSync(["bash", "-c", command], { + cwd, + stdout: "inherit", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + const stderr = result.stderr?.toString() || "" + log.warn( + `[worktree] Hook failed (exit ${result.exitCode}): ${command}${stderr ? `\n${stderr}` : ""}`, + ) + } + } catch (error) { + log.warn(`[worktree] Hook error: ${error}`) + } + } +} + +/** + * Load worktree-specific configuration from .opencode/worktree.jsonc + * Auto-creates config file with helpful defaults if it doesn't exist. + */ +async function loadWorktreeConfig(directory: string, log: Logger): Promise { + const configPath = path.join(directory, ".opencode", "worktree.jsonc") + + try { + const file = Bun.file(configPath) + if (!(await file.exists())) { + // Auto-create config with helpful defaults and comments + const defaultConfig = `{ + "$schema": "https://registry.kdco.dev/schemas/worktree.json", + + // Worktree plugin configuration + // Documentation: https://github.com/kdcokenny/ocx + + "sync": { + // Files to copy from main worktree to new worktrees + // Example: [".env", ".env.local", "dev.sqlite"] + "copyFiles": [], + + // Directories to symlink (saves disk space) + // Example: ["node_modules"] + "symlinkDirs": [], + + // Patterns to exclude from copying + "exclude": [] + }, + + "hooks": { + // Commands to run after worktree creation + // Example: ["pnpm install", "docker compose up -d"] + "postCreate": [], + + // Commands to run before worktree deletion + // Example: ["docker compose down"] + "preDelete": [] + } +} +` + // Ensure .opencode directory exists + await mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Bun.write(configPath, defaultConfig) + log.info(`[worktree] Created default config: ${configPath}`) + return worktreeConfigSchema.parse({}) + } + + const content = await file.text() + // Use proper JSONC parser (handles comments in strings correctly) + const parsed = parseJsonc(content) + if (parsed === undefined) { + log.error(`[worktree] Invalid worktree.jsonc syntax`) + return worktreeConfigSchema.parse({}) + } + return worktreeConfigSchema.parse(parsed) + } catch (error) { + log.warn(`[worktree] Failed to load config: ${error}`) + return worktreeConfigSchema.parse({}) + } +} + +// ============================================================================= +// PLUGIN ENTRY +// ============================================================================= + +export const WorktreePlugin: Plugin = async (ctx) => { + const { directory, client } = ctx + + const log = { + debug: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "debug", message: msg } }) + .catch(() => {}), + info: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "info", message: msg } }) + .catch(() => {}), + warn: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "warn", message: msg } }) + .catch(() => {}), + error: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "error", message: msg } }) + .catch(() => {}), + } + + // Initialize SQLite database + const database = await initDb(directory, log) + + return { + tool: { + worktree_create: tool({ + description: + "Create a new git worktree for isolated development. A new terminal will open with OpenCode in the worktree.", + args: { + branch: tool.schema + .string() + .describe("Branch name for the worktree (e.g., 'feature/dark-mode')"), + baseBranch: tool.schema + .string() + .optional() + .describe("Base branch to create from (defaults to HEAD)"), + }, + async execute(args, toolCtx) { + // Validate branch name at boundary + const branchResult = branchNameSchema.safeParse(args.branch) + if (!branchResult.success) { + return `❌ Invalid branch name: ${branchResult.error.issues[0]?.message}` + } + + // Validate base branch name at boundary + if (args.baseBranch) { + const baseResult = branchNameSchema.safeParse(args.baseBranch) + if (!baseResult.success) { + return `❌ Invalid base branch name: ${baseResult.error.issues[0]?.message}` + } + } + + // Create worktree + const result = await createWorktree(directory, args.branch, args.baseBranch) + if (!result.ok) { + return `Failed to create worktree: ${result.error}` + } + + const worktreePath = result.value + + // Sync files from main worktree + const worktreeConfig = await loadWorktreeConfig(directory, log) + const mainWorktreePath = directory // The repo root is the main worktree + + // Copy files + if (worktreeConfig.sync.copyFiles.length > 0) { + await copyFiles(mainWorktreePath, worktreePath, worktreeConfig.sync.copyFiles, log) + } + + // Symlink directories + if (worktreeConfig.sync.symlinkDirs.length > 0) { + await symlinkDirs(mainWorktreePath, worktreePath, worktreeConfig.sync.symlinkDirs, log) + } + + // Run postCreate hooks + if (worktreeConfig.hooks.postCreate.length > 0) { + await runHooks(worktreePath, worktreeConfig.hooks.postCreate, log) + } + + // Fork session with context (replaces --session resume) + const projectId = await getProjectId(worktreePath, client) + const { forkedSession, planCopied, delegationsCopied } = await forkWithContext( + client, + toolCtx.sessionID, + projectId, + async (sid) => { + // Walk up parentID chain to find root session + let currentId = sid + for (let depth = 0; depth < MAX_SESSION_CHAIN_DEPTH; depth++) { + const session = await client.session.get({ path: { id: currentId } }) + if (!session.data?.parentID) return currentId + currentId = session.data.parentID + } + return currentId + }, + ) + + log.debug( + `Forked session ${forkedSession.id}, plan: ${planCopied}, delegations: ${delegationsCopied}`, + ) + + // Spawn worktree with forked session + const command = buildWorktreeCommand(forkedSession.id) + const terminalResult = await openTerminal(worktreePath, command, args.branch) + + if (!terminalResult.success) { + log.warn(`[worktree] Failed to open terminal: ${terminalResult.error}`) + } + + // Record session for tracking (used by delete flow) + addSession(database, { + id: forkedSession.id, + branch: args.branch, + path: worktreePath, + createdAt: new Date().toISOString(), + }) + + return `Worktree created at ${worktreePath}\n\nA new terminal has been opened with OpenCode.` + }, + }), + + worktree_delete: tool({ + description: + "Delete the current worktree and clean up. Changes will be committed before removal.", + args: { + reason: tool.schema + .string() + .describe("Brief explanation of why you are calling this tool"), + }, + async execute(_args, toolCtx) { + // Find current session's worktree + const session = getSession(database, toolCtx?.sessionID ?? "") + if (!session) { + return `No worktree associated with this session` + } + + // Set pending delete for session.idle (atomic operation) + setPendingDelete(database, { branch: session.branch, path: session.path }, client) + + return `Worktree marked for cleanup. It will be removed when this session ends.` + }, + }), + }, + + event: async ({ event }: { event: Event }): Promise => { + if (event.type !== "session.idle") return + + // Handle pending delete + const pendingDelete = getPendingDelete(database) + if (pendingDelete) { + const { path: worktreePath, branch } = pendingDelete + + // Run preDelete hooks before cleanup + const config = await loadWorktreeConfig(directory, log) + if (config.hooks.preDelete.length > 0) { + await runHooks(worktreePath, config.hooks.preDelete, log) + } + + // Commit any uncommitted changes + const addResult = await git(["add", "-A"], worktreePath) + if (!addResult.ok) log.warn(`[worktree] git add failed: ${addResult.error}`) + + const commitResult = await git( + ["commit", "-m", "chore(worktree): session snapshot", "--allow-empty"], + worktreePath, + ) + if (!commitResult.ok) log.warn(`[worktree] git commit failed: ${commitResult.error}`) + + // Remove worktree + const removeResult = await removeWorktree(directory, worktreePath) + if (!removeResult.ok) { + log.warn(`[worktree] Failed to remove worktree: ${removeResult.error}`) + } + + // Clear pending delete atomically + clearPendingDelete(database) + + // Remove session from database + removeSession(database, branch) + } + }, + } +} + +export default WorktreePlugin diff --git a/AGENTS.md b/AGENTS.md index 19c4bf68..9df24c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -310,8 +310,11 @@ Configurations are merged in this order (later sources override earlier ones): - Filters by `exclude`/`include` patterns from profile's `ocx.jsonc` - Include patterns override exclude patterns (TypeScript/Vite style) 4. **Window naming** (optional): Sets terminal/tmux window name to `[profile]:repo/branch` for session identification -5. **Spawn OpenCode**: Launches OpenCode with merged configuration and discovered instructions -6. **Working directory**: OpenCode runs directly in the project directory +5. **Environment variables**: Sets context markers for plugin detection: + - **`OCX_CONTEXT: "1"`** - Marker indicating OpenCode was launched via OCX (used by plugins) + - **`OCX_BIN: `** - Absolute path to OCX binary (used by worktree plugin) +6. **Spawn OpenCode**: Launches OpenCode with merged configuration and discovered instructions +7. **Working directory**: OpenCode runs directly in the project directory ### Instruction File Discovery @@ -355,6 +358,18 @@ To use a custom OpenCode binary (e.g., a development build), set the `bin` optio 2. `OPENCODE_BIN` environment variable 3. `opencode` (system PATH) +### Worktree Profile Preservation + +When using the worktree plugin with OCX, your profile context is automatically preserved: + +| Launch Command | Worktree Spawns | +|----------------|-----------------| +| `opencode` | `opencode --session ` | +| `ocx opencode` | `ocx opencode --session ` | +| `ocx opencode -p work` | `ocx opencode -p work --session ` | + +This ensures your profile settings, instructions, and configuration follow you into worktrees. The worktree plugin detects OCX context via the `OCX_CONTEXT` environment variable and uses `OCX_BIN` to spawn the correct binary. + ### Profile Management Use profile commands to manage multiple configurations: diff --git a/facades/opencode-worktree/README.md b/facades/opencode-worktree/README.md index 7814f6b4..58d8d37c 100644 --- a/facades/opencode-worktree/README.md +++ b/facades/opencode-worktree/README.md @@ -120,6 +120,36 @@ The plugin detects your terminal automatically: 3. **Environment vars** - Checks `TERM_PROGRAM`, `KITTY_WINDOW_ID`, `GHOSTTY_RESOURCES_DIR`, etc. 4. **Fallback** - System defaults (Terminal.app, xterm, cmd.exe) +## OCX Profile Support + +When running OpenCode via [OCX](https://github.com/kdcokenny/ocx) (`ocx opencode -p `), the worktree plugin automatically preserves your profile context. + +### How It Works + +| Launch Command | Worktree Spawns | +|----------------|-----------------| +| `opencode` | `opencode --session ` | +| `ocx opencode` | `ocx opencode --session ` | +| `ocx opencode -p work` | `ocx opencode -p work --session ` | + +The plugin detects OCX context via environment variables (`OCX_CONTEXT`, `OCX_BIN`, `OCX_PROFILE`) and spawns worktrees with the same profile configuration. + +### Multiple Profiles + +You can run multiple OpenCode sessions with different profiles simultaneously. Each session's worktrees will inherit the correct profile: + +```bash +# Terminal 1 +ocx opencode -p work +# Creates worktree → spawns with -p work + +# Terminal 2 +ocx opencode -p personal +# Creates worktree → spawns with -p personal +``` + +Environment variables are process-scoped, so profiles don't "leak" between sessions. + ## Configuration Auto-creates `.opencode/worktree.jsonc` on first use: @@ -203,6 +233,10 @@ No. It uses standard git worktrees. `git worktree list` shows them. Branches mer Isolation. You can close the worktree session without affecting your main workflow. If the AI breaks something, your original terminal remains untouched. +### Does the worktree preserve my OCX profile? + +Yes! If you launch OpenCode via `ocx opencode -p `, any worktrees you create will automatically use the same profile. See [OCX Profile Support](#ocx-profile-support) for details. + ## Limitations ### Security diff --git a/packages/cli/src/commands/opencode.ts b/packages/cli/src/commands/opencode.ts index a26755d7..ec9a918b 100644 --- a/packages/cli/src/commands/opencode.ts +++ b/packages/cli/src/commands/opencode.ts @@ -29,6 +29,24 @@ interface OpencodeOptions { json?: boolean } +/** + * Resolve the path to the OCX binary. + * Priority: existing OCX_BIN env > Bun.which("ocx") + * Fails if OCX binary cannot be found. + */ +function resolveOcxBin(): string { + // 1. Already set (nested OCX) - preserve it if non-empty + const envBin = process.env.OCX_BIN?.trim() + if (envBin) return envBin + + // 2. Resolve from PATH (preferred - returns symlinked command path) + const which = Bun.which("ocx") + if (which) return which + + // 3. Fail before spawning + throw new Error("Cannot determine ocx binary path. Set OCX_BIN or ensure ocx is in PATH.") +} + export function registerOpencodeCommand(program: Command): void { program .command("opencode [path]") @@ -137,10 +155,14 @@ async function runOpencode( cwd: projectDir, env: { ...process.env, + // OCX context markers for worktree plugin + OCX_CONTEXT: "1", + OCX_BIN: resolveOcxBin(), + ...(config.profileName && { OCX_PROFILE: config.profileName }), + // OpenCode config injection OPENCODE_DISABLE_PROJECT_CONFIG: "true", ...(profileDir && { OPENCODE_CONFIG_DIR: profileDir }), ...(configToPass && { OPENCODE_CONFIG_CONTENT: JSON.stringify(configToPass) }), - ...(config.profileName && { OCX_PROFILE: config.profileName }), }, stdin: "inherit", stdout: "inherit", diff --git a/workers/kdco-registry/files/plugin/worktree.ts b/workers/kdco-registry/files/plugin/worktree.ts index d6d3e7cd..8a44f0c6 100644 --- a/workers/kdco-registry/files/plugin/worktree.ts +++ b/workers/kdco-registry/files/plugin/worktree.ts @@ -29,8 +29,8 @@ interface Logger { import { parse as parseJsonc } from "jsonc-parser" import { z } from "zod" - import { getProjectId } from "./kdco-primitives/get-project-id" +import { escapeBash } from "./kdco-primitives/shell" import { addSession, clearPendingDelete, @@ -43,6 +43,53 @@ import { } from "./worktree/state" import { openTerminal } from "./worktree/terminal" +// ============================================================================ +// OCX Context Detection +// ============================================================================ + +type OcxContext = { mode: "ocx"; bin: string; profile: string | undefined } | { mode: "opencode" } + +/** + * Parse OCX context from environment variables. + * Only detects OCX if explicit OCX_CONTEXT=1 marker is set. + * Fails loud if OCX context detected but OCX_BIN is missing. + */ +function parseOcxContext(): OcxContext { + if (process.env.OCX_CONTEXT !== "1") { + return { mode: "opencode" } + } + + const bin = process.env.OCX_BIN?.trim() + if (!bin) { + throw new Error( + "OCX context detected (OCX_CONTEXT=1) but OCX_BIN not set. " + + "This indicates a configuration error in OCX.", + ) + } + + const profile = process.env.OCX_PROFILE || undefined + return { mode: "ocx", bin, profile } +} + +/** + * Build the command to spawn OpenCode in a worktree. + * Uses OCX with profile if running under OCX, otherwise plain opencode. + */ +function buildWorktreeCommand(sessionId: string): string { + const ctx = parseOcxContext() + const escapedSessionId = escapeBash(sessionId) + + if (ctx.mode === "ocx") { + const escapedBin = escapeBash(ctx.bin) + const profileArg = ctx.profile ? ` -p "${escapeBash(ctx.profile)}"` : "" + return `"${escapedBin}" opencode${profileArg} --session "${escapedSessionId}"` + } else { + const bin = process.env.OPENCODE_BIN ?? "opencode" + const escapedBin = escapeBash(bin) + return `"${escapedBin}" --session "${escapedSessionId}"` + } +} + /** Maximum retries for database initialization */ const DB_MAX_RETRIES = 3 @@ -773,11 +820,8 @@ export const WorktreePlugin: Plugin = async (ctx) => { ) // Spawn worktree with forked session - const terminalResult = await openTerminal( - worktreePath, - `opencode --session ${forkedSession.id}`, - args.branch, - ) + const command = buildWorktreeCommand(forkedSession.id) + const terminalResult = await openTerminal(worktreePath, command, args.branch) if (!terminalResult.success) { log.warn(`[worktree] Failed to open terminal: ${terminalResult.error}`) From c6f59d8f5373bcfada6599da02fc9d99b3c9e536 Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 22 Jan 2026 13:01:16 -0500 Subject: [PATCH 2/3] fix(worktree): improve shell escaping for argv approach - Change from command string to argv array for spawn commands - Add argvToBashCommand() and argvToBatchCommand() with platform-specific escaping - Fix Windows batch escaping: reject newlines, escape embedded quotes - Add null byte validation for bash arguments - Trim OCX_PROFILE for consistency - Remove local .opencode/plugin/worktree.ts (registry is source of truth) --- .opencode/plugin/worktree.ts | 905 ------------------ .../kdco-registry/files/plugin/worktree.ts | 28 +- .../files/plugin/worktree/terminal.ts | 94 +- 3 files changed, 82 insertions(+), 945 deletions(-) delete mode 100644 .opencode/plugin/worktree.ts diff --git a/.opencode/plugin/worktree.ts b/.opencode/plugin/worktree.ts deleted file mode 100644 index 8a44f0c6..00000000 --- a/.opencode/plugin/worktree.ts +++ /dev/null @@ -1,905 +0,0 @@ -/** - * OCX Worktree Plugin - * - * Creates isolated git worktrees for AI development sessions with - * seamless terminal spawning across macOS, Windows, and Linux. - * - * Inspired by opencode-worktree-session by Felix Anhalt - * https://github.com/felixAnhalt/opencode-worktree-session - * License: MIT - * - * Rewritten for OCX with production-proven patterns. - */ - -import type { Database } from "bun:sqlite" -import { access, copyFile, cp, mkdir, rm, stat, symlink } from "node:fs/promises" -import * as os from "node:os" -import * as path from "node:path" -import { type Plugin, tool } from "@opencode-ai/plugin" -import type { Event } from "@opencode-ai/sdk" -import type { OpencodeClient } from "./kdco-primitives/types" - -/** Logger interface for structured logging */ -interface Logger { - debug: (msg: string) => void - info: (msg: string) => void - warn: (msg: string) => void - error: (msg: string) => void -} - -import { parse as parseJsonc } from "jsonc-parser" -import { z } from "zod" -import { getProjectId } from "./kdco-primitives/get-project-id" -import { escapeBash } from "./kdco-primitives/shell" -import { - addSession, - clearPendingDelete, - getPendingDelete, - getSession, - getWorktreePath, - initStateDb, - removeSession, - setPendingDelete, -} from "./worktree/state" -import { openTerminal } from "./worktree/terminal" - -// ============================================================================ -// OCX Context Detection -// ============================================================================ - -type OcxContext = { mode: "ocx"; bin: string; profile: string | undefined } | { mode: "opencode" } - -/** - * Parse OCX context from environment variables. - * Only detects OCX if explicit OCX_CONTEXT=1 marker is set. - * Fails loud if OCX context detected but OCX_BIN is missing. - */ -function parseOcxContext(): OcxContext { - if (process.env.OCX_CONTEXT !== "1") { - return { mode: "opencode" } - } - - const bin = process.env.OCX_BIN?.trim() - if (!bin) { - throw new Error( - "OCX context detected (OCX_CONTEXT=1) but OCX_BIN not set. " + - "This indicates a configuration error in OCX.", - ) - } - - const profile = process.env.OCX_PROFILE || undefined - return { mode: "ocx", bin, profile } -} - -/** - * Build the command to spawn OpenCode in a worktree. - * Uses OCX with profile if running under OCX, otherwise plain opencode. - */ -function buildWorktreeCommand(sessionId: string): string { - const ctx = parseOcxContext() - const escapedSessionId = escapeBash(sessionId) - - if (ctx.mode === "ocx") { - const escapedBin = escapeBash(ctx.bin) - const profileArg = ctx.profile ? ` -p "${escapeBash(ctx.profile)}"` : "" - return `"${escapedBin}" opencode${profileArg} --session "${escapedSessionId}"` - } else { - const bin = process.env.OPENCODE_BIN ?? "opencode" - const escapedBin = escapeBash(bin) - return `"${escapedBin}" --session "${escapedSessionId}"` - } -} - -/** Maximum retries for database initialization */ -const DB_MAX_RETRIES = 3 - -/** Delay between retry attempts in milliseconds */ -const DB_RETRY_DELAY_MS = 100 - -/** Maximum depth to traverse session parent chain */ -const MAX_SESSION_CHAIN_DEPTH = 10 - -// ============================================================================= -// TYPES & SCHEMAS -// ============================================================================= - -/** Result type for fallible operations */ -interface OkResult { - readonly ok: true - readonly value: T -} -interface ErrResult { - readonly ok: false - readonly error: E -} -type Result = OkResult | ErrResult - -const Result = { - ok: (value: T): OkResult => ({ ok: true, value }), - err: (error: E): ErrResult => ({ ok: false, error }), -} - -/** - * Git branch name validation - blocks invalid refs and shell metacharacters - * Characters blocked: control chars (0x00-0x1f, 0x7f), ~^:?*[]\\, and shell metacharacters - */ -function isValidBranchName(name: string): boolean { - // Check for control characters - for (let i = 0; i < name.length; i++) { - const code = name.charCodeAt(i) - if (code <= 0x1f || code === 0x7f) return false - } - // Check for invalid git ref characters and shell metacharacters - if (/[~^:?*[\]\\;&|`$()]/.test(name)) return false - return true -} - -const branchNameSchema = z - .string() - .min(1, "Branch name cannot be empty") - .refine((name) => !name.startsWith("-"), { - message: "Branch name cannot start with '-' (prevents option injection)", - }) - .refine((name) => !name.startsWith("/") && !name.endsWith("/"), { - message: "Branch name cannot start or end with '/'", - }) - .refine((name) => !name.includes("//"), { - message: "Branch name cannot contain '//'", - }) - .refine((name) => !name.includes("@{"), { - message: "Branch name cannot contain '@{' (git reflog syntax)", - }) - .refine((name) => !name.includes(".."), { - message: "Branch name cannot contain '..'", - }) - // biome-ignore lint/suspicious/noControlCharactersInRegex: Control character detection is intentional for security - .refine((name) => !/[\x00-\x1f\x7f ~^:?*[\]\\]/.test(name), { - message: "Branch name contains invalid characters", - }) - .max(255, "Branch name too long") - .refine((name) => isValidBranchName(name), "Contains invalid git ref characters") - .refine((name) => !name.startsWith(".") && !name.endsWith("."), "Cannot start or end with dot") - .refine((name) => !name.endsWith(".lock"), "Cannot end with .lock") - -/** - * Worktree plugin configuration schema. - * Config file: .opencode/worktree.jsonc - */ -const worktreeConfigSchema = z.object({ - sync: z - .object({ - /** Files to copy from main worktree (relative paths only) */ - copyFiles: z.array(z.string()).default([]), - /** Directories to symlink from main worktree (saves disk space) */ - symlinkDirs: z.array(z.string()).default([]), - /** Patterns to exclude from copying (reserved for future use) */ - exclude: z.array(z.string()).default([]), - }) - .default(() => ({ copyFiles: [], symlinkDirs: [], exclude: [] })), - hooks: z - .object({ - /** Commands to run after worktree creation */ - postCreate: z.array(z.string()).default([]), - /** Commands to run before worktree deletion */ - preDelete: z.array(z.string()).default([]), - }) - .default(() => ({ postCreate: [], preDelete: [] })), -}) - -type WorktreeConfig = z.infer - -// ============================================================================= -// ERROR TYPES -// ============================================================================= - -class WorktreeError extends Error { - constructor( - message: string, - public readonly operation: string, - public readonly cause?: unknown, - ) { - super(`${operation}: ${message}`) - this.name = "WorktreeError" - } -} - -// ============================================================================= -// SESSION FORKING HELPERS -// ============================================================================= - -/** - * Check if a path exists, distinguishing ENOENT from other errors (Law 4) - */ -async function pathExists(filePath: string): Promise { - try { - await access(filePath) - return true - } catch (e: unknown) { - if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") { - return false - } - throw e // Re-throw permission errors, etc. - } -} - -/** - * Copy file if source exists. Returns true if copied, false if source doesn't exist. - * Throws on copy failure (Law 4: Fail Loud) - */ -async function copyIfExists(src: string, dest: string): Promise { - if (!(await pathExists(src))) return false - await copyFile(src, dest) - return true -} - -/** - * Copy directory contents if source exists. - * @param src - Source directory path - * @param dest - Destination directory path - * @returns true if copy was performed, false if source doesn't exist - */ -async function copyDirIfExists(src: string, dest: string): Promise { - if (!(await pathExists(src))) return false - await cp(src, dest, { recursive: true }) - return true -} - -interface ForkResult { - forkedSession: { id: string } - rootSessionId: string - planCopied: boolean - delegationsCopied: boolean -} - -/** - * Fork a session and copy associated plans/delegations. - * Cleans up forked session on failure (atomic operation). - */ -async function forkWithContext( - client: OpencodeClient, - sessionId: string, - projectId: string, - getRootSessionIdFn: (sessionId: string) => Promise, -): Promise { - // Guard clauses (Law 1) - if (!client) throw new WorktreeError("client is required", "forkWithContext") - if (!sessionId) throw new WorktreeError("sessionId is required", "forkWithContext") - if (!projectId) throw new WorktreeError("projectId is required", "forkWithContext") - - // Get root session ID with error wrapping - let rootSessionId: string - try { - rootSessionId = await getRootSessionIdFn(sessionId) - } catch (e) { - throw new WorktreeError("Failed to get root session ID", "forkWithContext", e) - } - - // Fork session - const forkedSessionResponse = await client.session.fork({ - path: { id: sessionId }, - body: {}, - }) - const forkedSession = forkedSessionResponse.data - if (!forkedSession?.id) { - throw new WorktreeError("Failed to fork session: no session data returned", "forkWithContext") - } - - // Copy data with cleanup on failure - let planCopied = false - let delegationsCopied = false - - try { - const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") - const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") - - const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) - const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) - - await mkdir(destWorkspaceDir, { recursive: true }) - await mkdir(destDelegationsDir, { recursive: true }) - - // Copy plan - const srcPlan = path.join(workspaceBase, projectId, rootSessionId, "plan.md") - const destPlan = path.join(destWorkspaceDir, "plan.md") - planCopied = await copyIfExists(srcPlan, destPlan) - - // Copy delegations - const srcDelegations = path.join(delegationsBase, projectId, rootSessionId) - delegationsCopied = await copyDirIfExists(srcDelegations, destDelegationsDir) - } catch (error) { - client.app - .log({ - body: { - service: "worktree", - level: "error", - message: `forkWithContext: Copy failed, cleaning up forked session: ${error}`, - }, - }) - .catch(() => {}) - // Clean up orphaned directories - const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") - const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") - const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) - const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) - await rm(destWorkspaceDir, { recursive: true, force: true }).catch((e) => { - client.app - .log({ - body: { - service: "worktree", - level: "error", - message: `forkWithContext: Failed to clean up workspace dir ${destWorkspaceDir}: ${e}`, - }, - }) - .catch(() => {}) - }) - await rm(destDelegationsDir, { recursive: true, force: true }).catch((e) => { - client.app - .log({ - body: { - service: "worktree", - level: "error", - message: `forkWithContext: Failed to clean up delegations dir ${destDelegationsDir}: ${e}`, - }, - }) - .catch(() => {}) - }) - await client.session.delete({ path: { id: forkedSession.id } }).catch((e) => { - client.app - .log({ - body: { - service: "worktree", - level: "error", - message: `forkWithContext: Failed to clean up forked session ${forkedSession.id}: ${e}`, - }, - }) - .catch(() => {}) - }) - throw new WorktreeError( - `Failed to copy session data: ${error instanceof Error ? error.message : String(error)}`, - "forkWithContext", - error, - ) - } - - return { forkedSession, rootSessionId, planCopied, delegationsCopied } -} - -// ============================================================================= -// MODULE-LEVEL STATE -// ============================================================================= - -/** Database instance - initialized once per plugin lifecycle */ -let db: Database | null = null - -/** Project root path - stored on first initialization */ -let projectRoot: string | null = null - -/** Flag to prevent duplicate cleanup handler registration */ -let cleanupRegistered = false - -/** - * Register process cleanup handlers for graceful database shutdown. - * Ensures WAL checkpoint and proper close on process termination. - * - * NOTE: process.once() is an EventEmitter method that never throws. - * The boolean guard is defense-in-depth for idempotency, not error recovery. - * - * @param database - The database instance to clean up - */ -function registerCleanupHandlers(database: Database): void { - if (cleanupRegistered) return // Early exit guard - cleanupRegistered = true - - const cleanup = () => { - try { - database.exec("PRAGMA wal_checkpoint(TRUNCATE)") - database.close() - } catch { - // Best effort cleanup - process is exiting anyway - } - } - - process.once("SIGTERM", cleanup) - process.once("SIGINT", cleanup) - process.once("beforeExit", cleanup) -} - -/** - * Get the database instance, initializing if needed. - * Includes retry logic for transient initialization failures. - * - * @returns Database instance - * @throws {Error} if initialization fails after all retries - */ -async function getDb(log: Logger): Promise { - if (db) return db - - if (!projectRoot) { - throw new Error("Database not initialized: projectRoot not set. Call initDb() first.") - } - - let lastError: Error | null = null - - for (let attempt = 1; attempt <= DB_MAX_RETRIES; attempt++) { - try { - db = await initStateDb(projectRoot) - registerCleanupHandlers(db) - return db - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)) - log.warn(`Database init attempt ${attempt}/${DB_MAX_RETRIES} failed: ${lastError.message}`) - - if (attempt < DB_MAX_RETRIES) { - Bun.sleepSync(DB_RETRY_DELAY_MS) - } - } - } - - throw new Error( - `Failed to initialize database after ${DB_MAX_RETRIES} attempts: ${lastError?.message}`, - ) -} - -/** - * Initialize the database with the project root path. - * Must be called once before any getDb() calls. - */ -async function initDb(root: string, log: Logger): Promise { - projectRoot = root - return getDb(log) -} - -// ============================================================================= -// GIT MODULE -// ============================================================================= - -/** - * Execute a git command safely using Bun.spawn with explicit array. - * Avoids shell interpolation entirely by passing args as array. - */ -async function git(args: string[], cwd: string): Promise> { - try { - const proc = Bun.spawn(["git", ...args], { - cwd, - stdout: "pipe", - stderr: "pipe", - }) - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - if (exitCode !== 0) { - return Result.err(stderr.trim() || `git ${args[0]} failed`) - } - return Result.ok(stdout.trim()) - } catch (error) { - return Result.err(error instanceof Error ? error.message : String(error)) - } -} - -async function branchExists(cwd: string, branch: string): Promise { - const result = await git(["rev-parse", "--verify", branch], cwd) - return result.ok -} - -async function createWorktree( - repoRoot: string, - branch: string, - baseBranch?: string, -): Promise> { - const worktreePath = await getWorktreePath(repoRoot, branch) - - // Ensure parent directory exists - await mkdir(path.dirname(worktreePath), { recursive: true }) - - const exists = await branchExists(repoRoot, branch) - - if (exists) { - // Checkout existing branch into worktree - const result = await git(["worktree", "add", worktreePath, branch], repoRoot) - return result.ok ? Result.ok(worktreePath) : result - } else { - // Create new branch from base - const base = baseBranch ?? "HEAD" - const result = await git(["worktree", "add", "-b", branch, worktreePath, base], repoRoot) - return result.ok ? Result.ok(worktreePath) : result - } -} - -async function removeWorktree( - repoRoot: string, - worktreePath: string, -): Promise> { - const result = await git(["worktree", "remove", "--force", worktreePath], repoRoot) - return result.ok ? Result.ok(undefined) : Result.err(result.error) -} - -// ============================================================================= -// FILE SYNC MODULE -// ============================================================================= - -/** - * Validate that a path is safe (no escape from base directory) - */ -function isPathSafe(filePath: string, baseDir: string, log: Logger): boolean { - // Reject absolute paths - if (path.isAbsolute(filePath)) { - log.warn(`[worktree] Rejected absolute path: ${filePath}`) - return false - } - // Reject obvious path traversal - if (filePath.includes("..")) { - log.warn(`[worktree] Rejected path traversal: ${filePath}`) - return false - } - // Verify resolved path stays within base directory - const resolved = path.resolve(baseDir, filePath) - if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) { - log.warn(`[worktree] Path escapes base directory: ${filePath}`) - return false - } - return true -} - -/** - * Copy files from source directory to target directory. - * Skips missing files silently (production pattern). - */ -async function copyFiles( - sourceDir: string, - targetDir: string, - files: string[], - log: Logger, -): Promise { - for (const file of files) { - if (!isPathSafe(file, sourceDir, log)) continue - - const sourcePath = path.join(sourceDir, file) - const targetPath = path.join(targetDir, file) - - try { - const sourceFile = Bun.file(sourcePath) - if (!(await sourceFile.exists())) { - log.debug(`[worktree] Skipping missing file: ${file}`) - continue - } - - // Ensure target directory exists - const targetFileDir = path.dirname(targetPath) - await mkdir(targetFileDir, { recursive: true }) - - // Copy file - await Bun.write(targetPath, sourceFile) - log.info(`[worktree] Copied: ${file}`) - } catch (error) { - const isNotFound = - error instanceof Error && - (error.message.includes("ENOENT") || error.message.includes("no such file")) - if (isNotFound) { - log.debug(`[worktree] Skipping missing: ${file}`) - } else { - log.warn(`[worktree] Failed to copy ${file}: ${error}`) - } - } - } -} - -/** - * Create symlinks for directories from source to target. - * Uses absolute paths for symlink targets. - */ -async function symlinkDirs( - sourceDir: string, - targetDir: string, - dirs: string[], - log: Logger, -): Promise { - for (const dir of dirs) { - if (!isPathSafe(dir, sourceDir, log)) continue - - const sourcePath = path.join(sourceDir, dir) - const targetPath = path.join(targetDir, dir) - - try { - // Check if source directory exists - const fileStat = await stat(sourcePath).catch(() => null) - if (!fileStat || !fileStat.isDirectory()) { - log.debug(`[worktree] Skipping missing directory: ${dir}`) - continue - } - - // Ensure parent directory exists - const targetParentDir = path.dirname(targetPath) - await mkdir(targetParentDir, { recursive: true }) - - // Remove existing target if it exists (might be empty dir from git) - await rm(targetPath, { recursive: true, force: true }) - - // Create symlink (use absolute path for source) - await symlink(sourcePath, targetPath, "dir") - log.info(`[worktree] Symlinked: ${dir}`) - } catch (error) { - log.warn(`[worktree] Failed to symlink ${dir}: ${error}`) - } - } -} - -/** - * Run hook commands in the worktree directory. - */ -async function runHooks(cwd: string, commands: string[], log: Logger): Promise { - for (const command of commands) { - log.info(`[worktree] Running hook: ${command}`) - try { - // Use shell to properly handle quoted arguments and complex commands - const result = Bun.spawnSync(["bash", "-c", command], { - cwd, - stdout: "inherit", - stderr: "pipe", - }) - if (result.exitCode !== 0) { - const stderr = result.stderr?.toString() || "" - log.warn( - `[worktree] Hook failed (exit ${result.exitCode}): ${command}${stderr ? `\n${stderr}` : ""}`, - ) - } - } catch (error) { - log.warn(`[worktree] Hook error: ${error}`) - } - } -} - -/** - * Load worktree-specific configuration from .opencode/worktree.jsonc - * Auto-creates config file with helpful defaults if it doesn't exist. - */ -async function loadWorktreeConfig(directory: string, log: Logger): Promise { - const configPath = path.join(directory, ".opencode", "worktree.jsonc") - - try { - const file = Bun.file(configPath) - if (!(await file.exists())) { - // Auto-create config with helpful defaults and comments - const defaultConfig = `{ - "$schema": "https://registry.kdco.dev/schemas/worktree.json", - - // Worktree plugin configuration - // Documentation: https://github.com/kdcokenny/ocx - - "sync": { - // Files to copy from main worktree to new worktrees - // Example: [".env", ".env.local", "dev.sqlite"] - "copyFiles": [], - - // Directories to symlink (saves disk space) - // Example: ["node_modules"] - "symlinkDirs": [], - - // Patterns to exclude from copying - "exclude": [] - }, - - "hooks": { - // Commands to run after worktree creation - // Example: ["pnpm install", "docker compose up -d"] - "postCreate": [], - - // Commands to run before worktree deletion - // Example: ["docker compose down"] - "preDelete": [] - } -} -` - // Ensure .opencode directory exists - await mkdir(path.join(directory, ".opencode"), { recursive: true }) - await Bun.write(configPath, defaultConfig) - log.info(`[worktree] Created default config: ${configPath}`) - return worktreeConfigSchema.parse({}) - } - - const content = await file.text() - // Use proper JSONC parser (handles comments in strings correctly) - const parsed = parseJsonc(content) - if (parsed === undefined) { - log.error(`[worktree] Invalid worktree.jsonc syntax`) - return worktreeConfigSchema.parse({}) - } - return worktreeConfigSchema.parse(parsed) - } catch (error) { - log.warn(`[worktree] Failed to load config: ${error}`) - return worktreeConfigSchema.parse({}) - } -} - -// ============================================================================= -// PLUGIN ENTRY -// ============================================================================= - -export const WorktreePlugin: Plugin = async (ctx) => { - const { directory, client } = ctx - - const log = { - debug: (msg: string) => - client.app - .log({ body: { service: "worktree", level: "debug", message: msg } }) - .catch(() => {}), - info: (msg: string) => - client.app - .log({ body: { service: "worktree", level: "info", message: msg } }) - .catch(() => {}), - warn: (msg: string) => - client.app - .log({ body: { service: "worktree", level: "warn", message: msg } }) - .catch(() => {}), - error: (msg: string) => - client.app - .log({ body: { service: "worktree", level: "error", message: msg } }) - .catch(() => {}), - } - - // Initialize SQLite database - const database = await initDb(directory, log) - - return { - tool: { - worktree_create: tool({ - description: - "Create a new git worktree for isolated development. A new terminal will open with OpenCode in the worktree.", - args: { - branch: tool.schema - .string() - .describe("Branch name for the worktree (e.g., 'feature/dark-mode')"), - baseBranch: tool.schema - .string() - .optional() - .describe("Base branch to create from (defaults to HEAD)"), - }, - async execute(args, toolCtx) { - // Validate branch name at boundary - const branchResult = branchNameSchema.safeParse(args.branch) - if (!branchResult.success) { - return `❌ Invalid branch name: ${branchResult.error.issues[0]?.message}` - } - - // Validate base branch name at boundary - if (args.baseBranch) { - const baseResult = branchNameSchema.safeParse(args.baseBranch) - if (!baseResult.success) { - return `❌ Invalid base branch name: ${baseResult.error.issues[0]?.message}` - } - } - - // Create worktree - const result = await createWorktree(directory, args.branch, args.baseBranch) - if (!result.ok) { - return `Failed to create worktree: ${result.error}` - } - - const worktreePath = result.value - - // Sync files from main worktree - const worktreeConfig = await loadWorktreeConfig(directory, log) - const mainWorktreePath = directory // The repo root is the main worktree - - // Copy files - if (worktreeConfig.sync.copyFiles.length > 0) { - await copyFiles(mainWorktreePath, worktreePath, worktreeConfig.sync.copyFiles, log) - } - - // Symlink directories - if (worktreeConfig.sync.symlinkDirs.length > 0) { - await symlinkDirs(mainWorktreePath, worktreePath, worktreeConfig.sync.symlinkDirs, log) - } - - // Run postCreate hooks - if (worktreeConfig.hooks.postCreate.length > 0) { - await runHooks(worktreePath, worktreeConfig.hooks.postCreate, log) - } - - // Fork session with context (replaces --session resume) - const projectId = await getProjectId(worktreePath, client) - const { forkedSession, planCopied, delegationsCopied } = await forkWithContext( - client, - toolCtx.sessionID, - projectId, - async (sid) => { - // Walk up parentID chain to find root session - let currentId = sid - for (let depth = 0; depth < MAX_SESSION_CHAIN_DEPTH; depth++) { - const session = await client.session.get({ path: { id: currentId } }) - if (!session.data?.parentID) return currentId - currentId = session.data.parentID - } - return currentId - }, - ) - - log.debug( - `Forked session ${forkedSession.id}, plan: ${planCopied}, delegations: ${delegationsCopied}`, - ) - - // Spawn worktree with forked session - const command = buildWorktreeCommand(forkedSession.id) - const terminalResult = await openTerminal(worktreePath, command, args.branch) - - if (!terminalResult.success) { - log.warn(`[worktree] Failed to open terminal: ${terminalResult.error}`) - } - - // Record session for tracking (used by delete flow) - addSession(database, { - id: forkedSession.id, - branch: args.branch, - path: worktreePath, - createdAt: new Date().toISOString(), - }) - - return `Worktree created at ${worktreePath}\n\nA new terminal has been opened with OpenCode.` - }, - }), - - worktree_delete: tool({ - description: - "Delete the current worktree and clean up. Changes will be committed before removal.", - args: { - reason: tool.schema - .string() - .describe("Brief explanation of why you are calling this tool"), - }, - async execute(_args, toolCtx) { - // Find current session's worktree - const session = getSession(database, toolCtx?.sessionID ?? "") - if (!session) { - return `No worktree associated with this session` - } - - // Set pending delete for session.idle (atomic operation) - setPendingDelete(database, { branch: session.branch, path: session.path }, client) - - return `Worktree marked for cleanup. It will be removed when this session ends.` - }, - }), - }, - - event: async ({ event }: { event: Event }): Promise => { - if (event.type !== "session.idle") return - - // Handle pending delete - const pendingDelete = getPendingDelete(database) - if (pendingDelete) { - const { path: worktreePath, branch } = pendingDelete - - // Run preDelete hooks before cleanup - const config = await loadWorktreeConfig(directory, log) - if (config.hooks.preDelete.length > 0) { - await runHooks(worktreePath, config.hooks.preDelete, log) - } - - // Commit any uncommitted changes - const addResult = await git(["add", "-A"], worktreePath) - if (!addResult.ok) log.warn(`[worktree] git add failed: ${addResult.error}`) - - const commitResult = await git( - ["commit", "-m", "chore(worktree): session snapshot", "--allow-empty"], - worktreePath, - ) - if (!commitResult.ok) log.warn(`[worktree] git commit failed: ${commitResult.error}`) - - // Remove worktree - const removeResult = await removeWorktree(directory, worktreePath) - if (!removeResult.ok) { - log.warn(`[worktree] Failed to remove worktree: ${removeResult.error}`) - } - - // Clear pending delete atomically - clearPendingDelete(database) - - // Remove session from database - removeSession(database, branch) - } - }, - } -} - -export default WorktreePlugin diff --git a/workers/kdco-registry/files/plugin/worktree.ts b/workers/kdco-registry/files/plugin/worktree.ts index 8a44f0c6..f3bee3ef 100644 --- a/workers/kdco-registry/files/plugin/worktree.ts +++ b/workers/kdco-registry/files/plugin/worktree.ts @@ -30,7 +30,7 @@ interface Logger { import { parse as parseJsonc } from "jsonc-parser" import { z } from "zod" import { getProjectId } from "./kdco-primitives/get-project-id" -import { escapeBash } from "./kdco-primitives/shell" + import { addSession, clearPendingDelete, @@ -67,26 +67,28 @@ function parseOcxContext(): OcxContext { ) } - const profile = process.env.OCX_PROFILE || undefined + const profile = process.env.OCX_PROFILE?.trim() || undefined return { mode: "ocx", bin, profile } } /** - * Build the command to spawn OpenCode in a worktree. - * Uses OCX with profile if running under OCX, otherwise plain opencode. + * Build argv array for spawning OpenCode in a worktree. + * Returns structured data - terminal layer handles shell escaping. */ -function buildWorktreeCommand(sessionId: string): string { +function buildWorktreeArgv(sessionId: string): string[] { const ctx = parseOcxContext() - const escapedSessionId = escapeBash(sessionId) if (ctx.mode === "ocx") { - const escapedBin = escapeBash(ctx.bin) - const profileArg = ctx.profile ? ` -p "${escapeBash(ctx.profile)}"` : "" - return `"${escapedBin}" opencode${profileArg} --session "${escapedSessionId}"` + return [ + ctx.bin, + "opencode", + ...(ctx.profile ? ["-p", ctx.profile] : []), + "--session", + sessionId, + ] } else { const bin = process.env.OPENCODE_BIN ?? "opencode" - const escapedBin = escapeBash(bin) - return `"${escapedBin}" --session "${escapedSessionId}"` + return [bin, "--session", sessionId] } } @@ -820,8 +822,8 @@ export const WorktreePlugin: Plugin = async (ctx) => { ) // Spawn worktree with forked session - const command = buildWorktreeCommand(forkedSession.id) - const terminalResult = await openTerminal(worktreePath, command, args.branch) + const argv = buildWorktreeArgv(forkedSession.id) + const terminalResult = await openTerminal(worktreePath, argv, args.branch) if (!terminalResult.success) { log.warn(`[worktree] Failed to open terminal: ${terminalResult.error}`) diff --git a/workers/kdco-registry/files/plugin/worktree/terminal.ts b/workers/kdco-registry/files/plugin/worktree/terminal.ts index 8e77ac30..aab7d66a 100644 --- a/workers/kdco-registry/files/plugin/worktree/terminal.ts +++ b/workers/kdco-registry/files/plugin/worktree/terminal.ts @@ -23,6 +23,50 @@ import { Mutex, } from "../kdco-primitives" +// ============================================================================= +// ARGV TO SHELL COMMAND HELPERS +// ============================================================================= + +/** + * Convert argv array to a properly-escaped bash command string. + * Validates that arguments don't contain characters that can't be safely escaped. + */ +function argvToBashCommand(argv: string[]): string { + return argv + .map((arg) => { + // Null bytes can't exist in bash arguments + if (arg.includes("\0")) { + throw new Error("Cannot escape argument containing null bytes for bash") + } + return `"${escapeBash(arg)}"` + }) + .join(" ") +} + +/** + * Convert argv array to a properly-escaped Windows batch command string. + * Validates that arguments don't contain characters that can't be safely escaped in cmd.exe. + */ +function argvToBatchCommand(argv: string[]): string { + return argv + .map((arg) => { + // Reject arguments with newlines (can't be safely escaped in batch) + if (arg.includes("\n") || arg.includes("\r")) { + throw new Error( + `Cannot safely escape argument containing newlines for Windows batch: ${arg.slice(0, 50)}...`, + ) + } + + // For cmd.exe, embedded quotes are problematic. The safest approach is to: + // 1. Replace embedded " with "" (batch quote escaping) + // 2. Then wrap the whole thing in quotes + // 3. Then apply escapeBatch for other metacharacters + const quoteSafeArg = arg.replace(/"/g, '""') + return `"${escapeBatch(quoteSafeArg)}"` + }) + .join(" ") +} + // ============================================================================= // TEMP SCRIPT HELPER // ============================================================================= @@ -255,10 +299,10 @@ export async function openTmuxWindow(options: { if (command) { const scriptPath = path.join(getTempDir(), `worktree-${Bun.randomUUIDv7()}.sh`) const escapedCwd = escapeBash(cwd) - const escapedCommand = escapeBash(command) + // Command is already a valid shell command (either passed as string or converted from argv) const scriptContent = wrapWithSelfCleanup( `cd "${escapedCwd}" || exit 1 -${escapedCommand} +${command} exec $SHELL`, ) await Bun.write(scriptPath, scriptContent) @@ -340,11 +384,9 @@ export async function openMacOSTerminal(cwd: string, command?: string): Promise< } const escapedCwd = escapeBash(cwd) - const escapedCommand = command ? escapeBash(command) : "" + // Command is already a valid shell command (either passed as string or converted from argv) const scriptContent = wrapWithSelfCleanup( - command - ? `cd "${escapedCwd}" && ${escapedCommand}\nexec bash` - : `cd "${escapedCwd}"\nexec bash`, + command ? `cd "${escapedCwd}" && ${command}\nexec bash` : `cd "${escapedCwd}"\nexec bash`, ) const terminal = detectCurrentMacTerminal() @@ -368,7 +410,7 @@ export async function openMacOSTerminal(cwd: string, command?: string): Promise< "-e", "bash", "-c", - command ? `cd "${escapedCwd}" && ${escapedCommand}` : `cd "${escapedCwd}"`, + command ? `cd "${escapedCwd}" && ${command}` : `cd "${escapedCwd}"`, ], { detached: true, @@ -573,11 +615,9 @@ export async function openLinuxTerminal(cwd: string, command?: string): Promise< } const escapedCwd = escapeBash(cwd) - const escapedCommand = command ? escapeBash(command) : "" + // Command is already a valid shell command (either passed as string or converted from argv) const scriptContent = wrapWithSelfCleanup( - command - ? `cd "${escapedCwd}" && ${escapedCommand}\nexec bash` - : `cd "${escapedCwd}"\nexec bash`, + command ? `cd "${escapedCwd}" && ${command}\nexec bash` : `cd "${escapedCwd}"\nexec bash`, ) // Write script directly - it self-deletes via trap @@ -805,11 +845,9 @@ export async function openWindowsTerminal(cwd: string, command?: string): Promis } const escapedCwd = escapeBatch(cwd) - const escapedCommand = command ? escapeBatch(command) : "" + // Command is already a valid shell command (either passed as string or converted from argv) const scriptContent = wrapBatchWithSelfCleanup( - command - ? `cd /d "${escapedCwd}"\r\n${escapedCommand}\r\ncmd /k` - : `cd /d "${escapedCwd}"\r\ncmd /k`, + command ? `cd /d "${escapedCwd}"\r\n${command}\r\ncmd /k` : `cd /d "${escapedCwd}"\r\ncmd /k`, ) // Write script directly - it self-deletes via goto trick @@ -887,11 +925,9 @@ export async function openWSLTerminal(cwd: string, command?: string): Promise { const terminalType = detectTerminalType() @@ -973,21 +1009,25 @@ export async function openTerminal( return openTmuxWindow({ windowName: windowName || "worktree", cwd, - command, + command: Array.isArray(command) ? argvToBashCommand(command) : command, }) case "macos": - return openMacOSTerminal(cwd, command) + return openMacOSTerminal(cwd, Array.isArray(command) ? argvToBashCommand(command) : command) case "windows": - // Check if we're in WSL + // Check if we're in WSL (bash-based, not batch) if (process.platform === "linux" && isInsideWSL()) { - return openWSLTerminal(cwd, command) + return openWSLTerminal(cwd, Array.isArray(command) ? argvToBashCommand(command) : command) } - return openWindowsTerminal(cwd, command) + // Native Windows uses batch escaping + return openWindowsTerminal( + cwd, + Array.isArray(command) ? argvToBatchCommand(command) : command, + ) case "linux-desktop": - return openLinuxTerminal(cwd, command) + return openLinuxTerminal(cwd, Array.isArray(command) ? argvToBashCommand(command) : command) default: return { success: false, error: `Unsupported terminal type: ${terminalType}` } From a6b7c7e1d8461d6648bf0aac854cc5c9132daa60 Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 22 Jan 2026 13:10:32 -0500 Subject: [PATCH 3/3] fix(worktree): use correct quoted-context escaping for Windows batch escapeBatch() is for unquoted contexts and adds caret escapes. Inside double quotes, only % and " need escaping - metacharacters like &, <, >, |, ^ are already protected by the quotes. --- .../files/plugin/worktree/terminal.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/workers/kdco-registry/files/plugin/worktree/terminal.ts b/workers/kdco-registry/files/plugin/worktree/terminal.ts index aab7d66a..ee098a86 100644 --- a/workers/kdco-registry/files/plugin/worktree/terminal.ts +++ b/workers/kdco-registry/files/plugin/worktree/terminal.ts @@ -16,7 +16,6 @@ import type { OpencodeClient } from "../kdco-primitives" import { escapeAppleScript, escapeBash, - escapeBatch, getTempDir, isInsideTmux, logWarn, @@ -45,7 +44,7 @@ function argvToBashCommand(argv: string[]): string { /** * Convert argv array to a properly-escaped Windows batch command string. - * Validates that arguments don't contain characters that can't be safely escaped in cmd.exe. + * Uses quoted-context escaping where only % and " need special handling. */ function argvToBatchCommand(argv: string[]): string { return argv @@ -57,12 +56,12 @@ function argvToBatchCommand(argv: string[]): string { ) } - // For cmd.exe, embedded quotes are problematic. The safest approach is to: - // 1. Replace embedded " with "" (batch quote escaping) - // 2. Then wrap the whole thing in quotes - // 3. Then apply escapeBatch for other metacharacters - const quoteSafeArg = arg.replace(/"/g, '""') - return `"${escapeBatch(quoteSafeArg)}"` + // For quoted batch arguments: + // 1. Escape percent signs: % → %% (special even inside quotes) + // 2. Escape embedded quotes: " → "" (batch quote escaping) + // Note: Other metacharacters (&, <, >, |, ^) are protected by the surrounding quotes + const escaped = arg.replace(/%/g, "%%").replace(/"/g, '""') + return `"${escaped}"` }) .join(" ") } @@ -844,7 +843,9 @@ export async function openWindowsTerminal(cwd: string, command?: string): Promis return { success: false, error: "Working directory is required" } } - const escapedCwd = escapeBatch(cwd) + // For quoted batch arguments, only % and " need escaping + // (other metacharacters are protected by the surrounding quotes) + const escapedCwd = cwd.replace(/%/g, "%%").replace(/"/g, '""') // Command is already a valid shell command (either passed as string or converted from argv) const scriptContent = wrapBatchWithSelfCleanup( command ? `cd /d "${escapedCwd}"\r\n${command}\r\ncmd /k` : `cd /d "${escapedCwd}"\r\ncmd /k`,