Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ overwrite user-modified agents.
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` |
| Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` |
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents |
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection |
| Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` |
| Aider | Detected | — | `CONVENTIONS.md` via `.aider.conf.yml` |
| KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions |
Expand Down
114 changes: 81 additions & 33 deletions src/cli/client_adapter.c
Original file line number Diff line number Diff line change
Expand Up @@ -188,46 +188,94 @@ char *cbm_client_adapter_opencode(const char *binary_path) {

adapter_sb_t sb = {0};
emit_header(&sb, "OpenCode");
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n"
"// automatic graph lookup before a grep/glob, which other clients get\n"
"// through their own hook configuration.\n");
sb_append(&sb,
"// OpenCode already reaches every tool over MCP; this module adds the\n"
"// context surfaces other clients get through hook configuration: graph\n"
"// lookup after grep/glob, index-coverage notes after read, session-start\n"
"// tier routing (carried on the first tool result of each session, since\n"
"// OpenCode documents no context-output lifecycle hook), and reinjection\n"
"// after compaction via the documented experimental surface.\n");
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
sb_append(&sb, "';\n\n");

/* hook-augment requires hook_event_name and accepts Grep/Glob only under
* PreToolUse; omitting it makes the whole hook a silent no-op. */
sb_append(&sb, "function augment(tool, args) {\n"
" return new Promise((resolve) => {\n"
" const child = spawn(BIN, ['hook-augment'], {\n"
" stdio: ['pipe', 'pipe', 'ignore'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" let out = '';\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
" child.on('error', () => resolve(''));\n"
" child.on('close', () => resolve(out));\n"
" child.stdin.end(JSON.stringify({\n"
" hook_event_name: 'PreToolUse',\n"
" tool_name: tool,\n"
" tool_input: args ?? {},\n"
" }));\n"
" });\n"
"}\n\n");
/* hook-augment requires hook_event_name; its default dialect accepts
* Grep/Glob under PreToolUse, Read under PostToolUse, and the
* SessionStart lifecycle event — and emits the Claude JSON envelope, so
* the plugin unwraps additionalContext instead of pasting raw JSON into
* the tool output. Every failure path resolves to '' (fail open). */
sb_append(&sb,
"function augment(payload) {\n"
" return new Promise((resolve) => {\n"
" const child = spawn(BIN, ['hook-augment'], {\n"
" stdio: ['pipe', 'pipe', 'ignore'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" let out = '';\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
" child.on('error', () => resolve(''));\n"
" child.on('close', () => {\n"
" try {\n"
" const ctx = JSON.parse(out)?.hookSpecificOutput?.additionalContext;\n"
" resolve(typeof ctx === 'string' ? ctx : '');\n"
" } catch { resolve(''); }\n"
" });\n"
" child.stdin.end(JSON.stringify(payload));\n"
" });\n"
"}\n\n");

sb_append(&sb,
"export const CodebaseMemory = async () => ({\n"
" 'tool.execute.after': async (input, output) => {\n"
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
": null;\n"
" if (!tool) return;\n"
" const extra = await augment(tool, output?.args);\n"
" if (extra && typeof output?.output === 'string') {\n"
" output.output += '\\n' + extra;\n"
" }\n"
" },\n"
"});\n");
"export const CodebaseMemory = async (ctx) => {\n"
" const dir = ctx?.directory;\n"
" const seen = new Set();\n"
" const lifecycle = () =>\n"
" augment({ hook_event_name: 'SessionStart', cwd: dir });\n"
" return {\n"
" 'tool.execute.after': async (input, output) => {\n"
" if (typeof output?.output !== 'string') return;\n"
" const pieces = [];\n"
" const sid = input?.sessionID;\n"
" if (typeof sid === 'string' && !seen.has(sid)) {\n"
" seen.add(sid);\n"
" pieces.push(await lifecycle());\n"
" }\n"
" const args = output?.args ?? {};\n"
" const search =\n"
" input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n"
" if (search) {\n"
" pieces.push(await augment({\n"
" hook_event_name: 'PreToolUse',\n"
" tool_name: search,\n"
" tool_input: args,\n"
" cwd: dir,\n"
" }));\n"
" } else if (input?.tool === 'read') {\n"
" const filePath = args.filePath ?? args.file_path ?? args.path;\n"
" if (typeof filePath === 'string' && filePath) {\n"
" pieces.push(await augment({\n"
" hook_event_name: 'PostToolUse',\n"
" tool_name: 'Read',\n"
" tool_input: { file_path: filePath },\n"
" cwd: dir,\n"
" }));\n"
" }\n"
" }\n"
" const extra = pieces.filter(Boolean).join('\\n');\n"
" if (extra) {\n"
" output.output += '\\n' + extra;\n"
" }\n"
" },\n"
" // Documented (experimental) compaction surface: output.context is the\n"
" // mutable array of context strings for the rebuilt session.\n"
" 'experimental.session.compacting': async (_input, output) => {\n"
" const note = await lifecycle();\n"
" if (note && Array.isArray(output?.context)) {\n"
" output.context.push(note);\n"
" }\n"
" },\n"
" };\n"
"};\n");

if (sb.failed) {
free(sb.buf);
Expand Down
23 changes: 23 additions & 0 deletions tests/test_agent_clients.c
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,28 @@ TEST(client_adapter_opencode_sends_the_required_hook_event) {
PASS();
}

/* The richer OpenCode adapter carries every context surface the plugin API
* documents: session-start tier routing on the first tool result of each
* session, post-read coverage notes, and post-compaction reinjection through
* the documented experimental surface. It must unwrap hook-augment's Claude
* JSON envelope so plain text — not raw JSON — reaches the model. */
TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'SessionStart'"));
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PostToolUse'"));
ASSERT_NOT_NULL(strstr(js, "tool_name: 'Read'"));
ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'"));
ASSERT_NOT_NULL(strstr(js, "additionalContext"));
/* file_path is the key hook-augment's default dialect reads; OpenCode's
* read tool argues filePath, so the adapter must map it. */
ASSERT_NOT_NULL(strstr(js, "file_path: filePath"));
/* Session context may only be injected once per session id. */
ASSERT_NOT_NULL(strstr(js, "seen.has(sid)"));
free(js);
PASS();
}

/* Empty/NULL inputs must not produce a module at all. */
TEST(client_adapter_rejects_missing_binary_path) {
ASSERT_NULL(cbm_client_adapter_pi(NULL));
Expand Down Expand Up @@ -1187,5 +1209,6 @@ SUITE(agent_clients) {
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
RUN_TEST(client_adapter_rejects_missing_binary_path);
}
Loading