38fe93cba0
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.
80 lines
3.0 KiB
Go
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)
|
|
}
|