Skip to content
Draft
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
26 changes: 25 additions & 1 deletion md/design/agent-details/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <event>` 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

Expand Down
2 changes: 1 addition & 1 deletion md/design/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions md/reference/agents/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <event>`. 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

Expand Down
14 changes: 14 additions & 0 deletions opencode-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
174 changes: 174 additions & 0 deletions opencode-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
| { 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<HookResult> {
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<string, StashedInfo>();

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;
}
},
};
};
14 changes: 14 additions & 0 deletions opencode-plugin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading