feat(agent): opencode adapter + activity plugin hooks (#80)

* feat(agent): add opencode adapter + activity plugin hooks

Add an opencode (sst/opencode) agent adapter implementing the 6-method
ports.Agent interface and register it in the daemon's agent resolver, so a
session with harness "opencode" spawns and restores a real opencode worker.

opencode diverges from the claude-code/codex adapters in two ways the adapter
bridges:

- No native command-hook config. Unlike Claude Code (.claude/settings.local.json)
  and Codex (.codex/hooks.json), opencode has no "run this command on event"
  config (see sst/opencode#5409). Its only lifecycle surface is a JS/TS plugin
  loaded from .opencode/plugins/. GetAgentHooks therefore //go:embeds an
  AO-owned plugin (assets/ao-activity.ts) and writes it atomically; install is an
  idempotent overwrite and uninstall is a sentinel-guarded delete, so
  user-authored plugins are never touched. The plugin maps opencode events onto
  AO's three normalized activity events: session.created -> session-start,
  message.updated/message.part.updated -> user-prompt-submit, and
  session.status(idle) -> stop (NOT the deprecated session.idle, which is
  unreliable under `opencode run`). It shells `ao hooks opencode <event>` via a
  guarded sh -c so a missing `ao` binary is a silent no-op.

- A single approval flag. opencode exposes only --dangerously-skip-permissions
  (no graduated accept-edits/auto) and no system-prompt flag, so those map to a
  bypass-only permission flag and a documented no-op for the system prompt
  (deferred to opencode's own config).

Launch uses the interactive TUI (`opencode --prompt <p>`); restore continues a
captured native session via `opencode --session <id>`.

opencode_test.go mirrors codex_test.go (12 tests) and the daemon wiring test now
asserts the opencode harness resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): address Greptile review on opencode adapter (#80)

- Dispatch all opencode plugin hooks synchronously (Bun.spawnSync). The
  session.created handler previously fired session-start via an async
  Bun.spawn; if opencode does not await the event handler, a following
  message.updated -> user-prompt-submit (sync) could complete before the
  in-flight async session-start, so AO would see the prompt before the session
  was registered. A sync spawn blocks opencode's single-threaded event loop, so
  events are now reported strictly in dispatch order. Removes the now-unused
  async callHook helper.
- Fix package/doc comments that said .opencode/plugin/ (singular) to match the
  plural .opencode/plugins/ the adapter actually writes to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): surface opencode hook failures instead of swallowing them

The activity plugin previously discarded every failure: callHookSync ignored
the subprocess exit code/stderr and both catch blocks were empty, so a failing
`ao hooks` invocation or a malformed event payload was completely invisible.

Now failures are reported through opencode's structured logger (client.app.log)
while still never crashing opencode:
- callHookSync pipes stderr and checks result.success; a non-zero exit (a real
  `ao hooks` failure — the `command -v ao` guard makes a missing binary exit 0)
  is logged with its exit code and stderr.
- spawn exceptions (e.g. no `sh`) are caught and logged.
- the event-handler catch logs the offending event type instead of swallowing.
- logHookFailure is itself best-effort (optional-chained, rejection swallowed),
  so logging can never throw back into opencode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): address opencode adapter review — install guard, prompt dedup, hook timeout

Maintainer review on #80 surfaced three pre-merge issues:

- GetAgentHooks could clobber a user file: install overwrote
  .opencode/plugins/ao-activity.ts unconditionally while uninstall was
  sentinel-guarded. Install now refuses (loud error) to overwrite a file that
  isn't AO-managed; absent/AO-managed targets still write idempotently.

- Empty-prompt report poisoned the dedup: message.updated fired
  user-prompt-submit with an empty prompt AND marked the message seen, so the
  text from the following message.part.updated was deduped away and never
  reached AO — breaking title-from-prompt. reportUserPrompt now reports at most
  twice: an optional early empty report (keeps run-mode flows active) that does
  NOT block a later text report, and a text report that is terminal.

- Bun.spawnSync had no timeout, so a hung `ao hooks` could block opencode
  indefinitely. Each spawn is now time-boxed at 30s, matching the claude-code
  and codex hook timeouts.

Adds TestGetAgentHooksRefusesToClobberForeignFile and a spawn-timeout assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden opencode activity hooks

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
This commit is contained in:
yyovil 2026-06-03 17:06:32 +05:30 committed by GitHub
parent 210c9df758
commit e25b2ad4de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1002 additions and 1 deletions

View File

@ -0,0 +1,174 @@
// agent-orchestrator: managed opencode activity plugin (do not edit)
//
// It maps opencode's native lifecycle events onto AO's three normalized
// activity events:
// session.created -> `ao hooks opencode session-start`
// message.updated / message.part.updated -> `ao hooks opencode user-prompt-submit`
// session.status (status.type == idle) -> `ao hooks opencode stop`
//
// The opencode-native session id (and prompt/model where known) is piped to the
// hook command as JSON on stdin, run with cwd set to the worktree so AO can
// correlate the opencode session to its AO session. Every invocation is
// best-effort and must never crash the user's opencode session: a missing `ao`
// binary is a guarded no-op (`command -v ao`), and spawn exceptions, non-zero
// exit codes, and malformed event payloads are caught and surfaced through
// opencode's structured logger (client.app.log) for diagnosis — never rethrown.
//
// `import type` is erased at runtime by Bun's transpiler, so this loads even
// before opencode has installed @opencode-ai/plugin into the config dir.
import type { Plugin } from "@opencode-ai/plugin"
export const aoActivity: Plugin = async ({ directory, client }) => {
// ao hooks must never be able to hang opencode: cap each invocation, matching
// the 30s timeout the claude-code and codex hook entries use.
const HOOK_TIMEOUT_MS = 30_000
// A user message is reported at most twice (see reportUserPrompt): an optional
// early empty report, then an upgrade carrying the prompt text. Maps a message
// id to whether the report we already sent included the prompt text.
const promptReports = new Map<string, boolean>()
// message.* events don't carry the session id, so track it from events that do.
let currentSessionID: string | null = null
// The model of the most recent assistant message, forwarded for context.
let currentModel: string | null = null
const messageStore = new Map<string, any>()
// Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op
// (exit 0) rather than a per-event error in the user's session.
function hookCmd(hookName: string): string[] {
return ["sh", "-c", `if ! command -v ao >/dev/null 2>&1; then exit 0; fi; exec ao hooks opencode ${hookName}`]
}
// Report a hook failure through opencode's structured logger. Best-effort: the
// log call must itself never throw or reject back into opencode, hence the
// optional chaining + swallowed rejection.
function logHookFailure(hookName: string, detail: string) {
try {
void client?.app
?.log?.({ body: { service: "ao-activity", level: "error", message: `hook ${hookName} failed: ${detail}` } })
?.catch?.(() => {})
} catch {
// The logger itself is unavailable — nothing more we can safely do.
}
}
// All hooks are dispatched synchronously (Bun.spawnSync), for two reasons:
// 1. Ordering. An async hook yields the event loop; if opencode does not
// await the handler's promise, a later event (e.g. message.updated ->
// user-prompt-submit) could complete before an in-flight async
// session-start, so AO would see the prompt before the session is
// registered. spawnSync blocks opencode's single-threaded loop until the
// hook returns, so events are reported strictly in dispatch order.
// 2. `opencode run` exits on the idle event, so an async stop hook would be
// killed before completing.
//
// A non-zero exit (the guard makes a missing `ao` exit 0, so this is a real
// `ao hooks` failure) or a spawn exception is logged with its stderr and never
// rethrown, so reporting failures are diagnosable without crashing opencode.
function callHookSync(hookName: string, payload: Record<string, unknown>) {
try {
const result = Bun.spawnSync(hookCmd(hookName), {
cwd: directory,
stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"),
stdout: "ignore",
stderr: "pipe",
timeout: HOOK_TIMEOUT_MS,
})
if (!result.success) {
const stderr = result.stderr ? new TextDecoder().decode(result.stderr).trim() : ""
logHookFailure(hookName, `exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`)
}
} catch (err) {
// The spawn itself failed (e.g. no `sh` on PATH). Never propagate.
logHookFailure(hookName, err instanceof Error ? err.message : String(err))
}
}
function switchedSession(sessionID: string): boolean {
if (currentSessionID === sessionID) return false
promptReports.clear()
messageStore.clear()
currentModel = null
currentSessionID = sessionID
return true
}
// Report a user prompt, preferring the one that carries the prompt text.
// message.updated can arrive before message.part.updated with no text, so an
// early empty report must NOT dedup away the later text report — otherwise the
// prompt never reaches AO and title-from-prompt metadata breaks. Therefore: an
// empty report fires at most once (so run-mode flows that omit the text part
// still mark the session active), and a text report fires once and is terminal.
function reportUserPrompt(sessionID: string, messageID: string, prompt: string) {
const hasText = prompt.length > 0
const reportedWithText = promptReports.get(messageID)
if (reportedWithText) return // already reported with text — terminal
if (reportedWithText === false && !hasText) return // already reported empty; no new info
promptReports.set(messageID, hasText)
callHookSync("user-prompt-submit", { session_id: sessionID, prompt, model: currentModel ?? "" })
}
return {
event: async ({ event }) => {
try {
switch (event.type) {
case "session.created": {
const session = (event as any).properties?.info
if (!session?.id) break
if (switchedSession(session.id)) {
callHookSync("session-start", { session_id: session.id })
}
break
}
case "message.updated": {
const msg = (event as any).properties?.info
if (!msg) break
if (msg.sessionID && switchedSession(msg.sessionID)) {
callHookSync("session-start", { session_id: msg.sessionID })
}
if (msg.role === "assistant" && msg.modelID) currentModel = msg.modelID
// Fallback: some `opencode run` flows never deliver message.part.updated
// for the prompt, so start the turn from the user message itself.
if (msg.role === "user") {
messageStore.set(msg.id, msg)
const sessionID = msg.sessionID ?? currentSessionID
if (sessionID) reportUserPrompt(sessionID, msg.id, "")
}
break
}
case "message.part.updated": {
const part = (event as any).properties?.part
if (!part?.messageID) break
const msg = messageStore.get(part.messageID)
if (msg?.role === "user" && part.type === "text") {
const sessionID = msg.sessionID ?? currentSessionID
const prompt = part.text ?? ""
if (sessionID) reportUserPrompt(sessionID, msg.id, prompt)
if (prompt.length > 0) messageStore.delete(part.messageID)
}
break
}
case "session.status": {
// session.status fires in both TUI and `opencode run`; session.idle
// is deprecated and not reliably emitted in run mode.
// AO's "stop" hook means "the current turn is idle/finished", not
// "the whole native session has terminated", so multi-turn TUI
// sessions intentionally emit one stop per idle transition.
const props = (event as any).properties
if (props?.status?.type !== "idle") break
const sessionID = props?.sessionID ?? currentSessionID
if (!sessionID) break
callHookSync("stop", { session_id: sessionID, model: currentModel ?? "" })
break
}
}
} catch (err) {
// A malformed/unexpected event payload must never crash opencode; log
// it (tagged with the event type) for diagnosis and move on.
logHookFailure(`event:${(event as any)?.type ?? "unknown"}`, err instanceof Error ? err.message : String(err))
}
},
}
}

View File

@ -0,0 +1,185 @@
package opencode
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
_ "embed"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
// opencode scans both `.opencode/plugin/` and `.opencode/plugins/` for
// `*.js`/`*.ts` files (see opencode's ConfigPlugin glob
// "{plugin,plugins}/*.{ts,js}"). AO writes the plural `plugins/`, matching
// the directory the upstream opencode tooling (and the entire-cli reference
// integration) uses.
opencodePluginDirName = ".opencode"
opencodePluginSubDir = "plugins"
// opencodePluginFileName is the AO-owned plugin file. AO fully owns this
// filename: install overwrites it and uninstall deletes it (guarded by the
// sentinel), so user-authored plugins in other files are never touched.
// It is TypeScript (opencode runs on Bun); the file's only import is a
// type-only import, which Bun erases at runtime.
opencodePluginFileName = "ao-activity.ts"
// opencodePluginSentinel marks the file as AO-managed. AreHooksInstalled and
// UninstallHooks key off it so AO never deletes a user file that happens to
// share the name. It must appear verbatim in the embedded plugin source.
opencodePluginSentinel = "agent-orchestrator: managed opencode activity plugin"
// opencodeHookCommandPrefix identifies the hook commands AO owns. The
// embedded plugin shells `ao hooks opencode <event>`; this prefix is the
// shared contract with the (forthcoming) `ao hooks` CLI and is asserted by
// tests so the plugin can't silently drift away from it.
opencodeHookCommandPrefix = "ao hooks opencode "
)
// opencodePluginSource is the AO-managed opencode plugin, embedded so it ships
// inside the binary and is written verbatim into a session's worktree on hook
// install. It is a real, lintable source file under assets/ rather than a Go
// string literal because it is opencode plugin source code, not a data
// structure AO assembles (the way it builds Codex/Claude hook JSON).
//
//go:embed assets/ao-activity.ts
var opencodePluginSource string
// opencodeManagedEvents are the three normalized activity events the embedded
// plugin reports. They are defined here (not parsed from the file) so tests can
// assert the plugin wires every one via the `ao hooks opencode <event>` command.
var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "stop"}
// GetAgentHooks installs AO's opencode activity plugin into the worktree-local
// .opencode/plugins/ directory. Unlike Claude Code and Codex, opencode has no
// native command-hook config to merge into; its only lifecycle-extensibility
// surface is a JS/TS plugin. AO therefore writes a dedicated, AO-owned plugin
// file. The write is atomic and idempotent: re-installing overwrites AO's own
// file with identical content. It refuses to overwrite a file that is NOT
// AO-managed (no sentinel), so a user plugin that happens to occupy our path is
// never silently destroyed — install fails loudly instead.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(cfg.WorkspacePath) == "" {
return errors.New("opencode.GetAgentHooks: WorkspacePath is required")
}
pluginPath := opencodePluginPath(cfg.WorkspacePath)
// Guard against clobbering a user file at our path: overwrite only when the
// target is absent or already AO-managed. A foreign file is a loud error,
// not silent data loss (uninstall is sentinel-guarded the same way).
if _, err := os.Stat(pluginPath); err == nil {
managed, err := isAOManagedPlugin(pluginPath)
if err != nil {
return fmt.Errorf("opencode.GetAgentHooks: %w", err)
}
if !managed {
return fmt.Errorf("opencode.GetAgentHooks: refusing to overwrite non-AO file at %s — move it so AO can install its plugin", pluginPath)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("opencode.GetAgentHooks: stat plugin: %w", err)
}
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil {
return fmt.Errorf("opencode.GetAgentHooks: create plugin dir: %w", err)
}
if err := atomicWriteFile(pluginPath, []byte(opencodePluginSource), 0o600); err != nil {
return fmt.Errorf("opencode.GetAgentHooks: write plugin: %w", err)
}
return nil
}
// UninstallHooks removes AO's opencode plugin from the workspace-local
// .opencode/plugins/ directory. It deletes the file only when it carries the AO
// sentinel, so a user file that happens to share the name is left in place. A
// missing file is a no-op.
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(workspacePath) == "" {
return errors.New("opencode.UninstallHooks: workspacePath is required")
}
pluginPath := opencodePluginPath(workspacePath)
managed, err := isAOManagedPlugin(pluginPath)
if err != nil {
return fmt.Errorf("opencode.UninstallHooks: %w", err)
}
if !managed {
return nil
}
if err := os.Remove(pluginPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("opencode.UninstallHooks: remove plugin: %w", err)
}
return nil
}
// AreHooksInstalled reports whether AO's opencode plugin is present in the
// workspace-local plugin dir. A missing file, or a same-named file without the
// AO sentinel, means none are installed.
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
if strings.TrimSpace(workspacePath) == "" {
return false, errors.New("opencode.AreHooksInstalled: workspacePath is required")
}
managed, err := isAOManagedPlugin(opencodePluginPath(workspacePath))
if err != nil {
return false, fmt.Errorf("opencode.AreHooksInstalled: %w", err)
}
return managed, nil
}
func opencodePluginPath(workspacePath string) string {
return filepath.Join(workspacePath, opencodePluginDirName, opencodePluginSubDir, opencodePluginFileName)
}
// isAOManagedPlugin reports whether the file at path exists and carries the AO
// sentinel. A missing file yields (false, nil).
func isAOManagedPlugin(path string) (bool, error) {
data, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", path, err)
}
return strings.Contains(string(data), opencodePluginSentinel), nil
}
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated plugin file that opencode then fails to import
// (silently disabling activity reporting).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

View File

@ -0,0 +1,263 @@
// Package opencode implements the opencode (sst/opencode) agent adapter:
// launching new TUI sessions, resuming sessions by native id, installing a
// workspace-local activity plugin, and reading plugin-derived session info.
//
// opencode differs from Claude Code and Codex in two ways AO has to bridge:
// - It has no native command-hook config (no settings.local.json / hooks.json
// equivalent). Its only lifecycle-extensibility surface is a JS/TS plugin
// loaded from .opencode/plugins/, so GetAgentHooks installs an AO-owned
// plugin file (see hooks.go) instead of merging JSON.
// - Its CLI exposes only one approval flag (--dangerously-skip-permissions)
// and no system-prompt flag, so the graduated permission modes and the
// system prompt are deferred to opencode's own config.
//
// AO-managed sessions derive native session identity and display metadata from
// the opencode plugin's reported events, mirroring the Codex adapter.
package opencode
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
// adapterID is the registry id and the value users pass to
// `ao spawn --agent`. It matches domain.HarnessOpenCode.
adapterID = "opencode"
// Normalized session-metadata keys the opencode plugin persists into the AO
// session store and SessionInfo reads back. Shared vocabulary with the Codex
// and Claude Code adapters so the dashboard treats every agent uniformly.
opencodeAgentSessionIDMetadataKey = "agentSessionId"
opencodeTitleMetadataKey = "title"
opencodeSummaryMetadataKey = "summary"
)
// Plugin is the opencode agent adapter. It is safe for concurrent use; the
// binary path is resolved once and cached under binaryMu.
type Plugin struct {
binaryMu sync.Mutex
resolvedBinary string
}
// New returns a ready-to-register opencode adapter.
func New() *Plugin {
return &Plugin{}
}
var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
return adapters.Manifest{
ID: adapterID,
Name: "opencode",
Description: "Run opencode worker sessions.",
Version: "0.0.1",
Capabilities: []adapters.Capability{
adapters.CapabilityAgent,
},
}
}
// GetConfigSpec reports the agent-specific config keys. opencode exposes none
// yet: model and agent selection are read from opencode's own config
// (opencode.json / ~/.config/opencode), exactly as a normal launch.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
}
// GetLaunchCommand builds the argv to start a new interactive opencode session.
// Shape:
//
// opencode [--dangerously-skip-permissions] [--prompt <prompt>]
//
// The session runs in the worktree (cwd is set by the runtime, as for Claude
// Code and Codex). opencode has no CLI flag to set a system prompt, so
// cfg.SystemPrompt / SystemPromptFile are intentionally ignored here — opencode
// resolves instructions from its own config and AGENTS.md rules. The initial
// task prompt is delivered via --prompt (its argument, so a leading "-" is not
// read as a flag).
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.opencodeBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary}
appendPermissionFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "--prompt", cfg.Prompt)
}
return cmd, nil
}
// GetPromptDeliveryStrategy reports that opencode receives its prompt in the
// launch command itself (via --prompt).
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing opencode
// session: `opencode [--dangerously-skip-permissions] --session <agentSessionId>`.
// It re-applies the permission flag (resume otherwise reverts to the configured
// default) but not the prompt, which the session already carries. ok is false
// when the plugin-derived native session id has not landed yet, so callers fall
// back to fresh launch behavior — mirroring the Codex adapter.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[opencodeAgentSessionIDMetadataKey])
if agentSessionID == "" {
return nil, false, nil
}
binary, err := p.opencodeBinary(ctx)
if err != nil {
return nil, false, err
}
cmd = make([]string, 0, 4)
cmd = append(cmd, binary)
appendPermissionFlags(&cmd, cfg.Permissions)
cmd = append(cmd, "--session", agentSessionID)
return cmd, true, nil
}
// SessionInfo surfaces opencode plugin-derived metadata. Metadata is
// intentionally nil for opencode: callers get the normalized fields directly,
// matching the Codex adapter.
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
if err := ctx.Err(); err != nil {
return ports.SessionInfo{}, false, err
}
info := ports.SessionInfo{
AgentSessionID: session.Metadata[opencodeAgentSessionIDMetadataKey],
Title: session.Metadata[opencodeTitleMetadataKey],
Summary: session.Metadata[opencodeSummaryMetadataKey],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
}
return info, true, nil
}
// appendPermissionFlags maps AO's permission modes onto opencode's single
// approval flag. opencode exposes only --dangerously-skip-permissions (no
// graduated accept-edits/auto modes), so:
// - bypass-permissions → --dangerously-skip-permissions
// - default / accept-edits / auto → no flag. opencode resolves approvals from
// its own `permission` config exactly as a normal launch.
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions {
*cmd = append(*cmd, "--dangerously-skip-permissions")
}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
// Empty or unrecognized: defer to opencode's own config (no flag).
return ports.PermissionModeDefault
}
}
// ResolveOpenCodeBinary returns the path to the opencode binary on this machine,
// searching PATH then a handful of well-known install locations (the install
// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a
// last-ditch fallback so callers see a clear "command not found" rather than an
// empty argv.
func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"opencode.cmd", "opencode.exe", "opencode"} {
if path, err := exec.LookPath(name); err == nil && path != "" {
return path, nil
}
}
candidates := []string{}
if appData := os.Getenv("APPDATA"); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "npm", "opencode.cmd"),
filepath.Join(appData, "npm", "opencode.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
}
return "opencode", nil
}
if path, err := exec.LookPath("opencode"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/opencode",
"/opt/homebrew/bin/opencode",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".opencode", "bin", "opencode"),
filepath.Join(home, ".npm", "bin", "opencode"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "opencode", nil
}
func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
if p.resolvedBinary != "" {
return p.resolvedBinary, nil
}
binary, err := ResolveOpenCodeBinary(ctx)
if err != nil {
return "", err
}
p.resolvedBinary = binary
return binary, nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@ -0,0 +1,377 @@
package opencode
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
Prompt: "-fix this",
SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"),
SystemPrompt: "ignored",
})
if err != nil {
t.Fatal(err)
}
// opencode has no system-prompt flag, so SystemPrompt/SystemPromptFile are
// dropped; the prompt is delivered via --prompt.
want := []string{
"opencode",
"--dangerously-skip-permissions",
"--prompt", "-fix this",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
tests := []struct {
name string
permission ports.PermissionMode
wantFlag bool
notExpected string
}{
{name: "default", permission: ports.PermissionModeDefault, notExpected: "--dangerously-skip-permissions"},
{name: "accept-edits", permission: ports.PermissionModeAcceptEdits, notExpected: "--dangerously-skip-permissions"},
{name: "auto", permission: ports.PermissionModeAuto, notExpected: "--dangerously-skip-permissions"},
{name: "bypass-permissions", permission: ports.PermissionModeBypassPermissions, wantFlag: true},
{name: "empty", permission: "", notExpected: "--dangerously-skip-permissions"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.permission})
if err != nil {
t.Fatal(err)
}
has := contains(cmd, "--dangerously-skip-permissions")
if tt.wantFlag && !has {
t.Fatalf("command %#v missing --dangerously-skip-permissions", cmd)
}
if tt.notExpected != "" && has {
t.Fatalf("command %#v contains %q", cmd, tt.notExpected)
}
})
}
}
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
t.Fatal(err)
}
if len(spec.Fields) != 0 {
t.Fatalf("unexpected config fields: %#v", spec.Fields)
}
}
func TestGetAgentHooksInstallsPlugin(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
workspace := t.TempDir()
// A user's own plugin in the same dir must survive AO's install untouched.
pluginDir := filepath.Dir(opencodePluginPath(workspace))
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
userPlugin := filepath.Join(pluginDir, "user.js")
userBody := []byte("export const userPlugin = async () => ({})\n")
if err := os.WriteFile(userPlugin, userBody, 0o644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
t.Fatal(err)
}
// A second install must be idempotent (overwrite with identical content).
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
}
data, err := os.ReadFile(opencodePluginPath(workspace))
if err != nil {
t.Fatal(err)
}
body := string(data)
if !strings.Contains(body, opencodePluginSentinel) {
t.Fatalf("installed plugin missing AO sentinel:\n%s", body)
}
// Every normalized activity event must be wired via `ao hooks opencode <event>`.
for _, event := range opencodeManagedEvents {
want := opencodeHookCommandPrefix + event
if !strings.Contains(body, want) {
t.Fatalf("installed plugin missing hook command %q:\n%s", want, body)
}
}
// The opencode-native lifecycle events the plugin subscribes to. Stop maps
// to session.status(idle) — NOT the deprecated session.idle — and the user
// prompt is detected from message.updated/message.part.updated.
for _, marker := range []string{"session.created", "message.updated", "message.part.updated", "session.status"} {
if !strings.Contains(body, marker) {
t.Fatalf("installed plugin missing opencode event %q:\n%s", marker, body)
}
}
// Guard against regressing back to subscribing to the deprecated/unreliable
// session.idle event (the quoted event string is how a `case` would name it;
// the explanatory comment mentions it unquoted, which is fine).
if strings.Contains(body, `"session.idle"`) {
t.Fatalf("plugin subscribes to deprecated session.idle; use session.status(idle):\n%s", body)
}
// A hung `ao hooks` call must not block opencode forever, so each spawn is
// time-boxed (parity with the claude/codex 30s hook timeout).
if !strings.Contains(body, "timeout:") {
t.Fatalf("plugin spawn has no timeout; a hung hook would block opencode:\n%s", body)
}
// The user's plugin is untouched.
got, err := os.ReadFile(userPlugin)
if err != nil {
t.Fatalf("user plugin removed by install: %v", err)
}
if !reflect.DeepEqual(got, userBody) {
t.Fatalf("user plugin modified by install: %q", got)
}
}
func TestGetAgentHooksRefusesToClobberForeignFile(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
workspace := t.TempDir()
ctx := context.Background()
// A non-AO file occupying AO's exact path must NOT be silently overwritten.
pluginPath := opencodePluginPath(workspace)
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil {
t.Fatal(err)
}
foreign := []byte("export const notOurs = async () => ({})\n")
if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil {
t.Fatal(err)
}
err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: workspace})
if err == nil {
t.Fatal("GetAgentHooks overwrote a non-AO file; want a loud error")
}
got, readErr := os.ReadFile(pluginPath)
if readErr != nil {
t.Fatalf("foreign file removed by refused install: %v", readErr)
}
if !reflect.DeepEqual(got, foreign) {
t.Fatalf("foreign file modified by refused install: %q", got)
}
}
func TestUninstallHooksRemovesPlugin(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
workspace := t.TempDir()
ctx := context.Background()
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
// Pre-seed a user's own plugin; it must survive uninstall.
pluginDir := filepath.Dir(opencodePluginPath(workspace))
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
userPlugin := filepath.Join(pluginDir, "user.js")
if err := os.WriteFile(userPlugin, []byte("export const userPlugin = async () => ({})\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
}
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
}
if _, err := os.Stat(opencodePluginPath(workspace)); !os.IsNotExist(err) {
t.Fatalf("AO plugin still present after uninstall: err=%v", err)
}
if _, err := os.Stat(userPlugin); err != nil {
t.Fatalf("user plugin removed by uninstall: %v", err)
}
}
func TestUninstallHooksLeavesForeignFile(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
workspace := t.TempDir()
ctx := context.Background()
// A non-AO file occupying AO's filename must NOT be deleted by uninstall.
pluginPath := opencodePluginPath(workspace)
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil {
t.Fatal(err)
}
foreign := []byte("export const notOurs = async () => ({})\n")
if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
t.Fatalf("AreHooksInstalled on foreign file = (%v, %v), want (false, nil)", installed, err)
}
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("foreign file removed by uninstall: %v", err)
}
if !reflect.DeepEqual(got, foreign) {
t.Fatalf("foreign file modified by uninstall: %q", got)
}
}
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeBypassPermissions,
Session: ports.SessionRef{
Metadata: map[string]string{opencodeAgentSessionIDMetadataKey: "ses_abc123"},
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatal("ok = false, want true")
}
want := []string{
"opencode",
"--dangerously-skip-permissions",
"--session", "ses_abc123",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
cases := []struct {
name string
ref ports.SessionRef
}{
{"empty session ref", ports.SessionRef{}},
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{opencodeAgentSessionIDMetadataKey: " "}}},
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeDefault,
Session: tc.ref,
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if cmd != nil {
t.Fatalf("cmd = %#v, want nil", cmd)
}
})
}
}
func TestSessionInfoReadsHookMetadata(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{
opencodeAgentSessionIDMetadataKey: "ses_abc123",
opencodeTitleMetadataKey: "Fix login redirect",
opencodeSummaryMetadataKey: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatalf("ok = false, want true")
}
if info.AgentSessionID != "ses_abc123" {
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
}
if info.Title != "Fix login redirect" {
t.Fatalf("Title = %q, want hook title", info.Title)
}
if info.Summary != "Updated the auth callback and tests." {
t.Fatalf("Summary = %q, want hook summary", info.Summary)
}
if info.Metadata != nil {
t.Fatalf("Metadata = %#v, want nil for opencode", info.Metadata)
}
}
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
t.Fatalf("info = %#v, want zero value", info)
}
}
func contains(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}

View File

@ -9,6 +9,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -118,7 +119,7 @@ func newSessionMessenger(store *sqlite.Store, runtime runtimeMessageSender, _ *s
// empty/duplicate id — a programmer error, not a runtime condition.
func buildAgentRegistry() (*adapters.Registry, error) {
reg := adapters.NewRegistry()
for _, a := range []adapters.Adapter{claudecode.New(), codex.New()} {
for _, a := range []adapters.Adapter{claudecode.New(), codex.New(), opencode.New()} {
if err := reg.Register(a); err != nil {
return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err)
}

View File

@ -92,6 +92,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
}{
{domain.HarnessClaudeCode, "claude-code"},
{domain.HarnessCodex, "codex"},
{domain.HarnessOpenCode, "opencode"},
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
} {
agent, ok := resolver.Agent(tc.harness)