-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent): opencode adapter + activity plugin hooks #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
dd662b0
feat(agent): add opencode adapter + activity plugin hooks
yyovil 773d476
fix(agent): address Greptile review on opencode adapter (#80)
yyovil dbd976f
fix(agent): surface opencode hook failures instead of swallowing them
yyovil 4a206f7
fix(agent): address opencode adapter review — install guard, prompt d…
yyovil 57af22f
fix: harden opencode activity hooks
harshitsinghbhandari File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
174 changes: 174 additions & 0 deletions
174
backend/internal/adapters/agent/opencode/assets/ao-activity.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`] | ||
| } | ||
|
|
||
| // 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)) | ||
|
harshitsinghbhandari marked this conversation as resolved.
|
||
| } | ||
| }, | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
harshitsinghbhandari marked this conversation as resolved.
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.