Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions backend/internal/adapters/agent/opencode/assets/ao-activity.ts
Original file line number Diff line number Diff line change
@@ -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}`]
}
Comment thread
harshitsinghbhandari marked this conversation as resolved.

// 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))
Comment thread
harshitsinghbhandari marked this conversation as resolved.
}
},
}
}
185 changes: 185 additions & 0 deletions backend/internal/adapters/agent/opencode/hooks.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
harshitsinghbhandari marked this conversation as resolved.
}
Loading
Loading