Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>`** - 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

Expand Down Expand Up @@ -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 <id>` |
| `ocx opencode` | `ocx opencode --session <id>` |
| `ocx opencode -p work` | `ocx opencode -p work --session <id>` |

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:
Expand Down
34 changes: 34 additions & 0 deletions facades/opencode-worktree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profile>`), the worktree plugin automatically preserves your profile context.

### How It Works

| Launch Command | Worktree Spawns |
|----------------|-----------------|
| `opencode` | `opencode --session <id>` |
| `ocx opencode` | `ocx opencode --session <id>` |
| `ocx opencode -p work` | `ocx opencode -p work --session <id>` |

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:
Expand Down Expand Up @@ -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 <profile>`, any worktrees you create will automatically use the same profile. See [OCX Profile Support](#ocx-profile-support) for details.

## Limitations

### Security
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/commands/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down Expand Up @@ -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",
Expand Down
58 changes: 52 additions & 6 deletions workers/kdco-registry/files/plugin/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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}`)
Expand Down
94 changes: 67 additions & 27 deletions workers/kdco-registry/files/plugin/worktree/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -887,11 +925,9 @@ export async function openWSLTerminal(cwd: string, command?: string): Promise<Te
}

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
Expand Down Expand Up @@ -957,13 +993,13 @@ export async function openWSLTerminal(cwd: string, command?: string): Promise<Te
* Automatically detects the best terminal type and method.
*
* @param cwd - Working directory for the terminal
* @param command - Optional command to execute
* @param command - Optional command to execute (string or argv array)
* @param windowName - Optional window name (used for tmux)
* @returns Success status and optional error message
*/
export async function openTerminal(
cwd: string,
command?: string,
command?: string | string[],
windowName?: string,
): Promise<TerminalResult> {
const terminalType = detectTerminalType()
Expand All @@ -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}` }
Expand Down