Files
zzet--gortex/internal/hooks/opencode.go
Andrey Kumanyaev 38fe93cba0 feat(hooks): add an OpenCode bridge over a shared host-agnostic core
OpenCode has no lifecycle-hook configuration at all — a JS plugin is
its only extension point — so it gets the same treatment Pi already
had: a Gortex-owned event envelope on stdin, a decision on stdout.

Rather than fork handlePi, PiEvent/PiDecision become BridgeEvent/
BridgeDecision (Pi keeps aliases) and both hosts share one core that
folds host event names into three slots.

The core now writes hook-effectiveness records, which the Pi bridge
never did. Without a denominator, doctor reads a working bridge as a
hook that has never fired. Pi gets the fix for free.
2026-08-15 12:38:14 +02:00

80 lines
3.0 KiB
Go

package hooks
import (
"encoding/json"
"io"
"os"
"strings"
)
// opencode.go is the Go side of the OpenCode (v1.18.18) integration.
//
// OpenCode has no lifecycle-hook configuration at all — there is no settings
// key that runs a command on session start or before a tool call. Its only
// extension point is a JS/TS plugin exposing `tool.execute.before` (throwing
// from it blocks the call), `tool.execute.after`, `permission.ask` (returns
// "ask" | "deny" | "allow"), `chat.message`, and a bus `event` hook. So
// OpenCode gets the same treatment Pi does: the Gortex-owned bridge envelope
// (pi.go), with a small JavaScript plugin that shells
// `gortex hook --agent=opencode`, writes a BridgeEvent to stdin, and applies
// the BridgeDecision it reads back — Block on `tool.execute.before` /
// `permission.ask`, Orientation on the session event, AdditionalContext on
// `chat.message`.
//
// Everything policy-shaped (the deny / enrich / consult-unlock / nudge
// postures, indexed-source classification, telemetry) therefore lives in the
// shared bridge core rather than in the plugin, where it would have to be
// re-implemented in TypeScript and would drift.
// openCodeToolNames maps OpenCode's built-in tool vocabulary onto the Claude
// names the shared enrichment leaves switch on.
//
// The plugin is expected to send names already normalized (that is the
// bridge contract), so this table is belt and braces — but a cheap one: if a
// plugin build ever forwards OpenCode's own lowercase names instead, the
// policy would otherwise classify nothing and the install would look healthy
// while enforcing nothing. Already-normalized names miss the table and pass
// through untouched, so applying it twice is a no-op.
var openCodeToolNames = map[string]string{
"read": "Read",
"write": "Write",
"edit": "Edit",
"bash": "Bash",
"grep": "Grep",
"glob": "Glob",
"task": "Task",
}
// RunOpenCode reads a single bridge envelope from stdin, dispatches on the
// event phase, and writes a decision to stdout. Any read/parse error is a
// silent no-op (an empty decision) — a hook must never break the host
// agent's flow.
func RunOpenCode(port int, mode Mode) {
data, err := io.ReadAll(os.Stdin)
if err != nil {
emitBridgeDecision(BridgeDecision{})
return
}
emitBridgeDecision(handleOpenCode(data, port, mode))
}
// handleOpenCode is the testable core: decode the envelope, normalize the
// one host-specific field, and hand it to the shared bridge router.
func handleOpenCode(data []byte, port int, mode Mode) BridgeDecision {
var ev BridgeEvent
if err := json.Unmarshal(data, &ev); err != nil {
return BridgeDecision{}
}
ev.ToolName = openCodeToolName(ev.ToolName)
return handleBridgeEvent(ev, port, mode)
}
// openCodeToolName maps one OpenCode tool name into the Claude vocabulary,
// leaving anything unrecognised untouched.
func openCodeToolName(name string) string {
if mapped, ok := openCodeToolNames[strings.ToLower(strings.TrimSpace(name))]; ok {
return mapped
}
return strings.TrimSpace(name)
}