diff --git a/AGENTS.md b/AGENTS.md index 19c4bf6..9df24c1 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 7814f6b..58d8d37 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 a26755d..ec9a918 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 d6d3e7c..f3bee3e 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 { addSession, clearPendingDelete, @@ -43,6 +43,55 @@ 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?.trim() || undefined + return { mode: "ocx", bin, profile } +} + +/** + * Build argv array for spawning OpenCode in a worktree. + * Returns structured data - terminal layer handles shell escaping. + */ +function buildWorktreeArgv(sessionId: string): string[] { + const ctx = parseOcxContext() + + if (ctx.mode === "ocx") { + return [ + ctx.bin, + "opencode", + ...(ctx.profile ? ["-p", ctx.profile] : []), + "--session", + sessionId, + ] + } else { + const bin = process.env.OPENCODE_BIN ?? "opencode" + return [bin, "--session", sessionId] + } +} + /** Maximum retries for database initialization */ const DB_MAX_RETRIES = 3 @@ -773,11 +822,8 @@ export const WorktreePlugin: Plugin = async (ctx) => { ) // Spawn worktree with forked session - const terminalResult = await openTerminal( - worktreePath, - `opencode --session ${forkedSession.id}`, - 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 8e77ac3..ee098a8 100644 --- a/workers/kdco-registry/files/plugin/worktree/terminal.ts +++ b/workers/kdco-registry/files/plugin/worktree/terminal.ts @@ -16,13 +16,56 @@ import type { OpencodeClient } from "../kdco-primitives" import { escapeAppleScript, escapeBash, - escapeBatch, getTempDir, isInsideTmux, logWarn, 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. + * Uses quoted-context escaping where only % and " need special handling. + */ +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 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(" ") +} + // ============================================================================= // TEMP SCRIPT HELPER // ============================================================================= @@ -255,10 +298,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 +383,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 +409,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 +614,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 @@ -804,12 +843,12 @@ export async function openWindowsTerminal(cwd: string, command?: string): Promis return { success: false, error: "Working directory is required" } } - const escapedCwd = escapeBatch(cwd) - const escapedCommand = command ? escapeBatch(command) : "" + // 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${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 +926,9 @@ export async function openWSLTerminal(cwd: string, command?: string): Promise { const terminalType = detectTerminalType() @@ -973,21 +1010,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}` }