diff --git a/md/design/agent-details/opencode.md b/md/design/agent-details/opencode.md index 464f1a4e..70ccc4d8 100644 --- a/md/design/agent-details/opencode.md +++ b/md/design/agent-details/opencode.md @@ -10,7 +10,31 @@ OpenCode's extensibility centers on TypeScript/JavaScript plugins, not shell commands. Plugins are async functions that receive a context object and return a hooks object. A secondary experimental system supports shell-command hooks in `opencode.json`. -Symposium does not currently integrate with OpenCode's hook system. OpenCode is supported as a skills-only agent. +Symposium integrates with OpenCode's hook system via a TypeScript plugin (`opencode-plugin/`) that bridges OpenCode's native `tool.execute.before`/`tool.execute.after` hooks to Symposium's canonical hook pipeline. The plugin spawns `cargo-agents hook opencode ` with Symposium-format JSON on stdin and maps responses back to OpenCode's mutable output objects. + +On the Rust side, OpenCode speaks the Symposium canonical wire format directly — no agent-specific serialization is needed. The `opencode.rs` hook schema delegates to the symposium `InputEvent`/output types. + +### Plugin installation + +The TypeScript plugin source lives in `opencode-plugin/src/index.ts` and is embedded into the `cargo-agents` binary at compile time via `include_str!`. During `cargo agents init` (or any hook registration path), Symposium writes it to: + +| Scope | Path | +|-------|------| +| Project | `.opencode/plugins/symposium.ts` | +| Global | `~/.config/opencode/plugins/symposium.ts` | + +The file is prefixed with a `// @generated by symposium` header. On subsequent runs: +- If the file content matches the embedded version, it is left alone (idempotent). +- If the file has the `@generated` header but different content (binary was upgraded), it is overwritten. +- If the file exists without the `@generated` header (user-managed), it is skipped with a warning. + +Unregistration deletes the file only if it has the `@generated` header. + +### Limitations of the bridge + +- **Cannot block tool execution.** OpenCode's `tool.execute.before` hook can modify args but not deny. If a Symposium hook returns a deny/block, the plugin logs a warning and injects it as context in the post-tool output. +- **No `UserPromptSubmit` or `SessionStart` mapping.** OpenCode's closest equivalents (`message.updated` with `role === "user"`, `session.created`) are event-only observers that can't return data to the agent. +- **MCP tool calls bypass hooks.** OpenCode's `tool.execute.before`/`after` are not triggered for MCP tool invocations. ## Plugin Locations and Load Order diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 2014d1ad..272bfaae 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -10,7 +10,7 @@ Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[ag ### `agents.rs` — agent abstraction -Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode hooks are handled by a companion TypeScript plugin (`opencode-plugin/`) rather than config-file registration; Goose is a skills-only agent (no hook registration). ### `init.rs` — initialization command diff --git a/md/reference/agents/opencode.md b/md/reference/agents/opencode.md index c7cdc6ea..12cc1701 100644 --- a/md/reference/agents/opencode.md +++ b/md/reference/agents/opencode.md @@ -11,9 +11,23 @@ Config name: `opencode` ## Hooks -**OpenCode does not support shell-command hooks.** Its extensibility is based on TypeScript/JavaScript plugins. Symposium cannot register hooks for OpenCode. +Symposium hooks are supported via a TypeScript plugin that bridges OpenCode's native hook system to `cargo-agents hook opencode `. The plugin is embedded in the `cargo-agents` binary and installed automatically during `cargo agents init`. -OpenCode is supported as a skills-only agent — `cargo agents sync` will install skill files, but no hooks are registered. +| Scope | Path | +|-------|------| +| Project | `.opencode/plugins/symposium.ts` | +| Global | `~/.config/opencode/plugins/symposium.ts` | + +The plugin is regenerated on each `init` if the binary has been upgraded. User-managed files at the same path (without the `@generated` header) are left untouched. + +| OpenCode hook | Symposium event | Capabilities | +|---|---|---| +| `tool.execute.before` | `PreToolUse` | Modify tool args via `updatedInput`. Cannot block execution. | +| `tool.execute.after` | `PostToolUse` | Inject `additionalContext` into tool output. | + +OpenCode speaks the Symposium canonical wire format — no agent-specific serialization is needed. + +**Limitations:** OpenCode cannot block tool execution. If a Symposium hook returns a deny, the plugin injects a warning into the post-tool output. `UserPromptSubmit` and `SessionStart` have no OpenCode equivalents that can return data. ## MCP servers diff --git a/opencode-plugin/package.json b/opencode-plugin/package.json new file mode 100644 index 00000000..f1fa0f24 --- /dev/null +++ b/opencode-plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "@symposium/opencode-plugin", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Symposium hook bridge for OpenCode", + "exports": { + ".": "./src/index.ts" + }, + "files": ["src"], + "dependencies": { + "@opencode-ai/plugin": "^1.14.0" + } +} diff --git a/opencode-plugin/src/index.ts b/opencode-plugin/src/index.ts new file mode 100644 index 00000000..e3fa5fc0 --- /dev/null +++ b/opencode-plugin/src/index.ts @@ -0,0 +1,174 @@ +import type { Plugin } from "@opencode-ai/plugin"; + +// Symposium canonical input types (tagged enum). +type PreToolUseInput = { + PreToolUse: { + tool_name: string; + tool_input: unknown; + session_id: string | null; + cwd: string | null; + }; +}; + +type PostToolUseInput = { + PostToolUse: { + tool_name: string; + tool_input: unknown; + tool_response: unknown; + session_id: string | null; + cwd: string | null; + }; +}; + +// Symposium canonical output types. +type PreToolUseOutput = { + additionalContext?: string | null; + updatedInput?: unknown | null; +}; + +type PostToolUseOutput = { + additionalContext?: string | null; +}; + +type HookResult = + | { ok: true; output: Record } + | { ok: false; denied: true; reason: string } + | { ok: false; denied: false }; + +// Stashed information from pre-tool hooks that we surface in post-tool output. +type StashedInfo = { + deniedMessage?: string; + additionalContext?: string; +}; + +async function runHook( + binary: string, + event: string, + input: unknown, +): Promise { + const payload = JSON.stringify(input); + try { + const proc = Bun.spawn([binary, "hook", "opencode", event], { + stdin: new Blob([payload]), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode === 2) { + return { + ok: false, + denied: true, + reason: stderr.trim() || "hook denied execution", + }; + } + if (exitCode !== 0) { + console.error(`[symposium] hook exited ${exitCode}: ${stderr.trim()}`); + return { ok: false, denied: false }; + } + const trimmed = stdout.trim(); + if (!trimmed) return { ok: false, denied: false }; + return { ok: true, output: JSON.parse(trimmed) }; + } catch (e) { + console.error(`[symposium] failed to run hook:`, e); + return { ok: false, denied: false }; + } +} + +function findBinary(): string { + return process.env.SYMPOSIUM_BINARY ?? "cargo-agents"; +} + +export const server: Plugin = async (ctx) => { + const binary = findBinary(); + const cwd = ctx.directory; + const stash = new Map(); + + return { + "tool.execute.before": async (input, output) => { + const payload: PreToolUseInput = { + PreToolUse: { + tool_name: input.tool, + tool_input: output.args, + session_id: input.sessionID ?? null, + cwd, + }, + }; + + const result = await runHook(binary, "pre-tool-use", payload); + + if (!result.ok && result.denied) { + stash.set(input.callID, { + deniedMessage: result.reason, + }); + return; + } + + if (!result.ok) return; + + const hookOutput = result.output as PreToolUseOutput; + const info: StashedInfo = {}; + + if (hookOutput.updatedInput != null) { + output.args = hookOutput.updatedInput; + } + + if (hookOutput.additionalContext) { + info.additionalContext = hookOutput.additionalContext; + } + + if (info.additionalContext) { + stash.set(input.callID, info); + } + }, + + "tool.execute.after": async (input, output) => { + const payload: PostToolUseInput = { + PostToolUse: { + tool_name: input.tool, + tool_input: input.args, + tool_response: output.output, + session_id: input.sessionID ?? null, + cwd, + }, + }; + + const result = await runHook(binary, "post-tool-use", payload); + + // Collect all context to append. + const parts: string[] = []; + + // Drain stashed info from the pre-tool phase. + const info = stash.get(input.callID); + if (info) { + stash.delete(input.callID); + if (info.deniedMessage) { + parts.push( + `[symposium] Warning: a hook attempted to block this tool call but OpenCode does not support blocking. Reason: ${info.deniedMessage}`, + ); + } + if (info.additionalContext) { + parts.push(info.additionalContext); + } + } + + // Add post-tool context. + if (result.ok) { + const hookOutput = result.output as PostToolUseOutput; + if (hookOutput.additionalContext) { + parts.push(hookOutput.additionalContext); + } + } + + if (parts.length > 0) { + const suffix = "\n\n" + parts.join("\n\n"); + output.output = (output.output ?? "") + suffix; + } + }, + }; +}; diff --git a/opencode-plugin/tsconfig.json b/opencode-plugin/tsconfig.json new file mode 100644 index 00000000..c9ac2d7e --- /dev/null +++ b/opencode-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 8cfdeefc..daf13f28 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -148,8 +148,7 @@ impl Agent { Ok(()) } Agent::OpenCode => { - out.info("OpenCode uses JS/TS plugins for hooks; skipping hook registration (skills only)"); - Ok(()) + register_opencode_plugin(&project_root.join(".opencode").join("plugins"), out) } }?; @@ -178,10 +177,10 @@ impl Agent { ); Ok(()) } - Agent::OpenCode => { - out.info("OpenCode uses JS/TS plugins for hooks; skipping hook registration (skills only)"); - Ok(()) - } + Agent::OpenCode => register_opencode_plugin( + &home.join(".config").join("opencode").join("plugins"), + out, + ), }?; Ok(()) @@ -392,8 +391,10 @@ impl Agent { unregister_gemini_hooks(&project_root.join(".gemini").join("settings.json"), out) } Agent::Kiro => unregister_kiro_hooks(&project_root.join(".kiro").join("agents"), out), - Agent::Goose => {} // no hooks to unregister - Agent::OpenCode => {} // no hooks to unregister + Agent::Goose => {} // no hooks to unregister + Agent::OpenCode => { + unregister_opencode_plugin(&project_root.join(".opencode").join("plugins"), out) + } } } @@ -411,8 +412,11 @@ impl Agent { unregister_gemini_hooks(&home.join(".gemini").join("settings.json"), out) } Agent::Kiro => unregister_kiro_hooks(&home.join(".kiro").join("agents"), out), - Agent::Goose => {} // no hooks to unregister - Agent::OpenCode => {} // no hooks to unregister + Agent::Goose => {} // no hooks to unregister + Agent::OpenCode => unregister_opencode_plugin( + &home.join(".config").join("opencode").join("plugins"), + out, + ), } } @@ -931,6 +935,58 @@ fn unregister_kiro_hooks(agents_dir: &Path, out: &Output) { } } +// --------------------------------------------------------------------------- +// OpenCode plugin registration +// --------------------------------------------------------------------------- + +const OPENCODE_PLUGIN_SOURCE: &str = include_str!("../../opencode-plugin/src/index.ts"); +const OPENCODE_PLUGIN_HEADER: &str = "// @generated by symposium — do not edit\n"; + +fn opencode_plugin_content() -> String { + format!("{OPENCODE_PLUGIN_HEADER}{OPENCODE_PLUGIN_SOURCE}") +} + +fn register_opencode_plugin(plugins_dir: &Path, out: &Output) -> Result<()> { + fs::create_dir_all(plugins_dir)?; + let plugin_file = plugins_dir.join("symposium.ts"); + let display = display_path(&plugin_file); + let expected = opencode_plugin_content(); + + if plugin_file.exists() { + let existing = fs::read_to_string(&plugin_file)?; + if existing == expected { + out.already_ok(format!("{display}: plugin already up to date")); + return Ok(()); + } + if !existing.starts_with(OPENCODE_PLUGIN_HEADER) { + out.warn(format!( + "{display}: file exists but was not generated by symposium; skipping" + )); + return Ok(()); + } + } + + fs::write(&plugin_file, &expected)?; + out.done(format!("{display}: installed hook bridge plugin")); + Ok(()) +} + +fn unregister_opencode_plugin(plugins_dir: &Path, out: &Output) { + let plugin_file = plugins_dir.join("symposium.ts"); + if !plugin_file.exists() { + return; + } + let display = display_path(&plugin_file); + if let Ok(content) = fs::read_to_string(&plugin_file) { + if !content.starts_with(OPENCODE_PLUGIN_HEADER) { + return; + } + } + if fs::remove_file(&plugin_file).is_ok() { + out.removed(format!("{display}: removed hook bridge plugin")); + } +} + // --------------------------------------------------------------------------- // Hook unregistration // --------------------------------------------------------------------------- @@ -1285,6 +1341,99 @@ mod tests { ); } + #[test] + fn register_opencode_plugin_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + assert!(plugin_file.exists()); + let content = fs::read_to_string(&plugin_file).unwrap(); + assert!(content.starts_with(OPENCODE_PLUGIN_HEADER)); + assert!(content.contains("tool.execute.before")); + } + + #[test] + fn register_opencode_plugin_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + let content = fs::read_to_string(&plugin_file).unwrap(); + assert!(content.starts_with(OPENCODE_PLUGIN_HEADER)); + } + + #[test] + fn register_opencode_plugin_upgrades_stale() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + fs::write( + &plugin_file, + format!("{OPENCODE_PLUGIN_HEADER}// old version"), + ) + .unwrap(); + + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + + let content = fs::read_to_string(&plugin_file).unwrap(); + assert!( + content.contains("tool.execute.before"), + "should have been overwritten with current content" + ); + } + + #[test] + fn register_opencode_plugin_skips_user_managed() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + let user_content = "// user's custom plugin\nexport const server = async () => ({});\n"; + fs::write(&plugin_file, user_content).unwrap(); + + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + + let content = fs::read_to_string(&plugin_file).unwrap(); + assert_eq!( + content, user_content, + "should not overwrite user-managed file" + ); + } + + #[test] + fn unregister_opencode_plugin_removes_file() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + register_opencode_plugin(&plugins_dir, &Output::quiet()).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + assert!(plugin_file.exists()); + + unregister_opencode_plugin(&plugins_dir, &Output::quiet()); + assert!(!plugin_file.exists()); + } + + #[test] + fn unregister_opencode_plugin_preserves_user_managed() { + let tmp = tempfile::tempdir().unwrap(); + let plugins_dir = tmp.path().join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + + let plugin_file = plugins_dir.join("symposium.ts"); + let user_content = "// user's custom plugin\n"; + fs::write(&plugin_file, user_content).unwrap(); + + unregister_opencode_plugin(&plugins_dir, &Output::quiet()); + assert!(plugin_file.exists(), "should not remove user-managed file"); + } + #[test] fn agent_from_config_name_goose() { assert_eq!(Agent::from_config_name("goose").unwrap(), Agent::Goose); diff --git a/src/hook_schema/opencode.rs b/src/hook_schema/opencode.rs index 346a573d..bc856289 100644 --- a/src/hook_schema/opencode.rs +++ b/src/hook_schema/opencode.rs @@ -1,8 +1,46 @@ -use crate::hook_schema::{Agent, ErasedAgentHookEvent, HookEvent}; +use crate::hook_schema::{ + Agent, AgentHookEvent, ErasedAgentHookEvent, HookEvent, erase_agent_hook_event, symposium, +}; pub struct OpenCode; impl Agent for OpenCode { - fn event(&self, _event: HookEvent) -> Option> { - None // OpenCode uses JS/TS plugins, not shell hooks + fn event(&self, event: HookEvent) -> Option> { + Some(match event { + HookEvent::PreToolUse => erase_agent_hook_event(OpenCodePreToolUseEvent), + HookEvent::PostToolUse => erase_agent_hook_event(OpenCodePostToolUseEvent), + HookEvent::UserPromptSubmit => erase_agent_hook_event(OpenCodeUserPromptSubmitEvent), + HookEvent::SessionStart => erase_agent_hook_event(OpenCodeSessionStartEvent), + }) } } + +macro_rules! opencode_event { + ($event:ident, $input:ty, $output:ty) => { + struct $event; + impl AgentHookEvent for $event { + type Input = $input; + type Output = $output; + } + }; +} + +opencode_event!( + OpenCodePreToolUseEvent, + symposium::InputEvent, + symposium::PreToolUseOutput +); +opencode_event!( + OpenCodePostToolUseEvent, + symposium::InputEvent, + symposium::PostToolUseOutput +); +opencode_event!( + OpenCodeUserPromptSubmitEvent, + symposium::InputEvent, + symposium::UserPromptSubmitOutput +); +opencode_event!( + OpenCodeSessionStartEvent, + symposium::InputEvent, + symposium::SessionStartOutput +); diff --git a/src/hook_schema/symposium.rs b/src/hook_schema/symposium.rs index 1c8ec504..93f55437 100644 --- a/src/hook_schema/symposium.rs +++ b/src/hook_schema/symposium.rs @@ -203,8 +203,9 @@ impl OutputEvent { } } -// ── AgentHookInput for InputEvent ──────────────────────────────────── -// Allows symposium-format plugins to receive canonical InputEvent JSON. +// ── AgentHookInput / AgentHookOutput for canonical types ───────────── +// Allows agents that speak symposium format natively (e.g. OpenCode) +// to reuse these types directly as their wire format. impl super::AgentHookInput for InputEvent { fn parse_input(payload: &str) -> anyhow::Result { @@ -224,6 +225,39 @@ impl super::AgentHookInput for InputEvent { } } +macro_rules! symposium_output_impl { + ($ty:ident, $variant:ident) => { + impl super::AgentHookOutput for $ty { + fn parse_output(output: &[u8]) -> anyhow::Result { + if output.is_empty() { + return Ok(Self::default()); + } + Ok(serde_json::from_slice(output)?) + } + fn from_symposium(event: &OutputEvent) -> Self { + match event { + OutputEvent::$variant(o) => o.clone(), + _ => Self::default(), + } + } + fn to_symposium(&self) -> OutputEvent { + OutputEvent::$variant(self.clone()) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } + fn into_any(self: Box) -> Box { + self + } + } + }; +} + +symposium_output_impl!(PreToolUseOutput, PreToolUse); +symposium_output_impl!(PostToolUseOutput, PostToolUse); +symposium_output_impl!(UserPromptSubmitOutput, UserPromptSubmit); +symposium_output_impl!(SessionStartOutput, SessionStart); + #[cfg(test)] mod tests { use super::*; diff --git a/tests/plugin_dispatch.rs b/tests/plugin_dispatch.rs index 4c588fee..6628e5c5 100644 --- a/tests/plugin_dispatch.rs +++ b/tests/plugin_dispatch.rs @@ -252,3 +252,99 @@ async fn matcher_filters_out_non_matching_hooks() { .await .unwrap(); } + +/// OpenCode as host agent with a symposium-format hook: the inline shell hook +/// produces `additionalContext` that flows through the symposium → OpenCode path. +#[tokio::test(flavor = "multi_thread")] +async fn opencode_host_receives_symposium_hook_context() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-hooks0"], + async |mut ctx| { + let result = ctx + .prompt_or_hook( + "ignored", + &[HookStep::PreToolUse { + tool_name: "Bash".to_string(), + tool_input: json!({"command": "ls"}), + }], + HookAgent::OpenCode, + ) + .await?; + + assert!( + result.has_context_containing("inline-shell-output"), + "expected `inline-shell-output` in OpenCode hook output, got: {:#?}", + result.outputs_for(HookEvent::PreToolUse), + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// OpenCode as host agent: matcher filtering still works — a tool name no hook +/// matches produces no additionalContext. +#[tokio::test(flavor = "multi_thread")] +async fn opencode_host_matcher_filters() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-hooks0"], + async |mut ctx| { + let result = ctx + .prompt_or_hook( + "ignored", + &[HookStep::PreToolUse { + tool_name: "Grep".to_string(), + tool_input: json!({"pattern": "foo"}), + }], + HookAgent::OpenCode, + ) + .await?; + + assert!( + !result.has_context_containing("output"), + "no hook should fire for `Grep` via OpenCode, got: {:#?}", + result.outputs_for(HookEvent::PreToolUse), + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// OpenCode receives PostToolUse hook output. +#[tokio::test(flavor = "multi_thread")] +async fn opencode_host_post_tool_use() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-hooks0"], + async |mut ctx| { + let result = ctx + .prompt_or_hook( + "ignored", + &[HookStep::PostToolUse { + tool_name: "Bash".to_string(), + tool_input: json!({"command": "ls"}), + tool_response: json!("file1.rs\nfile2.rs"), + }], + HookAgent::OpenCode, + ) + .await?; + + // PostToolUse hooks are not configured for Bash in this fixture, + // so there should be no context. This verifies the pipeline doesn't + // error out for OpenCode PostToolUse events. + assert!( + !result.has_context_containing("output"), + "no PostToolUse hooks configured for Bash, got: {:#?}", + result.outputs_for(HookEvent::PostToolUse), + ); + Ok(()) + }, + ) + .await + .unwrap(); +}