From dd662b0730626a8345bc5fec090d3d943845d648 Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 2 Jun 2026 14:56:24 +0530 Subject: [PATCH 1/5] 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 ` 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

`); restore continues a captured native session via `opencode --session `. 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) --- .../agent/opencode/assets/ao-activity.ts | 145 ++++++++ .../internal/adapters/agent/opencode/hooks.go | 164 +++++++++ .../adapters/agent/opencode/opencode.go | 263 +++++++++++++ .../adapters/agent/opencode/opencode_test.go | 344 ++++++++++++++++++ backend/internal/daemon/lifecycle_wiring.go | 3 +- backend/internal/daemon/wiring_test.go | 1 + 6 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 backend/internal/adapters/agent/opencode/assets/ao-activity.ts create mode 100644 backend/internal/adapters/agent/opencode/hooks.go create mode 100644 backend/internal/adapters/agent/opencode/opencode.go create mode 100644 backend/internal/adapters/agent/opencode/opencode_test.go diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts new file mode 100644 index 000000000..89fe9f659 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -0,0 +1,145 @@ +// agent-orchestrator: managed opencode activity plugin (do not edit) +// +// opencode has no native command-hook config (unlike Claude Code's +// settings.local.json or Codex's hooks.json), so AO installs this plugin into +// .opencode/plugins/ to report session lifecycle into AO's store for activity +// detection. The file is owned and overwritten by AO on every hook install; +// user-authored plugins live in their own files and are never touched. +// +// It maps opencode's native lifecycle events onto AO's three normalized +// activity events (matching the claude-code and codex adapters): +// 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: a missing `ao` binary or a failing hook must never crash the +// user's opencode session, so each call is guarded (`command -v ao`) and +// wrapped in try/catch. +// +// `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 }) => { + // Fire user-prompt-submit only once per user message (it can surface on both + // message.updated and message.part.updated). + const seenUserMessages = new Set() + // 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() + + // Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op + // 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}`] + } + + // Async invocation: fire-and-forget for events that don't precede process exit. + async function callHook(hookName: string, payload: Record) { + try { + const proc = Bun.spawn(hookCmd(hookName), { + cwd: directory, + stdin: new Blob([JSON.stringify(payload) + "\n"]), + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited + } catch { + // Best-effort: never let a reporting failure surface to opencode. + } + } + + // Sync invocation: `opencode run` exits on the idle event, so an async stop + // hook would be killed before completing. user-prompt-submit is sync so AO + // sees an ACTIVE session before any fast mid-turn work. + function callHookSync(hookName: string, payload: Record) { + try { + Bun.spawnSync(hookCmd(hookName), { + cwd: directory, + stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"), + stdout: "ignore", + stderr: "ignore", + }) + } catch { + // Best-effort: never let a reporting failure surface to opencode. + } + } + + function switchedSession(sessionID: string): boolean { + if (currentSessionID === sessionID) return false + seenUserMessages.clear() + messageStore.clear() + currentModel = null + currentSessionID = sessionID + return true + } + + function reportUserPrompt(sessionID: string, messageID: string, prompt: string) { + if (seenUserMessages.has(messageID)) return + seenUserMessages.add(messageID) + 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)) { + await callHook("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 }) + } + messageStore.set(msg.id, msg) + 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") { + 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 + if (sessionID) reportUserPrompt(sessionID, msg.id, part.text ?? "") + } + break + } + + case "session.status": { + // session.status fires in both TUI and `opencode run`; session.idle + // is deprecated and not reliably emitted in run mode. + 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 { + // Best-effort: never let a reporting failure surface to opencode. + } + }, + } +} diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go new file mode 100644 index 000000000..a6942c8de --- /dev/null +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -0,0 +1,164 @@ +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 `; 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 ` command. +var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "stop"} + +// GetAgentHooks installs AO's opencode activity plugin into the worktree-local +// .opencode/plugin/ 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 and never touches user-authored plugins. +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) + 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/plugin/ 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.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go new file mode 100644 index 000000000..52e71a3ac --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -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/plugin/, 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 ] +// +// 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 `. +// 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() +} diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go new file mode 100644 index 000000000..75bff6a2e --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -0,0 +1,344 @@ +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 `. + 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) + } + + // 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 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: "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/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: "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/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: "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/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 +} diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 69aae5740..84464376a 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -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) } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 0350c3737..93b023e73 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -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) From 773d47626c18dadd50dd22509366f74225dc86cd Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 2 Jun 2026 15:06:44 +0530 Subject: [PATCH 2/5] 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) --- .../agent/opencode/assets/ao-activity.ts | 29 +++++++------------ .../internal/adapters/agent/opencode/hooks.go | 4 +-- .../adapters/agent/opencode/opencode.go | 2 +- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts index 89fe9f659..38571af9c 100644 --- a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -39,24 +39,15 @@ export const aoActivity: Plugin = async ({ directory }) => { return ["sh", "-c", `if ! command -v ao >/dev/null 2>&1; then exit 0; fi; exec ao hooks opencode ${hookName}`] } - // Async invocation: fire-and-forget for events that don't precede process exit. - async function callHook(hookName: string, payload: Record) { - try { - const proc = Bun.spawn(hookCmd(hookName), { - cwd: directory, - stdin: new Blob([JSON.stringify(payload) + "\n"]), - stdout: "ignore", - stderr: "ignore", - }) - await proc.exited - } catch { - // Best-effort: never let a reporting failure surface to opencode. - } - } - - // Sync invocation: `opencode run` exits on the idle event, so an async stop - // hook would be killed before completing. user-prompt-submit is sync so AO - // sees an ACTIVE session before any fast mid-turn work. + // 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. function callHookSync(hookName: string, payload: Record) { try { Bun.spawnSync(hookCmd(hookName), { @@ -93,7 +84,7 @@ export const aoActivity: Plugin = async ({ directory }) => { const session = (event as any).properties?.info if (!session?.id) break if (switchedSession(session.id)) { - await callHook("session-start", { session_id: session.id }) + callHookSync("session-start", { session_id: session.id }) } break } diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go index a6942c8de..a31e33108 100644 --- a/backend/internal/adapters/agent/opencode/hooks.go +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -56,7 +56,7 @@ var opencodePluginSource string var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "stop"} // GetAgentHooks installs AO's opencode activity plugin into the worktree-local -// .opencode/plugin/ directory. Unlike Claude Code and Codex, opencode has no +// .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 @@ -80,7 +80,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi } // UninstallHooks removes AO's opencode plugin from the workspace-local -// .opencode/plugin/ directory. It deletes the file only when it carries the AO +// .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 { diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 52e71a3ac..377f1bde3 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -5,7 +5,7 @@ // 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/plugin/, so GetAgentHooks installs an AO-owned +// 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 From dbd976fbdbb65fcd22ff309d6e414aa3be7f3229 Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 2 Jun 2026 22:07:30 +0530 Subject: [PATCH 3/5] fix(agent): surface opencode hook failures instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent/opencode/assets/ao-activity.ts | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts index 38571af9c..17bc65abc 100644 --- a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -15,15 +15,16 @@ // 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: a missing `ao` binary or a failing hook must never crash the -// user's opencode session, so each call is guarded (`command -v ao`) and -// wrapped in try/catch. +// 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 }) => { +export const aoActivity: Plugin = async ({ directory, client }) => { // Fire user-prompt-submit only once per user message (it can surface on both // message.updated and message.part.updated). const seenUserMessages = new Set() @@ -34,11 +35,24 @@ export const aoActivity: Plugin = async ({ directory }) => { const messageStore = new Map() // Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op - // rather than a per-event error in the user's session. + // (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 -> @@ -48,16 +62,25 @@ export const aoActivity: Plugin = async ({ directory }) => { // 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) { try { - Bun.spawnSync(hookCmd(hookName), { + const result = Bun.spawnSync(hookCmd(hookName), { cwd: directory, stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"), stdout: "ignore", - stderr: "ignore", + stderr: "pipe", }) - } catch { - // Best-effort: never let a reporting failure surface to opencode. + 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)) } } @@ -128,8 +151,10 @@ export const aoActivity: Plugin = async ({ directory }) => { break } } - } catch { - // Best-effort: never let a reporting failure surface to opencode. + } 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)) } }, } From 4a206f731e27e681bd0d232bfc2bc60b5f3720e7 Mon Sep 17 00:00:00 2001 From: yyovil Date: Wed, 3 Jun 2026 16:50:13 +0530 Subject: [PATCH 4/5] =?UTF-8?q?fix(agent):=20address=20opencode=20adapter?= =?UTF-8?q?=20review=20=E2=80=94=20install=20guard,=20prompt=20dedup,=20ho?= =?UTF-8?q?ok=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent/opencode/assets/ao-activity.ts | 34 ++++++++++++------- .../internal/adapters/agent/opencode/hooks.go | 19 ++++++++++- .../adapters/agent/opencode/opencode_test.go | 33 ++++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts index 17bc65abc..b127d762d 100644 --- a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -1,13 +1,7 @@ // agent-orchestrator: managed opencode activity plugin (do not edit) // -// opencode has no native command-hook config (unlike Claude Code's -// settings.local.json or Codex's hooks.json), so AO installs this plugin into -// .opencode/plugins/ to report session lifecycle into AO's store for activity -// detection. The file is owned and overwritten by AO on every hook install; -// user-authored plugins live in their own files and are never touched. -// // It maps opencode's native lifecycle events onto AO's three normalized -// activity events (matching the claude-code and codex adapters): +// 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` @@ -25,9 +19,13 @@ import type { Plugin } from "@opencode-ai/plugin" export const aoActivity: Plugin = async ({ directory, client }) => { - // Fire user-prompt-submit only once per user message (it can surface on both - // message.updated and message.part.updated). - const seenUserMessages = new Set() + // 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() // 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. @@ -73,6 +71,7 @@ export const aoActivity: Plugin = async ({ directory, client }) => { 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() : "" @@ -86,16 +85,25 @@ export const aoActivity: Plugin = async ({ directory, client }) => { function switchedSession(sessionID: string): boolean { if (currentSessionID === sessionID) return false - seenUserMessages.clear() + 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) { - if (seenUserMessages.has(messageID)) return - seenUserMessages.add(messageID) + 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 ?? "" }) } diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go index a31e33108..68ee7e1f3 100644 --- a/backend/internal/adapters/agent/opencode/hooks.go +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -60,7 +60,9 @@ var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "sto // 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 and never touches user-authored plugins. +// 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 @@ -70,6 +72,21 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi } 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) } diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go index 75bff6a2e..ba73297c1 100644 --- a/backend/internal/adapters/agent/opencode/opencode_test.go +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -150,6 +150,11 @@ func TestGetAgentHooksInstallsPlugin(t *testing.T) { 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) @@ -161,6 +166,34 @@ func TestGetAgentHooksInstallsPlugin(t *testing.T) { } } +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() From 57af22f5e68613f8bf32265f00587e088fc309da Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 3 Jun 2026 16:57:27 +0530 Subject: [PATCH 5/5] fix: harden opencode activity hooks --- .../adapters/agent/opencode/assets/ao-activity.ts | 9 +++++++-- backend/internal/adapters/agent/opencode/hooks.go | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts index b127d762d..4cd217df9 100644 --- a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -126,11 +126,11 @@ export const aoActivity: Plugin = async ({ directory, client }) => { if (msg.sessionID && switchedSession(msg.sessionID)) { callHookSync("session-start", { session_id: msg.sessionID }) } - messageStore.set(msg.id, msg) 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, "") } @@ -143,7 +143,9 @@ export const aoActivity: Plugin = async ({ directory, client }) => { const msg = messageStore.get(part.messageID) if (msg?.role === "user" && part.type === "text") { const sessionID = msg.sessionID ?? currentSessionID - if (sessionID) reportUserPrompt(sessionID, msg.id, part.text ?? "") + const prompt = part.text ?? "" + if (sessionID) reportUserPrompt(sessionID, msg.id, prompt) + if (prompt.length > 0) messageStore.delete(part.messageID) } break } @@ -151,6 +153,9 @@ export const aoActivity: Plugin = async ({ directory, client }) => { 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 diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go index 68ee7e1f3..8e7b1b5ea 100644 --- a/backend/internal/adapters/agent/opencode/hooks.go +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -174,6 +174,10 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode) error { _ = tmp.Close() return err } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } if err := tmp.Close(); err != nil { return err }