Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 29 additions & 0 deletions backends/opencode/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook entry for OpenCode.
#
# Single RPC into the in-process orchestrator (lua/code-preview/post_tool.lua).
# The orchestrator clears the changes registry, closes any open preview for
# the affected file, and refreshes neo-tree.
#
# When Neovim is unreachable, the shim abstains silently (exit 0).

# No `set -e`: abstain on jq/nvim_call failure rather than surfacing a
# hook failure to the agent. See the matching note in code-preview-diff.sh.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$SCRIPT_DIR/../../bin"

INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$r, $b]' 2>/dev/null || true)"
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.post_tool handle "$ARGS" >/dev/null
36 changes: 36 additions & 0 deletions backends/opencode/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook entry for OpenCode.
#
# After issue #47 phase 3, this shim is a thin wrapper around a single RPC
# into the in-process orchestrator (lua/code-preview/pre_tool/init.lua). The
# TS plugin (backends/opencode/index.ts) collects OpenCode's {tool, args,
# directory} into a JSON payload, pipes it to this shim, and awaits the
# result. Lua-side normalisation maps the camelCase/lowercase shape into the
# canonical form. See docs/adr/0006-opencode-defers-os-independence-to-46.md.
#
# When Neovim is unreachable, the shim abstains silently (exit 0).

# No `set -e`: the shim is the boundary between the agent and the plugin.
# When jq fails on a malformed payload or nvim_call returns rc=2, we want
# to exit 0 (abstain) so the agent falls back to its native flow rather
# than seeing a hook failure.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$SCRIPT_DIR/../../bin"

INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

# Socket discovery — silent failure is fine, we abstain below.
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$r, $b]' 2>/dev/null || true)"
# Malformed payload (jq couldn't parse) — abstain silently.
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.pre_tool handle "$ARGS"
165 changes: 79 additions & 86 deletions backends/opencode/index.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,98 @@
// index.ts — OpenCode plugin entry point
// index.ts — OpenCode plugin entry point.
//
// Thin adapter that translates OpenCode's hook format to the normalized
// JSON format and delegates to the unified core shell scripts.
// After issue #47 phase 3, this plugin is a thin transport layer. It collects
// OpenCode's {tool, args, directory} from each hook firing, JSON-encodes it,
// and pipes it into the shell shim under backends/opencode/, which performs
// socket discovery and RPCs the in-process orchestrator. Tool-name and
// camelCase→snake_case mapping live Lua-side (pre_tool.normalisers.opencode).
//
// See docs/adr/0006-opencode-defers-os-independence-to-46.md for why this
// keeps the bash shim instead of speaking nvim RPC directly from TS.

import type { Plugin } from "@opencode-ai/plugin"
import { execSync } from "child_process"
import { readFileSync } from "fs"
import { existsSync, readFileSync } from "fs"
import { resolve, dirname } from "path"
import { fileURLToPath } from "url"

// ── Resolve path to bin/ directory ───────────────────────────────

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

function binDir(): string {
// When installed to .opencode/plugins/, bin-path.txt contains the
// absolute path to the plugin's bin/ directory.
// ── Shim path resolution ─────────────────────────────────────────
// bin-path.txt was historically written by the installer pointing at the
// plugin's bin/ directory. Phase 3 changes its meaning to the plugin root
// (so we can locate backends/opencode/ alongside bin/). For users who
// upgrade without re-running :CodePreviewInstallOpenCodeHooks, fall back to
// the legacy interpretation by stepping up one directory.
//
// Transitional for v2.3; remove the legacy fallback in v3.0.

function resolveShim(name: string): string | null {
const root = readBinPath()
if (!root) return null
const primary = resolve(root, "backends/opencode", name)
if (existsSync(primary)) return primary
const legacy = resolve(root, "..", "backends/opencode", name)
if (existsSync(legacy)) return legacy
return null
}

function readBinPath(): string | null {
try {
return readFileSync(resolve(__dirname, "bin-path.txt"), "utf-8").trim()
} catch {
// Fallback for development: resolve relative to plugin source
return resolve(__dirname, "../../bin")
// Development fallback: plugin source lives at <root>/backends/opencode/.
return resolve(__dirname, "../..")
}
}

// ── Tool name mapping ────────────────────────────────────────────

const TOOL_MAP: Record<string, string> = {
edit: "Edit",
write: "Write",
multiedit: "MultiEdit",
bash: "Bash",
apply_patch: "ApplyPatch",
}

// ── Format translation ──────────────────────────────────────────

function toNormalizedJson(
tool: string,
args: Record<string, any>,
cwd: string,
): string | null {
const toolName = TOOL_MAP[tool]
if (!toolName) return null

const toolInput: Record<string, any> = {}

// Resolve file path (OpenCode may pass relative paths)
if (args.filePath !== undefined) {
const p = args.filePath as string
toolInput.file_path = p && !p.startsWith("/") ? resolve(cwd, p) : p
}

// Edit fields (camelCase → snake_case)
if (args.oldString !== undefined) toolInput.old_string = args.oldString
if (args.newString !== undefined) toolInput.new_string = args.newString
if (args.replaceAll !== undefined) toolInput.replace_all = args.replaceAll

// Write fields
if (args.content !== undefined) toolInput.content = args.content

// MultiEdit fields
if (args.edits !== undefined) {
toolInput.edits = (args.edits as any[]).map((e) => ({
old_string: e.oldString,
new_string: e.newString,
}))
// ── Tool allowlist ───────────────────────────────────────────────
// OpenCode tools as of 2026-05-19 the plugin previews: edit, write,
// multiedit, bash, apply_patch. Tools we deliberately ignore (fire-and-
// forget reads): read, glob, grep, list, todoread, todowrite, webfetch,
// websearch, task. Short-circuiting these here saves a bash fork + RPC per
// firing — OpenCode reads/greps prolifically.
//
// Symptom of forgetting to add a new structured-edit tool: no diff appears
// for it. Update this set and pre_tool.normalisers.opencode's tool map
// together.
const PREVIEW_TOOLS = new Set(["edit", "write", "multiedit", "bash", "apply_patch"])

// ── Shim invocation ──────────────────────────────────────────────

function runShim(scriptName: string, payload: object): void {
const shim = resolveShim(scriptName)
if (!shim) {
// Symmetric with the timeout branch below: surface enough breadcrumb
// that a misconfigured bin-path.txt isn't a silently-broken plugin.
// eslint-disable-next-line no-console
console.debug(`[code-preview] could not resolve shim ${scriptName}`)
return
}

// Bash fields
if (args.command !== undefined) toolInput.command = args.command

// ApplyPatch fields — handle both possible field names from different models
if (args.patchText !== undefined) toolInput.patch_text = args.patchText
if (args.patch !== undefined) toolInput.patch_text = args.patch

return JSON.stringify({ tool_name: toolName, cwd, tool_input: toolInput })
}

// ── Core script execution ───────────────────────────────────────

function runCoreScript(script: string, json: string): void {
const bin = binDir()
try {
execSync(`"${bin}/${script}"`, {
input: json,
execSync(`"${shim}"`, {
input: JSON.stringify(payload),
env: { ...process.env, CODE_PREVIEW_BACKEND: "opencode" },
timeout: 15000,
stdio: ["pipe", "pipe", "pipe"],
})
} catch {
// Errors are non-fatal — the diff preview is best-effort
} catch (err: any) {
// Abstain on any failure. Log timeouts at debug-equivalent because
// a silent 15s hang is otherwise hard to diagnose; everything else
// is treated as best-effort and swallowed.
if (err && (err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) {
// eslint-disable-next-line no-console
console.debug(`[code-preview] ${scriptName} timed out after 15s`)
}
}
}

// ── Hook serialization ─────────────────────────────────────────
// OpenCode may fire tool.execute.after(file1) and tool.execute.before(file2)
// concurrently. Without serialization, the close_diff from file1's after-hook
// races with the show_diff from file2's before-hook, killing file2's preview.
// A simple queue ensures hooks execute one at a time.
// ── Hook serialisation ───────────────────────────────────────────
// TS→nvim send-order preservation: OpenCode fires before(A) and after(B)
// concurrently; without this, RPCs can reorder during socket discovery and a
// post-tool close can land before its matching pre-tool open. The in-process
// Lua orchestrator serialises *within* nvim's main thread, but cannot fix
// out-of-order arrivals from the TS side.

let hookQueue: Promise<void> = Promise.resolve()

Expand All @@ -110,22 +103,22 @@ function enqueueHook(fn: () => void): Promise<void> {
return hookQueue
}

// ── Plugin entry point ──────────────────────────────────────────
// ── Plugin entry point ──────────────────────────────────────────

const plugin: Plugin = async ({ directory }) => {
return {
"tool.execute.before": async (input, output) => {
const args = output.args as Record<string, any>
const json = toNormalizedJson(input.tool, args, directory)
if (!json) return
await enqueueHook(() => runCoreScript("core-pre-tool.sh", json))
if (!PREVIEW_TOOLS.has(input.tool)) return
const args = (output.args as Record<string, any>) ?? {}
const payload = { tool: input.tool, args, cwd: directory }
await enqueueHook(() => runShim("code-preview-diff.sh", payload))
},

"tool.execute.after": async (input, _output) => {
const args = (input as any).args ?? {}
const json = toNormalizedJson(input.tool, args, directory)
if (!json) return
await enqueueHook(() => runCoreScript("core-post-tool.sh", json))
if (!PREVIEW_TOOLS.has(input.tool)) return
const args = ((input as any).args as Record<string, any>) ?? {}
const payload = { tool: input.tool, args, cwd: directory }
await enqueueHook(() => runShim("code-close-diff.sh", payload))
},
}
}
Expand Down
17 changes: 17 additions & 0 deletions docs/adr/0006-opencode-defers-os-independence-to-46.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OpenCode's integration keeps the bash shim, deferring OS-independence to issue #46

Issue #47 phase 3 flips OpenCode's [hook entry](../../CONTEXT.md#hook-entry) from `execSync`ing the bash [core handler](../../CONTEXT.md#core-handler) to a single [RPC](../../CONTEXT.md#rpc) into the in-process Lua orchestrator (see [ADR-0005](0005-core-handler-runs-in-process.md)). The natural next question: OpenCode's plugin is TypeScript, which runs natively on Windows. Should the flip also make OpenCode's integration the *first* bash-free [integration](../../CONTEXT.md#integration) — TS calls `nvim --server` directly, or speaks msgpack-rpc, with [socket discovery](../../CONTEXT.md#socket-discovery) reimplemented in TS?

We decided no. The TS plugin continues to `execSync` a thin `backends/opencode/code-preview-diff.sh` shim that sources `bin/nvim-socket.sh` and `bin/nvim-call.sh`, identical in shape to the claudecode shim.

## Considered Options

- **TS-native discovery** — reimplement the pidfile read, the `/var/folders/*/T/nvim.*/0` / `/tmp/${NVIM_APPNAME}.*/0` / `$XDG_RUNTIME_DIR` glob fallbacks, the cwd-matching tiebreak, and the stale-socket probe in TypeScript. Pro: OpenCode becomes the first integration that doesn't need bash. Con: two divergent discovery implementations — every change to `bin/nvim-socket.sh` (e.g. issue #47 phase 1's pidfile work) has to land twice and stay in sync.
- **JS msgpack-rpc client** — add `neovim` or similar to the OpenCode plugin's dependencies and speak the protocol directly. Same divergent-implementation problem, plus a new runtime dependency to manage.
- **Bash shim** *(chosen)* — claudecode's pattern. One discovery implementation; OpenCode pays a bash dependency.

## Consequences

- OpenCode's integration still requires bash to be on `PATH`, which is the very thing issue #47 was originally opened to fix for Windows users. The Windows story for OpenCode users does not improve in this phase.
- Issue #46 (centralised RPC/discovery rewrite) inherits exactly one target to port, not two. When #46 lands, OpenCode and claudecode flip together.
- This ADR is **not** a claim that bash is the right end state for OpenCode. It is a deliberate deferral: the TS plugin's natural cross-platform reach is being held back so that #46 has a single discovery implementation to replace. Once #46 ships, OpenCode is expected to be among the first integrations to drop the bash dependency.
24 changes: 18 additions & 6 deletions lua/code-preview/backends/opencode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ local function plugin_root()
return vim.fn.fnamemodify(lua_dir, ":h:h:h")
end

local function bin_dir()
return plugin_root() .. "/bin"
end

local function plugin_source_dir()
return plugin_root() .. "/backends/opencode"
end
Expand Down Expand Up @@ -44,14 +40,30 @@ function M.install()
end
end

-- Write bin-path.txt so the plugin can find the core scripts
-- Write bin-path.txt pointing at the plugin root. The TS plugin derives
-- both backends/opencode/ (shim location) and bin/ (legacy callers) from
-- this single path. Historically this file pointed at bin/ directly; the
-- TS side keeps a transitional fallback for that legacy value.
local bin_path_file = target .. "/bin-path.txt"
local bf = io.open(bin_path_file, "w")
if bf then
bf:write(bin_dir())
bf:write(plugin_root())
bf:close()
end

-- Ensure shim scripts are executable in-tree. The TS plugin execSyncs them
-- directly from <plugin_root>/backends/opencode/. No-op on Windows: the
-- permissions model differs and bash isn't the end-state for opencode-on-
-- Windows anyway (see ADR-0006). #46 will resolve the Windows story.
if vim.fn.has("unix") == 1 then
for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do
local script_path = source .. "/" .. script
if vim.fn.filereadable(script_path) == 1 then
vim.fn.system({ "chmod", "+x", script_path })
end
end
end

vim.notify("[code-preview] OpenCode plugin installed → " .. target, vim.log.levels.INFO)
end

Expand Down
4 changes: 3 additions & 1 deletion lua/code-preview/post_tool.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ local function patch_paths(patch_text, cwd)
end
local custom = line:match("^%*%*%* %a+ File:%s*(.+)$")
if custom then
table.insert(paths, custom:gsub("%s+$", ""))
-- gsub returns (string, count); parens discard the count so table.insert
-- doesn't fall into its (t, pos, value) 3-arg form.
table.insert(paths, (custom:gsub("%s+$", "")))
end
end
-- Resolve relative paths against cwd.
Expand Down
Loading
Loading